Skip to main content

fd_policy/
ap2.rs

1//! AP2 (Agent Payments Protocol) signed-Mandate pre-call spend gate.
2//!
3//! This is the **second payment rail** on the *same* pre-call spend gate the
4//! [`x402`](crate::x402) module added on 2026-07-24. Where x402 gates a payment
5//! quoted inline as an HTTP `402 Payment Required` challenge, [Google AP2][ap2]
6//! gates a payment authorized ahead of time by a **signed Mandate chain**:
7//!
8//! - an **Intent Mandate** — the user (or their key) pre-authorizes an agent to
9//!   spend, *within a scope*: which merchants/categories, up to a max amount
10//!   ([`Ap2IntentMandate`]); and
11//! - a **Cart Mandate** — the concrete cart the agent assembled, **bound to that
12//!   intent** by `intent_id` and signed ([`Ap2CartMandate`]).
13//!
14//! Before an autonomous payment is authorized, [`evaluate_ap2_payment`]:
15//!
16//! 1. **verifies the signature chain** — the Intent and the Cart are each signed
17//!    by a key in the trusted [`Ap2Keyring`], and the Cart is cryptographically
18//!    bound to the Intent it claims (real Ed25519, RFC 8032);
19//! 2. checks the **cart total against the per-task ceiling the policy already
20//!    enforces for x402** — the very same [`Budget::has_cost_headroom`]
21//!    (crate::budget::Budget::has_cost_headroom); and
22//! 3. checks the cart stays **within the Intent's authorized scope** (merchant,
23//!    category, the user's own max amount).
24//!
25//! It is **deny-by-default**: a missing or invalid signature, an unknown signing
26//! key, a Cart not bound to its Intent, a cart total over the budget ceiling, an
27//! amount over the user's intent max, or a merchant/category outside the intent
28//! scope all return an [`Ap2GateOutcome::Deny`] and authorize nothing.
29//!
30//! Like the x402 gate, **this module never moves money**. It verifies mandates
31//! and returns an authorize/deny *decision*; executing the payment (card rail,
32//! bank transfer, stablecoin settlement) lives entirely outside FerrumDeck. An
33//! authorized payment normalizes to an [`Ap2CostEvent`] that folds into the
34//! **same `cost_cents` ledger** as x402 and token cost, so a run's cost slope
35//! covers both payment rails.
36//!
37//! [ap2]: https://github.com/google-agentic-commerce/AP2
38
39use std::collections::BTreeMap;
40
41use ed25519_dalek::{Signature, Verifier, VerifyingKey};
42use serde::{Deserialize, Serialize};
43
44use crate::budget::{Budget, BudgetUsage};
45use crate::decision::PolicyDecision;
46
47/// Stable anchor recorded alongside an AP2 decision so audit consumers can cite
48/// the protocol without re-reading docstrings.
49pub const AP2_ANCHOR: &str = "ap2:signed-mandate-chain";
50
51/// A money amount on the AP2 rail, normalized to the common budget unit (cents).
52///
53/// AP2 settles in fiat (and stablecoins); FerrumDeck's budget ceiling is in
54/// cents, so only USD-denominated totals can be checked offline. A non-USD
55/// currency is **unpriceable** here and denied by default — the same posture the
56/// x402 gate takes for an asset with no known USD peg.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Ap2Money {
59    /// Amount in the currency's minor unit (USD cents).
60    pub amount_cents: u64,
61    /// ISO-4217-style currency code, e.g. `"USD"`.
62    pub currency: String,
63}
64
65impl Ap2Money {
66    /// Cents if this amount can be priced against a cents budget (USD), else
67    /// `None` (deny-by-default upstream).
68    pub fn to_cents(&self) -> Option<u64> {
69        if self.currency.trim().eq_ignore_ascii_case("USD") {
70            Some(self.amount_cents)
71        } else {
72            None
73        }
74    }
75}
76
77/// The scope an [`Ap2IntentMandate`] authorizes — what the user actually
78/// consented to. An empty `merchants`/`categories` list means "unconstrained on
79/// that axis"; `max_amount` always binds.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct Ap2Scope {
82    /// Allowed merchants; empty ⇒ any merchant.
83    #[serde(default)]
84    pub merchants: Vec<String>,
85    /// Allowed categories; empty ⇒ any category.
86    #[serde(default)]
87    pub categories: Vec<String>,
88    /// The maximum the user authorized (their own ceiling, distinct from the
89    /// per-task budget ceiling the policy enforces).
90    pub max_amount: Ap2Money,
91}
92
93/// A parsed AP2 **Intent Mandate**: the user's pre-authorization for an agent to
94/// spend within [`Ap2Scope`], signed by a key in the [`Ap2Keyring`].
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct Ap2IntentMandate {
97    /// Unique id the Cart binds to.
98    pub intent_id: String,
99    /// The principal who authorized (audit/display).
100    pub subject: String,
101    /// What the user consented to.
102    pub scope: Ap2Scope,
103    /// Id of the verifying key in the keyring that signed this intent.
104    pub key_id: String,
105    /// Hex-encoded Ed25519 signature over [`Ap2IntentMandate::signing_bytes`].
106    #[serde(default)]
107    pub signature: String,
108}
109
110impl Ap2IntentMandate {
111    /// The canonical bytes the signature covers. Fixed field order + a version
112    /// tag so the signed payload is stable and unambiguous (not dependent on
113    /// JSON key ordering).
114    pub fn signing_bytes(&self) -> Vec<u8> {
115        let mut merchants = self.scope.merchants.clone();
116        merchants.sort();
117        let mut categories = self.scope.categories.clone();
118        categories.sort();
119        format!(
120            "ap2-intent-v1|{}|{}|{}|{}|{}|{}",
121            self.intent_id,
122            self.subject,
123            merchants.join(","),
124            categories.join(","),
125            self.scope.max_amount.amount_cents,
126            self.scope.max_amount.currency.to_ascii_uppercase(),
127        )
128        .into_bytes()
129    }
130}
131
132/// A parsed AP2 **Cart Mandate**: the concrete cart the agent assembled, bound to
133/// an Intent by `intent_id` and signed.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct Ap2CartMandate {
136    /// Unique cart id (audit/display).
137    pub cart_id: String,
138    /// The Intent this cart is authorized under — the binding link.
139    pub intent_id: String,
140    /// The merchant this cart pays.
141    pub merchant: String,
142    /// Optional category (checked against the intent scope when the scope
143    /// constrains categories).
144    #[serde(default)]
145    pub category: Option<String>,
146    /// The cart total.
147    pub total: Ap2Money,
148    /// Id of the verifying key in the keyring that signed this cart.
149    pub key_id: String,
150    /// Hex-encoded Ed25519 signature over [`Ap2CartMandate::signing_bytes`].
151    #[serde(default)]
152    pub signature: String,
153}
154
155impl Ap2CartMandate {
156    /// The canonical bytes the signature covers — includes `intent_id`, so a
157    /// valid cart signature cryptographically binds the cart to one specific
158    /// intent (the chain link).
159    pub fn signing_bytes(&self) -> Vec<u8> {
160        format!(
161            "ap2-cart-v1|{}|{}|{}|{}|{}|{}",
162            self.cart_id,
163            self.intent_id,
164            self.merchant,
165            self.category.as_deref().unwrap_or(""),
166            self.total.amount_cents,
167            self.total.currency.to_ascii_uppercase(),
168        )
169        .into_bytes()
170    }
171}
172
173/// A registry of trusted Ed25519 verifying keys, keyed by `key_id`. A mandate
174/// signed by a `key_id` absent here is denied ([`Ap2DenyKind::UnknownKey`]).
175#[derive(Debug, Clone, Default)]
176pub struct Ap2Keyring {
177    keys: BTreeMap<String, VerifyingKey>,
178}
179
180impl Ap2Keyring {
181    /// An empty keyring — every mandate is denied for an unknown key until one is
182    /// registered (deny-by-default).
183    pub fn new() -> Self {
184        Self::default()
185    }
186
187    /// Register a verifying key from its 32 raw bytes.
188    pub fn insert_key(&mut self, key_id: impl Into<String>, key: VerifyingKey) {
189        self.keys.insert(key_id.into(), key);
190    }
191
192    /// Register a verifying key from a 64-hex-char (32-byte) string. Returns an
193    /// error string on malformed hex / wrong length / invalid point.
194    pub fn insert_hex(&mut self, key_id: impl Into<String>, hex_key: &str) -> Result<(), String> {
195        let bytes = hex::decode(hex_key.trim()).map_err(|e| format!("bad hex key: {e}"))?;
196        let arr: [u8; 32] = bytes
197            .as_slice()
198            .try_into()
199            .map_err(|_| format!("verifying key must be 32 bytes, got {}", bytes.len()))?;
200        let vk = VerifyingKey::from_bytes(&arr).map_err(|e| format!("invalid ed25519 key: {e}"))?;
201        self.insert_key(key_id, vk);
202        Ok(())
203    }
204
205    fn get(&self, key_id: &str) -> Option<&VerifyingKey> {
206        self.keys.get(key_id)
207    }
208
209    /// Number of registered keys.
210    pub fn len(&self) -> usize {
211        self.keys.len()
212    }
213
214    /// Whether the keyring has no keys (so every mandate is denied).
215    pub fn is_empty(&self) -> bool {
216        self.keys.is_empty()
217    }
218}
219
220/// Why an AP2 payment was denied. Every variant is a **hard stop** — the payment
221/// is not authorized.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum Ap2DenyKind {
225    /// The Cart's `intent_id` does not match the Intent presented.
226    IntentCartMismatch,
227    /// A mandate carried an empty signature.
228    MissingSignature,
229    /// A mandate was signed by a `key_id` not in the trusted keyring.
230    UnknownKey,
231    /// A signature failed Ed25519 verification (tampered / wrong key).
232    InvalidSignature,
233    /// The cart total is in a currency that can't be priced against the cents
234    /// budget (non-USD).
235    Unpriceable,
236    /// The cart's merchant/category/amount falls outside the Intent's scope.
237    IntentScopeMismatch,
238    /// The cart total would breach the per-task budget ceiling.
239    CartOverCeiling,
240}
241
242impl Ap2DenyKind {
243    /// Stable snake_case wire label.
244    pub fn as_str(self) -> &'static str {
245        match self {
246            Ap2DenyKind::IntentCartMismatch => "intent_cart_mismatch",
247            Ap2DenyKind::MissingSignature => "missing_signature",
248            Ap2DenyKind::UnknownKey => "unknown_key",
249            Ap2DenyKind::InvalidSignature => "invalid_signature",
250            Ap2DenyKind::Unpriceable => "unpriceable",
251            Ap2DenyKind::IntentScopeMismatch => "intent_scope_mismatch",
252            Ap2DenyKind::CartOverCeiling => "cart_over_ceiling",
253        }
254    }
255}
256
257/// A verified, authorized AP2 payment normalized to cents — a first-class cost
258/// event that folds into the **same `cost_cents` ledger** as x402 and token
259/// cost.
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261pub struct Ap2CostEvent {
262    /// The authorized cart id.
263    pub cart_id: String,
264    /// The intent it was authorized under.
265    pub intent_id: String,
266    /// The merchant paid.
267    pub merchant: String,
268    /// The authorized amount, normalized to cents.
269    pub cost_cents: u64,
270    /// The settlement currency (`"USD"`).
271    pub currency: String,
272}
273
274impl Ap2CostEvent {
275    /// Fold this authorized payment into a run's [`BudgetUsage`], **alongside
276    /// token + x402 cost**, by adding `cost_cents` to the shared ledger.
277    /// Saturating — a run can't wrap its cost ledger. Does not touch
278    /// `tool_calls` (the payment rides a tool call the caller already counts).
279    pub fn apply_to(&self, usage: &mut BudgetUsage) {
280        usage.cost_cents = usage.cost_cents.saturating_add(self.cost_cents);
281    }
282}
283
284/// The pre-call spend-gate decision for an AP2 payment, produced by
285/// [`evaluate_ap2_payment`] *before* any payment is authorized.
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub enum Ap2GateOutcome {
288    /// The signed mandate chain verified, the cart is in scope, and it fits the
289    /// budget ceiling — the caller may authorize the payment.
290    /// `budget_remaining_cents` is the headroom that would remain after it
291    /// settles (`None` when no cost cap is set).
292    Authorize {
293        event: Ap2CostEvent,
294        budget_remaining_cents: Option<u64>,
295    },
296    /// The payment is refused — hard stop.
297    Deny { kind: Ap2DenyKind, reason: String },
298}
299
300impl Ap2GateOutcome {
301    /// Whether the payment may proceed.
302    pub fn is_authorized(&self) -> bool {
303        matches!(self, Ap2GateOutcome::Authorize { .. })
304    }
305
306    /// The deny kind, or `None` when authorized.
307    pub fn deny_kind(&self) -> Option<Ap2DenyKind> {
308        match self {
309            Ap2GateOutcome::Deny { kind, .. } => Some(*kind),
310            Ap2GateOutcome::Authorize { .. } => None,
311        }
312    }
313
314    /// The single operator alert to emit on a hard stop, or `None` when
315    /// authorized. Exactly one alert per denied mandate — one pre-call check.
316    pub fn alert_line(&self) -> Option<String> {
317        match self {
318            Ap2GateOutcome::Authorize { .. } => None,
319            Ap2GateOutcome::Deny { kind, reason } => Some(format!(
320                "AP2 spend gate BLOCKED payment [{}]: {reason} — payment not authorized",
321                kind.as_str()
322            )),
323        }
324    }
325
326    /// Map onto the crate's [`PolicyDecision`] so an AP2 verdict flows through
327    /// the same allow/deny pipeline as x402 and every other gate.
328    pub fn to_policy_decision(&self) -> PolicyDecision {
329        match self {
330            Ap2GateOutcome::Authorize { event, .. } => PolicyDecision::allow(format!(
331                "ap2 payment authorized: {}¢ to {} (intent {}) within budget + scope",
332                event.cost_cents, event.merchant, event.intent_id
333            )),
334            Ap2GateOutcome::Deny { reason, .. } => PolicyDecision::deny(reason.clone()),
335        }
336    }
337}
338
339/// Verify a hex Ed25519 signature over `msg` under `vk`. Maps each failure onto
340/// the precise [`Ap2DenyKind`].
341fn verify_hex_sig(vk: &VerifyingKey, msg: &[u8], sig_hex: &str) -> Result<(), Ap2DenyKind> {
342    let sig_hex = sig_hex.trim();
343    if sig_hex.is_empty() {
344        return Err(Ap2DenyKind::MissingSignature);
345    }
346    let bytes = hex::decode(sig_hex).map_err(|_| Ap2DenyKind::InvalidSignature)?;
347    let arr: [u8; 64] = bytes
348        .as_slice()
349        .try_into()
350        .map_err(|_| Ap2DenyKind::InvalidSignature)?;
351    let sig = Signature::from_bytes(&arr);
352    vk.verify(msg, &sig)
353        .map_err(|_| Ap2DenyKind::InvalidSignature)
354}
355
356/// Verify one mandate's signature under the keyring, resolving the key first.
357fn verify_mandate(
358    keyring: &Ap2Keyring,
359    key_id: &str,
360    msg: &[u8],
361    sig_hex: &str,
362) -> Result<(), Ap2DenyKind> {
363    let vk = keyring.get(key_id).ok_or(Ap2DenyKind::UnknownKey)?;
364    verify_hex_sig(vk, msg, sig_hex)
365}
366
367/// The AP2 pre-call spend gate: verify the signed mandate chain, the intent
368/// scope, and the per-task budget ceiling **before** authorizing an autonomous
369/// payment.
370///
371/// Reuses [`Budget::has_cost_headroom`] — the *same* per-task cost ceiling the
372/// x402 gate enforces — so both payment rails answer to one budget. Pure and
373/// deterministic (Ed25519 verification is I/O-free): same mandates + keyring +
374/// budget ⇒ same decision.
375///
376/// Deny-by-default order (first failure wins, most specific reason surfaced):
377/// intent/cart binding → currency priceable → intent signature → cart signature
378/// → intent scope → budget ceiling.
379pub fn evaluate_ap2_payment(
380    intent: &Ap2IntentMandate,
381    cart: &Ap2CartMandate,
382    keyring: &Ap2Keyring,
383    budget: &Budget,
384    usage: &BudgetUsage,
385) -> Ap2GateOutcome {
386    // 1. Binding: the cart must claim the intent presented.
387    if cart.intent_id != intent.intent_id {
388        return deny(
389            Ap2DenyKind::IntentCartMismatch,
390            format!(
391                "cart '{}' is bound to intent '{}', not the presented intent '{}'",
392                cart.cart_id, cart.intent_id, intent.intent_id
393            ),
394        );
395    }
396
397    // 2. Priceable: an amount we can't put in cents can't be checked at all.
398    let Some(amount_cents) = cart.total.to_cents() else {
399        return deny(
400            Ap2DenyKind::Unpriceable,
401            format!(
402                "cart total in '{}' cannot be priced against a cents budget (deny-by-default)",
403                cart.total.currency
404            ),
405        );
406    };
407
408    // 3-4. Signature chain: intent then cart, each under the trusted keyring.
409    if let Err(kind) = verify_mandate(
410        keyring,
411        &intent.key_id,
412        &intent.signing_bytes(),
413        &intent.signature,
414    ) {
415        return deny(
416            kind,
417            format!(
418                "intent '{}' signature ({}) failed verification under key '{}'",
419                intent.intent_id,
420                kind.as_str(),
421                intent.key_id
422            ),
423        );
424    }
425    if let Err(kind) = verify_mandate(
426        keyring,
427        &cart.key_id,
428        &cart.signing_bytes(),
429        &cart.signature,
430    ) {
431        return deny(
432            kind,
433            format!(
434                "cart '{}' signature ({}) failed verification under key '{}'",
435                cart.cart_id,
436                kind.as_str(),
437                cart.key_id
438            ),
439        );
440    }
441
442    // 5. Scope: the verified cart must stay within what the user authorized.
443    if let Some(reason) = scope_violation(intent, cart, amount_cents) {
444        return deny(Ap2DenyKind::IntentScopeMismatch, reason);
445    }
446
447    // 6. Ceiling: the SAME per-task budget gate x402 uses.
448    if !budget.has_cost_headroom(usage, amount_cents) {
449        let cap = budget.max_cost_cents.unwrap_or(0);
450        let projected = usage.cost_cents.saturating_add(amount_cents);
451        return deny(
452            Ap2DenyKind::CartOverCeiling,
453            format!(
454                "ap2 spend gate: paying {amount_cents}¢ to {} would push run cost to {projected}¢ over the {cap}¢ budget (by {}¢)",
455                cart.merchant,
456                projected.saturating_sub(cap)
457            ),
458        );
459    }
460
461    // Authorized.
462    let event = Ap2CostEvent {
463        cart_id: cart.cart_id.clone(),
464        intent_id: cart.intent_id.clone(),
465        merchant: cart.merchant.clone(),
466        cost_cents: amount_cents,
467        currency: cart.total.currency.to_ascii_uppercase(),
468    };
469    let budget_remaining_cents = budget
470        .max_cost_cents
471        .map(|cap| cap.saturating_sub(usage.cost_cents.saturating_add(amount_cents)));
472    Ap2GateOutcome::Authorize {
473        event,
474        budget_remaining_cents,
475    }
476}
477
478/// Scope check: merchant (if constrained), category (if constrained), currency,
479/// and the user's own intent max. `None` ⇒ in scope.
480fn scope_violation(
481    intent: &Ap2IntentMandate,
482    cart: &Ap2CartMandate,
483    amount_cents: u64,
484) -> Option<String> {
485    let scope = &intent.scope;
486    if !scope.merchants.is_empty() && !scope.merchants.iter().any(|m| m == &cart.merchant) {
487        return Some(format!(
488            "merchant '{}' is not in the intent's authorized merchants",
489            cart.merchant
490        ));
491    }
492    if !scope.categories.is_empty() {
493        match &cart.category {
494            Some(c) if scope.categories.iter().any(|sc| sc == c) => {}
495            _ => {
496                return Some(format!(
497                    "cart category {:?} is not in the intent's authorized categories",
498                    cart.category
499                ))
500            }
501        }
502    }
503    if !cart
504        .total
505        .currency
506        .eq_ignore_ascii_case(&scope.max_amount.currency)
507    {
508        return Some(format!(
509            "cart currency '{}' does not match the intent currency '{}'",
510            cart.total.currency, scope.max_amount.currency
511        ));
512    }
513    if amount_cents > scope.max_amount.amount_cents {
514        return Some(format!(
515            "cart total {amount_cents}¢ exceeds the user's authorized intent max {}¢",
516            scope.max_amount.amount_cents
517        ));
518    }
519    None
520}
521
522fn deny(kind: Ap2DenyKind, reason: String) -> Ap2GateOutcome {
523    Ap2GateOutcome::Deny { kind, reason }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use ed25519_dalek::{Signer, SigningKey};
530
531    const USER_KEY_ID: &str = "user-key-1";
532
533    fn signing_key() -> SigningKey {
534        // Fixed 32-byte seed → deterministic key + signatures (RFC 8032).
535        SigningKey::from_bytes(&[7u8; 32])
536    }
537
538    fn keyring_with(signer: &SigningKey) -> Ap2Keyring {
539        let mut kr = Ap2Keyring::new();
540        kr.insert_key(USER_KEY_ID, signer.verifying_key());
541        kr
542    }
543
544    fn usd(cents: u64) -> Ap2Money {
545        Ap2Money {
546            amount_cents: cents,
547            currency: "USD".into(),
548        }
549    }
550
551    fn signed_intent(signer: &SigningKey, max_cents: u64) -> Ap2IntentMandate {
552        let mut intent = Ap2IntentMandate {
553            intent_id: "intent-abc".into(),
554            subject: "user@example.com".into(),
555            scope: Ap2Scope {
556                merchants: vec!["acme-store".into()],
557                categories: vec!["office-supplies".into()],
558                max_amount: usd(max_cents),
559            },
560            key_id: USER_KEY_ID.into(),
561            signature: String::new(),
562        };
563        intent.signature = hex::encode(signer.sign(&intent.signing_bytes()).to_bytes());
564        intent
565    }
566
567    fn signed_cart(signer: &SigningKey, total_cents: u64) -> Ap2CartMandate {
568        let mut cart = Ap2CartMandate {
569            cart_id: "cart-xyz".into(),
570            intent_id: "intent-abc".into(),
571            merchant: "acme-store".into(),
572            category: Some("office-supplies".into()),
573            total: usd(total_cents),
574            key_id: USER_KEY_ID.into(),
575            signature: String::new(),
576        };
577        cart.signature = hex::encode(signer.sign(&cart.signing_bytes()).to_bytes());
578        cart
579    }
580
581    fn budget(cap: u64) -> Budget {
582        Budget {
583            max_cost_cents: Some(cap),
584            ..Budget::default()
585        }
586    }
587
588    #[test]
589    fn authorizes_a_valid_in_scope_in_budget_payment() {
590        let signer = signing_key();
591        let kr = keyring_with(&signer);
592        let intent = signed_intent(&signer, 5000); // user authorized up to $50
593        let cart = signed_cart(&signer, 4000); // $40 cart
594        let usage = BudgetUsage {
595            cost_cents: 10,
596            ..BudgetUsage::default()
597        };
598        let outcome = evaluate_ap2_payment(&intent, &cart, &kr, &budget(10_000), &usage);
599        match outcome {
600            Ap2GateOutcome::Authorize {
601                event,
602                budget_remaining_cents,
603            } => {
604                assert_eq!(event.cost_cents, 4000);
605                assert_eq!(event.merchant, "acme-store");
606                assert_eq!(budget_remaining_cents, Some(10_000 - 10 - 4000));
607            }
608            other => panic!("expected authorize, got {other:?}"),
609        }
610    }
611
612    #[test]
613    fn authorized_payment_folds_into_the_shared_cost_ledger() {
614        let signer = signing_key();
615        let kr = keyring_with(&signer);
616        let intent = signed_intent(&signer, 5000);
617        let cart = signed_cart(&signer, 4000);
618        let mut usage = BudgetUsage {
619            cost_cents: 30, // token + x402 so far
620            ..BudgetUsage::default()
621        };
622        if let Ap2GateOutcome::Authorize { event, .. } =
623            evaluate_ap2_payment(&intent, &cart, &kr, &budget(10_000), &usage)
624        {
625            event.apply_to(&mut usage);
626        } else {
627            panic!("expected authorize");
628        }
629        assert_eq!(usage.cost_cents, 4030); // one ledger across rails
630        assert_eq!(usage.tool_calls, 0);
631    }
632
633    #[test]
634    fn denies_a_tampered_cart_signature() {
635        let signer = signing_key();
636        let kr = keyring_with(&signer);
637        let intent = signed_intent(&signer, 5000);
638        let mut cart = signed_cart(&signer, 4000);
639        // Tamper the total AFTER signing — signature no longer matches the bytes.
640        cart.total.amount_cents = 1; // agent tries to under-report to fit the budget
641        let outcome = evaluate_ap2_payment(
642            &intent,
643            &cart,
644            &kr,
645            &budget(10_000),
646            &BudgetUsage::default(),
647        );
648        assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::InvalidSignature));
649        assert!(outcome.to_policy_decision().is_denied());
650        assert!(outcome.alert_line().unwrap().contains("invalid_signature"));
651    }
652
653    #[test]
654    fn denies_a_missing_signature() {
655        let signer = signing_key();
656        let kr = keyring_with(&signer);
657        let intent = signed_intent(&signer, 5000);
658        let mut cart = signed_cart(&signer, 4000);
659        cart.signature = String::new();
660        let outcome = evaluate_ap2_payment(
661            &intent,
662            &cart,
663            &kr,
664            &budget(10_000),
665            &BudgetUsage::default(),
666        );
667        assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::MissingSignature));
668    }
669
670    #[test]
671    fn denies_an_unknown_signing_key() {
672        let signer = signing_key();
673        let empty = Ap2Keyring::new(); // no keys registered → deny-by-default
674        let intent = signed_intent(&signer, 5000);
675        let cart = signed_cart(&signer, 4000);
676        let outcome = evaluate_ap2_payment(
677            &intent,
678            &cart,
679            &empty,
680            &budget(10_000),
681            &BudgetUsage::default(),
682        );
683        assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::UnknownKey));
684    }
685
686    #[test]
687    fn denies_a_cart_over_the_per_task_ceiling() {
688        // Cart is validly signed + in the user's intent scope, but breaches the
689        // SAME per-task budget ceiling x402 uses.
690        let signer = signing_key();
691        let kr = keyring_with(&signer);
692        let intent = signed_intent(&signer, 20_000); // user allows up to $200
693        let cart = signed_cart(&signer, 15_000); // $150 cart
694        let usage = BudgetUsage {
695            cost_cents: 0,
696            ..BudgetUsage::default()
697        };
698        let outcome = evaluate_ap2_payment(&intent, &cart, &kr, &budget(100), &usage); // $1 task ceiling
699        match &outcome {
700            Ap2GateOutcome::Deny { kind, reason } => {
701                assert_eq!(*kind, Ap2DenyKind::CartOverCeiling);
702                assert!(reason.contains("15000"));
703            }
704            other => panic!("expected deny, got {other:?}"),
705        }
706    }
707
708    #[test]
709    fn denies_a_merchant_outside_intent_scope() {
710        let signer = signing_key();
711        let kr = keyring_with(&signer);
712        let intent = signed_intent(&signer, 5000);
713        let mut cart = Ap2CartMandate {
714            merchant: "evil-merchant".into(), // not in the intent's authorized merchants
715            ..signed_cart(&signer, 4000)
716        };
717        // Re-sign so the signature is valid over the tampered merchant — the
718        // failure must be SCOPE, not signature.
719        cart.signature = hex::encode(signer.sign(&cart.signing_bytes()).to_bytes());
720        let outcome = evaluate_ap2_payment(
721            &intent,
722            &cart,
723            &kr,
724            &budget(10_000),
725            &BudgetUsage::default(),
726        );
727        assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::IntentScopeMismatch));
728    }
729
730    #[test]
731    fn denies_a_cart_over_the_users_own_intent_max() {
732        // Signed + in-merchant + under the per-task ceiling, but over what the
733        // user themselves authorized in the intent.
734        let signer = signing_key();
735        let kr = keyring_with(&signer);
736        let intent = signed_intent(&signer, 3000); // user max $30
737        let cart = signed_cart(&signer, 4000); // $40 cart
738        let outcome = evaluate_ap2_payment(
739            &intent,
740            &cart,
741            &kr,
742            &budget(100_000),
743            &BudgetUsage::default(),
744        );
745        assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::IntentScopeMismatch));
746    }
747
748    #[test]
749    fn denies_a_cart_not_bound_to_the_intent() {
750        let signer = signing_key();
751        let kr = keyring_with(&signer);
752        let intent = signed_intent(&signer, 5000);
753        let mut cart = signed_cart(&signer, 4000);
754        cart.intent_id = "some-other-intent".into();
755        cart.signature = hex::encode(signer.sign(&cart.signing_bytes()).to_bytes());
756        let outcome = evaluate_ap2_payment(
757            &intent,
758            &cart,
759            &kr,
760            &budget(10_000),
761            &BudgetUsage::default(),
762        );
763        assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::IntentCartMismatch));
764    }
765
766    #[test]
767    fn denies_an_unpriceable_non_usd_cart() {
768        let signer = signing_key();
769        let kr = keyring_with(&signer);
770        let intent = signed_intent(&signer, 5000);
771        let mut cart = signed_cart(&signer, 4000);
772        cart.total.currency = "EUR".into();
773        cart.signature = hex::encode(signer.sign(&cart.signing_bytes()).to_bytes());
774        let outcome = evaluate_ap2_payment(
775            &intent,
776            &cart,
777            &kr,
778            &budget(10_000),
779            &BudgetUsage::default(),
780        );
781        assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::Unpriceable));
782    }
783
784    #[test]
785    fn mandates_round_trip_through_json_and_verify() {
786        // The parse path an external caller uses: mandates arrive as JSON.
787        let signer = signing_key();
788        let kr = keyring_with(&signer);
789        let intent = signed_intent(&signer, 5000);
790        let cart = signed_cart(&signer, 4000);
791        let intent2: Ap2IntentMandate =
792            serde_json::from_str(&serde_json::to_string(&intent).unwrap()).unwrap();
793        let cart2: Ap2CartMandate =
794            serde_json::from_str(&serde_json::to_string(&cart).unwrap()).unwrap();
795        assert!(evaluate_ap2_payment(
796            &intent2,
797            &cart2,
798            &kr,
799            &budget(10_000),
800            &BudgetUsage::default()
801        )
802        .is_authorized());
803    }
804
805    #[test]
806    fn keyring_insert_hex_round_trips() {
807        let signer = signing_key();
808        let hexkey = hex::encode(signer.verifying_key().to_bytes());
809        let mut kr = Ap2Keyring::new();
810        kr.insert_hex(USER_KEY_ID, &hexkey).expect("valid hex key");
811        assert_eq!(kr.len(), 1);
812        assert!(!kr.is_empty());
813        // A bad key is rejected, not silently dropped.
814        assert!(kr.insert_hex("bad", "zzzz").is_err());
815    }
816
817    #[test]
818    fn deny_kind_wire_labels_are_stable() {
819        assert_eq!(Ap2DenyKind::InvalidSignature.as_str(), "invalid_signature");
820        assert_eq!(Ap2DenyKind::CartOverCeiling.as_str(), "cart_over_ceiling");
821        assert_eq!(
822            Ap2DenyKind::IntentScopeMismatch.as_str(),
823            "intent_scope_mismatch"
824        );
825    }
826}