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 neither the platform issuer key nor (for the
540    /// Intent link, when a persona credential is on record) the persona's
541    /// own recorded signing key.
542    #[error("{0} mandate is not signed by a trusted key")]
543    UntrustedSigner(&'static str),
544    /// A link is bound to a different conversation than the one it is being
545    /// consumed in.
546    #[error("{0} mandate is bound to a different conversation")]
547    ConversationMismatch(&'static str),
548    /// The chain's links are not all bound to one consistent caller.
549    #[error("mandate chain is not bound to one consistent caller")]
550    CallerMismatch,
551    /// A link has expired as of the check time.
552    #[error("{kind} mandate expired at {expires_at_unix}")]
553    Expired {
554        /// Which link expired.
555        kind: &'static str,
556        /// Its signed unix-seconds expiry.
557        expires_at_unix: u64,
558    },
559    /// The Cart's `intent_hash` does not reference the presented Intent.
560    #[error("cart mandate does not chain to the presented intent mandate")]
561    CartNotChainedToIntent,
562    /// The Payment's `cart_hash` does not reference the presented Cart.
563    #[error("payment mandate does not chain to the presented cart mandate")]
564    PaymentNotChainedToCart,
565    /// A signed amount field is not a parseable base-unit integer. Fails
566    /// closed: a mandate whose amount cannot be understood authorizes
567    /// nothing.
568    #[error("{0} mandate carries an unparseable base-unit amount")]
569    MalformedAmount(&'static str),
570    /// The Cart's amount exceeds the Intent's ceiling (widening).
571    #[error("cart amount {cart} exceeds the intent ceiling {intent}")]
572    AmountWidensAtCart {
573        /// The Cart's base-unit amount.
574        cart: u128,
575        /// The Intent's base-unit ceiling.
576        intent: u128,
577    },
578    /// The Payment's amount exceeds the Cart's amount (widening).
579    #[error("payment amount {payment} exceeds the cart amount {cart}")]
580    AmountWidensAtPayment {
581        /// The Payment's base-unit amount.
582        payment: u128,
583        /// The Cart's base-unit amount.
584        cart: u128,
585    },
586    /// Currency differs across the chain.
587    #[error("mandate currency does not match across the chain")]
588    CurrencyMismatch,
589    /// The Cart's merchant scope is empty — a scopeless cart authorizes
590    /// nothing (fail closed).
591    #[error("cart mandate carries no merchant scope")]
592    EmptyScope,
593    /// A verified chain does not cover the proposed spend (amount or
594    /// destination).
595    #[error(
596        "mandate authorizes at most {authorized} base units against {authorized_host}; \
597         requested {requested} base units against {requested_host}"
598    )]
599    ExceedsAuthorization {
600        /// The base-unit amount the chain authorizes.
601        authorized: u128,
602        /// The merchant host the chain authorizes spend against.
603        authorized_host: String,
604        /// The requested base-unit amount.
605        requested: u128,
606        /// The host the request targets.
607        requested_host: String,
608    },
609    /// The call's args do not match the exact `args_json` the Payment
610    /// mandate signed — a mandate minted for one call cannot authorize a
611    /// different one.
612    #[error("the payment mandate is bound to a different tool call's args")]
613    ArgsBindingMismatch,
614    /// The verified HITL approval offered as the trust root does not
615    /// authorize the exact call the Payment mandate is being minted for.
616    #[error("the HITL approval does not authorize this exact call; no mandate was minted")]
617    ApprovalDoesNotAuthorizeCall,
618}
619
620/// The three signed, wire-form mandate payloads presented together as one
621/// pre-authorization.
622#[derive(Debug, Clone, Default)]
623pub struct MandateChain {
624    /// Signed Intent mandate payload ([`sign_intent_mandate`] output).
625    pub intent: Vec<u8>,
626    /// Signed Cart mandate payload.
627    pub cart: Vec<u8>,
628    /// Signed Payment mandate payload.
629    pub payment: Vec<u8>,
630}
631
632/// How long a [`PersonaSignerTrust`] revocation check stays trustworthy
633/// before [`MandateChain::verify`] refuses to rely on it. Bounds the window
634/// in which a credential revoked *after* the caller's lookup ran but
635/// *before* this mandate is verified would otherwise go unnoticed — a stale
636/// cache or a revocation racing an in-flight resolution cannot be fresher
637/// than this ceiling and still be trusted.
638const PERSONA_TRUST_MAX_AGE_SECS: u64 = 300;
639
640/// Evidence that a persona's signing key is safe for [`MandateChain::verify`]
641/// to trust for the Intent link, offered as an alternative to the platform
642/// issuer key.
643///
644/// This is deliberately not a bare key: it is the caller's proof that a
645/// `PersonaCredential` lookup ran, found the credential **not revoked**, and
646/// did so recently enough (within a bounded freshness window) to still hold
647/// "as of now". [`MandateChain::verify`] checks `revoked` and
648/// `checked_at_unix` itself, rather than trusting a caller's
649/// documentation-only promise that a revocation check already happened. A
650/// revoked or stale [`PersonaSignerTrust`] is treated exactly like passing
651/// `None`: the Intent link falls back to the platform issuer key alone.
652/// This closes the gap
653/// where a future lookup bug — a stale cache, a revocation event racing an
654/// in-flight resolution, a query that omits a `revoked_at` filter — could
655/// otherwise hand this function a since-revoked key indistinguishable from
656/// an active one.
657#[derive(Debug, Clone, Copy)]
658pub struct PersonaSignerTrust<'a> {
659    /// The persona's derived ed25519 signing public key
660    /// (`PersonaCredential::signing_public_key`).
661    pub signing_public_key: &'a [u8],
662    /// The credential's own revocation flag (`PersonaCredential::revoked`),
663    /// as observed at `checked_at_unix`.
664    pub revoked: bool,
665    /// Unix time (seconds) the revocation check above was performed.
666    pub checked_at_unix: u64,
667}
668
669impl PersonaSignerTrust<'_> {
670    /// Whether this trust evidence is both non-revoked and fresh as of
671    /// `now_unix` — the single gate [`MandateChain::verify`] applies before
672    /// ever comparing key bytes. Stale evidence (older than
673    /// [`PERSONA_TRUST_MAX_AGE_SECS`]) or evidence dated in the future
674    /// (clock skew, or simply bogus) is untrustworthy, same as `revoked`.
675    const fn is_trustworthy(&self, now_unix: u64) -> bool {
676        !self.revoked
677            && self.checked_at_unix <= now_unix
678            && now_unix.saturating_sub(self.checked_at_unix) <= PERSONA_TRUST_MAX_AGE_SECS
679    }
680}
681
682impl MandateChain {
683    /// Validate the whole chain: every link is bound to `conversation_id`
684    /// and one consistent caller, the Payment chains (by [`mandate_hash`])
685    /// to the Cart and the Cart to the Intent, amounts narrow (never widen)
686    /// down the chain, currency agrees, the Cart carries a non-empty
687    /// merchant scope, and no link has expired as of `now_unix`.
688    ///
689    /// Per-link signer trust is resolved separately for each link rather
690    /// than against one shared key. The Cart and Payment links ALWAYS
691    /// verify against `issuer_public_key` (the platform mandate-issuing
692    /// key) — narrowing a human's authorization down to a merchant and
693    /// amount is never something a browser-held key can do on its own, even
694    /// when the Intent above it is user-signed. The Intent link trusts
695    /// EITHER `issuer_public_key` (today's fully-platform-signed path,
696    /// unchanged) OR `persona_signer` — a [`PersonaSignerTrust`] proving the
697    /// persona's own recorded credential is active and was checked recently
698    /// enough, passed as `Some` only when the persona has such a credential
699    /// on record — whichever one actually signed it. This function itself
700    /// re-checks that trust itself (revocation and freshness), not merely
701    /// the caller's say-so. An Intent signed by neither trusted
702    /// key is untrusted. Passing `None`, or `Some` evidence that is revoked
703    /// or stale, reduces to exactly today's behavior: this feature is
704    /// additive and never a hard requirement.
705    ///
706    /// # Errors
707    ///
708    /// One [`MandateError`] per broken invariant — see each variant. Fails
709    /// closed on everything: malformed payloads, unknown signers, chain
710    /// breaks, widened or unparseable amounts, scope/currency drift,
711    /// expiry.
712    pub fn verify(
713        &self,
714        conversation_id: &str,
715        issuer_public_key: &[u8],
716        persona_signer: Option<PersonaSignerTrust<'_>>,
717        now_unix: u64,
718    ) -> Result<VerifiedMandateChain, MandateError> {
719        let intent =
720            verify_signed_intent_mandate(&self.intent).ok_or(MandateError::InvalidIntent)?;
721        let cart = verify_signed_cart_mandate(&self.cart).ok_or(MandateError::InvalidCart)?;
722        let payment =
723            verify_signed_payment_mandate(&self.payment).ok_or(MandateError::InvalidPayment)?;
724
725        // Conversation + expiry checks, root first — unchanged for every
726        // link regardless of which key trusts it.
727        let links: [(&'static str, &str, u64); 3] = [
728            ("intent", &intent.conversation_id, intent.expires_at_unix),
729            ("cart", &cart.conversation_id, cart.expires_at_unix),
730            ("payment", &payment.conversation_id, payment.expires_at_unix),
731        ];
732        for (label, conv, expires_at_unix) in links {
733            if conv != conversation_id {
734                return Err(MandateError::ConversationMismatch(label));
735            }
736            if expires_at_unix <= now_unix {
737                return Err(MandateError::Expired {
738                    kind: label,
739                    expires_at_unix,
740                });
741            }
742        }
743
744        // Per-link signer trust. The Intent link ADDITIONALLY trusts the
745        // persona's own recorded signing key when it is exactly the key
746        // that signed this Intent AND the caller's trust evidence is itself
747        // fresh and non-revoked as of `now_unix` (checked here, not merely
748        // asserted by the caller); the Cart and Payment links ALWAYS trust
749        // only the platform issuer key, even when the Intent above them is
750        // user-signed — see the doc comment above for why.
751        let intent_user_signed = persona_signer.is_some_and(|trust| {
752            trust.is_trustworthy(now_unix) && intent.signer_public_key == trust.signing_public_key
753        });
754        if !intent_user_signed && intent.signer_public_key != issuer_public_key {
755            return Err(MandateError::UntrustedSigner("intent"));
756        }
757        if cart.signer_public_key != issuer_public_key {
758            return Err(MandateError::UntrustedSigner("cart"));
759        }
760        if payment.signer_public_key != issuer_public_key {
761            return Err(MandateError::UntrustedSigner("payment"));
762        }
763        if intent.caller != cart.caller || cart.caller != payment.caller {
764            return Err(MandateError::CallerMismatch);
765        }
766
767        // Chain links: each child signed the sha256 of its parent's FULL
768        // signed payload, so the reference commits to one exact,
769        // already-verified artifact — a re-signed parent (even over
770        // byte-identical fields) breaks the chain.
771        if cart.intent_hash != mandate_hash(&self.intent) {
772            return Err(MandateError::CartNotChainedToIntent);
773        }
774        if payment.cart_hash != mandate_hash(&self.cart) {
775            return Err(MandateError::PaymentNotChainedToCart);
776        }
777
778        // Amounts narrow down the chain. The Intent's ceiling may be empty
779        // (unbounded — the Cart's own signed amount still bounds spend);
780        // the Cart/Payment amounts must parse or the chain authorizes
781        // nothing.
782        let cart_amount: u128 = cart
783            .amount_base_units
784            .parse()
785            .map_err(|_| MandateError::MalformedAmount("cart"))?;
786        let payment_amount: u128 = payment
787            .amount_base_units
788            .parse()
789            .map_err(|_| MandateError::MalformedAmount("payment"))?;
790        if !intent.max_total_base_units.is_empty() {
791            let ceiling: u128 = intent
792                .max_total_base_units
793                .parse()
794                .map_err(|_| MandateError::MalformedAmount("intent"))?;
795            if cart_amount > ceiling {
796                return Err(MandateError::AmountWidensAtCart {
797                    cart: cart_amount,
798                    intent: ceiling,
799                });
800            }
801        }
802        if payment_amount > cart_amount {
803            return Err(MandateError::AmountWidensAtPayment {
804                payment: payment_amount,
805                cart: cart_amount,
806            });
807        }
808
809        if intent.currency != cart.currency || cart.currency != payment.currency {
810            return Err(MandateError::CurrencyMismatch);
811        }
812        if cart.merchant_host.is_empty() {
813            return Err(MandateError::EmptyScope);
814        }
815
816        Ok(VerifiedMandateChain {
817            authorized_amount_base_units: payment_amount,
818            merchant_host: cart.merchant_host.clone(),
819            currency: payment.currency.clone(),
820            caller: payment.caller.clone(),
821            conversation_id: payment.conversation_id.clone(),
822            args_json: payment.args_json.clone(),
823            intent,
824            cart,
825            payment,
826        })
827    }
828}
829
830/// A fully chain-validated mandate: the authoritative pre-authorization for
831/// exactly one payment.
832#[derive(Debug, Clone)]
833pub struct VerifiedMandateChain {
834    /// The maximum this chain authorizes spending — the Payment link's
835    /// exact base-unit amount.
836    pub authorized_amount_base_units: u128,
837    /// The merchant host the chain authorizes spend against (from the
838    /// Cart).
839    pub merchant_host: String,
840    /// The settlement currency the chain is denominated in.
841    pub currency: String,
842    /// The principal the chain is bound to.
843    pub caller: String,
844    /// The conversation the chain is bound to.
845    pub conversation_id: String,
846    /// The exact `args_json` the Payment link authorizes.
847    pub args_json: String,
848    /// The verified Intent link.
849    pub intent: VerifiedIntentMandate,
850    /// The verified Cart link.
851    pub cart: VerifiedCartMandate,
852    /// The verified Payment link.
853    pub payment: VerifiedPaymentMandate,
854}
855
856impl VerifiedMandateChain {
857    /// Authorize a proposed spend: `requested_base_units` against
858    /// `requested_host`, fulfilling the call whose arguments are
859    /// `args_json`.
860    ///
861    /// The pre-sign check the payment proxy calls at its enforcement seam,
862    /// alongside a pre-signing spend cap's own authorize check. Three
863    /// bindings, all fail-closed:
864    ///
865    /// * **destination** — `requested_host` must equal the Cart's merchant
866    ///   scope (ASCII case-insensitively; hosts are DNS names);
867    /// * **call** — `args_json` must value-match the exact args the Payment
868    ///   link signed (canonicalized with the same rules as the approval
869    ///   binding, so key order does not matter but any key/value difference
870    ///   refuses);
871    /// * **amount** — `requested_base_units` must not exceed the Payment
872    ///   link's amount.
873    ///
874    /// # Errors
875    ///
876    /// [`MandateError::ExceedsAuthorization`] on a host or amount breach;
877    /// [`MandateError::ArgsBindingMismatch`] when the call's args are not
878    /// the ones the mandate was minted for.
879    pub fn authorize(
880        &self,
881        requested_base_units: u128,
882        requested_host: &str,
883        args_json: &str,
884    ) -> Result<(), MandateError> {
885        if canon_args(args_json) != canon_args(&self.args_json) {
886            return Err(MandateError::ArgsBindingMismatch);
887        }
888        let host_ok = self.merchant_host.eq_ignore_ascii_case(requested_host);
889        if !host_ok || requested_base_units > self.authorized_amount_base_units {
890            return Err(MandateError::ExceedsAuthorization {
891                authorized: self.authorized_amount_base_units,
892                authorized_host: self.merchant_host.clone(),
893                requested: requested_base_units,
894                requested_host: requested_host.to_owned(),
895            });
896        }
897        Ok(())
898    }
899}
900
901/// The feature-gated pre-authorization resolver the payment proxy calls at
902/// its enforcement seam (`proxy::fulfill`).
903///
904/// Returns `Ok(None)` — a no-op, today's spend-cap-only behavior UNCHANGED —
905/// unless BOTH a chain is presented AND an issuer key is configured
906/// (`TEMPO_MANDATE_ISSUER_PUBKEY`; unset by default, so mandates are off by
907/// default). When both are present the chain must validate end-to-end
908/// ([`MandateChain::verify`]) or the payment is refused — a
909/// presented-but-invalid mandate is never silently ignored.
910///
911/// `persona_signer` is threaded straight through to [`MandateChain::verify`],
912/// which re-checks it (revocation and freshness, not just key equality)
913/// before ever trusting it: `Some` only when the caller has resolved a
914/// `PersonaCredential` for the persona behind `conversation_id`; `None` (no
915/// credential, or the caller didn't resolve one), or `Some` evidence that
916/// turns out revoked or stale, leaves the Intent link on today's
917/// platform-only path.
918///
919/// # Errors
920///
921/// See [`MandateChain::verify`].
922pub fn resolve(
923    chain: Option<&MandateChain>,
924    issuer_public_key: Option<&[u8]>,
925    persona_signer: Option<PersonaSignerTrust<'_>>,
926    conversation_id: &str,
927    now_unix: u64,
928) -> Result<Option<VerifiedMandateChain>, MandateError> {
929    match (chain, issuer_public_key) {
930        (Some(c), Some(key)) => c
931            .verify(conversation_id, key, persona_signer, now_unix)
932            .map(Some),
933        _ => Ok(None),
934    }
935}
936
937/// Formalize an ALREADY-verified Slack/Telegram HITL approval
938/// ([`VerifiedResponse`]) as a signed Payment mandate chained to
939/// `cart_payload`.
940///
941/// This is the HITL→mandate bridge: it mints NO new approval surface and
942/// trusts NOTHING beyond what the existing signed-approval machinery
943/// already verified. A Payment mandate is minted only when:
944///
945/// * `approved` authorizes the EXACT `(request_id, tool_name, args_json)`
946///   tuple being fulfilled ([`VerifiedResponse::authorizes_call`] — the
947///   same binding the proxy's approval gate requires; a denial or an
948///   approval for any other call refuses);
949/// * `cart_payload` is a validly signed Cart mandate whose `caller` and
950///   `conversation_id` equal the approval's own signed values — a cart
951///   scoped to a different principal or conversation than the human who
952///   approved cannot be completed under that approval.
953///
954/// The minted Payment inherits the Cart's amount and currency (an exact
955/// narrowing: it authorizes the whole cart, nothing more), binds the
956/// approved `args_json`, and chains to the Cart by [`mandate_hash`].
957/// Returns the full signed payload bytes, ready to persist or present in a
958/// [`MandateChain`].
959///
960/// # Errors
961///
962/// [`MandateError::ApprovalDoesNotAuthorizeCall`] when the approval does
963/// not cover the exact call; [`MandateError::InvalidCart`] when
964/// `cart_payload` does not verify; [`MandateError::CallerMismatch`] /
965/// [`MandateError::ConversationMismatch`] when the cart is scoped to a
966/// different principal / conversation than the approval.
967#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the minted mandate
968pub fn payment_mandate_from_approval(
969    approved: &VerifiedResponse,
970    request_id: &str,
971    tool_name: &str,
972    args_json: &str,
973    cart_payload: &[u8],
974    issued_at_unix: u64,
975    expires_at_unix: u64,
976    nonce: &str,
977    signer: &Signer,
978) -> Result<Vec<u8>, MandateError> {
979    if !approved.authorizes_call(request_id, tool_name, args_json) {
980        return Err(MandateError::ApprovalDoesNotAuthorizeCall);
981    }
982    let cart = verify_signed_cart_mandate(cart_payload).ok_or(MandateError::InvalidCart)?;
983    if cart.caller != approved.caller {
984        return Err(MandateError::CallerMismatch);
985    }
986    if cart.conversation_id != approved.conversation_id {
987        return Err(MandateError::ConversationMismatch("cart"));
988    }
989    let fields = PaymentFields {
990        cart_hash: &mandate_hash(cart_payload),
991        caller: &approved.caller,
992        conversation_id: &approved.conversation_id,
993        args_json,
994        currency: &cart.currency,
995        amount_base_units: &cart.amount_base_units,
996        issued_at_unix,
997        expires_at_unix,
998        nonce,
999    };
1000    let (payload, _sig, _pk) = sign_payment_mandate(&fields, signer);
1001    Ok(payload)
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
1007
1008    use super::*;
1009    use crate::approval::{ApprovalSigner, response_payload, verify_signed_response};
1010
1011    /// Trust evidence for an active, freshly-checked persona credential —
1012    /// the happy path a caller presents when its `PersonaCredential` lookup
1013    /// found the credential un-revoked just now.
1014    fn active_persona_trust(signing_public_key: &[u8], now_unix: u64) -> PersonaSignerTrust<'_> {
1015        PersonaSignerTrust {
1016            signing_public_key,
1017            revoked: false,
1018            checked_at_unix: now_unix,
1019        }
1020    }
1021
1022    /// Trust evidence for a credential the caller's lookup found revoked —
1023    /// distinct from `None` (no credential resolved at all): this is the
1024    /// shape a since-revoked credential takes when a caller still presents
1025    /// it, and `MandateChain::verify` must refuse it exactly like `None`.
1026    fn revoked_persona_trust(signing_public_key: &[u8], now_unix: u64) -> PersonaSignerTrust<'_> {
1027        PersonaSignerTrust {
1028            signing_public_key,
1029            revoked: true,
1030            checked_at_unix: now_unix,
1031        }
1032    }
1033
1034    fn intent(signer: &Signer) -> (Vec<u8>, IntentFields<'static>) {
1035        let fields = IntentFields {
1036            caller: "slack:T1:U9",
1037            conversation_id: "conv-1",
1038            scope_description: "research report purchases",
1039            currency: "0xUSD",
1040            max_total_base_units: "1000000",
1041            issued_at_unix: 1_000,
1042            expires_at_unix: 10_000,
1043            nonce: "intent-nonce-1",
1044        };
1045        let (payload, _sig, _pk) = sign_intent_mandate(&fields, signer);
1046        (payload, fields)
1047    }
1048
1049    #[test]
1050    fn intent_mandate_round_trips() {
1051        let signer = Signer::from_seed(1);
1052        let (payload, fields) = intent(&signer);
1053        let verified = verify_signed_intent_mandate(&payload).expect("verifies");
1054        assert_eq!(verified.caller, fields.caller);
1055        assert_eq!(verified.conversation_id, fields.conversation_id);
1056        assert_eq!(verified.max_total_base_units, fields.max_total_base_units);
1057        assert_eq!(verified.signer_public_key, signer.public_key_bytes());
1058    }
1059
1060    #[test]
1061    fn intent_mandate_tampered_amount_fails() {
1062        let signer = Signer::from_seed(1);
1063        let (payload, _fields) = intent(&signer);
1064        let mut v: Value = serde_json::from_slice(&payload).unwrap();
1065        v["max_total_base_units"] = Value::String("999999999".to_owned());
1066        assert!(verify_signed_intent_mandate(&v.to_string().into_bytes()).is_none());
1067    }
1068
1069    #[test]
1070    fn intent_mandate_wrong_kind_rejected() {
1071        // A Cart payload must never verify as an Intent, even before the
1072        // signature is checked — the literal `kind` tag is the first gate.
1073        let signer = Signer::from_seed(1);
1074        let cart_fields = CartFields {
1075            intent_hash: "deadbeef",
1076            caller: "slack:T1:U9",
1077            conversation_id: "conv-1",
1078            merchant_host: "api.example.com",
1079            currency: "0xUSD",
1080            amount_base_units: "500000",
1081            issued_at_unix: 1_000,
1082            expires_at_unix: 5_000,
1083            nonce: "cart-nonce-1",
1084        };
1085        let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1086        assert!(verify_signed_intent_mandate(&cart_payload).is_none());
1087    }
1088
1089    #[test]
1090    fn cart_mandate_round_trips_and_chains_by_hash() {
1091        let signer = Signer::from_seed(2);
1092        let (intent_payload, _fields) = intent(&signer);
1093        let intent_hash = mandate_hash(&intent_payload);
1094
1095        let cart_fields = CartFields {
1096            intent_hash: &intent_hash,
1097            caller: "slack:T1:U9",
1098            conversation_id: "conv-1",
1099            merchant_host: "api.example.com",
1100            currency: "0xUSD",
1101            amount_base_units: "500000",
1102            issued_at_unix: 1_000,
1103            expires_at_unix: 5_000,
1104            nonce: "cart-nonce-1",
1105        };
1106        let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1107        let verified = verify_signed_cart_mandate(&cart_payload).expect("cart verifies");
1108        assert_eq!(verified.intent_hash, intent_hash);
1109        assert_eq!(verified.merchant_host, "api.example.com");
1110    }
1111
1112    #[test]
1113    fn cart_mandate_tampered_intent_hash_fails() {
1114        let signer = Signer::from_seed(2);
1115        let (intent_payload, _fields) = intent(&signer);
1116        let intent_hash = mandate_hash(&intent_payload);
1117        let cart_fields = CartFields {
1118            intent_hash: &intent_hash,
1119            caller: "slack:T1:U9",
1120            conversation_id: "conv-1",
1121            merchant_host: "api.example.com",
1122            currency: "0xUSD",
1123            amount_base_units: "500000",
1124            issued_at_unix: 1_000,
1125            expires_at_unix: 5_000,
1126            nonce: "cart-nonce-1",
1127        };
1128        let (payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1129        let mut v: Value = serde_json::from_slice(&payload).unwrap();
1130        v["intent_hash"] = Value::String("0".repeat(64));
1131        assert!(verify_signed_cart_mandate(&v.to_string().into_bytes()).is_none());
1132    }
1133
1134    #[test]
1135    fn payment_mandate_round_trips_and_chains_by_hash() {
1136        let signer = Signer::from_seed(3);
1137        let (intent_payload, _fields) = intent(&signer);
1138        let intent_hash = mandate_hash(&intent_payload);
1139        let cart_fields = CartFields {
1140            intent_hash: &intent_hash,
1141            caller: "slack:T1:U9",
1142            conversation_id: "conv-1",
1143            merchant_host: "api.example.com",
1144            currency: "0xUSD",
1145            amount_base_units: "500000",
1146            issued_at_unix: 1_000,
1147            expires_at_unix: 5_000,
1148            nonce: "cart-nonce-1",
1149        };
1150        let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1151        let cart_hash = mandate_hash(&cart_payload);
1152
1153        let payment_fields = PaymentFields {
1154            cart_hash: &cart_hash,
1155            caller: "slack:T1:U9",
1156            conversation_id: "conv-1",
1157            args_json: r#"{"url":"https://api.example.com/report"}"#,
1158            currency: "0xUSD",
1159            amount_base_units: "250000",
1160            issued_at_unix: 1_000,
1161            expires_at_unix: 4_000,
1162            nonce: "payment-nonce-1",
1163        };
1164        let (payment_payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
1165        let verified = verify_signed_payment_mandate(&payment_payload).expect("payment verifies");
1166        assert_eq!(verified.cart_hash, cart_hash);
1167        assert_eq!(verified.amount_base_units, "250000");
1168        assert_eq!(verified.args_json, payment_fields.args_json);
1169    }
1170
1171    #[test]
1172    fn payment_mandate_tampered_args_json_fails() {
1173        let signer = Signer::from_seed(3);
1174        let payment_fields = PaymentFields {
1175            cart_hash: "deadbeef",
1176            caller: "slack:T1:U9",
1177            conversation_id: "conv-1",
1178            args_json: r#"{"url":"https://api.example.com/report"}"#,
1179            currency: "0xUSD",
1180            amount_base_units: "250000",
1181            issued_at_unix: 1_000,
1182            expires_at_unix: 4_000,
1183            nonce: "payment-nonce-1",
1184        };
1185        let (payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
1186        let mut v: Value = serde_json::from_slice(&payload).unwrap();
1187        v["args_json"] = Value::String(r#"{"url":"https://evil.example.com/steal"}"#.to_owned());
1188        assert!(verify_signed_payment_mandate(&v.to_string().into_bytes()).is_none());
1189    }
1190
1191    #[test]
1192    fn mandate_hash_is_stable_and_sensitive_to_signature() {
1193        let signer_a = Signer::from_seed(9);
1194        let signer_b = Signer::from_seed(10);
1195        let fields = IntentFields {
1196            caller: "slack:T1:U9",
1197            conversation_id: "conv-1",
1198            scope_description: "x",
1199            currency: "0xUSD",
1200            max_total_base_units: "1000",
1201            issued_at_unix: 1,
1202            expires_at_unix: 2,
1203            nonce: "n",
1204        };
1205        let (payload_a, _s, _p) = sign_intent_mandate(&fields, &signer_a);
1206        let (payload_b, _s, _p) = sign_intent_mandate(&fields, &signer_b);
1207        // Same fields, deterministic re-hash.
1208        assert_eq!(mandate_hash(&payload_a), mandate_hash(&payload_a));
1209        // Different signer over IDENTICAL fields ⇒ a different signature ⇒ a
1210        // different hash, since the hash commits to the full signed artifact.
1211        assert_ne!(mandate_hash(&payload_a), mandate_hash(&payload_b));
1212    }
1213
1214    #[test]
1215    fn garbage_payload_returns_none_not_panic() {
1216        assert!(verify_signed_intent_mandate(b"not json").is_none());
1217        assert!(verify_signed_cart_mandate(b"{}").is_none());
1218        assert!(verify_signed_payment_mandate(b"[]").is_none());
1219    }
1220
1221    const CONV: &str = "conv-1";
1222    const CALLER: &str = "slack:T1:U9";
1223    const HOST: &str = "api.example.com";
1224    const USD: &str = "0x20c0000000000000000000000000000000000000";
1225    const ARGS: &str = r#"{"url":"https://api.example.com/report"}"#;
1226
1227    fn issuer() -> Signer {
1228        Signer::from_seed(99)
1229    }
1230
1231    fn intent_fields(max_total: &str) -> IntentFields<'_> {
1232        IntentFields {
1233            caller: CALLER,
1234            conversation_id: CONV,
1235            scope_description: "research report purchases",
1236            currency: USD,
1237            max_total_base_units: max_total,
1238            issued_at_unix: 100,
1239            expires_at_unix: 10_000,
1240            nonce: "n-intent",
1241        }
1242    }
1243
1244    /// Sign a mutually consistent, valid chain: Intent ceiling 1_000_000,
1245    /// Cart 500_000 at `HOST`, Payment 500_000 bound to `ARGS`.
1246    fn valid_chain(signer: &Signer) -> MandateChain {
1247        let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), signer);
1248        let intent_hash = mandate_hash(&intent);
1249        let (cart, _s, _p) = sign_cart_mandate(
1250            &CartFields {
1251                intent_hash: &intent_hash,
1252                caller: CALLER,
1253                conversation_id: CONV,
1254                merchant_host: HOST,
1255                currency: USD,
1256                amount_base_units: "500000",
1257                issued_at_unix: 100,
1258                expires_at_unix: 9_000,
1259                nonce: "n-cart",
1260            },
1261            signer,
1262        );
1263        let cart_hash = mandate_hash(&cart);
1264        let (payment, _s, _p) = sign_payment_mandate(
1265            &PaymentFields {
1266                cart_hash: &cart_hash,
1267                caller: CALLER,
1268                conversation_id: CONV,
1269                args_json: ARGS,
1270                currency: USD,
1271                amount_base_units: "500000",
1272                issued_at_unix: 100,
1273                expires_at_unix: 8_000,
1274                nonce: "n-payment",
1275            },
1276            signer,
1277        );
1278        MandateChain {
1279            intent,
1280            cart,
1281            payment,
1282        }
1283    }
1284
1285    #[test]
1286    fn valid_chain_verifies_and_authorizes_within_bounds() {
1287        let signer = issuer();
1288        let chain = valid_chain(&signer);
1289        let verified = chain
1290            .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1291            .expect("a mutually consistent, unexpired chain must verify");
1292        assert_eq!(verified.authorized_amount_base_units, 500_000);
1293        assert_eq!(verified.merchant_host, HOST);
1294        assert_eq!(verified.caller, CALLER);
1295
1296        // At and under the ceiling, right host, exact args: authorized.
1297        verified.authorize(500_000, HOST, ARGS).expect("at-cap ok");
1298        verified.authorize(1, HOST, ARGS).expect("under-cap ok");
1299        // Host matching is case-insensitive (hosts are DNS names).
1300        verified
1301            .authorize(500_000, "API.Example.COM", ARGS)
1302            .expect("case-insensitive host");
1303
1304        // Over the authorized amount: refused.
1305        assert!(matches!(
1306            verified.authorize(500_001, HOST, ARGS),
1307            Err(MandateError::ExceedsAuthorization { .. })
1308        ));
1309        // Wrong destination: refused even for one base unit.
1310        assert!(matches!(
1311            verified.authorize(1, "evil.example.com", ARGS),
1312            Err(MandateError::ExceedsAuthorization { .. })
1313        ));
1314        // Different call args: refused — a mandate minted for one call
1315        // cannot authorize a different one.
1316        assert!(matches!(
1317            verified.authorize(1, HOST, r#"{"url":"https://api.example.com/OTHER"}"#),
1318            Err(MandateError::ArgsBindingMismatch)
1319        ));
1320    }
1321
1322    #[test]
1323    fn args_binding_matches_by_value_not_key_order() {
1324        // Mirrors the approval binding's canon_args behavior: same
1325        // key/value pairs in a different order still authorize; the same
1326        // loop that bit the HITL gate must not bite mandates.
1327        let signer = issuer();
1328        let (intent, _s, _p) = sign_intent_mandate(&intent_fields(""), &signer);
1329        let intent_hash = mandate_hash(&intent);
1330        let (cart, _s, _p) = sign_cart_mandate(
1331            &CartFields {
1332                intent_hash: &intent_hash,
1333                caller: CALLER,
1334                conversation_id: CONV,
1335                merchant_host: HOST,
1336                currency: USD,
1337                amount_base_units: "500000",
1338                issued_at_unix: 100,
1339                expires_at_unix: 9_000,
1340                nonce: "n-cart",
1341            },
1342            &signer,
1343        );
1344        let (payment, _s, _p) = sign_payment_mandate(
1345            &PaymentFields {
1346                cart_hash: &mandate_hash(&cart),
1347                caller: CALLER,
1348                conversation_id: CONV,
1349                args_json: r#"{"max_spend":"0.10","url":"https://api.example.com/r"}"#,
1350                currency: USD,
1351                amount_base_units: "500000",
1352                issued_at_unix: 100,
1353                expires_at_unix: 8_000,
1354                nonce: "n-payment",
1355            },
1356            &signer,
1357        );
1358        let verified = MandateChain {
1359            intent,
1360            cart,
1361            payment,
1362        }
1363        .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1364        .expect("chain verifies");
1365        verified
1366            .authorize(
1367                1,
1368                HOST,
1369                r#"{"url":"https://api.example.com/r","max_spend":"0.10"}"#,
1370            )
1371            .expect("reordered-but-equal args must pass the binding");
1372    }
1373
1374    #[test]
1375    fn tampered_link_fails_signature_verification() {
1376        let signer = issuer();
1377        let mut chain = valid_chain(&signer);
1378        // Flip the cart's amount in place: the signature no longer covers
1379        // the bytes, so the whole chain is refused as InvalidCart.
1380        let mut v: serde_json::Value = serde_json::from_slice(&chain.cart).unwrap();
1381        v["amount_base_units"] = serde_json::Value::String("999999999".to_owned());
1382        chain.cart = v.to_string().into_bytes();
1383        assert_eq!(
1384            chain
1385                .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1386                .unwrap_err(),
1387            MandateError::InvalidCart
1388        );
1389    }
1390
1391    #[test]
1392    fn validly_signed_but_unchained_links_are_rejected() {
1393        // Every link validly signed by the trusted issuer — but the cart
1394        // references a DIFFERENT intent. The chain-hash check itself must
1395        // refuse (the attack a signature check alone cannot catch).
1396        let signer = issuer();
1397        let chain = valid_chain(&signer);
1398        let (other_intent, _s, _p) = sign_intent_mandate(&intent_fields("2000000"), &signer);
1399        let other_hash = mandate_hash(&other_intent);
1400        let (unchained_cart, _s, _p) = sign_cart_mandate(
1401            &CartFields {
1402                intent_hash: &other_hash, // not the presented intent
1403                caller: CALLER,
1404                conversation_id: CONV,
1405                merchant_host: HOST,
1406                currency: USD,
1407                amount_base_units: "500000",
1408                issued_at_unix: 100,
1409                expires_at_unix: 9_000,
1410                nonce: "n-cart",
1411            },
1412            &signer,
1413        );
1414        let broken = MandateChain {
1415            intent: chain.intent.clone(),
1416            cart: unchained_cart.clone(),
1417            payment: chain.payment.clone(),
1418        };
1419        assert_eq!(
1420            broken
1421                .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1422                .unwrap_err(),
1423            MandateError::CartNotChainedToIntent
1424        );
1425
1426        // Same for the payment link: correctly chained cart, but a payment
1427        // referencing a different cart.
1428        let (payment_for_other, _s, _p) = sign_payment_mandate(
1429            &PaymentFields {
1430                cart_hash: &mandate_hash(&unchained_cart),
1431                caller: CALLER,
1432                conversation_id: CONV,
1433                args_json: ARGS,
1434                currency: USD,
1435                amount_base_units: "500000",
1436                issued_at_unix: 100,
1437                expires_at_unix: 8_000,
1438                nonce: "n-payment",
1439            },
1440            &signer,
1441        );
1442        let broken = MandateChain {
1443            intent: chain.intent,
1444            cart: chain.cart,
1445            payment: payment_for_other,
1446        };
1447        assert_eq!(
1448            broken
1449                .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1450                .unwrap_err(),
1451            MandateError::PaymentNotChainedToCart
1452        );
1453    }
1454
1455    #[test]
1456    fn widened_amounts_are_rejected() {
1457        let signer = issuer();
1458        // Cart widens past the intent ceiling.
1459        let (intent, _s, _p) = sign_intent_mandate(&intent_fields("100"), &signer);
1460        let intent_hash = mandate_hash(&intent);
1461        let (cart, _s, _p) = sign_cart_mandate(
1462            &CartFields {
1463                intent_hash: &intent_hash,
1464                caller: CALLER,
1465                conversation_id: CONV,
1466                merchant_host: HOST,
1467                currency: USD,
1468                amount_base_units: "999", // > the 100 ceiling
1469                issued_at_unix: 100,
1470                expires_at_unix: 9_000,
1471                nonce: "n-cart",
1472            },
1473            &signer,
1474        );
1475        let (payment, _s, _p) = sign_payment_mandate(
1476            &PaymentFields {
1477                cart_hash: &mandate_hash(&cart),
1478                caller: CALLER,
1479                conversation_id: CONV,
1480                args_json: ARGS,
1481                currency: USD,
1482                amount_base_units: "999",
1483                issued_at_unix: 100,
1484                expires_at_unix: 8_000,
1485                nonce: "n-payment",
1486            },
1487            &signer,
1488        );
1489        let chain = MandateChain {
1490            intent,
1491            cart,
1492            payment,
1493        };
1494        assert_eq!(
1495            chain
1496                .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1497                .unwrap_err(),
1498            MandateError::AmountWidensAtCart {
1499                cart: 999,
1500                intent: 100
1501            }
1502        );
1503
1504        // Payment widens past the cart.
1505        let base = valid_chain(&signer);
1506        let (over_payment, _s, _p) = sign_payment_mandate(
1507            &PaymentFields {
1508                cart_hash: &mandate_hash(&base.cart),
1509                caller: CALLER,
1510                conversation_id: CONV,
1511                args_json: ARGS,
1512                currency: USD,
1513                amount_base_units: "500001", // cart authorized 500000
1514                issued_at_unix: 100,
1515                expires_at_unix: 8_000,
1516                nonce: "n-payment",
1517            },
1518            &signer,
1519        );
1520        let chain = MandateChain {
1521            intent: base.intent,
1522            cart: base.cart,
1523            payment: over_payment,
1524        };
1525        assert_eq!(
1526            chain
1527                .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1528                .unwrap_err(),
1529            MandateError::AmountWidensAtPayment {
1530                payment: 500_001,
1531                cart: 500_000
1532            }
1533        );
1534    }
1535
1536    #[test]
1537    fn unbounded_intent_ceiling_admits_any_cart_amount() {
1538        // An Intent with an EMPTY ceiling is unbounded by design (the
1539        // cart's own signed amount still bounds spend).
1540        let signer = issuer();
1541        let (intent, _s, _p) = sign_intent_mandate(&intent_fields(""), &signer);
1542        let intent_hash = mandate_hash(&intent);
1543        let (cart, _s, _p) = sign_cart_mandate(
1544            &CartFields {
1545                intent_hash: &intent_hash,
1546                caller: CALLER,
1547                conversation_id: CONV,
1548                merchant_host: HOST,
1549                currency: USD,
1550                amount_base_units: "123456789",
1551                issued_at_unix: 100,
1552                expires_at_unix: 9_000,
1553                nonce: "n-cart",
1554            },
1555            &signer,
1556        );
1557        let (payment, _s, _p) = sign_payment_mandate(
1558            &PaymentFields {
1559                cart_hash: &mandate_hash(&cart),
1560                caller: CALLER,
1561                conversation_id: CONV,
1562                args_json: ARGS,
1563                currency: USD,
1564                amount_base_units: "123456789",
1565                issued_at_unix: 100,
1566                expires_at_unix: 8_000,
1567                nonce: "n-payment",
1568            },
1569            &signer,
1570        );
1571        let chain = MandateChain {
1572            intent,
1573            cart,
1574            payment,
1575        };
1576        let verified = chain
1577            .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1578            .expect("empty intent ceiling is unbounded");
1579        assert_eq!(verified.authorized_amount_base_units, 123_456_789);
1580    }
1581
1582    #[test]
1583    fn expired_link_is_rejected() {
1584        let signer = issuer();
1585        let chain = valid_chain(&signer);
1586        // The payment expires first (8_000). At exactly its expiry (`<=` is
1587        // expired) the chain refuses naming the payment link.
1588        assert_eq!(
1589            chain
1590                .verify(CONV, &signer.public_key_bytes(), None, 8_000)
1591                .unwrap_err(),
1592            MandateError::Expired {
1593                kind: "payment",
1594                expires_at_unix: 8_000
1595            }
1596        );
1597        // Once EVERY link has lapsed, the root (checked first) is named.
1598        assert_eq!(
1599            chain
1600                .verify(CONV, &signer.public_key_bytes(), None, 50_000)
1601                .unwrap_err(),
1602            MandateError::Expired {
1603                kind: "intent",
1604                expires_at_unix: 10_000
1605            }
1606        );
1607        // Comfortably before every expiry: verifies.
1608        assert!(
1609            chain
1610                .verify(CONV, &signer.public_key_bytes(), None, 1)
1611                .is_ok()
1612        );
1613    }
1614
1615    #[test]
1616    fn untrusted_issuer_is_rejected() {
1617        let signer = issuer();
1618        let chain = valid_chain(&signer);
1619        let wrong_key = Signer::from_seed(1).public_key_bytes();
1620        assert_eq!(
1621            chain.verify(CONV, &wrong_key, None, 1_000).unwrap_err(),
1622            MandateError::UntrustedSigner("intent")
1623        );
1624    }
1625
1626    /// Sign a chain whose Intent link is signed by `persona_signer` (the
1627    /// persona's own recorded credential key) while the Cart and Payment
1628    /// links stay signed by `issuer_signer` (the platform key) — the shape
1629    /// a user-signed Intent takes once #772's enrollment ceremony has run,
1630    /// per the PRD's "Cart/Payment always platform-signed" invariant.
1631    fn chain_with_user_signed_intent(
1632        issuer_signer: &Signer,
1633        persona_signer: &Signer,
1634    ) -> MandateChain {
1635        let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), persona_signer);
1636        let intent_hash = mandate_hash(&intent);
1637        let (cart, _s, _p) = sign_cart_mandate(
1638            &CartFields {
1639                intent_hash: &intent_hash,
1640                caller: CALLER,
1641                conversation_id: CONV,
1642                merchant_host: HOST,
1643                currency: USD,
1644                amount_base_units: "500000",
1645                issued_at_unix: 100,
1646                expires_at_unix: 9_000,
1647                nonce: "n-cart",
1648            },
1649            issuer_signer,
1650        );
1651        let cart_hash = mandate_hash(&cart);
1652        let (payment, _s, _p) = sign_payment_mandate(
1653            &PaymentFields {
1654                cart_hash: &cart_hash,
1655                caller: CALLER,
1656                conversation_id: CONV,
1657                args_json: ARGS,
1658                currency: USD,
1659                amount_base_units: "500000",
1660                issued_at_unix: 100,
1661                expires_at_unix: 8_000,
1662                nonce: "n-payment",
1663            },
1664            issuer_signer,
1665        );
1666        MandateChain {
1667            intent,
1668            cart,
1669            payment,
1670        }
1671    }
1672
1673    #[test]
1674    fn user_signed_intent_with_platform_cart_and_payment_verifies() {
1675        // The Intent is signed by the persona's own recorded credential key,
1676        // not the platform issuer key; Cart/Payment stay platform-signed, as
1677        // every link always must be. Presenting the resolved, non-revoked
1678        // persona key lets the Intent link trust it.
1679        let issuer_signer = issuer();
1680        let persona_signer = Signer::from_seed(555);
1681        let persona_key = persona_signer.public_key_bytes();
1682        let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1683        let verified = chain
1684            .verify(
1685                CONV,
1686                &issuer_signer.public_key_bytes(),
1687                Some(active_persona_trust(&persona_key, 1_000)),
1688                1_000,
1689            )
1690            .expect("a user-signed intent with platform cart/payment must verify");
1691        assert_eq!(verified.intent.signer_public_key, persona_key);
1692        assert_eq!(verified.authorized_amount_base_units, 500_000);
1693    }
1694
1695    #[test]
1696    fn platform_signed_intent_still_verifies_when_persona_has_an_active_credential() {
1697        // A persona WITH an active credential is never forced onto the
1698        // user-signed path — a platform-signed Intent (today's behavior)
1699        // still verifies even when the caller resolved and passed a persona
1700        // signing key. This feature is additive, never a hard requirement.
1701        let issuer_signer = issuer();
1702        let persona_signer = Signer::from_seed(555);
1703        let persona_key = persona_signer.public_key_bytes();
1704        let chain = valid_chain(&issuer_signer); // every link platform-signed
1705        assert!(
1706            chain
1707                .verify(
1708                    CONV,
1709                    &issuer_signer.public_key_bytes(),
1710                    Some(active_persona_trust(&persona_key, 1_000)),
1711                    1_000,
1712                )
1713                .is_ok()
1714        );
1715    }
1716
1717    #[test]
1718    fn intent_signed_by_a_non_recorded_key_is_refused() {
1719        // Neither the platform issuer key nor the persona's recorded key
1720        // signed this Intent — an unrelated third key did. Untrusted no
1721        // matter which trust anchors are configured.
1722        let issuer_signer = issuer();
1723        let persona_signer = Signer::from_seed(555);
1724        let persona_key = persona_signer.public_key_bytes();
1725        let stranger = Signer::from_seed(556);
1726        let chain = chain_with_user_signed_intent(&issuer_signer, &stranger);
1727        assert_eq!(
1728            chain
1729                .verify(
1730                    CONV,
1731                    &issuer_signer.public_key_bytes(),
1732                    Some(active_persona_trust(&persona_key, 1_000)),
1733                    1_000,
1734                )
1735                .unwrap_err(),
1736            MandateError::UntrustedSigner("intent")
1737        );
1738    }
1739
1740    #[test]
1741    fn user_signed_intent_is_refused_when_no_persona_key_is_resolved() {
1742        // The Intent IS signed by a persona credential key, but the caller
1743        // passes `None` — no credential was resolved at all. Without that
1744        // trust anchor the Intent has neither a matching persona key nor
1745        // the platform issuer key behind it, so it is refused.
1746        let issuer_signer = issuer();
1747        let persona_signer = Signer::from_seed(555);
1748        let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1749        assert_eq!(
1750            chain
1751                .verify(CONV, &issuer_signer.public_key_bytes(), None, 1_000)
1752                .unwrap_err(),
1753            MandateError::UntrustedSigner("intent")
1754        );
1755    }
1756
1757    #[test]
1758    fn user_signed_intent_is_refused_when_the_credential_is_revoked() {
1759        // Distinct from the `None` case above: a credential WAS resolved
1760        // for this exact signing key, but the caller's own lookup marked it
1761        // revoked. `MandateChain::verify` must reach the same refusal by
1762        // checking `revoked` itself — not because the caller filtered a
1763        // revoked credential out before calling in (a future lookup bug
1764        // could fail to do that), but because this function refuses to
1765        // trust revoked evidence no matter what the caller hands it.
1766        let issuer_signer = issuer();
1767        let persona_signer = Signer::from_seed(555);
1768        let persona_key = persona_signer.public_key_bytes();
1769        let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1770        assert_eq!(
1771            chain
1772                .verify(
1773                    CONV,
1774                    &issuer_signer.public_key_bytes(),
1775                    Some(revoked_persona_trust(&persona_key, 1_000)),
1776                    1_000,
1777                )
1778                .unwrap_err(),
1779            MandateError::UntrustedSigner("intent")
1780        );
1781    }
1782
1783    #[test]
1784    fn user_signed_intent_is_refused_when_the_revocation_check_is_stale() {
1785        // The credential was active and correctly matched at the moment the
1786        // caller checked it, but that check happened too long ago relative
1787        // to `now_unix` — older than `PERSONA_TRUST_MAX_AGE_SECS`. A
1788        // revocation that landed in the gap between the check and this
1789        // verification must not be silently missed, so stale evidence is
1790        // refused exactly like a revoked one.
1791        let issuer_signer = issuer();
1792        let persona_signer = Signer::from_seed(555);
1793        let persona_key = persona_signer.public_key_bytes();
1794        let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1795        // Must stay below every link's `expires_at_unix` (payment: 8_000,
1796        // the tightest) so this exercises staleness, not expiry.
1797        let now_unix = 5_000;
1798        let checked_at_unix = now_unix - PERSONA_TRUST_MAX_AGE_SECS - 1;
1799        assert_eq!(
1800            chain
1801                .verify(
1802                    CONV,
1803                    &issuer_signer.public_key_bytes(),
1804                    Some(PersonaSignerTrust {
1805                        signing_public_key: &persona_key,
1806                        revoked: false,
1807                        checked_at_unix,
1808                    }),
1809                    now_unix,
1810                )
1811                .unwrap_err(),
1812            MandateError::UntrustedSigner("intent")
1813        );
1814    }
1815
1816    #[test]
1817    fn user_signed_intent_is_refused_when_the_revocation_check_is_dated_in_the_future() {
1818        // `checked_at_unix` after `now_unix` cannot represent a real
1819        // "checked as of now" — clock skew or bogus data either way —
1820        // so it is untrustworthy regardless of how small the gap is.
1821        let issuer_signer = issuer();
1822        let persona_signer = Signer::from_seed(555);
1823        let persona_key = persona_signer.public_key_bytes();
1824        let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1825        assert_eq!(
1826            chain
1827                .verify(
1828                    CONV,
1829                    &issuer_signer.public_key_bytes(),
1830                    Some(PersonaSignerTrust {
1831                        signing_public_key: &persona_key,
1832                        revoked: false,
1833                        checked_at_unix: 1_001,
1834                    }),
1835                    1_000,
1836                )
1837                .unwrap_err(),
1838            MandateError::UntrustedSigner("intent")
1839        );
1840    }
1841
1842    #[test]
1843    fn cart_and_payment_never_trust_the_persona_key_even_when_intent_does() {
1844        // The persona key legitimately signs the Intent, but Cart/Payment
1845        // links MUST always verify against the platform issuer key — a
1846        // narrowing link (merchant, amount) is never user-signable, even
1847        // once the Intent above it is.
1848        let issuer_signer = issuer();
1849        let persona_signer = Signer::from_seed(555);
1850        let persona_key = persona_signer.public_key_bytes();
1851        let issuer_key = issuer_signer.public_key_bytes();
1852
1853        let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), &persona_signer);
1854        let intent_hash = mandate_hash(&intent);
1855
1856        // Cart signed by the PERSONA key instead of the issuer key.
1857        let (bad_cart, _s, _p) = sign_cart_mandate(
1858            &CartFields {
1859                intent_hash: &intent_hash,
1860                caller: CALLER,
1861                conversation_id: CONV,
1862                merchant_host: HOST,
1863                currency: USD,
1864                amount_base_units: "500000",
1865                issued_at_unix: 100,
1866                expires_at_unix: 9_000,
1867                nonce: "n-cart",
1868            },
1869            &persona_signer,
1870        );
1871        let (payment_for_bad_cart, _s, _p) = sign_payment_mandate(
1872            &PaymentFields {
1873                cart_hash: &mandate_hash(&bad_cart),
1874                caller: CALLER,
1875                conversation_id: CONV,
1876                args_json: ARGS,
1877                currency: USD,
1878                amount_base_units: "500000",
1879                issued_at_unix: 100,
1880                expires_at_unix: 8_000,
1881                nonce: "n-payment",
1882            },
1883            &issuer_signer,
1884        );
1885        let chain = MandateChain {
1886            intent: intent.clone(),
1887            cart: bad_cart,
1888            payment: payment_for_bad_cart,
1889        };
1890        assert_eq!(
1891            chain
1892                .verify(
1893                    CONV,
1894                    &issuer_key,
1895                    Some(active_persona_trust(&persona_key, 1_000)),
1896                    1_000,
1897                )
1898                .unwrap_err(),
1899            MandateError::UntrustedSigner("cart")
1900        );
1901
1902        // Cart platform-signed (fine), but Payment signed by the PERSONA key.
1903        let (good_cart, _s, _p) = sign_cart_mandate(
1904            &CartFields {
1905                intent_hash: &intent_hash,
1906                caller: CALLER,
1907                conversation_id: CONV,
1908                merchant_host: HOST,
1909                currency: USD,
1910                amount_base_units: "500000",
1911                issued_at_unix: 100,
1912                expires_at_unix: 9_000,
1913                nonce: "n-cart",
1914            },
1915            &issuer_signer,
1916        );
1917        let (bad_payment, _s, _p) = sign_payment_mandate(
1918            &PaymentFields {
1919                cart_hash: &mandate_hash(&good_cart),
1920                caller: CALLER,
1921                conversation_id: CONV,
1922                args_json: ARGS,
1923                currency: USD,
1924                amount_base_units: "500000",
1925                issued_at_unix: 100,
1926                expires_at_unix: 8_000,
1927                nonce: "n-payment",
1928            },
1929            &persona_signer,
1930        );
1931        let chain = MandateChain {
1932            intent,
1933            cart: good_cart,
1934            payment: bad_payment,
1935        };
1936        assert_eq!(
1937            chain
1938                .verify(
1939                    CONV,
1940                    &issuer_key,
1941                    Some(active_persona_trust(&persona_key, 1_000)),
1942                    1_000,
1943                )
1944                .unwrap_err(),
1945            MandateError::UntrustedSigner("payment")
1946        );
1947    }
1948
1949    #[test]
1950    fn splicing_a_user_signed_intent_under_a_different_chain_still_breaks_the_hash_link() {
1951        // The pre-existing chaining invariant (Cart/Payment must reference
1952        // the EXACT presented parent by hash) must still hold once an
1953        // Intent link can be user-signed — a valid, trusted, user-signed
1954        // Intent is not a license to splice it under someone else's
1955        // Cart/Payment.
1956        let issuer_signer = issuer();
1957        let persona_signer = Signer::from_seed(555);
1958        let persona_key = persona_signer.public_key_bytes();
1959
1960        // A legitimately user-signed Intent, on its own — distinct fields
1961        // (a different nonce) from the one the Cart/Payment below actually
1962        // chain to, so its hash differs.
1963        let mut foreign_fields = intent_fields("1000000");
1964        foreign_fields.nonce = "n-intent-foreign";
1965        let (foreign_intent, _s, _p) = sign_intent_mandate(&foreign_fields, &persona_signer);
1966
1967        // A Cart/Payment pair chained to a DIFFERENT (also user-signed) Intent.
1968        let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1969
1970        let spliced = MandateChain {
1971            intent: foreign_intent,
1972            cart: chain.cart,
1973            payment: chain.payment,
1974        };
1975        assert_eq!(
1976            spliced
1977                .verify(
1978                    CONV,
1979                    &issuer_signer.public_key_bytes(),
1980                    Some(active_persona_trust(&persona_key, 1_000)),
1981                    1_000,
1982                )
1983                .unwrap_err(),
1984            MandateError::CartNotChainedToIntent
1985        );
1986    }
1987
1988    #[test]
1989    fn conversation_and_caller_bindings_are_enforced() {
1990        let signer = issuer();
1991        let chain = valid_chain(&signer);
1992        // Presented in a different conversation than every link binds.
1993        assert_eq!(
1994            chain
1995                .verify("conv-OTHER", &signer.public_key_bytes(), None, 1_000)
1996                .unwrap_err(),
1997            MandateError::ConversationMismatch("intent")
1998        );
1999
2000        // A validly signed payment re-scoped to a different caller —
2001        // chained correctly, amounts fine — must still refuse.
2002        let (payment, _s, _p) = sign_payment_mandate(
2003            &PaymentFields {
2004                cart_hash: &mandate_hash(&chain.cart),
2005                caller: "slack:T1:UATTACKER",
2006                conversation_id: CONV,
2007                args_json: ARGS,
2008                currency: USD,
2009                amount_base_units: "500000",
2010                issued_at_unix: 100,
2011                expires_at_unix: 8_000,
2012                nonce: "n-payment",
2013            },
2014            &signer,
2015        );
2016        let cross_caller = MandateChain {
2017            intent: chain.intent,
2018            cart: chain.cart,
2019            payment,
2020        };
2021        assert_eq!(
2022            cross_caller
2023                .verify(CONV, &signer.public_key_bytes(), None, 1_000)
2024                .unwrap_err(),
2025            MandateError::CallerMismatch
2026        );
2027    }
2028
2029    #[test]
2030    fn currency_mismatch_is_rejected() {
2031        let signer = issuer();
2032        let chain = valid_chain(&signer);
2033        let (payment, _s, _p) = sign_payment_mandate(
2034            &PaymentFields {
2035                cart_hash: &mandate_hash(&chain.cart),
2036                caller: CALLER,
2037                conversation_id: CONV,
2038                args_json: ARGS,
2039                currency: "0xOTHER",
2040                amount_base_units: "500000",
2041                issued_at_unix: 100,
2042                expires_at_unix: 8_000,
2043                nonce: "n-payment",
2044            },
2045            &signer,
2046        );
2047        let cross_currency = MandateChain {
2048            intent: chain.intent,
2049            cart: chain.cart,
2050            payment,
2051        };
2052        assert_eq!(
2053            cross_currency
2054                .verify(CONV, &signer.public_key_bytes(), None, 1_000)
2055                .unwrap_err(),
2056            MandateError::CurrencyMismatch
2057        );
2058    }
2059
2060    #[test]
2061    fn resolve_is_a_noop_when_unconfigured_or_absent() {
2062        let signer = issuer();
2063        let chain = valid_chain(&signer);
2064        let key = signer.public_key_bytes();
2065
2066        // No chain presented ⇒ Ok(None) regardless of issuer config —
2067        // today's HITL + caps path, unchanged.
2068        assert!(matches!(
2069            resolve(None, Some(&key), None, CONV, 1_000),
2070            Ok(None)
2071        ));
2072        assert!(matches!(resolve(None, None, None, CONV, 1_000), Ok(None)));
2073
2074        // Chain presented but NO issuer key configured (the off-by-default
2075        // feature gate): still Ok(None) — zero behavior change when
2076        // unconfigured, even with mandate bytes on the wire.
2077        assert!(matches!(
2078            resolve(Some(&chain), None, None, CONV, 1_000),
2079            Ok(None)
2080        ));
2081
2082        // Both present and valid ⇒ engaged.
2083        assert!(matches!(
2084            resolve(Some(&chain), Some(&key), None, CONV, 1_000),
2085            Ok(Some(_))
2086        ));
2087    }
2088
2089    #[test]
2090    fn resolve_fails_closed_on_an_invalid_presented_chain() {
2091        // Presented + configured but expired ⇒ Err, never silently ignored.
2092        let signer = issuer();
2093        let chain = valid_chain(&signer);
2094        let key = signer.public_key_bytes();
2095        assert!(matches!(
2096            resolve(Some(&chain), Some(&key), None, CONV, 50_000).unwrap_err(),
2097            MandateError::Expired { .. }
2098        ));
2099    }
2100
2101    #[test]
2102    fn resolve_refuses_a_revoked_persona_signer_at_the_same_seam_the_proxy_calls() {
2103        // The exact seam `payments::proxy::fulfill` calls: a caller-supplied
2104        // `PersonaSignerTrust` that turns out revoked must not let a
2105        // user-signed Intent through here either — this is `resolve`, not
2106        // just `MandateChain::verify` directly, since that's the function
2107        // signature `polyc-payments` actually depends on.
2108        let issuer_signer = issuer();
2109        let persona_signer = Signer::from_seed(555);
2110        let persona_key = persona_signer.public_key_bytes();
2111        let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
2112        let issuer_key = issuer_signer.public_key_bytes();
2113        assert_eq!(
2114            resolve(
2115                Some(&chain),
2116                Some(&issuer_key),
2117                Some(revoked_persona_trust(&persona_key, 1_000)),
2118                CONV,
2119                1_000,
2120            )
2121            .unwrap_err(),
2122            MandateError::UntrustedSigner("intent")
2123        );
2124    }
2125
2126    /// Sign a HITL `approval_response` exactly as the control plane does
2127    /// and verify it back into the [`VerifiedResponse`] the bridge takes.
2128    fn hitl_approval(approved: bool, args_json: &str) -> VerifiedResponse {
2129        let approval_signer = ApprovalSigner::from_seed(7);
2130        let (payload, _sig, _pk) = response_payload(
2131            "req-1",
2132            "paid_fetch",
2133            args_json,
2134            "",
2135            approved,
2136            false,
2137            &[],
2138            CALLER,
2139            "",
2140            "workspace-write",
2141            if approved { "looks fine" } else { "no" },
2142            "",
2143            CONV,
2144            "approval-nonce-1",
2145            &approval_signer,
2146        );
2147        verify_signed_response(&payload).expect("approval signature verifies")
2148    }
2149
2150    /// A signed intent + cart prefix for the HITL bridge tests.
2151    fn chain_prefix(signer: &Signer) -> (Vec<u8>, Vec<u8>, String) {
2152        let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), signer);
2153        let intent_hash = mandate_hash(&intent);
2154        let (cart, _s, _p) = sign_cart_mandate(
2155            &CartFields {
2156                intent_hash: &intent_hash,
2157                caller: CALLER,
2158                conversation_id: CONV,
2159                merchant_host: HOST,
2160                currency: USD,
2161                amount_base_units: "500000",
2162                issued_at_unix: 100,
2163                expires_at_unix: 9_000,
2164                nonce: "n-cart",
2165            },
2166            signer,
2167        );
2168        (intent, cart, intent_hash)
2169    }
2170
2171    #[test]
2172    fn hitl_approval_mints_a_payment_mandate_that_completes_the_chain() {
2173        let signer = issuer();
2174        let (intent, cart, _ih) = chain_prefix(&signer);
2175
2176        // The human approves the exact paid_fetch call via Slack/Telegram;
2177        // the bridge formalizes that decision as a signed Payment mandate.
2178        let approved = hitl_approval(true, ARGS);
2179        let payment = payment_mandate_from_approval(
2180            &approved,
2181            "req-1",
2182            "paid_fetch",
2183            ARGS,
2184            &cart,
2185            200,
2186            8_000,
2187            "n-payment",
2188            &signer,
2189        )
2190        .expect("the approval authorizes this exact call");
2191
2192        // The minted mandate COMPLETES a chain that verifies end-to-end and
2193        // authorizes exactly the approved call at the cart's amount/host.
2194        let chain = MandateChain {
2195            intent,
2196            cart,
2197            payment,
2198        };
2199        let verified = chain
2200            .verify(CONV, &signer.public_key_bytes(), None, 1_000)
2201            .expect("the minted payment chains to the cart");
2202        assert_eq!(verified.authorized_amount_base_units, 500_000);
2203        assert_eq!(verified.caller, CALLER);
2204        verified
2205            .authorize(500_000, HOST, ARGS)
2206            .expect("authorizes the approved call");
2207        assert!(matches!(
2208            verified.authorize(1, HOST, r#"{"url":"https://evil.example.com/x"}"#),
2209            Err(MandateError::ArgsBindingMismatch)
2210        ));
2211    }
2212
2213    #[test]
2214    fn hitl_bridge_refuses_denials_and_mismatched_scopes() {
2215        let signer = issuer();
2216        let (_intent, cart, intent_hash) = chain_prefix(&signer);
2217
2218        // A denial mints nothing.
2219        let denied = hitl_approval(false, ARGS);
2220        assert_eq!(
2221            payment_mandate_from_approval(
2222                &denied,
2223                "req-1",
2224                "paid_fetch",
2225                ARGS,
2226                &cart,
2227                200,
2228                8_000,
2229                "n",
2230                &signer
2231            )
2232            .unwrap_err(),
2233            MandateError::ApprovalDoesNotAuthorizeCall
2234        );
2235
2236        // An approval for DIFFERENT args mints nothing for this call.
2237        let approved = hitl_approval(true, ARGS);
2238        assert_eq!(
2239            payment_mandate_from_approval(
2240                &approved,
2241                "req-1",
2242                "paid_fetch",
2243                r#"{"url":"https://evil.example.com/x"}"#,
2244                &cart,
2245                200,
2246                8_000,
2247                "n",
2248                &signer
2249            )
2250            .unwrap_err(),
2251            MandateError::ApprovalDoesNotAuthorizeCall
2252        );
2253
2254        // A cart scoped to a DIFFERENT conversation than the approval
2255        // cannot be completed under it.
2256        let (foreign_cart, _s, _p) = sign_cart_mandate(
2257            &CartFields {
2258                intent_hash: &intent_hash,
2259                caller: CALLER,
2260                conversation_id: "conv-OTHER",
2261                merchant_host: HOST,
2262                currency: USD,
2263                amount_base_units: "500000",
2264                issued_at_unix: 100,
2265                expires_at_unix: 9_000,
2266                nonce: "n-cart",
2267            },
2268            &signer,
2269        );
2270        assert_eq!(
2271            payment_mandate_from_approval(
2272                &approved,
2273                "req-1",
2274                "paid_fetch",
2275                ARGS,
2276                &foreign_cart,
2277                200,
2278                8_000,
2279                "n",
2280                &signer
2281            )
2282            .unwrap_err(),
2283            MandateError::ConversationMismatch("cart")
2284        );
2285
2286        // A cart scoped to a DIFFERENT caller than the approver likewise.
2287        let (foreign_caller_cart, _s, _p) = sign_cart_mandate(
2288            &CartFields {
2289                intent_hash: &intent_hash,
2290                caller: "slack:T1:USOMEONE",
2291                conversation_id: CONV,
2292                merchant_host: HOST,
2293                currency: USD,
2294                amount_base_units: "500000",
2295                issued_at_unix: 100,
2296                expires_at_unix: 9_000,
2297                nonce: "n-cart",
2298            },
2299            &signer,
2300        );
2301        assert_eq!(
2302            payment_mandate_from_approval(
2303                &approved,
2304                "req-1",
2305                "paid_fetch",
2306                ARGS,
2307                &foreign_caller_cart,
2308                200,
2309                8_000,
2310                "n",
2311                &signer
2312            )
2313            .unwrap_err(),
2314            MandateError::CallerMismatch
2315        );
2316    }
2317}
2318
2319#[cfg(test)]
2320mod canonical_freeze {
2321    //! Every signed canonical in this module, frozen as literal bytes (`#1845`).
2322    //!
2323    //! These are the bytes a deployment has already signed and has sitting in
2324    //! its log. They are checked in, never regenerated: regenerating one is the
2325    //! defect this module exists to catch, because a canonical whose bytes move
2326    //! invalidates every signature ever minted over the old ones.
2327    //!
2328    //! The reason they can be single literals at all is the conversion `#1845`
2329    //! made, following `#1842`. Before it, each canonical was a
2330    //! [`serde_json::Value`], whose object is a `BTreeMap` (keys sorted) by
2331    //! default and an `IndexMap` (insertion order) whenever anything in the
2332    //! build graph enables `serde_json/preserve_order` — so the same payload
2333    //! signed by two binaries with different dependency sets produced different
2334    //! bytes and different signatures. Every literal below is the
2335    //! insertion-order form, which is what a control-plane binary (where
2336    //! `preserve_order` is unified in) has always signed. Run this module under
2337    //! either selection and every literal holds:
2338    //!
2339    //! ```text
2340    //! cargo nextest run -p polyc-crypto                    # no preserve_order
2341    //! cargo nextest run -p polyc-crypto -p polyc-payments  # preserve_order on
2342    //! ```
2343    //!
2344    //! See ADR 0009 for the decision these literals enforce.
2345    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
2346
2347    use super::*;
2348
2349    /// Assert a canonical's bytes are exactly the frozen literal.
2350    fn frozen(label: &str, got: &[u8], want: &str) {
2351        assert_eq!(
2352            String::from_utf8(got.to_vec()).unwrap(),
2353            want,
2354            "{label}: canonical bytes moved — every signature over the old bytes is now unverifiable"
2355        );
2356    }
2357
2358    const ARGS_JSON: &str = r#"{"url":"https://shop.example/item","max":"25000"}"#;
2359
2360    fn intent() -> IntentFields<'static> {
2361        IntentFields {
2362            caller: "slack:T1:U9",
2363            conversation_id: "conv-1",
2364            scope_description: "groceries for the week",
2365            currency: "0xToken",
2366            max_total_base_units: "100000",
2367            issued_at_unix: 1_750_000_000,
2368            expires_at_unix: 1_750_086_400,
2369            nonce: "nonce-intent",
2370        }
2371    }
2372
2373    fn cart() -> CartFields<'static> {
2374        CartFields {
2375            intent_hash: "abcd1234",
2376            caller: "slack:T1:U9",
2377            conversation_id: "conv-1",
2378            merchant_host: "shop.example",
2379            currency: "0xToken",
2380            amount_base_units: "25000",
2381            issued_at_unix: 1_750_000_000,
2382            expires_at_unix: 1_750_086_400,
2383            nonce: "nonce-cart",
2384        }
2385    }
2386
2387    fn payment() -> PaymentFields<'static> {
2388        PaymentFields {
2389            cart_hash: "beef5678",
2390            caller: "slack:T1:U9",
2391            conversation_id: "conv-1",
2392            args_json: ARGS_JSON,
2393            currency: "0xToken",
2394            amount_base_units: "25000",
2395            issued_at_unix: 1_750_000_000,
2396            expires_at_unix: 1_750_086_400,
2397            nonce: "nonce-payment",
2398        }
2399    }
2400
2401    #[test]
2402    fn intent_mandate_is_frozen() {
2403        frozen(
2404            "IntentFields::canonical_json",
2405            &canonical_bytes(&intent().canonical_json()),
2406            INTENT_CANONICAL,
2407        );
2408        let (full, sig, _) = sign_intent_mandate(&intent(), &Signer::from_seed(99));
2409        frozen("sign_intent_mandate", &full, INTENT_PAYLOAD);
2410        assert_eq!(crate::hex::lower(&sig), INTENT_SIG);
2411    }
2412
2413    #[test]
2414    fn cart_mandate_is_frozen() {
2415        frozen(
2416            "CartFields::canonical_json",
2417            &canonical_bytes(&cart().canonical_json()),
2418            CART_CANONICAL,
2419        );
2420        let (full, sig, _) = sign_cart_mandate(&cart(), &Signer::from_seed(99));
2421        frozen("sign_cart_mandate", &full, CART_PAYLOAD);
2422        assert_eq!(crate::hex::lower(&sig), CART_SIG);
2423    }
2424
2425    #[test]
2426    fn payment_mandate_is_frozen() {
2427        frozen(
2428            "PaymentFields::canonical_json",
2429            &canonical_bytes(&payment().canonical_json()),
2430            PAYMENT_CANONICAL,
2431        );
2432        let (full, sig, _) = sign_payment_mandate(&payment(), &Signer::from_seed(99));
2433        frozen("sign_payment_mandate", &full, PAYMENT_PAYLOAD);
2434        assert_eq!(crate::hex::lower(&sig), PAYMENT_SIG);
2435    }
2436
2437    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"}"#;
2438    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"}"#;
2439    const INTENT_SIG: &str = "85089b532b35c7ad7689c4f7f830959c08d7db71a9a3c45eb53db40289445494e125122bf45c4367aab60cb727c30fc5e4fd3fdc43abd6b0e7a0b2d3f2642703";
2440    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"}"#;
2441    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"}"#;
2442    const CART_SIG: &str = "01c9ed068ef6f7f7724391fb2adffccab3db18e447e2514c7e07a233333534cef73ce2301204ca9f4beafb8907b992d9757e951a827765125a2639926afe2907";
2443    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"}"#;
2444    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"}"#;
2445    const PAYMENT_SIG: &str = "95c1f52b6b4f3433a78884d7f65caafbaa8423a091632ad4b6e04b95966ca71afdf7819a5399c8f8dc1c0eb2c80b3b3eaa4fe59551eb9eb6a4492f36e9e07e06";
2446}