use std::sync::Arc;
use mpp::client::PaymentProvider;
use mpp::{MppError, PaymentChallenge, PaymentCredential};
use polyc_crypto::canon::canon_args;
use polyc_crypto::mandate::{self, MandateChain, MandateError, VerifiedMandateChain};
use polyc_egress::egress::{CapMode, pinned_http_client, pinned_http_client_with_timeout};
use polyc_egress::ssrf::{self, SsrfError, SsrfPolicy};
use polyc_payments_client::outbound::{
CappedProvider, OutboundRequest, PaymentError, paid_request_with_client,
};
use polyc_payments_client::resolver::{KeySource, PayerKind};
use polyc_spend_policy::budget::{
CommitError, ConversationSpend, ReserveRefused, Settlement, SharedReservation,
};
use polyc_spend_policy::presign::{CapExceeded, SpendCap};
use crate::config::PaymentsConfig;
struct PaymentObserver<'a, P> {
inner: &'a P,
reservation: SharedReservation,
}
impl<P> Clone for PaymentObserver<'_, P> {
fn clone(&self) -> Self {
Self {
inner: self.inner,
reservation: self.reservation.clone(),
}
}
}
impl<P: PaymentProvider> PaymentProvider for PaymentObserver<'_, P> {
fn supports(&self, method: &str, intent: &str) -> bool {
self.inner.supports(method, intent)
}
async fn pay(&self, challenge: &PaymentChallenge) -> Result<PaymentCredential, MppError> {
let credential = self.inner.pay(challenge).await?;
let charged = polyc_payments_client::outbound::challenge_amount_base_units(challenge)
.ok()
.map(|amount| u128::try_from(amount).unwrap_or(u128::MAX));
if charged.is_none() {
tracing::warn!(
"outbound payment settled but charged amount was not captured; \
recording the full reserved cap"
);
}
let resolved = charged.map_or_else(
|| self.reservation.commit_full(),
|amount| self.reservation.commit_actual(amount),
);
if let Err(e) = resolved {
return Err(MppError::verification_failed(match e {
CommitError::AlreadyResolved => {
"this request already paid once, and each approved request pays \
at most once, so the site\'s follow-up charge was not signed and \
no money moved for it"
}
CommitError::Unavailable => {
"this conversation\'s spending record could not be updated, so the \
payment was not completed and no money moved"
}
}));
}
Ok(credential)
}
}
#[async_trait::async_trait]
pub trait SettlementSink: Send + Sync {
async fn persist_settlement(&self, amount: u128);
}
struct ArmedNet {
sink: Arc<dyn SettlementSink>,
reservation: SharedReservation,
handle: tokio::runtime::Handle,
}
pub struct SettlementNet {
armed: Option<ArmedNet>,
}
impl SettlementNet {
fn arm(sink: Option<Arc<dyn SettlementSink>>, reservation: SharedReservation) -> Self {
Self {
armed: sink.map(|sink| ArmedNet {
sink,
reservation,
handle: tokio::runtime::Handle::current(),
}),
}
}
const fn inert() -> Self {
Self { armed: None }
}
pub fn disarm(&mut self) {
self.armed = None;
}
}
impl Drop for SettlementNet {
fn drop(&mut self) {
let Some(armed) = self.armed.take() else {
return;
};
let Some(Settlement::Charged(amount)) = armed.reservation.settlement() else {
return;
};
let sink = armed.sink;
drop(armed.handle.spawn(async move {
sink.persist_settlement(amount).await;
}));
}
}
pub(crate) const DEFAULT_CAP: &str = "0.10";
#[derive(Debug, Default, Clone)]
pub struct ProxyReceipt {
pub reference: String,
pub method: String,
pub timestamp: String,
}
impl From<&mpp::Receipt> for ProxyReceipt {
fn from(r: &mpp::Receipt) -> Self {
Self {
reference: r.reference.clone(),
method: r.method.as_str().to_string(),
timestamp: r.timestamp.clone(),
}
}
}
#[derive(Debug, Default, Clone)]
pub struct PaidAttribution {
pub amount_base_units: Option<u128>,
pub paying_account: Option<String>,
pub paid_amount: Option<String>,
pub payer_kind: Option<PayerKind>,
}
#[derive(Debug, thiserror::Error)]
pub enum RejectReason {
#[error("args do not match the approved tool call")]
ApprovalMismatch,
#[error("blocked destination: {0}")]
BlockedDestination(SsrfError),
#[error("outbound client build failed")]
ClientBuild,
#[error("destination host is not on the configured allowlist")]
HostNotAllowed,
#[error("destination host is not on this wallet's allowed hosts")]
PersonaHostNotAllowed,
#[error("over pre-authorized spend cap: {0}")]
OverSpendCap(CapExceeded),
#[error("mandate refused: {0}")]
MandateRefused(MandateError),
#[error("mandate refused: destination has no host")]
MandateHostUnknown,
#[error("over conversation budget: {0}")]
OverBudget(polyc_spend_policy::budget::BudgetExceeded),
#[error("this conversation already has as many payments in flight as it can hold")]
TooManyPaymentsInFlight,
#[error("this request is already holding budget for a payment that has not finished")]
PaymentAlreadyInFlight,
#[error("the conversation's spending record could not be reached")]
SpendUnreachable,
#[error("no settlement currency configured")]
MissingCurrency,
#[error("max_spend could not be parsed into base units")]
InvalidMaxSpend,
#[error("payment backend unavailable: {0}")]
BackendUnavailable(crate::config::PaymentsConfigError),
#[error("upstream fetch failed")]
FetchFailed,
#[error(transparent)]
Payment(PaymentError),
}
#[derive(Debug)]
pub enum ProxyOutcome {
Fetched {
status: u16,
body: String,
receipt_header: Option<String>,
receipt: Option<ProxyReceipt>,
paid: Option<PaidAttribution>,
},
Rejected {
reason: RejectReason,
paid: Option<PaidAttribution>,
},
}
impl ProxyOutcome {
const fn rejected(reason: RejectReason) -> Self {
Self::Rejected { reason, paid: None }
}
#[must_use]
pub const fn is_ok(&self) -> bool {
matches!(self, Self::Fetched { .. })
}
#[must_use]
pub const fn reason(&self) -> Option<&RejectReason> {
match self {
Self::Fetched { .. } => None,
Self::Rejected { reason, .. } => Some(reason),
}
}
#[must_use]
pub const fn paid(&self) -> Option<&PaidAttribution> {
match self {
Self::Fetched { paid, .. } | Self::Rejected { paid, .. } => paid.as_ref(),
}
}
#[must_use]
pub const fn status(&self) -> Option<u16> {
match self {
Self::Fetched { status, .. } => Some(*status),
Self::Rejected { .. } => None,
}
}
#[must_use]
pub fn body(&self) -> Option<&str> {
match self {
Self::Fetched { body, .. } => Some(body),
Self::Rejected { .. } => None,
}
}
#[must_use]
pub const fn receipt(&self) -> Option<&ProxyReceipt> {
match self {
Self::Fetched { receipt, .. } => receipt.as_ref(),
Self::Rejected { .. } => None,
}
}
#[must_use]
pub fn receipt_header(&self) -> Option<&str> {
match self {
Self::Fetched { receipt_header, .. } => receipt_header.as_deref(),
Self::Rejected { .. } => None,
}
}
const fn paid_mut(&mut self) -> Option<&mut PaidAttribution> {
match self {
Self::Fetched { paid, .. } | Self::Rejected { paid, .. } => paid.as_mut(),
}
}
}
#[must_use]
pub fn host_allowed(url: &str, allowlist: Option<&[String]>) -> bool {
let Some(list) = allowlist else {
return true;
};
let Ok(parsed) = reqwest::Url::parse(url) else {
return false;
};
parsed.host_str().is_some_and(|host| {
let host = host.to_ascii_lowercase();
list.iter().any(|allowed| allowed == &host)
})
}
pub struct Gates<'a> {
pub ssrf_policy: SsrfPolicy,
pub cap_base_units: u128,
pub spend_cap: &'a SpendCap,
pub mandate: Option<&'a VerifiedMandateChain>,
}
pub struct ProxyCall<'a> {
pub args_json: &'a str,
pub approved_args_json: &'a str,
pub url: &'a str,
pub method: reqwest::Method,
pub body: Option<&'a str>,
pub headers: &'a [(String, String)],
pub conversation_id: &'a str,
pub reservation: SharedReservation,
pub settlement_sink: Option<Arc<dyn SettlementSink>>,
pub gates: Gates<'a>,
}
pub async fn fulfill_with<P: PaymentProvider>(
mut call: ProxyCall<'_>,
provider: &P,
spend: &dyn ConversationSpend,
) -> (ProxyOutcome, SettlementNet) {
let net = SettlementNet::arm(call.settlement_sink.take(), call.reservation.clone());
let outcome = fulfill_call(call, provider, spend).await;
(outcome, net)
}
#[tracing::instrument(
name = "payments.fulfill",
level = "info",
skip_all,
fields(conversation_id = call.conversation_id)
)]
async fn fulfill_call<P: PaymentProvider>(
call: ProxyCall<'_>,
provider: &P,
spend: &dyn ConversationSpend,
) -> ProxyOutcome {
if canon_args(call.args_json) != canon_args(call.approved_args_json) {
return ProxyOutcome::rejected(RejectReason::ApprovalMismatch);
}
let resolver = match ssrf::pinned_resolver_async(call.url, call.gates.ssrf_policy).await {
Ok(r) => r,
Err(e) => return ProxyOutcome::rejected(RejectReason::BlockedDestination(e)),
};
let Ok(client) = pinned_http_client(resolver) else {
return ProxyOutcome::rejected(RejectReason::ClientBuild);
};
let Ok(floor) = spend.floor(call.conversation_id).await else {
return ProxyOutcome::rejected(RejectReason::SpendUnreachable);
};
if let Err(e) = call
.gates
.spend_cap
.authorize(call.gates.cap_base_units, floor)
{
return ProxyOutcome::rejected(RejectReason::OverSpendCap(e));
}
if let Some(chain) = call.gates.mandate {
let Some(host) = reqwest::Url::parse(call.url)
.ok()
.and_then(|u| u.host_str().map(str::to_owned))
else {
return ProxyOutcome::rejected(RejectReason::MandateHostUnknown);
};
if let Err(e) = chain.authorize(call.gates.cap_base_units, &host, call.approved_args_json) {
return ProxyOutcome::rejected(RejectReason::MandateRefused(e));
}
}
let reservation = call.reservation;
if let Err(refused) = spend
.reserve(call.conversation_id, reservation.id(), reservation.amount())
.await
{
return ProxyOutcome::rejected(match refused {
ReserveRefused::Ceiling(exceeded) => RejectReason::OverBudget(exceeded),
ReserveRefused::TooManyInFlight => RejectReason::TooManyPaymentsInFlight,
ReserveRefused::AlreadyHeld => RejectReason::PaymentAlreadyInFlight,
ReserveRefused::Unreachable(_) => RejectReason::SpendUnreachable,
});
}
let observed = PaymentObserver {
inner: provider,
reservation: reservation.clone(),
};
let request = OutboundRequest {
method: call.method,
url: call.url,
body: call.body,
headers: call.headers,
};
let result = paid_request_with_client(&observed, &request, &client).await;
reservation.release();
let paid = match reservation.settlement() {
Some(Settlement::Charged(amount)) => Some(PaidAttribution {
amount_base_units: Some(amount),
..PaidAttribution::default()
}),
Some(Settlement::Unspent) | None => None,
};
match result {
Ok(resp) => {
let receipt = resp.receipt.as_ref().map(ProxyReceipt::from);
ProxyOutcome::Fetched {
status: resp.status,
body: resp.body,
receipt_header: resp.receipt_header,
receipt,
paid,
}
}
Err(e) => {
tracing::warn!(
conversation_id = call.conversation_id,
error = %e,
"outbound payment attempt failed"
);
ProxyOutcome::Rejected {
reason: RejectReason::Payment(e),
paid,
}
}
}
}
const WEB_FETCH_BODY_CAP: usize = 100_000;
const WEB_FETCH_TIMEOUT_SECS: u64 = 20;
pub async fn fulfill_unpaid(
url: &str,
ssrf_policy: SsrfPolicy,
allowlist: Option<&[String]>,
) -> ProxyOutcome {
if !host_allowed(url, allowlist) {
return ProxyOutcome::rejected(RejectReason::HostNotAllowed);
}
let resolver = match ssrf::pinned_resolver_async(url, ssrf_policy).await {
Ok(r) => r,
Err(e) => return ProxyOutcome::rejected(RejectReason::BlockedDestination(e)),
};
let Ok(client) = pinned_http_client_with_timeout(
resolver,
std::time::Duration::from_secs(WEB_FETCH_TIMEOUT_SECS),
) else {
return ProxyOutcome::rejected(RejectReason::ClientBuild);
};
match client.get(url).send().await {
Ok(resp) => {
let status = resp.status().as_u16();
polyc_egress::egress::read_capped_text(resp, WEB_FETCH_BODY_CAP, CapMode::Truncate)
.await
.map_or_else(
|_| ProxyOutcome::rejected(RejectReason::FetchFailed),
|body| ProxyOutcome::Fetched {
status,
body,
receipt_header: None,
receipt: None,
paid: None,
},
)
}
Err(_) => ProxyOutcome::rejected(RejectReason::FetchFailed),
}
}
pub struct FulfillRequest<'a> {
pub args_json: &'a str,
pub approved_args_json: &'a str,
pub url: &'a str,
pub method: reqwest::Method,
pub body: Option<&'a str>,
pub headers: &'a [(String, String)],
pub max_spend: Option<&'a str>,
pub key_source: KeySource<'a>,
pub conversation_id: &'a str,
pub reservation_id: &'a str,
pub now_unix: u64,
pub mandate_chain: Option<&'a MandateChain>,
pub recorder: Option<polyc_payments_client::outbound::AttemptRecorder>,
pub settlement_sink: Option<Arc<dyn SettlementSink>>,
pub persona_per_payment_limit: Option<&'a str>,
pub persona_allowed_hosts: Option<&'a [String]>,
}
fn resolve_cap_base_units(
req_max_spend: Option<&str>,
cfg_max_spend: Option<&str>,
decimals: u32,
) -> Result<u128, RejectReason> {
let parse = |v: &str| {
crate::amount::dollars_to_base_units(v, decimals).ok_or(RejectReason::InvalidMaxSpend)
};
let req_cap = req_max_spend.map(parse).transpose()?;
let cfg_cap = cfg_max_spend.map(parse).transpose()?;
Ok(match (req_cap, cfg_cap) {
(Some(r), Some(c)) => r.min(c),
(Some(r), None) => r,
(None, Some(c)) => c,
(None, None) => {
crate::amount::dollars_to_base_units(DEFAULT_CAP, decimals).expect("default cap parses")
}
})
}
const fn inert_rejection(reason: RejectReason) -> (ProxyOutcome, SettlementNet) {
(ProxyOutcome::rejected(reason), SettlementNet::inert())
}
pub async fn fulfill(
req: FulfillRequest<'_>,
cfg: &PaymentsConfig,
spend: &dyn ConversationSpend,
) -> (ProxyOutcome, SettlementNet) {
if !host_allowed(req.url, cfg.paid_host_allowlist.as_deref()) {
return inert_rejection(RejectReason::HostNotAllowed);
}
let persona_hosts = req.persona_allowed_hosts.filter(|h| !h.is_empty());
if !host_allowed(req.url, persona_hosts) {
return inert_rejection(RejectReason::PersonaHostNotAllowed);
}
let cap_base_units = match resolve_cap_base_units(
req.max_spend,
cfg.max_spend.as_deref(),
cfg.currency_decimals,
) {
Ok(units) => units,
Err(reason) => return inert_rejection(reason),
};
let cap_base_units = req
.persona_per_payment_limit
.filter(|l| !l.is_empty())
.and_then(|l| crate::amount::dollars_to_base_units(l, cfg.currency_decimals))
.map_or(cap_base_units, |persona_cap| {
persona_cap.min(cap_base_units)
});
let spend_cap = cfg.presign_spend_cap();
let verified_mandate = match mandate::resolve(
req.mandate_chain,
cfg.mandate_issuer_public_key(),
req.conversation_id,
req.now_unix,
) {
Ok(v) => v,
Err(e) => return inert_rejection(RejectReason::MandateRefused(e)),
};
let Some(currency) = cfg.currency.as_deref() else {
return inert_rejection(RejectReason::MissingCurrency);
};
let payer_kind = req.key_source.payer_kind();
let client = match cfg.resolve_client(req.key_source, req.now_unix).await {
Ok(c) => c,
Err(e) => return inert_rejection(RejectReason::BackendUnavailable(e)),
};
let paying_account = client.payer_address().map(str::to_owned);
let recorder = req.recorder.unwrap_or_else(|| {
std::sync::Arc::new(|_audit: polyc_payments_client::outbound::AttemptAudit| {
Box::pin(async { Ok(()) }) as _
})
});
let self_settling = polyc_payments_client::outbound::ForceDirectSettle::new(
client.provider().clone(),
cfg.self_settle,
);
let signer_with_recorder =
polyc_payments_client::outbound::AttemptRecordingProvider::new(self_settling, recorder);
let capped =
match CappedProvider::from_base_units(signer_with_recorder, cap_base_units, currency) {
Ok(c) => c.with_expected_chain_id(cfg.chain_id),
Err(e) => return inert_rejection(RejectReason::Payment(e)),
};
let (mut outcome, net) = fulfill_with(
ProxyCall {
args_json: req.args_json,
approved_args_json: req.approved_args_json,
url: req.url,
method: req.method,
body: req.body,
headers: req.headers,
conversation_id: req.conversation_id,
reservation: SharedReservation::granted(req.reservation_id, cap_base_units),
settlement_sink: req.settlement_sink,
gates: Gates {
ssrf_policy: SsrfPolicy::default(),
cap_base_units,
spend_cap: &spend_cap,
mandate: verified_mandate.as_ref(),
},
},
&capped,
spend,
)
.await;
if let Some(paid) = outcome.paid_mut() {
paid.paying_account = paying_account;
paid.payer_kind = Some(payer_kind);
paid.paid_amount = paid
.amount_base_units
.map(|a| crate::amount::format_settled_amount(a, cfg.currency_decimals));
}
(outcome, net)
}
#[cfg(test)]
mod tests {
use super::*;
use mpp::client::PaymentProvider;
use mpp::{
Base64UrlJson, MppError, PaymentChallenge, PaymentCredential, PaymentPayload,
format_www_authenticate,
};
use wiremock::matchers::{header_exists, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
use polyc_spend_policy::budget::SpendUnreachable;
#[derive(Debug)]
struct Ceiling {
cap: u128,
held: Mutex<std::collections::HashMap<String, u128>>,
reachable: AtomicBool,
}
impl Ceiling {
fn new(cap: u128) -> Self {
Self {
cap,
held: Mutex::new(std::collections::HashMap::new()),
reachable: AtomicBool::new(true),
}
}
fn held(&self, conversation_id: &str) -> u128 {
*self.held.lock().unwrap().get(conversation_id).unwrap_or(&0)
}
fn go_dark(&self) {
self.reachable.store(false, AtomicOrdering::SeqCst);
}
}
#[async_trait::async_trait]
impl ConversationSpend for Ceiling {
async fn floor(&self, conversation_id: &str) -> Result<u128, SpendUnreachable> {
if !self.reachable.load(AtomicOrdering::SeqCst) {
return Err(SpendUnreachable);
}
Ok(self.held(conversation_id))
}
async fn reserve(
&self,
conversation_id: &str,
_reservation_id: &str,
amount: u128,
) -> Result<(), ReserveRefused> {
if !self.reachable.load(AtomicOrdering::SeqCst) {
return Err(ReserveRefused::Unreachable(SpendUnreachable));
}
let mut held = self.held.lock().unwrap();
let already = *held.get(conversation_id).unwrap_or(&0);
let next = already.saturating_add(amount);
let outcome = if next > self.cap {
Err(ReserveRefused::Ceiling(
polyc_spend_policy::budget::BudgetExceeded {
requested: amount,
already,
cap: self.cap,
},
))
} else {
held.insert(conversation_id.to_owned(), next);
Ok(())
};
drop(held);
outcome
}
}
#[test]
fn host_allowed_open_when_no_allowlist() {
assert!(host_allowed("https://api.example.com/x", None));
assert!(host_allowed("https://anything.io", None));
}
#[test]
fn host_allowed_member_passes_case_and_port_insensitive() {
let list = vec!["api.example.com".to_string(), "data.bar.io".to_string()];
assert!(host_allowed(
"https://api.example.com/path?q=1",
Some(&list)
));
assert!(host_allowed("https://API.Example.COM/x", Some(&list)));
assert!(host_allowed("https://data.bar.io:8443/y", Some(&list)));
}
#[test]
fn host_allowed_non_member_rejected() {
let list = vec!["api.example.com".to_string()];
assert!(!host_allowed("https://evil.example.com/x", Some(&list)));
assert!(!host_allowed("https://sub.api.example.com/x", Some(&list)));
}
#[test]
fn host_allowed_fails_closed_on_bad_url() {
let list = vec!["api.example.com".to_string()];
assert!(!host_allowed("not a url", Some(&list)));
assert!(!host_allowed("", Some(&list)));
}
#[test]
fn host_allowed_empty_list_denies_all() {
assert!(!host_allowed("https://api.example.com/x", Some(&[])));
}
#[test]
fn host_allowed_rejects_encoding_and_control_tricks() {
let list = ["api.example.com".to_owned()];
assert!(!host_allowed(
"https://api.example.com%00.evil.test/x",
Some(&list)
));
assert!(!host_allowed(
"https://api.example.com\u{0}.evil.test/x",
Some(&list)
));
assert!(!host_allowed(
"https://api%2eexample%2ecom.evil.test/x",
Some(&list)
));
assert!(!host_allowed(
"https://api.example.com.evil.test/x",
Some(&list)
));
assert!(!host_allowed("data:text/plain,hi", Some(&list)));
assert!(!host_allowed("file:///etc/passwd", Some(&list)));
}
#[test]
fn host_allowed_userinfo_cannot_spoof_host() {
let list = vec!["api.example.com".to_string()];
assert!(!host_allowed(
"https://api.example.com@evil.io/x",
Some(&list)
));
assert!(host_allowed("https://user@api.example.com/x", Some(&list)));
}
#[derive(Clone)]
struct NoopProvider;
impl PaymentProvider for NoopProvider {
fn supports(&self, _method: &str, _intent: &str) -> bool {
false
}
async fn pay(&self, _challenge: &PaymentChallenge) -> Result<PaymentCredential, MppError> {
Err(MppError::InvalidConfig("noop provider cannot pay".into()))
}
}
#[derive(Clone)]
struct PayingStub;
impl PaymentProvider for PayingStub {
fn supports(&self, _method: &str, _intent: &str) -> bool {
true
}
async fn pay(&self, challenge: &PaymentChallenge) -> Result<PaymentCredential, MppError> {
Ok(PaymentCredential::new(
challenge.to_echo(),
PaymentPayload::hash("0xstub"),
))
}
}
#[derive(Clone)]
struct HangingPayStub;
impl PaymentProvider for HangingPayStub {
fn supports(&self, _method: &str, _intent: &str) -> bool {
true
}
async fn pay(&self, _challenge: &PaymentChallenge) -> Result<PaymentCredential, MppError> {
std::future::pending().await
}
}
#[derive(Clone)]
struct FailingPayProvider;
impl PaymentProvider for FailingPayProvider {
fn supports(&self, _method: &str, _intent: &str) -> bool {
true
}
async fn pay(&self, _challenge: &PaymentChallenge) -> Result<PaymentCredential, MppError> {
Err(MppError::InvalidConfig(
"stubbed chain failure: key rejected".into(),
))
}
}
#[derive(Clone, Default)]
struct CountingPayStub(Arc<std::sync::atomic::AtomicUsize>);
impl CountingPayStub {
fn minted(&self) -> usize {
self.0.load(AtomicOrdering::SeqCst)
}
}
impl PaymentProvider for CountingPayStub {
fn supports(&self, _method: &str, _intent: &str) -> bool {
true
}
async fn pay(&self, challenge: &PaymentChallenge) -> Result<PaymentCredential, MppError> {
self.0.fetch_add(1, AtomicOrdering::SeqCst);
Ok(PaymentCredential::new(
challenge.to_echo(),
PaymentPayload::hash("0xstub"),
))
}
}
fn challenge_header_for(id: &str, amount_base_units: &str) -> String {
let request = Base64UrlJson::from_value(
&serde_json::json!({ "amount": amount_base_units, "currency": PATH_USD }),
)
.unwrap();
let challenge = PaymentChallenge::new(id, "polychrome", "tempo", "charge", request);
format_www_authenticate(&challenge).unwrap()
}
fn challenge_header() -> String {
let request = Base64UrlJson::from_value(
&serde_json::json!({ "amount": "10000", "currency": PATH_USD }),
)
.unwrap();
let challenge = PaymentChallenge::new("proxy-c1", "polychrome", "tempo", "charge", request);
format_www_authenticate(&challenge).unwrap()
}
async fn mount_402_then_200(server: &MockServer, with_receipt: bool) {
let mut ok = ResponseTemplate::new(200).set_body_string("paid-body");
if with_receipt {
let header = mpp::format_receipt(&mpp::Receipt::success("tempo", "0xabc123")).unwrap();
ok = ok.insert_header(mpp::PAYMENT_RECEIPT_HEADER, header.as_str());
}
Mock::given(method("GET"))
.and(path("/paid"))
.and(header_exists("authorization"))
.respond_with(ok)
.with_priority(1)
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/paid"))
.respond_with(
ResponseTemplate::new(402)
.insert_header("www-authenticate", challenge_header().as_str()),
)
.with_priority(5)
.mount(server)
.await;
}
fn args(url: &str) -> String {
format!(r#"{{"url":"{url}"}}"#)
}
async fn mount_402_then_200_post(server: &MockServer, expected_body: &str) {
use wiremock::matchers::{body_string, header};
let ok = ResponseTemplate::new(200).set_body_string("paid-body");
Mock::given(method("POST"))
.and(path("/paid"))
.and(body_string(expected_body))
.and(header("x-custom", "1"))
.and(header_exists("authorization"))
.respond_with(ok)
.with_priority(1)
.mount(server)
.await;
Mock::given(method("POST"))
.and(path("/paid"))
.and(body_string(expected_body))
.and(header("x-custom", "1"))
.respond_with(
ResponseTemplate::new(402)
.insert_header("www-authenticate", challenge_header().as_str()),
)
.with_priority(5)
.mount(server)
.await;
}
fn basic_call<'a>(
args_json: &'a str,
approved_args_json: &'a str,
url: &'a str,
conversation_id: &'a str,
cap_base_units: u128,
spend_cap: &'a SpendCap,
) -> ProxyCall<'a> {
ProxyCall {
args_json,
approved_args_json,
url,
method: reqwest::Method::GET,
body: None,
headers: &[],
conversation_id,
reservation: SharedReservation::granted("res-1", cap_base_units),
settlement_sink: None,
gates: Gates {
ssrf_policy: SsrfPolicy::permissive_for_tests(),
cap_base_units,
spend_cap,
mandate: None,
},
}
}
fn charged(out: &ProxyOutcome) -> Option<u128> {
out.paid().and_then(|p| p.amount_base_units)
}
#[tokio::test]
async fn rejects_when_args_do_not_match_approval() {
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let sandbox_args = args("https://example.com/a");
let approved_args = args("https://example.com/DIFFERENT");
let (out, _net) = fulfill_with(
basic_call(
&sandbox_args,
&approved_args,
"https://example.com/DIFFERENT",
"conv1",
100_000,
&cap,
),
&NoopProvider,
&spend,
)
.await;
assert!(!out.is_ok());
assert!(matches!(out.reason(), Some(RejectReason::ApprovalMismatch)));
assert_eq!(
spend.held("conv1"),
0,
"no reservation on approval mismatch"
);
}
#[tokio::test]
async fn rejects_when_the_method_does_not_match_approval() {
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let approved_args = r#"{"url":"https://example.com/a","method":"GET"}"#;
let sandbox_args = r#"{"url":"https://example.com/a","method":"POST"}"#;
let (out, _net) = fulfill_with(
basic_call(
sandbox_args,
approved_args,
"https://example.com/a",
"conv1",
100_000,
&cap,
),
&NoopProvider,
&spend,
)
.await;
assert!(!out.is_ok());
assert!(
matches!(out.reason(), Some(RejectReason::ApprovalMismatch)),
"a method the approver never saw must not execute"
);
assert_eq!(
spend.held("conv1"),
0,
"no reservation on approval mismatch"
);
}
#[tokio::test]
async fn accepts_a_method_the_approver_did_see() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let url = format!("{}/a", server.uri());
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let same = format!(r#"{{"url":"{url}","method":"GET"}}"#);
let (out, _net) = fulfill_with(
basic_call(&same, &same, &url, "conv-method-control", 100_000, &cap),
&NoopProvider,
&spend,
)
.await;
assert!(
out.is_ok(),
"matching args must clear the gate and reach the destination, got: {:?}",
out.reason()
);
}
#[tokio::test]
async fn accepts_args_with_reordered_keys() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let url = format!("{}/a", server.uri());
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let sandbox_args = format!(r#"{{"url":"{url}","max_spend":"$0.01"}}"#);
let approved_args = format!(r#"{{"max_spend":"$0.01","url":"{url}"}}"#);
let (out, _net) = fulfill_with(
basic_call(
&sandbox_args,
&approved_args,
&url,
"conv-reorder",
100_000,
&cap,
),
&NoopProvider,
&spend,
)
.await;
assert!(
!matches!(out.reason(), Some(RejectReason::ApprovalMismatch)),
"reordered-but-equal args must pass the approval binding, got: {:?}",
out.reason()
);
}
#[tokio::test]
async fn rejects_ssrf_destination() {
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let a = args("https://169.254.169.254/latest/meta-data/");
let mut call = basic_call(
&a,
&a,
"https://169.254.169.254/latest/meta-data/",
"conv1",
100_000,
&cap,
);
call.gates.ssrf_policy = SsrfPolicy::default();
let (out, _net) = fulfill_with(call, &NoopProvider, &spend).await;
assert!(!out.is_ok());
assert!(matches!(
out.reason(),
Some(RejectReason::BlockedDestination(_))
));
assert_eq!(spend.held("conv1"), 0, "no reservation when SSRF-blocked");
}
#[tokio::test]
async fn unpaid_fetches_without_payment() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_string("hello web"))
.mount(&server)
.await;
let url = format!("{}/page", server.uri());
let out = fulfill_unpaid(&url, SsrfPolicy::permissive_for_tests(), None).await;
assert!(
out.is_ok(),
"unpaid fetch should succeed: {:?}",
out.reason()
);
assert_eq!(out.status(), Some(200));
assert_eq!(out.body(), Some("hello web"));
assert!(out.paid().is_none(), "web_fetch never pays");
}
#[tokio::test]
async fn unpaid_does_not_follow_redirects() {
let server = MockServer::start().await;
let location = format!("{}/internal", server.uri());
Mock::given(method("GET"))
.and(path("/start"))
.respond_with(ResponseTemplate::new(302).insert_header("location", location.as_str()))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/internal"))
.respond_with(ResponseTemplate::new(200).set_body_string("FOLLOWED"))
.mount(&server)
.await;
let url = format!("{}/start", server.uri());
let out = fulfill_unpaid(&url, SsrfPolicy::permissive_for_tests(), None).await;
assert!(
out.is_ok(),
"fetch should return the redirect response itself: {:?}",
out.reason()
);
assert_eq!(out.status(), Some(302), "redirect must NOT be followed");
assert_ne!(out.body(), Some("FOLLOWED"));
}
#[tokio::test]
async fn unpaid_rejects_ssrf_destination() {
let out = fulfill_unpaid(
"https://169.254.169.254/latest/meta-data/",
SsrfPolicy::default(),
None,
)
.await;
assert!(!out.is_ok());
assert!(matches!(
out.reason(),
Some(RejectReason::BlockedDestination(_))
));
}
#[tokio::test]
async fn unpaid_rejects_off_allowlist_host() {
let out = fulfill_unpaid(
"https://evil.example.com/x",
SsrfPolicy::permissive_for_tests(),
Some(&["good.example.com".to_owned()]),
)
.await;
assert!(!out.is_ok());
assert!(matches!(out.reason(), Some(RejectReason::HostNotAllowed)));
}
#[tokio::test]
async fn rejects_over_budget() {
let spend = Ceiling::new(50_000); let cap = SpendCap::unlimited();
let a = args("http://127.0.0.1:9/x");
let (out, _net) = fulfill_with(
basic_call(
&a,
&a,
"http://127.0.0.1:9/x",
"conv1",
100_000, &cap,
),
&NoopProvider,
&spend,
)
.await;
assert!(!out.is_ok());
assert!(matches!(out.reason(), Some(RejectReason::OverBudget(_))));
assert_eq!(spend.held("conv1"), 0, "rejected reservation not counted");
}
#[tokio::test]
async fn free_fetch_releases_budget() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/data"))
.respond_with(ResponseTemplate::new(200).set_body_string("hello"))
.mount(&server)
.await;
let url = format!("{}/data", server.uri());
let a = args(&url);
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let call = basic_call(&a, &a, &url, "conv1", 100_000, &cap);
let held = call.reservation.clone();
let (out, _net) = fulfill_with(call, &NoopProvider, &spend).await;
assert!(out.is_ok(), "expected success, got {:?}", out.reason());
assert_eq!(out.status(), Some(200));
assert_eq!(out.body(), Some("hello"));
assert!(out.paid().is_none(), "a free fetch made no payment");
assert_eq!(
held.settlement(),
Some(Settlement::Unspent),
"a free fetch must resolve its reservation as unspent"
);
assert_eq!(
spend.held("conv1"),
100_000,
"the durable reservation is the caller's to return"
);
}
#[tokio::test]
async fn paid_fetch_commits_budget() {
let server = MockServer::start().await;
mount_402_then_200(&server, true).await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let (out, _net) = fulfill_with(
basic_call(&a, &a, &url, "conv1", 100_000, &cap),
&PayingStub,
&spend,
)
.await;
assert!(out.is_ok(), "expected success, got {:?}", out.reason());
assert!(out.receipt().is_some(), "a receipt must be parsed");
assert_eq!(
charged(&out),
Some(10_000),
"the charged base-unit amount is captured from the challenge"
);
assert_eq!(
spend.held("conv1"),
100_000,
"the reservation holds the ceiling until the caller commits the charge"
);
}
#[tokio::test]
async fn paid_fetch_post_preserves_body_and_headers_through_the_retry() {
let server = MockServer::start().await;
let body = r#"{"a":1}"#;
mount_402_then_200_post(&server, body).await;
let url = format!("{}/paid", server.uri());
let a = format!(r#"{{"url":"{url}","method":"POST","body":{body:?}}}"#);
let headers = vec![("x-custom".to_owned(), "1".to_owned())];
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let (out, _net) = fulfill_with(
ProxyCall {
args_json: &a,
approved_args_json: &a,
url: &url,
method: reqwest::Method::POST,
body: Some(body),
headers: &headers,
conversation_id: "conv1",
reservation: SharedReservation::granted("res-1", 100_000),
settlement_sink: None,
gates: Gates {
ssrf_policy: SsrfPolicy::permissive_for_tests(),
cap_base_units: 100_000,
spend_cap: &cap,
mandate: None,
},
},
&PayingStub,
&spend,
)
.await;
assert!(
out.is_ok(),
"expected success (proves the mock matched on BOTH the 402 probe \
and the post-pay retry), got {:?}",
out.reason()
);
assert_eq!(
charged(&out),
Some(10_000),
"the charged base-unit amount is captured from the challenge"
);
}
#[tokio::test]
async fn paid_fetch_without_receipt_still_commits_budget() {
let server = MockServer::start().await;
mount_402_then_200(&server, false).await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let (out, _net) = fulfill_with(
basic_call(&a, &a, &url, "conv1", 100_000, &cap),
&PayingStub,
&spend,
)
.await;
assert!(out.is_ok(), "expected success, got {:?}", out.reason());
assert!(out.receipt().is_none(), "no receipt header was returned");
assert!(
out.paid().is_some(),
"a payment was made (pay invoked) — must carry attribution for audit"
);
assert_eq!(
charged(&out),
Some(10_000),
"a payment with no receipt must still record the charged amount (no bypass)"
);
}
#[tokio::test]
async fn paid_then_retry_failure_still_commits_budget() {
let server = MockServer::start().await;
let oversize = vec![b'x'; 9 * 1024 * 1024]; Mock::given(method("GET"))
.and(path("/paid"))
.and(header_exists("authorization"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(oversize))
.with_priority(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/paid"))
.respond_with(
ResponseTemplate::new(402)
.insert_header("www-authenticate", challenge_header().as_str()),
)
.with_priority(5)
.mount(&server)
.await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let (out, _net) = fulfill_with(
basic_call(&a, &a, &url, "conv1", 100_000, &cap),
&PayingStub,
&spend,
)
.await;
assert!(!out.is_ok(), "the over-cap body read fails → error result");
assert!(matches!(
out.reason(),
Some(RejectReason::Payment(PaymentError::BodyTooLarge { .. }))
));
assert!(
out.paid().is_some(),
"a credential was created → must carry attribution for accounting"
);
assert_eq!(
charged(&out),
Some(10_000),
"settle-but-fail must NOT release the charge; it records the charged amount (no bypass)"
);
}
#[tracing_test::traced_test]
#[tokio::test]
async fn verification_failure_logs_the_real_reason() {
let server = MockServer::start().await;
mount_402_then_200(&server, false).await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let (out, _net) = fulfill_with(
basic_call(&a, &a, &url, "conv-verify", 100_000, &cap),
&FailingPayProvider,
&spend,
)
.await;
assert!(matches!(
out.reason(),
Some(RejectReason::Payment(PaymentError::Verification(_)))
));
assert!(logs_contain("outbound payment attempt failed"));
assert!(
logs_contain("conv-verify"),
"expected the conversation id for correlation"
);
assert!(
logs_contain("stubbed chain failure: key rejected"),
"expected the REAL underlying reason, not a discarded/generic one"
);
}
async fn mount_402_then_hang(server: &MockServer) {
Mock::given(method("GET"))
.and(path("/paid"))
.and(header_exists("authorization"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("late")
.set_delay(std::time::Duration::from_secs(30)),
)
.with_priority(1)
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/paid"))
.respond_with(
ResponseTemplate::new(402)
.insert_header("www-authenticate", challenge_header().as_str()),
)
.with_priority(5)
.mount(server)
.await;
}
#[tokio::test]
async fn budget_is_charged_at_settlement_not_at_return() {
let server = MockServer::start().await;
mount_402_then_hang(&server).await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let call = basic_call(&a, &a, &url, "conv1", 100_000, &cap);
let held = call.reservation.clone();
let mut fut = Box::pin(fulfill_with(call, &PayingStub, &spend));
let polled = tokio::time::timeout(std::time::Duration::from_millis(300), &mut fut).await;
assert!(
polled.is_err(),
"the hung retry must keep the future suspended past settlement"
);
assert_eq!(
held.settlement(),
Some(Settlement::Charged(10_000)),
"the charge is recorded while the call is still in flight"
);
drop(fut);
assert_eq!(
held.settlement(),
Some(Settlement::Charged(10_000)),
"cancelling after settlement cannot unrecord money already spent"
);
}
#[tokio::test]
async fn cancelled_during_settlement_leaves_no_phantom_charge() {
let server = MockServer::start().await;
mount_402_then_hang(&server).await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let call = basic_call(&a, &a, &url, "conv1", 100_000, &cap);
let held = call.reservation.clone();
let fut = fulfill_with(call, &HangingPayStub, &spend);
let out = tokio::time::timeout(std::time::Duration::from_millis(300), fut).await;
assert!(
out.is_err(),
"the hung `pay` must outlast the deadline so the future is dropped mid-settlement"
);
assert_eq!(
held.settlement(),
None,
"a drop BEFORE a credential exists must record no charge at all"
);
assert_eq!(
spend.held("conv1"),
100_000,
"the durable reservation must stay held for the reaper"
);
}
#[tokio::test]
async fn cancelled_after_settlement_commits_budget() {
let server = MockServer::start().await;
mount_402_then_hang(&server).await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let call = basic_call(&a, &a, &url, "conv1", 100_000, &cap);
let held = call.reservation.clone();
let fut = fulfill_with(call, &PayingStub, &spend);
let out = tokio::time::timeout(std::time::Duration::from_millis(300), fut).await;
assert!(
out.is_err(),
"the hung retry must outlast the deadline so the future is dropped mid-settlement"
);
assert_eq!(
held.settlement(),
Some(Settlement::Charged(10_000)),
"a deadline drop AFTER settlement must RECORD the charged spend, not release it"
);
}
#[derive(Clone, Default)]
struct RecordingSink(std::sync::Arc<std::sync::Mutex<Vec<u128>>>);
#[async_trait::async_trait]
impl SettlementSink for RecordingSink {
async fn persist_settlement(&self, amount: u128) {
self.0
.lock()
.expect("recording-sink mutex poisoned")
.push(amount);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelled_after_settlement_persists_durable_receipt() {
let server = MockServer::start().await;
mount_402_then_hang(&server).await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let spend = Ceiling::new(1_000_000);
let sink = RecordingSink::default();
let cap = SpendCap::unlimited();
let mut call = basic_call(&a, &a, &url, "conv1", 100_000, &cap);
call.settlement_sink = Some(std::sync::Arc::new(sink.clone()));
let fut = fulfill_with(call, &PayingStub, &spend);
let out = tokio::time::timeout(std::time::Duration::from_millis(300), fut).await;
assert!(
out.is_err(),
"the hung retry must outlast the deadline so the future is dropped mid-settlement"
);
let mut recorded = None;
for _ in 0..80 {
if let Some(&first) = sink.0.lock().unwrap().first() {
recorded = Some(first);
break;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
assert_eq!(
recorded,
Some(10_000),
"a drop after settlement must record the charged spend durably (no lost charge)"
);
assert_eq!(spend.held("conv1"), 100_000);
}
async fn mount_402_then_402_then_200(server: &MockServer) {
Mock::given(method("GET"))
.and(path("/paid"))
.and(header_exists("authorization"))
.respond_with(ResponseTemplate::new(402).insert_header(
"www-authenticate",
challenge_header_for("multi-b", "20000").as_str(),
))
.up_to_n_times(1)
.with_priority(1)
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/paid"))
.and(header_exists("authorization"))
.respond_with(ResponseTemplate::new(200).set_body_string("paid-body"))
.with_priority(2)
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/paid"))
.respond_with(ResponseTemplate::new(402).insert_header(
"www-authenticate",
challenge_header_for("multi-a", "10000").as_str(),
))
.with_priority(5)
.mount(server)
.await;
}
#[tokio::test]
async fn refuses_a_second_payment_in_one_call() {
let server = MockServer::start().await;
mount_402_then_402_then_200(&server).await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let provider = CountingPayStub::default();
let (out, _net) = fulfill_with(
basic_call(&a, &a, &url, "conv1", 100_000, &cap),
&provider,
&spend,
)
.await;
assert!(
!out.is_ok(),
"the refused second payment must fail the call, not quietly succeed"
);
assert!(
matches!(
out.reason(),
Some(RejectReason::Payment(PaymentError::Verification(_)))
),
"expected the refused commit to surface as a verification failure, got {:?}",
out.reason()
);
assert_eq!(
charged(&out),
Some(10_000),
"the attribution reports the one payment that was actually made"
);
assert_eq!(
provider.minted(),
2,
"the provider is asked to sign both challenges"
);
let requests = server
.received_requests()
.await
.expect("the mock server records requests");
assert_eq!(
requests.len(),
2,
"only the unauthed probe and challenge A's retry reach the merchant; \
a third request would mean credential B was sent"
);
assert_eq!(
requests
.iter()
.filter(|r| r.headers.contains_key("authorization"))
.count(),
1,
"exactly one credential is handed to the merchant"
);
}
#[tokio::test]
async fn payment_failure_releases_budget() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/paid"))
.respond_with(
ResponseTemplate::new(402).insert_header("www-authenticate", "Payment realm=\"x\""),
)
.mount(&server)
.await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let (out, _net) = fulfill_with(
basic_call(&a, &a, &url, "conv1", 100_000, &cap),
&NoopProvider,
&spend,
)
.await;
assert!(!out.is_ok());
assert!(matches!(out.reason(), Some(RejectReason::Payment(_))));
assert!(
out.paid().is_none(),
"a failed payment must record no charge"
);
}
#[tokio::test]
async fn an_unreachable_spend_authority_refuses_rather_than_reporting_zero() {
let server = MockServer::start().await;
mount_402_then_200(&server, true).await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let spend = Ceiling::new(1_000_000);
spend.go_dark();
let cap = SpendCap::unlimited();
let call = basic_call(&a, &a, &url, "conv1", 100_000, &cap);
let held = call.reservation.clone();
let (out, _net) = fulfill_with(call, &PayingStub, &spend).await;
assert!(
matches!(out.reason(), Some(RejectReason::SpendUnreachable)),
"an unreachable authority must refuse, got {:?}",
out.reason()
);
assert!(out.paid().is_none(), "the refused call paid nothing");
assert_eq!(
held.settlement(),
None,
"a call refused before signing resolves nothing"
);
}
#[tokio::test]
async fn an_over_ceiling_refusal_is_not_worded_as_an_unreachable_one() {
let server = MockServer::start().await;
mount_402_then_200(&server, true).await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let spend = Ceiling::new(100);
let cap = SpendCap::unlimited();
let (out, _net) = fulfill_with(
basic_call(&a, &a, &url, "conv1", 100_000, &cap),
&PayingStub,
&spend,
)
.await;
assert!(
matches!(out.reason(), Some(RejectReason::OverBudget(_))),
"a conversation at its ceiling must not read as an unreachable authority, got {:?}",
out.reason()
);
}
const PATH_USD: &str = "0x20c0000000000000000000000000000000000000";
async fn mount_402_with_request(server: &MockServer, request_json: serde_json::Value) {
let request = Base64UrlJson::from_value(&request_json).unwrap();
let challenge =
PaymentChallenge::new("proxy-mal", "polychrome", "tempo", "charge", request);
let header = format_www_authenticate(&challenge).unwrap();
Mock::given(method("GET"))
.and(path("/paid"))
.respond_with(
ResponseTemplate::new(402).insert_header("www-authenticate", header.as_str()),
)
.mount(server)
.await;
}
#[tokio::test]
async fn over_cap_challenge_does_not_consume_budget() {
let server = MockServer::start().await;
mount_402_with_request(
&server,
serde_json::json!({ "amount": "200000", "currency": PATH_USD }),
)
.await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let capped = CappedProvider::new(PayingStub, "0.10", PATH_USD, 6).expect("cap parses");
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let (out, _net) = fulfill_with(
basic_call(&a, &a, &url, "conv1", 100_000, &cap),
&capped,
&spend,
)
.await;
assert!(!out.is_ok(), "an over-cap challenge must be rejected");
assert!(
out.paid().is_none(),
"no credential was created → must NOT flag a payment"
);
assert!(
out.paid().is_none(),
"a rejected over-cap challenge must record no charge (no false spend)"
);
}
#[tokio::test]
async fn wrong_chain_challenge_does_not_consume_budget() {
let server = MockServer::start().await;
mount_402_with_request(
&server,
serde_json::json!({
"amount": "10000", "currency": PATH_USD,
"methodDetails": { "chainId": 1 } }),
)
.await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let capped = CappedProvider::new(PayingStub, "1.00", PATH_USD, 6)
.expect("cap parses")
.with_expected_chain_id(42431); let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let (out, _net) = fulfill_with(
basic_call(&a, &a, &url, "conv1", 100_000, &cap),
&capped,
&spend,
)
.await;
assert!(!out.is_ok(), "a wrong-chain challenge must be rejected");
assert!(
out.paid().is_none(),
"no credential was created → must NOT flag a payment"
);
assert!(
out.paid().is_none(),
"a rejected wrong-chain challenge must record no charge (no false spend)"
);
}
#[tokio::test]
async fn rejects_over_presign_per_tx_cap_before_signing() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_string("unreached"))
.mount(&server)
.await;
let url = format!("{}/x", server.uri());
let a = args(&url);
let spend = Ceiling::new(10_000_000);
let cap = SpendCap::new(Some(50_000), None); let (out, _net) = fulfill_with(
basic_call(
&a, &a, &url, "conv1",
100_000, &cap,
),
&PayingStub,
&spend,
)
.await;
assert!(!out.is_ok(), "an over-pre-auth request must be rejected");
assert!(
matches!(out.reason(), Some(RejectReason::OverSpendCap(_))),
"rejection must name the pre-authorization cap: {:?}",
out.reason()
);
assert!(
out.paid().is_none(),
"no payment was made (no credential minted)"
);
assert_eq!(
spend.held("conv1"),
0,
"a pre-auth rejection reserves no budget"
);
}
#[tokio::test]
async fn rejects_over_presign_per_session_cap_before_signing() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_string("unreached"))
.mount(&server)
.await;
let url = format!("{}/x", server.uri());
let a = args(&url);
let spend = Ceiling::new(10_000_000);
spend
.reserve("conv1", "earlier-call", 400_000)
.await
.expect("the earlier call fit");
let cap = SpendCap::new(None, Some(450_000)); let (out, _net) = fulfill_with(
basic_call(
&a, &a, &url, "conv1",
100_000, &cap,
),
&PayingStub,
&spend,
)
.await;
assert!(!out.is_ok(), "an over-session-cap request must be rejected");
assert!(matches!(out.reason(), Some(RejectReason::OverSpendCap(_))));
assert_eq!(
spend.held("conv1"),
400_000,
"the rejected call adds nothing; only the prior $0.40 stays committed"
);
}
#[tokio::test]
#[ignore = "requires a funded Moderato keychain keys.toml + TEMPO_TEST_PAID_URL"]
async fn live_moderato_keychain_payment() {
use crate::config::PaymentsConfig;
let cfg = PaymentsConfig::from_env()
.expect("payments env (TEMPO_WALLET_KEYS_PATH + TEMPO_CURRENCY + TEMPO_CHAIN_ID)");
let url = std::env::var("TEMPO_TEST_PAID_URL")
.expect("set TEMPO_TEST_PAID_URL to a Moderato 402 endpoint");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let spend = Ceiling::new(
crate::amount::dollars_to_base_units("10.00", cfg.currency_decimals).unwrap(),
);
let args = format!(r#"{{"url":"{url}","max_spend":"1.00"}}"#);
let (resp, _net) = fulfill(
FulfillRequest {
args_json: &args,
approved_args_json: &args,
url: &url,
method: reqwest::Method::GET,
body: None,
headers: &[],
max_spend: Some("1.00"),
key_source: KeySource::FileSecret,
conversation_id: "live-conv",
reservation_id: "res-1",
now_unix: now,
mandate_chain: None,
recorder: None,
settlement_sink: None,
persona_per_payment_limit: None,
persona_allowed_hosts: None,
},
&cfg,
&spend,
)
.await;
eprintln!(
"live keychain payment → status={:?} paid={:?} receipt={:?} reason={:?}",
resp.status(),
resp.paid(),
resp.receipt(),
resp.reason()
);
assert!(resp.is_ok(), "keychain payment failed: {:?}", resp.reason());
assert!(
resp.paid().is_some(),
"expected an onchain payment (pay invoked)"
);
}
fn verified_mandate_for(
host: &str,
amount: &str,
args_json: &str,
) -> polyc_crypto::mandate::VerifiedMandateChain {
use polyc_crypto::Signer;
use polyc_crypto::mandate::{
CartFields, IntentFields, PaymentFields, mandate_hash, sign_cart_mandate,
sign_intent_mandate, sign_payment_mandate,
};
let signer = Signer::from_seed(77);
let (intent, _s, _p) = sign_intent_mandate(
&IntentFields {
caller: "slack:T1:U9",
conversation_id: "conv1",
scope_description: "proxy seam test",
currency: PATH_USD,
max_total_base_units: "",
issued_at_unix: 0,
expires_at_unix: u64::MAX,
nonce: "n-intent",
},
&signer,
);
let (cart, _s, _p) = sign_cart_mandate(
&CartFields {
intent_hash: &mandate_hash(&intent),
caller: "slack:T1:U9",
conversation_id: "conv1",
merchant_host: host,
currency: PATH_USD,
amount_base_units: amount,
issued_at_unix: 0,
expires_at_unix: u64::MAX,
nonce: "n-cart",
},
&signer,
);
let (payment, _s, _p) = sign_payment_mandate(
&PaymentFields {
cart_hash: &mandate_hash(&cart),
caller: "slack:T1:U9",
conversation_id: "conv1",
args_json,
currency: PATH_USD,
amount_base_units: amount,
issued_at_unix: 0,
expires_at_unix: u64::MAX,
nonce: "n-payment",
},
&signer,
);
polyc_crypto::mandate::MandateChain {
intent,
cart,
payment,
}
.verify("conv1", &signer.public_key_bytes(), 1_000)
.expect("fixture chain verifies")
}
#[tokio::test]
async fn valid_mandate_authorizes_the_payment() {
let server = MockServer::start().await;
mount_402_then_200(&server, true).await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let host = reqwest::Url::parse(&server.uri())
.unwrap()
.host_str()
.unwrap()
.to_owned();
let mandate = verified_mandate_for(&host, "100000", &a);
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let mut call = basic_call(&a, &a, &url, "conv1", 100_000, &cap);
call.gates.mandate = Some(&mandate);
let (out, _net) = fulfill_with(call, &PayingStub, &spend).await;
assert!(out.is_ok(), "expected success, got {:?}", out.reason());
assert!(
out.paid().is_some(),
"the mandate-authorized payment settles"
);
assert_eq!(
charged(&out),
Some(10_000),
"the charged amount is what gets recorded"
);
}
#[tokio::test]
async fn mandate_below_requested_spend_refuses_before_signing() {
let server = MockServer::start().await;
mount_402_then_200(&server, true).await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let host = reqwest::Url::parse(&server.uri())
.unwrap()
.host_str()
.unwrap()
.to_owned();
let mandate = verified_mandate_for(&host, "50000", &a); let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let mut call = basic_call(&a, &a, &url, "conv1", 100_000, &cap);
call.gates.mandate = Some(&mandate);
let (out, _net) = fulfill_with(call, &PayingStub, &spend).await;
assert!(!out.is_ok(), "an under-authorized mandate must refuse");
assert!(matches!(
out.reason(),
Some(RejectReason::MandateRefused(_))
));
assert!(out.paid().is_none(), "no credential was minted");
assert_eq!(spend.held("conv1"), 0, "no reservation was made");
}
#[tokio::test]
async fn mandate_for_a_different_host_refuses_before_signing() {
let server = MockServer::start().await;
mount_402_then_200(&server, true).await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let mandate = verified_mandate_for("api.other-merchant.com", "100000", &a);
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let mut call = basic_call(&a, &a, &url, "conv1", 100_000, &cap);
call.gates.mandate = Some(&mandate);
let (out, _net) = fulfill_with(call, &PayingStub, &spend).await;
assert!(!out.is_ok(), "a wrong-merchant mandate must refuse");
assert!(matches!(
out.reason(),
Some(RejectReason::MandateRefused(_))
));
assert_eq!(spend.held("conv1"), 0);
}
#[tokio::test]
async fn mandate_bound_to_different_args_refuses_before_signing() {
let server = MockServer::start().await;
mount_402_then_200(&server, true).await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let host = reqwest::Url::parse(&server.uri())
.unwrap()
.host_str()
.unwrap()
.to_owned();
let mandate = verified_mandate_for(
&host,
"100000",
r#"{"url":"https://api.example.com/other"}"#,
);
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let mut call = basic_call(&a, &a, &url, "conv1", 100_000, &cap);
call.gates.mandate = Some(&mandate);
let (out, _net) = fulfill_with(call, &PayingStub, &spend).await;
assert!(!out.is_ok(), "an args-mismatched mandate must refuse");
assert!(matches!(
out.reason(),
Some(RejectReason::MandateRefused(_))
));
assert_eq!(spend.held("conv1"), 0);
}
#[tokio::test]
async fn fulfill_ignores_presented_chain_when_mandates_unconfigured() {
let cfg = PaymentsConfig::from_lookup(|k| match k {
"TEMPO_CURRENCY" => Some(PATH_USD.to_owned()),
_ => None,
})
.expect("config builds");
assert!(cfg.mandate_issuer_public_key().is_none(), "off by default");
let garbage = MandateChain {
intent: b"not a mandate".to_vec(),
cart: b"not a mandate".to_vec(),
payment: b"not a mandate".to_vec(),
};
let spend = Ceiling::new(1_000_000);
let a = args("https://api.example.com/x");
let (out, _net) = fulfill(
FulfillRequest {
args_json: &a,
approved_args_json: &a,
url: "https://api.example.com/x",
method: reqwest::Method::GET,
body: None,
headers: &[],
max_spend: None,
key_source: KeySource::FileSecret,
conversation_id: "conv1",
reservation_id: "res-1",
now_unix: 1_000,
mandate_chain: Some(&garbage),
recorder: None,
settlement_sink: None,
persona_per_payment_limit: None,
persona_allowed_hosts: None,
},
&cfg,
&spend,
)
.await;
assert!(
matches!(out.reason(), Some(RejectReason::BackendUnavailable(_))),
"must fail at signer resolution (past the mandate seam), got: {:?}",
out.reason()
);
}
#[tokio::test]
async fn fulfill_refuses_invalid_chain_when_mandates_configured() {
let issuer_hex = hex::encode(polyc_crypto::Signer::from_seed(77).public_key_bytes());
let cfg = PaymentsConfig::from_lookup(|k| match k {
"TEMPO_CURRENCY" => Some(PATH_USD.to_owned()),
"TEMPO_MANDATE_ISSUER_PUBKEY" => Some(issuer_hex.clone()),
_ => None,
})
.expect("config builds");
assert!(cfg.mandate_issuer_public_key().is_some());
let garbage = MandateChain {
intent: b"not a mandate".to_vec(),
cart: b"not a mandate".to_vec(),
payment: b"not a mandate".to_vec(),
};
let spend = Ceiling::new(1_000_000);
let a = args("https://api.example.com/x");
let (out, _net) = fulfill(
FulfillRequest {
args_json: &a,
approved_args_json: &a,
url: "https://api.example.com/x",
method: reqwest::Method::GET,
body: None,
headers: &[],
max_spend: None,
key_source: KeySource::FileSecret,
conversation_id: "conv1",
reservation_id: "res-1",
now_unix: 1_000,
mandate_chain: Some(&garbage),
recorder: None,
settlement_sink: None,
persona_per_payment_limit: None,
persona_allowed_hosts: None,
},
&cfg,
&spend,
)
.await;
assert!(
matches!(out.reason(), Some(RejectReason::MandateRefused(_))),
"the refusal must name the mandate gate, got: {:?}",
out.reason()
);
assert_eq!(spend.held("conv1"), 0);
}
#[tokio::test]
async fn paid_fetch_refuses_a_host_off_the_persona_allowlist() {
let cfg = PaymentsConfig::from_lookup(|k| match k {
"TEMPO_CURRENCY" => Some(PATH_USD.to_owned()),
_ => None,
})
.expect("config builds");
assert!(cfg.paid_host_allowlist.is_none(), "no deployment allowlist");
let spend = Ceiling::new(1_000_000);
let a = args("https://evil.example.com/x");
let persona_hosts = vec!["api.example.com".to_owned()];
let (out, _net) = fulfill(
FulfillRequest {
args_json: &a,
approved_args_json: &a,
url: "https://evil.example.com/x",
method: reqwest::Method::GET,
body: None,
headers: &[],
max_spend: None,
key_source: KeySource::FileSecret,
conversation_id: "conv1",
reservation_id: "res-1",
now_unix: 1_000,
mandate_chain: None,
recorder: None,
settlement_sink: None,
persona_per_payment_limit: None,
persona_allowed_hosts: Some(&persona_hosts),
},
&cfg,
&spend,
)
.await;
assert!(
matches!(out.reason(), Some(RejectReason::PersonaHostNotAllowed)),
"must refuse via the persona-allowlist gate, got: {:?}",
out.reason()
);
assert_eq!(spend.held("conv1"), 0);
}
#[tokio::test]
async fn persona_allowlist_narrows_not_replaces_the_deployment_allowlist() {
let cfg = PaymentsConfig::from_lookup(|k| match k {
"TEMPO_CURRENCY" => Some(PATH_USD.to_owned()),
"TEMPO_PAID_HOST_ALLOWLIST" => Some("api.example.com,other.example.com".to_owned()),
_ => None,
})
.expect("config builds");
assert_eq!(
cfg.paid_host_allowlist.as_deref(),
Some(&["api.example.com".to_owned(), "other.example.com".to_owned()][..])
);
let spend = Ceiling::new(1_000_000);
let a = args("https://api.example.com/x");
let persona_hosts = vec!["only-this-host.example.com".to_owned()];
let (out, _net) = fulfill(
FulfillRequest {
args_json: &a,
approved_args_json: &a,
url: "https://api.example.com/x",
method: reqwest::Method::GET,
body: None,
headers: &[],
max_spend: None,
key_source: KeySource::FileSecret,
conversation_id: "conv1",
reservation_id: "res-1",
now_unix: 1_000,
mandate_chain: None,
recorder: None,
settlement_sink: None,
persona_per_payment_limit: None,
persona_allowed_hosts: Some(&persona_hosts),
},
&cfg,
&spend,
)
.await;
assert!(
matches!(out.reason(), Some(RejectReason::PersonaHostNotAllowed)),
"a deployment-allowed host must still be refused when it's off the \
caller's OWN persona allowlist, got: {:?}",
out.reason()
);
assert_eq!(spend.held("conv1"), 0);
let (out_no_persona_list, _net) = fulfill(
FulfillRequest {
args_json: &a,
approved_args_json: &a,
url: "https://api.example.com/x",
method: reqwest::Method::GET,
body: None,
headers: &[],
max_spend: None,
key_source: KeySource::FileSecret,
conversation_id: "conv1",
reservation_id: "res-1",
now_unix: 1_000,
mandate_chain: None,
recorder: None,
settlement_sink: None,
persona_per_payment_limit: None,
persona_allowed_hosts: None,
},
&cfg,
&spend,
)
.await;
assert!(
!matches!(
out_no_persona_list.reason(),
Some(RejectReason::PersonaHostNotAllowed)
),
"no persona allowlist must not itself refuse a deployment-allowed host, got: {:?}",
out_no_persona_list.reason()
);
}
#[tokio::test]
async fn persona_per_payment_limit_caps_the_payment_below_the_deployment_cap() {
let server = MockServer::start().await;
mount_402_with_request(
&server,
serde_json::json!({ "amount": "150000", "currency": PATH_USD }),
)
.await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let deployment_cap_base_units = 1_000_000u128; let persona_cap_base_units = 100_000u128; let narrowed = persona_cap_base_units.min(deployment_cap_base_units);
assert_eq!(narrowed, persona_cap_base_units, "the persona cap must win");
let capped =
CappedProvider::from_base_units(PayingStub, narrowed, PATH_USD).expect("cap parses");
let spend = Ceiling::new(1_000_000);
let cap = SpendCap::unlimited();
let (out, _net) = fulfill_with(
basic_call(&a, &a, &url, "conv1", narrowed, &cap),
&capped,
&spend,
)
.await;
assert!(
!out.is_ok(),
"a challenge above the persona-narrowed cap must be refused"
);
assert!(
out.paid().is_none(),
"no credential was created → must NOT flag a payment"
);
assert!(
out.paid().is_none(),
"a rejected over-persona-cap challenge must record no charge — the SAME \
untouched-on-refusal guarantee the deployment cap already carries"
);
}
#[tokio::test]
async fn persona_per_payment_limit_raised_by_the_admin_permits_a_payment_the_old_value_refused()
{
let server = MockServer::start().await;
mount_402_then_200(&server, false).await;
let url = format!("{}/paid", server.uri());
let a = args(&url);
let deployment_cap_base_units = 1_000_000u128; let challenge_base_units = 10_000u128; let spend = Ceiling::new(10_000_000);
let cap = SpendCap::unlimited();
let original_limit = crate::amount::format_bare_amount(5_000, 6);
assert_eq!(original_limit, "0.005");
let original_cap = crate::amount::dollars_to_base_units(&original_limit, 6)
.expect("parses")
.min(deployment_cap_base_units);
assert!(
original_cap < challenge_base_units,
"sanity: the original limit is below the challenge"
);
let capped_original = CappedProvider::from_base_units(PayingStub, original_cap, PATH_USD)
.expect("cap parses");
let (out_original, _net) = fulfill_with(
basic_call(&a, &a, &url, "conv1", original_cap, &cap),
&capped_original,
&spend,
)
.await;
assert!(
!out_original.is_ok(),
"the $0.01 challenge exceeds the original $0.005 limit and must be refused"
);
let raised_limit = crate::amount::format_bare_amount(20_000, 6);
assert_eq!(raised_limit, "0.02");
let raised_cap = crate::amount::dollars_to_base_units(&raised_limit, 6)
.expect("the raised figure parses")
.min(deployment_cap_base_units);
assert!(
raised_cap > challenge_base_units,
"sanity: the raised cap is now above the $0.01 challenge"
);
let capped_raised =
CappedProvider::from_base_units(PayingStub, raised_cap, PATH_USD).expect("cap parses");
let (out_raised, _net) = fulfill_with(
basic_call(&a, &a, &url, "conv2", raised_cap, &cap),
&capped_raised,
&spend,
)
.await;
assert!(
out_raised.is_ok(),
"the SAME $0.01 challenge must now be permitted under the admin's \
raised limit: {:?}",
out_raised.reason()
);
}
#[test]
fn resolve_cap_prefers_the_tighter_of_agent_and_operator_caps() {
let cap = resolve_cap_base_units(Some("1.00"), Some("0.10"), 6).expect("parses");
assert_eq!(
cap, 100_000,
"operator ceiling must win over a wider agent ask"
);
let cap = resolve_cap_base_units(Some("0.05"), Some("0.10"), 6).expect("parses");
assert_eq!(cap, 50_000, "a narrower agent ask must still win");
let cap = resolve_cap_base_units(Some("2.50"), None, 6).expect("parses");
assert_eq!(
cap, 2_500_000,
"no operator ceiling ⇒ agent ask alone applies"
);
let cap = resolve_cap_base_units(None, Some("0.10"), 6).expect("parses");
assert_eq!(
cap, 100_000,
"no agent ask ⇒ operator ceiling alone applies"
);
let cap = resolve_cap_base_units(None, None, 6).expect("parses");
assert_eq!(
cap,
crate::amount::dollars_to_base_units(DEFAULT_CAP, 6).unwrap(),
"neither side set ⇒ DEFAULT_CAP"
);
}
#[test]
fn resolve_cap_fails_closed_on_an_unparseable_value_rather_than_widening() {
let overflowing = "999999999999999999999999999999999999999999999999999999";
let err = resolve_cap_base_units(Some(overflowing), Some("0.01"), 6)
.expect_err("an overflowing agent max_spend must reject, not fall back");
assert!(matches!(err, RejectReason::InvalidMaxSpend));
let err = resolve_cap_base_units(Some("not-a-number"), None, 6)
.expect_err("garbage max_spend must reject");
assert!(matches!(err, RejectReason::InvalidMaxSpend));
let err = resolve_cap_base_units(Some("0.01"), Some("not-a-number"), 6)
.expect_err("an unparseable operator ceiling must reject");
assert!(matches!(err, RejectReason::InvalidMaxSpend));
}
#[tokio::test]
async fn fulfill_enforces_the_operator_ceiling_over_a_wider_agent_max_spend() {
let cfg = PaymentsConfig::from_lookup(|k| match k {
"TEMPO_CURRENCY" => Some(PATH_USD.to_owned()),
"TEMPO_MAX_SPEND" => Some("0.01".to_owned()),
_ => None,
})
.expect("config builds");
assert_eq!(cfg.max_spend.as_deref(), Some("0.01"));
let spend = Ceiling::new(1_000_000);
let a = args("https://api.example.com/x");
let (out, _net) = fulfill(
FulfillRequest {
args_json: &a,
approved_args_json: &a,
url: "https://api.example.com/x",
method: reqwest::Method::GET,
body: None,
headers: &[],
max_spend: Some("1.00"),
key_source: KeySource::FileSecret,
conversation_id: "conv1",
reservation_id: "res-1",
now_unix: 1_000,
mandate_chain: None,
recorder: None,
settlement_sink: None,
persona_per_payment_limit: None,
persona_allowed_hosts: None,
},
&cfg,
&spend,
)
.await;
assert!(
matches!(out.reason(), Some(RejectReason::BackendUnavailable(_))),
"must proceed past cap resolution to signer resolution, got: {:?}",
out.reason()
);
}
#[tokio::test]
async fn fulfill_rejects_rather_than_widens_on_an_unparseable_max_spend() {
let cfg = PaymentsConfig::from_lookup(|k| match k {
"TEMPO_CURRENCY" => Some(PATH_USD.to_owned()),
"TEMPO_MAX_SPEND" => Some("0.01".to_owned()),
_ => None,
})
.expect("config builds");
let spend = Ceiling::new(1_000_000);
let a = args("https://api.example.com/x");
let (out, _net) = fulfill(
FulfillRequest {
args_json: &a,
approved_args_json: &a,
url: "https://api.example.com/x",
method: reqwest::Method::GET,
body: None,
headers: &[],
max_spend: Some("not-a-number"),
key_source: KeySource::FileSecret,
conversation_id: "conv1",
reservation_id: "res-1",
now_unix: 1_000,
mandate_chain: None,
recorder: None,
settlement_sink: None,
persona_per_payment_limit: None,
persona_allowed_hosts: None,
},
&cfg,
&spend,
)
.await;
assert!(
matches!(out.reason(), Some(RejectReason::InvalidMaxSpend)),
"an unparseable max_spend must fail closed, got: {:?}",
out.reason()
);
assert_eq!(
spend.held("conv1"),
0,
"a rejected-before-reservation call must not touch the budget"
);
}
}