jcp-x402: the x402 agentic-payment wire, dual-ceiling gated + settlement coupling #24

Merged
dcharlot merged 3 commits from jcp/x402-bridge into main 2026-07-18 03:29:42 -04:00
6 changed files with 749 additions and 0 deletions

14
jcp/jcp-rs/Cargo.lock generated
View file

@ -737,6 +737,20 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "jcp-x402"
version = "0.1.0"
dependencies = [
"ed25519-dalek",
"jcp-bridge",
"jcp-grant",
"jcp-receipt",
"jcp-runtime",
"jcp-schema",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "libc"
version = "0.2.186"

View file

@ -15,6 +15,7 @@ members = [
"crates/jcp-gateway",
"crates/jcp-trace",
"crates/jcp-a2a",
"crates/jcp-x402",
"crates/jcp-policy",
"crates/jcp-prompt",
"crates/jcp-orchestrate",

View file

@ -74,6 +74,27 @@ pub fn a2a_task_frame(grant_id: ContentId, agent: &str, method: &str, payload: &
}
}
/// Content-address an x402 **merchant** — the `(network, payTo)` a payment grant
/// binds to. A grant is therefore scoped to an exact settlement recipient on an
/// exact network, an audience an attacker cannot spoof by name.
pub fn x402_resource(network: &str, pay_to: &str) -> ContentId {
ContentId::of_bytes(format!("x402:{network}:{pay_to}").as_bytes())
}
/// The JCP Frame for an outbound x402 payment — principal control. The action is
/// always `pay`; `payload` is the charge being paid for (its resource + amount),
/// hashed into the frame.
pub fn x402_pay_frame(grant_id: ContentId, network: &str, pay_to: &str, payload: &[u8]) -> Frame {
Frame {
kind: FrameKind::AgentTask,
trust: Trust::Control,
grant_id,
action: "pay".to_string(),
resource: x402_resource(network, pay_to),
payload_hash: ContentId::of_bytes(payload),
}
}
/// The result of gating one call: the verdict, the frame it decided, and the
/// sealed JCR-1 receipt. The host MUST forward to the endpoint only when
/// `decision.is_allow()`.
@ -135,6 +156,27 @@ impl Bridge {
Gated { decision, frame, receipt }
}
/// Gate an outbound x402 payment against a joule ceiling **and** a money
/// ceiling (SPEC §4.8) — the x402 path, where an HTTP-402 charge is
/// denominated in a stablecoin and JCP meters the paid compute in joules.
/// The money ceiling comes from the payment, the joule ceiling from the
/// operator, and no exchange rate is invented between them.
#[allow(clippy::too_many_arguments)]
pub fn call_x402_priced(
&mut self,
sg: &SignedGrant,
caller: Did,
network: &str,
pay_to: &str,
payload: &[u8],
cost: Energy,
price: &Price,
now: u64,
) -> Gated {
let frame = x402_pay_frame(sg.grant.id(), network, pay_to, payload);
self.gate_priced(frame, sg, caller, cost, price, now)
}
/// Gate an outbound A2A task against a joule ceiling **and** a money
/// ceiling — the AP2 path, where a payment mandate bounds the spend and the
/// operator bounds the energy.

View file

@ -0,0 +1,21 @@
[package]
name = "jcp-x402"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "x402 profile of JCP — ingest an x402 PaymentRequirements (its stablecoin charge becomes the grant's money ceiling, SPEC §4.8), pair it with an operator-set joule ceiling, and gate every payment against both — outside the model, sealed in a JCR-1 receipt. A charge past the money cap, in a foreign asset, or to the wrong merchant is denied before the agent pays, never converted. The open agentic-payment wire keeps its rail; JCP adds the capability and the dual ceiling. Ingest, not compete."
[dependencies]
jcp-schema = { workspace = true }
jcp-grant = { workspace = true }
jcp-runtime = { workspace = true }
jcp-receipt = { workspace = true }
jcp-bridge = { workspace = true }
ed25519-dalek = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[lints]
workspace = true

View file

@ -0,0 +1,94 @@
//! Ingest an x402 PaymentRequirements, mint a dual-ceiling JCP grant (a USDC
//! session cap + an operator joule cap), and gate live charges — every decision
//! sealed, no USDC→joule exchange rate ever invented.
//!
//! ```text
//! cargo run -p jcp-x402 --example pay
//! ```
use ed25519_dalek::SigningKey;
use jcp_schema::{Did, Funds, Verdict};
use jcp_x402::{ingest_requirements, Gated, X402Profile, X402Requirements};
use serde_json::json;
fn main() {
// 1. A server's `402` challenge: pay 1.00 USDC (6 decimals) on Base to a
// merchant for a premium inference endpoint.
let req = ingest_requirements(&json!({
"scheme": "exact",
"network": "base",
"maxAmountRequired": "1000000",
"resource": "/api/premium-inference",
"payTo": "0xMerchantAddress",
"asset": "USDC"
}))
.expect("valid x402 requirements");
println!(
"x402 charge: {} minor {} on {} → {} for {}",
req.max_amount, req.asset, req.network, req.pay_to, req.resource
);
// 2. Mint a dual-ceiling grant. The PAYMENT bounds the money (a 2.50 USDC
// session cap); the OPERATOR bounds the energy (a joule ceiling the
// payment has no authority over). Two ceilings, two sources of authority
// — which is exactly why they are never converted into one another.
let principal = SigningKey::from_bytes(&[1u8; 32]); // authorizes the spend
let agent = Did::from_verifying_key(&SigningKey::from_bytes(&[2u8; 32]).verifying_key());
let mut profile = X402Profile::new(
"base",
"0xMerchantAddress",
20_000_000, // joule ceiling (µJ) — operator-set
Funds::new("USDC", 2_500_000), // money ceiling — 2.50 USDC session cap
&principal,
agent,
SigningKey::from_bytes(&[7u8; 32]),
"did:example:x402-steward",
)
.expect("mint grant");
let (net, merch) = profile.merchant();
println!("minted a dual-ceiling grant for {net}:{merch} — 2.50 USDC + 20 mJ\n");
// 3. Two 1.00 USDC charges fit the 2.50 cap → allowed; both dimensions debit.
for n in 1..=2 {
let g = profile.authorize(&req, 6_000);
println!("charge #{n} (1.00 USDC, 6000 µJ) → {}", verdict(&g));
}
// 4. A third would total 3.00 > 2.50 → denied on MONEY while joules still had
// room. The receipt records which ceiling bound — the statement a
// single-dimension authorization cannot make.
let g = profile.authorize(&req, 6_000);
println!("charge #3 (would total 3.00 USDC) → {}", verdict(&g));
// 5. A charge in a different asset is refused, not converted.
let eurc = X402Requirements { asset: "EURC".into(), max_amount: 100, ..req.clone() };
println!("charge in EURC (grant is USDC) → {}", verdict(&profile.authorize(&eurc, 1)));
// 6. Every decision sealed a JCR-1 receipt that verifies under the steward
// key — a third party trusts the decision, not the gateway.
let steward = profile.steward().to_string();
let pk = profile.steward_pubkey();
let verified = profile
.receipts()
.iter()
.filter(|r| jcp_receipt::extract(r, &steward, &pk).is_ok())
.count();
println!(
"\nsealed receipts: {} (all {} verify under the steward key)",
profile.receipts().len(),
verified
);
println!(
"spent: {} µJ of 20 mJ · {} USDC-minor of the 2.50 cap",
profile.spent_uj(),
profile.spent_minor()
);
}
/// Read the verdict the way a verifier would — off the sealed decision.
fn verdict(g: &Gated) -> String {
match g.decision.verdict {
Verdict::Allow => "ALLOW".into(),
Verdict::Deny(r) => format!("DENY ({})", r.label()),
}
}

View file

@ -0,0 +1,577 @@
//! The **x402 profile of JCP** — the open agentic-payment wire, made
//! capability-scoped, dual-ceiling-bounded, decided outside the model, and sealed.
//!
//! [x402](https://x402.org) is the open HTTP-native payment wire: a server
//! answers a request with `402 Payment Required` and a **PaymentRequirements**
//! (a stablecoin charge — `scheme`, `network`, `maxAmountRequired`, `payTo`,
//! `asset`), the agent retries with a signed `X-PAYMENT` header, and the server
//! settles on chain and returns the transaction. It standardizes *how an agent
//! pays inline* — but the authorization lives in the wire, denominated in a
//! stablecoin, with no bound on the compute the paid-for work consumes.
//!
//! JCP already has the enforcement primitive, and — since SPEC §4.8 — the second
//! ceiling this needs: a signed **Grant** carrying BOTH a joule budget and an
//! independent **money** budget ([`jcp_schema::Funds`]), each enforced on its own
//! terms with no exchange rate invented between them. That is exactly the shape
//! an x402 charge wants: the stablecoin amount is a money ceiling, the compute is
//! a joule ceiling, and the two must not be reconciled by a fabricated
//! USDC→joule rate. This crate closes the loop:
//!
//! - [`ingest_requirements`] parses an x402 PaymentRequirements;
//! [`X402Requirements::price`] reads its charge as a money [`Price`] in the
//! `asset` currency, and [`X402Requirements::funds_ceiling`] as a [`Funds`]
//! ceiling. No joule figure is derived from it — the operator supplies that,
//! because the payment has no authority over energy.
//! - [`X402Profile`] mints one dual-ceiling grant bound to a `(network, payTo)`
//! merchant audience and gates every charge against it through a
//! [`jcp_bridge::Bridge`]: a charge whose amount would carry the grant past its
//! money ceiling — or whose compute would carry it past its joule ceiling — is
//! denied **before** the agent pays, and the denial seals just like an allow,
//! recording which ceiling bound. A charge for a different merchant, or in a
//! different `asset` than the grant's ceiling, is denied rather than converted.
//!
//! - After the host settles on chain, [`X402Profile::seal_settlement`] seals a
//! receipt whose `payload_hash` commits to the **settled transaction hash and
//! the measured joules the paid compute consumed** — the coupling neither side
//! produces alone. The payment rail knows the transaction but not the energy;
//! an energy meter knows the joules but not the transaction. A verifier holding
//! the settlement record recomputes its content id and checks it against the
//! receipt, so the binding is cryptographic, not asserted.
//!
//! (That coupling requires a receipt that commits what it decided over. It does
//! since the `jcp/2` profile added `payload_hash` to the signed core, SPEC §8;
//! under `jcp/1` this crate deliberately stopped short of claiming it.)
//!
//! Ingest, not compete: the x402 rail keeps its transport and its on-chain edge;
//! JCP contributes the capability, the dual ceiling, and the sealed audit.
use ed25519_dalek::SigningKey;
use jcp_bridge::{x402_resource, Bridge};
use jcp_grant::issue;
use jcp_runtime::Price;
use jcp_schema::{Capability, ContentId, Did, Energy, Funds, Grant, SignedGrant};
use serde_json::{json, Value};
/// The result of gating one charge — the verdict, the frame, and the sealed
/// JCR-1 receipt. Re-exported so callers need not also depend on `jcp-bridge`.
pub use jcp_bridge::Gated;
/// Errors ingesting an x402 PaymentRequirements or minting a payment grant.
#[derive(Debug, thiserror::Error)]
pub enum X402Error {
/// The document was not the expected JSON shape.
#[error("malformed x402 {what}: {detail}")]
Malformed {
/// Which document.
what: &'static str,
/// What was wrong.
detail: String,
},
/// The payment grant could not be issued.
#[error("grant issue failed: {0}")]
Grant(#[from] jcp_grant::GrantError),
}
fn malformed(what: &'static str, detail: impl Into<String>) -> X402Error {
X402Error::Malformed { what, detail: detail.into() }
}
// ───────────────────────────── PaymentRequirements ─────────────────────────
/// An x402 **PaymentRequirements**: the `402` challenge a server returns,
/// naming a single stablecoin charge for a resource.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct X402Requirements {
/// Payment scheme (`"exact"` is the v1 default).
pub scheme: String,
/// Settlement network (`"base"`, `"base-sepolia"`, `"solana"`, …).
pub network: String,
/// The charge, in the asset's atomic minor units (x402 carries this as a
/// decimal *string* `maxAmountRequired`; parsed here to an integer — money
/// is typed, never a float).
pub max_amount: u64,
/// The resource being paid for (a URI or path).
pub resource: String,
/// The settlement recipient (the merchant address).
pub pay_to: String,
/// The token/asset the charge is denominated in (e.g. a USDC contract
/// address or the symbol `"USDC"`). This is the currency of the money
/// ceiling — a grant capped in one asset will not pay a charge in another.
pub asset: String,
}
impl X402Requirements {
/// The money **price** of this single charge, for a dual-ceiling decision.
pub fn price(&self) -> Price {
Price::new(&self.asset, self.max_amount)
}
/// This charge expressed as a money **ceiling** — the natural per-charge cap
/// when a grant authorizes exactly one PaymentRequirements. For a session
/// grant covering many charges, set a larger ceiling and let each
/// [`price`](Self::price) debit against it.
pub fn funds_ceiling(&self) -> Funds {
Funds::new(&self.asset, self.max_amount)
}
}
/// Parse an x402 PaymentRequirements from its JSON form. Reads `scheme`,
/// `network`, `maxAmountRequired`, `resource`, `payTo`, and `asset`; unknown
/// fields (`description`, `mimeType`, `maxTimeoutSeconds`, `extra`, …) are
/// tolerated. `maxAmountRequired` may arrive as a JSON string (the wire form) or
/// a JSON integer.
pub fn ingest_requirements(req: &Value) -> Result<X402Requirements, X402Error> {
// Some servers wrap the requirements in an `accepts: [ … ]` array; take the
// first entry when present, else the object itself.
let root = req
.get("accepts")
.and_then(|a| a.as_array())
.and_then(|a| a.first())
.unwrap_or(req);
let obj = root.as_object().ok_or_else(|| malformed("requirements", "not a JSON object"))?;
let s = |k: &str| obj.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let network = s("network");
let pay_to = obj
.get("payTo")
.or_else(|| obj.get("pay_to"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if network.is_empty() || pay_to.is_empty() {
return Err(malformed("requirements", "missing `network` or `payTo`"));
}
// maxAmountRequired: decimal string (the wire form) or integer minor units.
let raw = obj
.get("maxAmountRequired")
.or_else(|| obj.get("max_amount_required"))
.ok_or_else(|| malformed("requirements", "no `maxAmountRequired`"))?;
let max_amount = match raw {
Value::String(t) => t
.trim()
.parse::<u64>()
.map_err(|_| malformed("requirements", format!("`maxAmountRequired` not a uint: {t}")))?,
Value::Number(n) => n
.as_u64()
.ok_or_else(|| malformed("requirements", "`maxAmountRequired` not a non-negative integer"))?,
_ => return Err(malformed("requirements", "`maxAmountRequired` must be a string or integer")),
};
let asset = s("asset");
if asset.is_empty() {
return Err(malformed("requirements", "no `asset` — the money ceiling needs a currency"));
}
Ok(X402Requirements {
scheme: {
let sc = s("scheme");
if sc.is_empty() { "exact".into() } else { sc }
},
network,
max_amount,
resource: s("resource"),
pay_to,
asset,
})
}
// ───────────────────────────── settlement coupling ─────────────────────────
/// The outcome of settling an x402 charge on chain — mirrors the x402
/// `X-PAYMENT-RESPONSE` result. Sealed into a receipt by
/// [`X402Profile::seal_settlement`], where it becomes the transaction↔energy
/// coupling.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct X402Settlement {
/// The on-chain transaction hash, present when settlement succeeded.
pub transaction: Option<String>,
/// The network it settled on.
pub network: String,
/// The payer address recovered from the signature, if any.
pub payer: Option<String>,
}
impl X402Settlement {
/// The canonical coupling record: the settled transaction, the resource paid
/// for, and the **measured** joules the paid compute consumed.
///
/// A receipt sealed over this commits to its content id (`payload_hash`,
/// SPEC §8), so a verifier holding the record can recompute the hash and
/// confirm the receipt binds *this* transaction to *this* energy figure.
/// Use [`coupling_id`](Self::coupling_id) to compute that id independently.
pub fn coupling_record(&self, resource: &str, joules_uj: u64) -> Vec<u8> {
let record = json!({
"x402_settlement": {
"network": self.network,
"transaction": self.transaction,
"payer": self.payer,
"resource": resource,
"joules_uj": joules_uj,
}
});
serde_json::to_vec(&record).unwrap_or_default()
}
/// The content id a settlement receipt's `payload_hash` must equal — the
/// check a third party runs to verify the coupling.
pub fn coupling_id(&self, resource: &str, joules_uj: u64) -> ContentId {
ContentId::of_bytes(&self.coupling_record(resource, joules_uj))
}
}
// ───────────────────────────── the profile ─────────────────────────────
/// A dual-ceiling x402 payment profile: one grant scoped to a `(network, payTo)`
/// merchant, gating every charge against a money ceiling **and** a joule
/// ceiling, each sealed.
pub struct X402Profile {
bridge: Bridge,
network: String,
pay_to: String,
caller: Did,
grant: SignedGrant,
steward: String,
steward_pk: [u8; 32],
receipts: Vec<Vec<u8>>,
now: u64,
}
impl X402Profile {
/// Mint a dual-ceiling payment grant (SPEC §4.8) for a `(network, payTo)`
/// merchant: a **joule** ceiling the operator sets (`budget_uj`) and a
/// **money** ceiling denominated in the merchant's `asset` (`budget_funds`).
/// The money ceiling is a *session cap* — each charge's
/// [`X402Requirements::price`] debits against it. No exchange rate is
/// invented between the two ceilings.
#[allow(clippy::too_many_arguments)]
pub fn new(
network: impl Into<String>,
pay_to: impl Into<String>,
budget_uj: u64,
budget_funds: Funds,
issuer_sk: &SigningKey,
subject: Did,
steward_sk: SigningKey,
steward: impl Into<String>,
) -> Result<Self, X402Error> {
let network = network.into();
let pay_to = pay_to.into();
let issuer = Did::from_verifying_key(&issuer_sk.verifying_key());
let grant = Grant {
issuer,
subject,
capability: Capability::new(x402_resource(&network, &pay_to), ["pay"]),
budget_uj,
budget_funds: Some(budget_funds),
expiry: 0,
parent: None,
delegate_depth: None,
nonce: [0; 16],
};
let grant = issue(issuer_sk, grant)?;
let steward = steward.into();
let steward_pk = steward_sk.verifying_key().to_bytes();
Ok(Self {
bridge: Bridge::new(steward_sk, steward.clone()),
network,
pay_to,
caller: subject,
grant,
steward,
steward_pk,
receipts: Vec::new(),
now: 0,
})
}
/// Mint a profile whose money ceiling is a **single charge** — the natural
/// form when a grant authorizes exactly one x402 PaymentRequirements. The
/// operator still supplies the joule ceiling; the money ceiling and the
/// merchant audience come from the requirements.
pub fn for_charge(
req: &X402Requirements,
budget_uj: u64,
issuer_sk: &SigningKey,
subject: Did,
steward_sk: SigningKey,
steward: impl Into<String>,
) -> Result<Self, X402Error> {
Self::new(
req.network.clone(),
req.pay_to.clone(),
budget_uj,
req.funds_ceiling(),
issuer_sk,
subject,
steward_sk,
steward,
)
}
/// **Authorize** a charge against both ceilings, *before* the agent pays.
/// `cost_uj` is what the paid-for compute is expected to consume; the money
/// price is the charge's `maxAmountRequired` in its `asset`. Denies — and
/// seals the denial — if the charge is for a different merchant/network than
/// this grant, if the amount would exceed the money ceiling, if the asset
/// differs from the grant's, or if the compute would exceed the joule
/// ceiling. The host settles on chain **only when `allowed()`**.
pub fn authorize(&mut self, req: &X402Requirements, cost_uj: u64) -> Gated {
// A charge for a different merchant than this grant's audience still
// decides (and seals) against the held grant: its frame's audience
// cannot match, so the runtime denies `wrong-resource` — recorded.
let payload = json!({
"x402_charge": { "resource": req.resource, "asset": req.asset, "amount": req.max_amount }
});
let payload = serde_json::to_vec(&payload).unwrap_or_default();
let gated = self.bridge.call_x402_priced(
&self.grant,
self.caller,
&req.network,
&req.pay_to,
&payload,
Energy::estimator(cost_uj),
&req.price(),
self.now,
);
self.receipts.push(gated.receipt.clone());
gated
}
/// **Seal the settlement** — after the host settles an authorized charge on
/// chain, seal a receipt whose `payload_hash` commits to the on-chain
/// transaction and the **measured** joules the paid compute consumed.
///
/// This is the artifact neither side produces alone: the payment rail knows
/// the transaction but not the energy; an energy meter knows the joules but
/// not the transaction. It debits nothing — [`authorize`](Self::authorize)
/// already debited both ceilings against the estimate — so it is an allow
/// that carries the record. A verifier checks it by recomputing
/// [`X402Settlement::coupling_id`] and comparing it to the receipt's
/// `payload_hash`.
pub fn seal_settlement(
&mut self,
req: &X402Requirements,
settlement: &X402Settlement,
measured_uj: u64,
) -> Gated {
let payload = settlement.coupling_record(&req.resource, measured_uj);
let gated = self.bridge.call_x402_priced(
&self.grant,
self.caller,
&req.network,
&req.pay_to,
&payload,
Energy::measured(0), // records the coupling; does not re-debit
&Price::new(&req.asset, 0),
self.now,
);
self.receipts.push(gated.receipt.clone());
gated
}
/// The merchant `(network, payTo)` this profile pays.
pub fn merchant(&self) -> (&str, &str) {
(&self.network, &self.pay_to)
}
/// Joules debited from the payment grant, in µJ.
pub fn spent_uj(&self) -> u64 {
self.bridge.runtime().spent(&self.grant.grant.id())
}
/// Money debited from the payment grant, in the ceiling's minor units.
pub fn spent_minor(&self) -> u64 {
self.bridge.runtime().spent_minor(&self.grant.grant.id())
}
/// The sealed decision receipts, in order (the audit ledger).
pub fn receipts(&self) -> &[Vec<u8>] {
&self.receipts
}
/// The sealing steward id.
pub fn steward(&self) -> &str {
&self.steward
}
/// The steward's public key — a third party verifies the receipts with this.
pub fn steward_pubkey(&self) -> [u8; 32] {
self.steward_pk
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn sk(s: u8) -> SigningKey {
SigningKey::from_bytes(&[s; 32])
}
fn sample_req() -> Value {
json!({
"scheme": "exact",
"network": "base",
"maxAmountRequired": "1000000", // 1.00 USDC (6 decimals), wire string
"resource": "/api/premium-inference",
"payTo": "0xMerchantAddress",
"asset": "USDC",
"maxTimeoutSeconds": 60
})
}
#[test]
fn ingests_requirements_including_string_amount() {
let r = ingest_requirements(&sample_req()).unwrap();
assert_eq!(r.network, "base");
assert_eq!(r.max_amount, 1_000_000);
assert_eq!(r.pay_to, "0xMerchantAddress");
assert_eq!(r.asset, "USDC");
assert_eq!(r.price(), Price::new("USDC", 1_000_000));
assert_eq!(r.funds_ceiling(), Funds::new("USDC", 1_000_000));
}
#[test]
fn ingest_tolerates_accepts_wrapper_and_integer_amount() {
let wrapped = json!({
"x402Version": 1,
"accepts": [{ "network": "solana", "maxAmountRequired": 500, "payTo": "Merc", "asset": "USDC" }]
});
let r = ingest_requirements(&wrapped).unwrap();
assert_eq!(r.network, "solana");
assert_eq!(r.max_amount, 500);
}
#[test]
fn ingest_rejects_missing_asset() {
let bad = json!({ "network": "base", "maxAmountRequired": "1", "payTo": "0xM" });
assert!(ingest_requirements(&bad).is_err());
}
/// The whole point: a stablecoin charge becomes a §4.8 money ceiling, the
/// operator supplies the joules, and money binds while joules have room.
#[test]
fn charge_gates_on_money_while_joules_have_room() {
let req = ingest_requirements(&sample_req()).unwrap();
// Session cap of 1.50 USDC, generous joules.
let mut profile = X402Profile::new(
"base",
"0xMerchantAddress",
10_000_000,
Funds::new("USDC", 1_500_000),
&sk(1),
Did::from_verifying_key(&sk(2).verifying_key()),
sk(7),
"did:example:x402-steward",
)
.unwrap();
// First 1.00 USDC charge → allowed; both dimensions debit.
let g1 = profile.authorize(&req, 5_000);
assert!(g1.allowed());
assert_eq!(profile.spent_minor(), 1_000_000);
assert_eq!(profile.spent_uj(), 5_000);
// A second 1.00 USDC charge would exceed the 1.50 session cap → denied on
// MONEY, though joules had ample room. The receipt records exactly that.
let g2 = profile.authorize(&req, 5_000);
assert!(!g2.allowed());
let core = jcp_receipt::extract(&g2.receipt, profile.steward(), &profile.steward_pubkey()).unwrap();
assert_eq!(core.verdict, "deny");
assert_eq!(core.deny_reason.as_deref(), Some("funds-exceeded"));
// The denied charge debited neither dimension.
assert_eq!(profile.spent_minor(), 1_000_000);
assert_eq!(profile.spent_uj(), 5_000);
}
/// A charge in a different asset than the grant's ceiling is refused, not
/// converted — JCP invents no exchange rate.
#[test]
fn foreign_asset_charge_is_denied_not_converted() {
let req = X402Requirements {
scheme: "exact".into(),
network: "base".into(),
max_amount: 1,
resource: "/r".into(),
pay_to: "0xM".into(),
asset: "EURC".into(), // the grant is capped in USDC
};
let mut profile = X402Profile::new(
"base", "0xM", 10_000_000, Funds::new("USDC", 10_000_000),
&sk(1), Did::from_verifying_key(&sk(2).verifying_key()), sk(7), "did:example:x402-steward",
)
.unwrap();
let g = profile.authorize(&req, 1);
assert!(!g.allowed());
let core = jcp_receipt::extract(&g.receipt, profile.steward(), &profile.steward_pubkey()).unwrap();
assert_eq!(core.deny_reason.as_deref(), Some("currency-mismatch"));
}
/// THE coupling: the settlement receipt cryptographically binds the settled
/// on-chain transaction to the measured joules. Possible only because the
/// `jcp/2` profile commits `payload_hash` (SPEC §8).
#[test]
fn settlement_receipt_binds_tx_to_measured_joules() {
let req = ingest_requirements(&sample_req()).unwrap();
let mut profile = X402Profile::for_charge(
&req,
10_000_000,
&sk(1),
Did::from_verifying_key(&sk(2).verifying_key()),
sk(7),
"did:example:x402-steward",
)
.unwrap();
assert!(profile.authorize(&req, 4_200).allowed());
let settled = X402Settlement {
transaction: Some("0xdeadbeefcafe".into()),
network: "base".into(),
payer: Some("0xAgentWallet".into()),
};
let g = profile.seal_settlement(&req, &settled, 4_200);
assert!(g.allowed());
// A third party recomputes the coupling id and checks the receipt.
let core = jcp_receipt::extract(&g.receipt, profile.steward(), &profile.steward_pubkey()).unwrap();
assert_eq!(
core.payload_hash.as_deref(),
Some(settled.coupling_id(&req.resource, 4_200).to_hex().as_str()),
"the receipt must bind THIS transaction to THIS energy figure"
);
// A different tx hash — or different joules — yields a different id, so
// the receipt cannot be re-pointed at another settlement.
let other_tx = X402Settlement { transaction: Some("0xfeed".into()), ..settled.clone() };
assert_ne!(
core.payload_hash.as_deref(),
Some(other_tx.coupling_id(&req.resource, 4_200).to_hex().as_str())
);
assert_ne!(
core.payload_hash.as_deref(),
Some(settled.coupling_id(&req.resource, 9_999).to_hex().as_str())
);
// Two receipts on the ledger: the authorize and the settlement.
assert_eq!(profile.receipts().len(), 2);
// The settlement did not re-debit; only the authorize estimate counted.
assert_eq!(profile.spent_uj(), 4_200);
}
/// A charge to a merchant the grant is not scoped to is denied on audience.
#[test]
fn charge_to_wrong_merchant_is_denied() {
let req = ingest_requirements(&sample_req()).unwrap(); // payTo 0xMerchantAddress
let mut profile = X402Profile::new(
"base", "0xDifferentMerchant", 10_000_000, Funds::new("USDC", 10_000_000),
&sk(1), Did::from_verifying_key(&sk(2).verifying_key()), sk(7), "did:example:x402-steward",
)
.unwrap();
let g = profile.authorize(&req, 1);
assert!(!g.allowed());
let core = jcp_receipt::extract(&g.receipt, profile.steward(), &profile.steward_pubkey()).unwrap();
assert_eq!(core.deny_reason.as_deref(), Some("wrong-resource"));
}
}