open-standards/jcp/jcp-rs/crates/jcp-x402/examples/pay.rs
Transaction Science 86f130e3dc jcp-x402: upto holds its maximum and releases what it did not spend
The money hold existed in the runtime; `upto` was still paying through a
single-phase decision that debited the authorized maximum and never gave any
of it back. The crate's own documentation said so and told callers to size a
session's ceiling to the sum of its maxima. That is now obsolete.

`hold_upto` reserves the maximum in both dimensions before the agent pays,
debiting neither, and seals the reservation as a hold. `settle_upto` resolves
it with the measured joules and the settled amount, so the difference is
released: a session that settles at a tenth of its maxima keeps nine tenths of
its ceiling. The test asserts the thing that was previously impossible — after
a 100_000 settlement against a 1_000_000 authorization, a further 900_000 is
still authorizable, where the old path had spent the lot.

A refused resolution leaves the hold outstanding so the caller can retry rather
than lose the reservation; resolving twice is refused, because the hold is
gone; and `hold_upto` refuses a second hold while one is outstanding instead of
silently replacing it, which would leave the first unresolvable.

Supporting work, all of it required for the above to be honest:

- `jcp-receipt` gains `seal_hold` and `seal_settle`, the unlogged forms of
  `append_hold` / `append_settle`. Without them a gateway that seals without a
  hash-chained log could place a hold no receipt states — a reservation no
  replay can restore, so the enforcement point comes back from a restart with
  the interrupted charge payable a second time.
- `jcp-bridge` gains `hold_x402` and `settle_x402`.
- `X402Profile::new` takes a **per-call joule ceiling**. A hold reserves the
  stated per-invocation bound where the grant states one, so a metered session
  with a per-call ceiling admits concurrent charges while one without it is
  bounded by serialization. Passing `0` states none and keeps the old
  behaviour. `for_charge` passes its joule figure through, a single charge's
  session ceiling being its per-call ceiling by definition.
- `hold_upto` takes no cost estimate. The joule bound comes from the grant,
  because a ceiling may not admit work against an estimate — which is the same
  rule that makes the authorized maximum, not a guess at the outcome, the thing
  a money hold reserves.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-11-10 13:16:16 -05:00

154 lines
6.6 KiB
Rust

//! 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, X402Settlement};
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
5_000_000, // per-call joule ceiling — holds stay concurrent
&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. THE COUPLING. The host settles the authorized charge on chain and gets
// back a transaction; its meter reports what the paid compute consumed,
// at the grade it read it. Sealing them together produces the artifact
// neither side has: the payment rail knows the transaction but not the
// energy, an energy meter knows the joules but not the transaction.
// Here no transaction settles (its hash is illustrative). The paid
// compute is a small workload run here, metered by this host's meter as
// it ran, and committed at the grade the meter gave it: a hardware
// reading only where a live counter read the work, else the estimate,
// which the record names as one.
let settled = X402Settlement {
transaction: Some("0xdeadbeefcafe".into()),
network: "base".into(),
payer: Some("0xAgentWallet".into()),
settled_amount: None,
};
let meter = jcp_energy::Meter::detect();
let (_, energy) = meter.measure(paid_compute);
let g = profile.seal_settlement(&req, &settled, energy);
println!("\nsettled {} (illustrative tx) → sealed {}", settled.transaction.as_deref().unwrap_or("-"), verdict(&g));
println!(
" paid compute: {} µJ [{}], metered here by the {:?} rung",
energy.uj(),
energy.provenance.jcr1_label(),
meter.rung()
);
// A third party verifies the binding without trusting the gateway: it
// recomputes the coupling id from the settlement record it holds and
// checks it against the receipt's payload_hash (jcp/2, SPEC §8).
let core = jcp_receipt::extract(&g.receipt, profile.steward(), &profile.steward_pubkey())
.expect("settlement receipt verifies");
let expected = settled.coupling_id(&req.resource, &energy).to_hex();
println!(
" coupling verified: {} (tx + {} µJ [{}], bound into payload_hash {}…)",
core.payload_hash.as_deref() == Some(expected.as_str()),
energy.uj(),
energy.provenance.jcr1_label(),
&expected[..16]
);
// Re-pointing the receipt at a different transaction — or a different
// energy figure — yields a different id, so the binding cannot be moved.
let other = X402Settlement { transaction: Some("0xfeed".into()), ..settled.clone() };
println!(
" re-point to another tx → id matches: {}",
core.payload_hash.as_deref() == Some(other.coupling_id(&req.resource, &energy).to_hex().as_str())
);
// 7. 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()
);
}
/// The paid compute: a fixed amount of real CPU work on this thread, so the
/// meter has something to read.
fn paid_compute() -> u64 {
let mut x = 0x9e37_79b9_7f4a_7c15u64;
for i in 0..5_000_000u64 {
x = std::hint::black_box(x.rotate_left(5) ^ i).wrapping_mul(0x0000_0100_0000_01b3);
}
x
}
/// 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()),
}
}