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_json::Value;
50use sha2::{Digest, Sha256};
51
52use crate::approval::VerifiedResponse;
53use crate::canon::canon_args;
54use crate::{Signer, verify};
55
56/// Signed `kind` tag for an Intent mandate's canonical JSON.
57const KIND_INTENT: &str = "ap2.intent.v1";
58/// Signed `kind` tag for a Cart mandate's canonical JSON.
59const KIND_CART: &str = "ap2.cart.v1";
60/// Signed `kind` tag for a Payment mandate's canonical JSON.
61const KIND_PAYMENT: &str = "ap2.payment.v1";
62
63/// Sha256 hex of a mandate's FULL signed payload bytes — the chain link.
64///
65/// Takes the bytes a `sign_*_mandate` function returned (or read back from
66/// storage); the result is the value a child mandate signs into its
67/// `intent_hash` / `cart_hash` field.
68#[must_use]
69pub fn mandate_hash(signed_payload: &[u8]) -> String {
70    let mut hasher = Sha256::new();
71    hasher.update(signed_payload);
72    crate::hex::lower(&hasher.finalize())
73}
74
75/// Named signed fields for an Intent mandate — the top-level, human-granted
76/// authorization scope.
77///
78/// Passed as a single struct (mirrors [`crate::approval::ReceiptPayload`]) so
79/// two same-typed `&str` fields can't be silently swapped at a call site.
80#[derive(Debug, Clone, Copy)]
81pub struct IntentFields<'a> {
82    /// The identity that granted this authorization (mirrors
83    /// `approval_response.caller`, e.g. `slack:T1:U9`).
84    pub caller: &'a str,
85    /// The conversation this intent is scoped to.
86    pub conversation_id: &'a str,
87    /// Free-form human-readable description of what was authorized (audit /
88    /// display only; not itself a scope predicate).
89    pub scope_description: &'a str,
90    /// Settlement token contract address every descendant Cart/Payment must
91    /// match.
92    pub currency: &'a str,
93    /// Decimal base-unit ceiling on the total this intent may ultimately
94    /// authorize across every descendant Cart; empty ⇒ unbounded (a Cart's
95    /// amount is still bounded by its own signed value, just not by this
96    /// intent).
97    pub max_total_base_units: &'a str,
98    /// Unix seconds this mandate was issued.
99    pub issued_at_unix: u64,
100    /// Unix seconds after which this mandate (and every descendant) is no
101    /// longer valid.
102    pub expires_at_unix: u64,
103    /// Per-mandate unique value (mirrors `approval_response.nonce`).
104    pub nonce: &'a str,
105}
106
107impl IntentFields<'_> {
108    fn canonical_json(&self) -> Value {
109        serde_json::json!({
110            "kind": KIND_INTENT,
111            "caller": self.caller,
112            "conversation_id": self.conversation_id,
113            "scope_description": self.scope_description,
114            "currency": self.currency,
115            "max_total_base_units": self.max_total_base_units,
116            "issued_at_unix": self.issued_at_unix,
117            "expires_at_unix": self.expires_at_unix,
118            "nonce": self.nonce,
119        })
120    }
121}
122
123/// A verified, decoded Intent mandate.
124#[derive(Debug, Clone)]
125pub struct VerifiedIntentMandate {
126    /// The identity that granted this authorization.
127    pub caller: String,
128    /// The conversation this intent is scoped to.
129    pub conversation_id: String,
130    /// Human-readable description of what was authorized.
131    pub scope_description: String,
132    /// Settlement token contract address.
133    pub currency: String,
134    /// Decimal base-unit ceiling on the total; empty ⇒ unbounded.
135    pub max_total_base_units: String,
136    /// Unix seconds this mandate was issued.
137    pub issued_at_unix: u64,
138    /// Unix seconds after which this mandate is no longer valid.
139    pub expires_at_unix: u64,
140    /// Per-mandate unique value.
141    pub nonce: String,
142    /// The verified signer's public key (encoded).
143    pub signer_public_key: Vec<u8>,
144}
145
146/// Sign the canonical bytes of `fields`.
147///
148/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`; the
149/// caller persists `full_payload_bytes` and, for a Cart mandate, feeds it
150/// through [`mandate_hash`] to build the chain link.
151#[must_use]
152pub fn sign_intent_mandate(
153    fields: &IntentFields<'_>,
154    signer: &Signer,
155) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
156    sign_envelope(fields.canonical_json(), signer)
157}
158
159/// Verify a persisted Intent mandate payload.
160///
161/// Returns `Some(record)` if the signature checks out against the embedded
162/// public key and the payload carries the Intent `kind` tag. Returns `None` if the
163/// payload is malformed, the hex fields don't decode, the `kind` tag doesn't
164/// match, or the signature doesn't verify.
165#[must_use]
166pub fn verify_signed_intent_mandate(payload: &[u8]) -> Option<VerifiedIntentMandate> {
167    let v: Value = serde_json::from_slice(payload).ok()?;
168    if v.get("kind")?.as_str()? != KIND_INTENT {
169        return None;
170    }
171    let caller = v.get("caller")?.as_str()?.to_owned();
172    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
173    let scope_description = v.get("scope_description")?.as_str()?.to_owned();
174    let currency = v.get("currency")?.as_str()?.to_owned();
175    let max_total_base_units = v.get("max_total_base_units")?.as_str()?.to_owned();
176    let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
177    let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
178    let nonce = v.get("nonce")?.as_str()?.to_owned();
179    let (pk, sig) = envelope_signature(&v)?;
180
181    let fields = IntentFields {
182        caller: &caller,
183        conversation_id: &conversation_id,
184        scope_description: &scope_description,
185        currency: &currency,
186        max_total_base_units: &max_total_base_units,
187        issued_at_unix,
188        expires_at_unix,
189        nonce: &nonce,
190    };
191    if verify(&pk, &fields.canonical_json().to_string().into_bytes(), &sig) {
192        Some(VerifiedIntentMandate {
193            caller,
194            conversation_id,
195            scope_description,
196            currency,
197            max_total_base_units,
198            issued_at_unix,
199            expires_at_unix,
200            nonce,
201            signer_public_key: pk,
202        })
203    } else {
204        None
205    }
206}
207
208/// Named signed fields for a Cart mandate — narrows an Intent to a specific
209/// merchant and amount.
210#[derive(Debug, Clone, Copy)]
211pub struct CartFields<'a> {
212    /// [`mandate_hash`] of the parent Intent mandate's full signed payload.
213    pub intent_hash: &'a str,
214    /// The identity that granted this authorization (must equal the parent
215    /// Intent's `caller`; checked by the chain validator, not here).
216    pub caller: &'a str,
217    /// The conversation this cart is scoped to.
218    pub conversation_id: &'a str,
219    /// The merchant host this cart authorizes payment to (lowercased).
220    pub merchant_host: &'a str,
221    /// Settlement token contract address.
222    pub currency: &'a str,
223    /// Decimal base-unit total for this cart.
224    pub amount_base_units: &'a str,
225    /// Unix seconds this mandate was issued.
226    pub issued_at_unix: u64,
227    /// Unix seconds after which this mandate is no longer valid.
228    pub expires_at_unix: u64,
229    /// Per-mandate unique value.
230    pub nonce: &'a str,
231}
232
233impl CartFields<'_> {
234    fn canonical_json(&self) -> Value {
235        serde_json::json!({
236            "kind": KIND_CART,
237            "intent_hash": self.intent_hash,
238            "caller": self.caller,
239            "conversation_id": self.conversation_id,
240            "merchant_host": self.merchant_host,
241            "currency": self.currency,
242            "amount_base_units": self.amount_base_units,
243            "issued_at_unix": self.issued_at_unix,
244            "expires_at_unix": self.expires_at_unix,
245            "nonce": self.nonce,
246        })
247    }
248}
249
250/// A verified, decoded Cart mandate.
251#[derive(Debug, Clone)]
252pub struct VerifiedCartMandate {
253    /// [`mandate_hash`] of the parent Intent mandate this cart chains to.
254    pub intent_hash: String,
255    /// The identity that granted this authorization.
256    pub caller: String,
257    /// The conversation this cart is scoped to.
258    pub conversation_id: String,
259    /// The merchant host this cart authorizes payment to.
260    pub merchant_host: String,
261    /// Settlement token contract address.
262    pub currency: String,
263    /// Decimal base-unit total for this cart.
264    pub amount_base_units: String,
265    /// Unix seconds this mandate was issued.
266    pub issued_at_unix: u64,
267    /// Unix seconds after which this mandate is no longer valid.
268    pub expires_at_unix: u64,
269    /// Per-mandate unique value.
270    pub nonce: String,
271    /// The verified signer's public key (encoded).
272    pub signer_public_key: Vec<u8>,
273}
274
275/// Sign the canonical bytes of `fields`. Returns
276/// `(full_payload_bytes, signature_bytes, public_key_bytes)`.
277#[must_use]
278pub fn sign_cart_mandate(fields: &CartFields<'_>, signer: &Signer) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
279    sign_envelope(fields.canonical_json(), signer)
280}
281
282/// Verify a persisted Cart mandate payload. See
283/// [`verify_signed_intent_mandate`] for the failure modes.
284#[must_use]
285pub fn verify_signed_cart_mandate(payload: &[u8]) -> Option<VerifiedCartMandate> {
286    let v: Value = serde_json::from_slice(payload).ok()?;
287    if v.get("kind")?.as_str()? != KIND_CART {
288        return None;
289    }
290    let intent_hash = v.get("intent_hash")?.as_str()?.to_owned();
291    let caller = v.get("caller")?.as_str()?.to_owned();
292    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
293    let merchant_host = v.get("merchant_host")?.as_str()?.to_owned();
294    let currency = v.get("currency")?.as_str()?.to_owned();
295    let amount_base_units = v.get("amount_base_units")?.as_str()?.to_owned();
296    let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
297    let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
298    let nonce = v.get("nonce")?.as_str()?.to_owned();
299    let (pk, sig) = envelope_signature(&v)?;
300
301    let fields = CartFields {
302        intent_hash: &intent_hash,
303        caller: &caller,
304        conversation_id: &conversation_id,
305        merchant_host: &merchant_host,
306        currency: &currency,
307        amount_base_units: &amount_base_units,
308        issued_at_unix,
309        expires_at_unix,
310        nonce: &nonce,
311    };
312    if verify(&pk, &fields.canonical_json().to_string().into_bytes(), &sig) {
313        Some(VerifiedCartMandate {
314            intent_hash,
315            caller,
316            conversation_id,
317            merchant_host,
318            currency,
319            amount_base_units,
320            issued_at_unix,
321            expires_at_unix,
322            nonce,
323            signer_public_key: pk,
324        })
325    } else {
326        None
327    }
328}
329
330/// Named signed fields for a Payment mandate — the final authorization bound
331/// to one exact tool call.
332#[derive(Debug, Clone, Copy)]
333pub struct PaymentFields<'a> {
334    /// [`mandate_hash`] of the parent Cart mandate's full signed payload.
335    pub cart_hash: &'a str,
336    /// The identity that granted this authorization.
337    pub caller: &'a str,
338    /// The conversation this payment is scoped to.
339    pub conversation_id: &'a str,
340    /// The exact `paid_fetch` `args_json` this payment authorizes (mirrors
341    /// `approval_response.args_json` binding — a captured mandate cannot be
342    /// replayed against a different call).
343    pub args_json: &'a str,
344    /// Settlement token contract address.
345    pub currency: &'a str,
346    /// Decimal base-unit amount for this payment.
347    pub amount_base_units: &'a str,
348    /// Unix seconds this mandate was issued.
349    pub issued_at_unix: u64,
350    /// Unix seconds after which this mandate is no longer valid.
351    pub expires_at_unix: u64,
352    /// Per-mandate unique value.
353    pub nonce: &'a str,
354}
355
356impl PaymentFields<'_> {
357    fn canonical_json(&self) -> Value {
358        serde_json::json!({
359            "kind": KIND_PAYMENT,
360            "cart_hash": self.cart_hash,
361            "caller": self.caller,
362            "conversation_id": self.conversation_id,
363            "args_json": self.args_json,
364            "currency": self.currency,
365            "amount_base_units": self.amount_base_units,
366            "issued_at_unix": self.issued_at_unix,
367            "expires_at_unix": self.expires_at_unix,
368            "nonce": self.nonce,
369        })
370    }
371}
372
373/// A verified, decoded Payment mandate.
374#[derive(Debug, Clone)]
375pub struct VerifiedPaymentMandate {
376    /// [`mandate_hash`] of the parent Cart mandate this payment chains to.
377    pub cart_hash: String,
378    /// The identity that granted this authorization.
379    pub caller: String,
380    /// The conversation this payment is scoped to.
381    pub conversation_id: String,
382    /// The exact `paid_fetch` `args_json` this payment authorizes.
383    pub args_json: String,
384    /// Settlement token contract address.
385    pub currency: String,
386    /// Decimal base-unit amount for this payment.
387    pub amount_base_units: String,
388    /// Unix seconds this mandate was issued.
389    pub issued_at_unix: u64,
390    /// Unix seconds after which this mandate is no longer valid.
391    pub expires_at_unix: u64,
392    /// Per-mandate unique value.
393    pub nonce: String,
394    /// The verified signer's public key (encoded).
395    pub signer_public_key: Vec<u8>,
396}
397
398/// Sign the canonical bytes of `fields`. Returns
399/// `(full_payload_bytes, signature_bytes, public_key_bytes)`.
400#[must_use]
401pub fn sign_payment_mandate(
402    fields: &PaymentFields<'_>,
403    signer: &Signer,
404) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
405    sign_envelope(fields.canonical_json(), signer)
406}
407
408/// Verify a persisted Payment mandate payload. See
409/// [`verify_signed_intent_mandate`] for the failure modes.
410#[must_use]
411pub fn verify_signed_payment_mandate(payload: &[u8]) -> Option<VerifiedPaymentMandate> {
412    let v: Value = serde_json::from_slice(payload).ok()?;
413    if v.get("kind")?.as_str()? != KIND_PAYMENT {
414        return None;
415    }
416    let cart_hash = v.get("cart_hash")?.as_str()?.to_owned();
417    let caller = v.get("caller")?.as_str()?.to_owned();
418    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
419    let args_json = v.get("args_json")?.as_str()?.to_owned();
420    let currency = v.get("currency")?.as_str()?.to_owned();
421    let amount_base_units = v.get("amount_base_units")?.as_str()?.to_owned();
422    let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
423    let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
424    let nonce = v.get("nonce")?.as_str()?.to_owned();
425    let (pk, sig) = envelope_signature(&v)?;
426
427    let fields = PaymentFields {
428        cart_hash: &cart_hash,
429        caller: &caller,
430        conversation_id: &conversation_id,
431        args_json: &args_json,
432        currency: &currency,
433        amount_base_units: &amount_base_units,
434        issued_at_unix,
435        expires_at_unix,
436        nonce: &nonce,
437    };
438    if verify(&pk, &fields.canonical_json().to_string().into_bytes(), &sig) {
439        Some(VerifiedPaymentMandate {
440            cart_hash,
441            caller,
442            conversation_id,
443            args_json,
444            currency,
445            amount_base_units,
446            issued_at_unix,
447            expires_at_unix,
448            nonce,
449            signer_public_key: pk,
450        })
451    } else {
452        None
453    }
454}
455
456/// Signs `canonical` and appends the two signature fields, mirroring
457/// [`crate::approval::receipt_payload`]'s "canonical object plus signature
458/// fields" construction — the body field set stays owned by each
459/// `*Fields::canonical_json`, this only adds the envelope.
460fn sign_envelope(mut canonical: Value, signer: &Signer) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
461    let canonical_bytes = canonical.to_string().into_bytes();
462    let signature = signer.sign(&canonical_bytes);
463    let pk = signer.public_key_bytes();
464    if let Value::Object(map) = &mut canonical {
465        map.insert(
466            "signed_by".to_owned(),
467            Value::String(crate::hex::lower(&pk)),
468        );
469        map.insert(
470            "signature_hex".to_owned(),
471            Value::String(crate::hex::lower(&signature)),
472        );
473    }
474    (canonical.to_string().into_bytes(), signature, pk)
475}
476
477/// Extracts and hex-decodes the `signed_by` / `signature_hex` pair a
478/// `verify_signed_*_mandate` needs, common to all three envelope shapes.
479fn envelope_signature(v: &Value) -> Option<(Vec<u8>, Vec<u8>)> {
480    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
481    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
482    Some((pk, sig))
483}
484
485// ---------------------------------------------------------------------------
486// Chain validation — the AP2 business rules built on the signing primitives
487// above: hash-chaining, amount narrowing, currency/caller/conversation
488// agreement, and expiry. [`MandateChain::verify`] is the sole entry point;
489// everything below exists to produce or consume its result.
490// ---------------------------------------------------------------------------
491
492/// Raised while validating a [`MandateChain`] or authorizing a spend
493/// against a [`VerifiedMandateChain`].
494#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
495pub enum MandateError {
496    /// The Intent mandate is malformed or its signature does not verify.
497    #[error("intent mandate is malformed or its signature does not verify")]
498    InvalidIntent,
499    /// The Cart mandate is malformed or its signature does not verify.
500    #[error("cart mandate is malformed or its signature does not verify")]
501    InvalidCart,
502    /// The Payment mandate is malformed or its signature does not verify.
503    #[error("payment mandate is malformed or its signature does not verify")]
504    InvalidPayment,
505    /// A link's signer is neither the platform issuer key nor (for the
506    /// Intent link, when a persona credential is on record) the persona's
507    /// own recorded signing key.
508    #[error("{0} mandate is not signed by a trusted key")]
509    UntrustedSigner(&'static str),
510    /// A link is bound to a different conversation than the one it is being
511    /// consumed in.
512    #[error("{0} mandate is bound to a different conversation")]
513    ConversationMismatch(&'static str),
514    /// The chain's links are not all bound to one consistent caller.
515    #[error("mandate chain is not bound to one consistent caller")]
516    CallerMismatch,
517    /// A link has expired as of the check time.
518    #[error("{kind} mandate expired at {expires_at_unix}")]
519    Expired {
520        /// Which link expired.
521        kind: &'static str,
522        /// Its signed unix-seconds expiry.
523        expires_at_unix: u64,
524    },
525    /// The Cart's `intent_hash` does not reference the presented Intent.
526    #[error("cart mandate does not chain to the presented intent mandate")]
527    CartNotChainedToIntent,
528    /// The Payment's `cart_hash` does not reference the presented Cart.
529    #[error("payment mandate does not chain to the presented cart mandate")]
530    PaymentNotChainedToCart,
531    /// A signed amount field is not a parseable base-unit integer. Fails
532    /// closed: a mandate whose amount cannot be understood authorizes
533    /// nothing.
534    #[error("{0} mandate carries an unparseable base-unit amount")]
535    MalformedAmount(&'static str),
536    /// The Cart's amount exceeds the Intent's ceiling (widening).
537    #[error("cart amount {cart} exceeds the intent ceiling {intent}")]
538    AmountWidensAtCart {
539        /// The Cart's base-unit amount.
540        cart: u128,
541        /// The Intent's base-unit ceiling.
542        intent: u128,
543    },
544    /// The Payment's amount exceeds the Cart's amount (widening).
545    #[error("payment amount {payment} exceeds the cart amount {cart}")]
546    AmountWidensAtPayment {
547        /// The Payment's base-unit amount.
548        payment: u128,
549        /// The Cart's base-unit amount.
550        cart: u128,
551    },
552    /// Currency differs across the chain.
553    #[error("mandate currency does not match across the chain")]
554    CurrencyMismatch,
555    /// The Cart's merchant scope is empty — a scopeless cart authorizes
556    /// nothing (fail closed).
557    #[error("cart mandate carries no merchant scope")]
558    EmptyScope,
559    /// A verified chain does not cover the proposed spend (amount or
560    /// destination).
561    #[error(
562        "mandate authorizes at most {authorized} base units against {authorized_host}; \
563         requested {requested} base units against {requested_host}"
564    )]
565    ExceedsAuthorization {
566        /// The base-unit amount the chain authorizes.
567        authorized: u128,
568        /// The merchant host the chain authorizes spend against.
569        authorized_host: String,
570        /// The requested base-unit amount.
571        requested: u128,
572        /// The host the request targets.
573        requested_host: String,
574    },
575    /// The call's args do not match the exact `args_json` the Payment
576    /// mandate signed — a mandate minted for one call cannot authorize a
577    /// different one.
578    #[error("the payment mandate is bound to a different tool call's args")]
579    ArgsBindingMismatch,
580    /// The verified HITL approval offered as the trust root does not
581    /// authorize the exact call the Payment mandate is being minted for.
582    #[error("the HITL approval does not authorize this exact call; no mandate was minted")]
583    ApprovalDoesNotAuthorizeCall,
584}
585
586/// The three signed, wire-form mandate payloads presented together as one
587/// pre-authorization.
588#[derive(Debug, Clone, Default)]
589pub struct MandateChain {
590    /// Signed Intent mandate payload ([`sign_intent_mandate`] output).
591    pub intent: Vec<u8>,
592    /// Signed Cart mandate payload.
593    pub cart: Vec<u8>,
594    /// Signed Payment mandate payload.
595    pub payment: Vec<u8>,
596}
597
598/// How long a [`PersonaSignerTrust`] revocation check stays trustworthy
599/// before [`MandateChain::verify`] refuses to rely on it. Bounds the window
600/// in which a credential revoked *after* the caller's lookup ran but
601/// *before* this mandate is verified would otherwise go unnoticed — a stale
602/// cache or a revocation racing an in-flight resolution cannot be fresher
603/// than this ceiling and still be trusted.
604const PERSONA_TRUST_MAX_AGE_SECS: u64 = 300;
605
606/// Evidence that a persona's signing key is safe for [`MandateChain::verify`]
607/// to trust for the Intent link, offered as an alternative to the platform
608/// issuer key.
609///
610/// This is deliberately not a bare key: it is the caller's proof that a
611/// `PersonaCredential` lookup ran, found the credential **not revoked**, and
612/// did so recently enough (within a bounded freshness window) to still hold
613/// "as of now". [`MandateChain::verify`] checks `revoked` and
614/// `checked_at_unix` itself, rather than trusting a caller's
615/// documentation-only promise that a revocation check already happened. A
616/// revoked or stale [`PersonaSignerTrust`] is treated exactly like passing
617/// `None`: the Intent link falls back to the platform issuer key alone.
618/// This closes the gap
619/// where a future lookup bug — a stale cache, a revocation event racing an
620/// in-flight resolution, a query that omits a `revoked_at` filter — could
621/// otherwise hand this function a since-revoked key indistinguishable from
622/// an active one.
623#[derive(Debug, Clone, Copy)]
624pub struct PersonaSignerTrust<'a> {
625    /// The persona's derived ed25519 signing public key
626    /// (`PersonaCredential::signing_public_key`).
627    pub signing_public_key: &'a [u8],
628    /// The credential's own revocation flag (`PersonaCredential::revoked`),
629    /// as observed at `checked_at_unix`.
630    pub revoked: bool,
631    /// Unix time (seconds) the revocation check above was performed.
632    pub checked_at_unix: u64,
633}
634
635impl PersonaSignerTrust<'_> {
636    /// Whether this trust evidence is both non-revoked and fresh as of
637    /// `now_unix` — the single gate [`MandateChain::verify`] applies before
638    /// ever comparing key bytes. Stale evidence (older than
639    /// [`PERSONA_TRUST_MAX_AGE_SECS`]) or evidence dated in the future
640    /// (clock skew, or simply bogus) is untrustworthy, same as `revoked`.
641    const fn is_trustworthy(&self, now_unix: u64) -> bool {
642        !self.revoked
643            && self.checked_at_unix <= now_unix
644            && now_unix.saturating_sub(self.checked_at_unix) <= PERSONA_TRUST_MAX_AGE_SECS
645    }
646}
647
648impl MandateChain {
649    /// Validate the whole chain: every link is bound to `conversation_id`
650    /// and one consistent caller, the Payment chains (by [`mandate_hash`])
651    /// to the Cart and the Cart to the Intent, amounts narrow (never widen)
652    /// down the chain, currency agrees, the Cart carries a non-empty
653    /// merchant scope, and no link has expired as of `now_unix`.
654    ///
655    /// Per-link signer trust is resolved separately for each link rather
656    /// than against one shared key. The Cart and Payment links ALWAYS
657    /// verify against `issuer_public_key` (the platform mandate-issuing
658    /// key) — narrowing a human's authorization down to a merchant and
659    /// amount is never something a browser-held key can do on its own, even
660    /// when the Intent above it is user-signed. The Intent link trusts
661    /// EITHER `issuer_public_key` (today's fully-platform-signed path,
662    /// unchanged) OR `persona_signer` — a [`PersonaSignerTrust`] proving the
663    /// persona's own recorded credential is active and was checked recently
664    /// enough, passed as `Some` only when the persona has such a credential
665    /// on record — whichever one actually signed it. This function itself
666    /// re-checks that trust itself (revocation and freshness), not merely
667    /// the caller's say-so. An Intent signed by neither trusted
668    /// key is untrusted. Passing `None`, or `Some` evidence that is revoked
669    /// or stale, reduces to exactly today's behavior: this feature is
670    /// additive and never a hard requirement.
671    ///
672    /// # Errors
673    ///
674    /// One [`MandateError`] per broken invariant — see each variant. Fails
675    /// closed on everything: malformed payloads, unknown signers, chain
676    /// breaks, widened or unparseable amounts, scope/currency drift,
677    /// expiry.
678    pub fn verify(
679        &self,
680        conversation_id: &str,
681        issuer_public_key: &[u8],
682        persona_signer: Option<PersonaSignerTrust<'_>>,
683        now_unix: u64,
684    ) -> Result<VerifiedMandateChain, MandateError> {
685        let intent =
686            verify_signed_intent_mandate(&self.intent).ok_or(MandateError::InvalidIntent)?;
687        let cart = verify_signed_cart_mandate(&self.cart).ok_or(MandateError::InvalidCart)?;
688        let payment =
689            verify_signed_payment_mandate(&self.payment).ok_or(MandateError::InvalidPayment)?;
690
691        // Conversation + expiry checks, root first — unchanged for every
692        // link regardless of which key trusts it.
693        let links: [(&'static str, &str, u64); 3] = [
694            ("intent", &intent.conversation_id, intent.expires_at_unix),
695            ("cart", &cart.conversation_id, cart.expires_at_unix),
696            ("payment", &payment.conversation_id, payment.expires_at_unix),
697        ];
698        for (label, conv, expires_at_unix) in links {
699            if conv != conversation_id {
700                return Err(MandateError::ConversationMismatch(label));
701            }
702            if expires_at_unix <= now_unix {
703                return Err(MandateError::Expired {
704                    kind: label,
705                    expires_at_unix,
706                });
707            }
708        }
709
710        // Per-link signer trust. The Intent link ADDITIONALLY trusts the
711        // persona's own recorded signing key when it is exactly the key
712        // that signed this Intent AND the caller's trust evidence is itself
713        // fresh and non-revoked as of `now_unix` (checked here, not merely
714        // asserted by the caller); the Cart and Payment links ALWAYS trust
715        // only the platform issuer key, even when the Intent above them is
716        // user-signed — see the doc comment above for why.
717        let intent_user_signed = persona_signer.is_some_and(|trust| {
718            trust.is_trustworthy(now_unix) && intent.signer_public_key == trust.signing_public_key
719        });
720        if !intent_user_signed && intent.signer_public_key != issuer_public_key {
721            return Err(MandateError::UntrustedSigner("intent"));
722        }
723        if cart.signer_public_key != issuer_public_key {
724            return Err(MandateError::UntrustedSigner("cart"));
725        }
726        if payment.signer_public_key != issuer_public_key {
727            return Err(MandateError::UntrustedSigner("payment"));
728        }
729        if intent.caller != cart.caller || cart.caller != payment.caller {
730            return Err(MandateError::CallerMismatch);
731        }
732
733        // Chain links: each child signed the sha256 of its parent's FULL
734        // signed payload, so the reference commits to one exact,
735        // already-verified artifact — a re-signed parent (even over
736        // byte-identical fields) breaks the chain.
737        if cart.intent_hash != mandate_hash(&self.intent) {
738            return Err(MandateError::CartNotChainedToIntent);
739        }
740        if payment.cart_hash != mandate_hash(&self.cart) {
741            return Err(MandateError::PaymentNotChainedToCart);
742        }
743
744        // Amounts narrow down the chain. The Intent's ceiling may be empty
745        // (unbounded — the Cart's own signed amount still bounds spend);
746        // the Cart/Payment amounts must parse or the chain authorizes
747        // nothing.
748        let cart_amount: u128 = cart
749            .amount_base_units
750            .parse()
751            .map_err(|_| MandateError::MalformedAmount("cart"))?;
752        let payment_amount: u128 = payment
753            .amount_base_units
754            .parse()
755            .map_err(|_| MandateError::MalformedAmount("payment"))?;
756        if !intent.max_total_base_units.is_empty() {
757            let ceiling: u128 = intent
758                .max_total_base_units
759                .parse()
760                .map_err(|_| MandateError::MalformedAmount("intent"))?;
761            if cart_amount > ceiling {
762                return Err(MandateError::AmountWidensAtCart {
763                    cart: cart_amount,
764                    intent: ceiling,
765                });
766            }
767        }
768        if payment_amount > cart_amount {
769            return Err(MandateError::AmountWidensAtPayment {
770                payment: payment_amount,
771                cart: cart_amount,
772            });
773        }
774
775        if intent.currency != cart.currency || cart.currency != payment.currency {
776            return Err(MandateError::CurrencyMismatch);
777        }
778        if cart.merchant_host.is_empty() {
779            return Err(MandateError::EmptyScope);
780        }
781
782        Ok(VerifiedMandateChain {
783            authorized_amount_base_units: payment_amount,
784            merchant_host: cart.merchant_host.clone(),
785            currency: payment.currency.clone(),
786            caller: payment.caller.clone(),
787            conversation_id: payment.conversation_id.clone(),
788            args_json: payment.args_json.clone(),
789            intent,
790            cart,
791            payment,
792        })
793    }
794}
795
796/// A fully chain-validated mandate: the authoritative pre-authorization for
797/// exactly one payment.
798#[derive(Debug, Clone)]
799pub struct VerifiedMandateChain {
800    /// The maximum this chain authorizes spending — the Payment link's
801    /// exact base-unit amount.
802    pub authorized_amount_base_units: u128,
803    /// The merchant host the chain authorizes spend against (from the
804    /// Cart).
805    pub merchant_host: String,
806    /// The settlement currency the chain is denominated in.
807    pub currency: String,
808    /// The principal the chain is bound to.
809    pub caller: String,
810    /// The conversation the chain is bound to.
811    pub conversation_id: String,
812    /// The exact `args_json` the Payment link authorizes.
813    pub args_json: String,
814    /// The verified Intent link.
815    pub intent: VerifiedIntentMandate,
816    /// The verified Cart link.
817    pub cart: VerifiedCartMandate,
818    /// The verified Payment link.
819    pub payment: VerifiedPaymentMandate,
820}
821
822impl VerifiedMandateChain {
823    /// Authorize a proposed spend: `requested_base_units` against
824    /// `requested_host`, fulfilling the call whose arguments are
825    /// `args_json`.
826    ///
827    /// The pre-sign check the payment proxy calls at its enforcement seam,
828    /// alongside a pre-signing spend cap's own authorize check. Three
829    /// bindings, all fail-closed:
830    ///
831    /// * **destination** — `requested_host` must equal the Cart's merchant
832    ///   scope (ASCII case-insensitively; hosts are DNS names);
833    /// * **call** — `args_json` must value-match the exact args the Payment
834    ///   link signed (canonicalized with the same rules as the approval
835    ///   binding, so key order does not matter but any key/value difference
836    ///   refuses);
837    /// * **amount** — `requested_base_units` must not exceed the Payment
838    ///   link's amount.
839    ///
840    /// # Errors
841    ///
842    /// [`MandateError::ExceedsAuthorization`] on a host or amount breach;
843    /// [`MandateError::ArgsBindingMismatch`] when the call's args are not
844    /// the ones the mandate was minted for.
845    pub fn authorize(
846        &self,
847        requested_base_units: u128,
848        requested_host: &str,
849        args_json: &str,
850    ) -> Result<(), MandateError> {
851        if canon_args(args_json) != canon_args(&self.args_json) {
852            return Err(MandateError::ArgsBindingMismatch);
853        }
854        let host_ok = self.merchant_host.eq_ignore_ascii_case(requested_host);
855        if !host_ok || requested_base_units > self.authorized_amount_base_units {
856            return Err(MandateError::ExceedsAuthorization {
857                authorized: self.authorized_amount_base_units,
858                authorized_host: self.merchant_host.clone(),
859                requested: requested_base_units,
860                requested_host: requested_host.to_owned(),
861            });
862        }
863        Ok(())
864    }
865}
866
867/// The feature-gated pre-authorization resolver the payment proxy calls at
868/// its enforcement seam (`proxy::fulfill`).
869///
870/// Returns `Ok(None)` — a no-op, today's spend-cap-only behavior UNCHANGED —
871/// unless BOTH a chain is presented AND an issuer key is configured
872/// (`TEMPO_MANDATE_ISSUER_PUBKEY`; unset by default, so mandates are off by
873/// default). When both are present the chain must validate end-to-end
874/// ([`MandateChain::verify`]) or the payment is refused — a
875/// presented-but-invalid mandate is never silently ignored.
876///
877/// `persona_signer` is threaded straight through to [`MandateChain::verify`],
878/// which re-checks it (revocation and freshness, not just key equality)
879/// before ever trusting it: `Some` only when the caller has resolved a
880/// `PersonaCredential` for the persona behind `conversation_id`; `None` (no
881/// credential, or the caller didn't resolve one), or `Some` evidence that
882/// turns out revoked or stale, leaves the Intent link on today's
883/// platform-only path.
884///
885/// # Errors
886///
887/// See [`MandateChain::verify`].
888pub fn resolve(
889    chain: Option<&MandateChain>,
890    issuer_public_key: Option<&[u8]>,
891    persona_signer: Option<PersonaSignerTrust<'_>>,
892    conversation_id: &str,
893    now_unix: u64,
894) -> Result<Option<VerifiedMandateChain>, MandateError> {
895    match (chain, issuer_public_key) {
896        (Some(c), Some(key)) => c
897            .verify(conversation_id, key, persona_signer, now_unix)
898            .map(Some),
899        _ => Ok(None),
900    }
901}
902
903/// Formalize an ALREADY-verified Slack/Telegram HITL approval
904/// ([`VerifiedResponse`]) as a signed Payment mandate chained to
905/// `cart_payload`.
906///
907/// This is the HITL→mandate bridge: it mints NO new approval surface and
908/// trusts NOTHING beyond what the existing signed-approval machinery
909/// already verified. A Payment mandate is minted only when:
910///
911/// * `approved` authorizes the EXACT `(request_id, tool_name, args_json)`
912///   tuple being fulfilled ([`VerifiedResponse::authorizes_call`] — the
913///   same binding the proxy's approval gate requires; a denial or an
914///   approval for any other call refuses);
915/// * `cart_payload` is a validly signed Cart mandate whose `caller` and
916///   `conversation_id` equal the approval's own signed values — a cart
917///   scoped to a different principal or conversation than the human who
918///   approved cannot be completed under that approval.
919///
920/// The minted Payment inherits the Cart's amount and currency (an exact
921/// narrowing: it authorizes the whole cart, nothing more), binds the
922/// approved `args_json`, and chains to the Cart by [`mandate_hash`].
923/// Returns the full signed payload bytes, ready to persist or present in a
924/// [`MandateChain`].
925///
926/// # Errors
927///
928/// [`MandateError::ApprovalDoesNotAuthorizeCall`] when the approval does
929/// not cover the exact call; [`MandateError::InvalidCart`] when
930/// `cart_payload` does not verify; [`MandateError::CallerMismatch`] /
931/// [`MandateError::ConversationMismatch`] when the cart is scoped to a
932/// different principal / conversation than the approval.
933#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the minted mandate
934pub fn payment_mandate_from_approval(
935    approved: &VerifiedResponse,
936    request_id: &str,
937    tool_name: &str,
938    args_json: &str,
939    cart_payload: &[u8],
940    issued_at_unix: u64,
941    expires_at_unix: u64,
942    nonce: &str,
943    signer: &Signer,
944) -> Result<Vec<u8>, MandateError> {
945    if !approved.authorizes_call(request_id, tool_name, args_json) {
946        return Err(MandateError::ApprovalDoesNotAuthorizeCall);
947    }
948    let cart = verify_signed_cart_mandate(cart_payload).ok_or(MandateError::InvalidCart)?;
949    if cart.caller != approved.caller {
950        return Err(MandateError::CallerMismatch);
951    }
952    if cart.conversation_id != approved.conversation_id {
953        return Err(MandateError::ConversationMismatch("cart"));
954    }
955    let fields = PaymentFields {
956        cart_hash: &mandate_hash(cart_payload),
957        caller: &approved.caller,
958        conversation_id: &approved.conversation_id,
959        args_json,
960        currency: &cart.currency,
961        amount_base_units: &cart.amount_base_units,
962        issued_at_unix,
963        expires_at_unix,
964        nonce,
965    };
966    let (payload, _sig, _pk) = sign_payment_mandate(&fields, signer);
967    Ok(payload)
968}
969
970#[cfg(test)]
971mod tests {
972    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
973
974    use super::*;
975    use crate::approval::{ApprovalSigner, response_payload, verify_signed_response};
976
977    /// Trust evidence for an active, freshly-checked persona credential —
978    /// the happy path a caller presents when its `PersonaCredential` lookup
979    /// found the credential un-revoked just now.
980    fn active_persona_trust(signing_public_key: &[u8], now_unix: u64) -> PersonaSignerTrust<'_> {
981        PersonaSignerTrust {
982            signing_public_key,
983            revoked: false,
984            checked_at_unix: now_unix,
985        }
986    }
987
988    /// Trust evidence for a credential the caller's lookup found revoked —
989    /// distinct from `None` (no credential resolved at all): this is the
990    /// shape a since-revoked credential takes when a caller still presents
991    /// it, and `MandateChain::verify` must refuse it exactly like `None`.
992    fn revoked_persona_trust(signing_public_key: &[u8], now_unix: u64) -> PersonaSignerTrust<'_> {
993        PersonaSignerTrust {
994            signing_public_key,
995            revoked: true,
996            checked_at_unix: now_unix,
997        }
998    }
999
1000    fn intent(signer: &Signer) -> (Vec<u8>, IntentFields<'static>) {
1001        let fields = IntentFields {
1002            caller: "slack:T1:U9",
1003            conversation_id: "conv-1",
1004            scope_description: "research report purchases",
1005            currency: "0xUSD",
1006            max_total_base_units: "1000000",
1007            issued_at_unix: 1_000,
1008            expires_at_unix: 10_000,
1009            nonce: "intent-nonce-1",
1010        };
1011        let (payload, _sig, _pk) = sign_intent_mandate(&fields, signer);
1012        (payload, fields)
1013    }
1014
1015    #[test]
1016    fn intent_mandate_round_trips() {
1017        let signer = Signer::from_seed(1);
1018        let (payload, fields) = intent(&signer);
1019        let verified = verify_signed_intent_mandate(&payload).expect("verifies");
1020        assert_eq!(verified.caller, fields.caller);
1021        assert_eq!(verified.conversation_id, fields.conversation_id);
1022        assert_eq!(verified.max_total_base_units, fields.max_total_base_units);
1023        assert_eq!(verified.signer_public_key, signer.public_key_bytes());
1024    }
1025
1026    #[test]
1027    fn intent_mandate_tampered_amount_fails() {
1028        let signer = Signer::from_seed(1);
1029        let (payload, _fields) = intent(&signer);
1030        let mut v: Value = serde_json::from_slice(&payload).unwrap();
1031        v["max_total_base_units"] = Value::String("999999999".to_owned());
1032        assert!(verify_signed_intent_mandate(&v.to_string().into_bytes()).is_none());
1033    }
1034
1035    #[test]
1036    fn intent_mandate_wrong_kind_rejected() {
1037        // A Cart payload must never verify as an Intent, even before the
1038        // signature is checked — the literal `kind` tag is the first gate.
1039        let signer = Signer::from_seed(1);
1040        let cart_fields = CartFields {
1041            intent_hash: "deadbeef",
1042            caller: "slack:T1:U9",
1043            conversation_id: "conv-1",
1044            merchant_host: "api.example.com",
1045            currency: "0xUSD",
1046            amount_base_units: "500000",
1047            issued_at_unix: 1_000,
1048            expires_at_unix: 5_000,
1049            nonce: "cart-nonce-1",
1050        };
1051        let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1052        assert!(verify_signed_intent_mandate(&cart_payload).is_none());
1053    }
1054
1055    #[test]
1056    fn cart_mandate_round_trips_and_chains_by_hash() {
1057        let signer = Signer::from_seed(2);
1058        let (intent_payload, _fields) = intent(&signer);
1059        let intent_hash = mandate_hash(&intent_payload);
1060
1061        let cart_fields = CartFields {
1062            intent_hash: &intent_hash,
1063            caller: "slack:T1:U9",
1064            conversation_id: "conv-1",
1065            merchant_host: "api.example.com",
1066            currency: "0xUSD",
1067            amount_base_units: "500000",
1068            issued_at_unix: 1_000,
1069            expires_at_unix: 5_000,
1070            nonce: "cart-nonce-1",
1071        };
1072        let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1073        let verified = verify_signed_cart_mandate(&cart_payload).expect("cart verifies");
1074        assert_eq!(verified.intent_hash, intent_hash);
1075        assert_eq!(verified.merchant_host, "api.example.com");
1076    }
1077
1078    #[test]
1079    fn cart_mandate_tampered_intent_hash_fails() {
1080        let signer = Signer::from_seed(2);
1081        let (intent_payload, _fields) = intent(&signer);
1082        let intent_hash = mandate_hash(&intent_payload);
1083        let cart_fields = CartFields {
1084            intent_hash: &intent_hash,
1085            caller: "slack:T1:U9",
1086            conversation_id: "conv-1",
1087            merchant_host: "api.example.com",
1088            currency: "0xUSD",
1089            amount_base_units: "500000",
1090            issued_at_unix: 1_000,
1091            expires_at_unix: 5_000,
1092            nonce: "cart-nonce-1",
1093        };
1094        let (payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1095        let mut v: Value = serde_json::from_slice(&payload).unwrap();
1096        v["intent_hash"] = Value::String("0".repeat(64));
1097        assert!(verify_signed_cart_mandate(&v.to_string().into_bytes()).is_none());
1098    }
1099
1100    #[test]
1101    fn payment_mandate_round_trips_and_chains_by_hash() {
1102        let signer = Signer::from_seed(3);
1103        let (intent_payload, _fields) = intent(&signer);
1104        let intent_hash = mandate_hash(&intent_payload);
1105        let cart_fields = CartFields {
1106            intent_hash: &intent_hash,
1107            caller: "slack:T1:U9",
1108            conversation_id: "conv-1",
1109            merchant_host: "api.example.com",
1110            currency: "0xUSD",
1111            amount_base_units: "500000",
1112            issued_at_unix: 1_000,
1113            expires_at_unix: 5_000,
1114            nonce: "cart-nonce-1",
1115        };
1116        let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1117        let cart_hash = mandate_hash(&cart_payload);
1118
1119        let payment_fields = PaymentFields {
1120            cart_hash: &cart_hash,
1121            caller: "slack:T1:U9",
1122            conversation_id: "conv-1",
1123            args_json: r#"{"url":"https://api.example.com/report"}"#,
1124            currency: "0xUSD",
1125            amount_base_units: "250000",
1126            issued_at_unix: 1_000,
1127            expires_at_unix: 4_000,
1128            nonce: "payment-nonce-1",
1129        };
1130        let (payment_payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
1131        let verified = verify_signed_payment_mandate(&payment_payload).expect("payment verifies");
1132        assert_eq!(verified.cart_hash, cart_hash);
1133        assert_eq!(verified.amount_base_units, "250000");
1134        assert_eq!(verified.args_json, payment_fields.args_json);
1135    }
1136
1137    #[test]
1138    fn payment_mandate_tampered_args_json_fails() {
1139        let signer = Signer::from_seed(3);
1140        let payment_fields = PaymentFields {
1141            cart_hash: "deadbeef",
1142            caller: "slack:T1:U9",
1143            conversation_id: "conv-1",
1144            args_json: r#"{"url":"https://api.example.com/report"}"#,
1145            currency: "0xUSD",
1146            amount_base_units: "250000",
1147            issued_at_unix: 1_000,
1148            expires_at_unix: 4_000,
1149            nonce: "payment-nonce-1",
1150        };
1151        let (payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
1152        let mut v: Value = serde_json::from_slice(&payload).unwrap();
1153        v["args_json"] = Value::String(r#"{"url":"https://evil.example.com/steal"}"#.to_owned());
1154        assert!(verify_signed_payment_mandate(&v.to_string().into_bytes()).is_none());
1155    }
1156
1157    #[test]
1158    fn mandate_hash_is_stable_and_sensitive_to_signature() {
1159        let signer_a = Signer::from_seed(9);
1160        let signer_b = Signer::from_seed(10);
1161        let fields = IntentFields {
1162            caller: "slack:T1:U9",
1163            conversation_id: "conv-1",
1164            scope_description: "x",
1165            currency: "0xUSD",
1166            max_total_base_units: "1000",
1167            issued_at_unix: 1,
1168            expires_at_unix: 2,
1169            nonce: "n",
1170        };
1171        let (payload_a, _s, _p) = sign_intent_mandate(&fields, &signer_a);
1172        let (payload_b, _s, _p) = sign_intent_mandate(&fields, &signer_b);
1173        // Same fields, deterministic re-hash.
1174        assert_eq!(mandate_hash(&payload_a), mandate_hash(&payload_a));
1175        // Different signer over IDENTICAL fields ⇒ a different signature ⇒ a
1176        // different hash, since the hash commits to the full signed artifact.
1177        assert_ne!(mandate_hash(&payload_a), mandate_hash(&payload_b));
1178    }
1179
1180    #[test]
1181    fn garbage_payload_returns_none_not_panic() {
1182        assert!(verify_signed_intent_mandate(b"not json").is_none());
1183        assert!(verify_signed_cart_mandate(b"{}").is_none());
1184        assert!(verify_signed_payment_mandate(b"[]").is_none());
1185    }
1186
1187    const CONV: &str = "conv-1";
1188    const CALLER: &str = "slack:T1:U9";
1189    const HOST: &str = "api.example.com";
1190    const USD: &str = "0x20c0000000000000000000000000000000000000";
1191    const ARGS: &str = r#"{"url":"https://api.example.com/report"}"#;
1192
1193    fn issuer() -> Signer {
1194        Signer::from_seed(99)
1195    }
1196
1197    fn intent_fields(max_total: &str) -> IntentFields<'_> {
1198        IntentFields {
1199            caller: CALLER,
1200            conversation_id: CONV,
1201            scope_description: "research report purchases",
1202            currency: USD,
1203            max_total_base_units: max_total,
1204            issued_at_unix: 100,
1205            expires_at_unix: 10_000,
1206            nonce: "n-intent",
1207        }
1208    }
1209
1210    /// Sign a mutually consistent, valid chain: Intent ceiling 1_000_000,
1211    /// Cart 500_000 at `HOST`, Payment 500_000 bound to `ARGS`.
1212    fn valid_chain(signer: &Signer) -> MandateChain {
1213        let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), signer);
1214        let intent_hash = mandate_hash(&intent);
1215        let (cart, _s, _p) = sign_cart_mandate(
1216            &CartFields {
1217                intent_hash: &intent_hash,
1218                caller: CALLER,
1219                conversation_id: CONV,
1220                merchant_host: HOST,
1221                currency: USD,
1222                amount_base_units: "500000",
1223                issued_at_unix: 100,
1224                expires_at_unix: 9_000,
1225                nonce: "n-cart",
1226            },
1227            signer,
1228        );
1229        let cart_hash = mandate_hash(&cart);
1230        let (payment, _s, _p) = sign_payment_mandate(
1231            &PaymentFields {
1232                cart_hash: &cart_hash,
1233                caller: CALLER,
1234                conversation_id: CONV,
1235                args_json: ARGS,
1236                currency: USD,
1237                amount_base_units: "500000",
1238                issued_at_unix: 100,
1239                expires_at_unix: 8_000,
1240                nonce: "n-payment",
1241            },
1242            signer,
1243        );
1244        MandateChain {
1245            intent,
1246            cart,
1247            payment,
1248        }
1249    }
1250
1251    #[test]
1252    fn valid_chain_verifies_and_authorizes_within_bounds() {
1253        let signer = issuer();
1254        let chain = valid_chain(&signer);
1255        let verified = chain
1256            .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1257            .expect("a mutually consistent, unexpired chain must verify");
1258        assert_eq!(verified.authorized_amount_base_units, 500_000);
1259        assert_eq!(verified.merchant_host, HOST);
1260        assert_eq!(verified.caller, CALLER);
1261
1262        // At and under the ceiling, right host, exact args: authorized.
1263        verified.authorize(500_000, HOST, ARGS).expect("at-cap ok");
1264        verified.authorize(1, HOST, ARGS).expect("under-cap ok");
1265        // Host matching is case-insensitive (hosts are DNS names).
1266        verified
1267            .authorize(500_000, "API.Example.COM", ARGS)
1268            .expect("case-insensitive host");
1269
1270        // Over the authorized amount: refused.
1271        assert!(matches!(
1272            verified.authorize(500_001, HOST, ARGS),
1273            Err(MandateError::ExceedsAuthorization { .. })
1274        ));
1275        // Wrong destination: refused even for one base unit.
1276        assert!(matches!(
1277            verified.authorize(1, "evil.example.com", ARGS),
1278            Err(MandateError::ExceedsAuthorization { .. })
1279        ));
1280        // Different call args: refused — a mandate minted for one call
1281        // cannot authorize a different one.
1282        assert!(matches!(
1283            verified.authorize(1, HOST, r#"{"url":"https://api.example.com/OTHER"}"#),
1284            Err(MandateError::ArgsBindingMismatch)
1285        ));
1286    }
1287
1288    #[test]
1289    fn args_binding_matches_by_value_not_key_order() {
1290        // Mirrors the approval binding's canon_args behavior: same
1291        // key/value pairs in a different order still authorize; the same
1292        // loop that bit the HITL gate must not bite mandates.
1293        let signer = issuer();
1294        let (intent, _s, _p) = sign_intent_mandate(&intent_fields(""), &signer);
1295        let intent_hash = mandate_hash(&intent);
1296        let (cart, _s, _p) = sign_cart_mandate(
1297            &CartFields {
1298                intent_hash: &intent_hash,
1299                caller: CALLER,
1300                conversation_id: CONV,
1301                merchant_host: HOST,
1302                currency: USD,
1303                amount_base_units: "500000",
1304                issued_at_unix: 100,
1305                expires_at_unix: 9_000,
1306                nonce: "n-cart",
1307            },
1308            &signer,
1309        );
1310        let (payment, _s, _p) = sign_payment_mandate(
1311            &PaymentFields {
1312                cart_hash: &mandate_hash(&cart),
1313                caller: CALLER,
1314                conversation_id: CONV,
1315                args_json: r#"{"max_spend":"0.10","url":"https://api.example.com/r"}"#,
1316                currency: USD,
1317                amount_base_units: "500000",
1318                issued_at_unix: 100,
1319                expires_at_unix: 8_000,
1320                nonce: "n-payment",
1321            },
1322            &signer,
1323        );
1324        let verified = MandateChain {
1325            intent,
1326            cart,
1327            payment,
1328        }
1329        .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1330        .expect("chain verifies");
1331        verified
1332            .authorize(
1333                1,
1334                HOST,
1335                r#"{"url":"https://api.example.com/r","max_spend":"0.10"}"#,
1336            )
1337            .expect("reordered-but-equal args must pass the binding");
1338    }
1339
1340    #[test]
1341    fn tampered_link_fails_signature_verification() {
1342        let signer = issuer();
1343        let mut chain = valid_chain(&signer);
1344        // Flip the cart's amount in place: the signature no longer covers
1345        // the bytes, so the whole chain is refused as InvalidCart.
1346        let mut v: serde_json::Value = serde_json::from_slice(&chain.cart).unwrap();
1347        v["amount_base_units"] = serde_json::Value::String("999999999".to_owned());
1348        chain.cart = v.to_string().into_bytes();
1349        assert_eq!(
1350            chain
1351                .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1352                .unwrap_err(),
1353            MandateError::InvalidCart
1354        );
1355    }
1356
1357    #[test]
1358    fn validly_signed_but_unchained_links_are_rejected() {
1359        // Every link validly signed by the trusted issuer — but the cart
1360        // references a DIFFERENT intent. The chain-hash check itself must
1361        // refuse (the attack a signature check alone cannot catch).
1362        let signer = issuer();
1363        let chain = valid_chain(&signer);
1364        let (other_intent, _s, _p) = sign_intent_mandate(&intent_fields("2000000"), &signer);
1365        let other_hash = mandate_hash(&other_intent);
1366        let (unchained_cart, _s, _p) = sign_cart_mandate(
1367            &CartFields {
1368                intent_hash: &other_hash, // not the presented intent
1369                caller: CALLER,
1370                conversation_id: CONV,
1371                merchant_host: HOST,
1372                currency: USD,
1373                amount_base_units: "500000",
1374                issued_at_unix: 100,
1375                expires_at_unix: 9_000,
1376                nonce: "n-cart",
1377            },
1378            &signer,
1379        );
1380        let broken = MandateChain {
1381            intent: chain.intent.clone(),
1382            cart: unchained_cart.clone(),
1383            payment: chain.payment.clone(),
1384        };
1385        assert_eq!(
1386            broken
1387                .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1388                .unwrap_err(),
1389            MandateError::CartNotChainedToIntent
1390        );
1391
1392        // Same for the payment link: correctly chained cart, but a payment
1393        // referencing a different cart.
1394        let (payment_for_other, _s, _p) = sign_payment_mandate(
1395            &PaymentFields {
1396                cart_hash: &mandate_hash(&unchained_cart),
1397                caller: CALLER,
1398                conversation_id: CONV,
1399                args_json: ARGS,
1400                currency: USD,
1401                amount_base_units: "500000",
1402                issued_at_unix: 100,
1403                expires_at_unix: 8_000,
1404                nonce: "n-payment",
1405            },
1406            &signer,
1407        );
1408        let broken = MandateChain {
1409            intent: chain.intent,
1410            cart: chain.cart,
1411            payment: payment_for_other,
1412        };
1413        assert_eq!(
1414            broken
1415                .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1416                .unwrap_err(),
1417            MandateError::PaymentNotChainedToCart
1418        );
1419    }
1420
1421    #[test]
1422    fn widened_amounts_are_rejected() {
1423        let signer = issuer();
1424        // Cart widens past the intent ceiling.
1425        let (intent, _s, _p) = sign_intent_mandate(&intent_fields("100"), &signer);
1426        let intent_hash = mandate_hash(&intent);
1427        let (cart, _s, _p) = sign_cart_mandate(
1428            &CartFields {
1429                intent_hash: &intent_hash,
1430                caller: CALLER,
1431                conversation_id: CONV,
1432                merchant_host: HOST,
1433                currency: USD,
1434                amount_base_units: "999", // > the 100 ceiling
1435                issued_at_unix: 100,
1436                expires_at_unix: 9_000,
1437                nonce: "n-cart",
1438            },
1439            &signer,
1440        );
1441        let (payment, _s, _p) = sign_payment_mandate(
1442            &PaymentFields {
1443                cart_hash: &mandate_hash(&cart),
1444                caller: CALLER,
1445                conversation_id: CONV,
1446                args_json: ARGS,
1447                currency: USD,
1448                amount_base_units: "999",
1449                issued_at_unix: 100,
1450                expires_at_unix: 8_000,
1451                nonce: "n-payment",
1452            },
1453            &signer,
1454        );
1455        let chain = MandateChain {
1456            intent,
1457            cart,
1458            payment,
1459        };
1460        assert_eq!(
1461            chain
1462                .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1463                .unwrap_err(),
1464            MandateError::AmountWidensAtCart {
1465                cart: 999,
1466                intent: 100
1467            }
1468        );
1469
1470        // Payment widens past the cart.
1471        let base = valid_chain(&signer);
1472        let (over_payment, _s, _p) = sign_payment_mandate(
1473            &PaymentFields {
1474                cart_hash: &mandate_hash(&base.cart),
1475                caller: CALLER,
1476                conversation_id: CONV,
1477                args_json: ARGS,
1478                currency: USD,
1479                amount_base_units: "500001", // cart authorized 500000
1480                issued_at_unix: 100,
1481                expires_at_unix: 8_000,
1482                nonce: "n-payment",
1483            },
1484            &signer,
1485        );
1486        let chain = MandateChain {
1487            intent: base.intent,
1488            cart: base.cart,
1489            payment: over_payment,
1490        };
1491        assert_eq!(
1492            chain
1493                .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1494                .unwrap_err(),
1495            MandateError::AmountWidensAtPayment {
1496                payment: 500_001,
1497                cart: 500_000
1498            }
1499        );
1500    }
1501
1502    #[test]
1503    fn unbounded_intent_ceiling_admits_any_cart_amount() {
1504        // An Intent with an EMPTY ceiling is unbounded by design (the
1505        // cart's own signed amount still bounds spend).
1506        let signer = issuer();
1507        let (intent, _s, _p) = sign_intent_mandate(&intent_fields(""), &signer);
1508        let intent_hash = mandate_hash(&intent);
1509        let (cart, _s, _p) = sign_cart_mandate(
1510            &CartFields {
1511                intent_hash: &intent_hash,
1512                caller: CALLER,
1513                conversation_id: CONV,
1514                merchant_host: HOST,
1515                currency: USD,
1516                amount_base_units: "123456789",
1517                issued_at_unix: 100,
1518                expires_at_unix: 9_000,
1519                nonce: "n-cart",
1520            },
1521            &signer,
1522        );
1523        let (payment, _s, _p) = sign_payment_mandate(
1524            &PaymentFields {
1525                cart_hash: &mandate_hash(&cart),
1526                caller: CALLER,
1527                conversation_id: CONV,
1528                args_json: ARGS,
1529                currency: USD,
1530                amount_base_units: "123456789",
1531                issued_at_unix: 100,
1532                expires_at_unix: 8_000,
1533                nonce: "n-payment",
1534            },
1535            &signer,
1536        );
1537        let chain = MandateChain {
1538            intent,
1539            cart,
1540            payment,
1541        };
1542        let verified = chain
1543            .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1544            .expect("empty intent ceiling is unbounded");
1545        assert_eq!(verified.authorized_amount_base_units, 123_456_789);
1546    }
1547
1548    #[test]
1549    fn expired_link_is_rejected() {
1550        let signer = issuer();
1551        let chain = valid_chain(&signer);
1552        // The payment expires first (8_000). At exactly its expiry (`<=` is
1553        // expired) the chain refuses naming the payment link.
1554        assert_eq!(
1555            chain
1556                .verify(CONV, &signer.public_key_bytes(), None, 8_000)
1557                .unwrap_err(),
1558            MandateError::Expired {
1559                kind: "payment",
1560                expires_at_unix: 8_000
1561            }
1562        );
1563        // Once EVERY link has lapsed, the root (checked first) is named.
1564        assert_eq!(
1565            chain
1566                .verify(CONV, &signer.public_key_bytes(), None, 50_000)
1567                .unwrap_err(),
1568            MandateError::Expired {
1569                kind: "intent",
1570                expires_at_unix: 10_000
1571            }
1572        );
1573        // Comfortably before every expiry: verifies.
1574        assert!(
1575            chain
1576                .verify(CONV, &signer.public_key_bytes(), None, 1)
1577                .is_ok()
1578        );
1579    }
1580
1581    #[test]
1582    fn untrusted_issuer_is_rejected() {
1583        let signer = issuer();
1584        let chain = valid_chain(&signer);
1585        let wrong_key = Signer::from_seed(1).public_key_bytes();
1586        assert_eq!(
1587            chain.verify(CONV, &wrong_key, None, 1_000).unwrap_err(),
1588            MandateError::UntrustedSigner("intent")
1589        );
1590    }
1591
1592    /// Sign a chain whose Intent link is signed by `persona_signer` (the
1593    /// persona's own recorded credential key) while the Cart and Payment
1594    /// links stay signed by `issuer_signer` (the platform key) — the shape
1595    /// a user-signed Intent takes once #772's enrollment ceremony has run,
1596    /// per the PRD's "Cart/Payment always platform-signed" invariant.
1597    fn chain_with_user_signed_intent(
1598        issuer_signer: &Signer,
1599        persona_signer: &Signer,
1600    ) -> MandateChain {
1601        let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), persona_signer);
1602        let intent_hash = mandate_hash(&intent);
1603        let (cart, _s, _p) = sign_cart_mandate(
1604            &CartFields {
1605                intent_hash: &intent_hash,
1606                caller: CALLER,
1607                conversation_id: CONV,
1608                merchant_host: HOST,
1609                currency: USD,
1610                amount_base_units: "500000",
1611                issued_at_unix: 100,
1612                expires_at_unix: 9_000,
1613                nonce: "n-cart",
1614            },
1615            issuer_signer,
1616        );
1617        let cart_hash = mandate_hash(&cart);
1618        let (payment, _s, _p) = sign_payment_mandate(
1619            &PaymentFields {
1620                cart_hash: &cart_hash,
1621                caller: CALLER,
1622                conversation_id: CONV,
1623                args_json: ARGS,
1624                currency: USD,
1625                amount_base_units: "500000",
1626                issued_at_unix: 100,
1627                expires_at_unix: 8_000,
1628                nonce: "n-payment",
1629            },
1630            issuer_signer,
1631        );
1632        MandateChain {
1633            intent,
1634            cart,
1635            payment,
1636        }
1637    }
1638
1639    #[test]
1640    fn user_signed_intent_with_platform_cart_and_payment_verifies() {
1641        // The Intent is signed by the persona's own recorded credential key,
1642        // not the platform issuer key; Cart/Payment stay platform-signed, as
1643        // every link always must be. Presenting the resolved, non-revoked
1644        // persona key lets the Intent link trust it.
1645        let issuer_signer = issuer();
1646        let persona_signer = Signer::from_seed(555);
1647        let persona_key = persona_signer.public_key_bytes();
1648        let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1649        let verified = chain
1650            .verify(
1651                CONV,
1652                &issuer_signer.public_key_bytes(),
1653                Some(active_persona_trust(&persona_key, 1_000)),
1654                1_000,
1655            )
1656            .expect("a user-signed intent with platform cart/payment must verify");
1657        assert_eq!(verified.intent.signer_public_key, persona_key);
1658        assert_eq!(verified.authorized_amount_base_units, 500_000);
1659    }
1660
1661    #[test]
1662    fn platform_signed_intent_still_verifies_when_persona_has_an_active_credential() {
1663        // A persona WITH an active credential is never forced onto the
1664        // user-signed path — a platform-signed Intent (today's behavior)
1665        // still verifies even when the caller resolved and passed a persona
1666        // signing key. This feature is additive, never a hard requirement.
1667        let issuer_signer = issuer();
1668        let persona_signer = Signer::from_seed(555);
1669        let persona_key = persona_signer.public_key_bytes();
1670        let chain = valid_chain(&issuer_signer); // every link platform-signed
1671        assert!(
1672            chain
1673                .verify(
1674                    CONV,
1675                    &issuer_signer.public_key_bytes(),
1676                    Some(active_persona_trust(&persona_key, 1_000)),
1677                    1_000,
1678                )
1679                .is_ok()
1680        );
1681    }
1682
1683    #[test]
1684    fn intent_signed_by_a_non_recorded_key_is_refused() {
1685        // Neither the platform issuer key nor the persona's recorded key
1686        // signed this Intent — an unrelated third key did. Untrusted no
1687        // matter which trust anchors are configured.
1688        let issuer_signer = issuer();
1689        let persona_signer = Signer::from_seed(555);
1690        let persona_key = persona_signer.public_key_bytes();
1691        let stranger = Signer::from_seed(556);
1692        let chain = chain_with_user_signed_intent(&issuer_signer, &stranger);
1693        assert_eq!(
1694            chain
1695                .verify(
1696                    CONV,
1697                    &issuer_signer.public_key_bytes(),
1698                    Some(active_persona_trust(&persona_key, 1_000)),
1699                    1_000,
1700                )
1701                .unwrap_err(),
1702            MandateError::UntrustedSigner("intent")
1703        );
1704    }
1705
1706    #[test]
1707    fn user_signed_intent_is_refused_when_no_persona_key_is_resolved() {
1708        // The Intent IS signed by a persona credential key, but the caller
1709        // passes `None` — no credential was resolved at all. Without that
1710        // trust anchor the Intent has neither a matching persona key nor
1711        // the platform issuer key behind it, so it is refused.
1712        let issuer_signer = issuer();
1713        let persona_signer = Signer::from_seed(555);
1714        let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1715        assert_eq!(
1716            chain
1717                .verify(CONV, &issuer_signer.public_key_bytes(), None, 1_000)
1718                .unwrap_err(),
1719            MandateError::UntrustedSigner("intent")
1720        );
1721    }
1722
1723    #[test]
1724    fn user_signed_intent_is_refused_when_the_credential_is_revoked() {
1725        // Distinct from the `None` case above: a credential WAS resolved
1726        // for this exact signing key, but the caller's own lookup marked it
1727        // revoked. `MandateChain::verify` must reach the same refusal by
1728        // checking `revoked` itself — not because the caller filtered a
1729        // revoked credential out before calling in (a future lookup bug
1730        // could fail to do that), but because this function refuses to
1731        // trust revoked evidence no matter what the caller hands it.
1732        let issuer_signer = issuer();
1733        let persona_signer = Signer::from_seed(555);
1734        let persona_key = persona_signer.public_key_bytes();
1735        let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1736        assert_eq!(
1737            chain
1738                .verify(
1739                    CONV,
1740                    &issuer_signer.public_key_bytes(),
1741                    Some(revoked_persona_trust(&persona_key, 1_000)),
1742                    1_000,
1743                )
1744                .unwrap_err(),
1745            MandateError::UntrustedSigner("intent")
1746        );
1747    }
1748
1749    #[test]
1750    fn user_signed_intent_is_refused_when_the_revocation_check_is_stale() {
1751        // The credential was active and correctly matched at the moment the
1752        // caller checked it, but that check happened too long ago relative
1753        // to `now_unix` — older than `PERSONA_TRUST_MAX_AGE_SECS`. A
1754        // revocation that landed in the gap between the check and this
1755        // verification must not be silently missed, so stale evidence is
1756        // refused exactly like a revoked one.
1757        let issuer_signer = issuer();
1758        let persona_signer = Signer::from_seed(555);
1759        let persona_key = persona_signer.public_key_bytes();
1760        let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1761        // Must stay below every link's `expires_at_unix` (payment: 8_000,
1762        // the tightest) so this exercises staleness, not expiry.
1763        let now_unix = 5_000;
1764        let checked_at_unix = now_unix - PERSONA_TRUST_MAX_AGE_SECS - 1;
1765        assert_eq!(
1766            chain
1767                .verify(
1768                    CONV,
1769                    &issuer_signer.public_key_bytes(),
1770                    Some(PersonaSignerTrust {
1771                        signing_public_key: &persona_key,
1772                        revoked: false,
1773                        checked_at_unix,
1774                    }),
1775                    now_unix,
1776                )
1777                .unwrap_err(),
1778            MandateError::UntrustedSigner("intent")
1779        );
1780    }
1781
1782    #[test]
1783    fn user_signed_intent_is_refused_when_the_revocation_check_is_dated_in_the_future() {
1784        // `checked_at_unix` after `now_unix` cannot represent a real
1785        // "checked as of now" — clock skew or bogus data either way —
1786        // so it is untrustworthy regardless of how small the gap is.
1787        let issuer_signer = issuer();
1788        let persona_signer = Signer::from_seed(555);
1789        let persona_key = persona_signer.public_key_bytes();
1790        let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1791        assert_eq!(
1792            chain
1793                .verify(
1794                    CONV,
1795                    &issuer_signer.public_key_bytes(),
1796                    Some(PersonaSignerTrust {
1797                        signing_public_key: &persona_key,
1798                        revoked: false,
1799                        checked_at_unix: 1_001,
1800                    }),
1801                    1_000,
1802                )
1803                .unwrap_err(),
1804            MandateError::UntrustedSigner("intent")
1805        );
1806    }
1807
1808    #[test]
1809    fn cart_and_payment_never_trust_the_persona_key_even_when_intent_does() {
1810        // The persona key legitimately signs the Intent, but Cart/Payment
1811        // links MUST always verify against the platform issuer key — a
1812        // narrowing link (merchant, amount) is never user-signable, even
1813        // once the Intent above it is.
1814        let issuer_signer = issuer();
1815        let persona_signer = Signer::from_seed(555);
1816        let persona_key = persona_signer.public_key_bytes();
1817        let issuer_key = issuer_signer.public_key_bytes();
1818
1819        let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), &persona_signer);
1820        let intent_hash = mandate_hash(&intent);
1821
1822        // Cart signed by the PERSONA key instead of the issuer key.
1823        let (bad_cart, _s, _p) = sign_cart_mandate(
1824            &CartFields {
1825                intent_hash: &intent_hash,
1826                caller: CALLER,
1827                conversation_id: CONV,
1828                merchant_host: HOST,
1829                currency: USD,
1830                amount_base_units: "500000",
1831                issued_at_unix: 100,
1832                expires_at_unix: 9_000,
1833                nonce: "n-cart",
1834            },
1835            &persona_signer,
1836        );
1837        let (payment_for_bad_cart, _s, _p) = sign_payment_mandate(
1838            &PaymentFields {
1839                cart_hash: &mandate_hash(&bad_cart),
1840                caller: CALLER,
1841                conversation_id: CONV,
1842                args_json: ARGS,
1843                currency: USD,
1844                amount_base_units: "500000",
1845                issued_at_unix: 100,
1846                expires_at_unix: 8_000,
1847                nonce: "n-payment",
1848            },
1849            &issuer_signer,
1850        );
1851        let chain = MandateChain {
1852            intent: intent.clone(),
1853            cart: bad_cart,
1854            payment: payment_for_bad_cart,
1855        };
1856        assert_eq!(
1857            chain
1858                .verify(
1859                    CONV,
1860                    &issuer_key,
1861                    Some(active_persona_trust(&persona_key, 1_000)),
1862                    1_000,
1863                )
1864                .unwrap_err(),
1865            MandateError::UntrustedSigner("cart")
1866        );
1867
1868        // Cart platform-signed (fine), but Payment signed by the PERSONA key.
1869        let (good_cart, _s, _p) = sign_cart_mandate(
1870            &CartFields {
1871                intent_hash: &intent_hash,
1872                caller: CALLER,
1873                conversation_id: CONV,
1874                merchant_host: HOST,
1875                currency: USD,
1876                amount_base_units: "500000",
1877                issued_at_unix: 100,
1878                expires_at_unix: 9_000,
1879                nonce: "n-cart",
1880            },
1881            &issuer_signer,
1882        );
1883        let (bad_payment, _s, _p) = sign_payment_mandate(
1884            &PaymentFields {
1885                cart_hash: &mandate_hash(&good_cart),
1886                caller: CALLER,
1887                conversation_id: CONV,
1888                args_json: ARGS,
1889                currency: USD,
1890                amount_base_units: "500000",
1891                issued_at_unix: 100,
1892                expires_at_unix: 8_000,
1893                nonce: "n-payment",
1894            },
1895            &persona_signer,
1896        );
1897        let chain = MandateChain {
1898            intent,
1899            cart: good_cart,
1900            payment: bad_payment,
1901        };
1902        assert_eq!(
1903            chain
1904                .verify(
1905                    CONV,
1906                    &issuer_key,
1907                    Some(active_persona_trust(&persona_key, 1_000)),
1908                    1_000,
1909                )
1910                .unwrap_err(),
1911            MandateError::UntrustedSigner("payment")
1912        );
1913    }
1914
1915    #[test]
1916    fn splicing_a_user_signed_intent_under_a_different_chain_still_breaks_the_hash_link() {
1917        // The pre-existing chaining invariant (Cart/Payment must reference
1918        // the EXACT presented parent by hash) must still hold once an
1919        // Intent link can be user-signed — a valid, trusted, user-signed
1920        // Intent is not a license to splice it under someone else's
1921        // Cart/Payment.
1922        let issuer_signer = issuer();
1923        let persona_signer = Signer::from_seed(555);
1924        let persona_key = persona_signer.public_key_bytes();
1925
1926        // A legitimately user-signed Intent, on its own — distinct fields
1927        // (a different nonce) from the one the Cart/Payment below actually
1928        // chain to, so its hash differs.
1929        let mut foreign_fields = intent_fields("1000000");
1930        foreign_fields.nonce = "n-intent-foreign";
1931        let (foreign_intent, _s, _p) = sign_intent_mandate(&foreign_fields, &persona_signer);
1932
1933        // A Cart/Payment pair chained to a DIFFERENT (also user-signed) Intent.
1934        let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1935
1936        let spliced = MandateChain {
1937            intent: foreign_intent,
1938            cart: chain.cart,
1939            payment: chain.payment,
1940        };
1941        assert_eq!(
1942            spliced
1943                .verify(
1944                    CONV,
1945                    &issuer_signer.public_key_bytes(),
1946                    Some(active_persona_trust(&persona_key, 1_000)),
1947                    1_000,
1948                )
1949                .unwrap_err(),
1950            MandateError::CartNotChainedToIntent
1951        );
1952    }
1953
1954    #[test]
1955    fn conversation_and_caller_bindings_are_enforced() {
1956        let signer = issuer();
1957        let chain = valid_chain(&signer);
1958        // Presented in a different conversation than every link binds.
1959        assert_eq!(
1960            chain
1961                .verify("conv-OTHER", &signer.public_key_bytes(), None, 1_000)
1962                .unwrap_err(),
1963            MandateError::ConversationMismatch("intent")
1964        );
1965
1966        // A validly signed payment re-scoped to a different caller —
1967        // chained correctly, amounts fine — must still refuse.
1968        let (payment, _s, _p) = sign_payment_mandate(
1969            &PaymentFields {
1970                cart_hash: &mandate_hash(&chain.cart),
1971                caller: "slack:T1:UATTACKER",
1972                conversation_id: CONV,
1973                args_json: ARGS,
1974                currency: USD,
1975                amount_base_units: "500000",
1976                issued_at_unix: 100,
1977                expires_at_unix: 8_000,
1978                nonce: "n-payment",
1979            },
1980            &signer,
1981        );
1982        let cross_caller = MandateChain {
1983            intent: chain.intent,
1984            cart: chain.cart,
1985            payment,
1986        };
1987        assert_eq!(
1988            cross_caller
1989                .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1990                .unwrap_err(),
1991            MandateError::CallerMismatch
1992        );
1993    }
1994
1995    #[test]
1996    fn currency_mismatch_is_rejected() {
1997        let signer = issuer();
1998        let chain = valid_chain(&signer);
1999        let (payment, _s, _p) = sign_payment_mandate(
2000            &PaymentFields {
2001                cart_hash: &mandate_hash(&chain.cart),
2002                caller: CALLER,
2003                conversation_id: CONV,
2004                args_json: ARGS,
2005                currency: "0xOTHER",
2006                amount_base_units: "500000",
2007                issued_at_unix: 100,
2008                expires_at_unix: 8_000,
2009                nonce: "n-payment",
2010            },
2011            &signer,
2012        );
2013        let cross_currency = MandateChain {
2014            intent: chain.intent,
2015            cart: chain.cart,
2016            payment,
2017        };
2018        assert_eq!(
2019            cross_currency
2020                .verify(CONV, &signer.public_key_bytes(), None, 1_000)
2021                .unwrap_err(),
2022            MandateError::CurrencyMismatch
2023        );
2024    }
2025
2026    #[test]
2027    fn resolve_is_a_noop_when_unconfigured_or_absent() {
2028        let signer = issuer();
2029        let chain = valid_chain(&signer);
2030        let key = signer.public_key_bytes();
2031
2032        // No chain presented ⇒ Ok(None) regardless of issuer config —
2033        // today's HITL + caps path, unchanged.
2034        assert!(matches!(
2035            resolve(None, Some(&key), None, CONV, 1_000),
2036            Ok(None)
2037        ));
2038        assert!(matches!(resolve(None, None, None, CONV, 1_000), Ok(None)));
2039
2040        // Chain presented but NO issuer key configured (the off-by-default
2041        // feature gate): still Ok(None) — zero behavior change when
2042        // unconfigured, even with mandate bytes on the wire.
2043        assert!(matches!(
2044            resolve(Some(&chain), None, None, CONV, 1_000),
2045            Ok(None)
2046        ));
2047
2048        // Both present and valid ⇒ engaged.
2049        assert!(matches!(
2050            resolve(Some(&chain), Some(&key), None, CONV, 1_000),
2051            Ok(Some(_))
2052        ));
2053    }
2054
2055    #[test]
2056    fn resolve_fails_closed_on_an_invalid_presented_chain() {
2057        // Presented + configured but expired ⇒ Err, never silently ignored.
2058        let signer = issuer();
2059        let chain = valid_chain(&signer);
2060        let key = signer.public_key_bytes();
2061        assert!(matches!(
2062            resolve(Some(&chain), Some(&key), None, CONV, 50_000).unwrap_err(),
2063            MandateError::Expired { .. }
2064        ));
2065    }
2066
2067    #[test]
2068    fn resolve_refuses_a_revoked_persona_signer_at_the_same_seam_the_proxy_calls() {
2069        // The exact seam `payments::proxy::fulfill` calls: a caller-supplied
2070        // `PersonaSignerTrust` that turns out revoked must not let a
2071        // user-signed Intent through here either — this is `resolve`, not
2072        // just `MandateChain::verify` directly, since that's the function
2073        // signature `polyc-payments` actually depends on.
2074        let issuer_signer = issuer();
2075        let persona_signer = Signer::from_seed(555);
2076        let persona_key = persona_signer.public_key_bytes();
2077        let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
2078        let issuer_key = issuer_signer.public_key_bytes();
2079        assert_eq!(
2080            resolve(
2081                Some(&chain),
2082                Some(&issuer_key),
2083                Some(revoked_persona_trust(&persona_key, 1_000)),
2084                CONV,
2085                1_000,
2086            )
2087            .unwrap_err(),
2088            MandateError::UntrustedSigner("intent")
2089        );
2090    }
2091
2092    /// Sign a HITL `approval_response` exactly as the control plane does
2093    /// and verify it back into the [`VerifiedResponse`] the bridge takes.
2094    fn hitl_approval(approved: bool, args_json: &str) -> VerifiedResponse {
2095        let approval_signer = ApprovalSigner::from_seed(7);
2096        let (payload, _sig, _pk) = response_payload(
2097            "req-1",
2098            "paid_fetch",
2099            args_json,
2100            "",
2101            approved,
2102            false,
2103            &[],
2104            CALLER,
2105            "",
2106            "workspace-write",
2107            if approved { "looks fine" } else { "no" },
2108            "",
2109            CONV,
2110            "approval-nonce-1",
2111            &approval_signer,
2112        );
2113        verify_signed_response(&payload).expect("approval signature verifies")
2114    }
2115
2116    /// A signed intent + cart prefix for the HITL bridge tests.
2117    fn chain_prefix(signer: &Signer) -> (Vec<u8>, Vec<u8>, String) {
2118        let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), signer);
2119        let intent_hash = mandate_hash(&intent);
2120        let (cart, _s, _p) = sign_cart_mandate(
2121            &CartFields {
2122                intent_hash: &intent_hash,
2123                caller: CALLER,
2124                conversation_id: CONV,
2125                merchant_host: HOST,
2126                currency: USD,
2127                amount_base_units: "500000",
2128                issued_at_unix: 100,
2129                expires_at_unix: 9_000,
2130                nonce: "n-cart",
2131            },
2132            signer,
2133        );
2134        (intent, cart, intent_hash)
2135    }
2136
2137    #[test]
2138    fn hitl_approval_mints_a_payment_mandate_that_completes_the_chain() {
2139        let signer = issuer();
2140        let (intent, cart, _ih) = chain_prefix(&signer);
2141
2142        // The human approves the exact paid_fetch call via Slack/Telegram;
2143        // the bridge formalizes that decision as a signed Payment mandate.
2144        let approved = hitl_approval(true, ARGS);
2145        let payment = payment_mandate_from_approval(
2146            &approved,
2147            "req-1",
2148            "paid_fetch",
2149            ARGS,
2150            &cart,
2151            200,
2152            8_000,
2153            "n-payment",
2154            &signer,
2155        )
2156        .expect("the approval authorizes this exact call");
2157
2158        // The minted mandate COMPLETES a chain that verifies end-to-end and
2159        // authorizes exactly the approved call at the cart's amount/host.
2160        let chain = MandateChain {
2161            intent,
2162            cart,
2163            payment,
2164        };
2165        let verified = chain
2166            .verify(CONV, &signer.public_key_bytes(), None, 1_000)
2167            .expect("the minted payment chains to the cart");
2168        assert_eq!(verified.authorized_amount_base_units, 500_000);
2169        assert_eq!(verified.caller, CALLER);
2170        verified
2171            .authorize(500_000, HOST, ARGS)
2172            .expect("authorizes the approved call");
2173        assert!(matches!(
2174            verified.authorize(1, HOST, r#"{"url":"https://evil.example.com/x"}"#),
2175            Err(MandateError::ArgsBindingMismatch)
2176        ));
2177    }
2178
2179    #[test]
2180    fn hitl_bridge_refuses_denials_and_mismatched_scopes() {
2181        let signer = issuer();
2182        let (_intent, cart, intent_hash) = chain_prefix(&signer);
2183
2184        // A denial mints nothing.
2185        let denied = hitl_approval(false, ARGS);
2186        assert_eq!(
2187            payment_mandate_from_approval(
2188                &denied,
2189                "req-1",
2190                "paid_fetch",
2191                ARGS,
2192                &cart,
2193                200,
2194                8_000,
2195                "n",
2196                &signer
2197            )
2198            .unwrap_err(),
2199            MandateError::ApprovalDoesNotAuthorizeCall
2200        );
2201
2202        // An approval for DIFFERENT args mints nothing for this call.
2203        let approved = hitl_approval(true, ARGS);
2204        assert_eq!(
2205            payment_mandate_from_approval(
2206                &approved,
2207                "req-1",
2208                "paid_fetch",
2209                r#"{"url":"https://evil.example.com/x"}"#,
2210                &cart,
2211                200,
2212                8_000,
2213                "n",
2214                &signer
2215            )
2216            .unwrap_err(),
2217            MandateError::ApprovalDoesNotAuthorizeCall
2218        );
2219
2220        // A cart scoped to a DIFFERENT conversation than the approval
2221        // cannot be completed under it.
2222        let (foreign_cart, _s, _p) = sign_cart_mandate(
2223            &CartFields {
2224                intent_hash: &intent_hash,
2225                caller: CALLER,
2226                conversation_id: "conv-OTHER",
2227                merchant_host: HOST,
2228                currency: USD,
2229                amount_base_units: "500000",
2230                issued_at_unix: 100,
2231                expires_at_unix: 9_000,
2232                nonce: "n-cart",
2233            },
2234            &signer,
2235        );
2236        assert_eq!(
2237            payment_mandate_from_approval(
2238                &approved,
2239                "req-1",
2240                "paid_fetch",
2241                ARGS,
2242                &foreign_cart,
2243                200,
2244                8_000,
2245                "n",
2246                &signer
2247            )
2248            .unwrap_err(),
2249            MandateError::ConversationMismatch("cart")
2250        );
2251
2252        // A cart scoped to a DIFFERENT caller than the approver likewise.
2253        let (foreign_caller_cart, _s, _p) = sign_cart_mandate(
2254            &CartFields {
2255                intent_hash: &intent_hash,
2256                caller: "slack:T1:USOMEONE",
2257                conversation_id: CONV,
2258                merchant_host: HOST,
2259                currency: USD,
2260                amount_base_units: "500000",
2261                issued_at_unix: 100,
2262                expires_at_unix: 9_000,
2263                nonce: "n-cart",
2264            },
2265            &signer,
2266        );
2267        assert_eq!(
2268            payment_mandate_from_approval(
2269                &approved,
2270                "req-1",
2271                "paid_fetch",
2272                ARGS,
2273                &foreign_caller_cart,
2274                200,
2275                8_000,
2276                "n",
2277                &signer
2278            )
2279            .unwrap_err(),
2280            MandateError::CallerMismatch
2281        );
2282    }
2283}