//! AP2-style pre-authorization mandates: canonical signing plus the chain
//! that narrows them (Intent → Cart → Payment).
//!
//! An AP2 mandate chain is three signed artifacts, each narrowing the one
//! before it: an **Intent** mandate (the human's up-front authorization
//! scope), a **Cart** mandate (a specific merchant + amount drawn from that
//! scope), and a **Payment** mandate (the exact tool call the cart pays for).
//! Each signs the canonical JSON encoding of its fields with `signed_by` /
//! `signature_hex` cleared — the same pattern [`crate::approval`] uses for
//! `approval_response` / `payment_receipt` — plus a literal `kind` tag so a
//! signed Cart can never be mistaken for a signed Intent even if their field
//! sets happened to overlap.
//!
//! A child mandate references its parent by `mandate_hash`: the sha256 hex
//! of the PARENT'S FULL signed payload (body + signature), not just its
//! unsigned fields. Hashing the signature too means the reference commits to
//! one exact, already-verified artifact — a parent re-signed by a different
//! key (even over byte-identical fields) produces a different hash and breaks
//! the chain.
//!
//! This module owns two layers over that signing primitive:
//!
//! * **Signing** — mints and verifies each link's individual signature and
//! computes the chain-link hash. Knows nothing about amount narrowing,
//! expiry ordering, or which call a Payment mandate authorizes.
//! * **Chain validation** — the *business* rules built on top: end-to-end
//! chain validation (`MandateChain::verify`) yielding a
//! `VerifiedMandateChain`; authorization of a concrete proposed spend
//! against a verified chain (`VerifiedMandateChain::authorize`);
//! `resolve`, the gate the payment proxy calls at the same enforcement
//! seam as a pre-signing spend cap — a no-op (today's behavior, unchanged)
//! whenever no chain is presented or no issuer key is configured; and
//! `payment_mandate_from_approval`, which formalizes an ALREADY-verified
//! HITL decision ([`crate::approval::VerifiedResponse`]) as a signed
//! Payment mandate chained to a Cart — reusing the existing signed-approval
//! machinery as the trust root rather than building a second approval
//! surface.
//!
//! Mandates are **additive and fail-closed**: absent a presented chain (or
//! with no issuer key configured), behavior is exactly today's
//! HITL-approval + spend-cap path. A chain that IS presented while the
//! feature is configured must verify end-to-end or the payment is refused —
//! an invalid mandate is never silently ignored.
//!
//! Amounts are settlement-token base units (`u128`), the same unit a
//! pre-signing spend cap, the challenge wire amount, and a conversation
//! budget use.
use serde::Serialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::approval::VerifiedResponse;
use crate::canon::canon_args;
use crate::signed::{Envelope, canonical_bytes};
use crate::{Signer, verify};
/// Signed `kind` tag for an Intent mandate's canonical JSON.
const KIND_INTENT: &str = "ap2.intent.v1";
/// Signed `kind` tag for a Cart mandate's canonical JSON.
const KIND_CART: &str = "ap2.cart.v1";
/// Signed `kind` tag for a Payment mandate's canonical JSON.
const KIND_PAYMENT: &str = "ap2.payment.v1";
/// Sha256 hex of a mandate's FULL signed payload bytes — the chain link.
///
/// Takes the bytes a `sign_*_mandate` function returned (or read back from
/// storage); the result is the value a child mandate signs into its
/// `intent_hash` / `cart_hash` field.
#[must_use]
pub fn mandate_hash(signed_payload: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(signed_payload);
crate::hex::lower(&hasher.finalize())
}
/// Named signed fields for an Intent mandate — the top-level, human-granted
/// authorization scope.
///
/// Passed as a single struct (mirrors [`crate::approval::ReceiptPayload`]) so
/// two same-typed `&str` fields can't be silently swapped at a call site.
#[derive(Debug, Clone, Copy)]
pub struct IntentFields<'a> {
/// The identity that granted this authorization (mirrors
/// `approval_response.caller`, e.g. `slack:T1:U9`).
pub caller: &'a str,
/// The conversation this intent is scoped to.
pub conversation_id: &'a str,
/// Free-form human-readable description of what was authorized (audit /
/// display only; not itself a scope predicate).
pub scope_description: &'a str,
/// Settlement token contract address every descendant Cart/Payment must
/// match.
pub currency: &'a str,
/// Decimal base-unit ceiling on the total this intent may ultimately
/// authorize across every descendant Cart; empty ⇒ unbounded (a Cart's
/// amount is still bounded by its own signed value, just not by this
/// intent).
pub max_total_base_units: &'a str,
/// Unix seconds this mandate was issued.
pub issued_at_unix: u64,
/// Unix seconds after which this mandate (and every descendant) is no
/// longer valid.
pub expires_at_unix: u64,
/// Per-mandate unique value (mirrors `approval_response.nonce`).
pub nonce: &'a str,
}
impl<'a> IntentFields<'a> {
const fn canonical_json(&self) -> IntentCanonical<'a> {
IntentCanonical {
kind: KIND_INTENT,
caller: self.caller,
conversation_id: self.conversation_id,
scope_description: self.scope_description,
currency: self.currency,
max_total_base_units: self.max_total_base_units,
issued_at_unix: self.issued_at_unix,
expires_at_unix: self.expires_at_unix,
nonce: self.nonce,
}
}
}
/// The signed Intent-mandate field set, in its frozen order.
///
/// Declaration order IS the signed contract — see [`canonical_bytes`] and ADR
/// 0009. Reordering a field invalidates every Intent mandate ever signed.
#[derive(Serialize)]
struct IntentCanonical<'a> {
kind: &'static str,
caller: &'a str,
conversation_id: &'a str,
scope_description: &'a str,
currency: &'a str,
max_total_base_units: &'a str,
issued_at_unix: u64,
expires_at_unix: u64,
nonce: &'a str,
}
/// A verified, decoded Intent mandate.
#[derive(Debug, Clone)]
pub struct VerifiedIntentMandate {
/// The identity that granted this authorization.
pub caller: String,
/// The conversation this intent is scoped to.
pub conversation_id: String,
/// Human-readable description of what was authorized.
pub scope_description: String,
/// Settlement token contract address.
pub currency: String,
/// Decimal base-unit ceiling on the total; empty ⇒ unbounded.
pub max_total_base_units: String,
/// Unix seconds this mandate was issued.
pub issued_at_unix: u64,
/// Unix seconds after which this mandate is no longer valid.
pub expires_at_unix: u64,
/// Per-mandate unique value.
pub nonce: String,
/// The verified signer's public key (encoded).
pub signer_public_key: Vec<u8>,
}
/// Sign the canonical bytes of `fields`.
///
/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`; the
/// caller persists `full_payload_bytes` and, for a Cart mandate, feeds it
/// through [`mandate_hash`] to build the chain link.
#[must_use]
pub fn sign_intent_mandate(
fields: &IntentFields<'_>,
signer: &Signer,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(fields.canonical_json(), signer)
}
/// Verify a persisted Intent mandate payload.
///
/// Returns `Some(record)` if the signature checks out against the embedded
/// public key and the payload carries the Intent `kind` tag. Returns `None` if the
/// payload is malformed, the hex fields don't decode, the `kind` tag doesn't
/// match, or the signature doesn't verify.
#[must_use]
pub fn verify_signed_intent_mandate(payload: &[u8]) -> Option<VerifiedIntentMandate> {
let v: Value = serde_json::from_slice(payload).ok()?;
if v.get("kind")?.as_str()? != KIND_INTENT {
return None;
}
let caller = v.get("caller")?.as_str()?.to_owned();
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let scope_description = v.get("scope_description")?.as_str()?.to_owned();
let currency = v.get("currency")?.as_str()?.to_owned();
let max_total_base_units = v.get("max_total_base_units")?.as_str()?.to_owned();
let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
let nonce = v.get("nonce")?.as_str()?.to_owned();
let (pk, sig) = envelope_signature(&v)?;
let fields = IntentFields {
caller: &caller,
conversation_id: &conversation_id,
scope_description: &scope_description,
currency: ¤cy,
max_total_base_units: &max_total_base_units,
issued_at_unix,
expires_at_unix,
nonce: &nonce,
};
if verify(&pk, &canonical_bytes(&fields.canonical_json()), &sig) {
Some(VerifiedIntentMandate {
caller,
conversation_id,
scope_description,
currency,
max_total_base_units,
issued_at_unix,
expires_at_unix,
nonce,
signer_public_key: pk,
})
} else {
None
}
}
/// Named signed fields for a Cart mandate — narrows an Intent to a specific
/// merchant and amount.
#[derive(Debug, Clone, Copy)]
pub struct CartFields<'a> {
/// [`mandate_hash`] of the parent Intent mandate's full signed payload.
pub intent_hash: &'a str,
/// The identity that granted this authorization (must equal the parent
/// Intent's `caller`; checked by the chain validator, not here).
pub caller: &'a str,
/// The conversation this cart is scoped to.
pub conversation_id: &'a str,
/// The merchant host this cart authorizes payment to (lowercased).
pub merchant_host: &'a str,
/// Settlement token contract address.
pub currency: &'a str,
/// Decimal base-unit total for this cart.
pub amount_base_units: &'a str,
/// Unix seconds this mandate was issued.
pub issued_at_unix: u64,
/// Unix seconds after which this mandate is no longer valid.
pub expires_at_unix: u64,
/// Per-mandate unique value.
pub nonce: &'a str,
}
impl<'a> CartFields<'a> {
const fn canonical_json(&self) -> CartCanonical<'a> {
CartCanonical {
kind: KIND_CART,
intent_hash: self.intent_hash,
caller: self.caller,
conversation_id: self.conversation_id,
merchant_host: self.merchant_host,
currency: self.currency,
amount_base_units: self.amount_base_units,
issued_at_unix: self.issued_at_unix,
expires_at_unix: self.expires_at_unix,
nonce: self.nonce,
}
}
}
/// The signed Cart-mandate field set, in its frozen order.
///
/// Declaration order IS the signed contract — see [`canonical_bytes`] and ADR
/// 0009.
#[derive(Serialize)]
struct CartCanonical<'a> {
kind: &'static str,
intent_hash: &'a str,
caller: &'a str,
conversation_id: &'a str,
merchant_host: &'a str,
currency: &'a str,
amount_base_units: &'a str,
issued_at_unix: u64,
expires_at_unix: u64,
nonce: &'a str,
}
/// A verified, decoded Cart mandate.
#[derive(Debug, Clone)]
pub struct VerifiedCartMandate {
/// [`mandate_hash`] of the parent Intent mandate this cart chains to.
pub intent_hash: String,
/// The identity that granted this authorization.
pub caller: String,
/// The conversation this cart is scoped to.
pub conversation_id: String,
/// The merchant host this cart authorizes payment to.
pub merchant_host: String,
/// Settlement token contract address.
pub currency: String,
/// Decimal base-unit total for this cart.
pub amount_base_units: String,
/// Unix seconds this mandate was issued.
pub issued_at_unix: u64,
/// Unix seconds after which this mandate is no longer valid.
pub expires_at_unix: u64,
/// Per-mandate unique value.
pub nonce: String,
/// The verified signer's public key (encoded).
pub signer_public_key: Vec<u8>,
}
/// Sign the canonical bytes of `fields`. Returns
/// `(full_payload_bytes, signature_bytes, public_key_bytes)`.
#[must_use]
pub fn sign_cart_mandate(fields: &CartFields<'_>, signer: &Signer) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(fields.canonical_json(), signer)
}
/// Verify a persisted Cart mandate payload. See
/// [`verify_signed_intent_mandate`] for the failure modes.
#[must_use]
pub fn verify_signed_cart_mandate(payload: &[u8]) -> Option<VerifiedCartMandate> {
let v: Value = serde_json::from_slice(payload).ok()?;
if v.get("kind")?.as_str()? != KIND_CART {
return None;
}
let intent_hash = v.get("intent_hash")?.as_str()?.to_owned();
let caller = v.get("caller")?.as_str()?.to_owned();
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let merchant_host = v.get("merchant_host")?.as_str()?.to_owned();
let currency = v.get("currency")?.as_str()?.to_owned();
let amount_base_units = v.get("amount_base_units")?.as_str()?.to_owned();
let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
let nonce = v.get("nonce")?.as_str()?.to_owned();
let (pk, sig) = envelope_signature(&v)?;
let fields = CartFields {
intent_hash: &intent_hash,
caller: &caller,
conversation_id: &conversation_id,
merchant_host: &merchant_host,
currency: ¤cy,
amount_base_units: &amount_base_units,
issued_at_unix,
expires_at_unix,
nonce: &nonce,
};
if verify(&pk, &canonical_bytes(&fields.canonical_json()), &sig) {
Some(VerifiedCartMandate {
intent_hash,
caller,
conversation_id,
merchant_host,
currency,
amount_base_units,
issued_at_unix,
expires_at_unix,
nonce,
signer_public_key: pk,
})
} else {
None
}
}
/// Named signed fields for a Payment mandate — the final authorization bound
/// to one exact tool call.
#[derive(Debug, Clone, Copy)]
pub struct PaymentFields<'a> {
/// [`mandate_hash`] of the parent Cart mandate's full signed payload.
pub cart_hash: &'a str,
/// The identity that granted this authorization.
pub caller: &'a str,
/// The conversation this payment is scoped to.
pub conversation_id: &'a str,
/// The exact `paid_fetch` `args_json` this payment authorizes (mirrors
/// `approval_response.args_json` binding — a captured mandate cannot be
/// replayed against a different call).
pub args_json: &'a str,
/// Settlement token contract address.
pub currency: &'a str,
/// Decimal base-unit amount for this payment.
pub amount_base_units: &'a str,
/// Unix seconds this mandate was issued.
pub issued_at_unix: u64,
/// Unix seconds after which this mandate is no longer valid.
pub expires_at_unix: u64,
/// Per-mandate unique value.
pub nonce: &'a str,
}
impl<'a> PaymentFields<'a> {
const fn canonical_json(&self) -> PaymentCanonical<'a> {
PaymentCanonical {
kind: KIND_PAYMENT,
cart_hash: self.cart_hash,
caller: self.caller,
conversation_id: self.conversation_id,
args_json: self.args_json,
currency: self.currency,
amount_base_units: self.amount_base_units,
issued_at_unix: self.issued_at_unix,
expires_at_unix: self.expires_at_unix,
nonce: self.nonce,
}
}
}
/// The signed Payment-mandate field set, in its frozen order.
///
/// Declaration order IS the signed contract — see [`canonical_bytes`] and ADR
/// 0009.
#[derive(Serialize)]
struct PaymentCanonical<'a> {
kind: &'static str,
cart_hash: &'a str,
caller: &'a str,
conversation_id: &'a str,
args_json: &'a str,
currency: &'a str,
amount_base_units: &'a str,
issued_at_unix: u64,
expires_at_unix: u64,
nonce: &'a str,
}
/// A verified, decoded Payment mandate.
#[derive(Debug, Clone)]
pub struct VerifiedPaymentMandate {
/// [`mandate_hash`] of the parent Cart mandate this payment chains to.
pub cart_hash: String,
/// The identity that granted this authorization.
pub caller: String,
/// The conversation this payment is scoped to.
pub conversation_id: String,
/// The exact `paid_fetch` `args_json` this payment authorizes.
pub args_json: String,
/// Settlement token contract address.
pub currency: String,
/// Decimal base-unit amount for this payment.
pub amount_base_units: String,
/// Unix seconds this mandate was issued.
pub issued_at_unix: u64,
/// Unix seconds after which this mandate is no longer valid.
pub expires_at_unix: u64,
/// Per-mandate unique value.
pub nonce: String,
/// The verified signer's public key (encoded).
pub signer_public_key: Vec<u8>,
}
/// Sign the canonical bytes of `fields`. Returns
/// `(full_payload_bytes, signature_bytes, public_key_bytes)`.
#[must_use]
pub fn sign_payment_mandate(
fields: &PaymentFields<'_>,
signer: &Signer,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(fields.canonical_json(), signer)
}
/// Verify a persisted Payment mandate payload. See
/// [`verify_signed_intent_mandate`] for the failure modes.
#[must_use]
pub fn verify_signed_payment_mandate(payload: &[u8]) -> Option<VerifiedPaymentMandate> {
let v: Value = serde_json::from_slice(payload).ok()?;
if v.get("kind")?.as_str()? != KIND_PAYMENT {
return None;
}
let cart_hash = v.get("cart_hash")?.as_str()?.to_owned();
let caller = v.get("caller")?.as_str()?.to_owned();
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let args_json = v.get("args_json")?.as_str()?.to_owned();
let currency = v.get("currency")?.as_str()?.to_owned();
let amount_base_units = v.get("amount_base_units")?.as_str()?.to_owned();
let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
let nonce = v.get("nonce")?.as_str()?.to_owned();
let (pk, sig) = envelope_signature(&v)?;
let fields = PaymentFields {
cart_hash: &cart_hash,
caller: &caller,
conversation_id: &conversation_id,
args_json: &args_json,
currency: ¤cy,
amount_base_units: &amount_base_units,
issued_at_unix,
expires_at_unix,
nonce: &nonce,
};
if verify(&pk, &canonical_bytes(&fields.canonical_json()), &sig) {
Some(VerifiedPaymentMandate {
cart_hash,
caller,
conversation_id,
args_json,
currency,
amount_base_units,
issued_at_unix,
expires_at_unix,
nonce,
signer_public_key: pk,
})
} else {
None
}
}
/// Extracts and hex-decodes the `signed_by` / `signature_hex` pair a
/// `verify_signed_*_mandate` needs, common to all three envelope shapes.
fn envelope_signature(v: &Value) -> Option<(Vec<u8>, Vec<u8>)> {
let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
Some((pk, sig))
}
// ---------------------------------------------------------------------------
// Chain validation — the AP2 business rules built on the signing primitives
// above: hash-chaining, amount narrowing, currency/caller/conversation
// agreement, and expiry. [`MandateChain::verify`] is the sole entry point;
// everything below exists to produce or consume its result.
// ---------------------------------------------------------------------------
/// Raised while validating a [`MandateChain`] or authorizing a spend
/// against a [`VerifiedMandateChain`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MandateError {
/// The Intent mandate is malformed or its signature does not verify.
#[error("intent mandate is malformed or its signature does not verify")]
InvalidIntent,
/// The Cart mandate is malformed or its signature does not verify.
#[error("cart mandate is malformed or its signature does not verify")]
InvalidCart,
/// The Payment mandate is malformed or its signature does not verify.
#[error("payment mandate is malformed or its signature does not verify")]
InvalidPayment,
/// A link's signer is not the platform issuer key.
#[error("{0} mandate is not signed by a trusted key")]
UntrustedSigner(&'static str),
/// A link is bound to a different conversation than the one it is being
/// consumed in.
#[error("{0} mandate is bound to a different conversation")]
ConversationMismatch(&'static str),
/// The chain's links are not all bound to one consistent caller.
#[error("mandate chain is not bound to one consistent caller")]
CallerMismatch,
/// A link has expired as of the check time.
#[error("{kind} mandate expired at {expires_at_unix}")]
Expired {
/// Which link expired.
kind: &'static str,
/// Its signed unix-seconds expiry.
expires_at_unix: u64,
},
/// The Cart's `intent_hash` does not reference the presented Intent.
#[error("cart mandate does not chain to the presented intent mandate")]
CartNotChainedToIntent,
/// The Payment's `cart_hash` does not reference the presented Cart.
#[error("payment mandate does not chain to the presented cart mandate")]
PaymentNotChainedToCart,
/// A signed amount field is not a parseable base-unit integer. Fails
/// closed: a mandate whose amount cannot be understood authorizes
/// nothing.
#[error("{0} mandate carries an unparseable base-unit amount")]
MalformedAmount(&'static str),
/// The Cart's amount exceeds the Intent's ceiling (widening).
#[error("cart amount {cart} exceeds the intent ceiling {intent}")]
AmountWidensAtCart {
/// The Cart's base-unit amount.
cart: u128,
/// The Intent's base-unit ceiling.
intent: u128,
},
/// The Payment's amount exceeds the Cart's amount (widening).
#[error("payment amount {payment} exceeds the cart amount {cart}")]
AmountWidensAtPayment {
/// The Payment's base-unit amount.
payment: u128,
/// The Cart's base-unit amount.
cart: u128,
},
/// Currency differs across the chain.
#[error("mandate currency does not match across the chain")]
CurrencyMismatch,
/// The Cart's merchant scope is empty — a scopeless cart authorizes
/// nothing (fail closed).
#[error("cart mandate carries no merchant scope")]
EmptyScope,
/// A verified chain does not cover the proposed spend (amount or
/// destination).
#[error(
"mandate authorizes at most {authorized} base units against {authorized_host}; \
requested {requested} base units against {requested_host}"
)]
ExceedsAuthorization {
/// The base-unit amount the chain authorizes.
authorized: u128,
/// The merchant host the chain authorizes spend against.
authorized_host: String,
/// The requested base-unit amount.
requested: u128,
/// The host the request targets.
requested_host: String,
},
/// The call's args do not match the exact `args_json` the Payment
/// mandate signed — a mandate minted for one call cannot authorize a
/// different one.
#[error("the payment mandate is bound to a different tool call's args")]
ArgsBindingMismatch,
/// The verified HITL approval offered as the trust root does not
/// authorize the exact call the Payment mandate is being minted for.
#[error("the HITL approval does not authorize this exact call; no mandate was minted")]
ApprovalDoesNotAuthorizeCall,
}
/// The three signed, wire-form mandate payloads presented together as one
/// pre-authorization.
#[derive(Debug, Clone, Default)]
pub struct MandateChain {
/// Signed Intent mandate payload ([`sign_intent_mandate`] output).
pub intent: Vec<u8>,
/// Signed Cart mandate payload.
pub cart: Vec<u8>,
/// Signed Payment mandate payload.
pub payment: Vec<u8>,
}
impl MandateChain {
/// Validate the whole chain: every link is bound to `conversation_id`
/// and one consistent caller, the Payment chains (by [`mandate_hash`])
/// to the Cart and the Cart to the Intent, amounts narrow (never widen)
/// down the chain, currency agrees, the Cart carries a non-empty
/// merchant scope, and no link has expired as of `now_unix`.
///
/// Every link verifies against `issuer_public_key`, the platform
/// mandate-issuing key: narrowing a human's authorization down to a
/// merchant and an amount is never something a browser-held key can do
/// on its own, and the Intent link above them is minted by the same
/// issuer. A link signed by any other key is untrusted.
///
/// # Errors
///
/// One [`MandateError`] per broken invariant — see each variant. Fails
/// closed on everything: malformed payloads, unknown signers, chain
/// breaks, widened or unparseable amounts, scope/currency drift,
/// expiry.
pub fn verify(
&self,
conversation_id: &str,
issuer_public_key: &[u8],
now_unix: u64,
) -> Result<VerifiedMandateChain, MandateError> {
let intent =
verify_signed_intent_mandate(&self.intent).ok_or(MandateError::InvalidIntent)?;
let cart = verify_signed_cart_mandate(&self.cart).ok_or(MandateError::InvalidCart)?;
let payment =
verify_signed_payment_mandate(&self.payment).ok_or(MandateError::InvalidPayment)?;
// Conversation + expiry checks, root first — unchanged for every
// link regardless of which key trusts it.
let links: [(&'static str, &str, u64); 3] = [
("intent", &intent.conversation_id, intent.expires_at_unix),
("cart", &cart.conversation_id, cart.expires_at_unix),
("payment", &payment.conversation_id, payment.expires_at_unix),
];
for (label, conv, expires_at_unix) in links {
if conv != conversation_id {
return Err(MandateError::ConversationMismatch(label));
}
if expires_at_unix <= now_unix {
return Err(MandateError::Expired {
kind: label,
expires_at_unix,
});
}
}
// Per-link signer trust: every link verifies against the platform
// issuer key — see the doc comment above for why a narrowing link is
// never user-signable.
if intent.signer_public_key != issuer_public_key {
return Err(MandateError::UntrustedSigner("intent"));
}
if cart.signer_public_key != issuer_public_key {
return Err(MandateError::UntrustedSigner("cart"));
}
if payment.signer_public_key != issuer_public_key {
return Err(MandateError::UntrustedSigner("payment"));
}
if intent.caller != cart.caller || cart.caller != payment.caller {
return Err(MandateError::CallerMismatch);
}
// Chain links: each child signed the sha256 of its parent's FULL
// signed payload, so the reference commits to one exact,
// already-verified artifact — a re-signed parent (even over
// byte-identical fields) breaks the chain.
if cart.intent_hash != mandate_hash(&self.intent) {
return Err(MandateError::CartNotChainedToIntent);
}
if payment.cart_hash != mandate_hash(&self.cart) {
return Err(MandateError::PaymentNotChainedToCart);
}
// Amounts narrow down the chain. The Intent's ceiling may be empty
// (unbounded — the Cart's own signed amount still bounds spend);
// the Cart/Payment amounts must parse or the chain authorizes
// nothing.
let cart_amount: u128 = cart
.amount_base_units
.parse()
.map_err(|_| MandateError::MalformedAmount("cart"))?;
let payment_amount: u128 = payment
.amount_base_units
.parse()
.map_err(|_| MandateError::MalformedAmount("payment"))?;
if !intent.max_total_base_units.is_empty() {
let ceiling: u128 = intent
.max_total_base_units
.parse()
.map_err(|_| MandateError::MalformedAmount("intent"))?;
if cart_amount > ceiling {
return Err(MandateError::AmountWidensAtCart {
cart: cart_amount,
intent: ceiling,
});
}
}
if payment_amount > cart_amount {
return Err(MandateError::AmountWidensAtPayment {
payment: payment_amount,
cart: cart_amount,
});
}
if intent.currency != cart.currency || cart.currency != payment.currency {
return Err(MandateError::CurrencyMismatch);
}
if cart.merchant_host.is_empty() {
return Err(MandateError::EmptyScope);
}
Ok(VerifiedMandateChain {
authorized_amount_base_units: payment_amount,
merchant_host: cart.merchant_host.clone(),
currency: payment.currency.clone(),
caller: payment.caller.clone(),
conversation_id: payment.conversation_id.clone(),
args_json: payment.args_json.clone(),
intent,
cart,
payment,
})
}
}
/// A fully chain-validated mandate: the authoritative pre-authorization for
/// exactly one payment.
#[derive(Debug, Clone)]
pub struct VerifiedMandateChain {
/// The maximum this chain authorizes spending — the Payment link's
/// exact base-unit amount.
pub authorized_amount_base_units: u128,
/// The merchant host the chain authorizes spend against (from the
/// Cart).
pub merchant_host: String,
/// The settlement currency the chain is denominated in.
pub currency: String,
/// The principal the chain is bound to.
pub caller: String,
/// The conversation the chain is bound to.
pub conversation_id: String,
/// The exact `args_json` the Payment link authorizes.
pub args_json: String,
/// The verified Intent link.
pub intent: VerifiedIntentMandate,
/// The verified Cart link.
pub cart: VerifiedCartMandate,
/// The verified Payment link.
pub payment: VerifiedPaymentMandate,
}
impl VerifiedMandateChain {
/// Authorize a proposed spend: `requested_base_units` against
/// `requested_host`, fulfilling the call whose arguments are
/// `args_json`.
///
/// The pre-sign check the payment proxy calls at its enforcement seam,
/// alongside a pre-signing spend cap's own authorize check. Three
/// bindings, all fail-closed:
///
/// * **destination** — `requested_host` must equal the Cart's merchant
/// scope (ASCII case-insensitively; hosts are DNS names);
/// * **call** — `args_json` must value-match the exact args the Payment
/// link signed (canonicalized with the same rules as the approval
/// binding, so key order does not matter but any key/value difference
/// refuses);
/// * **amount** — `requested_base_units` must not exceed the Payment
/// link's amount.
///
/// # Errors
///
/// [`MandateError::ExceedsAuthorization`] on a host or amount breach;
/// [`MandateError::ArgsBindingMismatch`] when the call's args are not
/// the ones the mandate was minted for.
pub fn authorize(
&self,
requested_base_units: u128,
requested_host: &str,
args_json: &str,
) -> Result<(), MandateError> {
if canon_args(args_json) != canon_args(&self.args_json) {
return Err(MandateError::ArgsBindingMismatch);
}
let host_ok = self.merchant_host.eq_ignore_ascii_case(requested_host);
if !host_ok || requested_base_units > self.authorized_amount_base_units {
return Err(MandateError::ExceedsAuthorization {
authorized: self.authorized_amount_base_units,
authorized_host: self.merchant_host.clone(),
requested: requested_base_units,
requested_host: requested_host.to_owned(),
});
}
Ok(())
}
}
/// The feature-gated pre-authorization resolver the payment proxy calls at
/// its enforcement seam (`proxy::fulfill`).
///
/// Returns `Ok(None)` — a no-op, today's spend-cap-only behavior UNCHANGED —
/// unless BOTH a chain is presented AND an issuer key is configured
/// (`TEMPO_MANDATE_ISSUER_PUBKEY`; unset by default, so mandates are off by
/// default). When both are present the chain must validate end-to-end
/// ([`MandateChain::verify`]) or the payment is refused — a
/// presented-but-invalid mandate is never silently ignored.
///
/// # Errors
///
/// See [`MandateChain::verify`].
pub fn resolve(
chain: Option<&MandateChain>,
issuer_public_key: Option<&[u8]>,
conversation_id: &str,
now_unix: u64,
) -> Result<Option<VerifiedMandateChain>, MandateError> {
match (chain, issuer_public_key) {
(Some(c), Some(key)) => c.verify(conversation_id, key, now_unix).map(Some),
_ => Ok(None),
}
}
/// Formalize an ALREADY-verified Slack/Telegram HITL approval
/// ([`VerifiedResponse`]) as a signed Payment mandate chained to
/// `cart_payload`.
///
/// This is the HITL→mandate bridge: it mints NO new approval surface and
/// trusts NOTHING beyond what the existing signed-approval machinery
/// already verified. A Payment mandate is minted only when:
///
/// * `approved` authorizes the EXACT `(request_id, tool_name, args_json)`
/// tuple being fulfilled ([`VerifiedResponse::authorizes_call`] — the
/// same binding the proxy's approval gate requires; a denial or an
/// approval for any other call refuses);
/// * `cart_payload` is a validly signed Cart mandate whose `caller` and
/// `conversation_id` equal the approval's own signed values — a cart
/// scoped to a different principal or conversation than the human who
/// approved cannot be completed under that approval.
///
/// The minted Payment inherits the Cart's amount and currency (an exact
/// narrowing: it authorizes the whole cart, nothing more), binds the
/// approved `args_json`, and chains to the Cart by [`mandate_hash`].
/// Returns the full signed payload bytes, ready to persist or present in a
/// [`MandateChain`].
///
/// # Errors
///
/// [`MandateError::ApprovalDoesNotAuthorizeCall`] when the approval does
/// not cover the exact call; [`MandateError::InvalidCart`] when
/// `cart_payload` does not verify; [`MandateError::CallerMismatch`] /
/// [`MandateError::ConversationMismatch`] when the cart is scoped to a
/// different principal / conversation than the approval.
#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the minted mandate
pub fn payment_mandate_from_approval(
approved: &VerifiedResponse,
request_id: &str,
tool_name: &str,
args_json: &str,
cart_payload: &[u8],
issued_at_unix: u64,
expires_at_unix: u64,
nonce: &str,
signer: &Signer,
) -> Result<Vec<u8>, MandateError> {
if !approved.authorizes_call(request_id, tool_name, args_json) {
return Err(MandateError::ApprovalDoesNotAuthorizeCall);
}
let cart = verify_signed_cart_mandate(cart_payload).ok_or(MandateError::InvalidCart)?;
if cart.caller != approved.caller {
return Err(MandateError::CallerMismatch);
}
if cart.conversation_id != approved.conversation_id {
return Err(MandateError::ConversationMismatch("cart"));
}
let fields = PaymentFields {
cart_hash: &mandate_hash(cart_payload),
caller: &approved.caller,
conversation_id: &approved.conversation_id,
args_json,
currency: &cart.currency,
amount_base_units: &cart.amount_base_units,
issued_at_unix,
expires_at_unix,
nonce,
};
let (payload, _sig, _pk) = sign_payment_mandate(&fields, signer);
Ok(payload)
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
use crate::approval::{ApprovalSigner, response_payload, verify_signed_response};
fn intent(signer: &Signer) -> (Vec<u8>, IntentFields<'static>) {
let fields = IntentFields {
caller: "slack:T1:U9",
conversation_id: "conv-1",
scope_description: "research report purchases",
currency: "0xUSD",
max_total_base_units: "1000000",
issued_at_unix: 1_000,
expires_at_unix: 10_000,
nonce: "intent-nonce-1",
};
let (payload, _sig, _pk) = sign_intent_mandate(&fields, signer);
(payload, fields)
}
#[test]
fn intent_mandate_round_trips() {
let signer = Signer::from_seed(1);
let (payload, fields) = intent(&signer);
let verified = verify_signed_intent_mandate(&payload).expect("verifies");
assert_eq!(verified.caller, fields.caller);
assert_eq!(verified.conversation_id, fields.conversation_id);
assert_eq!(verified.max_total_base_units, fields.max_total_base_units);
assert_eq!(verified.signer_public_key, signer.public_key_bytes());
}
#[test]
fn intent_mandate_tampered_amount_fails() {
let signer = Signer::from_seed(1);
let (payload, _fields) = intent(&signer);
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v["max_total_base_units"] = Value::String("999999999".to_owned());
assert!(verify_signed_intent_mandate(&v.to_string().into_bytes()).is_none());
}
#[test]
fn intent_mandate_wrong_kind_rejected() {
// A Cart payload must never verify as an Intent, even before the
// signature is checked — the literal `kind` tag is the first gate.
let signer = Signer::from_seed(1);
let cart_fields = CartFields {
intent_hash: "deadbeef",
caller: "slack:T1:U9",
conversation_id: "conv-1",
merchant_host: "api.example.com",
currency: "0xUSD",
amount_base_units: "500000",
issued_at_unix: 1_000,
expires_at_unix: 5_000,
nonce: "cart-nonce-1",
};
let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
assert!(verify_signed_intent_mandate(&cart_payload).is_none());
}
#[test]
fn cart_mandate_round_trips_and_chains_by_hash() {
let signer = Signer::from_seed(2);
let (intent_payload, _fields) = intent(&signer);
let intent_hash = mandate_hash(&intent_payload);
let cart_fields = CartFields {
intent_hash: &intent_hash,
caller: "slack:T1:U9",
conversation_id: "conv-1",
merchant_host: "api.example.com",
currency: "0xUSD",
amount_base_units: "500000",
issued_at_unix: 1_000,
expires_at_unix: 5_000,
nonce: "cart-nonce-1",
};
let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
let verified = verify_signed_cart_mandate(&cart_payload).expect("cart verifies");
assert_eq!(verified.intent_hash, intent_hash);
assert_eq!(verified.merchant_host, "api.example.com");
}
#[test]
fn cart_mandate_tampered_intent_hash_fails() {
let signer = Signer::from_seed(2);
let (intent_payload, _fields) = intent(&signer);
let intent_hash = mandate_hash(&intent_payload);
let cart_fields = CartFields {
intent_hash: &intent_hash,
caller: "slack:T1:U9",
conversation_id: "conv-1",
merchant_host: "api.example.com",
currency: "0xUSD",
amount_base_units: "500000",
issued_at_unix: 1_000,
expires_at_unix: 5_000,
nonce: "cart-nonce-1",
};
let (payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v["intent_hash"] = Value::String("0".repeat(64));
assert!(verify_signed_cart_mandate(&v.to_string().into_bytes()).is_none());
}
#[test]
fn payment_mandate_round_trips_and_chains_by_hash() {
let signer = Signer::from_seed(3);
let (intent_payload, _fields) = intent(&signer);
let intent_hash = mandate_hash(&intent_payload);
let cart_fields = CartFields {
intent_hash: &intent_hash,
caller: "slack:T1:U9",
conversation_id: "conv-1",
merchant_host: "api.example.com",
currency: "0xUSD",
amount_base_units: "500000",
issued_at_unix: 1_000,
expires_at_unix: 5_000,
nonce: "cart-nonce-1",
};
let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
let cart_hash = mandate_hash(&cart_payload);
let payment_fields = PaymentFields {
cart_hash: &cart_hash,
caller: "slack:T1:U9",
conversation_id: "conv-1",
args_json: r#"{"url":"https://api.example.com/report"}"#,
currency: "0xUSD",
amount_base_units: "250000",
issued_at_unix: 1_000,
expires_at_unix: 4_000,
nonce: "payment-nonce-1",
};
let (payment_payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
let verified = verify_signed_payment_mandate(&payment_payload).expect("payment verifies");
assert_eq!(verified.cart_hash, cart_hash);
assert_eq!(verified.amount_base_units, "250000");
assert_eq!(verified.args_json, payment_fields.args_json);
}
#[test]
fn payment_mandate_tampered_args_json_fails() {
let signer = Signer::from_seed(3);
let payment_fields = PaymentFields {
cart_hash: "deadbeef",
caller: "slack:T1:U9",
conversation_id: "conv-1",
args_json: r#"{"url":"https://api.example.com/report"}"#,
currency: "0xUSD",
amount_base_units: "250000",
issued_at_unix: 1_000,
expires_at_unix: 4_000,
nonce: "payment-nonce-1",
};
let (payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v["args_json"] = Value::String(r#"{"url":"https://evil.example.com/steal"}"#.to_owned());
assert!(verify_signed_payment_mandate(&v.to_string().into_bytes()).is_none());
}
#[test]
fn mandate_hash_is_stable_and_sensitive_to_signature() {
let signer_a = Signer::from_seed(9);
let signer_b = Signer::from_seed(10);
let fields = IntentFields {
caller: "slack:T1:U9",
conversation_id: "conv-1",
scope_description: "x",
currency: "0xUSD",
max_total_base_units: "1000",
issued_at_unix: 1,
expires_at_unix: 2,
nonce: "n",
};
let (payload_a, _s, _p) = sign_intent_mandate(&fields, &signer_a);
let (payload_b, _s, _p) = sign_intent_mandate(&fields, &signer_b);
// Same fields, deterministic re-hash.
assert_eq!(mandate_hash(&payload_a), mandate_hash(&payload_a));
// Different signer over IDENTICAL fields ⇒ a different signature ⇒ a
// different hash, since the hash commits to the full signed artifact.
assert_ne!(mandate_hash(&payload_a), mandate_hash(&payload_b));
}
#[test]
fn garbage_payload_returns_none_not_panic() {
assert!(verify_signed_intent_mandate(b"not json").is_none());
assert!(verify_signed_cart_mandate(b"{}").is_none());
assert!(verify_signed_payment_mandate(b"[]").is_none());
}
const CONV: &str = "conv-1";
const CALLER: &str = "slack:T1:U9";
const HOST: &str = "api.example.com";
const USD: &str = "0x20c0000000000000000000000000000000000000";
const ARGS: &str = r#"{"url":"https://api.example.com/report"}"#;
fn issuer() -> Signer {
Signer::from_seed(99)
}
fn intent_fields(max_total: &str) -> IntentFields<'_> {
IntentFields {
caller: CALLER,
conversation_id: CONV,
scope_description: "research report purchases",
currency: USD,
max_total_base_units: max_total,
issued_at_unix: 100,
expires_at_unix: 10_000,
nonce: "n-intent",
}
}
/// Sign a mutually consistent, valid chain: Intent ceiling 1_000_000,
/// Cart 500_000 at `HOST`, Payment 500_000 bound to `ARGS`.
fn valid_chain(signer: &Signer) -> MandateChain {
let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), signer);
let intent_hash = mandate_hash(&intent);
let (cart, _s, _p) = sign_cart_mandate(
&CartFields {
intent_hash: &intent_hash,
caller: CALLER,
conversation_id: CONV,
merchant_host: HOST,
currency: USD,
amount_base_units: "500000",
issued_at_unix: 100,
expires_at_unix: 9_000,
nonce: "n-cart",
},
signer,
);
let cart_hash = mandate_hash(&cart);
let (payment, _s, _p) = sign_payment_mandate(
&PaymentFields {
cart_hash: &cart_hash,
caller: CALLER,
conversation_id: CONV,
args_json: ARGS,
currency: USD,
amount_base_units: "500000",
issued_at_unix: 100,
expires_at_unix: 8_000,
nonce: "n-payment",
},
signer,
);
MandateChain {
intent,
cart,
payment,
}
}
#[test]
fn valid_chain_verifies_and_authorizes_within_bounds() {
let signer = issuer();
let chain = valid_chain(&signer);
let verified = chain
.verify(CONV, &signer.public_key_bytes(), 1_000)
.expect("a mutually consistent, unexpired chain must verify");
assert_eq!(verified.authorized_amount_base_units, 500_000);
assert_eq!(verified.merchant_host, HOST);
assert_eq!(verified.caller, CALLER);
// At and under the ceiling, right host, exact args: authorized.
verified.authorize(500_000, HOST, ARGS).expect("at-cap ok");
verified.authorize(1, HOST, ARGS).expect("under-cap ok");
// Host matching is case-insensitive (hosts are DNS names).
verified
.authorize(500_000, "API.Example.COM", ARGS)
.expect("case-insensitive host");
// Over the authorized amount: refused.
assert!(matches!(
verified.authorize(500_001, HOST, ARGS),
Err(MandateError::ExceedsAuthorization { .. })
));
// Wrong destination: refused even for one base unit.
assert!(matches!(
verified.authorize(1, "evil.example.com", ARGS),
Err(MandateError::ExceedsAuthorization { .. })
));
// Different call args: refused — a mandate minted for one call
// cannot authorize a different one.
assert!(matches!(
verified.authorize(1, HOST, r#"{"url":"https://api.example.com/OTHER"}"#),
Err(MandateError::ArgsBindingMismatch)
));
}
#[test]
fn args_binding_matches_by_value_not_key_order() {
// Mirrors the approval binding's canon_args behavior: same
// key/value pairs in a different order still authorize; the same
// loop that bit the HITL gate must not bite mandates.
let signer = issuer();
let (intent, _s, _p) = sign_intent_mandate(&intent_fields(""), &signer);
let intent_hash = mandate_hash(&intent);
let (cart, _s, _p) = sign_cart_mandate(
&CartFields {
intent_hash: &intent_hash,
caller: CALLER,
conversation_id: CONV,
merchant_host: HOST,
currency: USD,
amount_base_units: "500000",
issued_at_unix: 100,
expires_at_unix: 9_000,
nonce: "n-cart",
},
&signer,
);
let (payment, _s, _p) = sign_payment_mandate(
&PaymentFields {
cart_hash: &mandate_hash(&cart),
caller: CALLER,
conversation_id: CONV,
args_json: r#"{"max_spend":"0.10","url":"https://api.example.com/r"}"#,
currency: USD,
amount_base_units: "500000",
issued_at_unix: 100,
expires_at_unix: 8_000,
nonce: "n-payment",
},
&signer,
);
let verified = MandateChain {
intent,
cart,
payment,
}
.verify(CONV, &signer.public_key_bytes(), 1_000)
.expect("chain verifies");
verified
.authorize(
1,
HOST,
r#"{"url":"https://api.example.com/r","max_spend":"0.10"}"#,
)
.expect("reordered-but-equal args must pass the binding");
}
#[test]
fn tampered_link_fails_signature_verification() {
let signer = issuer();
let mut chain = valid_chain(&signer);
// Flip the cart's amount in place: the signature no longer covers
// the bytes, so the whole chain is refused as InvalidCart.
let mut v: serde_json::Value = serde_json::from_slice(&chain.cart).unwrap();
v["amount_base_units"] = serde_json::Value::String("999999999".to_owned());
chain.cart = v.to_string().into_bytes();
assert_eq!(
chain
.verify(CONV, &signer.public_key_bytes(), 1_000)
.unwrap_err(),
MandateError::InvalidCart
);
}
#[test]
fn validly_signed_but_unchained_links_are_rejected() {
// Every link validly signed by the trusted issuer — but the cart
// references a DIFFERENT intent. The chain-hash check itself must
// refuse (the attack a signature check alone cannot catch).
let signer = issuer();
let chain = valid_chain(&signer);
let (other_intent, _s, _p) = sign_intent_mandate(&intent_fields("2000000"), &signer);
let other_hash = mandate_hash(&other_intent);
let (unchained_cart, _s, _p) = sign_cart_mandate(
&CartFields {
intent_hash: &other_hash, // not the presented intent
caller: CALLER,
conversation_id: CONV,
merchant_host: HOST,
currency: USD,
amount_base_units: "500000",
issued_at_unix: 100,
expires_at_unix: 9_000,
nonce: "n-cart",
},
&signer,
);
let broken = MandateChain {
intent: chain.intent.clone(),
cart: unchained_cart.clone(),
payment: chain.payment.clone(),
};
assert_eq!(
broken
.verify(CONV, &signer.public_key_bytes(), 1_000)
.unwrap_err(),
MandateError::CartNotChainedToIntent
);
// Same for the payment link: correctly chained cart, but a payment
// referencing a different cart.
let (payment_for_other, _s, _p) = sign_payment_mandate(
&PaymentFields {
cart_hash: &mandate_hash(&unchained_cart),
caller: CALLER,
conversation_id: CONV,
args_json: ARGS,
currency: USD,
amount_base_units: "500000",
issued_at_unix: 100,
expires_at_unix: 8_000,
nonce: "n-payment",
},
&signer,
);
let broken = MandateChain {
intent: chain.intent,
cart: chain.cart,
payment: payment_for_other,
};
assert_eq!(
broken
.verify(CONV, &signer.public_key_bytes(), 1_000)
.unwrap_err(),
MandateError::PaymentNotChainedToCart
);
}
#[test]
fn widened_amounts_are_rejected() {
let signer = issuer();
// Cart widens past the intent ceiling.
let (intent, _s, _p) = sign_intent_mandate(&intent_fields("100"), &signer);
let intent_hash = mandate_hash(&intent);
let (cart, _s, _p) = sign_cart_mandate(
&CartFields {
intent_hash: &intent_hash,
caller: CALLER,
conversation_id: CONV,
merchant_host: HOST,
currency: USD,
amount_base_units: "999", // > the 100 ceiling
issued_at_unix: 100,
expires_at_unix: 9_000,
nonce: "n-cart",
},
&signer,
);
let (payment, _s, _p) = sign_payment_mandate(
&PaymentFields {
cart_hash: &mandate_hash(&cart),
caller: CALLER,
conversation_id: CONV,
args_json: ARGS,
currency: USD,
amount_base_units: "999",
issued_at_unix: 100,
expires_at_unix: 8_000,
nonce: "n-payment",
},
&signer,
);
let chain = MandateChain {
intent,
cart,
payment,
};
assert_eq!(
chain
.verify(CONV, &signer.public_key_bytes(), 1_000)
.unwrap_err(),
MandateError::AmountWidensAtCart {
cart: 999,
intent: 100
}
);
// Payment widens past the cart.
let base = valid_chain(&signer);
let (over_payment, _s, _p) = sign_payment_mandate(
&PaymentFields {
cart_hash: &mandate_hash(&base.cart),
caller: CALLER,
conversation_id: CONV,
args_json: ARGS,
currency: USD,
amount_base_units: "500001", // cart authorized 500000
issued_at_unix: 100,
expires_at_unix: 8_000,
nonce: "n-payment",
},
&signer,
);
let chain = MandateChain {
intent: base.intent,
cart: base.cart,
payment: over_payment,
};
assert_eq!(
chain
.verify(CONV, &signer.public_key_bytes(), 1_000)
.unwrap_err(),
MandateError::AmountWidensAtPayment {
payment: 500_001,
cart: 500_000
}
);
}
#[test]
fn unbounded_intent_ceiling_admits_any_cart_amount() {
// An Intent with an EMPTY ceiling is unbounded by design (the
// cart's own signed amount still bounds spend).
let signer = issuer();
let (intent, _s, _p) = sign_intent_mandate(&intent_fields(""), &signer);
let intent_hash = mandate_hash(&intent);
let (cart, _s, _p) = sign_cart_mandate(
&CartFields {
intent_hash: &intent_hash,
caller: CALLER,
conversation_id: CONV,
merchant_host: HOST,
currency: USD,
amount_base_units: "123456789",
issued_at_unix: 100,
expires_at_unix: 9_000,
nonce: "n-cart",
},
&signer,
);
let (payment, _s, _p) = sign_payment_mandate(
&PaymentFields {
cart_hash: &mandate_hash(&cart),
caller: CALLER,
conversation_id: CONV,
args_json: ARGS,
currency: USD,
amount_base_units: "123456789",
issued_at_unix: 100,
expires_at_unix: 8_000,
nonce: "n-payment",
},
&signer,
);
let chain = MandateChain {
intent,
cart,
payment,
};
let verified = chain
.verify(CONV, &signer.public_key_bytes(), 1_000)
.expect("empty intent ceiling is unbounded");
assert_eq!(verified.authorized_amount_base_units, 123_456_789);
}
#[test]
fn expired_link_is_rejected() {
let signer = issuer();
let chain = valid_chain(&signer);
// The payment expires first (8_000). At exactly its expiry (`<=` is
// expired) the chain refuses naming the payment link.
assert_eq!(
chain
.verify(CONV, &signer.public_key_bytes(), 8_000)
.unwrap_err(),
MandateError::Expired {
kind: "payment",
expires_at_unix: 8_000
}
);
// Once EVERY link has lapsed, the root (checked first) is named.
assert_eq!(
chain
.verify(CONV, &signer.public_key_bytes(), 50_000)
.unwrap_err(),
MandateError::Expired {
kind: "intent",
expires_at_unix: 10_000
}
);
// Comfortably before every expiry: verifies.
assert!(chain.verify(CONV, &signer.public_key_bytes(), 1).is_ok());
}
#[test]
fn untrusted_issuer_is_rejected() {
let signer = issuer();
let chain = valid_chain(&signer);
let wrong_key = Signer::from_seed(1).public_key_bytes();
assert_eq!(
chain.verify(CONV, &wrong_key, 1_000).unwrap_err(),
MandateError::UntrustedSigner("intent")
);
}
#[test]
fn conversation_and_caller_bindings_are_enforced() {
let signer = issuer();
let chain = valid_chain(&signer);
// Presented in a different conversation than every link binds.
assert_eq!(
chain
.verify("conv-OTHER", &signer.public_key_bytes(), 1_000)
.unwrap_err(),
MandateError::ConversationMismatch("intent")
);
// A validly signed payment re-scoped to a different caller —
// chained correctly, amounts fine — must still refuse.
let (payment, _s, _p) = sign_payment_mandate(
&PaymentFields {
cart_hash: &mandate_hash(&chain.cart),
caller: "slack:T1:UATTACKER",
conversation_id: CONV,
args_json: ARGS,
currency: USD,
amount_base_units: "500000",
issued_at_unix: 100,
expires_at_unix: 8_000,
nonce: "n-payment",
},
&signer,
);
let cross_caller = MandateChain {
intent: chain.intent,
cart: chain.cart,
payment,
};
assert_eq!(
cross_caller
.verify(CONV, &signer.public_key_bytes(), 1_000)
.unwrap_err(),
MandateError::CallerMismatch
);
}
#[test]
fn currency_mismatch_is_rejected() {
let signer = issuer();
let chain = valid_chain(&signer);
let (payment, _s, _p) = sign_payment_mandate(
&PaymentFields {
cart_hash: &mandate_hash(&chain.cart),
caller: CALLER,
conversation_id: CONV,
args_json: ARGS,
currency: "0xOTHER",
amount_base_units: "500000",
issued_at_unix: 100,
expires_at_unix: 8_000,
nonce: "n-payment",
},
&signer,
);
let cross_currency = MandateChain {
intent: chain.intent,
cart: chain.cart,
payment,
};
assert_eq!(
cross_currency
.verify(CONV, &signer.public_key_bytes(), 1_000)
.unwrap_err(),
MandateError::CurrencyMismatch
);
}
#[test]
fn resolve_is_a_noop_when_unconfigured_or_absent() {
let signer = issuer();
let chain = valid_chain(&signer);
let key = signer.public_key_bytes();
// No chain presented ⇒ Ok(None) regardless of issuer config —
// today's HITL + caps path, unchanged.
assert!(matches!(resolve(None, Some(&key), CONV, 1_000), Ok(None)));
assert!(matches!(resolve(None, None, CONV, 1_000), Ok(None)));
// Chain presented but NO issuer key configured (the off-by-default
// feature gate): still Ok(None) — zero behavior change when
// unconfigured, even with mandate bytes on the wire.
assert!(matches!(resolve(Some(&chain), None, CONV, 1_000), Ok(None)));
// Both present and valid ⇒ engaged.
assert!(matches!(
resolve(Some(&chain), Some(&key), CONV, 1_000),
Ok(Some(_))
));
}
#[test]
fn resolve_fails_closed_on_an_invalid_presented_chain() {
// Presented + configured but expired ⇒ Err, never silently ignored.
let signer = issuer();
let chain = valid_chain(&signer);
let key = signer.public_key_bytes();
assert!(matches!(
resolve(Some(&chain), Some(&key), CONV, 50_000).unwrap_err(),
MandateError::Expired { .. }
));
}
/// Sign a HITL `approval_response` exactly as the control plane does
/// and verify it back into the [`VerifiedResponse`] the bridge takes.
fn hitl_approval(approved: bool, args_json: &str) -> VerifiedResponse {
let approval_signer = ApprovalSigner::from_seed(7);
let (payload, _sig, _pk) = response_payload(
"req-1",
"paid_fetch",
args_json,
"",
approved,
false,
&[],
CALLER,
"",
"workspace-write",
if approved { "looks fine" } else { "no" },
"",
CONV,
"approval-nonce-1",
"",
&approval_signer,
);
verify_signed_response(&payload).expect("approval signature verifies")
}
/// A signed intent + cart prefix for the HITL bridge tests.
fn chain_prefix(signer: &Signer) -> (Vec<u8>, Vec<u8>, String) {
let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), signer);
let intent_hash = mandate_hash(&intent);
let (cart, _s, _p) = sign_cart_mandate(
&CartFields {
intent_hash: &intent_hash,
caller: CALLER,
conversation_id: CONV,
merchant_host: HOST,
currency: USD,
amount_base_units: "500000",
issued_at_unix: 100,
expires_at_unix: 9_000,
nonce: "n-cart",
},
signer,
);
(intent, cart, intent_hash)
}
#[test]
fn hitl_approval_mints_a_payment_mandate_that_completes_the_chain() {
let signer = issuer();
let (intent, cart, _ih) = chain_prefix(&signer);
// The human approves the exact paid_fetch call via Slack/Telegram;
// the bridge formalizes that decision as a signed Payment mandate.
let approved = hitl_approval(true, ARGS);
let payment = payment_mandate_from_approval(
&approved,
"req-1",
"paid_fetch",
ARGS,
&cart,
200,
8_000,
"n-payment",
&signer,
)
.expect("the approval authorizes this exact call");
// The minted mandate COMPLETES a chain that verifies end-to-end and
// authorizes exactly the approved call at the cart's amount/host.
let chain = MandateChain {
intent,
cart,
payment,
};
let verified = chain
.verify(CONV, &signer.public_key_bytes(), 1_000)
.expect("the minted payment chains to the cart");
assert_eq!(verified.authorized_amount_base_units, 500_000);
assert_eq!(verified.caller, CALLER);
verified
.authorize(500_000, HOST, ARGS)
.expect("authorizes the approved call");
assert!(matches!(
verified.authorize(1, HOST, r#"{"url":"https://evil.example.com/x"}"#),
Err(MandateError::ArgsBindingMismatch)
));
}
#[test]
fn hitl_bridge_refuses_denials_and_mismatched_scopes() {
let signer = issuer();
let (_intent, cart, intent_hash) = chain_prefix(&signer);
// A denial mints nothing.
let denied = hitl_approval(false, ARGS);
assert_eq!(
payment_mandate_from_approval(
&denied,
"req-1",
"paid_fetch",
ARGS,
&cart,
200,
8_000,
"n",
&signer
)
.unwrap_err(),
MandateError::ApprovalDoesNotAuthorizeCall
);
// An approval for DIFFERENT args mints nothing for this call.
let approved = hitl_approval(true, ARGS);
assert_eq!(
payment_mandate_from_approval(
&approved,
"req-1",
"paid_fetch",
r#"{"url":"https://evil.example.com/x"}"#,
&cart,
200,
8_000,
"n",
&signer
)
.unwrap_err(),
MandateError::ApprovalDoesNotAuthorizeCall
);
// A cart scoped to a DIFFERENT conversation than the approval
// cannot be completed under it.
let (foreign_cart, _s, _p) = sign_cart_mandate(
&CartFields {
intent_hash: &intent_hash,
caller: CALLER,
conversation_id: "conv-OTHER",
merchant_host: HOST,
currency: USD,
amount_base_units: "500000",
issued_at_unix: 100,
expires_at_unix: 9_000,
nonce: "n-cart",
},
&signer,
);
assert_eq!(
payment_mandate_from_approval(
&approved,
"req-1",
"paid_fetch",
ARGS,
&foreign_cart,
200,
8_000,
"n",
&signer
)
.unwrap_err(),
MandateError::ConversationMismatch("cart")
);
// A cart scoped to a DIFFERENT caller than the approver likewise.
let (foreign_caller_cart, _s, _p) = sign_cart_mandate(
&CartFields {
intent_hash: &intent_hash,
caller: "slack:T1:USOMEONE",
conversation_id: CONV,
merchant_host: HOST,
currency: USD,
amount_base_units: "500000",
issued_at_unix: 100,
expires_at_unix: 9_000,
nonce: "n-cart",
},
&signer,
);
assert_eq!(
payment_mandate_from_approval(
&approved,
"req-1",
"paid_fetch",
ARGS,
&foreign_caller_cart,
200,
8_000,
"n",
&signer
)
.unwrap_err(),
MandateError::CallerMismatch
);
}
}
#[cfg(test)]
mod canonical_freeze {
//! Every signed canonical in this module, frozen as literal bytes (`#1845`).
//!
//! These are the bytes a deployment has already signed and has sitting in
//! its log. They are checked in, never regenerated: regenerating one is the
//! defect this module exists to catch, because a canonical whose bytes move
//! invalidates every signature ever minted over the old ones.
//!
//! The reason they can be single literals at all is the conversion `#1845`
//! made, following `#1842`. Before it, each canonical was a
//! [`serde_json::Value`], whose object is a `BTreeMap` (keys sorted) by
//! default and an `IndexMap` (insertion order) whenever anything in the
//! build graph enables `serde_json/preserve_order` — so the same payload
//! signed by two binaries with different dependency sets produced different
//! bytes and different signatures. Every literal below is the
//! insertion-order form, which is what a control-plane binary (where
//! `preserve_order` is unified in) has always signed. Run this module under
//! either selection and every literal holds:
//!
//! ```text
//! cargo nextest run -p polyc-crypto # no preserve_order
//! cargo nextest run -p polyc-crypto -p polyc-payments # preserve_order on
//! ```
//!
//! See ADR 0009 for the decision these literals enforce.
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
/// Assert a canonical's bytes are exactly the frozen literal.
fn frozen(label: &str, got: &[u8], want: &str) {
assert_eq!(
String::from_utf8(got.to_vec()).unwrap(),
want,
"{label}: canonical bytes moved — every signature over the old bytes is now unverifiable"
);
}
const ARGS_JSON: &str = r#"{"url":"https://shop.example/item","max":"25000"}"#;
fn intent() -> IntentFields<'static> {
IntentFields {
caller: "slack:T1:U9",
conversation_id: "conv-1",
scope_description: "groceries for the week",
currency: "0xToken",
max_total_base_units: "100000",
issued_at_unix: 1_750_000_000,
expires_at_unix: 1_750_086_400,
nonce: "nonce-intent",
}
}
fn cart() -> CartFields<'static> {
CartFields {
intent_hash: "abcd1234",
caller: "slack:T1:U9",
conversation_id: "conv-1",
merchant_host: "shop.example",
currency: "0xToken",
amount_base_units: "25000",
issued_at_unix: 1_750_000_000,
expires_at_unix: 1_750_086_400,
nonce: "nonce-cart",
}
}
fn payment() -> PaymentFields<'static> {
PaymentFields {
cart_hash: "beef5678",
caller: "slack:T1:U9",
conversation_id: "conv-1",
args_json: ARGS_JSON,
currency: "0xToken",
amount_base_units: "25000",
issued_at_unix: 1_750_000_000,
expires_at_unix: 1_750_086_400,
nonce: "nonce-payment",
}
}
#[test]
fn intent_mandate_is_frozen() {
frozen(
"IntentFields::canonical_json",
&canonical_bytes(&intent().canonical_json()),
INTENT_CANONICAL,
);
let (full, sig, _) = sign_intent_mandate(&intent(), &Signer::from_seed(99));
frozen("sign_intent_mandate", &full, INTENT_PAYLOAD);
assert_eq!(crate::hex::lower(&sig), INTENT_SIG);
}
#[test]
fn cart_mandate_is_frozen() {
frozen(
"CartFields::canonical_json",
&canonical_bytes(&cart().canonical_json()),
CART_CANONICAL,
);
let (full, sig, _) = sign_cart_mandate(&cart(), &Signer::from_seed(99));
frozen("sign_cart_mandate", &full, CART_PAYLOAD);
assert_eq!(crate::hex::lower(&sig), CART_SIG);
}
#[test]
fn payment_mandate_is_frozen() {
frozen(
"PaymentFields::canonical_json",
&canonical_bytes(&payment().canonical_json()),
PAYMENT_CANONICAL,
);
let (full, sig, _) = sign_payment_mandate(&payment(), &Signer::from_seed(99));
frozen("sign_payment_mandate", &full, PAYMENT_PAYLOAD);
assert_eq!(crate::hex::lower(&sig), PAYMENT_SIG);
}
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"}"#;
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"}"#;
const INTENT_SIG: &str = "85089b532b35c7ad7689c4f7f830959c08d7db71a9a3c45eb53db40289445494e125122bf45c4367aab60cb727c30fc5e4fd3fdc43abd6b0e7a0b2d3f2642703";
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"}"#;
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"}"#;
const CART_SIG: &str = "01c9ed068ef6f7f7724391fb2adffccab3db18e447e2514c7e07a233333534cef73ce2301204ca9f4beafb8907b992d9757e951a827765125a2639926afe2907";
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"}"#;
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"}"#;
const PAYMENT_SIG: &str = "95c1f52b6b4f3433a78884d7f65caafbaa8423a091632ad4b6e04b95966ca71afdf7819a5399c8f8dc1c0eb2c80b3b3eaa4fe59551eb9eb6a4492f36e9e07e06";
}