feat(x402): add x402 payment protocol for machine-payable APIs (#3460)

This commit is contained in:
Steven Enamakel
2026-06-06 20:00:08 -04:00
committed by GitHub
parent f6917f2ece
commit 0fdd0b7805
13 changed files with 1900 additions and 39 deletions
+10
View File
@@ -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
# ---------------------------------------------------------------------------
+3
View File
@@ -151,6 +151,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
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<ControllerSchema> {
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());
+9
View File
@@ -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 `<workspace>/agents/*.toml`. Idempotent — safe to call
+10
View File
@@ -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",
+1
View File
@@ -125,3 +125,4 @@ pub mod webview_notifications;
pub mod whatsapp_data;
pub mod workflows;
pub mod workspace;
pub mod x402;
+158 -38
View File
@@ -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<reqwest::Response, String> {
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::<x402::SettlementResponse>(&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<ToolResult> {
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::<Vec<_>>()
.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::<Vec<_>>()
.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
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ mod chains;
mod defaults;
mod execution;
mod ops;
mod rpc;
pub(crate) mod rpc;
mod schemas;
pub mod tools;
+27
View File
@@ -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: <https://x402.org> / 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,
};
+710
View File
@@ -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<u64>,
) -> Result<reqwest::Response, X402Error> {
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<String, X402Error> {
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<X402PaymentResult, X402Error> {
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<SigningKey, X402Error> {
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<SigningKey, X402Error> {
use coins_bip39::{English, Mnemonic};
use ed25519_dalek::SECRET_KEY_LENGTH;
use hmac::{Hmac, Mac};
use sha2::Sha512;
let mnemonic_obj: Mnemonic<English> = 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<Sha512>;
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<Vec<u32>, 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<PaymentRequired, X402Error> {
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<SettlementResponse, String> {
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<PaymentPayload, X402Error> {
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<u8> {
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<u8>,
data: Vec<u8>,
}
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<u8> {
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<u8> {
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<u8> {
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)
}
+179
View File
@@ -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<ControllerSchema> {
vec![
schemas("get_summary"),
schemas("list_payments"),
schemas("update_budget"),
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
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<String, Value>) -> 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<u64>,
}
fn handle_list_payments(params: Map<String, Value>) -> 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<u64>,
#[serde(default)]
daily_max: Option<u64>,
#[serde(default)]
monthly_max: Option<u64>,
}
fn handle_update_budget(params: Map<String, Value>) -> 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 }))
})
}
+449
View File
@@ -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<String>,
pub status: PaymentStatus,
pub timestamp: DateTime<Utc>,
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<PaymentRecord>,
file_path: PathBuf,
budget: SpendingBudget,
session_id: String,
}
static GLOBAL_LEDGER: Lazy<Mutex<Option<PaymentLedger>>> = 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<PaymentRecord> {
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::<PaymentRecord>(&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<PaymentRecord> {
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::<u64>() {
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::<u64>() {
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::<u64>() {
debug!("{LOG_PREFIX} env override monthly_max={n}");
budget.monthly_max_atomic = n;
}
}
budget
}
pub fn with_ledger<F, R>(f: F) -> Result<R, String>
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, R>(f: F) -> Result<R, String>
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);
}
}
+154
View File
@@ -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<String>,
pub resource: ResourceInfo,
pub accepts: Vec<PaymentRequirements>,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extensions: serde_json::Map<String, serde_json::Value>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
}
#[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<PaymentExtra>,
}
#[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<String>,
/// Required memo value for transaction uniqueness.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memo: Option<String>,
}
// ---------------------------------------------------------------------------
// 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<ResourceInfo>,
pub accepted: PaymentRequirements,
pub payload: SolanaPaymentProof,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extensions: serde_json::Map<String, serde_json::Value>,
}
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub amount: Option<String>,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extensions: serde_json::Map<String, serde_json::Value>,
}
// ---------------------------------------------------------------------------
// 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()
}
}
+189
View File
@@ -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");
}