jcp-x402: the x402 agentic-payment wire, dual-ceiling gated + settlement coupling #24
6 changed files with 749 additions and 0 deletions
14
jcp/jcp-rs/Cargo.lock
generated
14
jcp/jcp-rs/Cargo.lock
generated
|
|
@ -737,6 +737,20 @@ dependencies = [
|
||||||
"thiserror 2.0.18",
|
"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]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.186"
|
version = "0.2.186"
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ members = [
|
||||||
"crates/jcp-gateway",
|
"crates/jcp-gateway",
|
||||||
"crates/jcp-trace",
|
"crates/jcp-trace",
|
||||||
"crates/jcp-a2a",
|
"crates/jcp-a2a",
|
||||||
|
"crates/jcp-x402",
|
||||||
"crates/jcp-policy",
|
"crates/jcp-policy",
|
||||||
"crates/jcp-prompt",
|
"crates/jcp-prompt",
|
||||||
"crates/jcp-orchestrate",
|
"crates/jcp-orchestrate",
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// 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
|
/// sealed JCR-1 receipt. The host MUST forward to the endpoint only when
|
||||||
/// `decision.is_allow()`.
|
/// `decision.is_allow()`.
|
||||||
|
|
@ -135,6 +156,27 @@ impl Bridge {
|
||||||
Gated { decision, frame, receipt }
|
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
|
/// 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
|
/// ceiling — the AP2 path, where a payment mandate bounds the spend and the
|
||||||
/// operator bounds the energy.
|
/// operator bounds the energy.
|
||||||
|
|
|
||||||
21
jcp/jcp-rs/crates/jcp-x402/Cargo.toml
Normal file
21
jcp/jcp-rs/crates/jcp-x402/Cargo.toml
Normal 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
|
||||||
94
jcp/jcp-rs/crates/jcp-x402/examples/pay.rs
Normal file
94
jcp/jcp-rs/crates/jcp-x402/examples/pay.rs
Normal 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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
577
jcp/jcp-rs/crates/jcp-x402/src/lib.rs
Normal file
577
jcp/jcp-rs/crates/jcp-x402/src/lib.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue