Skip to main content

polyc_crypto/
mandate.rs

1//! AP2-style pre-authorization mandates: canonical signing plus the chain
2//! that narrows them (Intent → Cart → Payment).
3//!
4//! An AP2 mandate chain is three signed artifacts, each narrowing the one
5//! before it: an **Intent** mandate (the human's up-front authorization
6//! scope), a **Cart** mandate (a specific merchant + amount drawn from that
7//! scope), and a **Payment** mandate (the exact tool call the cart pays for).
8//! Each signs the canonical JSON encoding of its fields with `signed_by` /
9//! `signature_hex` cleared — the same pattern [`crate::approval`] uses for
10//! `approval_response` / `payment_receipt` — plus a literal `kind` tag so a
11//! signed Cart can never be mistaken for a signed Intent even if their field
12//! sets happened to overlap.
13//!
14//! A child mandate references its parent by `mandate_hash`: the sha256 hex
15//! of the PARENT'S FULL signed payload (body + signature), not just its
16//! unsigned fields. Hashing the signature too means the reference commits to
17//! one exact, already-verified artifact — a parent re-signed by a different
18//! key (even over byte-identical fields) produces a different hash and breaks
19//! the chain.
20//!
21//! This module owns two layers over that signing primitive:
22//!
23//! * **Signing** — mints and verifies each link's individual signature and
24//!   computes the chain-link hash. Knows nothing about amount narrowing,
25//!   expiry ordering, or which call a Payment mandate authorizes.
26//! * **Chain validation** — the *business* rules built on top: end-to-end
27//!   chain validation (`MandateChain::verify`) yielding a
28//!   `VerifiedMandateChain`; authorization of a concrete proposed spend
29//!   against a verified chain (`VerifiedMandateChain::authorize`);
30//!   `resolve`, the gate the payment proxy calls at the same enforcement
31//!   seam as a pre-signing spend cap — a no-op (today's behavior, unchanged)
32//!   whenever no chain is presented or no issuer key is configured; and
33//!   `payment_mandate_from_approval`, which formalizes an ALREADY-verified
34//!   HITL decision ([`crate::approval::VerifiedResponse`]) as a signed
35//!   Payment mandate chained to a Cart — reusing the existing signed-approval
36//!   machinery as the trust root rather than building a second approval
37//!   surface.
38//!
39//! Mandates are **additive and fail-closed**: absent a presented chain (or
40//! with no issuer key configured), behavior is exactly today's
41//! HITL-approval + spend-cap path. A chain that IS presented while the
42//! feature is configured must verify end-to-end or the payment is refused —
43//! an invalid mandate is never silently ignored.
44//!
45//! Amounts are settlement-token base units (`u128`), the same unit a
46//! pre-signing spend cap, the challenge wire amount, and a conversation
47//! budget use.
48
49use serde::Serialize;
50use serde_json::Value;
51use sha2::{Digest, Sha256};
52
53use crate::approval::VerifiedResponse;
54use crate::canon::canon_args;
55use crate::signed::{Envelope, canonical_bytes};
56use crate::{Signer, verify};
57
58/// Signed `kind` tag for an Intent mandate's canonical JSON.
59const KIND_INTENT: &str = "ap2.intent.v1";
60/// Signed `kind` tag for a Cart mandate's canonical JSON.
61const KIND_CART: &str = "ap2.cart.v1";
62/// Signed `kind` tag for a Payment mandate's canonical JSON.
63const KIND_PAYMENT: &str = "ap2.payment.v1";
64
65/// Sha256 hex of a mandate's FULL signed payload bytes — the chain link.
66///
67/// Takes the bytes a `sign_*_mandate` function returned (or read back from
68/// storage); the result is the value a child mandate signs into its
69/// `intent_hash` / `cart_hash` field.
70#[must_use]
71pub fn mandate_hash(signed_payload: &[u8]) -> String {
72    let mut hasher = Sha256::new();
73    hasher.update(signed_payload);
74    crate::hex::lower(&hasher.finalize())
75}
76
77/// Named signed fields for an Intent mandate — the top-level, human-granted
78/// authorization scope.
79///
80/// Passed as a single struct (mirrors [`crate::approval::ReceiptPayload`]) so
81/// two same-typed `&str` fields can't be silently swapped at a call site.
82#[derive(Debug, Clone, Copy)]
83pub struct IntentFields<'a> {
84    /// The identity that granted this authorization (mirrors
85    /// `approval_response.caller`, e.g. `slack:T1:U9`).
86    pub caller: &'a str,
87    /// The conversation this intent is scoped to.
88    pub conversation_id: &'a str,
89    /// Free-form human-readable description of what was authorized (audit /
90    /// display only; not itself a scope predicate).
91    pub scope_description: &'a str,
92    /// Settlement token contract address every descendant Cart/Payment must
93    /// match.
94    pub currency: &'a str,
95    /// Decimal base-unit ceiling on the total this intent may ultimately
96    /// authorize across every descendant Cart; empty ⇒ unbounded (a Cart's
97    /// amount is still bounded by its own signed value, just not by this
98    /// intent).
99    pub max_total_base_units: &'a str,
100    /// Unix seconds this mandate was issued.
101    pub issued_at_unix: u64,
102    /// Unix seconds after which this mandate (and every descendant) is no
103    /// longer valid.
104    pub expires_at_unix: u64,
105    /// Per-mandate unique value (mirrors `approval_response.nonce`).
106    pub nonce: &'a str,
107}
108
109impl<'a> IntentFields<'a> {
110    const fn canonical_json(&self) -> IntentCanonical<'a> {
111        IntentCanonical {
112            kind: KIND_INTENT,
113            caller: self.caller,
114            conversation_id: self.conversation_id,
115            scope_description: self.scope_description,
116            currency: self.currency,
117            max_total_base_units: self.max_total_base_units,
118            issued_at_unix: self.issued_at_unix,
119            expires_at_unix: self.expires_at_unix,
120            nonce: self.nonce,
121        }
122    }
123}
124
125/// The signed Intent-mandate field set, in its frozen order.
126///
127/// Declaration order IS the signed contract — see [`canonical_bytes`] and ADR
128/// 0009. Reordering a field invalidates every Intent mandate ever signed.
129#[derive(Serialize)]
130struct IntentCanonical<'a> {
131    kind: &'static str,
132    caller: &'a str,
133    conversation_id: &'a str,
134    scope_description: &'a str,
135    currency: &'a str,
136    max_total_base_units: &'a str,
137    issued_at_unix: u64,
138    expires_at_unix: u64,
139    nonce: &'a str,
140}
141
142/// A verified, decoded Intent mandate.
143#[derive(Debug, Clone)]
144pub struct VerifiedIntentMandate {
145    /// The identity that granted this authorization.
146    pub caller: String,
147    /// The conversation this intent is scoped to.
148    pub conversation_id: String,
149    /// Human-readable description of what was authorized.
150    pub scope_description: String,
151    /// Settlement token contract address.
152    pub currency: String,
153    /// Decimal base-unit ceiling on the total; empty ⇒ unbounded.
154    pub max_total_base_units: String,
155    /// Unix seconds this mandate was issued.
156    pub issued_at_unix: u64,
157    /// Unix seconds after which this mandate is no longer valid.
158    pub expires_at_unix: u64,
159    /// Per-mandate unique value.
160    pub nonce: String,
161    /// The verified signer's public key (encoded).
162    pub signer_public_key: Vec<u8>,
163}
164
165/// Sign the canonical bytes of `fields`.
166///
167/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`; the
168/// caller persists `full_payload_bytes` and, for a Cart mandate, feeds it
169/// through [`mandate_hash`] to build the chain link.
170#[must_use]
171pub fn sign_intent_mandate(
172    fields: &IntentFields<'_>,
173    signer: &Signer,
174) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
175    Envelope::seal(fields.canonical_json(), signer)
176}
177
178/// Verify a persisted Intent mandate payload.
179///
180/// Returns `Some(record)` if the signature checks out against the embedded
181/// public key and the payload carries the Intent `kind` tag. Returns `None` if the
182/// payload is malformed, the hex fields don't decode, the `kind` tag doesn't
183/// match, or the signature doesn't verify.
184#[must_use]
185pub fn verify_signed_intent_mandate(payload: &[u8]) -> Option<VerifiedIntentMandate> {
186    let v: Value = serde_json::from_slice(payload).ok()?;
187    if v.get("kind")?.as_str()? != KIND_INTENT {
188        return None;
189    }
190    let caller = v.get("caller")?.as_str()?.to_owned();
191    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
192    let scope_description = v.get("scope_description")?.as_str()?.to_owned();
193    let currency = v.get("currency")?.as_str()?.to_owned();
194    let max_total_base_units = v.get("max_total_base_units")?.as_str()?.to_owned();
195    let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
196    let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
197    let nonce = v.get("nonce")?.as_str()?.to_owned();
198    let (pk, sig) = envelope_signature(&v)?;
199
200    let fields = IntentFields {
201        caller: &caller,
202        conversation_id: &conversation_id,
203        scope_description: &scope_description,
204        currency: &currency,
205        max_total_base_units: &max_total_base_units,
206        issued_at_unix,
207        expires_at_unix,
208        nonce: &nonce,
209    };
210    if verify(&pk, &canonical_bytes(&fields.canonical_json()), &sig) {
211        Some(VerifiedIntentMandate {
212            caller,
213            conversation_id,
214            scope_description,
215            currency,
216            max_total_base_units,
217            issued_at_unix,
218            expires_at_unix,
219            nonce,
220            signer_public_key: pk,
221        })
222    } else {
223        None
224    }
225}
226
227/// Named signed fields for a Cart mandate — narrows an Intent to a specific
228/// merchant and amount.
229#[derive(Debug, Clone, Copy)]
230pub struct CartFields<'a> {
231    /// [`mandate_hash`] of the parent Intent mandate's full signed payload.
232    pub intent_hash: &'a str,
233    /// The identity that granted this authorization (must equal the parent
234    /// Intent's `caller`; checked by the chain validator, not here).
235    pub caller: &'a str,
236    /// The conversation this cart is scoped to.
237    pub conversation_id: &'a str,
238    /// The merchant host this cart authorizes payment to (lowercased).
239    pub merchant_host: &'a str,
240    /// Settlement token contract address.
241    pub currency: &'a str,
242    /// Decimal base-unit total for this cart.
243    pub amount_base_units: &'a str,
244    /// Unix seconds this mandate was issued.
245    pub issued_at_unix: u64,
246    /// Unix seconds after which this mandate is no longer valid.
247    pub expires_at_unix: u64,
248    /// Per-mandate unique value.
249    pub nonce: &'a str,
250}
251
252impl<'a> CartFields<'a> {
253    const fn canonical_json(&self) -> CartCanonical<'a> {
254        CartCanonical {
255            kind: KIND_CART,
256            intent_hash: self.intent_hash,
257            caller: self.caller,
258            conversation_id: self.conversation_id,
259            merchant_host: self.merchant_host,
260            currency: self.currency,
261            amount_base_units: self.amount_base_units,
262            issued_at_unix: self.issued_at_unix,
263            expires_at_unix: self.expires_at_unix,
264            nonce: self.nonce,
265        }
266    }
267}
268
269/// The signed Cart-mandate field set, in its frozen order.
270///
271/// Declaration order IS the signed contract — see [`canonical_bytes`] and ADR
272/// 0009.
273#[derive(Serialize)]
274struct CartCanonical<'a> {
275    kind: &'static str,
276    intent_hash: &'a str,
277    caller: &'a str,
278    conversation_id: &'a str,
279    merchant_host: &'a str,
280    currency: &'a str,
281    amount_base_units: &'a str,
282    issued_at_unix: u64,
283    expires_at_unix: u64,
284    nonce: &'a str,
285}
286
287/// A verified, decoded Cart mandate.
288#[derive(Debug, Clone)]
289pub struct VerifiedCartMandate {
290    /// [`mandate_hash`] of the parent Intent mandate this cart chains to.
291    pub intent_hash: String,
292    /// The identity that granted this authorization.
293    pub caller: String,
294    /// The conversation this cart is scoped to.
295    pub conversation_id: String,
296    /// The merchant host this cart authorizes payment to.
297    pub merchant_host: String,
298    /// Settlement token contract address.
299    pub currency: String,
300    /// Decimal base-unit total for this cart.
301    pub amount_base_units: String,
302    /// Unix seconds this mandate was issued.
303    pub issued_at_unix: u64,
304    /// Unix seconds after which this mandate is no longer valid.
305    pub expires_at_unix: u64,
306    /// Per-mandate unique value.
307    pub nonce: String,
308    /// The verified signer's public key (encoded).
309    pub signer_public_key: Vec<u8>,
310}
311
312/// Sign the canonical bytes of `fields`. Returns
313/// `(full_payload_bytes, signature_bytes, public_key_bytes)`.
314#[must_use]
315pub fn sign_cart_mandate(fields: &CartFields<'_>, signer: &Signer) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
316    Envelope::seal(fields.canonical_json(), signer)
317}
318
319/// Verify a persisted Cart mandate payload. See
320/// [`verify_signed_intent_mandate`] for the failure modes.
321#[must_use]
322pub fn verify_signed_cart_mandate(payload: &[u8]) -> Option<VerifiedCartMandate> {
323    let v: Value = serde_json::from_slice(payload).ok()?;
324    if v.get("kind")?.as_str()? != KIND_CART {
325        return None;
326    }
327    let intent_hash = v.get("intent_hash")?.as_str()?.to_owned();
328    let caller = v.get("caller")?.as_str()?.to_owned();
329    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
330    let merchant_host = v.get("merchant_host")?.as_str()?.to_owned();
331    let currency = v.get("currency")?.as_str()?.to_owned();
332    let amount_base_units = v.get("amount_base_units")?.as_str()?.to_owned();
333    let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
334    let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
335    let nonce = v.get("nonce")?.as_str()?.to_owned();
336    let (pk, sig) = envelope_signature(&v)?;
337
338    let fields = CartFields {
339        intent_hash: &intent_hash,
340        caller: &caller,
341        conversation_id: &conversation_id,
342        merchant_host: &merchant_host,
343        currency: &currency,
344        amount_base_units: &amount_base_units,
345        issued_at_unix,
346        expires_at_unix,
347        nonce: &nonce,
348    };
349    if verify(&pk, &canonical_bytes(&fields.canonical_json()), &sig) {
350        Some(VerifiedCartMandate {
351            intent_hash,
352            caller,
353            conversation_id,
354            merchant_host,
355            currency,
356            amount_base_units,
357            issued_at_unix,
358            expires_at_unix,
359            nonce,
360            signer_public_key: pk,
361        })
362    } else {
363        None
364    }
365}
366
367/// Named signed fields for a Payment mandate — the final authorization bound
368/// to one exact tool call.
369#[derive(Debug, Clone, Copy)]
370pub struct PaymentFields<'a> {
371    /// [`mandate_hash`] of the parent Cart mandate's full signed payload.
372    pub cart_hash: &'a str,
373    /// The identity that granted this authorization.
374    pub caller: &'a str,
375    /// The conversation this payment is scoped to.
376    pub conversation_id: &'a str,
377    /// The exact `paid_fetch` `args_json` this payment authorizes (mirrors
378    /// `approval_response.args_json` binding — a captured mandate cannot be
379    /// replayed against a different call).
380    pub args_json: &'a str,
381    /// Settlement token contract address.
382    pub currency: &'a str,
383    /// Decimal base-unit amount for this payment.
384    pub amount_base_units: &'a str,
385    /// Unix seconds this mandate was issued.
386    pub issued_at_unix: u64,
387    /// Unix seconds after which this mandate is no longer valid.
388    pub expires_at_unix: u64,
389    /// Per-mandate unique value.
390    pub nonce: &'a str,
391}
392
393impl<'a> PaymentFields<'a> {
394    const fn canonical_json(&self) -> PaymentCanonical<'a> {
395        PaymentCanonical {
396            kind: KIND_PAYMENT,
397            cart_hash: self.cart_hash,
398            caller: self.caller,
399            conversation_id: self.conversation_id,
400            args_json: self.args_json,
401            currency: self.currency,
402            amount_base_units: self.amount_base_units,
403            issued_at_unix: self.issued_at_unix,
404            expires_at_unix: self.expires_at_unix,
405            nonce: self.nonce,
406        }
407    }
408}
409
410/// The signed Payment-mandate field set, in its frozen order.
411///
412/// Declaration order IS the signed contract — see [`canonical_bytes`] and ADR
413/// 0009.
414#[derive(Serialize)]
415struct PaymentCanonical<'a> {
416    kind: &'static str,
417    cart_hash: &'a str,
418    caller: &'a str,
419    conversation_id: &'a str,
420    args_json: &'a str,
421    currency: &'a str,
422    amount_base_units: &'a str,
423    issued_at_unix: u64,
424    expires_at_unix: u64,
425    nonce: &'a str,
426}
427
428/// A verified, decoded Payment mandate.
429#[derive(Debug, Clone)]
430pub struct VerifiedPaymentMandate {
431    /// [`mandate_hash`] of the parent Cart mandate this payment chains to.
432    pub cart_hash: String,
433    /// The identity that granted this authorization.
434    pub caller: String,
435    /// The conversation this payment is scoped to.
436    pub conversation_id: String,
437    /// The exact `paid_fetch` `args_json` this payment authorizes.
438    pub args_json: String,
439    /// Settlement token contract address.
440    pub currency: String,
441    /// Decimal base-unit amount for this payment.
442    pub amount_base_units: String,
443    /// Unix seconds this mandate was issued.
444    pub issued_at_unix: u64,
445    /// Unix seconds after which this mandate is no longer valid.
446    pub expires_at_unix: u64,
447    /// Per-mandate unique value.
448    pub nonce: String,
449    /// The verified signer's public key (encoded).
450    pub signer_public_key: Vec<u8>,
451}
452
453/// Sign the canonical bytes of `fields`. Returns
454/// `(full_payload_bytes, signature_bytes, public_key_bytes)`.
455#[must_use]
456pub fn sign_payment_mandate(
457    fields: &PaymentFields<'_>,
458    signer: &Signer,
459) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
460    Envelope::seal(fields.canonical_json(), signer)
461}
462
463/// Verify a persisted Payment mandate payload. See
464/// [`verify_signed_intent_mandate`] for the failure modes.
465#[must_use]
466pub fn verify_signed_payment_mandate(payload: &[u8]) -> Option<VerifiedPaymentMandate> {
467    let v: Value = serde_json::from_slice(payload).ok()?;
468    if v.get("kind")?.as_str()? != KIND_PAYMENT {
469        return None;
470    }
471    let cart_hash = v.get("cart_hash")?.as_str()?.to_owned();
472    let caller = v.get("caller")?.as_str()?.to_owned();
473    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
474    let args_json = v.get("args_json")?.as_str()?.to_owned();
475    let currency = v.get("currency")?.as_str()?.to_owned();
476    let amount_base_units = v.get("amount_base_units")?.as_str()?.to_owned();
477    let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
478    let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
479    let nonce = v.get("nonce")?.as_str()?.to_owned();
480    let (pk, sig) = envelope_signature(&v)?;
481
482    let fields = PaymentFields {
483        cart_hash: &cart_hash,
484        caller: &caller,
485        conversation_id: &conversation_id,
486        args_json: &args_json,
487        currency: &currency,
488        amount_base_units: &amount_base_units,
489        issued_at_unix,
490        expires_at_unix,
491        nonce: &nonce,
492    };
493    if verify(&pk, &canonical_bytes(&fields.canonical_json()), &sig) {
494        Some(VerifiedPaymentMandate {
495            cart_hash,
496            caller,
497            conversation_id,
498            args_json,
499            currency,
500            amount_base_units,
501            issued_at_unix,
502            expires_at_unix,
503            nonce,
504            signer_public_key: pk,
505        })
506    } else {
507        None
508    }
509}
510
511/// Extracts and hex-decodes the `signed_by` / `signature_hex` pair a
512/// `verify_signed_*_mandate` needs, common to all three envelope shapes.
513fn envelope_signature(v: &Value) -> Option<(Vec<u8>, Vec<u8>)> {
514    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
515    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
516    Some((pk, sig))
517}
518
519// ---------------------------------------------------------------------------
520// Chain validation — the AP2 business rules built on the signing primitives
521// above: hash-chaining, amount narrowing, currency/caller/conversation
522// agreement, and expiry. [`MandateChain::verify`] is the sole entry point;
523// everything below exists to produce or consume its result.
524// ---------------------------------------------------------------------------
525
526/// Raised while validating a [`MandateChain`] or authorizing a spend
527/// against a [`VerifiedMandateChain`].
528#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
529pub enum MandateError {
530    /// The Intent mandate is malformed or its signature does not verify.
531    #[error("intent mandate is malformed or its signature does not verify")]
532    InvalidIntent,
533    /// The Cart mandate is malformed or its signature does not verify.
534    #[error("cart mandate is malformed or its signature does not verify")]
535    InvalidCart,
536    /// The Payment mandate is malformed or its signature does not verify.
537    #[error("payment mandate is malformed or its signature does not verify")]
538    InvalidPayment,
539    /// A link's signer is not the platform issuer key.
540    #[error("{0} mandate is not signed by a trusted key")]
541    UntrustedSigner(&'static str),
542    /// A link is bound to a different conversation than the one it is being
543    /// consumed in.
544    #[error("{0} mandate is bound to a different conversation")]
545    ConversationMismatch(&'static str),
546    /// The chain's links are not all bound to one consistent caller.
547    #[error("mandate chain is not bound to one consistent caller")]
548    CallerMismatch,
549    /// A link has expired as of the check time.
550    #[error("{kind} mandate expired at {expires_at_unix}")]
551    Expired {
552        /// Which link expired.
553        kind: &'static str,
554        /// Its signed unix-seconds expiry.
555        expires_at_unix: u64,
556    },
557    /// The Cart's `intent_hash` does not reference the presented Intent.
558    #[error("cart mandate does not chain to the presented intent mandate")]
559    CartNotChainedToIntent,
560    /// The Payment's `cart_hash` does not reference the presented Cart.
561    #[error("payment mandate does not chain to the presented cart mandate")]
562    PaymentNotChainedToCart,
563    /// A signed amount field is not a parseable base-unit integer. Fails
564    /// closed: a mandate whose amount cannot be understood authorizes
565    /// nothing.
566    #[error("{0} mandate carries an unparseable base-unit amount")]
567    MalformedAmount(&'static str),
568    /// The Cart's amount exceeds the Intent's ceiling (widening).
569    #[error("cart amount {cart} exceeds the intent ceiling {intent}")]
570    AmountWidensAtCart {
571        /// The Cart's base-unit amount.
572        cart: u128,
573        /// The Intent's base-unit ceiling.
574        intent: u128,
575    },
576    /// The Payment's amount exceeds the Cart's amount (widening).
577    #[error("payment amount {payment} exceeds the cart amount {cart}")]
578    AmountWidensAtPayment {
579        /// The Payment's base-unit amount.
580        payment: u128,
581        /// The Cart's base-unit amount.
582        cart: u128,
583    },
584    /// Currency differs across the chain.
585    #[error("mandate currency does not match across the chain")]
586    CurrencyMismatch,
587    /// The Cart's merchant scope is empty — a scopeless cart authorizes
588    /// nothing (fail closed).
589    #[error("cart mandate carries no merchant scope")]
590    EmptyScope,
591    /// A verified chain does not cover the proposed spend (amount or
592    /// destination).
593    #[error(
594        "mandate authorizes at most {authorized} base units against {authorized_host}; \
595         requested {requested} base units against {requested_host}"
596    )]
597    ExceedsAuthorization {
598        /// The base-unit amount the chain authorizes.
599        authorized: u128,
600        /// The merchant host the chain authorizes spend against.
601        authorized_host: String,
602        /// The requested base-unit amount.
603        requested: u128,
604        /// The host the request targets.
605        requested_host: String,
606    },
607    /// The call's args do not match the exact `args_json` the Payment
608    /// mandate signed — a mandate minted for one call cannot authorize a
609    /// different one.
610    #[error("the payment mandate is bound to a different tool call's args")]
611    ArgsBindingMismatch,
612    /// The verified HITL approval offered as the trust root does not
613    /// authorize the exact call the Payment mandate is being minted for.
614    #[error("the HITL approval does not authorize this exact call; no mandate was minted")]
615    ApprovalDoesNotAuthorizeCall,
616}
617
618/// The three signed, wire-form mandate payloads presented together as one
619/// pre-authorization.
620#[derive(Debug, Clone, Default)]
621pub struct MandateChain {
622    /// Signed Intent mandate payload ([`sign_intent_mandate`] output).
623    pub intent: Vec<u8>,
624    /// Signed Cart mandate payload.
625    pub cart: Vec<u8>,
626    /// Signed Payment mandate payload.
627    pub payment: Vec<u8>,
628}
629
630impl MandateChain {
631    /// Validate the whole chain: every link is bound to `conversation_id`
632    /// and one consistent caller, the Payment chains (by [`mandate_hash`])
633    /// to the Cart and the Cart to the Intent, amounts narrow (never widen)
634    /// down the chain, currency agrees, the Cart carries a non-empty
635    /// merchant scope, and no link has expired as of `now_unix`.
636    ///
637    /// Every link verifies against `issuer_public_key`, the platform
638    /// mandate-issuing key: narrowing a human's authorization down to a
639    /// merchant and an amount is never something a browser-held key can do
640    /// on its own, and the Intent link above them is minted by the same
641    /// issuer. A link signed by any other key is untrusted.
642    ///
643    /// # Errors
644    ///
645    /// One [`MandateError`] per broken invariant — see each variant. Fails
646    /// closed on everything: malformed payloads, unknown signers, chain
647    /// breaks, widened or unparseable amounts, scope/currency drift,
648    /// expiry.
649    pub fn verify(
650        &self,
651        conversation_id: &str,
652        issuer_public_key: &[u8],
653        now_unix: u64,
654    ) -> Result<VerifiedMandateChain, MandateError> {
655        let intent =
656            verify_signed_intent_mandate(&self.intent).ok_or(MandateError::InvalidIntent)?;
657        let cart = verify_signed_cart_mandate(&self.cart).ok_or(MandateError::InvalidCart)?;
658        let payment =
659            verify_signed_payment_mandate(&self.payment).ok_or(MandateError::InvalidPayment)?;
660
661        // Conversation + expiry checks, root first — unchanged for every
662        // link regardless of which key trusts it.
663        let links: [(&'static str, &str, u64); 3] = [
664            ("intent", &intent.conversation_id, intent.expires_at_unix),
665            ("cart", &cart.conversation_id, cart.expires_at_unix),
666            ("payment", &payment.conversation_id, payment.expires_at_unix),
667        ];
668        for (label, conv, expires_at_unix) in links {
669            if conv != conversation_id {
670                return Err(MandateError::ConversationMismatch(label));
671            }
672            if expires_at_unix <= now_unix {
673                return Err(MandateError::Expired {
674                    kind: label,
675                    expires_at_unix,
676                });
677            }
678        }
679
680        // Per-link signer trust: every link verifies against the platform
681        // issuer key — see the doc comment above for why a narrowing link is
682        // never user-signable.
683        if intent.signer_public_key != issuer_public_key {
684            return Err(MandateError::UntrustedSigner("intent"));
685        }
686        if cart.signer_public_key != issuer_public_key {
687            return Err(MandateError::UntrustedSigner("cart"));
688        }
689        if payment.signer_public_key != issuer_public_key {
690            return Err(MandateError::UntrustedSigner("payment"));
691        }
692        if intent.caller != cart.caller || cart.caller != payment.caller {
693            return Err(MandateError::CallerMismatch);
694        }
695
696        // Chain links: each child signed the sha256 of its parent's FULL
697        // signed payload, so the reference commits to one exact,
698        // already-verified artifact — a re-signed parent (even over
699        // byte-identical fields) breaks the chain.
700        if cart.intent_hash != mandate_hash(&self.intent) {
701            return Err(MandateError::CartNotChainedToIntent);
702        }
703        if payment.cart_hash != mandate_hash(&self.cart) {
704            return Err(MandateError::PaymentNotChainedToCart);
705        }
706
707        // Amounts narrow down the chain. The Intent's ceiling may be empty
708        // (unbounded — the Cart's own signed amount still bounds spend);
709        // the Cart/Payment amounts must parse or the chain authorizes
710        // nothing.
711        let cart_amount: u128 = cart
712            .amount_base_units
713            .parse()
714            .map_err(|_| MandateError::MalformedAmount("cart"))?;
715        let payment_amount: u128 = payment
716            .amount_base_units
717            .parse()
718            .map_err(|_| MandateError::MalformedAmount("payment"))?;
719        if !intent.max_total_base_units.is_empty() {
720            let ceiling: u128 = intent
721                .max_total_base_units
722                .parse()
723                .map_err(|_| MandateError::MalformedAmount("intent"))?;
724            if cart_amount > ceiling {
725                return Err(MandateError::AmountWidensAtCart {
726                    cart: cart_amount,
727                    intent: ceiling,
728                });
729            }
730        }
731        if payment_amount > cart_amount {
732            return Err(MandateError::AmountWidensAtPayment {
733                payment: payment_amount,
734                cart: cart_amount,
735            });
736        }
737
738        if intent.currency != cart.currency || cart.currency != payment.currency {
739            return Err(MandateError::CurrencyMismatch);
740        }
741        if cart.merchant_host.is_empty() {
742            return Err(MandateError::EmptyScope);
743        }
744
745        Ok(VerifiedMandateChain {
746            authorized_amount_base_units: payment_amount,
747            merchant_host: cart.merchant_host.clone(),
748            currency: payment.currency.clone(),
749            caller: payment.caller.clone(),
750            conversation_id: payment.conversation_id.clone(),
751            args_json: payment.args_json.clone(),
752            intent,
753            cart,
754            payment,
755        })
756    }
757}
758
759/// A fully chain-validated mandate: the authoritative pre-authorization for
760/// exactly one payment.
761#[derive(Debug, Clone)]
762pub struct VerifiedMandateChain {
763    /// The maximum this chain authorizes spending — the Payment link's
764    /// exact base-unit amount.
765    pub authorized_amount_base_units: u128,
766    /// The merchant host the chain authorizes spend against (from the
767    /// Cart).
768    pub merchant_host: String,
769    /// The settlement currency the chain is denominated in.
770    pub currency: String,
771    /// The principal the chain is bound to.
772    pub caller: String,
773    /// The conversation the chain is bound to.
774    pub conversation_id: String,
775    /// The exact `args_json` the Payment link authorizes.
776    pub args_json: String,
777    /// The verified Intent link.
778    pub intent: VerifiedIntentMandate,
779    /// The verified Cart link.
780    pub cart: VerifiedCartMandate,
781    /// The verified Payment link.
782    pub payment: VerifiedPaymentMandate,
783}
784
785impl VerifiedMandateChain {
786    /// Authorize a proposed spend: `requested_base_units` against
787    /// `requested_host`, fulfilling the call whose arguments are
788    /// `args_json`.
789    ///
790    /// The pre-sign check the payment proxy calls at its enforcement seam,
791    /// alongside a pre-signing spend cap's own authorize check. Three
792    /// bindings, all fail-closed:
793    ///
794    /// * **destination** — `requested_host` must equal the Cart's merchant
795    ///   scope (ASCII case-insensitively; hosts are DNS names);
796    /// * **call** — `args_json` must value-match the exact args the Payment
797    ///   link signed (canonicalized with the same rules as the approval
798    ///   binding, so key order does not matter but any key/value difference
799    ///   refuses);
800    /// * **amount** — `requested_base_units` must not exceed the Payment
801    ///   link's amount.
802    ///
803    /// # Errors
804    ///
805    /// [`MandateError::ExceedsAuthorization`] on a host or amount breach;
806    /// [`MandateError::ArgsBindingMismatch`] when the call's args are not
807    /// the ones the mandate was minted for.
808    pub fn authorize(
809        &self,
810        requested_base_units: u128,
811        requested_host: &str,
812        args_json: &str,
813    ) -> Result<(), MandateError> {
814        if canon_args(args_json) != canon_args(&self.args_json) {
815            return Err(MandateError::ArgsBindingMismatch);
816        }
817        let host_ok = self.merchant_host.eq_ignore_ascii_case(requested_host);
818        if !host_ok || requested_base_units > self.authorized_amount_base_units {
819            return Err(MandateError::ExceedsAuthorization {
820                authorized: self.authorized_amount_base_units,
821                authorized_host: self.merchant_host.clone(),
822                requested: requested_base_units,
823                requested_host: requested_host.to_owned(),
824            });
825        }
826        Ok(())
827    }
828}
829
830/// The feature-gated pre-authorization resolver the payment proxy calls at
831/// its enforcement seam (`proxy::fulfill`).
832///
833/// Returns `Ok(None)` — a no-op, today's spend-cap-only behavior UNCHANGED —
834/// unless BOTH a chain is presented AND an issuer key is configured
835/// (`TEMPO_MANDATE_ISSUER_PUBKEY`; unset by default, so mandates are off by
836/// default). When both are present the chain must validate end-to-end
837/// ([`MandateChain::verify`]) or the payment is refused — a
838/// presented-but-invalid mandate is never silently ignored.
839///
840/// # Errors
841///
842/// See [`MandateChain::verify`].
843pub fn resolve(
844    chain: Option<&MandateChain>,
845    issuer_public_key: Option<&[u8]>,
846    conversation_id: &str,
847    now_unix: u64,
848) -> Result<Option<VerifiedMandateChain>, MandateError> {
849    match (chain, issuer_public_key) {
850        (Some(c), Some(key)) => c.verify(conversation_id, key, now_unix).map(Some),
851        _ => Ok(None),
852    }
853}
854
855/// Formalize an ALREADY-verified Slack/Telegram HITL approval
856/// ([`VerifiedResponse`]) as a signed Payment mandate chained to
857/// `cart_payload`.
858///
859/// This is the HITL→mandate bridge: it mints NO new approval surface and
860/// trusts NOTHING beyond what the existing signed-approval machinery
861/// already verified. A Payment mandate is minted only when:
862///
863/// * `approved` authorizes the EXACT `(request_id, tool_name, args_json)`
864///   tuple being fulfilled ([`VerifiedResponse::authorizes_call`] — the
865///   same binding the proxy's approval gate requires; a denial or an
866///   approval for any other call refuses);
867/// * `cart_payload` is a validly signed Cart mandate whose `caller` and
868///   `conversation_id` equal the approval's own signed values — a cart
869///   scoped to a different principal or conversation than the human who
870///   approved cannot be completed under that approval.
871///
872/// The minted Payment inherits the Cart's amount and currency (an exact
873/// narrowing: it authorizes the whole cart, nothing more), binds the
874/// approved `args_json`, and chains to the Cart by [`mandate_hash`].
875/// Returns the full signed payload bytes, ready to persist or present in a
876/// [`MandateChain`].
877///
878/// # Errors
879///
880/// [`MandateError::ApprovalDoesNotAuthorizeCall`] when the approval does
881/// not cover the exact call; [`MandateError::InvalidCart`] when
882/// `cart_payload` does not verify; [`MandateError::CallerMismatch`] /
883/// [`MandateError::ConversationMismatch`] when the cart is scoped to a
884/// different principal / conversation than the approval.
885#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the minted mandate
886pub fn payment_mandate_from_approval(
887    approved: &VerifiedResponse,
888    request_id: &str,
889    tool_name: &str,
890    args_json: &str,
891    cart_payload: &[u8],
892    issued_at_unix: u64,
893    expires_at_unix: u64,
894    nonce: &str,
895    signer: &Signer,
896) -> Result<Vec<u8>, MandateError> {
897    if !approved.authorizes_call(request_id, tool_name, args_json) {
898        return Err(MandateError::ApprovalDoesNotAuthorizeCall);
899    }
900    let cart = verify_signed_cart_mandate(cart_payload).ok_or(MandateError::InvalidCart)?;
901    if cart.caller != approved.caller {
902        return Err(MandateError::CallerMismatch);
903    }
904    if cart.conversation_id != approved.conversation_id {
905        return Err(MandateError::ConversationMismatch("cart"));
906    }
907    let fields = PaymentFields {
908        cart_hash: &mandate_hash(cart_payload),
909        caller: &approved.caller,
910        conversation_id: &approved.conversation_id,
911        args_json,
912        currency: &cart.currency,
913        amount_base_units: &cart.amount_base_units,
914        issued_at_unix,
915        expires_at_unix,
916        nonce,
917    };
918    let (payload, _sig, _pk) = sign_payment_mandate(&fields, signer);
919    Ok(payload)
920}
921
922#[cfg(test)]
923mod tests {
924    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
925
926    use super::*;
927    use crate::approval::{ApprovalSigner, response_payload, verify_signed_response};
928
929    fn intent(signer: &Signer) -> (Vec<u8>, IntentFields<'static>) {
930        let fields = IntentFields {
931            caller: "slack:T1:U9",
932            conversation_id: "conv-1",
933            scope_description: "research report purchases",
934            currency: "0xUSD",
935            max_total_base_units: "1000000",
936            issued_at_unix: 1_000,
937            expires_at_unix: 10_000,
938            nonce: "intent-nonce-1",
939        };
940        let (payload, _sig, _pk) = sign_intent_mandate(&fields, signer);
941        (payload, fields)
942    }
943
944    #[test]
945    fn intent_mandate_round_trips() {
946        let signer = Signer::from_seed(1);
947        let (payload, fields) = intent(&signer);
948        let verified = verify_signed_intent_mandate(&payload).expect("verifies");
949        assert_eq!(verified.caller, fields.caller);
950        assert_eq!(verified.conversation_id, fields.conversation_id);
951        assert_eq!(verified.max_total_base_units, fields.max_total_base_units);
952        assert_eq!(verified.signer_public_key, signer.public_key_bytes());
953    }
954
955    #[test]
956    fn intent_mandate_tampered_amount_fails() {
957        let signer = Signer::from_seed(1);
958        let (payload, _fields) = intent(&signer);
959        let mut v: Value = serde_json::from_slice(&payload).unwrap();
960        v["max_total_base_units"] = Value::String("999999999".to_owned());
961        assert!(verify_signed_intent_mandate(&v.to_string().into_bytes()).is_none());
962    }
963
964    #[test]
965    fn intent_mandate_wrong_kind_rejected() {
966        // A Cart payload must never verify as an Intent, even before the
967        // signature is checked — the literal `kind` tag is the first gate.
968        let signer = Signer::from_seed(1);
969        let cart_fields = CartFields {
970            intent_hash: "deadbeef",
971            caller: "slack:T1:U9",
972            conversation_id: "conv-1",
973            merchant_host: "api.example.com",
974            currency: "0xUSD",
975            amount_base_units: "500000",
976            issued_at_unix: 1_000,
977            expires_at_unix: 5_000,
978            nonce: "cart-nonce-1",
979        };
980        let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
981        assert!(verify_signed_intent_mandate(&cart_payload).is_none());
982    }
983
984    #[test]
985    fn cart_mandate_round_trips_and_chains_by_hash() {
986        let signer = Signer::from_seed(2);
987        let (intent_payload, _fields) = intent(&signer);
988        let intent_hash = mandate_hash(&intent_payload);
989
990        let cart_fields = CartFields {
991            intent_hash: &intent_hash,
992            caller: "slack:T1:U9",
993            conversation_id: "conv-1",
994            merchant_host: "api.example.com",
995            currency: "0xUSD",
996            amount_base_units: "500000",
997            issued_at_unix: 1_000,
998            expires_at_unix: 5_000,
999            nonce: "cart-nonce-1",
1000        };
1001        let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1002        let verified = verify_signed_cart_mandate(&cart_payload).expect("cart verifies");
1003        assert_eq!(verified.intent_hash, intent_hash);
1004        assert_eq!(verified.merchant_host, "api.example.com");
1005    }
1006
1007    #[test]
1008    fn cart_mandate_tampered_intent_hash_fails() {
1009        let signer = Signer::from_seed(2);
1010        let (intent_payload, _fields) = intent(&signer);
1011        let intent_hash = mandate_hash(&intent_payload);
1012        let cart_fields = CartFields {
1013            intent_hash: &intent_hash,
1014            caller: "slack:T1:U9",
1015            conversation_id: "conv-1",
1016            merchant_host: "api.example.com",
1017            currency: "0xUSD",
1018            amount_base_units: "500000",
1019            issued_at_unix: 1_000,
1020            expires_at_unix: 5_000,
1021            nonce: "cart-nonce-1",
1022        };
1023        let (payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1024        let mut v: Value = serde_json::from_slice(&payload).unwrap();
1025        v["intent_hash"] = Value::String("0".repeat(64));
1026        assert!(verify_signed_cart_mandate(&v.to_string().into_bytes()).is_none());
1027    }
1028
1029    #[test]
1030    fn payment_mandate_round_trips_and_chains_by_hash() {
1031        let signer = Signer::from_seed(3);
1032        let (intent_payload, _fields) = intent(&signer);
1033        let intent_hash = mandate_hash(&intent_payload);
1034        let cart_fields = CartFields {
1035            intent_hash: &intent_hash,
1036            caller: "slack:T1:U9",
1037            conversation_id: "conv-1",
1038            merchant_host: "api.example.com",
1039            currency: "0xUSD",
1040            amount_base_units: "500000",
1041            issued_at_unix: 1_000,
1042            expires_at_unix: 5_000,
1043            nonce: "cart-nonce-1",
1044        };
1045        let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1046        let cart_hash = mandate_hash(&cart_payload);
1047
1048        let payment_fields = PaymentFields {
1049            cart_hash: &cart_hash,
1050            caller: "slack:T1:U9",
1051            conversation_id: "conv-1",
1052            args_json: r#"{"url":"https://api.example.com/report"}"#,
1053            currency: "0xUSD",
1054            amount_base_units: "250000",
1055            issued_at_unix: 1_000,
1056            expires_at_unix: 4_000,
1057            nonce: "payment-nonce-1",
1058        };
1059        let (payment_payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
1060        let verified = verify_signed_payment_mandate(&payment_payload).expect("payment verifies");
1061        assert_eq!(verified.cart_hash, cart_hash);
1062        assert_eq!(verified.amount_base_units, "250000");
1063        assert_eq!(verified.args_json, payment_fields.args_json);
1064    }
1065
1066    #[test]
1067    fn payment_mandate_tampered_args_json_fails() {
1068        let signer = Signer::from_seed(3);
1069        let payment_fields = PaymentFields {
1070            cart_hash: "deadbeef",
1071            caller: "slack:T1:U9",
1072            conversation_id: "conv-1",
1073            args_json: r#"{"url":"https://api.example.com/report"}"#,
1074            currency: "0xUSD",
1075            amount_base_units: "250000",
1076            issued_at_unix: 1_000,
1077            expires_at_unix: 4_000,
1078            nonce: "payment-nonce-1",
1079        };
1080        let (payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
1081        let mut v: Value = serde_json::from_slice(&payload).unwrap();
1082        v["args_json"] = Value::String(r#"{"url":"https://evil.example.com/steal"}"#.to_owned());
1083        assert!(verify_signed_payment_mandate(&v.to_string().into_bytes()).is_none());
1084    }
1085
1086    #[test]
1087    fn mandate_hash_is_stable_and_sensitive_to_signature() {
1088        let signer_a = Signer::from_seed(9);
1089        let signer_b = Signer::from_seed(10);
1090        let fields = IntentFields {
1091            caller: "slack:T1:U9",
1092            conversation_id: "conv-1",
1093            scope_description: "x",
1094            currency: "0xUSD",
1095            max_total_base_units: "1000",
1096            issued_at_unix: 1,
1097            expires_at_unix: 2,
1098            nonce: "n",
1099        };
1100        let (payload_a, _s, _p) = sign_intent_mandate(&fields, &signer_a);
1101        let (payload_b, _s, _p) = sign_intent_mandate(&fields, &signer_b);
1102        // Same fields, deterministic re-hash.
1103        assert_eq!(mandate_hash(&payload_a), mandate_hash(&payload_a));
1104        // Different signer over IDENTICAL fields ⇒ a different signature ⇒ a
1105        // different hash, since the hash commits to the full signed artifact.
1106        assert_ne!(mandate_hash(&payload_a), mandate_hash(&payload_b));
1107    }
1108
1109    #[test]
1110    fn garbage_payload_returns_none_not_panic() {
1111        assert!(verify_signed_intent_mandate(b"not json").is_none());
1112        assert!(verify_signed_cart_mandate(b"{}").is_none());
1113        assert!(verify_signed_payment_mandate(b"[]").is_none());
1114    }
1115
1116    const CONV: &str = "conv-1";
1117    const CALLER: &str = "slack:T1:U9";
1118    const HOST: &str = "api.example.com";
1119    const USD: &str = "0x20c0000000000000000000000000000000000000";
1120    const ARGS: &str = r#"{"url":"https://api.example.com/report"}"#;
1121
1122    fn issuer() -> Signer {
1123        Signer::from_seed(99)
1124    }
1125
1126    fn intent_fields(max_total: &str) -> IntentFields<'_> {
1127        IntentFields {
1128            caller: CALLER,
1129            conversation_id: CONV,
1130            scope_description: "research report purchases",
1131            currency: USD,
1132            max_total_base_units: max_total,
1133            issued_at_unix: 100,
1134            expires_at_unix: 10_000,
1135            nonce: "n-intent",
1136        }
1137    }
1138
1139    /// Sign a mutually consistent, valid chain: Intent ceiling 1_000_000,
1140    /// Cart 500_000 at `HOST`, Payment 500_000 bound to `ARGS`.
1141    fn valid_chain(signer: &Signer) -> MandateChain {
1142        let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), signer);
1143        let intent_hash = mandate_hash(&intent);
1144        let (cart, _s, _p) = sign_cart_mandate(
1145            &CartFields {
1146                intent_hash: &intent_hash,
1147                caller: CALLER,
1148                conversation_id: CONV,
1149                merchant_host: HOST,
1150                currency: USD,
1151                amount_base_units: "500000",
1152                issued_at_unix: 100,
1153                expires_at_unix: 9_000,
1154                nonce: "n-cart",
1155            },
1156            signer,
1157        );
1158        let cart_hash = mandate_hash(&cart);
1159        let (payment, _s, _p) = sign_payment_mandate(
1160            &PaymentFields {
1161                cart_hash: &cart_hash,
1162                caller: CALLER,
1163                conversation_id: CONV,
1164                args_json: ARGS,
1165                currency: USD,
1166                amount_base_units: "500000",
1167                issued_at_unix: 100,
1168                expires_at_unix: 8_000,
1169                nonce: "n-payment",
1170            },
1171            signer,
1172        );
1173        MandateChain {
1174            intent,
1175            cart,
1176            payment,
1177        }
1178    }
1179
1180    #[test]
1181    fn valid_chain_verifies_and_authorizes_within_bounds() {
1182        let signer = issuer();
1183        let chain = valid_chain(&signer);
1184        let verified = chain
1185            .verify(CONV, &signer.public_key_bytes(), 1_000)
1186            .expect("a mutually consistent, unexpired chain must verify");
1187        assert_eq!(verified.authorized_amount_base_units, 500_000);
1188        assert_eq!(verified.merchant_host, HOST);
1189        assert_eq!(verified.caller, CALLER);
1190
1191        // At and under the ceiling, right host, exact args: authorized.
1192        verified.authorize(500_000, HOST, ARGS).expect("at-cap ok");
1193        verified.authorize(1, HOST, ARGS).expect("under-cap ok");
1194        // Host matching is case-insensitive (hosts are DNS names).
1195        verified
1196            .authorize(500_000, "API.Example.COM", ARGS)
1197            .expect("case-insensitive host");
1198
1199        // Over the authorized amount: refused.
1200        assert!(matches!(
1201            verified.authorize(500_001, HOST, ARGS),
1202            Err(MandateError::ExceedsAuthorization { .. })
1203        ));
1204        // Wrong destination: refused even for one base unit.
1205        assert!(matches!(
1206            verified.authorize(1, "evil.example.com", ARGS),
1207            Err(MandateError::ExceedsAuthorization { .. })
1208        ));
1209        // Different call args: refused — a mandate minted for one call
1210        // cannot authorize a different one.
1211        assert!(matches!(
1212            verified.authorize(1, HOST, r#"{"url":"https://api.example.com/OTHER"}"#),
1213            Err(MandateError::ArgsBindingMismatch)
1214        ));
1215    }
1216
1217    #[test]
1218    fn args_binding_matches_by_value_not_key_order() {
1219        // Mirrors the approval binding's canon_args behavior: same
1220        // key/value pairs in a different order still authorize; the same
1221        // loop that bit the HITL gate must not bite mandates.
1222        let signer = issuer();
1223        let (intent, _s, _p) = sign_intent_mandate(&intent_fields(""), &signer);
1224        let intent_hash = mandate_hash(&intent);
1225        let (cart, _s, _p) = sign_cart_mandate(
1226            &CartFields {
1227                intent_hash: &intent_hash,
1228                caller: CALLER,
1229                conversation_id: CONV,
1230                merchant_host: HOST,
1231                currency: USD,
1232                amount_base_units: "500000",
1233                issued_at_unix: 100,
1234                expires_at_unix: 9_000,
1235                nonce: "n-cart",
1236            },
1237            &signer,
1238        );
1239        let (payment, _s, _p) = sign_payment_mandate(
1240            &PaymentFields {
1241                cart_hash: &mandate_hash(&cart),
1242                caller: CALLER,
1243                conversation_id: CONV,
1244                args_json: r#"{"max_spend":"0.10","url":"https://api.example.com/r"}"#,
1245                currency: USD,
1246                amount_base_units: "500000",
1247                issued_at_unix: 100,
1248                expires_at_unix: 8_000,
1249                nonce: "n-payment",
1250            },
1251            &signer,
1252        );
1253        let verified = MandateChain {
1254            intent,
1255            cart,
1256            payment,
1257        }
1258        .verify(CONV, &signer.public_key_bytes(), 1_000)
1259        .expect("chain verifies");
1260        verified
1261            .authorize(
1262                1,
1263                HOST,
1264                r#"{"url":"https://api.example.com/r","max_spend":"0.10"}"#,
1265            )
1266            .expect("reordered-but-equal args must pass the binding");
1267    }
1268
1269    #[test]
1270    fn tampered_link_fails_signature_verification() {
1271        let signer = issuer();
1272        let mut chain = valid_chain(&signer);
1273        // Flip the cart's amount in place: the signature no longer covers
1274        // the bytes, so the whole chain is refused as InvalidCart.
1275        let mut v: serde_json::Value = serde_json::from_slice(&chain.cart).unwrap();
1276        v["amount_base_units"] = serde_json::Value::String("999999999".to_owned());
1277        chain.cart = v.to_string().into_bytes();
1278        assert_eq!(
1279            chain
1280                .verify(CONV, &signer.public_key_bytes(), 1_000)
1281                .unwrap_err(),
1282            MandateError::InvalidCart
1283        );
1284    }
1285
1286    #[test]
1287    fn validly_signed_but_unchained_links_are_rejected() {
1288        // Every link validly signed by the trusted issuer — but the cart
1289        // references a DIFFERENT intent. The chain-hash check itself must
1290        // refuse (the attack a signature check alone cannot catch).
1291        let signer = issuer();
1292        let chain = valid_chain(&signer);
1293        let (other_intent, _s, _p) = sign_intent_mandate(&intent_fields("2000000"), &signer);
1294        let other_hash = mandate_hash(&other_intent);
1295        let (unchained_cart, _s, _p) = sign_cart_mandate(
1296            &CartFields {
1297                intent_hash: &other_hash, // not the presented intent
1298                caller: CALLER,
1299                conversation_id: CONV,
1300                merchant_host: HOST,
1301                currency: USD,
1302                amount_base_units: "500000",
1303                issued_at_unix: 100,
1304                expires_at_unix: 9_000,
1305                nonce: "n-cart",
1306            },
1307            &signer,
1308        );
1309        let broken = MandateChain {
1310            intent: chain.intent.clone(),
1311            cart: unchained_cart.clone(),
1312            payment: chain.payment.clone(),
1313        };
1314        assert_eq!(
1315            broken
1316                .verify(CONV, &signer.public_key_bytes(), 1_000)
1317                .unwrap_err(),
1318            MandateError::CartNotChainedToIntent
1319        );
1320
1321        // Same for the payment link: correctly chained cart, but a payment
1322        // referencing a different cart.
1323        let (payment_for_other, _s, _p) = sign_payment_mandate(
1324            &PaymentFields {
1325                cart_hash: &mandate_hash(&unchained_cart),
1326                caller: CALLER,
1327                conversation_id: CONV,
1328                args_json: ARGS,
1329                currency: USD,
1330                amount_base_units: "500000",
1331                issued_at_unix: 100,
1332                expires_at_unix: 8_000,
1333                nonce: "n-payment",
1334            },
1335            &signer,
1336        );
1337        let broken = MandateChain {
1338            intent: chain.intent,
1339            cart: chain.cart,
1340            payment: payment_for_other,
1341        };
1342        assert_eq!(
1343            broken
1344                .verify(CONV, &signer.public_key_bytes(), 1_000)
1345                .unwrap_err(),
1346            MandateError::PaymentNotChainedToCart
1347        );
1348    }
1349
1350    #[test]
1351    fn widened_amounts_are_rejected() {
1352        let signer = issuer();
1353        // Cart widens past the intent ceiling.
1354        let (intent, _s, _p) = sign_intent_mandate(&intent_fields("100"), &signer);
1355        let intent_hash = mandate_hash(&intent);
1356        let (cart, _s, _p) = sign_cart_mandate(
1357            &CartFields {
1358                intent_hash: &intent_hash,
1359                caller: CALLER,
1360                conversation_id: CONV,
1361                merchant_host: HOST,
1362                currency: USD,
1363                amount_base_units: "999", // > the 100 ceiling
1364                issued_at_unix: 100,
1365                expires_at_unix: 9_000,
1366                nonce: "n-cart",
1367            },
1368            &signer,
1369        );
1370        let (payment, _s, _p) = sign_payment_mandate(
1371            &PaymentFields {
1372                cart_hash: &mandate_hash(&cart),
1373                caller: CALLER,
1374                conversation_id: CONV,
1375                args_json: ARGS,
1376                currency: USD,
1377                amount_base_units: "999",
1378                issued_at_unix: 100,
1379                expires_at_unix: 8_000,
1380                nonce: "n-payment",
1381            },
1382            &signer,
1383        );
1384        let chain = MandateChain {
1385            intent,
1386            cart,
1387            payment,
1388        };
1389        assert_eq!(
1390            chain
1391                .verify(CONV, &signer.public_key_bytes(), 1_000)
1392                .unwrap_err(),
1393            MandateError::AmountWidensAtCart {
1394                cart: 999,
1395                intent: 100
1396            }
1397        );
1398
1399        // Payment widens past the cart.
1400        let base = valid_chain(&signer);
1401        let (over_payment, _s, _p) = sign_payment_mandate(
1402            &PaymentFields {
1403                cart_hash: &mandate_hash(&base.cart),
1404                caller: CALLER,
1405                conversation_id: CONV,
1406                args_json: ARGS,
1407                currency: USD,
1408                amount_base_units: "500001", // cart authorized 500000
1409                issued_at_unix: 100,
1410                expires_at_unix: 8_000,
1411                nonce: "n-payment",
1412            },
1413            &signer,
1414        );
1415        let chain = MandateChain {
1416            intent: base.intent,
1417            cart: base.cart,
1418            payment: over_payment,
1419        };
1420        assert_eq!(
1421            chain
1422                .verify(CONV, &signer.public_key_bytes(), 1_000)
1423                .unwrap_err(),
1424            MandateError::AmountWidensAtPayment {
1425                payment: 500_001,
1426                cart: 500_000
1427            }
1428        );
1429    }
1430
1431    #[test]
1432    fn unbounded_intent_ceiling_admits_any_cart_amount() {
1433        // An Intent with an EMPTY ceiling is unbounded by design (the
1434        // cart's own signed amount still bounds spend).
1435        let signer = issuer();
1436        let (intent, _s, _p) = sign_intent_mandate(&intent_fields(""), &signer);
1437        let intent_hash = mandate_hash(&intent);
1438        let (cart, _s, _p) = sign_cart_mandate(
1439            &CartFields {
1440                intent_hash: &intent_hash,
1441                caller: CALLER,
1442                conversation_id: CONV,
1443                merchant_host: HOST,
1444                currency: USD,
1445                amount_base_units: "123456789",
1446                issued_at_unix: 100,
1447                expires_at_unix: 9_000,
1448                nonce: "n-cart",
1449            },
1450            &signer,
1451        );
1452        let (payment, _s, _p) = sign_payment_mandate(
1453            &PaymentFields {
1454                cart_hash: &mandate_hash(&cart),
1455                caller: CALLER,
1456                conversation_id: CONV,
1457                args_json: ARGS,
1458                currency: USD,
1459                amount_base_units: "123456789",
1460                issued_at_unix: 100,
1461                expires_at_unix: 8_000,
1462                nonce: "n-payment",
1463            },
1464            &signer,
1465        );
1466        let chain = MandateChain {
1467            intent,
1468            cart,
1469            payment,
1470        };
1471        let verified = chain
1472            .verify(CONV, &signer.public_key_bytes(), 1_000)
1473            .expect("empty intent ceiling is unbounded");
1474        assert_eq!(verified.authorized_amount_base_units, 123_456_789);
1475    }
1476
1477    #[test]
1478    fn expired_link_is_rejected() {
1479        let signer = issuer();
1480        let chain = valid_chain(&signer);
1481        // The payment expires first (8_000). At exactly its expiry (`<=` is
1482        // expired) the chain refuses naming the payment link.
1483        assert_eq!(
1484            chain
1485                .verify(CONV, &signer.public_key_bytes(), 8_000)
1486                .unwrap_err(),
1487            MandateError::Expired {
1488                kind: "payment",
1489                expires_at_unix: 8_000
1490            }
1491        );
1492        // Once EVERY link has lapsed, the root (checked first) is named.
1493        assert_eq!(
1494            chain
1495                .verify(CONV, &signer.public_key_bytes(), 50_000)
1496                .unwrap_err(),
1497            MandateError::Expired {
1498                kind: "intent",
1499                expires_at_unix: 10_000
1500            }
1501        );
1502        // Comfortably before every expiry: verifies.
1503        assert!(chain.verify(CONV, &signer.public_key_bytes(), 1).is_ok());
1504    }
1505
1506    #[test]
1507    fn untrusted_issuer_is_rejected() {
1508        let signer = issuer();
1509        let chain = valid_chain(&signer);
1510        let wrong_key = Signer::from_seed(1).public_key_bytes();
1511        assert_eq!(
1512            chain.verify(CONV, &wrong_key, 1_000).unwrap_err(),
1513            MandateError::UntrustedSigner("intent")
1514        );
1515    }
1516
1517    #[test]
1518    fn conversation_and_caller_bindings_are_enforced() {
1519        let signer = issuer();
1520        let chain = valid_chain(&signer);
1521        // Presented in a different conversation than every link binds.
1522        assert_eq!(
1523            chain
1524                .verify("conv-OTHER", &signer.public_key_bytes(), 1_000)
1525                .unwrap_err(),
1526            MandateError::ConversationMismatch("intent")
1527        );
1528
1529        // A validly signed payment re-scoped to a different caller —
1530        // chained correctly, amounts fine — must still refuse.
1531        let (payment, _s, _p) = sign_payment_mandate(
1532            &PaymentFields {
1533                cart_hash: &mandate_hash(&chain.cart),
1534                caller: "slack:T1:UATTACKER",
1535                conversation_id: CONV,
1536                args_json: ARGS,
1537                currency: USD,
1538                amount_base_units: "500000",
1539                issued_at_unix: 100,
1540                expires_at_unix: 8_000,
1541                nonce: "n-payment",
1542            },
1543            &signer,
1544        );
1545        let cross_caller = MandateChain {
1546            intent: chain.intent,
1547            cart: chain.cart,
1548            payment,
1549        };
1550        assert_eq!(
1551            cross_caller
1552                .verify(CONV, &signer.public_key_bytes(), 1_000)
1553                .unwrap_err(),
1554            MandateError::CallerMismatch
1555        );
1556    }
1557
1558    #[test]
1559    fn currency_mismatch_is_rejected() {
1560        let signer = issuer();
1561        let chain = valid_chain(&signer);
1562        let (payment, _s, _p) = sign_payment_mandate(
1563            &PaymentFields {
1564                cart_hash: &mandate_hash(&chain.cart),
1565                caller: CALLER,
1566                conversation_id: CONV,
1567                args_json: ARGS,
1568                currency: "0xOTHER",
1569                amount_base_units: "500000",
1570                issued_at_unix: 100,
1571                expires_at_unix: 8_000,
1572                nonce: "n-payment",
1573            },
1574            &signer,
1575        );
1576        let cross_currency = MandateChain {
1577            intent: chain.intent,
1578            cart: chain.cart,
1579            payment,
1580        };
1581        assert_eq!(
1582            cross_currency
1583                .verify(CONV, &signer.public_key_bytes(), 1_000)
1584                .unwrap_err(),
1585            MandateError::CurrencyMismatch
1586        );
1587    }
1588
1589    #[test]
1590    fn resolve_is_a_noop_when_unconfigured_or_absent() {
1591        let signer = issuer();
1592        let chain = valid_chain(&signer);
1593        let key = signer.public_key_bytes();
1594
1595        // No chain presented ⇒ Ok(None) regardless of issuer config —
1596        // today's HITL + caps path, unchanged.
1597        assert!(matches!(resolve(None, Some(&key), CONV, 1_000), Ok(None)));
1598        assert!(matches!(resolve(None, None, CONV, 1_000), Ok(None)));
1599
1600        // Chain presented but NO issuer key configured (the off-by-default
1601        // feature gate): still Ok(None) — zero behavior change when
1602        // unconfigured, even with mandate bytes on the wire.
1603        assert!(matches!(resolve(Some(&chain), None, CONV, 1_000), Ok(None)));
1604
1605        // Both present and valid ⇒ engaged.
1606        assert!(matches!(
1607            resolve(Some(&chain), Some(&key), CONV, 1_000),
1608            Ok(Some(_))
1609        ));
1610    }
1611
1612    #[test]
1613    fn resolve_fails_closed_on_an_invalid_presented_chain() {
1614        // Presented + configured but expired ⇒ Err, never silently ignored.
1615        let signer = issuer();
1616        let chain = valid_chain(&signer);
1617        let key = signer.public_key_bytes();
1618        assert!(matches!(
1619            resolve(Some(&chain), Some(&key), CONV, 50_000).unwrap_err(),
1620            MandateError::Expired { .. }
1621        ));
1622    }
1623
1624    /// Sign a HITL `approval_response` exactly as the control plane does
1625    /// and verify it back into the [`VerifiedResponse`] the bridge takes.
1626    fn hitl_approval(approved: bool, args_json: &str) -> VerifiedResponse {
1627        let approval_signer = ApprovalSigner::from_seed(7);
1628        let (payload, _sig, _pk) = response_payload(
1629            "req-1",
1630            "paid_fetch",
1631            args_json,
1632            "",
1633            approved,
1634            false,
1635            &[],
1636            CALLER,
1637            "",
1638            "workspace-write",
1639            if approved { "looks fine" } else { "no" },
1640            "",
1641            CONV,
1642            "approval-nonce-1",
1643            "",
1644            &approval_signer,
1645        );
1646        verify_signed_response(&payload).expect("approval signature verifies")
1647    }
1648
1649    /// A signed intent + cart prefix for the HITL bridge tests.
1650    fn chain_prefix(signer: &Signer) -> (Vec<u8>, Vec<u8>, String) {
1651        let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), signer);
1652        let intent_hash = mandate_hash(&intent);
1653        let (cart, _s, _p) = sign_cart_mandate(
1654            &CartFields {
1655                intent_hash: &intent_hash,
1656                caller: CALLER,
1657                conversation_id: CONV,
1658                merchant_host: HOST,
1659                currency: USD,
1660                amount_base_units: "500000",
1661                issued_at_unix: 100,
1662                expires_at_unix: 9_000,
1663                nonce: "n-cart",
1664            },
1665            signer,
1666        );
1667        (intent, cart, intent_hash)
1668    }
1669
1670    #[test]
1671    fn hitl_approval_mints_a_payment_mandate_that_completes_the_chain() {
1672        let signer = issuer();
1673        let (intent, cart, _ih) = chain_prefix(&signer);
1674
1675        // The human approves the exact paid_fetch call via Slack/Telegram;
1676        // the bridge formalizes that decision as a signed Payment mandate.
1677        let approved = hitl_approval(true, ARGS);
1678        let payment = payment_mandate_from_approval(
1679            &approved,
1680            "req-1",
1681            "paid_fetch",
1682            ARGS,
1683            &cart,
1684            200,
1685            8_000,
1686            "n-payment",
1687            &signer,
1688        )
1689        .expect("the approval authorizes this exact call");
1690
1691        // The minted mandate COMPLETES a chain that verifies end-to-end and
1692        // authorizes exactly the approved call at the cart's amount/host.
1693        let chain = MandateChain {
1694            intent,
1695            cart,
1696            payment,
1697        };
1698        let verified = chain
1699            .verify(CONV, &signer.public_key_bytes(), 1_000)
1700            .expect("the minted payment chains to the cart");
1701        assert_eq!(verified.authorized_amount_base_units, 500_000);
1702        assert_eq!(verified.caller, CALLER);
1703        verified
1704            .authorize(500_000, HOST, ARGS)
1705            .expect("authorizes the approved call");
1706        assert!(matches!(
1707            verified.authorize(1, HOST, r#"{"url":"https://evil.example.com/x"}"#),
1708            Err(MandateError::ArgsBindingMismatch)
1709        ));
1710    }
1711
1712    #[test]
1713    fn hitl_bridge_refuses_denials_and_mismatched_scopes() {
1714        let signer = issuer();
1715        let (_intent, cart, intent_hash) = chain_prefix(&signer);
1716
1717        // A denial mints nothing.
1718        let denied = hitl_approval(false, ARGS);
1719        assert_eq!(
1720            payment_mandate_from_approval(
1721                &denied,
1722                "req-1",
1723                "paid_fetch",
1724                ARGS,
1725                &cart,
1726                200,
1727                8_000,
1728                "n",
1729                &signer
1730            )
1731            .unwrap_err(),
1732            MandateError::ApprovalDoesNotAuthorizeCall
1733        );
1734
1735        // An approval for DIFFERENT args mints nothing for this call.
1736        let approved = hitl_approval(true, ARGS);
1737        assert_eq!(
1738            payment_mandate_from_approval(
1739                &approved,
1740                "req-1",
1741                "paid_fetch",
1742                r#"{"url":"https://evil.example.com/x"}"#,
1743                &cart,
1744                200,
1745                8_000,
1746                "n",
1747                &signer
1748            )
1749            .unwrap_err(),
1750            MandateError::ApprovalDoesNotAuthorizeCall
1751        );
1752
1753        // A cart scoped to a DIFFERENT conversation than the approval
1754        // cannot be completed under it.
1755        let (foreign_cart, _s, _p) = sign_cart_mandate(
1756            &CartFields {
1757                intent_hash: &intent_hash,
1758                caller: CALLER,
1759                conversation_id: "conv-OTHER",
1760                merchant_host: HOST,
1761                currency: USD,
1762                amount_base_units: "500000",
1763                issued_at_unix: 100,
1764                expires_at_unix: 9_000,
1765                nonce: "n-cart",
1766            },
1767            &signer,
1768        );
1769        assert_eq!(
1770            payment_mandate_from_approval(
1771                &approved,
1772                "req-1",
1773                "paid_fetch",
1774                ARGS,
1775                &foreign_cart,
1776                200,
1777                8_000,
1778                "n",
1779                &signer
1780            )
1781            .unwrap_err(),
1782            MandateError::ConversationMismatch("cart")
1783        );
1784
1785        // A cart scoped to a DIFFERENT caller than the approver likewise.
1786        let (foreign_caller_cart, _s, _p) = sign_cart_mandate(
1787            &CartFields {
1788                intent_hash: &intent_hash,
1789                caller: "slack:T1:USOMEONE",
1790                conversation_id: CONV,
1791                merchant_host: HOST,
1792                currency: USD,
1793                amount_base_units: "500000",
1794                issued_at_unix: 100,
1795                expires_at_unix: 9_000,
1796                nonce: "n-cart",
1797            },
1798            &signer,
1799        );
1800        assert_eq!(
1801            payment_mandate_from_approval(
1802                &approved,
1803                "req-1",
1804                "paid_fetch",
1805                ARGS,
1806                &foreign_caller_cart,
1807                200,
1808                8_000,
1809                "n",
1810                &signer
1811            )
1812            .unwrap_err(),
1813            MandateError::CallerMismatch
1814        );
1815    }
1816}
1817
1818#[cfg(test)]
1819mod canonical_freeze {
1820    //! Every signed canonical in this module, frozen as literal bytes (`#1845`).
1821    //!
1822    //! These are the bytes a deployment has already signed and has sitting in
1823    //! its log. They are checked in, never regenerated: regenerating one is the
1824    //! defect this module exists to catch, because a canonical whose bytes move
1825    //! invalidates every signature ever minted over the old ones.
1826    //!
1827    //! The reason they can be single literals at all is the conversion `#1845`
1828    //! made, following `#1842`. Before it, each canonical was a
1829    //! [`serde_json::Value`], whose object is a `BTreeMap` (keys sorted) by
1830    //! default and an `IndexMap` (insertion order) whenever anything in the
1831    //! build graph enables `serde_json/preserve_order` — so the same payload
1832    //! signed by two binaries with different dependency sets produced different
1833    //! bytes and different signatures. Every literal below is the
1834    //! insertion-order form, which is what a control-plane binary (where
1835    //! `preserve_order` is unified in) has always signed. Run this module under
1836    //! either selection and every literal holds:
1837    //!
1838    //! ```text
1839    //! cargo nextest run -p polyc-crypto                    # no preserve_order
1840    //! cargo nextest run -p polyc-crypto -p polyc-payments  # preserve_order on
1841    //! ```
1842    //!
1843    //! See ADR 0009 for the decision these literals enforce.
1844    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
1845
1846    use super::*;
1847
1848    /// Assert a canonical's bytes are exactly the frozen literal.
1849    fn frozen(label: &str, got: &[u8], want: &str) {
1850        assert_eq!(
1851            String::from_utf8(got.to_vec()).unwrap(),
1852            want,
1853            "{label}: canonical bytes moved — every signature over the old bytes is now unverifiable"
1854        );
1855    }
1856
1857    const ARGS_JSON: &str = r#"{"url":"https://shop.example/item","max":"25000"}"#;
1858
1859    fn intent() -> IntentFields<'static> {
1860        IntentFields {
1861            caller: "slack:T1:U9",
1862            conversation_id: "conv-1",
1863            scope_description: "groceries for the week",
1864            currency: "0xToken",
1865            max_total_base_units: "100000",
1866            issued_at_unix: 1_750_000_000,
1867            expires_at_unix: 1_750_086_400,
1868            nonce: "nonce-intent",
1869        }
1870    }
1871
1872    fn cart() -> CartFields<'static> {
1873        CartFields {
1874            intent_hash: "abcd1234",
1875            caller: "slack:T1:U9",
1876            conversation_id: "conv-1",
1877            merchant_host: "shop.example",
1878            currency: "0xToken",
1879            amount_base_units: "25000",
1880            issued_at_unix: 1_750_000_000,
1881            expires_at_unix: 1_750_086_400,
1882            nonce: "nonce-cart",
1883        }
1884    }
1885
1886    fn payment() -> PaymentFields<'static> {
1887        PaymentFields {
1888            cart_hash: "beef5678",
1889            caller: "slack:T1:U9",
1890            conversation_id: "conv-1",
1891            args_json: ARGS_JSON,
1892            currency: "0xToken",
1893            amount_base_units: "25000",
1894            issued_at_unix: 1_750_000_000,
1895            expires_at_unix: 1_750_086_400,
1896            nonce: "nonce-payment",
1897        }
1898    }
1899
1900    #[test]
1901    fn intent_mandate_is_frozen() {
1902        frozen(
1903            "IntentFields::canonical_json",
1904            &canonical_bytes(&intent().canonical_json()),
1905            INTENT_CANONICAL,
1906        );
1907        let (full, sig, _) = sign_intent_mandate(&intent(), &Signer::from_seed(99));
1908        frozen("sign_intent_mandate", &full, INTENT_PAYLOAD);
1909        assert_eq!(crate::hex::lower(&sig), INTENT_SIG);
1910    }
1911
1912    #[test]
1913    fn cart_mandate_is_frozen() {
1914        frozen(
1915            "CartFields::canonical_json",
1916            &canonical_bytes(&cart().canonical_json()),
1917            CART_CANONICAL,
1918        );
1919        let (full, sig, _) = sign_cart_mandate(&cart(), &Signer::from_seed(99));
1920        frozen("sign_cart_mandate", &full, CART_PAYLOAD);
1921        assert_eq!(crate::hex::lower(&sig), CART_SIG);
1922    }
1923
1924    #[test]
1925    fn payment_mandate_is_frozen() {
1926        frozen(
1927            "PaymentFields::canonical_json",
1928            &canonical_bytes(&payment().canonical_json()),
1929            PAYMENT_CANONICAL,
1930        );
1931        let (full, sig, _) = sign_payment_mandate(&payment(), &Signer::from_seed(99));
1932        frozen("sign_payment_mandate", &full, PAYMENT_PAYLOAD);
1933        assert_eq!(crate::hex::lower(&sig), PAYMENT_SIG);
1934    }
1935
1936    const INTENT_CANONICAL: &str = r#"{"kind":"ap2.intent.v1","caller":"slack:T1:U9","conversation_id":"conv-1","scope_description":"groceries for the week","currency":"0xToken","max_total_base_units":"100000","issued_at_unix":1750000000,"expires_at_unix":1750086400,"nonce":"nonce-intent"}"#;
1937    const INTENT_PAYLOAD: &str = r#"{"kind":"ap2.intent.v1","caller":"slack:T1:U9","conversation_id":"conv-1","scope_description":"groceries for the week","currency":"0xToken","max_total_base_units":"100000","issued_at_unix":1750000000,"expires_at_unix":1750086400,"nonce":"nonce-intent","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"85089b532b35c7ad7689c4f7f830959c08d7db71a9a3c45eb53db40289445494e125122bf45c4367aab60cb727c30fc5e4fd3fdc43abd6b0e7a0b2d3f2642703"}"#;
1938    const INTENT_SIG: &str = "85089b532b35c7ad7689c4f7f830959c08d7db71a9a3c45eb53db40289445494e125122bf45c4367aab60cb727c30fc5e4fd3fdc43abd6b0e7a0b2d3f2642703";
1939    const CART_CANONICAL: &str = r#"{"kind":"ap2.cart.v1","intent_hash":"abcd1234","caller":"slack:T1:U9","conversation_id":"conv-1","merchant_host":"shop.example","currency":"0xToken","amount_base_units":"25000","issued_at_unix":1750000000,"expires_at_unix":1750086400,"nonce":"nonce-cart"}"#;
1940    const CART_PAYLOAD: &str = r#"{"kind":"ap2.cart.v1","intent_hash":"abcd1234","caller":"slack:T1:U9","conversation_id":"conv-1","merchant_host":"shop.example","currency":"0xToken","amount_base_units":"25000","issued_at_unix":1750000000,"expires_at_unix":1750086400,"nonce":"nonce-cart","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"01c9ed068ef6f7f7724391fb2adffccab3db18e447e2514c7e07a233333534cef73ce2301204ca9f4beafb8907b992d9757e951a827765125a2639926afe2907"}"#;
1941    const CART_SIG: &str = "01c9ed068ef6f7f7724391fb2adffccab3db18e447e2514c7e07a233333534cef73ce2301204ca9f4beafb8907b992d9757e951a827765125a2639926afe2907";
1942    const PAYMENT_CANONICAL: &str = r#"{"kind":"ap2.payment.v1","cart_hash":"beef5678","caller":"slack:T1:U9","conversation_id":"conv-1","args_json":"{\"url\":\"https://shop.example/item\",\"max\":\"25000\"}","currency":"0xToken","amount_base_units":"25000","issued_at_unix":1750000000,"expires_at_unix":1750086400,"nonce":"nonce-payment"}"#;
1943    const PAYMENT_PAYLOAD: &str = r#"{"kind":"ap2.payment.v1","cart_hash":"beef5678","caller":"slack:T1:U9","conversation_id":"conv-1","args_json":"{\"url\":\"https://shop.example/item\",\"max\":\"25000\"}","currency":"0xToken","amount_base_units":"25000","issued_at_unix":1750000000,"expires_at_unix":1750086400,"nonce":"nonce-payment","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"95c1f52b6b4f3433a78884d7f65caafbaa8423a091632ad4b6e04b95966ca71afdf7819a5399c8f8dc1c0eb2c80b3b3eaa4fe59551eb9eb6a4492f36e9e07e06"}"#;
1944    const PAYMENT_SIG: &str = "95c1f52b6b4f3433a78884d7f65caafbaa8423a091632ad4b6e04b95966ca71afdf7819a5399c8f8dc1c0eb2c80b3b3eaa4fe59551eb9eb6a4492f36e9e07e06";
1945}