From 0de6efdafbcb8a87eb062c7356c44b6c5f639360 Mon Sep 17 00:00:00 2001 From: Pattermesh Date: Fri, 22 May 2026 21:57:09 +0530 Subject: [PATCH 1/2] =?UTF-8?q?examples:=20switchboard=5Fx402=5Fclient=20?= =?UTF-8?q?=E2=80=94=20Rust=20=E2=86=94=20Python=20interop=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds examples/switchboard_x402_client.rs — an arka-based agent calling a paid endpoint served by Python switchboard's HTTP-402 middleware. Proves the wire format (PaymentOffer / PaymentProof JSON envelopes) is genuinely language-neutral: arka has no switchboard dependency, just parses and emits the agreed shape. What the example does: - Creates a fresh arka Agent on Base - GET endpoint cold; expects 402 + X-Payment-Required header - Parses the accepts[] envelope (matches switchboard.x402_middleware.PaymentOffer) - Builds an X-Payment-Proof header (matches switchboard.x402_middleware.PaymentProof) - Retries with the proof; expects 200 The tx-hash is stubbed for the demo (real settlement would go through arka's wallet or switchboard's AgentEscrow contract via alloy). The point is the wire — bytes leaving this Rust process round-trip through the Python middleware's PaymentProof.from_header() without loss. Why this matters - The stack scene in switchboard's lab (web/agents-demo.html scene 09) names arka as a "consumer" — this example backs that claim with runnable code. - When the spec evolves (PQ envelope #33, receipt aggregation #43, privacy-preserving x402 #44), this example is the canary that catches wire drift between the Rust and Python sides. Adversarial conformance harness #45 will eventually run this as a fixture. Also updates examples table in README.md. Cross-references - switchboard repo: https://github.com/kcolbchain/switchboard - switchboard live lab: https://kcolbchain.github.io/switchboard/agents-demo.html - switchboard PR for PQ: https://github.com/kcolbchain/switchboard/issues/33 cargo check passes clean (no warnings). --- README.md | 1 + examples/switchboard_x402_client.rs | 200 ++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 examples/switchboard_x402_client.rs diff --git a/README.md b/README.md index 48a2490..aca6cb6 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ cargo run --example basic_agent | `dex_swap` | Swap tokens on Uniswap V3 | | `mpp_payment` | Pay for an API using MPP on Tempo | | `multi_chain` | Same agent operating across Base + Arbitrum + Optimism | +| `switchboard_x402_client` | Pay a [switchboard](https://github.com/kcolbchain/switchboard)-served HTTP-402 endpoint. Cross-language interop demo (Rust ↔ Python). | ## Contributing diff --git a/examples/switchboard_x402_client.rs b/examples/switchboard_x402_client.rs new file mode 100644 index 0000000..2123906 --- /dev/null +++ b/examples/switchboard_x402_client.rs @@ -0,0 +1,200 @@ +//! Switchboard x402 client — Rust ↔ Python interop. +//! +//! This example shows an arka-based agent calling a paid endpoint served +//! by a Python switchboard middleware. The wire is HTTP-402 + signed +//! payment proofs — a language-neutral protocol that both sides speak. +//! +//! Why this matters: switchboard is the Python reference impl of the +//! agent-payment substrate. arka is the Rust agent SDK. They don't share +//! any code, but they share the wire format. This file is the +//! cross-language conformance demo for it. +//! +//! # Running the demo +//! +//! Spin up the switchboard middleware on a paid route (Python side): +//! +//! ```bash +//! # In the switchboard repo: +//! pip install -e '.[dev]' +//! python -m switchboard.x402_middleware.serve --port 8402 --price 1000 +//! # Now `http://localhost:8402/agent-only` returns 402 on first hit. +//! ``` +//! +//! Then run this example (Rust side): +//! +//! ```bash +//! cargo run --example switchboard_x402_client -- \ +//! --endpoint http://localhost:8402/agent-only +//! ``` +//! +//! On a cold call you'll see: +//! - GET → 402 Payment Required + `X-Payment-Required` header +//! - parse the offer, build a payment proof, retry with `X-Payment-Proof` +//! - GET → 200 + body +//! +//! # Wire compatibility +//! +//! The HTTP envelopes here MUST match `switchboard.x402_middleware`'s +//! `PaymentOffer.to_header()` and `PaymentProof.from_header()` byte for +//! byte. If switchboard ever changes the envelope, this client breaks +//! and the cross-repo conformance test (planned in switchboard +//! `tests/conformance/`) catches it. +//! +//! # Related +//! +//! - switchboard docs: https://github.com/kcolbchain/switchboard +//! - switchboard lab: https://kcolbchain.github.io/switchboard/agents-demo.html +//! - x402 upstream spec: https://www.x402.org + +use std::time::{SystemTime, UNIX_EPOCH}; + +use arka::prelude::*; +use reqwest::header::{HeaderMap, HeaderValue}; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; + +/// One entry of the server's `accepts[]` envelope. Wire-compatible with +/// `switchboard.x402_middleware.PaymentOffer.to_header()`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PaymentOffer { + amount: String, // uint256 as decimal string + currency: String, + recipient: String, + chain_id: u64, + #[serde(default)] + scheme: String, + #[serde(default)] + description: String, + #[serde(default)] + nonce: String, + #[serde(default)] + expires_at: Option, +} + +/// What we send back. Wire-compatible with +/// `switchboard.x402_middleware.PaymentProof.from_header()`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct PaymentProof { + tx_hash: String, + chain_id: u64, + payer: String, + amount: String, + nonce: String, + timestamp: u64, +} + +fn parse_args() -> String { + let mut args = std::env::args().skip(1); + let mut endpoint = String::from("http://localhost:8402/agent-only"); + while let Some(arg) = args.next() { + match arg.as_str() { + "--endpoint" => { + if let Some(v) = args.next() { + endpoint = v; + } + } + "-h" | "--help" => { + eprintln!("Usage: switchboard_x402_client [--endpoint URL]"); + std::process::exit(0); + } + _ => {} + } + } + endpoint +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + let endpoint = parse_args(); + + // Fresh ephemeral wallet for this demo — in production this would be + // a long-lived key fetched from arka's wallet manager. + let wallet = Wallet::generate()?; + let agent = Agent::builder() + .chain(Chain::Base) + .wallet(wallet) + .build() + .await?; + let payer_addr = format!("{:?}", agent.address()); + println!("Agent ready on {} at {}", agent.chain(), payer_addr); + + let client = reqwest::Client::new(); + + // ── First attempt — expect 402 ──────────────────────────────────── + let resp = client.get(&endpoint).send().await?; + + if resp.status() != StatusCode::PAYMENT_REQUIRED { + // Switchboard returned something else — either 200 already (the + // server doesn't gate this route), or some other error. Print and + // exit. + println!("Unexpected status (no 402 dance needed): {}", resp.status()); + let body = resp.text().await.unwrap_or_default(); + println!("body: {body}"); + return Ok(()); + } + + // Parse the 402 envelope. Switchboard ships it via the + // `X-Payment-Required` header as a JSON object with an `accepts: []` + // array — we pick the first entry. + let header = resp + .headers() + .get("X-Payment-Required") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + println!("402 envelope: {header}"); + + #[derive(Deserialize)] + struct Envelope { + accepts: Vec, + } + let env: Envelope = serde_json::from_str(&header)?; + let offer = env + .accepts + .into_iter() + .next() + .ok_or_else(|| ArkaError::Mpp("empty accepts[]".into()))?; + println!( + "Server wants {} {} on chain_id={} to {}\n scheme={} description={:?} expires_at={:?}", + offer.amount, offer.currency, offer.chain_id, offer.recipient, + offer.scheme, offer.description, offer.expires_at + ); + + // ── Settle on-chain (stubbed in this demo) ──────────────────────── + // In a real flow this is where arka would issue the actual ETH / + // ERC-20 transfer via `agent.send_value(...)` or the AgentEscrow + // contract from switchboard. For a demo against a local middleware + // running in "mock-settle" mode, an ephemeral tx-hash is sufficient. + let tx_hash = format!("0x{:064x}", rand::random::() as u128); + + let proof = PaymentProof { + tx_hash, + chain_id: offer.chain_id, + payer: payer_addr, + amount: offer.amount.clone(), + nonce: offer.nonce.clone(), + timestamp: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + }; + + // ── Retry with the proof ────────────────────────────────────────── + let mut headers = HeaderMap::new(); + let proof_json = serde_json::to_string(&proof)?; + headers.insert( + "X-Payment-Proof", + HeaderValue::from_str(&proof_json) + .map_err(|e| ArkaError::Mpp(format!("invalid header value: {e}")))?, + ); + + let resp2 = client.get(&endpoint).headers(headers).send().await?; + let status = resp2.status(); + let body = resp2.text().await.unwrap_or_default(); + println!("After proof: {status} body={body}"); + + Ok(()) +} From ecb0e86f45367ab48d39fe0589af1cd82006308a Mon Sep 17 00:00:00 2001 From: Pattermesh Date: Sun, 24 May 2026 00:19:07 +0530 Subject: [PATCH 2/2] examples: cargo fmt for switchboard_x402_client (fixes CI) --- examples/switchboard_x402_client.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/examples/switchboard_x402_client.rs b/examples/switchboard_x402_client.rs index 2123906..2328442 100644 --- a/examples/switchboard_x402_client.rs +++ b/examples/switchboard_x402_client.rs @@ -159,8 +159,13 @@ async fn main() -> Result<()> { .ok_or_else(|| ArkaError::Mpp("empty accepts[]".into()))?; println!( "Server wants {} {} on chain_id={} to {}\n scheme={} description={:?} expires_at={:?}", - offer.amount, offer.currency, offer.chain_id, offer.recipient, - offer.scheme, offer.description, offer.expires_at + offer.amount, + offer.currency, + offer.chain_id, + offer.recipient, + offer.scheme, + offer.description, + offer.expires_at ); // ── Settle on-chain (stubbed in this demo) ────────────────────────