diff --git a/jcp/jcp-rs/Cargo.lock b/jcp/jcp-rs/Cargo.lock index cda3fb8c0..31d29587d 100644 --- a/jcp/jcp-rs/Cargo.lock +++ b/jcp/jcp-rs/Cargo.lock @@ -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" diff --git a/jcp/jcp-rs/Cargo.toml b/jcp/jcp-rs/Cargo.toml index b58d3495a..65e13434a 100644 --- a/jcp/jcp-rs/Cargo.toml +++ b/jcp/jcp-rs/Cargo.toml @@ -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", diff --git a/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs b/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs index da8f6eea5..2b34cee7e 100644 --- a/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-bridge/src/lib.rs @@ -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. diff --git a/jcp/jcp-rs/crates/jcp-x402/Cargo.toml b/jcp/jcp-rs/crates/jcp-x402/Cargo.toml new file mode 100644 index 000000000..8632912c1 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-x402/Cargo.toml @@ -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 diff --git a/jcp/jcp-rs/crates/jcp-x402/examples/pay.rs b/jcp/jcp-rs/crates/jcp-x402/examples/pay.rs new file mode 100644 index 000000000..620bdc668 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-x402/examples/pay.rs @@ -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()), + } +} diff --git a/jcp/jcp-rs/crates/jcp-x402/src/lib.rs b/jcp/jcp-rs/crates/jcp-x402/src/lib.rs new file mode 100644 index 000000000..e60516fe6 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-x402/src/lib.rs @@ -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) -> 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 { + // 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::() + .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, + /// The network it settled on. + pub network: String, + /// The payer address recovered from the signature, if any. + pub payer: Option, +} + +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 { + 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>, + 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, + pay_to: impl Into, + budget_uj: u64, + budget_funds: Funds, + issuer_sk: &SigningKey, + subject: Did, + steward_sk: SigningKey, + steward: impl Into, + ) -> Result { + 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, + ) -> Result { + 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] { + &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")); + } +}