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]>
154 lines
6.6 KiB
Rust
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()),
|
|
}
|
|
}
|