mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(x402): add x402 payment protocol for machine-payable APIs (#3460)
This commit is contained in:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -125,3 +125,4 @@ pub mod webview_notifications;
|
||||
pub mod whatsapp_data;
|
||||
pub mod workflows;
|
||||
pub mod workspace;
|
||||
pub mod x402;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ mod chains;
|
||||
mod defaults;
|
||||
mod execution;
|
||||
mod ops;
|
||||
mod rpc;
|
||||
pub(crate) mod rpc;
|
||||
mod schemas;
|
||||
pub mod tools;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }))
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user