From 0fdd0b7805d29d4fda61b8c091fb59a339dd52e6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sat, 6 Jun 2026 20:00:08 -0400 Subject: [PATCH] feat(x402): add x402 payment protocol for machine-payable APIs (#3460) --- .env.example | 10 + src/core/all.rs | 3 + src/core/jsonrpc.rs | 9 + src/openhuman/about_app/catalog_data.rs | 10 + src/openhuman/mod.rs | 1 + .../tools/impl/network/http_request.rs | 196 ++++- src/openhuman/wallet/mod.rs | 2 +- src/openhuman/x402/mod.rs | 27 + src/openhuman/x402/ops.rs | 710 ++++++++++++++++++ src/openhuman/x402/schemas.rs | 179 +++++ src/openhuman/x402/store.rs | 449 +++++++++++ src/openhuman/x402/types.rs | 154 ++++ src/openhuman/x402/x402_tests.rs | 189 +++++ 13 files changed, 1900 insertions(+), 39 deletions(-) create mode 100644 src/openhuman/x402/mod.rs create mode 100644 src/openhuman/x402/ops.rs create mode 100644 src/openhuman/x402/schemas.rs create mode 100644 src/openhuman/x402/store.rs create mode 100644 src/openhuman/x402/types.rs create mode 100644 src/openhuman/x402/x402_tests.rs diff --git a/.env.example b/.env.example index 44d6fb995..935db84f1 100644 --- a/.env.example +++ b/.env.example @@ -245,6 +245,16 @@ OPENHUMAN_TELEGRAM_BOT_USERNAME=openhuman_bot # OPENHUMAN_WALLET_RPC_SOLANA= # OPENHUMAN_WALLET_RPC_TRON= +# --------------------------------------------------------------------------- +# x402 Machine Payments +# --------------------------------------------------------------------------- +# Budget caps for automatic x402 API payments (atomic USDC: 1 USDC = 1_000_000). +# Defaults: per_request=1_000_000 (1 USDC), daily=10_000_000 (10 USDC), +# monthly=100_000_000 (100 USDC). +# OPENHUMAN_X402_PER_REQUEST_MAX=1000000 +# OPENHUMAN_X402_DAILY_MAX=10000000 +# OPENHUMAN_X402_MONTHLY_MAX=100000000 + # --------------------------------------------------------------------------- # Skills # --------------------------------------------------------------------------- diff --git a/src/core/all.rs b/src/core/all.rs index 71b68e207..786fc6200 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -151,6 +151,8 @@ fn build_registered_controllers() -> Vec { controllers.extend(crate::openhuman::http_host::all_http_host_registered_controllers()); // Token usage and billing cost tracking controllers.extend(crate::openhuman::cost::all_cost_registered_controllers()); + // x402 machine-payable API payment protocol + controllers.extend(crate::openhuman::x402::all_x402_registered_controllers()); // Inline autocomplete settings controllers.extend(crate::openhuman::autocomplete::all_autocomplete_registered_controllers()); // External messaging channels (Web, Telegram, etc.) @@ -332,6 +334,7 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend(crate::openhuman::heartbeat::all_heartbeat_controller_schemas()); schemas.extend(crate::openhuman::http_host::all_http_host_controller_schemas()); schemas.extend(crate::openhuman::cost::all_cost_controller_schemas()); + schemas.extend(crate::openhuman::x402::all_x402_controller_schemas()); schemas.extend(crate::openhuman::autocomplete::all_autocomplete_controller_schemas()); schemas .extend(crate::openhuman::channels::providers::web::all_web_channel_controller_schemas()); diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index e8fe50e28..04f194297 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -2062,6 +2062,15 @@ pub async fn bootstrap_core_runtime(host_kind: crate::core::types::HostKind) { // share one JSONL-backed store. Idempotent. crate::openhuman::cost::init_global(cfg.cost.clone(), &workspace_dir); + // --- x402 payment ledger --- + // Initializes the JSONL-backed spending ledger for machine-payable API + // payments (x402 protocol). Budget defaults can be overridden via + // the `openhuman.x402_update_budget` RPC. + { + let x402_session = format!("x402-{}", uuid::Uuid::new_v4()); + crate::openhuman::x402::init_ledger(&workspace_dir, &x402_session); + } + // --- Sub-agent definition registry bootstrap --- // Loads built-in archetype definitions plus any custom TOML files // under `/agents/*.toml`. Idempotent — safe to call diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index 4b661ba71..52b06f2a1 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -741,6 +741,16 @@ pub(super) const CAPABILITIES: &[Capability] = &[ status: CapabilityStatus::Beta, privacy: LOCAL_CREDENTIALS, }, + Capability { + id: "workflows.x402_payments", + name: "x402 Machine Payments", + domain: "x402", + category: CapabilityCategory::Workflows, + description: "Automatic HTTP 402 payment handling for machine-payable APIs via the x402 protocol. When an API returns 402 Payment Required, the agent pays with USDC on Solana using the local wallet and retries. Budget enforcement with per-request, daily, and monthly caps.", + how_to: "Use x402.* RPC methods (get_summary, list_payments, update_budget) to manage spending. Payments happen automatically when the http_request tool encounters a 402 with a PAYMENT-REQUIRED header.", + status: CapabilityStatus::Beta, + privacy: LOCAL_CREDENTIALS, + }, Capability { id: "workflows.connect_crypto_exchange", name: "Connect Crypto Exchange", diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 66dc02b83..7d03da67e 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -125,3 +125,4 @@ pub mod webview_notifications; pub mod whatsapp_data; pub mod workflows; pub mod workspace; +pub mod x402; diff --git a/src/openhuman/tools/impl/network/http_request.rs b/src/openhuman/tools/impl/network/http_request.rs index 328b9a1c6..9a9d373d2 100644 --- a/src/openhuman/tools/impl/network/http_request.rs +++ b/src/openhuman/tools/impl/network/http_request.rs @@ -3,6 +3,7 @@ use crate::openhuman::config::HttpRequestConfig; use crate::openhuman::security::SecurityPolicy; use crate::openhuman::tools::traits::{Tool, ToolResult}; use async_trait::async_trait; +use base64::engine::Engine as _; use serde_json::json; use std::sync::Arc; use std::time::Duration; @@ -134,6 +135,141 @@ impl HttpRequestTool { Ok(request.send().await?) } + async fn handle_x402_payment( + &self, + _initial_response: reqwest::Response, + url: &str, + method: reqwest::Method, + headers: Vec<(String, String)>, + body: Option<&str>, + ) -> Result { + use crate::openhuman::x402; + + log::debug!("[tool.http_request] 402 received with PAYMENT-REQUIRED, attempting x402 payment for {url}"); + + let initial_headers = _initial_response.headers().clone(); + let payment_result = x402::handle_402_and_pay(&initial_headers, url) + .await + .map_err(|e| format!("x402 payment failed: {e}"))?; + + let record = x402::PaymentRecord { + id: uuid::Uuid::new_v4().to_string(), + url: payment_result.url.clone(), + asset: payment_result.asset.clone(), + amount_atomic: payment_result.amount_atomic, + amount_display: format!( + "{:.6} USDC", + payment_result.amount_atomic as f64 / 1_000_000.0 + ), + recipient: payment_result.recipient.clone(), + network: payment_result.network.clone(), + tx_signature: None, + status: x402::PaymentStatus::Pending, + timestamp: chrono::Utc::now(), + session_id: String::new(), + }; + + let record_id = record.id.clone(); + let _ = x402::store::with_ledger_mut(|l| l.record_payment(record)); + + log::debug!( + "[tool.http_request] retrying with x402 payment header amount={} asset={}", + payment_result.amount_atomic, + payment_result.asset + ); + + let mut retry_headers = headers; + retry_headers.push(("PAYMENT-SIGNATURE".to_string(), payment_result.header_value)); + + let response = self + .execute_request(url, method, retry_headers, body) + .await + .map_err(|e| format!("x402 retry request failed: {e}"))?; + + let settled_status = if response.status().is_success() { + x402::PaymentStatus::Settled + } else { + x402::PaymentStatus::Failed + }; + let tx_sig = response + .headers() + .get("PAYMENT-RESPONSE") + .and_then(|v| v.to_str().ok()) + .and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok()) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|r| { + if r.success && !r.transaction.is_empty() { + Some(r.transaction) + } else { + None + } + }); + + let _ = x402::store::with_ledger_mut(|l| { + if let Some(rec) = l + .recent_payments(100) + .into_iter() + .find(|r| r.id == record_id) + { + let mut updated = rec; + updated.status = settled_status; + updated.tx_signature = tx_sig.clone(); + l.record_payment(updated); + } + }); + + if settled_status == x402::PaymentStatus::Settled { + log::debug!( + "[tool.http_request] x402 payment settled for {url} tx={:?}", + tx_sig + ); + } else { + log::warn!( + "[tool.http_request] x402 payment retry returned status {}", + response.status() + ); + } + + Ok(response) + } + + async fn format_response(&self, response: reqwest::Response) -> anyhow::Result { + let status = response.status(); + let status_code = status.as_u16(); + + let response_headers = response.headers().iter(); + let headers_text = response_headers + .map(|(k, _)| { + let is_sensitive = k.as_str().to_lowercase().contains("set-cookie"); + if is_sensitive { + format!("{}: ***REDACTED***", k.as_str()) + } else { + format!("{}: {:?}", k.as_str(), k.as_str()) + } + }) + .collect::>() + .join(", "); + + let response_text = match response.text().await { + Ok(text) => self.truncate_response(&text), + Err(e) => format!("[Failed to read response body: {e}]"), + }; + + let output = format!( + "Status: {} {}\nResponse Headers: {}\n\nResponse Body:\n{}", + status_code, + status.canonical_reason().unwrap_or("Unknown"), + headers_text, + response_text + ); + + if status.is_success() { + Ok(ToolResult::success(output)) + } else { + Ok(ToolResult::error(format!("HTTP {}", status_code))) + } + } + fn truncate_response(&self, text: &str) -> String { if text.len() > self.max_response_size { let mut truncated = text @@ -218,48 +354,32 @@ impl Tool for HttpRequestTool { let request_headers = self.parse_headers(&headers_val); - match self - .execute_request(&url, method, request_headers, body) + let response = match self + .execute_request(&url, method.clone(), request_headers.clone(), body) .await { - Ok(response) => { - let status = response.status(); - let status_code = status.as_u16(); + Ok(r) => r, + Err(e) => return Ok(ToolResult::error(format!("HTTP request failed: {e}"))), + }; - let response_headers = response.headers().iter(); - let headers_text = response_headers - .map(|(k, _)| { - let is_sensitive = k.as_str().to_lowercase().contains("set-cookie"); - if is_sensitive { - format!("{}: ***REDACTED***", k.as_str()) - } else { - format!("{}: {:?}", k.as_str(), k.as_str()) - } - }) - .collect::>() - .join(", "); - - let response_text = match response.text().await { - Ok(text) => self.truncate_response(&text), - Err(e) => format!("[Failed to read response body: {e}]"), - }; - - let output = format!( - "Status: {} {}\nResponse Headers: {}\n\nResponse Body:\n{}", - status_code, - status.canonical_reason().unwrap_or("Unknown"), - headers_text, - response_text - ); - - if status.is_success() { - Ok(ToolResult::success(output)) - } else { - Ok(ToolResult::error(format!("HTTP {}", status_code))) - } + // x402: if the server returns 402 with a PAYMENT-REQUIRED header, + // attempt to pay using the wallet's Solana key and retry. + let response = if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED + && (response.headers().get("PAYMENT-REQUIRED").is_some() + || response.headers().get("X-PAYMENT-REQUIRED").is_some()) + { + match self + .handle_x402_payment(response, &url, method, request_headers, body) + .await + { + Ok(paid_response) => paid_response, + Err(msg) => return Ok(ToolResult::error(msg)), } - Err(e) => Ok(ToolResult::error(format!("HTTP request failed: {e}"))), - } + } else { + response + }; + + self.format_response(response).await } } diff --git a/src/openhuman/wallet/mod.rs b/src/openhuman/wallet/mod.rs index 5793e5cc1..68cb5d8d4 100644 --- a/src/openhuman/wallet/mod.rs +++ b/src/openhuman/wallet/mod.rs @@ -8,7 +8,7 @@ mod chains; mod defaults; mod execution; mod ops; -mod rpc; +pub(crate) mod rpc; mod schemas; pub mod tools; diff --git a/src/openhuman/x402/mod.rs b/src/openhuman/x402/mod.rs new file mode 100644 index 000000000..a2f230551 --- /dev/null +++ b/src/openhuman/x402/mod.rs @@ -0,0 +1,27 @@ +//! x402 — HTTP 402 payment protocol for machine-payable APIs. +//! +//! Intercepts HTTP 402 responses carrying a `PAYMENT-REQUIRED` header, +//! constructs a Solana SPL token payment (typically USDC), signs it with the +//! wallet's ed25519 key, and retries the request with the payment proof in a +//! `PAYMENT-SIGNATURE` header. The facilitator co-signs as fee payer and +//! broadcasts, so the client never needs SOL for gas. +//! +//! Protocol spec: / coinbase/x402 (v2). + +mod ops; +mod schemas; +pub(crate) mod store; +mod types; + +#[cfg(test)] +mod x402_tests; + +pub use ops::{ + handle_402, handle_402_and_pay, try_paid_request, X402Client, X402Error, X402PaymentResult, +}; +pub use schemas::all_controller_schemas as all_x402_controller_schemas; +pub use schemas::all_registered_controllers as all_x402_registered_controllers; +pub use store::{init_global as init_ledger, PaymentRecord, PaymentStatus, SpendingBudget}; +pub use types::{ + PaymentPayload, PaymentRequired, PaymentRequirements, ResourceInfo, SettlementResponse, +}; diff --git a/src/openhuman/x402/ops.rs b/src/openhuman/x402/ops.rs new file mode 100644 index 000000000..fdebb5a94 --- /dev/null +++ b/src/openhuman/x402/ops.rs @@ -0,0 +1,710 @@ +//! x402 client operations — parse 402 challenges, build Solana payment +//! transactions, sign, and retry with proof. +//! +//! Transaction layout for the `exact` Solana scheme: +//! 1. ComputeBudget::SetComputeUnitLimit +//! 2. ComputeBudget::SetComputeUnitPrice +//! 3. SPL Token `TransferChecked` +//! 4. (optional) SPL Memo with `extra.memo` or random nonce +//! +//! The client signs as the transfer authority; the facilitator's fee-payer +//! signature slot is left zeroed for co-signing at settlement time. + +use base64::engine::{general_purpose::STANDARD as B64, Engine as _}; +use ed25519_dalek::{Signer, SigningKey}; +use log::{debug, warn}; +use reqwest::header::HeaderMap; +use sha2::{Digest, Sha256}; + +use super::types::*; + +const LOG_PREFIX: &str = "[x402]"; + +/// Reasonable compute budget defaults for a single SPL TransferChecked. +const DEFAULT_COMPUTE_UNITS: u32 = 50_000; +const DEFAULT_COMPUTE_UNIT_PRICE: u64 = 1000; // micro-lamports per CU + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// High-level x402 client. Wraps a `reqwest::Client` and knows how to +/// intercept 402 responses, build Solana payments, and retry transparently. +pub struct X402Client { + http: reqwest::Client, +} + +impl X402Client { + pub fn new(http: reqwest::Client) -> Self { + Self { http } + } + + /// Send a request. If the server returns 402 with a `PAYMENT-REQUIRED` + /// header, attempt to pay using the wallet's Solana key and retry. + /// + /// `signing_key` — the wallet's ed25519 key (caller derives from mnemonic). + /// `max_amount` — optional ceiling in atomic units; rejects challenges above + /// this to prevent runaway spending. + pub async fn try_paid_request( + &self, + request: reqwest::Request, + signing_key: &SigningKey, + max_amount: Option, + ) -> Result { + let method = request.method().clone(); + let url = request.url().clone(); + let headers = request.headers().clone(); + let body_bytes = request + .body() + .and_then(|b| b.as_bytes()) + .map(|b| b.to_vec()); + + debug!("{LOG_PREFIX} initial request {} {}", method, url); + let response = self + .http + .execute(request) + .await + .map_err(X402Error::Transport)?; + + if response.status() != reqwest::StatusCode::PAYMENT_REQUIRED { + return Ok(response); + } + + let challenge = parse_402_headers(response.headers())?; + debug!( + "{LOG_PREFIX} got 402 challenge version={} accepts={}", + challenge.x402_version, + challenge.accepts.len() + ); + + let requirement = challenge + .solana_exact_requirement() + .ok_or_else(|| X402Error::NoSolanaOption)?; + + let amount: u64 = requirement.amount.parse().map_err(|e| { + X402Error::Protocol(format!("invalid amount '{}': {e}", requirement.amount)) + })?; + + if let Some(cap) = max_amount { + if amount > cap { + return Err(X402Error::AmountExceedsCap { + requested: amount, + cap, + }); + } + } + + debug!( + "{LOG_PREFIX} paying {} atomic units of {} to {} (fee_payer={:?})", + amount, + requirement.asset, + requirement.pay_to, + requirement.fee_payer_pubkey(), + ); + + let payment = build_solana_payment(signing_key, &challenge, requirement).await?; + let encoded = B64.encode(serde_json::to_string(&payment).unwrap()); + + let mut retry_req = self.http.request(method, url); + for (key, value) in headers.iter() { + retry_req = retry_req.header(key, value); + } + retry_req = retry_req.header(HEADER_PAYMENT_SIGNATURE, &encoded); + if let Some(body) = body_bytes { + retry_req = retry_req.body(body); + } + + debug!("{LOG_PREFIX} retrying with payment proof"); + let paid_response = retry_req.send().await.map_err(X402Error::Transport)?; + + if let Some(receipt_header) = paid_response.headers().get(HEADER_PAYMENT_RESPONSE) { + match parse_settlement_response(receipt_header.to_str().unwrap_or("")) { + Ok(receipt) => { + if receipt.success { + debug!( + "{LOG_PREFIX} payment settled tx={} network={}", + receipt.transaction, receipt.network + ); + } else { + warn!( + "{LOG_PREFIX} payment settlement failed reason={:?}", + receipt.error_reason + ); + } + } + Err(e) => warn!("{LOG_PREFIX} could not parse settlement response: {e}"), + } + } + + Ok(paid_response) + } +} + +/// Standalone entry point — parse a 402 response's headers and return the +/// challenge with the index of the Solana payment option. Useful for +/// approval-gated flows where the agent must surface the cost before paying. +pub fn handle_402(headers: &HeaderMap) -> Result<(PaymentRequired, usize), X402Error> { + let challenge = parse_402_headers(headers)?; + let idx = challenge + .accepts + .iter() + .position(|r| r.scheme == "exact" && r.network.starts_with("solana:")) + .ok_or(X402Error::NoSolanaOption)?; + Ok((challenge, idx)) +} + +/// Build a payment and return the encoded header value ready to attach. +/// Separated from `try_paid_request` so callers that manage their own HTTP +/// layer can still use the payment construction. +pub async fn try_paid_request( + signing_key: &SigningKey, + challenge: &PaymentRequired, + requirement: &PaymentRequirements, +) -> Result { + let payment = build_solana_payment(signing_key, challenge, requirement).await?; + let json = serde_json::to_string(&payment) + .map_err(|e| X402Error::Protocol(format!("serialize payment: {e}")))?; + Ok(B64.encode(json)) +} + +/// Result of a successful x402 payment retry — the payment header value and +/// metadata for the ledger. +pub struct X402PaymentResult { + pub header_value: String, + pub amount_atomic: u64, + pub asset: String, + pub recipient: String, + pub network: String, + pub url: String, +} + +/// End-to-end 402 handler for the HTTP tool layer. Given a 402 response's +/// headers and the original URL: +/// +/// 1. Parses the PAYMENT-REQUIRED challenge +/// 2. Checks the spending budget +/// 3. Derives the wallet's Solana signing key +/// 4. Builds a partially-signed payment transaction +/// 5. Returns the encoded PAYMENT-SIGNATURE header value +/// +/// The caller retries the original request with this header attached and +/// records the payment outcome in the ledger. +pub async fn handle_402_and_pay( + response_headers: &HeaderMap, + request_url: &str, +) -> Result { + let (challenge, idx) = handle_402(response_headers)?; + let requirement = &challenge.accepts[idx]; + + let amount: u64 = requirement.amount.parse().map_err(|e| { + X402Error::Protocol(format!("invalid amount '{}': {e}", requirement.amount)) + })?; + + let budget_check = + super::store::with_ledger(|l| l.check_budget(amount)).map_err(|e| X402Error::Wallet(e))?; + + match budget_check { + super::store::BudgetCheck::Allowed => {} + super::store::BudgetCheck::ExceedsPerRequest { requested, cap } => { + return Err(X402Error::AmountExceedsCap { requested, cap }); + } + super::store::BudgetCheck::ExceedsDailyBudget { current, cap } => { + return Err(X402Error::BudgetExceeded { + period: "daily", + current, + cap, + }); + } + super::store::BudgetCheck::ExceedsMonthlyBudget { current, cap } => { + return Err(X402Error::BudgetExceeded { + period: "monthly", + current, + cap, + }); + } + } + + let signing_key = derive_wallet_signing_key().await?; + + debug!( + "{LOG_PREFIX} paying {} atomic {} to {} for {}", + amount, requirement.asset, requirement.pay_to, request_url + ); + + let header_value = try_paid_request(&signing_key, &challenge, requirement).await?; + + Ok(X402PaymentResult { + header_value, + amount_atomic: amount, + asset: requirement.asset.clone(), + recipient: requirement.pay_to.clone(), + network: requirement.network.clone(), + url: request_url.to_string(), + }) +} + +/// Derive the wallet's Solana ed25519 signing key from the encrypted mnemonic. +async fn derive_wallet_signing_key() -> Result { + use crate::openhuman::wallet::WalletChain; + + let secret = crate::openhuman::wallet::secret_material(WalletChain::Solana) + .await + .map_err(|e| X402Error::Wallet(format!("wallet secret: {e}")))?; + + let config = crate::openhuman::config::rpc::load_config_with_timeout() + .await + .map_err(|e| X402Error::Wallet(format!("load config: {e}")))?; + + let mnemonic = + crate::openhuman::encryption::rpc::decrypt_secret(&config, &secret.encrypted_mnemonic) + .await + .map_err(|e| X402Error::Wallet(format!("decrypt mnemonic: {e}")))? + .value; + + derive_solana_keypair_from_mnemonic(&mnemonic, &secret.derivation_path) +} + +fn derive_solana_keypair_from_mnemonic( + mnemonic: &str, + derivation_path: &str, +) -> Result { + use coins_bip39::{English, Mnemonic}; + use ed25519_dalek::SECRET_KEY_LENGTH; + use hmac::{Hmac, Mac}; + use sha2::Sha512; + + let mnemonic_obj: Mnemonic = mnemonic + .trim() + .parse() + .map_err(|e| X402Error::Wallet(format!("invalid mnemonic: {e}")))?; + let seed = mnemonic_obj + .to_seed(None) + .map_err(|e| X402Error::Wallet(format!("seed derivation: {e}")))?; + + // SLIP-0010 ed25519 derivation + type HmacSha512 = Hmac; + let mut mac = HmacSha512::new_from_slice(b"ed25519 seed") + .map_err(|e| X402Error::Wallet(format!("HMAC init: {e}")))?; + mac.update(&seed); + let i = mac.finalize().into_bytes(); + let mut key = [0u8; 32]; + let mut chain_code = [0u8; 32]; + key.copy_from_slice(&i[..32]); + chain_code.copy_from_slice(&i[32..]); + + let path = parse_derivation_path(derivation_path)?; + for index in path { + let hardened = index | 0x8000_0000; + let mut mac = HmacSha512::new_from_slice(&chain_code) + .map_err(|e| X402Error::Wallet(format!("HMAC init: {e}")))?; + mac.update(&[0u8]); + mac.update(&key); + mac.update(&hardened.to_be_bytes()); + let i = mac.finalize().into_bytes(); + key.copy_from_slice(&i[..32]); + chain_code.copy_from_slice(&i[32..]); + } + + let bytes: [u8; SECRET_KEY_LENGTH] = key; + Ok(SigningKey::from_bytes(&bytes)) +} + +fn parse_derivation_path(path: &str) -> Result, X402Error> { + let trimmed = path.trim(); + let mut iter = trimmed.split('/'); + match iter.next() { + Some("m") => {} + _ => { + return Err(X402Error::Wallet(format!( + "path must start with 'm': {path}" + ))) + } + } + let mut out = Vec::new(); + for seg in iter { + let stripped = seg + .strip_suffix('\'') + .ok_or_else(|| X402Error::Wallet(format!("non-hardened segment in: {path}")))?; + let v: u32 = stripped + .parse() + .map_err(|e| X402Error::Wallet(format!("invalid path segment '{seg}': {e}")))?; + out.push(v); + } + Ok(out) +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +#[derive(Debug)] +pub enum X402Error { + Transport(reqwest::Error), + NoPaymentHeader, + NoSolanaOption, + AmountExceedsCap { + requested: u64, + cap: u64, + }, + BudgetExceeded { + period: &'static str, + current: u64, + cap: u64, + }, + Protocol(String), + Wallet(String), +} + +impl std::fmt::Display for X402Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Transport(e) => write!(f, "x402 transport: {e}"), + Self::NoPaymentHeader => write!(f, "402 response missing PAYMENT-REQUIRED header"), + Self::NoSolanaOption => write!(f, "no Solana exact payment option in 402 challenge"), + Self::AmountExceedsCap { requested, cap } => { + write!(f, "x402 amount {requested} exceeds per-request cap {cap}") + } + Self::BudgetExceeded { + period, + current, + cap, + } => { + write!( + f, + "x402 {period} budget exceeded: {current}/{cap} atomic units" + ) + } + Self::Protocol(msg) => write!(f, "x402 protocol: {msg}"), + Self::Wallet(msg) => write!(f, "x402 wallet: {msg}"), + } + } +} + +impl std::error::Error for X402Error {} + +// --------------------------------------------------------------------------- +// Header parsing +// --------------------------------------------------------------------------- + +fn parse_402_headers(headers: &HeaderMap) -> Result { + let raw = headers + .get(HEADER_PAYMENT_REQUIRED) + .or_else(|| headers.get(HEADER_PAYMENT_REQUIRED_V1)) + .ok_or(X402Error::NoPaymentHeader)?; + let b64_str = raw.to_str().map_err(|e| { + X402Error::Protocol(format!("PAYMENT-REQUIRED header not valid UTF-8: {e}")) + })?; + let json_bytes = B64 + .decode(b64_str.trim()) + .map_err(|e| X402Error::Protocol(format!("PAYMENT-REQUIRED base64 decode: {e}")))?; + let challenge: PaymentRequired = serde_json::from_slice(&json_bytes) + .map_err(|e| X402Error::Protocol(format!("PAYMENT-REQUIRED JSON parse: {e}")))?; + if challenge.x402_version != X402_VERSION { + warn!( + "{LOG_PREFIX} unexpected x402 version {} (expected {X402_VERSION})", + challenge.x402_version + ); + } + Ok(challenge) +} + +fn parse_settlement_response(b64_str: &str) -> Result { + let json_bytes = B64 + .decode(b64_str.trim()) + .map_err(|e| format!("PAYMENT-RESPONSE base64 decode: {e}"))?; + serde_json::from_slice(&json_bytes).map_err(|e| format!("PAYMENT-RESPONSE JSON parse: {e}")) +} + +// --------------------------------------------------------------------------- +// Solana transaction construction +// --------------------------------------------------------------------------- + +/// Build a partially-signed Solana transaction for the `exact` scheme. +/// +/// Layout: +/// account_keys[0] = fee_payer (facilitator) — signer, writable +/// account_keys[1] = our_pubkey (transfer authority) — signer, writable +/// account_keys[2] = src_ata — writable +/// account_keys[3] = dst_ata — writable +/// account_keys[4] = mint — readonly +/// account_keys[5] = token_program — readonly +/// account_keys[6] = compute_budget_program — readonly +/// account_keys[7] = memo_program — readonly (if memo present) +/// +/// Instructions: +/// 0. SetComputeUnitLimit(DEFAULT_COMPUTE_UNITS) +/// 1. SetComputeUnitPrice(DEFAULT_COMPUTE_UNIT_PRICE) +/// 2. TransferChecked { amount, decimals=6 } +/// 3. Memo (if extra.memo set, otherwise random 16-byte hex nonce) +async fn build_solana_payment( + signing_key: &SigningKey, + challenge: &PaymentRequired, + req: &PaymentRequirements, +) -> Result { + let our_pubkey = signing_key.verifying_key().to_bytes(); + let amount: u64 = req + .amount + .parse() + .map_err(|e| X402Error::Protocol(format!("invalid amount '{}': {e}", req.amount)))?; + + let fee_payer = req + .fee_payer_pubkey() + .ok_or_else(|| X402Error::Protocol("no fee_payer in payment requirements".into()))?; + let fee_payer_bytes = b58_to_32(fee_payer)?; + let pay_to_bytes = b58_to_32(&req.pay_to)?; + let mint_bytes = b58_to_32(&req.asset)?; + + let token_program = b58_to_32(SPL_TOKEN_PROGRAM)?; + let compute_budget = b58_to_32(COMPUTE_BUDGET_PROGRAM)?; + let memo_program = b58_to_32(SPL_MEMO_PROGRAM)?; + + let src_ata = derive_ata(&our_pubkey, &mint_bytes, &token_program)?; + let dst_ata = derive_ata(&pay_to_bytes, &mint_bytes, &token_program)?; + + let memo_data = req + .memo_value() + .map(|m| m.as_bytes().to_vec()) + .unwrap_or_else(|| random_memo_nonce()); + + // -- account keys (order matters) -- + let account_keys: Vec<[u8; 32]> = vec![ + fee_payer_bytes, // 0: fee payer (signer, writable) + our_pubkey, // 1: transfer authority (signer, writable) + src_ata, // 2: source ATA (writable) + dst_ata, // 3: destination ATA (writable) + mint_bytes, // 4: mint (readonly) + token_program, // 5: SPL Token program (readonly) + compute_budget, // 6: Compute Budget program (readonly) + memo_program, // 7: SPL Memo program (readonly) + ]; + + // header: [num_required_sigs, num_readonly_signed, num_readonly_unsigned] + // 2 signers (fee_payer + us), 0 readonly signed, 4 readonly unsigned + // (mint, token_program, compute_budget, memo_program) + let header = [2u8, 0u8, 4u8]; + + // -- instructions -- + let set_cu_limit = build_set_compute_unit_limit(6, DEFAULT_COMPUTE_UNITS); + let set_cu_price = build_set_compute_unit_price(6, DEFAULT_COMPUTE_UNIT_PRICE); + let transfer_checked = build_transfer_checked( + 5, // token_program index + 2, // src_ata index + 4, // mint index + 3, // dst_ata index + 1, // authority (our_pubkey) index + amount, 6, // USDC decimals + ); + let memo = build_memo(7, &memo_data); + + let instructions = vec![set_cu_limit, set_cu_price, transfer_checked, memo]; + + // -- fetch recent blockhash -- + let blockhash = fetch_recent_blockhash_for_x402().await?; + + // -- encode message -- + let message = encode_legacy_message(&header, &account_keys, &blockhash, &instructions); + + // -- build wire: 2 signature slots, sign only ours (index 1) -- + let mut wire = Vec::with_capacity(1 + 128 + message.len()); + wire.extend(encode_shortvec(2)); // 2 required signatures + wire.extend([0u8; 64]); // slot 0: fee_payer (left zeroed for facilitator) + + let sig = signing_key.sign(&message); + wire.extend(sig.to_bytes()); // slot 1: our signature + wire.extend(&message); + + let tx_b64 = B64.encode(&wire); + debug!( + "{LOG_PREFIX} built payment tx {} bytes, amount={amount} asset={}", + wire.len(), + req.asset + ); + + Ok(PaymentPayload { + x402_version: X402_VERSION, + resource: Some(challenge.resource.clone()), + accepted: req.clone(), + payload: SolanaPaymentProof { + transaction: tx_b64, + }, + extensions: serde_json::Map::new(), + }) +} + +// --------------------------------------------------------------------------- +// Solana wire-format helpers (mirrors wallet/chains/solana.rs primitives) +// --------------------------------------------------------------------------- + +fn b58_to_32(addr: &str) -> Result<[u8; 32], X402Error> { + let v = bs58::decode(addr.trim()) + .into_vec() + .map_err(|e| X402Error::Protocol(format!("invalid base58 '{addr}': {e}")))?; + if v.len() != 32 { + return Err(X402Error::Protocol(format!( + "expected 32-byte key, got {} for '{addr}'", + v.len() + ))); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&v); + Ok(out) +} + +fn derive_ata( + owner: &[u8; 32], + mint: &[u8; 32], + token_program: &[u8; 32], +) -> Result<[u8; 32], X402Error> { + let ata_program = b58_to_32("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL")?; + for bump in (0u8..=255).rev() { + let mut hasher = Sha256::new(); + hasher.update(owner); + hasher.update(token_program); + hasher.update(mint); + hasher.update([bump]); + hasher.update(&ata_program); + hasher.update(b"ProgramDerivedAddress"); + let candidate: [u8; 32] = hasher.finalize().into(); + if curve25519_dalek::edwards::CompressedEdwardsY(candidate) + .decompress() + .is_none() + { + return Ok(candidate); + } + } + Err(X402Error::Protocol("ATA PDA derivation failed".into())) +} + +fn encode_shortvec(value: u16) -> Vec { + let mut out = Vec::new(); + let mut v = value as u32; + loop { + let mut byte = (v & 0x7f) as u8; + v >>= 7; + if v == 0 { + out.push(byte); + return out; + } + byte |= 0x80; + out.push(byte); + } +} + +struct Instruction { + program_id_index: u8, + accounts: Vec, + data: Vec, +} + +fn build_set_compute_unit_limit(program_idx: u8, units: u32) -> Instruction { + let mut data = vec![2u8]; // discriminator + data.extend(units.to_le_bytes()); + Instruction { + program_id_index: program_idx, + accounts: vec![], + data, + } +} + +fn build_set_compute_unit_price(program_idx: u8, micro_lamports: u64) -> Instruction { + let mut data = vec![3u8]; // discriminator + data.extend(micro_lamports.to_le_bytes()); + Instruction { + program_id_index: program_idx, + accounts: vec![], + data, + } +} + +fn build_transfer_checked( + token_program_idx: u8, + src_idx: u8, + mint_idx: u8, + dst_idx: u8, + authority_idx: u8, + amount: u64, + decimals: u8, +) -> Instruction { + let mut data = vec![12u8]; // SPL Token: TransferChecked = 12 + data.extend(amount.to_le_bytes()); + data.push(decimals); + Instruction { + program_id_index: token_program_idx, + accounts: vec![src_idx, mint_idx, dst_idx, authority_idx], + data, + } +} + +fn build_memo(program_idx: u8, memo_data: &[u8]) -> Instruction { + Instruction { + program_id_index: program_idx, + accounts: vec![], + data: memo_data.to_vec(), + } +} + +fn encode_instruction(ins: &Instruction) -> Vec { + let mut out = Vec::new(); + out.push(ins.program_id_index); + out.extend(encode_shortvec(ins.accounts.len() as u16)); + out.extend(&ins.accounts); + out.extend(encode_shortvec(ins.data.len() as u16)); + out.extend(&ins.data); + out +} + +fn encode_legacy_message( + header: &[u8; 3], + account_keys: &[[u8; 32]], + recent_blockhash: &[u8; 32], + instructions: &[Instruction], +) -> Vec { + let mut out = Vec::new(); + out.extend(header); + out.extend(encode_shortvec(account_keys.len() as u16)); + for key in account_keys { + out.extend(key); + } + out.extend(recent_blockhash); + out.extend(encode_shortvec(instructions.len() as u16)); + for ins in instructions { + out.extend(encode_instruction(ins)); + } + out +} + +fn random_memo_nonce() -> Vec { + use std::time::{SystemTime, UNIX_EPOCH}; + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let mut hasher = Sha256::new(); + hasher.update(ts.to_le_bytes()); + hasher.update(std::process::id().to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + hex::encode(&hash[..16]).into_bytes() +} + +async fn fetch_recent_blockhash_for_x402() -> Result<[u8; 32], X402Error> { + use crate::openhuman::wallet::WalletChain; + + #[derive(serde::Deserialize)] + struct BlockhashResponse { + value: BlockhashValue, + } + #[derive(serde::Deserialize)] + struct BlockhashValue { + blockhash: String, + } + + let result: BlockhashResponse = crate::openhuman::wallet::rpc::rpc_call( + WalletChain::Solana, + "getLatestBlockhash", + serde_json::json!([{"commitment": "finalized"}]), + ) + .await + .map_err(|e| X402Error::Wallet(format!("fetch blockhash: {e}")))?; + + b58_to_32(&result.value.blockhash) +} diff --git a/src/openhuman/x402/schemas.rs b/src/openhuman/x402/schemas.rs new file mode 100644 index 000000000..212664510 --- /dev/null +++ b/src/openhuman/x402/schemas.rs @@ -0,0 +1,179 @@ +//! Controller schemas + handlers for the `x402` namespace. +//! +//! Wires `x402_get_summary`, `x402_list_payments`, and `x402_update_budget` +//! into the global registry consumed by `src/core/all.rs`. + +use serde::Deserialize; +use serde_json::{json, Map, Value}; + +use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; + +use super::store; + +pub fn all_controller_schemas() -> Vec { + vec![ + schemas("get_summary"), + schemas("list_payments"), + schemas("update_budget"), + ] +} + +pub fn all_registered_controllers() -> Vec { + vec![ + RegisteredController { + schema: schemas("get_summary"), + handler: handle_get_summary, + }, + RegisteredController { + schema: schemas("list_payments"), + handler: handle_list_payments, + }, + RegisteredController { + schema: schemas("update_budget"), + handler: handle_update_budget, + }, + ] +} + +pub fn schemas(function: &str) -> ControllerSchema { + match function { + "get_summary" => ControllerSchema { + namespace: "x402", + function: "get_summary", + description: "Get x402 payment spending summary (session, daily, monthly totals and budget limits).", + inputs: vec![], + outputs: vec![ + FieldSchema { + name: "summary", + ty: TypeSchema::Ref("SpendingSummary"), + comment: "Spending totals for session, day, and month.", + required: true, + }, + FieldSchema { + name: "budget", + ty: TypeSchema::Ref("SpendingBudget"), + comment: "Current budget limits.", + required: true, + }, + ], + }, + "list_payments" => ControllerSchema { + namespace: "x402", + function: "list_payments", + description: "List recent x402 payment records.", + inputs: vec![FieldSchema { + name: "limit", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Max records to return (1-500, default 50).", + required: false, + }], + outputs: vec![FieldSchema { + name: "payments", + ty: TypeSchema::Array(Box::new(TypeSchema::Ref("PaymentRecord"))), + comment: "Recent payment records, newest first.", + required: true, + }], + }, + "update_budget" => ControllerSchema { + namespace: "x402", + function: "update_budget", + description: "Update x402 spending budget limits (atomic USDC units: 1 USDC = 1_000_000).", + inputs: vec![ + FieldSchema { + name: "per_request_max", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Max atomic USDC per single request.", + required: false, + }, + FieldSchema { + name: "daily_max", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Max atomic USDC per day.", + required: false, + }, + FieldSchema { + name: "monthly_max", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Max atomic USDC per month.", + required: false, + }, + ], + outputs: vec![FieldSchema { + name: "budget", + ty: TypeSchema::Ref("SpendingBudget"), + comment: "Updated budget.", + required: true, + }], + }, + other => panic!("unknown x402 schema function: {other}"), + } +} + +fn handle_get_summary(_params: Map) -> ControllerFuture { + Box::pin(async move { + let (summary, budget) = store::with_ledger(|l| (l.summary(), l.budget().clone())) + .map_err(|e| format!("x402 get_summary: {e}"))?; + + Ok(json!({ + "summary": summary, + "budget": budget, + })) + }) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListPaymentsParams { + #[serde(default)] + limit: Option, +} + +fn handle_list_payments(params: Map) -> ControllerFuture { + Box::pin(async move { + let p: ListPaymentsParams = serde_json::from_value(Value::Object(params)) + .map_err(|e| format!("x402 list_payments params: {e}"))?; + + let limit = p.limit.unwrap_or(50).min(500) as usize; + let payments = store::with_ledger(|l| l.recent_payments(limit)) + .map_err(|e| format!("x402 list_payments: {e}"))?; + + Ok(json!({ "payments": payments })) + }) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UpdateBudgetParams { + #[serde(default)] + per_request_max: Option, + #[serde(default)] + daily_max: Option, + #[serde(default)] + monthly_max: Option, +} + +fn handle_update_budget(params: Map) -> ControllerFuture { + Box::pin(async move { + let p: UpdateBudgetParams = serde_json::from_value(Value::Object(params)) + .map_err(|e| format!("x402 update_budget params: {e}"))?; + + let budget = store::with_ledger_mut(|l| { + let mut b = l.budget().clone(); + if let Some(v) = p.per_request_max { + b.per_request_max_atomic = v; + } + if let Some(v) = p.daily_max { + b.daily_max_atomic = v; + } + if let Some(v) = p.monthly_max { + b.monthly_max_atomic = v; + } + l.update_budget(b.clone()); + b + }) + .map_err(|e| format!("x402 update_budget: {e}"))?; + + Ok(json!({ "budget": budget })) + }) +} diff --git a/src/openhuman/x402/store.rs b/src/openhuman/x402/store.rs new file mode 100644 index 000000000..e321611ed --- /dev/null +++ b/src/openhuman/x402/store.rs @@ -0,0 +1,449 @@ +//! x402 payment ledger — append-only JSONL persistence for payment records +//! with session/daily/monthly budget enforcement. + +use std::fs::{self, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Datelike, Utc}; +use log::{debug, warn}; +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; + +const LOG_PREFIX: &str = "[x402::store]"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentRecord { + pub id: String, + pub url: String, + pub asset: String, + pub amount_atomic: u64, + pub amount_display: String, + pub recipient: String, + pub network: String, + pub tx_signature: Option, + pub status: PaymentStatus, + pub timestamp: DateTime, + pub session_id: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaymentStatus { + Pending, + Settled, + Failed, + Denied, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpendingSummary { + pub session_total_atomic: u64, + pub daily_total_atomic: u64, + pub monthly_total_atomic: u64, + pub session_count: usize, + pub daily_count: usize, + pub monthly_count: usize, +} + +impl Default for SpendingSummary { + fn default() -> Self { + Self { + session_total_atomic: 0, + daily_total_atomic: 0, + monthly_total_atomic: 0, + session_count: 0, + daily_count: 0, + monthly_count: 0, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpendingBudget { + pub per_request_max_atomic: u64, + pub daily_max_atomic: u64, + pub monthly_max_atomic: u64, +} + +impl Default for SpendingBudget { + fn default() -> Self { + Self { + // 1 USDC per request + per_request_max_atomic: 1_000_000, + // 10 USDC per day + daily_max_atomic: 10_000_000, + // 100 USDC per month + monthly_max_atomic: 100_000_000, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BudgetCheck { + Allowed, + ExceedsPerRequest { requested: u64, cap: u64 }, + ExceedsDailyBudget { current: u64, cap: u64 }, + ExceedsMonthlyBudget { current: u64, cap: u64 }, +} + +pub struct PaymentLedger { + records: Vec, + file_path: PathBuf, + budget: SpendingBudget, + session_id: String, +} + +static GLOBAL_LEDGER: Lazy>> = Lazy::new(|| Mutex::new(None)); + +impl PaymentLedger { + pub fn new(workspace_dir: &Path, session_id: &str, budget: SpendingBudget) -> Self { + let file_path = workspace_dir.join("x402").join("payments.jsonl"); + let records = Self::load_from_disk(&file_path); + debug!( + "{LOG_PREFIX} loaded {} existing payment records from {}", + records.len(), + file_path.display() + ); + Self { + records, + file_path, + budget, + session_id: session_id.to_string(), + } + } + + fn load_from_disk(path: &Path) -> Vec { + let file = match fs::File::open(path) { + Ok(f) => f, + Err(_) => return Vec::new(), + }; + let reader = BufReader::new(file); + let mut records = Vec::new(); + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(e) => { + warn!("{LOG_PREFIX} read error: {e}"); + continue; + } + }; + if line.trim().is_empty() { + continue; + } + match serde_json::from_str::(&line) { + Ok(r) => records.push(r), + Err(e) => warn!("{LOG_PREFIX} corrupt record: {e}"), + } + } + records + } + + fn append_to_disk(&self, record: &PaymentRecord) { + if let Some(parent) = self.file_path.parent() { + if let Err(e) = fs::create_dir_all(parent) { + warn!("{LOG_PREFIX} mkdir failed: {e}"); + return; + } + } + let mut file = match OpenOptions::new() + .create(true) + .append(true) + .open(&self.file_path) + { + Ok(f) => f, + Err(e) => { + warn!("{LOG_PREFIX} open failed: {e}"); + return; + } + }; + match serde_json::to_string(record) { + Ok(json) => { + if let Err(e) = writeln!(file, "{json}") { + warn!("{LOG_PREFIX} write failed: {e}"); + } + } + Err(e) => warn!("{LOG_PREFIX} serialize failed: {e}"), + } + } + + pub fn check_budget(&self, amount: u64) -> BudgetCheck { + if amount > self.budget.per_request_max_atomic { + return BudgetCheck::ExceedsPerRequest { + requested: amount, + cap: self.budget.per_request_max_atomic, + }; + } + + let now = Utc::now(); + let today = now.date_naive(); + let this_month = (now.year(), now.month()); + + let daily: u64 = self + .records + .iter() + .filter(|r| r.status == PaymentStatus::Settled && r.timestamp.date_naive() == today) + .map(|r| r.amount_atomic) + .sum(); + + if daily.saturating_add(amount) > self.budget.daily_max_atomic { + return BudgetCheck::ExceedsDailyBudget { + current: daily, + cap: self.budget.daily_max_atomic, + }; + } + + let monthly: u64 = self + .records + .iter() + .filter(|r| { + r.status == PaymentStatus::Settled && { + let ts = r.timestamp; + (ts.year(), ts.month()) == this_month + } + }) + .map(|r| r.amount_atomic) + .sum(); + + if monthly.saturating_add(amount) > self.budget.monthly_max_atomic { + return BudgetCheck::ExceedsMonthlyBudget { + current: monthly, + cap: self.budget.monthly_max_atomic, + }; + } + + BudgetCheck::Allowed + } + + pub fn record_payment(&mut self, record: PaymentRecord) { + self.append_to_disk(&record); + self.records.push(record); + } + + pub fn summary(&self) -> SpendingSummary { + let now = Utc::now(); + let today = now.date_naive(); + let this_month = (now.year(), now.month()); + + let settled: Vec<&PaymentRecord> = self + .records + .iter() + .filter(|r| r.status == PaymentStatus::Settled) + .collect(); + + let session: Vec<&&PaymentRecord> = settled + .iter() + .filter(|r| r.session_id == self.session_id) + .collect(); + + let daily: Vec<&&PaymentRecord> = settled + .iter() + .filter(|r| r.timestamp.date_naive() == today) + .collect(); + + let monthly: Vec<&&PaymentRecord> = settled + .iter() + .filter(|r| { + let ts = r.timestamp; + (ts.year(), ts.month()) == this_month + }) + .collect(); + + SpendingSummary { + session_total_atomic: session.iter().map(|r| r.amount_atomic).sum(), + daily_total_atomic: daily.iter().map(|r| r.amount_atomic).sum(), + monthly_total_atomic: monthly.iter().map(|r| r.amount_atomic).sum(), + session_count: session.len(), + daily_count: daily.len(), + monthly_count: monthly.len(), + } + } + + pub fn recent_payments(&self, limit: usize) -> Vec { + self.records.iter().rev().take(limit).cloned().collect() + } + + pub fn budget(&self) -> &SpendingBudget { + &self.budget + } + + pub fn update_budget(&mut self, budget: SpendingBudget) { + debug!( + "{LOG_PREFIX} budget updated per_request={} daily={} monthly={}", + budget.per_request_max_atomic, budget.daily_max_atomic, budget.monthly_max_atomic + ); + self.budget = budget; + } +} + +pub fn init_global(workspace_dir: &Path, session_id: &str) { + let budget = budget_from_env(); + let ledger = PaymentLedger::new(workspace_dir, session_id, budget); + *GLOBAL_LEDGER.lock() = Some(ledger); + debug!("{LOG_PREFIX} global ledger initialized"); +} + +fn budget_from_env() -> SpendingBudget { + let mut budget = SpendingBudget::default(); + if let Ok(v) = std::env::var("OPENHUMAN_X402_PER_REQUEST_MAX") { + if let Ok(n) = v.parse::() { + debug!("{LOG_PREFIX} env override per_request_max={n}"); + budget.per_request_max_atomic = n; + } + } + if let Ok(v) = std::env::var("OPENHUMAN_X402_DAILY_MAX") { + if let Ok(n) = v.parse::() { + debug!("{LOG_PREFIX} env override daily_max={n}"); + budget.daily_max_atomic = n; + } + } + if let Ok(v) = std::env::var("OPENHUMAN_X402_MONTHLY_MAX") { + if let Ok(n) = v.parse::() { + debug!("{LOG_PREFIX} env override monthly_max={n}"); + budget.monthly_max_atomic = n; + } + } + budget +} + +pub fn with_ledger(f: F) -> Result +where + F: FnOnce(&PaymentLedger) -> R, +{ + let guard = GLOBAL_LEDGER.lock(); + let ledger = guard + .as_ref() + .ok_or_else(|| "x402 payment ledger not initialized".to_string())?; + Ok(f(ledger)) +} + +pub fn with_ledger_mut(f: F) -> Result +where + F: FnOnce(&mut PaymentLedger) -> R, +{ + let mut guard = GLOBAL_LEDGER.lock(); + let ledger = guard + .as_mut() + .ok_or_else(|| "x402 payment ledger not initialized".to_string())?; + Ok(f(ledger)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_budget() -> SpendingBudget { + SpendingBudget { + per_request_max_atomic: 500_000, + daily_max_atomic: 2_000_000, + monthly_max_atomic: 10_000_000, + } + } + + const TEST_SESSION: &str = "test"; + + fn make_record(amount: u64, status: PaymentStatus) -> PaymentRecord { + PaymentRecord { + id: uuid::Uuid::new_v4().to_string(), + url: "https://api.example.com/data".into(), + asset: "USDC".into(), + amount_atomic: amount, + amount_display: format!("{:.6} USDC", amount as f64 / 1_000_000.0), + recipient: "RecipientPubkey".into(), + network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp".into(), + tx_signature: Some("sig123".into()), + status, + timestamp: Utc::now(), + session_id: TEST_SESSION.into(), + } + } + + #[test] + fn budget_check_allows_within_limits() { + let ledger = PaymentLedger { + records: vec![], + file_path: PathBuf::from("/tmp/test-x402.jsonl"), + budget: test_budget(), + session_id: "test".into(), + }; + assert_eq!(ledger.check_budget(100_000), BudgetCheck::Allowed); + } + + #[test] + fn budget_check_rejects_over_per_request() { + let ledger = PaymentLedger { + records: vec![], + file_path: PathBuf::from("/tmp/test-x402.jsonl"), + budget: test_budget(), + session_id: "test".into(), + }; + assert_eq!( + ledger.check_budget(600_000), + BudgetCheck::ExceedsPerRequest { + requested: 600_000, + cap: 500_000 + } + ); + } + + #[test] + fn budget_check_rejects_over_daily() { + let mut ledger = PaymentLedger { + records: vec![], + file_path: PathBuf::from("/tmp/test-x402.jsonl"), + budget: test_budget(), + session_id: "test".into(), + }; + ledger + .records + .push(make_record(1_800_000, PaymentStatus::Settled)); + assert_eq!( + ledger.check_budget(400_000), + BudgetCheck::ExceedsDailyBudget { + current: 1_800_000, + cap: 2_000_000 + } + ); + } + + #[test] + fn budget_check_ignores_failed_payments() { + let mut ledger = PaymentLedger { + records: vec![], + file_path: PathBuf::from("/tmp/test-x402.jsonl"), + budget: test_budget(), + session_id: "test".into(), + }; + ledger + .records + .push(make_record(1_800_000, PaymentStatus::Failed)); + assert_eq!(ledger.check_budget(400_000), BudgetCheck::Allowed); + } + + #[test] + fn summary_aggregates_correctly() { + let mut ledger = PaymentLedger { + records: vec![], + file_path: PathBuf::from("/tmp/test-x402.jsonl"), + budget: test_budget(), + session_id: "test".into(), + }; + ledger + .records + .push(make_record(100_000, PaymentStatus::Settled)); + ledger + .records + .push(make_record(200_000, PaymentStatus::Settled)); + ledger + .records + .push(make_record(50_000, PaymentStatus::Failed)); + + let summary = ledger.summary(); + assert_eq!(summary.session_total_atomic, 300_000); + assert_eq!(summary.session_count, 2); + } +} diff --git a/src/openhuman/x402/types.rs b/src/openhuman/x402/types.rs new file mode 100644 index 000000000..aee4cab77 --- /dev/null +++ b/src/openhuman/x402/types.rs @@ -0,0 +1,154 @@ +//! Wire types for the x402 protocol (v2). +//! +//! All header payloads are standard-base64-encoded JSON. Network identifiers +//! use CAIP-2 format (e.g. `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`). + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +pub const X402_VERSION: u8 = 2; + +pub const HEADER_PAYMENT_REQUIRED: &str = "PAYMENT-REQUIRED"; +pub const HEADER_PAYMENT_REQUIRED_V1: &str = "X-PAYMENT-REQUIRED"; +pub const HEADER_PAYMENT_SIGNATURE: &str = "PAYMENT-SIGNATURE"; +pub const HEADER_PAYMENT_SIGNATURE_V1: &str = "X-PAYMENT"; +pub const HEADER_PAYMENT_RESPONSE: &str = "PAYMENT-RESPONSE"; + +pub const SOLANA_MAINNET_CAIP2: &str = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"; +pub const SOLANA_DEVNET_CAIP2: &str = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; + +pub const USDC_MINT_MAINNET: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; +pub const USDC_MINT_DEVNET: &str = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; + +pub const SPL_TOKEN_PROGRAM: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; +pub const SPL_MEMO_PROGRAM: &str = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"; +pub const COMPUTE_BUDGET_PROGRAM: &str = "ComputeBudget111111111111111111111111111111"; + +// --------------------------------------------------------------------------- +// 402 challenge — server → client (PAYMENT-REQUIRED header) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentRequired { + pub x402_version: u8, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + pub resource: ResourceInfo, + pub accepts: Vec, + #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] + pub extensions: serde_json::Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourceInfo { + pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mime_type: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentRequirements { + pub scheme: String, + pub network: String, + /// Amount in atomic token units (e.g. 1 USDC = 1_000_000). + pub amount: String, + /// Token mint address (Solana) or contract address (EVM). + pub asset: String, + /// Recipient wallet address. + pub pay_to: String, + pub max_timeout_seconds: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extra: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentExtra { + /// Facilitator pubkey that will co-sign as fee payer. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fee_payer: Option, + /// Required memo value for transaction uniqueness. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +// --------------------------------------------------------------------------- +// Payment proof — client → server (PAYMENT-SIGNATURE header) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentPayload { + pub x402_version: u8, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource: Option, + pub accepted: PaymentRequirements, + pub payload: SolanaPaymentProof, + #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] + pub extensions: serde_json::Map, +} + +/// Solana `exact` scheme payload — a partially-signed `VersionedTransaction` +/// serialized as standard base64. The facilitator adds its fee-payer signature +/// and broadcasts. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SolanaPaymentProof { + pub transaction: String, +} + +// --------------------------------------------------------------------------- +// Settlement response — server → client (PAYMENT-RESPONSE header) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SettlementResponse { + pub success: bool, + /// Base58 transaction signature (Solana) or hex tx hash (EVM). + pub transaction: String, + pub network: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payer: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub amount: Option, + #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] + pub extensions: serde_json::Map, +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +impl PaymentRequired { + /// Find the first `accepts` entry whose network starts with `"solana:"` and + /// whose scheme is `"exact"`. + pub fn solana_exact_requirement(&self) -> Option<&PaymentRequirements> { + self.accepts + .iter() + .find(|r| r.scheme == "exact" && r.network.starts_with("solana:")) + } +} + +impl PaymentRequirements { + pub fn is_solana_mainnet(&self) -> bool { + self.network == SOLANA_MAINNET_CAIP2 + } + + pub fn fee_payer_pubkey(&self) -> Option<&str> { + self.extra.as_ref()?.fee_payer.as_deref() + } + + pub fn memo_value(&self) -> Option<&str> { + self.extra.as_ref()?.memo.as_deref() + } +} diff --git a/src/openhuman/x402/x402_tests.rs b/src/openhuman/x402/x402_tests.rs new file mode 100644 index 000000000..1ab7e3e55 --- /dev/null +++ b/src/openhuman/x402/x402_tests.rs @@ -0,0 +1,189 @@ +use base64::engine::{general_purpose::STANDARD as B64, Engine as _}; +use serde_json::json; + +use super::types::*; + +#[test] +fn parse_payment_required_round_trips() { + let challenge = PaymentRequired { + x402_version: 2, + error: Some("PAYMENT-SIGNATURE header is required".into()), + resource: ResourceInfo { + url: "https://api.example.com/data".into(), + description: Some("Premium data".into()), + mime_type: Some("application/json".into()), + }, + accepts: vec![PaymentRequirements { + scheme: "exact".into(), + network: SOLANA_MAINNET_CAIP2.into(), + amount: "10000".into(), + asset: USDC_MINT_MAINNET.into(), + pay_to: "2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4".into(), + max_timeout_seconds: 60, + extra: Some(PaymentExtra { + fee_payer: Some("EwWqGE4ZFKLofuestmU4LDdK7XM1N4ALgdZccwYugwGd".into()), + memo: Some("pi_3abc123".into()), + }), + }], + extensions: serde_json::Map::new(), + }; + let json_str = serde_json::to_string(&challenge).unwrap(); + let parsed: PaymentRequired = serde_json::from_str(&json_str).unwrap(); + assert_eq!(parsed.x402_version, 2); + assert_eq!(parsed.accepts.len(), 1); + assert_eq!(parsed.accepts[0].amount, "10000"); + assert_eq!( + parsed.accepts[0].pay_to, + "2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4" + ); +} + +#[test] +fn solana_exact_requirement_finds_match() { + let challenge = PaymentRequired { + x402_version: 2, + error: None, + resource: ResourceInfo { + url: "https://example.com".into(), + description: None, + mime_type: None, + }, + accepts: vec![ + PaymentRequirements { + scheme: "exact".into(), + network: "eip155:84532".into(), + amount: "100".into(), + asset: "0xUsdc".into(), + pay_to: "0xRecipient".into(), + max_timeout_seconds: 30, + extra: None, + }, + PaymentRequirements { + scheme: "exact".into(), + network: SOLANA_MAINNET_CAIP2.into(), + amount: "5000".into(), + asset: USDC_MINT_MAINNET.into(), + pay_to: "SomeRecipient".into(), + max_timeout_seconds: 60, + extra: None, + }, + ], + extensions: serde_json::Map::new(), + }; + let sol = challenge.solana_exact_requirement().unwrap(); + assert_eq!(sol.amount, "5000"); + assert!(sol.is_solana_mainnet()); +} + +#[test] +fn solana_exact_requirement_returns_none_when_absent() { + let challenge = PaymentRequired { + x402_version: 2, + error: None, + resource: ResourceInfo { + url: "https://example.com".into(), + description: None, + mime_type: None, + }, + accepts: vec![PaymentRequirements { + scheme: "exact".into(), + network: "eip155:1".into(), + amount: "100".into(), + asset: "0xUsdc".into(), + pay_to: "0xRecipient".into(), + max_timeout_seconds: 30, + extra: None, + }], + extensions: serde_json::Map::new(), + }; + assert!(challenge.solana_exact_requirement().is_none()); +} + +#[test] +fn payment_extra_accessors() { + let req = PaymentRequirements { + scheme: "exact".into(), + network: SOLANA_MAINNET_CAIP2.into(), + amount: "1000".into(), + asset: USDC_MINT_MAINNET.into(), + pay_to: "Recipient".into(), + max_timeout_seconds: 60, + extra: Some(PaymentExtra { + fee_payer: Some("FeePayer123".into()), + memo: Some("order_456".into()), + }), + }; + assert_eq!(req.fee_payer_pubkey(), Some("FeePayer123")); + assert_eq!(req.memo_value(), Some("order_456")); +} + +#[test] +fn payment_extra_accessors_none() { + let req = PaymentRequirements { + scheme: "exact".into(), + network: SOLANA_MAINNET_CAIP2.into(), + amount: "1000".into(), + asset: USDC_MINT_MAINNET.into(), + pay_to: "Recipient".into(), + max_timeout_seconds: 60, + extra: None, + }; + assert_eq!(req.fee_payer_pubkey(), None); + assert_eq!(req.memo_value(), None); +} + +#[test] +fn settlement_response_deserializes_success() { + let json_str = r#"{ + "success": true, + "transaction": "4vJ9YFuPzUgdLkWYJf3Kqf", + "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "payer": "EwWqGE4ZFKLofuestmU4LDdK7XM1N4ALgdZccwYugwGd" + }"#; + let resp: SettlementResponse = serde_json::from_str(json_str).unwrap(); + assert!(resp.success); + assert_eq!(resp.transaction, "4vJ9YFuPzUgdLkWYJf3Kqf"); + assert!(resp.error_reason.is_none()); +} + +#[test] +fn settlement_response_deserializes_failure() { + let json_str = r#"{ + "success": false, + "transaction": "", + "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "payer": "EwWqGE4ZFKLofuestmU4LDdK7XM1N4ALgdZccwYugwGd", + "errorReason": "insufficient_funds" + }"#; + let resp: SettlementResponse = serde_json::from_str(json_str).unwrap(); + assert!(!resp.success); + assert_eq!(resp.error_reason.as_deref(), Some("insufficient_funds")); +} + +#[test] +fn base64_header_round_trip() { + let challenge = PaymentRequired { + x402_version: 2, + error: None, + resource: ResourceInfo { + url: "https://example.com/api".into(), + description: None, + mime_type: None, + }, + accepts: vec![PaymentRequirements { + scheme: "exact".into(), + network: SOLANA_MAINNET_CAIP2.into(), + amount: "1000000".into(), + asset: USDC_MINT_MAINNET.into(), + pay_to: "RecipientPubkey".into(), + max_timeout_seconds: 60, + extra: None, + }], + extensions: serde_json::Map::new(), + }; + let json_bytes = serde_json::to_vec(&challenge).unwrap(); + let encoded = B64.encode(&json_bytes); + let decoded = B64.decode(&encoded).unwrap(); + let parsed: PaymentRequired = serde_json::from_slice(&decoded).unwrap(); + assert_eq!(parsed.accepts[0].amount, "1000000"); +}