1use serde::Serialize;
50use serde_json::Value;
51use sha2::{Digest, Sha256};
52
53use crate::approval::VerifiedResponse;
54use crate::canon::canon_args;
55use crate::signed::{Envelope, canonical_bytes};
56use crate::{Signer, verify};
57
58const KIND_INTENT: &str = "ap2.intent.v1";
60const KIND_CART: &str = "ap2.cart.v1";
62const KIND_PAYMENT: &str = "ap2.payment.v1";
64
65#[must_use]
71pub fn mandate_hash(signed_payload: &[u8]) -> String {
72 let mut hasher = Sha256::new();
73 hasher.update(signed_payload);
74 crate::hex::lower(&hasher.finalize())
75}
76
77#[derive(Debug, Clone, Copy)]
83pub struct IntentFields<'a> {
84 pub caller: &'a str,
87 pub conversation_id: &'a str,
89 pub scope_description: &'a str,
92 pub currency: &'a str,
95 pub max_total_base_units: &'a str,
100 pub issued_at_unix: u64,
102 pub expires_at_unix: u64,
105 pub nonce: &'a str,
107}
108
109impl<'a> IntentFields<'a> {
110 const fn canonical_json(&self) -> IntentCanonical<'a> {
111 IntentCanonical {
112 kind: KIND_INTENT,
113 caller: self.caller,
114 conversation_id: self.conversation_id,
115 scope_description: self.scope_description,
116 currency: self.currency,
117 max_total_base_units: self.max_total_base_units,
118 issued_at_unix: self.issued_at_unix,
119 expires_at_unix: self.expires_at_unix,
120 nonce: self.nonce,
121 }
122 }
123}
124
125#[derive(Serialize)]
130struct IntentCanonical<'a> {
131 kind: &'static str,
132 caller: &'a str,
133 conversation_id: &'a str,
134 scope_description: &'a str,
135 currency: &'a str,
136 max_total_base_units: &'a str,
137 issued_at_unix: u64,
138 expires_at_unix: u64,
139 nonce: &'a str,
140}
141
142#[derive(Debug, Clone)]
144pub struct VerifiedIntentMandate {
145 pub caller: String,
147 pub conversation_id: String,
149 pub scope_description: String,
151 pub currency: String,
153 pub max_total_base_units: String,
155 pub issued_at_unix: u64,
157 pub expires_at_unix: u64,
159 pub nonce: String,
161 pub signer_public_key: Vec<u8>,
163}
164
165#[must_use]
171pub fn sign_intent_mandate(
172 fields: &IntentFields<'_>,
173 signer: &Signer,
174) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
175 Envelope::seal(fields.canonical_json(), signer)
176}
177
178#[must_use]
185pub fn verify_signed_intent_mandate(payload: &[u8]) -> Option<VerifiedIntentMandate> {
186 let v: Value = serde_json::from_slice(payload).ok()?;
187 if v.get("kind")?.as_str()? != KIND_INTENT {
188 return None;
189 }
190 let caller = v.get("caller")?.as_str()?.to_owned();
191 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
192 let scope_description = v.get("scope_description")?.as_str()?.to_owned();
193 let currency = v.get("currency")?.as_str()?.to_owned();
194 let max_total_base_units = v.get("max_total_base_units")?.as_str()?.to_owned();
195 let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
196 let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
197 let nonce = v.get("nonce")?.as_str()?.to_owned();
198 let (pk, sig) = envelope_signature(&v)?;
199
200 let fields = IntentFields {
201 caller: &caller,
202 conversation_id: &conversation_id,
203 scope_description: &scope_description,
204 currency: ¤cy,
205 max_total_base_units: &max_total_base_units,
206 issued_at_unix,
207 expires_at_unix,
208 nonce: &nonce,
209 };
210 if verify(&pk, &canonical_bytes(&fields.canonical_json()), &sig) {
211 Some(VerifiedIntentMandate {
212 caller,
213 conversation_id,
214 scope_description,
215 currency,
216 max_total_base_units,
217 issued_at_unix,
218 expires_at_unix,
219 nonce,
220 signer_public_key: pk,
221 })
222 } else {
223 None
224 }
225}
226
227#[derive(Debug, Clone, Copy)]
230pub struct CartFields<'a> {
231 pub intent_hash: &'a str,
233 pub caller: &'a str,
236 pub conversation_id: &'a str,
238 pub merchant_host: &'a str,
240 pub currency: &'a str,
242 pub amount_base_units: &'a str,
244 pub issued_at_unix: u64,
246 pub expires_at_unix: u64,
248 pub nonce: &'a str,
250}
251
252impl<'a> CartFields<'a> {
253 const fn canonical_json(&self) -> CartCanonical<'a> {
254 CartCanonical {
255 kind: KIND_CART,
256 intent_hash: self.intent_hash,
257 caller: self.caller,
258 conversation_id: self.conversation_id,
259 merchant_host: self.merchant_host,
260 currency: self.currency,
261 amount_base_units: self.amount_base_units,
262 issued_at_unix: self.issued_at_unix,
263 expires_at_unix: self.expires_at_unix,
264 nonce: self.nonce,
265 }
266 }
267}
268
269#[derive(Serialize)]
274struct CartCanonical<'a> {
275 kind: &'static str,
276 intent_hash: &'a str,
277 caller: &'a str,
278 conversation_id: &'a str,
279 merchant_host: &'a str,
280 currency: &'a str,
281 amount_base_units: &'a str,
282 issued_at_unix: u64,
283 expires_at_unix: u64,
284 nonce: &'a str,
285}
286
287#[derive(Debug, Clone)]
289pub struct VerifiedCartMandate {
290 pub intent_hash: String,
292 pub caller: String,
294 pub conversation_id: String,
296 pub merchant_host: String,
298 pub currency: String,
300 pub amount_base_units: String,
302 pub issued_at_unix: u64,
304 pub expires_at_unix: u64,
306 pub nonce: String,
308 pub signer_public_key: Vec<u8>,
310}
311
312#[must_use]
315pub fn sign_cart_mandate(fields: &CartFields<'_>, signer: &Signer) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
316 Envelope::seal(fields.canonical_json(), signer)
317}
318
319#[must_use]
322pub fn verify_signed_cart_mandate(payload: &[u8]) -> Option<VerifiedCartMandate> {
323 let v: Value = serde_json::from_slice(payload).ok()?;
324 if v.get("kind")?.as_str()? != KIND_CART {
325 return None;
326 }
327 let intent_hash = v.get("intent_hash")?.as_str()?.to_owned();
328 let caller = v.get("caller")?.as_str()?.to_owned();
329 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
330 let merchant_host = v.get("merchant_host")?.as_str()?.to_owned();
331 let currency = v.get("currency")?.as_str()?.to_owned();
332 let amount_base_units = v.get("amount_base_units")?.as_str()?.to_owned();
333 let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
334 let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
335 let nonce = v.get("nonce")?.as_str()?.to_owned();
336 let (pk, sig) = envelope_signature(&v)?;
337
338 let fields = CartFields {
339 intent_hash: &intent_hash,
340 caller: &caller,
341 conversation_id: &conversation_id,
342 merchant_host: &merchant_host,
343 currency: ¤cy,
344 amount_base_units: &amount_base_units,
345 issued_at_unix,
346 expires_at_unix,
347 nonce: &nonce,
348 };
349 if verify(&pk, &canonical_bytes(&fields.canonical_json()), &sig) {
350 Some(VerifiedCartMandate {
351 intent_hash,
352 caller,
353 conversation_id,
354 merchant_host,
355 currency,
356 amount_base_units,
357 issued_at_unix,
358 expires_at_unix,
359 nonce,
360 signer_public_key: pk,
361 })
362 } else {
363 None
364 }
365}
366
367#[derive(Debug, Clone, Copy)]
370pub struct PaymentFields<'a> {
371 pub cart_hash: &'a str,
373 pub caller: &'a str,
375 pub conversation_id: &'a str,
377 pub args_json: &'a str,
381 pub currency: &'a str,
383 pub amount_base_units: &'a str,
385 pub issued_at_unix: u64,
387 pub expires_at_unix: u64,
389 pub nonce: &'a str,
391}
392
393impl<'a> PaymentFields<'a> {
394 const fn canonical_json(&self) -> PaymentCanonical<'a> {
395 PaymentCanonical {
396 kind: KIND_PAYMENT,
397 cart_hash: self.cart_hash,
398 caller: self.caller,
399 conversation_id: self.conversation_id,
400 args_json: self.args_json,
401 currency: self.currency,
402 amount_base_units: self.amount_base_units,
403 issued_at_unix: self.issued_at_unix,
404 expires_at_unix: self.expires_at_unix,
405 nonce: self.nonce,
406 }
407 }
408}
409
410#[derive(Serialize)]
415struct PaymentCanonical<'a> {
416 kind: &'static str,
417 cart_hash: &'a str,
418 caller: &'a str,
419 conversation_id: &'a str,
420 args_json: &'a str,
421 currency: &'a str,
422 amount_base_units: &'a str,
423 issued_at_unix: u64,
424 expires_at_unix: u64,
425 nonce: &'a str,
426}
427
428#[derive(Debug, Clone)]
430pub struct VerifiedPaymentMandate {
431 pub cart_hash: String,
433 pub caller: String,
435 pub conversation_id: String,
437 pub args_json: String,
439 pub currency: String,
441 pub amount_base_units: String,
443 pub issued_at_unix: u64,
445 pub expires_at_unix: u64,
447 pub nonce: String,
449 pub signer_public_key: Vec<u8>,
451}
452
453#[must_use]
456pub fn sign_payment_mandate(
457 fields: &PaymentFields<'_>,
458 signer: &Signer,
459) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
460 Envelope::seal(fields.canonical_json(), signer)
461}
462
463#[must_use]
466pub fn verify_signed_payment_mandate(payload: &[u8]) -> Option<VerifiedPaymentMandate> {
467 let v: Value = serde_json::from_slice(payload).ok()?;
468 if v.get("kind")?.as_str()? != KIND_PAYMENT {
469 return None;
470 }
471 let cart_hash = v.get("cart_hash")?.as_str()?.to_owned();
472 let caller = v.get("caller")?.as_str()?.to_owned();
473 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
474 let args_json = v.get("args_json")?.as_str()?.to_owned();
475 let currency = v.get("currency")?.as_str()?.to_owned();
476 let amount_base_units = v.get("amount_base_units")?.as_str()?.to_owned();
477 let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
478 let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
479 let nonce = v.get("nonce")?.as_str()?.to_owned();
480 let (pk, sig) = envelope_signature(&v)?;
481
482 let fields = PaymentFields {
483 cart_hash: &cart_hash,
484 caller: &caller,
485 conversation_id: &conversation_id,
486 args_json: &args_json,
487 currency: ¤cy,
488 amount_base_units: &amount_base_units,
489 issued_at_unix,
490 expires_at_unix,
491 nonce: &nonce,
492 };
493 if verify(&pk, &canonical_bytes(&fields.canonical_json()), &sig) {
494 Some(VerifiedPaymentMandate {
495 cart_hash,
496 caller,
497 conversation_id,
498 args_json,
499 currency,
500 amount_base_units,
501 issued_at_unix,
502 expires_at_unix,
503 nonce,
504 signer_public_key: pk,
505 })
506 } else {
507 None
508 }
509}
510
511fn envelope_signature(v: &Value) -> Option<(Vec<u8>, Vec<u8>)> {
514 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
515 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
516 Some((pk, sig))
517}
518
519#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
529pub enum MandateError {
530 #[error("intent mandate is malformed or its signature does not verify")]
532 InvalidIntent,
533 #[error("cart mandate is malformed or its signature does not verify")]
535 InvalidCart,
536 #[error("payment mandate is malformed or its signature does not verify")]
538 InvalidPayment,
539 #[error("{0} mandate is not signed by a trusted key")]
541 UntrustedSigner(&'static str),
542 #[error("{0} mandate is bound to a different conversation")]
545 ConversationMismatch(&'static str),
546 #[error("mandate chain is not bound to one consistent caller")]
548 CallerMismatch,
549 #[error("{kind} mandate expired at {expires_at_unix}")]
551 Expired {
552 kind: &'static str,
554 expires_at_unix: u64,
556 },
557 #[error("cart mandate does not chain to the presented intent mandate")]
559 CartNotChainedToIntent,
560 #[error("payment mandate does not chain to the presented cart mandate")]
562 PaymentNotChainedToCart,
563 #[error("{0} mandate carries an unparseable base-unit amount")]
567 MalformedAmount(&'static str),
568 #[error("cart amount {cart} exceeds the intent ceiling {intent}")]
570 AmountWidensAtCart {
571 cart: u128,
573 intent: u128,
575 },
576 #[error("payment amount {payment} exceeds the cart amount {cart}")]
578 AmountWidensAtPayment {
579 payment: u128,
581 cart: u128,
583 },
584 #[error("mandate currency does not match across the chain")]
586 CurrencyMismatch,
587 #[error("cart mandate carries no merchant scope")]
590 EmptyScope,
591 #[error(
594 "mandate authorizes at most {authorized} base units against {authorized_host}; \
595 requested {requested} base units against {requested_host}"
596 )]
597 ExceedsAuthorization {
598 authorized: u128,
600 authorized_host: String,
602 requested: u128,
604 requested_host: String,
606 },
607 #[error("the payment mandate is bound to a different tool call's args")]
611 ArgsBindingMismatch,
612 #[error("the HITL approval does not authorize this exact call; no mandate was minted")]
615 ApprovalDoesNotAuthorizeCall,
616}
617
618#[derive(Debug, Clone, Default)]
621pub struct MandateChain {
622 pub intent: Vec<u8>,
624 pub cart: Vec<u8>,
626 pub payment: Vec<u8>,
628}
629
630impl MandateChain {
631 pub fn verify(
650 &self,
651 conversation_id: &str,
652 issuer_public_key: &[u8],
653 now_unix: u64,
654 ) -> Result<VerifiedMandateChain, MandateError> {
655 let intent =
656 verify_signed_intent_mandate(&self.intent).ok_or(MandateError::InvalidIntent)?;
657 let cart = verify_signed_cart_mandate(&self.cart).ok_or(MandateError::InvalidCart)?;
658 let payment =
659 verify_signed_payment_mandate(&self.payment).ok_or(MandateError::InvalidPayment)?;
660
661 let links: [(&'static str, &str, u64); 3] = [
664 ("intent", &intent.conversation_id, intent.expires_at_unix),
665 ("cart", &cart.conversation_id, cart.expires_at_unix),
666 ("payment", &payment.conversation_id, payment.expires_at_unix),
667 ];
668 for (label, conv, expires_at_unix) in links {
669 if conv != conversation_id {
670 return Err(MandateError::ConversationMismatch(label));
671 }
672 if expires_at_unix <= now_unix {
673 return Err(MandateError::Expired {
674 kind: label,
675 expires_at_unix,
676 });
677 }
678 }
679
680 if intent.signer_public_key != issuer_public_key {
684 return Err(MandateError::UntrustedSigner("intent"));
685 }
686 if cart.signer_public_key != issuer_public_key {
687 return Err(MandateError::UntrustedSigner("cart"));
688 }
689 if payment.signer_public_key != issuer_public_key {
690 return Err(MandateError::UntrustedSigner("payment"));
691 }
692 if intent.caller != cart.caller || cart.caller != payment.caller {
693 return Err(MandateError::CallerMismatch);
694 }
695
696 if cart.intent_hash != mandate_hash(&self.intent) {
701 return Err(MandateError::CartNotChainedToIntent);
702 }
703 if payment.cart_hash != mandate_hash(&self.cart) {
704 return Err(MandateError::PaymentNotChainedToCart);
705 }
706
707 let cart_amount: u128 = cart
712 .amount_base_units
713 .parse()
714 .map_err(|_| MandateError::MalformedAmount("cart"))?;
715 let payment_amount: u128 = payment
716 .amount_base_units
717 .parse()
718 .map_err(|_| MandateError::MalformedAmount("payment"))?;
719 if !intent.max_total_base_units.is_empty() {
720 let ceiling: u128 = intent
721 .max_total_base_units
722 .parse()
723 .map_err(|_| MandateError::MalformedAmount("intent"))?;
724 if cart_amount > ceiling {
725 return Err(MandateError::AmountWidensAtCart {
726 cart: cart_amount,
727 intent: ceiling,
728 });
729 }
730 }
731 if payment_amount > cart_amount {
732 return Err(MandateError::AmountWidensAtPayment {
733 payment: payment_amount,
734 cart: cart_amount,
735 });
736 }
737
738 if intent.currency != cart.currency || cart.currency != payment.currency {
739 return Err(MandateError::CurrencyMismatch);
740 }
741 if cart.merchant_host.is_empty() {
742 return Err(MandateError::EmptyScope);
743 }
744
745 Ok(VerifiedMandateChain {
746 authorized_amount_base_units: payment_amount,
747 merchant_host: cart.merchant_host.clone(),
748 currency: payment.currency.clone(),
749 caller: payment.caller.clone(),
750 conversation_id: payment.conversation_id.clone(),
751 args_json: payment.args_json.clone(),
752 intent,
753 cart,
754 payment,
755 })
756 }
757}
758
759#[derive(Debug, Clone)]
762pub struct VerifiedMandateChain {
763 pub authorized_amount_base_units: u128,
766 pub merchant_host: String,
769 pub currency: String,
771 pub caller: String,
773 pub conversation_id: String,
775 pub args_json: String,
777 pub intent: VerifiedIntentMandate,
779 pub cart: VerifiedCartMandate,
781 pub payment: VerifiedPaymentMandate,
783}
784
785impl VerifiedMandateChain {
786 pub fn authorize(
809 &self,
810 requested_base_units: u128,
811 requested_host: &str,
812 args_json: &str,
813 ) -> Result<(), MandateError> {
814 if canon_args(args_json) != canon_args(&self.args_json) {
815 return Err(MandateError::ArgsBindingMismatch);
816 }
817 let host_ok = self.merchant_host.eq_ignore_ascii_case(requested_host);
818 if !host_ok || requested_base_units > self.authorized_amount_base_units {
819 return Err(MandateError::ExceedsAuthorization {
820 authorized: self.authorized_amount_base_units,
821 authorized_host: self.merchant_host.clone(),
822 requested: requested_base_units,
823 requested_host: requested_host.to_owned(),
824 });
825 }
826 Ok(())
827 }
828}
829
830pub fn resolve(
844 chain: Option<&MandateChain>,
845 issuer_public_key: Option<&[u8]>,
846 conversation_id: &str,
847 now_unix: u64,
848) -> Result<Option<VerifiedMandateChain>, MandateError> {
849 match (chain, issuer_public_key) {
850 (Some(c), Some(key)) => c.verify(conversation_id, key, now_unix).map(Some),
851 _ => Ok(None),
852 }
853}
854
855#[allow(clippy::too_many_arguments)] pub fn payment_mandate_from_approval(
887 approved: &VerifiedResponse,
888 request_id: &str,
889 tool_name: &str,
890 args_json: &str,
891 cart_payload: &[u8],
892 issued_at_unix: u64,
893 expires_at_unix: u64,
894 nonce: &str,
895 signer: &Signer,
896) -> Result<Vec<u8>, MandateError> {
897 if !approved.authorizes_call(request_id, tool_name, args_json) {
898 return Err(MandateError::ApprovalDoesNotAuthorizeCall);
899 }
900 let cart = verify_signed_cart_mandate(cart_payload).ok_or(MandateError::InvalidCart)?;
901 if cart.caller != approved.caller {
902 return Err(MandateError::CallerMismatch);
903 }
904 if cart.conversation_id != approved.conversation_id {
905 return Err(MandateError::ConversationMismatch("cart"));
906 }
907 let fields = PaymentFields {
908 cart_hash: &mandate_hash(cart_payload),
909 caller: &approved.caller,
910 conversation_id: &approved.conversation_id,
911 args_json,
912 currency: &cart.currency,
913 amount_base_units: &cart.amount_base_units,
914 issued_at_unix,
915 expires_at_unix,
916 nonce,
917 };
918 let (payload, _sig, _pk) = sign_payment_mandate(&fields, signer);
919 Ok(payload)
920}
921
922#[cfg(test)]
923mod tests {
924 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
925
926 use super::*;
927 use crate::approval::{ApprovalSigner, response_payload, verify_signed_response};
928
929 fn intent(signer: &Signer) -> (Vec<u8>, IntentFields<'static>) {
930 let fields = IntentFields {
931 caller: "slack:T1:U9",
932 conversation_id: "conv-1",
933 scope_description: "research report purchases",
934 currency: "0xUSD",
935 max_total_base_units: "1000000",
936 issued_at_unix: 1_000,
937 expires_at_unix: 10_000,
938 nonce: "intent-nonce-1",
939 };
940 let (payload, _sig, _pk) = sign_intent_mandate(&fields, signer);
941 (payload, fields)
942 }
943
944 #[test]
945 fn intent_mandate_round_trips() {
946 let signer = Signer::from_seed(1);
947 let (payload, fields) = intent(&signer);
948 let verified = verify_signed_intent_mandate(&payload).expect("verifies");
949 assert_eq!(verified.caller, fields.caller);
950 assert_eq!(verified.conversation_id, fields.conversation_id);
951 assert_eq!(verified.max_total_base_units, fields.max_total_base_units);
952 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
953 }
954
955 #[test]
956 fn intent_mandate_tampered_amount_fails() {
957 let signer = Signer::from_seed(1);
958 let (payload, _fields) = intent(&signer);
959 let mut v: Value = serde_json::from_slice(&payload).unwrap();
960 v["max_total_base_units"] = Value::String("999999999".to_owned());
961 assert!(verify_signed_intent_mandate(&v.to_string().into_bytes()).is_none());
962 }
963
964 #[test]
965 fn intent_mandate_wrong_kind_rejected() {
966 let signer = Signer::from_seed(1);
969 let cart_fields = CartFields {
970 intent_hash: "deadbeef",
971 caller: "slack:T1:U9",
972 conversation_id: "conv-1",
973 merchant_host: "api.example.com",
974 currency: "0xUSD",
975 amount_base_units: "500000",
976 issued_at_unix: 1_000,
977 expires_at_unix: 5_000,
978 nonce: "cart-nonce-1",
979 };
980 let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
981 assert!(verify_signed_intent_mandate(&cart_payload).is_none());
982 }
983
984 #[test]
985 fn cart_mandate_round_trips_and_chains_by_hash() {
986 let signer = Signer::from_seed(2);
987 let (intent_payload, _fields) = intent(&signer);
988 let intent_hash = mandate_hash(&intent_payload);
989
990 let cart_fields = CartFields {
991 intent_hash: &intent_hash,
992 caller: "slack:T1:U9",
993 conversation_id: "conv-1",
994 merchant_host: "api.example.com",
995 currency: "0xUSD",
996 amount_base_units: "500000",
997 issued_at_unix: 1_000,
998 expires_at_unix: 5_000,
999 nonce: "cart-nonce-1",
1000 };
1001 let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1002 let verified = verify_signed_cart_mandate(&cart_payload).expect("cart verifies");
1003 assert_eq!(verified.intent_hash, intent_hash);
1004 assert_eq!(verified.merchant_host, "api.example.com");
1005 }
1006
1007 #[test]
1008 fn cart_mandate_tampered_intent_hash_fails() {
1009 let signer = Signer::from_seed(2);
1010 let (intent_payload, _fields) = intent(&signer);
1011 let intent_hash = mandate_hash(&intent_payload);
1012 let cart_fields = CartFields {
1013 intent_hash: &intent_hash,
1014 caller: "slack:T1:U9",
1015 conversation_id: "conv-1",
1016 merchant_host: "api.example.com",
1017 currency: "0xUSD",
1018 amount_base_units: "500000",
1019 issued_at_unix: 1_000,
1020 expires_at_unix: 5_000,
1021 nonce: "cart-nonce-1",
1022 };
1023 let (payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1024 let mut v: Value = serde_json::from_slice(&payload).unwrap();
1025 v["intent_hash"] = Value::String("0".repeat(64));
1026 assert!(verify_signed_cart_mandate(&v.to_string().into_bytes()).is_none());
1027 }
1028
1029 #[test]
1030 fn payment_mandate_round_trips_and_chains_by_hash() {
1031 let signer = Signer::from_seed(3);
1032 let (intent_payload, _fields) = intent(&signer);
1033 let intent_hash = mandate_hash(&intent_payload);
1034 let cart_fields = CartFields {
1035 intent_hash: &intent_hash,
1036 caller: "slack:T1:U9",
1037 conversation_id: "conv-1",
1038 merchant_host: "api.example.com",
1039 currency: "0xUSD",
1040 amount_base_units: "500000",
1041 issued_at_unix: 1_000,
1042 expires_at_unix: 5_000,
1043 nonce: "cart-nonce-1",
1044 };
1045 let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1046 let cart_hash = mandate_hash(&cart_payload);
1047
1048 let payment_fields = PaymentFields {
1049 cart_hash: &cart_hash,
1050 caller: "slack:T1:U9",
1051 conversation_id: "conv-1",
1052 args_json: r#"{"url":"https://api.example.com/report"}"#,
1053 currency: "0xUSD",
1054 amount_base_units: "250000",
1055 issued_at_unix: 1_000,
1056 expires_at_unix: 4_000,
1057 nonce: "payment-nonce-1",
1058 };
1059 let (payment_payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
1060 let verified = verify_signed_payment_mandate(&payment_payload).expect("payment verifies");
1061 assert_eq!(verified.cart_hash, cart_hash);
1062 assert_eq!(verified.amount_base_units, "250000");
1063 assert_eq!(verified.args_json, payment_fields.args_json);
1064 }
1065
1066 #[test]
1067 fn payment_mandate_tampered_args_json_fails() {
1068 let signer = Signer::from_seed(3);
1069 let payment_fields = PaymentFields {
1070 cart_hash: "deadbeef",
1071 caller: "slack:T1:U9",
1072 conversation_id: "conv-1",
1073 args_json: r#"{"url":"https://api.example.com/report"}"#,
1074 currency: "0xUSD",
1075 amount_base_units: "250000",
1076 issued_at_unix: 1_000,
1077 expires_at_unix: 4_000,
1078 nonce: "payment-nonce-1",
1079 };
1080 let (payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
1081 let mut v: Value = serde_json::from_slice(&payload).unwrap();
1082 v["args_json"] = Value::String(r#"{"url":"https://evil.example.com/steal"}"#.to_owned());
1083 assert!(verify_signed_payment_mandate(&v.to_string().into_bytes()).is_none());
1084 }
1085
1086 #[test]
1087 fn mandate_hash_is_stable_and_sensitive_to_signature() {
1088 let signer_a = Signer::from_seed(9);
1089 let signer_b = Signer::from_seed(10);
1090 let fields = IntentFields {
1091 caller: "slack:T1:U9",
1092 conversation_id: "conv-1",
1093 scope_description: "x",
1094 currency: "0xUSD",
1095 max_total_base_units: "1000",
1096 issued_at_unix: 1,
1097 expires_at_unix: 2,
1098 nonce: "n",
1099 };
1100 let (payload_a, _s, _p) = sign_intent_mandate(&fields, &signer_a);
1101 let (payload_b, _s, _p) = sign_intent_mandate(&fields, &signer_b);
1102 assert_eq!(mandate_hash(&payload_a), mandate_hash(&payload_a));
1104 assert_ne!(mandate_hash(&payload_a), mandate_hash(&payload_b));
1107 }
1108
1109 #[test]
1110 fn garbage_payload_returns_none_not_panic() {
1111 assert!(verify_signed_intent_mandate(b"not json").is_none());
1112 assert!(verify_signed_cart_mandate(b"{}").is_none());
1113 assert!(verify_signed_payment_mandate(b"[]").is_none());
1114 }
1115
1116 const CONV: &str = "conv-1";
1117 const CALLER: &str = "slack:T1:U9";
1118 const HOST: &str = "api.example.com";
1119 const USD: &str = "0x20c0000000000000000000000000000000000000";
1120 const ARGS: &str = r#"{"url":"https://api.example.com/report"}"#;
1121
1122 fn issuer() -> Signer {
1123 Signer::from_seed(99)
1124 }
1125
1126 fn intent_fields(max_total: &str) -> IntentFields<'_> {
1127 IntentFields {
1128 caller: CALLER,
1129 conversation_id: CONV,
1130 scope_description: "research report purchases",
1131 currency: USD,
1132 max_total_base_units: max_total,
1133 issued_at_unix: 100,
1134 expires_at_unix: 10_000,
1135 nonce: "n-intent",
1136 }
1137 }
1138
1139 fn valid_chain(signer: &Signer) -> MandateChain {
1142 let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), signer);
1143 let intent_hash = mandate_hash(&intent);
1144 let (cart, _s, _p) = sign_cart_mandate(
1145 &CartFields {
1146 intent_hash: &intent_hash,
1147 caller: CALLER,
1148 conversation_id: CONV,
1149 merchant_host: HOST,
1150 currency: USD,
1151 amount_base_units: "500000",
1152 issued_at_unix: 100,
1153 expires_at_unix: 9_000,
1154 nonce: "n-cart",
1155 },
1156 signer,
1157 );
1158 let cart_hash = mandate_hash(&cart);
1159 let (payment, _s, _p) = sign_payment_mandate(
1160 &PaymentFields {
1161 cart_hash: &cart_hash,
1162 caller: CALLER,
1163 conversation_id: CONV,
1164 args_json: ARGS,
1165 currency: USD,
1166 amount_base_units: "500000",
1167 issued_at_unix: 100,
1168 expires_at_unix: 8_000,
1169 nonce: "n-payment",
1170 },
1171 signer,
1172 );
1173 MandateChain {
1174 intent,
1175 cart,
1176 payment,
1177 }
1178 }
1179
1180 #[test]
1181 fn valid_chain_verifies_and_authorizes_within_bounds() {
1182 let signer = issuer();
1183 let chain = valid_chain(&signer);
1184 let verified = chain
1185 .verify(CONV, &signer.public_key_bytes(), 1_000)
1186 .expect("a mutually consistent, unexpired chain must verify");
1187 assert_eq!(verified.authorized_amount_base_units, 500_000);
1188 assert_eq!(verified.merchant_host, HOST);
1189 assert_eq!(verified.caller, CALLER);
1190
1191 verified.authorize(500_000, HOST, ARGS).expect("at-cap ok");
1193 verified.authorize(1, HOST, ARGS).expect("under-cap ok");
1194 verified
1196 .authorize(500_000, "API.Example.COM", ARGS)
1197 .expect("case-insensitive host");
1198
1199 assert!(matches!(
1201 verified.authorize(500_001, HOST, ARGS),
1202 Err(MandateError::ExceedsAuthorization { .. })
1203 ));
1204 assert!(matches!(
1206 verified.authorize(1, "evil.example.com", ARGS),
1207 Err(MandateError::ExceedsAuthorization { .. })
1208 ));
1209 assert!(matches!(
1212 verified.authorize(1, HOST, r#"{"url":"https://api.example.com/OTHER"}"#),
1213 Err(MandateError::ArgsBindingMismatch)
1214 ));
1215 }
1216
1217 #[test]
1218 fn args_binding_matches_by_value_not_key_order() {
1219 let signer = issuer();
1223 let (intent, _s, _p) = sign_intent_mandate(&intent_fields(""), &signer);
1224 let intent_hash = mandate_hash(&intent);
1225 let (cart, _s, _p) = sign_cart_mandate(
1226 &CartFields {
1227 intent_hash: &intent_hash,
1228 caller: CALLER,
1229 conversation_id: CONV,
1230 merchant_host: HOST,
1231 currency: USD,
1232 amount_base_units: "500000",
1233 issued_at_unix: 100,
1234 expires_at_unix: 9_000,
1235 nonce: "n-cart",
1236 },
1237 &signer,
1238 );
1239 let (payment, _s, _p) = sign_payment_mandate(
1240 &PaymentFields {
1241 cart_hash: &mandate_hash(&cart),
1242 caller: CALLER,
1243 conversation_id: CONV,
1244 args_json: r#"{"max_spend":"0.10","url":"https://api.example.com/r"}"#,
1245 currency: USD,
1246 amount_base_units: "500000",
1247 issued_at_unix: 100,
1248 expires_at_unix: 8_000,
1249 nonce: "n-payment",
1250 },
1251 &signer,
1252 );
1253 let verified = MandateChain {
1254 intent,
1255 cart,
1256 payment,
1257 }
1258 .verify(CONV, &signer.public_key_bytes(), 1_000)
1259 .expect("chain verifies");
1260 verified
1261 .authorize(
1262 1,
1263 HOST,
1264 r#"{"url":"https://api.example.com/r","max_spend":"0.10"}"#,
1265 )
1266 .expect("reordered-but-equal args must pass the binding");
1267 }
1268
1269 #[test]
1270 fn tampered_link_fails_signature_verification() {
1271 let signer = issuer();
1272 let mut chain = valid_chain(&signer);
1273 let mut v: serde_json::Value = serde_json::from_slice(&chain.cart).unwrap();
1276 v["amount_base_units"] = serde_json::Value::String("999999999".to_owned());
1277 chain.cart = v.to_string().into_bytes();
1278 assert_eq!(
1279 chain
1280 .verify(CONV, &signer.public_key_bytes(), 1_000)
1281 .unwrap_err(),
1282 MandateError::InvalidCart
1283 );
1284 }
1285
1286 #[test]
1287 fn validly_signed_but_unchained_links_are_rejected() {
1288 let signer = issuer();
1292 let chain = valid_chain(&signer);
1293 let (other_intent, _s, _p) = sign_intent_mandate(&intent_fields("2000000"), &signer);
1294 let other_hash = mandate_hash(&other_intent);
1295 let (unchained_cart, _s, _p) = sign_cart_mandate(
1296 &CartFields {
1297 intent_hash: &other_hash, caller: CALLER,
1299 conversation_id: CONV,
1300 merchant_host: HOST,
1301 currency: USD,
1302 amount_base_units: "500000",
1303 issued_at_unix: 100,
1304 expires_at_unix: 9_000,
1305 nonce: "n-cart",
1306 },
1307 &signer,
1308 );
1309 let broken = MandateChain {
1310 intent: chain.intent.clone(),
1311 cart: unchained_cart.clone(),
1312 payment: chain.payment.clone(),
1313 };
1314 assert_eq!(
1315 broken
1316 .verify(CONV, &signer.public_key_bytes(), 1_000)
1317 .unwrap_err(),
1318 MandateError::CartNotChainedToIntent
1319 );
1320
1321 let (payment_for_other, _s, _p) = sign_payment_mandate(
1324 &PaymentFields {
1325 cart_hash: &mandate_hash(&unchained_cart),
1326 caller: CALLER,
1327 conversation_id: CONV,
1328 args_json: ARGS,
1329 currency: USD,
1330 amount_base_units: "500000",
1331 issued_at_unix: 100,
1332 expires_at_unix: 8_000,
1333 nonce: "n-payment",
1334 },
1335 &signer,
1336 );
1337 let broken = MandateChain {
1338 intent: chain.intent,
1339 cart: chain.cart,
1340 payment: payment_for_other,
1341 };
1342 assert_eq!(
1343 broken
1344 .verify(CONV, &signer.public_key_bytes(), 1_000)
1345 .unwrap_err(),
1346 MandateError::PaymentNotChainedToCart
1347 );
1348 }
1349
1350 #[test]
1351 fn widened_amounts_are_rejected() {
1352 let signer = issuer();
1353 let (intent, _s, _p) = sign_intent_mandate(&intent_fields("100"), &signer);
1355 let intent_hash = mandate_hash(&intent);
1356 let (cart, _s, _p) = sign_cart_mandate(
1357 &CartFields {
1358 intent_hash: &intent_hash,
1359 caller: CALLER,
1360 conversation_id: CONV,
1361 merchant_host: HOST,
1362 currency: USD,
1363 amount_base_units: "999", issued_at_unix: 100,
1365 expires_at_unix: 9_000,
1366 nonce: "n-cart",
1367 },
1368 &signer,
1369 );
1370 let (payment, _s, _p) = sign_payment_mandate(
1371 &PaymentFields {
1372 cart_hash: &mandate_hash(&cart),
1373 caller: CALLER,
1374 conversation_id: CONV,
1375 args_json: ARGS,
1376 currency: USD,
1377 amount_base_units: "999",
1378 issued_at_unix: 100,
1379 expires_at_unix: 8_000,
1380 nonce: "n-payment",
1381 },
1382 &signer,
1383 );
1384 let chain = MandateChain {
1385 intent,
1386 cart,
1387 payment,
1388 };
1389 assert_eq!(
1390 chain
1391 .verify(CONV, &signer.public_key_bytes(), 1_000)
1392 .unwrap_err(),
1393 MandateError::AmountWidensAtCart {
1394 cart: 999,
1395 intent: 100
1396 }
1397 );
1398
1399 let base = valid_chain(&signer);
1401 let (over_payment, _s, _p) = sign_payment_mandate(
1402 &PaymentFields {
1403 cart_hash: &mandate_hash(&base.cart),
1404 caller: CALLER,
1405 conversation_id: CONV,
1406 args_json: ARGS,
1407 currency: USD,
1408 amount_base_units: "500001", issued_at_unix: 100,
1410 expires_at_unix: 8_000,
1411 nonce: "n-payment",
1412 },
1413 &signer,
1414 );
1415 let chain = MandateChain {
1416 intent: base.intent,
1417 cart: base.cart,
1418 payment: over_payment,
1419 };
1420 assert_eq!(
1421 chain
1422 .verify(CONV, &signer.public_key_bytes(), 1_000)
1423 .unwrap_err(),
1424 MandateError::AmountWidensAtPayment {
1425 payment: 500_001,
1426 cart: 500_000
1427 }
1428 );
1429 }
1430
1431 #[test]
1432 fn unbounded_intent_ceiling_admits_any_cart_amount() {
1433 let signer = issuer();
1436 let (intent, _s, _p) = sign_intent_mandate(&intent_fields(""), &signer);
1437 let intent_hash = mandate_hash(&intent);
1438 let (cart, _s, _p) = sign_cart_mandate(
1439 &CartFields {
1440 intent_hash: &intent_hash,
1441 caller: CALLER,
1442 conversation_id: CONV,
1443 merchant_host: HOST,
1444 currency: USD,
1445 amount_base_units: "123456789",
1446 issued_at_unix: 100,
1447 expires_at_unix: 9_000,
1448 nonce: "n-cart",
1449 },
1450 &signer,
1451 );
1452 let (payment, _s, _p) = sign_payment_mandate(
1453 &PaymentFields {
1454 cart_hash: &mandate_hash(&cart),
1455 caller: CALLER,
1456 conversation_id: CONV,
1457 args_json: ARGS,
1458 currency: USD,
1459 amount_base_units: "123456789",
1460 issued_at_unix: 100,
1461 expires_at_unix: 8_000,
1462 nonce: "n-payment",
1463 },
1464 &signer,
1465 );
1466 let chain = MandateChain {
1467 intent,
1468 cart,
1469 payment,
1470 };
1471 let verified = chain
1472 .verify(CONV, &signer.public_key_bytes(), 1_000)
1473 .expect("empty intent ceiling is unbounded");
1474 assert_eq!(verified.authorized_amount_base_units, 123_456_789);
1475 }
1476
1477 #[test]
1478 fn expired_link_is_rejected() {
1479 let signer = issuer();
1480 let chain = valid_chain(&signer);
1481 assert_eq!(
1484 chain
1485 .verify(CONV, &signer.public_key_bytes(), 8_000)
1486 .unwrap_err(),
1487 MandateError::Expired {
1488 kind: "payment",
1489 expires_at_unix: 8_000
1490 }
1491 );
1492 assert_eq!(
1494 chain
1495 .verify(CONV, &signer.public_key_bytes(), 50_000)
1496 .unwrap_err(),
1497 MandateError::Expired {
1498 kind: "intent",
1499 expires_at_unix: 10_000
1500 }
1501 );
1502 assert!(chain.verify(CONV, &signer.public_key_bytes(), 1).is_ok());
1504 }
1505
1506 #[test]
1507 fn untrusted_issuer_is_rejected() {
1508 let signer = issuer();
1509 let chain = valid_chain(&signer);
1510 let wrong_key = Signer::from_seed(1).public_key_bytes();
1511 assert_eq!(
1512 chain.verify(CONV, &wrong_key, 1_000).unwrap_err(),
1513 MandateError::UntrustedSigner("intent")
1514 );
1515 }
1516
1517 #[test]
1518 fn conversation_and_caller_bindings_are_enforced() {
1519 let signer = issuer();
1520 let chain = valid_chain(&signer);
1521 assert_eq!(
1523 chain
1524 .verify("conv-OTHER", &signer.public_key_bytes(), 1_000)
1525 .unwrap_err(),
1526 MandateError::ConversationMismatch("intent")
1527 );
1528
1529 let (payment, _s, _p) = sign_payment_mandate(
1532 &PaymentFields {
1533 cart_hash: &mandate_hash(&chain.cart),
1534 caller: "slack:T1:UATTACKER",
1535 conversation_id: CONV,
1536 args_json: ARGS,
1537 currency: USD,
1538 amount_base_units: "500000",
1539 issued_at_unix: 100,
1540 expires_at_unix: 8_000,
1541 nonce: "n-payment",
1542 },
1543 &signer,
1544 );
1545 let cross_caller = MandateChain {
1546 intent: chain.intent,
1547 cart: chain.cart,
1548 payment,
1549 };
1550 assert_eq!(
1551 cross_caller
1552 .verify(CONV, &signer.public_key_bytes(), 1_000)
1553 .unwrap_err(),
1554 MandateError::CallerMismatch
1555 );
1556 }
1557
1558 #[test]
1559 fn currency_mismatch_is_rejected() {
1560 let signer = issuer();
1561 let chain = valid_chain(&signer);
1562 let (payment, _s, _p) = sign_payment_mandate(
1563 &PaymentFields {
1564 cart_hash: &mandate_hash(&chain.cart),
1565 caller: CALLER,
1566 conversation_id: CONV,
1567 args_json: ARGS,
1568 currency: "0xOTHER",
1569 amount_base_units: "500000",
1570 issued_at_unix: 100,
1571 expires_at_unix: 8_000,
1572 nonce: "n-payment",
1573 },
1574 &signer,
1575 );
1576 let cross_currency = MandateChain {
1577 intent: chain.intent,
1578 cart: chain.cart,
1579 payment,
1580 };
1581 assert_eq!(
1582 cross_currency
1583 .verify(CONV, &signer.public_key_bytes(), 1_000)
1584 .unwrap_err(),
1585 MandateError::CurrencyMismatch
1586 );
1587 }
1588
1589 #[test]
1590 fn resolve_is_a_noop_when_unconfigured_or_absent() {
1591 let signer = issuer();
1592 let chain = valid_chain(&signer);
1593 let key = signer.public_key_bytes();
1594
1595 assert!(matches!(resolve(None, Some(&key), CONV, 1_000), Ok(None)));
1598 assert!(matches!(resolve(None, None, CONV, 1_000), Ok(None)));
1599
1600 assert!(matches!(resolve(Some(&chain), None, CONV, 1_000), Ok(None)));
1604
1605 assert!(matches!(
1607 resolve(Some(&chain), Some(&key), CONV, 1_000),
1608 Ok(Some(_))
1609 ));
1610 }
1611
1612 #[test]
1613 fn resolve_fails_closed_on_an_invalid_presented_chain() {
1614 let signer = issuer();
1616 let chain = valid_chain(&signer);
1617 let key = signer.public_key_bytes();
1618 assert!(matches!(
1619 resolve(Some(&chain), Some(&key), CONV, 50_000).unwrap_err(),
1620 MandateError::Expired { .. }
1621 ));
1622 }
1623
1624 fn hitl_approval(approved: bool, args_json: &str) -> VerifiedResponse {
1627 let approval_signer = ApprovalSigner::from_seed(7);
1628 let (payload, _sig, _pk) = response_payload(
1629 "req-1",
1630 "paid_fetch",
1631 args_json,
1632 "",
1633 approved,
1634 false,
1635 &[],
1636 CALLER,
1637 "",
1638 "workspace-write",
1639 if approved { "looks fine" } else { "no" },
1640 "",
1641 CONV,
1642 "approval-nonce-1",
1643 "",
1644 &approval_signer,
1645 );
1646 verify_signed_response(&payload).expect("approval signature verifies")
1647 }
1648
1649 fn chain_prefix(signer: &Signer) -> (Vec<u8>, Vec<u8>, String) {
1651 let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), signer);
1652 let intent_hash = mandate_hash(&intent);
1653 let (cart, _s, _p) = sign_cart_mandate(
1654 &CartFields {
1655 intent_hash: &intent_hash,
1656 caller: CALLER,
1657 conversation_id: CONV,
1658 merchant_host: HOST,
1659 currency: USD,
1660 amount_base_units: "500000",
1661 issued_at_unix: 100,
1662 expires_at_unix: 9_000,
1663 nonce: "n-cart",
1664 },
1665 signer,
1666 );
1667 (intent, cart, intent_hash)
1668 }
1669
1670 #[test]
1671 fn hitl_approval_mints_a_payment_mandate_that_completes_the_chain() {
1672 let signer = issuer();
1673 let (intent, cart, _ih) = chain_prefix(&signer);
1674
1675 let approved = hitl_approval(true, ARGS);
1678 let payment = payment_mandate_from_approval(
1679 &approved,
1680 "req-1",
1681 "paid_fetch",
1682 ARGS,
1683 &cart,
1684 200,
1685 8_000,
1686 "n-payment",
1687 &signer,
1688 )
1689 .expect("the approval authorizes this exact call");
1690
1691 let chain = MandateChain {
1694 intent,
1695 cart,
1696 payment,
1697 };
1698 let verified = chain
1699 .verify(CONV, &signer.public_key_bytes(), 1_000)
1700 .expect("the minted payment chains to the cart");
1701 assert_eq!(verified.authorized_amount_base_units, 500_000);
1702 assert_eq!(verified.caller, CALLER);
1703 verified
1704 .authorize(500_000, HOST, ARGS)
1705 .expect("authorizes the approved call");
1706 assert!(matches!(
1707 verified.authorize(1, HOST, r#"{"url":"https://evil.example.com/x"}"#),
1708 Err(MandateError::ArgsBindingMismatch)
1709 ));
1710 }
1711
1712 #[test]
1713 fn hitl_bridge_refuses_denials_and_mismatched_scopes() {
1714 let signer = issuer();
1715 let (_intent, cart, intent_hash) = chain_prefix(&signer);
1716
1717 let denied = hitl_approval(false, ARGS);
1719 assert_eq!(
1720 payment_mandate_from_approval(
1721 &denied,
1722 "req-1",
1723 "paid_fetch",
1724 ARGS,
1725 &cart,
1726 200,
1727 8_000,
1728 "n",
1729 &signer
1730 )
1731 .unwrap_err(),
1732 MandateError::ApprovalDoesNotAuthorizeCall
1733 );
1734
1735 let approved = hitl_approval(true, ARGS);
1737 assert_eq!(
1738 payment_mandate_from_approval(
1739 &approved,
1740 "req-1",
1741 "paid_fetch",
1742 r#"{"url":"https://evil.example.com/x"}"#,
1743 &cart,
1744 200,
1745 8_000,
1746 "n",
1747 &signer
1748 )
1749 .unwrap_err(),
1750 MandateError::ApprovalDoesNotAuthorizeCall
1751 );
1752
1753 let (foreign_cart, _s, _p) = sign_cart_mandate(
1756 &CartFields {
1757 intent_hash: &intent_hash,
1758 caller: CALLER,
1759 conversation_id: "conv-OTHER",
1760 merchant_host: HOST,
1761 currency: USD,
1762 amount_base_units: "500000",
1763 issued_at_unix: 100,
1764 expires_at_unix: 9_000,
1765 nonce: "n-cart",
1766 },
1767 &signer,
1768 );
1769 assert_eq!(
1770 payment_mandate_from_approval(
1771 &approved,
1772 "req-1",
1773 "paid_fetch",
1774 ARGS,
1775 &foreign_cart,
1776 200,
1777 8_000,
1778 "n",
1779 &signer
1780 )
1781 .unwrap_err(),
1782 MandateError::ConversationMismatch("cart")
1783 );
1784
1785 let (foreign_caller_cart, _s, _p) = sign_cart_mandate(
1787 &CartFields {
1788 intent_hash: &intent_hash,
1789 caller: "slack:T1:USOMEONE",
1790 conversation_id: CONV,
1791 merchant_host: HOST,
1792 currency: USD,
1793 amount_base_units: "500000",
1794 issued_at_unix: 100,
1795 expires_at_unix: 9_000,
1796 nonce: "n-cart",
1797 },
1798 &signer,
1799 );
1800 assert_eq!(
1801 payment_mandate_from_approval(
1802 &approved,
1803 "req-1",
1804 "paid_fetch",
1805 ARGS,
1806 &foreign_caller_cart,
1807 200,
1808 8_000,
1809 "n",
1810 &signer
1811 )
1812 .unwrap_err(),
1813 MandateError::CallerMismatch
1814 );
1815 }
1816}
1817
1818#[cfg(test)]
1819mod canonical_freeze {
1820 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
1845
1846 use super::*;
1847
1848 fn frozen(label: &str, got: &[u8], want: &str) {
1850 assert_eq!(
1851 String::from_utf8(got.to_vec()).unwrap(),
1852 want,
1853 "{label}: canonical bytes moved — every signature over the old bytes is now unverifiable"
1854 );
1855 }
1856
1857 const ARGS_JSON: &str = r#"{"url":"https://shop.example/item","max":"25000"}"#;
1858
1859 fn intent() -> IntentFields<'static> {
1860 IntentFields {
1861 caller: "slack:T1:U9",
1862 conversation_id: "conv-1",
1863 scope_description: "groceries for the week",
1864 currency: "0xToken",
1865 max_total_base_units: "100000",
1866 issued_at_unix: 1_750_000_000,
1867 expires_at_unix: 1_750_086_400,
1868 nonce: "nonce-intent",
1869 }
1870 }
1871
1872 fn cart() -> CartFields<'static> {
1873 CartFields {
1874 intent_hash: "abcd1234",
1875 caller: "slack:T1:U9",
1876 conversation_id: "conv-1",
1877 merchant_host: "shop.example",
1878 currency: "0xToken",
1879 amount_base_units: "25000",
1880 issued_at_unix: 1_750_000_000,
1881 expires_at_unix: 1_750_086_400,
1882 nonce: "nonce-cart",
1883 }
1884 }
1885
1886 fn payment() -> PaymentFields<'static> {
1887 PaymentFields {
1888 cart_hash: "beef5678",
1889 caller: "slack:T1:U9",
1890 conversation_id: "conv-1",
1891 args_json: ARGS_JSON,
1892 currency: "0xToken",
1893 amount_base_units: "25000",
1894 issued_at_unix: 1_750_000_000,
1895 expires_at_unix: 1_750_086_400,
1896 nonce: "nonce-payment",
1897 }
1898 }
1899
1900 #[test]
1901 fn intent_mandate_is_frozen() {
1902 frozen(
1903 "IntentFields::canonical_json",
1904 &canonical_bytes(&intent().canonical_json()),
1905 INTENT_CANONICAL,
1906 );
1907 let (full, sig, _) = sign_intent_mandate(&intent(), &Signer::from_seed(99));
1908 frozen("sign_intent_mandate", &full, INTENT_PAYLOAD);
1909 assert_eq!(crate::hex::lower(&sig), INTENT_SIG);
1910 }
1911
1912 #[test]
1913 fn cart_mandate_is_frozen() {
1914 frozen(
1915 "CartFields::canonical_json",
1916 &canonical_bytes(&cart().canonical_json()),
1917 CART_CANONICAL,
1918 );
1919 let (full, sig, _) = sign_cart_mandate(&cart(), &Signer::from_seed(99));
1920 frozen("sign_cart_mandate", &full, CART_PAYLOAD);
1921 assert_eq!(crate::hex::lower(&sig), CART_SIG);
1922 }
1923
1924 #[test]
1925 fn payment_mandate_is_frozen() {
1926 frozen(
1927 "PaymentFields::canonical_json",
1928 &canonical_bytes(&payment().canonical_json()),
1929 PAYMENT_CANONICAL,
1930 );
1931 let (full, sig, _) = sign_payment_mandate(&payment(), &Signer::from_seed(99));
1932 frozen("sign_payment_mandate", &full, PAYMENT_PAYLOAD);
1933 assert_eq!(crate::hex::lower(&sig), PAYMENT_SIG);
1934 }
1935
1936 const INTENT_CANONICAL: &str = r#"{"kind":"ap2.intent.v1","caller":"slack:T1:U9","conversation_id":"conv-1","scope_description":"groceries for the week","currency":"0xToken","max_total_base_units":"100000","issued_at_unix":1750000000,"expires_at_unix":1750086400,"nonce":"nonce-intent"}"#;
1937 const INTENT_PAYLOAD: &str = r#"{"kind":"ap2.intent.v1","caller":"slack:T1:U9","conversation_id":"conv-1","scope_description":"groceries for the week","currency":"0xToken","max_total_base_units":"100000","issued_at_unix":1750000000,"expires_at_unix":1750086400,"nonce":"nonce-intent","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"85089b532b35c7ad7689c4f7f830959c08d7db71a9a3c45eb53db40289445494e125122bf45c4367aab60cb727c30fc5e4fd3fdc43abd6b0e7a0b2d3f2642703"}"#;
1938 const INTENT_SIG: &str = "85089b532b35c7ad7689c4f7f830959c08d7db71a9a3c45eb53db40289445494e125122bf45c4367aab60cb727c30fc5e4fd3fdc43abd6b0e7a0b2d3f2642703";
1939 const CART_CANONICAL: &str = r#"{"kind":"ap2.cart.v1","intent_hash":"abcd1234","caller":"slack:T1:U9","conversation_id":"conv-1","merchant_host":"shop.example","currency":"0xToken","amount_base_units":"25000","issued_at_unix":1750000000,"expires_at_unix":1750086400,"nonce":"nonce-cart"}"#;
1940 const CART_PAYLOAD: &str = r#"{"kind":"ap2.cart.v1","intent_hash":"abcd1234","caller":"slack:T1:U9","conversation_id":"conv-1","merchant_host":"shop.example","currency":"0xToken","amount_base_units":"25000","issued_at_unix":1750000000,"expires_at_unix":1750086400,"nonce":"nonce-cart","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"01c9ed068ef6f7f7724391fb2adffccab3db18e447e2514c7e07a233333534cef73ce2301204ca9f4beafb8907b992d9757e951a827765125a2639926afe2907"}"#;
1941 const CART_SIG: &str = "01c9ed068ef6f7f7724391fb2adffccab3db18e447e2514c7e07a233333534cef73ce2301204ca9f4beafb8907b992d9757e951a827765125a2639926afe2907";
1942 const PAYMENT_CANONICAL: &str = r#"{"kind":"ap2.payment.v1","cart_hash":"beef5678","caller":"slack:T1:U9","conversation_id":"conv-1","args_json":"{\"url\":\"https://shop.example/item\",\"max\":\"25000\"}","currency":"0xToken","amount_base_units":"25000","issued_at_unix":1750000000,"expires_at_unix":1750086400,"nonce":"nonce-payment"}"#;
1943 const PAYMENT_PAYLOAD: &str = r#"{"kind":"ap2.payment.v1","cart_hash":"beef5678","caller":"slack:T1:U9","conversation_id":"conv-1","args_json":"{\"url\":\"https://shop.example/item\",\"max\":\"25000\"}","currency":"0xToken","amount_base_units":"25000","issued_at_unix":1750000000,"expires_at_unix":1750086400,"nonce":"nonce-payment","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"95c1f52b6b4f3433a78884d7f65caafbaa8423a091632ad4b6e04b95966ca71afdf7819a5399c8f8dc1c0eb2c80b3b3eaa4fe59551eb9eb6a4492f36e9e07e06"}"#;
1944 const PAYMENT_SIG: &str = "95c1f52b6b4f3433a78884d7f65caafbaa8423a091632ad4b6e04b95966ca71afdf7819a5399c8f8dc1c0eb2c80b3b3eaa4fe59551eb9eb6a4492f36e9e07e06";
1945}