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",
|
||||
]
|
||||
|
||||
[[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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
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