Skip to main content

polyc_crypto/
mandate.rs

1//! Canonical signing for AP2-style pre-authorization mandates.
2//!
3//! An AP2 mandate chain is three signed artifacts, each narrowing the one
4//! before it: an **Intent** mandate (the human's up-front authorization
5//! scope), a **Cart** mandate (a specific merchant + amount drawn from that
6//! scope), and a **Payment** mandate (the exact tool call the cart pays for).
7//! Each signs the canonical JSON encoding of its fields with `signed_by` /
8//! `signature_hex` cleared — the same pattern [`crate::approval`] uses for
9//! `approval_response` / `payment_receipt` — plus a literal `kind` tag so a
10//! signed Cart can never be mistaken for a signed Intent even if their field
11//! sets happened to overlap.
12//!
13//! A child mandate references its parent by [`mandate_hash`]: the sha256 hex
14//! of the PARENT'S FULL signed payload (body + signature), not just its
15//! unsigned fields. Hashing the signature too means the reference commits to
16//! one exact, already-verified artifact — a parent re-signed by a different
17//! key (even over byte-identical fields) produces a different hash and breaks
18//! the chain.
19//!
20//! This module only mints and verifies individual signatures and computes the
21//! chain-link hash; it knows nothing about amount narrowing, expiry ordering,
22//! or which call a Payment mandate authorizes — that business logic lives in
23//! `polyc_payments::mandate`, which calls back into the `verify_signed_*`
24//! functions here exactly as [`crate::approval`]'s callers do.
25
26use serde_json::Value;
27use sha2::{Digest, Sha256};
28
29use crate::{Signer, verify};
30
31/// Signed `kind` tag for an Intent mandate's canonical JSON.
32const KIND_INTENT: &str = "ap2.intent.v1";
33/// Signed `kind` tag for a Cart mandate's canonical JSON.
34const KIND_CART: &str = "ap2.cart.v1";
35/// Signed `kind` tag for a Payment mandate's canonical JSON.
36const KIND_PAYMENT: &str = "ap2.payment.v1";
37
38/// Sha256 hex of a mandate's FULL signed payload bytes — the chain link.
39///
40/// Takes the bytes a `sign_*_mandate` function returned (or read back from
41/// storage); the result is the value a child mandate signs into its
42/// `intent_hash` / `cart_hash` field.
43#[must_use]
44pub fn mandate_hash(signed_payload: &[u8]) -> String {
45    let mut hasher = Sha256::new();
46    hasher.update(signed_payload);
47    hex_lower(&hasher.finalize())
48}
49
50/// Named signed fields for an Intent mandate — the top-level, human-granted
51/// authorization scope.
52///
53/// Passed as a single struct (mirrors [`crate::approval::ReceiptPayload`]) so
54/// two same-typed `&str` fields can't be silently swapped at a call site.
55#[derive(Debug, Clone, Copy)]
56pub struct IntentFields<'a> {
57    /// The identity that granted this authorization (mirrors
58    /// `approval_response.caller`, e.g. `slack:T1:U9`).
59    pub caller: &'a str,
60    /// The conversation this intent is scoped to.
61    pub conversation_id: &'a str,
62    /// Free-form human-readable description of what was authorized (audit /
63    /// display only; not itself a scope predicate).
64    pub scope_description: &'a str,
65    /// Settlement token contract address every descendant Cart/Payment must
66    /// match.
67    pub currency: &'a str,
68    /// Decimal base-unit ceiling on the total this intent may ultimately
69    /// authorize across every descendant Cart; empty ⇒ unbounded (a Cart's
70    /// amount is still bounded by its own signed value, just not by this
71    /// intent).
72    pub max_total_base_units: &'a str,
73    /// Unix seconds this mandate was issued.
74    pub issued_at_unix: u64,
75    /// Unix seconds after which this mandate (and every descendant) is no
76    /// longer valid.
77    pub expires_at_unix: u64,
78    /// Per-mandate unique value (mirrors `approval_response.nonce`).
79    pub nonce: &'a str,
80}
81
82impl IntentFields<'_> {
83    fn canonical_json(&self) -> Value {
84        serde_json::json!({
85            "kind": KIND_INTENT,
86            "caller": self.caller,
87            "conversation_id": self.conversation_id,
88            "scope_description": self.scope_description,
89            "currency": self.currency,
90            "max_total_base_units": self.max_total_base_units,
91            "issued_at_unix": self.issued_at_unix,
92            "expires_at_unix": self.expires_at_unix,
93            "nonce": self.nonce,
94        })
95    }
96}
97
98/// A verified, decoded Intent mandate.
99#[derive(Debug, Clone)]
100pub struct VerifiedIntentMandate {
101    /// The identity that granted this authorization.
102    pub caller: String,
103    /// The conversation this intent is scoped to.
104    pub conversation_id: String,
105    /// Human-readable description of what was authorized.
106    pub scope_description: String,
107    /// Settlement token contract address.
108    pub currency: String,
109    /// Decimal base-unit ceiling on the total; empty ⇒ unbounded.
110    pub max_total_base_units: String,
111    /// Unix seconds this mandate was issued.
112    pub issued_at_unix: u64,
113    /// Unix seconds after which this mandate is no longer valid.
114    pub expires_at_unix: u64,
115    /// Per-mandate unique value.
116    pub nonce: String,
117    /// The verified signer's public key (encoded).
118    pub signer_public_key: Vec<u8>,
119}
120
121/// Sign the canonical bytes of `fields`.
122///
123/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`; the
124/// caller persists `full_payload_bytes` and, for a Cart mandate, feeds it
125/// through [`mandate_hash`] to build the chain link.
126#[must_use]
127pub fn sign_intent_mandate(
128    fields: &IntentFields<'_>,
129    signer: &Signer,
130) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
131    sign_envelope(fields.canonical_json(), signer)
132}
133
134/// Verify a persisted Intent mandate payload.
135///
136/// Returns `Some(record)` if the signature checks out against the embedded
137/// public key and the payload carries the Intent `kind` tag. Returns `None` if the
138/// payload is malformed, the hex fields don't decode, the `kind` tag doesn't
139/// match, or the signature doesn't verify.
140#[must_use]
141pub fn verify_signed_intent_mandate(payload: &[u8]) -> Option<VerifiedIntentMandate> {
142    let v: Value = serde_json::from_slice(payload).ok()?;
143    if v.get("kind")?.as_str()? != KIND_INTENT {
144        return None;
145    }
146    let caller = v.get("caller")?.as_str()?.to_owned();
147    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
148    let scope_description = v.get("scope_description")?.as_str()?.to_owned();
149    let currency = v.get("currency")?.as_str()?.to_owned();
150    let max_total_base_units = v.get("max_total_base_units")?.as_str()?.to_owned();
151    let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
152    let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
153    let nonce = v.get("nonce")?.as_str()?.to_owned();
154    let (pk, sig) = envelope_signature(&v)?;
155
156    let fields = IntentFields {
157        caller: &caller,
158        conversation_id: &conversation_id,
159        scope_description: &scope_description,
160        currency: &currency,
161        max_total_base_units: &max_total_base_units,
162        issued_at_unix,
163        expires_at_unix,
164        nonce: &nonce,
165    };
166    if verify(&pk, &fields.canonical_json().to_string().into_bytes(), &sig) {
167        Some(VerifiedIntentMandate {
168            caller,
169            conversation_id,
170            scope_description,
171            currency,
172            max_total_base_units,
173            issued_at_unix,
174            expires_at_unix,
175            nonce,
176            signer_public_key: pk,
177        })
178    } else {
179        None
180    }
181}
182
183/// Named signed fields for a Cart mandate — narrows an Intent to a specific
184/// merchant and amount.
185#[derive(Debug, Clone, Copy)]
186pub struct CartFields<'a> {
187    /// [`mandate_hash`] of the parent Intent mandate's full signed payload.
188    pub intent_hash: &'a str,
189    /// The identity that granted this authorization (must equal the parent
190    /// Intent's `caller`; checked by the chain validator, not here).
191    pub caller: &'a str,
192    /// The conversation this cart is scoped to.
193    pub conversation_id: &'a str,
194    /// The merchant host this cart authorizes payment to (lowercased).
195    pub merchant_host: &'a str,
196    /// Settlement token contract address.
197    pub currency: &'a str,
198    /// Decimal base-unit total for this cart.
199    pub amount_base_units: &'a str,
200    /// Unix seconds this mandate was issued.
201    pub issued_at_unix: u64,
202    /// Unix seconds after which this mandate is no longer valid.
203    pub expires_at_unix: u64,
204    /// Per-mandate unique value.
205    pub nonce: &'a str,
206}
207
208impl CartFields<'_> {
209    fn canonical_json(&self) -> Value {
210        serde_json::json!({
211            "kind": KIND_CART,
212            "intent_hash": self.intent_hash,
213            "caller": self.caller,
214            "conversation_id": self.conversation_id,
215            "merchant_host": self.merchant_host,
216            "currency": self.currency,
217            "amount_base_units": self.amount_base_units,
218            "issued_at_unix": self.issued_at_unix,
219            "expires_at_unix": self.expires_at_unix,
220            "nonce": self.nonce,
221        })
222    }
223}
224
225/// A verified, decoded Cart mandate.
226#[derive(Debug, Clone)]
227pub struct VerifiedCartMandate {
228    /// [`mandate_hash`] of the parent Intent mandate this cart chains to.
229    pub intent_hash: String,
230    /// The identity that granted this authorization.
231    pub caller: String,
232    /// The conversation this cart is scoped to.
233    pub conversation_id: String,
234    /// The merchant host this cart authorizes payment to.
235    pub merchant_host: String,
236    /// Settlement token contract address.
237    pub currency: String,
238    /// Decimal base-unit total for this cart.
239    pub amount_base_units: String,
240    /// Unix seconds this mandate was issued.
241    pub issued_at_unix: u64,
242    /// Unix seconds after which this mandate is no longer valid.
243    pub expires_at_unix: u64,
244    /// Per-mandate unique value.
245    pub nonce: String,
246    /// The verified signer's public key (encoded).
247    pub signer_public_key: Vec<u8>,
248}
249
250/// Sign the canonical bytes of `fields`. Returns
251/// `(full_payload_bytes, signature_bytes, public_key_bytes)`.
252#[must_use]
253pub fn sign_cart_mandate(fields: &CartFields<'_>, signer: &Signer) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
254    sign_envelope(fields.canonical_json(), signer)
255}
256
257/// Verify a persisted Cart mandate payload. See
258/// [`verify_signed_intent_mandate`] for the failure modes.
259#[must_use]
260pub fn verify_signed_cart_mandate(payload: &[u8]) -> Option<VerifiedCartMandate> {
261    let v: Value = serde_json::from_slice(payload).ok()?;
262    if v.get("kind")?.as_str()? != KIND_CART {
263        return None;
264    }
265    let intent_hash = v.get("intent_hash")?.as_str()?.to_owned();
266    let caller = v.get("caller")?.as_str()?.to_owned();
267    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
268    let merchant_host = v.get("merchant_host")?.as_str()?.to_owned();
269    let currency = v.get("currency")?.as_str()?.to_owned();
270    let amount_base_units = v.get("amount_base_units")?.as_str()?.to_owned();
271    let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
272    let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
273    let nonce = v.get("nonce")?.as_str()?.to_owned();
274    let (pk, sig) = envelope_signature(&v)?;
275
276    let fields = CartFields {
277        intent_hash: &intent_hash,
278        caller: &caller,
279        conversation_id: &conversation_id,
280        merchant_host: &merchant_host,
281        currency: &currency,
282        amount_base_units: &amount_base_units,
283        issued_at_unix,
284        expires_at_unix,
285        nonce: &nonce,
286    };
287    if verify(&pk, &fields.canonical_json().to_string().into_bytes(), &sig) {
288        Some(VerifiedCartMandate {
289            intent_hash,
290            caller,
291            conversation_id,
292            merchant_host,
293            currency,
294            amount_base_units,
295            issued_at_unix,
296            expires_at_unix,
297            nonce,
298            signer_public_key: pk,
299        })
300    } else {
301        None
302    }
303}
304
305/// Named signed fields for a Payment mandate — the final authorization bound
306/// to one exact tool call.
307#[derive(Debug, Clone, Copy)]
308pub struct PaymentFields<'a> {
309    /// [`mandate_hash`] of the parent Cart mandate's full signed payload.
310    pub cart_hash: &'a str,
311    /// The identity that granted this authorization.
312    pub caller: &'a str,
313    /// The conversation this payment is scoped to.
314    pub conversation_id: &'a str,
315    /// The exact `paid_fetch` `args_json` this payment authorizes (mirrors
316    /// `approval_response.args_json` binding — a captured mandate cannot be
317    /// replayed against a different call).
318    pub args_json: &'a str,
319    /// Settlement token contract address.
320    pub currency: &'a str,
321    /// Decimal base-unit amount for this payment.
322    pub amount_base_units: &'a str,
323    /// Unix seconds this mandate was issued.
324    pub issued_at_unix: u64,
325    /// Unix seconds after which this mandate is no longer valid.
326    pub expires_at_unix: u64,
327    /// Per-mandate unique value.
328    pub nonce: &'a str,
329}
330
331impl PaymentFields<'_> {
332    fn canonical_json(&self) -> Value {
333        serde_json::json!({
334            "kind": KIND_PAYMENT,
335            "cart_hash": self.cart_hash,
336            "caller": self.caller,
337            "conversation_id": self.conversation_id,
338            "args_json": self.args_json,
339            "currency": self.currency,
340            "amount_base_units": self.amount_base_units,
341            "issued_at_unix": self.issued_at_unix,
342            "expires_at_unix": self.expires_at_unix,
343            "nonce": self.nonce,
344        })
345    }
346}
347
348/// A verified, decoded Payment mandate.
349#[derive(Debug, Clone)]
350pub struct VerifiedPaymentMandate {
351    /// [`mandate_hash`] of the parent Cart mandate this payment chains to.
352    pub cart_hash: String,
353    /// The identity that granted this authorization.
354    pub caller: String,
355    /// The conversation this payment is scoped to.
356    pub conversation_id: String,
357    /// The exact `paid_fetch` `args_json` this payment authorizes.
358    pub args_json: String,
359    /// Settlement token contract address.
360    pub currency: String,
361    /// Decimal base-unit amount for this payment.
362    pub amount_base_units: String,
363    /// Unix seconds this mandate was issued.
364    pub issued_at_unix: u64,
365    /// Unix seconds after which this mandate is no longer valid.
366    pub expires_at_unix: u64,
367    /// Per-mandate unique value.
368    pub nonce: String,
369    /// The verified signer's public key (encoded).
370    pub signer_public_key: Vec<u8>,
371}
372
373/// Sign the canonical bytes of `fields`. Returns
374/// `(full_payload_bytes, signature_bytes, public_key_bytes)`.
375#[must_use]
376pub fn sign_payment_mandate(
377    fields: &PaymentFields<'_>,
378    signer: &Signer,
379) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
380    sign_envelope(fields.canonical_json(), signer)
381}
382
383/// Verify a persisted Payment mandate payload. See
384/// [`verify_signed_intent_mandate`] for the failure modes.
385#[must_use]
386pub fn verify_signed_payment_mandate(payload: &[u8]) -> Option<VerifiedPaymentMandate> {
387    let v: Value = serde_json::from_slice(payload).ok()?;
388    if v.get("kind")?.as_str()? != KIND_PAYMENT {
389        return None;
390    }
391    let cart_hash = v.get("cart_hash")?.as_str()?.to_owned();
392    let caller = v.get("caller")?.as_str()?.to_owned();
393    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
394    let args_json = v.get("args_json")?.as_str()?.to_owned();
395    let currency = v.get("currency")?.as_str()?.to_owned();
396    let amount_base_units = v.get("amount_base_units")?.as_str()?.to_owned();
397    let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
398    let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
399    let nonce = v.get("nonce")?.as_str()?.to_owned();
400    let (pk, sig) = envelope_signature(&v)?;
401
402    let fields = PaymentFields {
403        cart_hash: &cart_hash,
404        caller: &caller,
405        conversation_id: &conversation_id,
406        args_json: &args_json,
407        currency: &currency,
408        amount_base_units: &amount_base_units,
409        issued_at_unix,
410        expires_at_unix,
411        nonce: &nonce,
412    };
413    if verify(&pk, &fields.canonical_json().to_string().into_bytes(), &sig) {
414        Some(VerifiedPaymentMandate {
415            cart_hash,
416            caller,
417            conversation_id,
418            args_json,
419            currency,
420            amount_base_units,
421            issued_at_unix,
422            expires_at_unix,
423            nonce,
424            signer_public_key: pk,
425        })
426    } else {
427        None
428    }
429}
430
431/// Signs `canonical` and appends the two signature fields, mirroring
432/// [`crate::approval::receipt_payload`]'s "canonical object plus signature
433/// fields" construction — the body field set stays owned by each
434/// `*Fields::canonical_json`, this only adds the envelope.
435fn sign_envelope(mut canonical: Value, signer: &Signer) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
436    let canonical_bytes = canonical.to_string().into_bytes();
437    let signature = signer.sign(&canonical_bytes);
438    let pk = signer.public_key_bytes();
439    if let Value::Object(map) = &mut canonical {
440        map.insert("signed_by".to_owned(), Value::String(hex_lower(&pk)));
441        map.insert(
442            "signature_hex".to_owned(),
443            Value::String(hex_lower(&signature)),
444        );
445    }
446    (canonical.to_string().into_bytes(), signature, pk)
447}
448
449/// Extracts and hex-decodes the `signed_by` / `signature_hex` pair a
450/// `verify_signed_*_mandate` needs, common to all three envelope shapes.
451fn envelope_signature(v: &Value) -> Option<(Vec<u8>, Vec<u8>)> {
452    let pk = hex_decode(v.get("signed_by")?.as_str()?)?;
453    let sig = hex_decode(v.get("signature_hex")?.as_str()?)?;
454    Some((pk, sig))
455}
456
457fn hex_lower(bytes: &[u8]) -> String {
458    let mut s = String::with_capacity(bytes.len() * 2);
459    for b in bytes {
460        use std::fmt::Write as _;
461        let _ = write!(&mut s, "{b:02x}");
462    }
463    s
464}
465
466fn hex_decode(s: &str) -> Option<Vec<u8>> {
467    if !s.len().is_multiple_of(2) {
468        return None;
469    }
470    (0..s.len())
471        .step_by(2)
472        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
473        .collect()
474}
475
476#[cfg(test)]
477mod tests {
478    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
479
480    use super::*;
481
482    fn intent(signer: &Signer) -> (Vec<u8>, IntentFields<'static>) {
483        let fields = IntentFields {
484            caller: "slack:T1:U9",
485            conversation_id: "conv-1",
486            scope_description: "research report purchases",
487            currency: "0xUSD",
488            max_total_base_units: "1000000",
489            issued_at_unix: 1_000,
490            expires_at_unix: 10_000,
491            nonce: "intent-nonce-1",
492        };
493        let (payload, _sig, _pk) = sign_intent_mandate(&fields, signer);
494        (payload, fields)
495    }
496
497    #[test]
498    fn intent_mandate_round_trips() {
499        let signer = Signer::from_seed(1);
500        let (payload, fields) = intent(&signer);
501        let verified = verify_signed_intent_mandate(&payload).expect("verifies");
502        assert_eq!(verified.caller, fields.caller);
503        assert_eq!(verified.conversation_id, fields.conversation_id);
504        assert_eq!(verified.max_total_base_units, fields.max_total_base_units);
505        assert_eq!(verified.signer_public_key, signer.public_key_bytes());
506    }
507
508    #[test]
509    fn intent_mandate_tampered_amount_fails() {
510        let signer = Signer::from_seed(1);
511        let (payload, _fields) = intent(&signer);
512        let mut v: Value = serde_json::from_slice(&payload).unwrap();
513        v["max_total_base_units"] = Value::String("999999999".to_owned());
514        assert!(verify_signed_intent_mandate(&v.to_string().into_bytes()).is_none());
515    }
516
517    #[test]
518    fn intent_mandate_wrong_kind_rejected() {
519        // A Cart payload must never verify as an Intent, even before the
520        // signature is checked — the literal `kind` tag is the first gate.
521        let signer = Signer::from_seed(1);
522        let cart_fields = CartFields {
523            intent_hash: "deadbeef",
524            caller: "slack:T1:U9",
525            conversation_id: "conv-1",
526            merchant_host: "api.example.com",
527            currency: "0xUSD",
528            amount_base_units: "500000",
529            issued_at_unix: 1_000,
530            expires_at_unix: 5_000,
531            nonce: "cart-nonce-1",
532        };
533        let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
534        assert!(verify_signed_intent_mandate(&cart_payload).is_none());
535    }
536
537    #[test]
538    fn cart_mandate_round_trips_and_chains_by_hash() {
539        let signer = Signer::from_seed(2);
540        let (intent_payload, _fields) = intent(&signer);
541        let intent_hash = mandate_hash(&intent_payload);
542
543        let cart_fields = CartFields {
544            intent_hash: &intent_hash,
545            caller: "slack:T1:U9",
546            conversation_id: "conv-1",
547            merchant_host: "api.example.com",
548            currency: "0xUSD",
549            amount_base_units: "500000",
550            issued_at_unix: 1_000,
551            expires_at_unix: 5_000,
552            nonce: "cart-nonce-1",
553        };
554        let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
555        let verified = verify_signed_cart_mandate(&cart_payload).expect("cart verifies");
556        assert_eq!(verified.intent_hash, intent_hash);
557        assert_eq!(verified.merchant_host, "api.example.com");
558    }
559
560    #[test]
561    fn cart_mandate_tampered_intent_hash_fails() {
562        let signer = Signer::from_seed(2);
563        let (intent_payload, _fields) = intent(&signer);
564        let intent_hash = mandate_hash(&intent_payload);
565        let cart_fields = CartFields {
566            intent_hash: &intent_hash,
567            caller: "slack:T1:U9",
568            conversation_id: "conv-1",
569            merchant_host: "api.example.com",
570            currency: "0xUSD",
571            amount_base_units: "500000",
572            issued_at_unix: 1_000,
573            expires_at_unix: 5_000,
574            nonce: "cart-nonce-1",
575        };
576        let (payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
577        let mut v: Value = serde_json::from_slice(&payload).unwrap();
578        v["intent_hash"] = Value::String("0".repeat(64));
579        assert!(verify_signed_cart_mandate(&v.to_string().into_bytes()).is_none());
580    }
581
582    #[test]
583    fn payment_mandate_round_trips_and_chains_by_hash() {
584        let signer = Signer::from_seed(3);
585        let (intent_payload, _fields) = intent(&signer);
586        let intent_hash = mandate_hash(&intent_payload);
587        let cart_fields = CartFields {
588            intent_hash: &intent_hash,
589            caller: "slack:T1:U9",
590            conversation_id: "conv-1",
591            merchant_host: "api.example.com",
592            currency: "0xUSD",
593            amount_base_units: "500000",
594            issued_at_unix: 1_000,
595            expires_at_unix: 5_000,
596            nonce: "cart-nonce-1",
597        };
598        let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
599        let cart_hash = mandate_hash(&cart_payload);
600
601        let payment_fields = PaymentFields {
602            cart_hash: &cart_hash,
603            caller: "slack:T1:U9",
604            conversation_id: "conv-1",
605            args_json: r#"{"url":"https://api.example.com/report"}"#,
606            currency: "0xUSD",
607            amount_base_units: "250000",
608            issued_at_unix: 1_000,
609            expires_at_unix: 4_000,
610            nonce: "payment-nonce-1",
611        };
612        let (payment_payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
613        let verified = verify_signed_payment_mandate(&payment_payload).expect("payment verifies");
614        assert_eq!(verified.cart_hash, cart_hash);
615        assert_eq!(verified.amount_base_units, "250000");
616        assert_eq!(verified.args_json, payment_fields.args_json);
617    }
618
619    #[test]
620    fn payment_mandate_tampered_args_json_fails() {
621        let signer = Signer::from_seed(3);
622        let payment_fields = PaymentFields {
623            cart_hash: "deadbeef",
624            caller: "slack:T1:U9",
625            conversation_id: "conv-1",
626            args_json: r#"{"url":"https://api.example.com/report"}"#,
627            currency: "0xUSD",
628            amount_base_units: "250000",
629            issued_at_unix: 1_000,
630            expires_at_unix: 4_000,
631            nonce: "payment-nonce-1",
632        };
633        let (payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
634        let mut v: Value = serde_json::from_slice(&payload).unwrap();
635        v["args_json"] = Value::String(r#"{"url":"https://evil.example.com/steal"}"#.to_owned());
636        assert!(verify_signed_payment_mandate(&v.to_string().into_bytes()).is_none());
637    }
638
639    #[test]
640    fn mandate_hash_is_stable_and_sensitive_to_signature() {
641        let signer_a = Signer::from_seed(9);
642        let signer_b = Signer::from_seed(10);
643        let fields = IntentFields {
644            caller: "slack:T1:U9",
645            conversation_id: "conv-1",
646            scope_description: "x",
647            currency: "0xUSD",
648            max_total_base_units: "1000",
649            issued_at_unix: 1,
650            expires_at_unix: 2,
651            nonce: "n",
652        };
653        let (payload_a, _s, _p) = sign_intent_mandate(&fields, &signer_a);
654        let (payload_b, _s, _p) = sign_intent_mandate(&fields, &signer_b);
655        // Same fields, deterministic re-hash.
656        assert_eq!(mandate_hash(&payload_a), mandate_hash(&payload_a));
657        // Different signer over IDENTICAL fields ⇒ a different signature ⇒ a
658        // different hash, since the hash commits to the full signed artifact.
659        assert_ne!(mandate_hash(&payload_a), mandate_hash(&payload_b));
660    }
661
662    #[test]
663    fn garbage_payload_returns_none_not_panic() {
664        assert!(verify_signed_intent_mandate(b"not json").is_none());
665        assert!(verify_signed_cart_mandate(b"{}").is_none());
666        assert!(verify_signed_payment_mandate(b"[]").is_none());
667    }
668}