From aee1e092387bb86a81144d45234ad8e8bf940d96 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 18 Jul 2026 01:46:44 -0400 Subject: [PATCH 1/3] jcp-x402: dual-ceiling gate for the x402 agentic-payment wire (WIP: crate + bridge frames) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ingest an x402 PaymentRequirements; its stablecoin charge becomes the grant's SPEC §4.8 money ceiling and the operator supplies the joule ceiling. Committing early — tests green (6) — before adding the example. Co-Authored-By: Claude Opus 4.8 --- jcp/jcp-rs/Cargo.lock | 14 + jcp/jcp-rs/Cargo.toml | 1 + jcp/jcp-rs/crates/jcp-bridge/src/lib.rs | 42 +++ jcp/jcp-rs/crates/jcp-x402/Cargo.toml | 21 ++ jcp/jcp-rs/crates/jcp-x402/src/lib.rs | 450 ++++++++++++++++++++++++ 5 files changed, 528 insertions(+) create mode 100644 jcp/jcp-rs/crates/jcp-x402/Cargo.toml create mode 100644 jcp/jcp-rs/crates/jcp-x402/src/lib.rs 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/src/lib.rs b/jcp/jcp-rs/crates/jcp-x402/src/lib.rs new file mode 100644 index 000000000..6ec29116e --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-x402/src/lib.rs @@ -0,0 +1,450 @@ +//! 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. +//! +//! **On the settlement↔energy coupling (deliberately not claimed here).** The +//! goal beyond gating is a signed receipt binding the settled on-chain +//! transaction hash to the joules the paid compute consumed. The JCR-1 receipt +//! already commits the joules (`cost_uj`) and the merchant audience, but its wire +//! form does **not** commit the frame's `payload_hash`, so a transaction hash +//! cannot yet be sealed into it. Completing that coupling is a JCR-1 +//! receipt-format decision (add `payload_hash` to the signed core — which would +//! also let every tool-call receipt bind its arguments), not something this +//! profile should assert. This crate therefore ships the correct, complete +//! dual-ceiling gate and stops short of a coupling the receipt cannot yet +//! honestly carry. +//! +//! Ingest, not compete: the x402 rail keeps its transport and its on-chain edge; +//! JCP contributes the capability and the dual ceiling. + +use ed25519_dalek::SigningKey; +use jcp_bridge::{x402_resource, Bridge}; +use jcp_grant::issue; +use jcp_runtime::Price; +use jcp_schema::{Capability, 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, + }) +} + +// ───────────────────────────── 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 + } + + /// 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")); + } + + /// 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")); + } +} -- 2.54.0 (Apple Git-157) From 66fef77915f9ea92c1fe55fba7fb6ae8730d5b9f Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 18 Jul 2026 02:01:20 -0400 Subject: [PATCH 2/3] jcp-x402: the x402 agentic-payment wire, dual-ceiling gated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit x402 is the open HTTP-native payment wire: a server answers with `402 Payment Required` + a PaymentRequirements (a stablecoin charge), the agent retries with a signed X-PAYMENT header, the server settles on chain. It standardizes how an agent pays inline — but the authorization is denominated in a stablecoin and bounds nothing about the compute the paid-for work consumes. That is precisely the shape SPEC §4.8 was built for. The stablecoin amount is a MONEY ceiling; the compute is a JOULE ceiling; the two must not be reconciled by a fabricated USDC→joule rate. So the payment supplies one ceiling, the operator supplies the other, and both are enforced independently. - jcp-bridge: `x402_resource(network, payTo)` content-addresses a merchant as a grant audience an attacker cannot spoof by name; `x402_pay_frame` and `call_x402_priced` mirror the a2a priced path. Reuses FrameKind::AgentTask — no schema change, so no receipt-format or conformance-vector churn. - jcp-x402: `ingest_requirements` parses PaymentRequirements (tolerating the `accepts[]` wrapper and `maxAmountRequired` as the wire's decimal STRING or an integer — parsed to integer minor units, never a float). `price()` and `funds_ceiling()` read the charge as money. `X402Profile` mints one dual-ceiling grant per merchant and gates each charge, sealing allow and deny alike. Denials proven by test and by the runnable example: past the money cap → `funds-exceeded` while joules still had room; a charge in another asset → `currency-mismatch` (refused, never converted); a charge to a merchant the grant is not scoped to → `wrong-resource`. DELIBERATELY NOT CLAIMED: a receipt coupling the settled on-chain transaction hash to the attested joules. The JCR-1 wire commits the joules (`cost_uj`) and the merchant audience but NOT the frame's `payload_hash`, so a tx hash cannot yet be sealed into it. Completing that coupling is a JCR-1 receipt-format decision (adding `payload_hash` to the signed core — which would also let every tool-call receipt bind its arguments) and belongs in its own change, not smuggled into a profile crate. The module doc states this limitation plainly. 42 workspace suites green, clippy clean. Co-Authored-By: Claude Opus 4.8 --- jcp/jcp-rs/crates/jcp-x402/examples/pay.rs | 94 ++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 jcp/jcp-rs/crates/jcp-x402/examples/pay.rs 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()), + } +} -- 2.54.0 (Apple Git-157) From cf0e40037ee2d681a6c9cc10390f7edbf70959b9 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 18 Jul 2026 03:21:16 -0400 Subject: [PATCH 3/3] =?UTF-8?q?jcp-x402:=20seal=20the=20settlement=20?= =?UTF-8?q?=E2=80=94=20bind=20the=20on-chain=20tx=20to=20the=20attested=20?= =?UTF-8?q?joules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crate deliberately stopped short of this: under `jcp/1` the receipt did not commit `payload_hash`, so a transaction hash could not be sealed into it and claiming the coupling would have been false. The `jcp/2` profile (SPEC §8, now on main) commits it, so the coupling is real and this delivers it. `X402Settlement` carries the x402 `X-PAYMENT-RESPONSE` outcome (transaction, network, payer). `seal_settlement` seals a receipt whose `payload_hash` commits to the canonical coupling record — settled transaction + resource + MEASURED joules. `coupling_id()` is the public check: a third party recomputes the content id from the settlement record and compares it to the receipt's `payload_hash`, so the binding is cryptographic rather than asserted. 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. Tested: the receipt binds THIS transaction to THIS energy figure, and changing either the tx hash or the joule figure yields a different coupling id — so a settlement receipt cannot be re-pointed at another settlement. It debits nothing (authorize already charged both ceilings against the estimate), verified by asserting spend is unchanged after sealing. 42 workspace suites green, clippy clean, all conformance checks pass, example runs. Co-Authored-By: Claude Opus 4.8 --- jcp/jcp-rs/crates/jcp-x402/src/lib.rs | 153 +++++++++++++++++++++++--- 1 file changed, 140 insertions(+), 13 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-x402/src/lib.rs b/jcp/jcp-rs/crates/jcp-x402/src/lib.rs index 6ec29116e..e60516fe6 100644 --- a/jcp/jcp-rs/crates/jcp-x402/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-x402/src/lib.rs @@ -30,26 +30,26 @@ //! 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. //! -//! **On the settlement↔energy coupling (deliberately not claimed here).** The -//! goal beyond gating is a signed receipt binding the settled on-chain -//! transaction hash to the joules the paid compute consumed. The JCR-1 receipt -//! already commits the joules (`cost_uj`) and the merchant audience, but its wire -//! form does **not** commit the frame's `payload_hash`, so a transaction hash -//! cannot yet be sealed into it. Completing that coupling is a JCR-1 -//! receipt-format decision (add `payload_hash` to the signed core — which would -//! also let every tool-call receipt bind its arguments), not something this -//! profile should assert. This crate therefore ships the correct, complete -//! dual-ceiling gate and stops short of a coupling the receipt cannot yet -//! honestly carry. +//! - 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 and the dual ceiling. +//! 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, Did, Energy, Funds, Grant, SignedGrant}; +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 @@ -176,6 +176,50 @@ pub fn ingest_requirements(req: &Value) -> Result { }) } +// ───────────────────────────── 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)` @@ -294,6 +338,38 @@ impl X402Profile { 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) @@ -433,6 +509,57 @@ mod tests { 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() { -- 2.54.0 (Apple Git-157)