feat(x402): add EVM/Base chain support and dedicated agent tool (#3564)

This commit is contained in:
Steven Enamakel
2026-06-09 17:36:47 -07:00
committed by GitHub
parent 1cffc8891a
commit 6a194a2be3
10 changed files with 1209 additions and 35 deletions
@@ -1,7 +1,7 @@
id = "crypto_agent"
display_name = "Crypto Agent"
delegate_name = "do_crypto"
when_to_use = "Crypto wallet & market specialist — drives wallet balances, quotes, transfers, swaps, contract calls, and exchange trading workflows on the user's connected wallet identities. Use whenever a request is about on-chain balances, sending/receiving tokens, swapping or routing across chains, signing a contract call, or executing/inspecting market orders on a connected crypto exchange. Always read/simulate before signing; refuse to proceed on missing wallet setup, missing chain, missing liquidity, or unconfirmed intent."
when_to_use = "Crypto wallet & market specialist — drives wallet balances, quotes, transfers, swaps, contract calls, exchange trading workflows, and x402 paid API requests on the user's connected wallet identities. Use whenever a request is about on-chain balances, sending/receiving tokens, swapping or routing across chains, signing a contract call, executing/inspecting market orders on a connected crypto exchange, or making HTTP requests to x402-payable APIs (HTTP 402 Payment Required with on-chain USDC settlement). Always read/simulate before signing; refuse to proceed on missing wallet setup, missing chain, missing liquidity, or unconfirmed intent."
temperature = 0.2
max_iterations = 8
sandbox_mode = "none"
@@ -73,4 +73,8 @@ named = [
# Time grounding for "as of <when>" framing and freshness checks on
# quotes before execute.
"current_time",
# x402 HTTP 402 payment protocol — makes paid API requests by signing
# an on-chain payment (EVM EIP-3009 on Base/Ethereum or Solana SPL)
# and retrying with a PAYMENT-SIGNATURE header.
"x402_request",
]
@@ -8,12 +8,13 @@ You are the **Crypto Agent** — OpenHuman's specialist for wallet and market op
- Quoting transfers, swaps and contract calls; surfacing fees, slippage and the destination route.
- Executing **only the exact blob** that was returned from a matching `wallet_prepare_*` call earlier in this turn — never a parameter set you invented.
- Pulling crypto / FX market data to sanity-check a quote before signing.
- Making paid API requests via the **x402 protocol** (HTTP 402 Payment Required). When a server returns 402 with a `PAYMENT-REQUIRED` header, `x402_request` automatically signs a USDC payment (EIP-3009 on Base/Ethereum, or SPL transfer on Solana) and retries with the proof. Use this for x402-enabled APIs (e.g. twit.sh). The wallet must have USDC on the target chain.
- Pointing the user back to **Settings → Connections** when a chain, exchange, or wallet identity isn't set up.
## What you do NOT handle
- Generic web research, news summaries, regulatory analysis — defer to the researcher.
- Code writing, file edits, shell access, broad HTTP. You have no shell, no file_write, no curl.
- Code writing, file edits, shell access, broad HTTP. You have no shell, no file_write, no curl. (For x402-payable endpoints, use `x402_request` — not generic HTTP tools.)
- Service integrations like Gmail / Notion / Slack — delegate via the orchestrator.
- Autonomous background trading. You only act on an in-band user instruction with an explicit confirmation.
@@ -876,6 +876,12 @@ mod tests {
"crypto_agent needs supporting tool `{required}`"
);
}
// x402 paid HTTP requests — signs on-chain USDC payments
// for APIs behind HTTP 402 challenges.
assert!(
tools.iter().any(|t| t == "x402_request"),
"crypto_agent needs x402_request for paid API access"
);
assert!(!tools.iter().any(|t| t == "call_memory_agent"));
// Hard exclusions — no broad-surface or write-anywhere tools.
// Includes the orchestrator-level delegate_* tools so a future
+7
View File
@@ -546,6 +546,13 @@ pub fn all_tools_with_runtime(
http_config.timeout_secs,
)));
// x402 — dedicated tool for making paid HTTP requests to x402-enabled
// APIs (Base USDC / Solana USDC). Handles the 402 challenge, EIP-3009
// or SPL payment signing, and ledger recording.
tools.push(Box::new(
crate::openhuman::x402::tools::X402RequestTool::new(),
));
// Coding-harness baseline `web_fetch` (issue #1205) — single-purpose
// GET-and-read primitive that reuses the same allowed-domains gate
// as `http_request`. Use this for docs/READMEs; reach for
+3 -1
View File
@@ -11,6 +11,7 @@
mod ops;
mod schemas;
pub(crate) mod store;
pub mod tools;
mod types;
#[cfg(test)]
@@ -23,5 +24,6 @@ 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,
EvmAuthorization, EvmPaymentProof, PaymentChain, PaymentPayload, PaymentProof, PaymentRequired,
PaymentRequirements, ResourceInfo, SettlementResponse, SolanaPaymentProof,
};
+312 -29
View File
@@ -1,14 +1,15 @@
//! x402 client operations — parse 402 challenges, build Solana payment
//! transactions, sign, and retry with proof.
//! x402 client operations — parse 402 challenges, build payment transactions
//! (Solana SPL or EVM ERC-20), sign, and retry with proof.
//!
//! Transaction layout for the `exact` Solana scheme:
//! Solana `exact` scheme layout:
//! 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.
//! EVM `exact` scheme:
//! EIP-3009 `transferWithAuthorization` signed by the wallet's EVM key.
//! The facilitator submits the signed authorization on-chain.
use base64::engine::{general_purpose::STANDARD as B64, Engine as _};
use ed25519_dalek::{Signer, SigningKey};
@@ -77,9 +78,9 @@ impl X402Client {
challenge.accepts.len()
);
let requirement = challenge
.solana_exact_requirement()
.ok_or_else(|| X402Error::NoSolanaOption)?;
let (requirement, chain) = challenge
.best_exact_requirement()
.ok_or_else(|| X402Error::NoPaymentOption)?;
let amount: u64 = requirement.amount.parse().map_err(|e| {
X402Error::Protocol(format!("invalid amount '{}': {e}", requirement.amount))
@@ -95,14 +96,20 @@ impl X402Client {
}
debug!(
"{LOG_PREFIX} paying {} atomic units of {} to {} (fee_payer={:?})",
"{LOG_PREFIX} paying {} atomic units of {} to {} chain={:?} (fee_payer={:?})",
amount,
requirement.asset,
requirement.pay_to,
chain,
requirement.fee_payer_pubkey(),
);
let payment = build_solana_payment(signing_key, &challenge, requirement).await?;
let payment = match chain {
PaymentChain::Solana => {
build_solana_payment(signing_key, &challenge, requirement).await?
}
PaymentChain::Evm => build_evm_payment(&challenge, requirement).await?,
};
let encoded = B64.encode(serde_json::to_string(&payment).unwrap());
let mut retry_req = self.http.request(method, url);
@@ -141,16 +148,28 @@ impl X402Client {
}
/// 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> {
/// challenge with the index of the best payment option and its chain family.
pub fn handle_402(
headers: &HeaderMap,
) -> Result<(PaymentRequired, usize, PaymentChain), X402Error> {
let challenge = parse_402_headers(headers)?;
let idx = challenge
// Prefer Solana (lower fees, faster finality), fall back to EVM
let (idx, chain) = challenge
.accepts
.iter()
.position(|r| r.scheme == "exact" && r.network.starts_with("solana:"))
.ok_or(X402Error::NoSolanaOption)?;
Ok((challenge, idx))
.enumerate()
.find(|(_, r)| r.scheme == "exact" && r.network.starts_with("solana:"))
.map(|(i, _)| (i, PaymentChain::Solana))
.or_else(|| {
challenge
.accepts
.iter()
.enumerate()
.find(|(_, r)| r.scheme == "exact" && r.network.starts_with("eip155:"))
.map(|(i, _)| (i, PaymentChain::Evm))
})
.ok_or(X402Error::NoPaymentOption)?;
Ok((challenge, idx, chain))
}
/// Build a payment and return the encoded header value ready to attach.
@@ -161,7 +180,15 @@ pub async fn try_paid_request(
challenge: &PaymentRequired,
requirement: &PaymentRequirements,
) -> Result<String, X402Error> {
let payment = build_solana_payment(signing_key, challenge, requirement).await?;
let chain = if requirement.network.starts_with("eip155:") {
PaymentChain::Evm
} else {
PaymentChain::Solana
};
let payment = match chain {
PaymentChain::Solana => build_solana_payment(signing_key, challenge, requirement).await?,
PaymentChain::Evm => build_evm_payment(challenge, requirement).await?,
};
let json = serde_json::to_string(&payment)
.map_err(|e| X402Error::Protocol(format!("serialize payment: {e}")))?;
Ok(B64.encode(json))
@@ -183,7 +210,7 @@ pub struct X402PaymentResult {
///
/// 1. Parses the PAYMENT-REQUIRED challenge
/// 2. Checks the spending budget
/// 3. Derives the wallet's Solana signing key
/// 3. Derives the wallet's signing key (Solana preferred, EVM fallback)
/// 4. Builds a partially-signed payment transaction
/// 5. Returns the encoded PAYMENT-SIGNATURE header value
///
@@ -193,7 +220,7 @@ pub async fn handle_402_and_pay(
response_headers: &HeaderMap,
request_url: &str,
) -> Result<X402PaymentResult, X402Error> {
let (challenge, idx) = handle_402(response_headers)?;
let (challenge, idx, chain) = handle_402(response_headers)?;
let requirement = &challenge.accepts[idx];
let amount: u64 = requirement.amount.parse().map_err(|e| {
@@ -224,14 +251,22 @@ pub async fn handle_402_and_pay(
}
}
let signing_key = derive_wallet_signing_key().await?;
debug!(
"{LOG_PREFIX} paying {} atomic {} to {} for {}",
amount, requirement.asset, requirement.pay_to, request_url
"{LOG_PREFIX} paying {} atomic {} to {} for {} chain={:?}",
amount, requirement.asset, requirement.pay_to, request_url, chain
);
let header_value = try_paid_request(&signing_key, &challenge, requirement).await?;
let payment = match chain {
PaymentChain::Solana => {
let signing_key = derive_wallet_signing_key().await?;
build_solana_payment(&signing_key, &challenge, requirement).await?
}
PaymentChain::Evm => build_evm_payment(&challenge, requirement).await?,
};
let header_value = serde_json::to_string(&payment)
.map(|json| B64.encode(json))
.map_err(|e| X402Error::Protocol(format!("serialize payment: {e}")))?;
Ok(X402PaymentResult {
header_value,
@@ -341,7 +376,7 @@ fn parse_derivation_path(path: &str) -> Result<Vec<u32>, X402Error> {
pub enum X402Error {
Transport(reqwest::Error),
NoPaymentHeader,
NoSolanaOption,
NoPaymentOption,
AmountExceedsCap {
requested: u64,
cap: u64,
@@ -360,7 +395,12 @@ impl std::fmt::Display for X402Error {
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::NoPaymentOption => {
write!(
f,
"no supported payment option (Solana exact or EVM exact) in 402 challenge"
)
}
Self::AmountExceedsCap { requested, cap } => {
write!(f, "x402 amount {requested} exceeds per-request cap {cap}")
}
@@ -524,13 +564,256 @@ async fn build_solana_payment(
x402_version: X402_VERSION,
resource: Some(challenge.resource.clone()),
accepted: req.clone(),
payload: SolanaPaymentProof {
payload: PaymentProof::Solana(SolanaPaymentProof {
transaction: tx_b64,
},
}),
extensions: serde_json::Map::new(),
})
}
// ---------------------------------------------------------------------------
// EVM payment construction (EIP-3009 transferWithAuthorization)
// ---------------------------------------------------------------------------
/// Build an EVM payment using EIP-3009 `transferWithAuthorization`.
/// Signs the typed data with the wallet's EVM key and returns the proof
/// for the facilitator to submit on-chain.
async fn build_evm_payment(
challenge: &PaymentRequired,
req: &PaymentRequirements,
) -> Result<PaymentPayload, X402Error> {
let (signer, from_address) = derive_evm_signer().await?;
build_evm_payment_with_signer(&signer, from_address, challenge, req)
}
/// Core EVM payment construction — separated from wallet derivation for testability.
pub(crate) fn build_evm_payment_with_signer(
signer: &ethers_signers::LocalWallet,
from_address: ethers_core::types::Address,
challenge: &PaymentRequired,
req: &PaymentRequirements,
) -> Result<PaymentPayload, X402Error> {
use ethers_core::types::{Address, U256};
use ethers_signers::Signer;
use std::str::FromStr;
let chain_id = req
.evm_chain_id()
.ok_or_else(|| X402Error::Protocol(format!("not an EVM network: {}", req.network)))?;
let amount = U256::from_dec_str(&req.amount)
.map_err(|e| X402Error::Protocol(format!("invalid amount '{}': {e}", req.amount)))?;
let pay_to = Address::from_str(&req.pay_to).map_err(|e| {
X402Error::Protocol(format!("invalid EVM payTo address '{}': {e}", req.pay_to))
})?;
let token_address = Address::from_str(&req.asset).map_err(|e| {
X402Error::Protocol(format!("invalid EVM token address '{}': {e}", req.asset))
})?;
// EIP-3009 parameters
let valid_after = U256::zero();
let valid_before = U256::from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
+ req.max_timeout_seconds,
);
// Random nonce for EIP-3009
let nonce = {
let mut hasher = Sha256::new();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
hasher.update(ts.to_le_bytes());
hasher.update(std::process::id().to_le_bytes());
let hash: [u8; 32] = hasher.finalize().into();
hash
};
// EIP-712 typed data for `transferWithAuthorization`
let domain_name = req
.extra
.as_ref()
.and_then(|e| e.name.as_deref())
.unwrap_or("USD Coin");
let domain_version = req
.extra
.as_ref()
.and_then(|e| e.version.as_deref())
.unwrap_or("2");
let domain_separator =
eip712_domain_separator_named(token_address, chain_id, domain_name, domain_version);
let struct_hash = eip3009_struct_hash(
from_address,
pay_to,
amount,
valid_after,
valid_before,
nonce,
);
let mut digest_input = Vec::with_capacity(66);
digest_input.extend(b"\x19\x01");
digest_input.extend(domain_separator);
digest_input.extend(struct_hash);
let digest: [u8; 32] = {
use ethers_core::types::H256;
let h = H256::from(ethers_core::utils::keccak256(&digest_input));
h.into()
};
let signature = signer
.sign_hash(ethers_core::types::H256::from(digest))
.map_err(|e| X402Error::Wallet(format!("EVM sign EIP-3009: {e}")))?;
let sig_hex = format!("0x{}", hex::encode(signature.to_vec()));
let nonce_hex = format!("0x{}", hex::encode(nonce));
debug!(
"{LOG_PREFIX} built EVM payment chain_id={chain_id} amount={} asset={} from={:#x} to={:#x}",
req.amount, req.asset, from_address, pay_to
);
Ok(PaymentPayload {
x402_version: X402_VERSION,
resource: Some(challenge.resource.clone()),
accepted: req.clone(),
payload: PaymentProof::Evm(EvmPaymentProof {
signature: sig_hex,
authorization: EvmAuthorization {
from: format!("{from_address:#x}"),
to: format!("{pay_to:#x}"),
value: req.amount.clone(),
valid_after: "0".to_string(),
valid_before: valid_before.to_string(),
nonce: nonce_hex,
},
}),
extensions: serde_json::Map::new(),
})
}
/// Derive the wallet's EVM signer from the encrypted mnemonic.
async fn derive_evm_signer(
) -> Result<(ethers_signers::LocalWallet, ethers_core::types::Address), X402Error> {
use crate::openhuman::wallet::WalletChain;
use ethers_signers::{coins_bip39::English, MnemonicBuilder, Signer};
let secret = crate::openhuman::wallet::secret_material(WalletChain::Evm)
.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;
let wallet = MnemonicBuilder::<English>::default()
.phrase(mnemonic.as_str())
.derivation_path(&secret.derivation_path)
.map_err(|e| {
X402Error::Wallet(format!(
"invalid EVM derivation path '{}': {e}",
secret.derivation_path
))
})?
.build()
.map_err(|e| X402Error::Wallet(format!("derive EVM signer: {e}")))?;
let address = wallet.address();
Ok((wallet, address))
}
/// EIP-712 domain separator with default USDC params ("USD Coin", "2").
pub(crate) fn eip712_domain_separator(
verifying_contract: ethers_core::types::Address,
chain_id: u64,
) -> [u8; 32] {
eip712_domain_separator_named(verifying_contract, chain_id, "USD Coin", "2")
}
/// EIP-712 domain separator with explicit name and version from the 402 extra.
pub(crate) fn eip712_domain_separator_named(
verifying_contract: ethers_core::types::Address,
chain_id: u64,
name: &str,
version: &str,
) -> [u8; 32] {
use ethers_core::utils::keccak256;
let type_hash = keccak256(
b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)",
);
let name_hash = keccak256(name.as_bytes());
let version_hash = keccak256(version.as_bytes());
let mut encoded = Vec::with_capacity(5 * 32);
encoded.extend(type_hash);
encoded.extend(name_hash);
encoded.extend(version_hash);
let mut chain_id_bytes = [0u8; 32];
chain_id_bytes[24..].copy_from_slice(&chain_id.to_be_bytes());
encoded.extend(chain_id_bytes);
let mut addr_bytes = [0u8; 32];
addr_bytes[12..].copy_from_slice(verifying_contract.as_bytes());
encoded.extend(addr_bytes);
keccak256(&encoded)
}
/// EIP-3009 `TransferWithAuthorization` struct hash.
pub(crate) fn eip3009_struct_hash(
from: ethers_core::types::Address,
to: ethers_core::types::Address,
value: ethers_core::types::U256,
valid_after: ethers_core::types::U256,
valid_before: ethers_core::types::U256,
nonce: [u8; 32],
) -> [u8; 32] {
use ethers_core::utils::keccak256;
let type_hash = keccak256(
b"TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)",
);
let mut encoded = Vec::with_capacity(7 * 32);
encoded.extend(type_hash);
let mut from_bytes = [0u8; 32];
from_bytes[12..].copy_from_slice(from.as_bytes());
encoded.extend(from_bytes);
let mut to_bytes = [0u8; 32];
to_bytes[12..].copy_from_slice(to.as_bytes());
encoded.extend(to_bytes);
let mut value_bytes = [0u8; 32];
value.to_big_endian(&mut value_bytes);
encoded.extend(value_bytes);
let mut va_bytes = [0u8; 32];
valid_after.to_big_endian(&mut va_bytes);
encoded.extend(va_bytes);
let mut vb_bytes = [0u8; 32];
valid_before.to_big_endian(&mut vb_bytes);
encoded.extend(vb_bytes);
encoded.extend(nonce);
keccak256(&encoded)
}
// ---------------------------------------------------------------------------
// Solana wire-format helpers (mirrors wallet/chains/solana.rs primitives)
// ---------------------------------------------------------------------------
+358
View File
@@ -0,0 +1,358 @@
//! Agent tool for making x402-paid HTTP requests.
//!
//! Unlike the general `http_request` tool (which silently handles 402s as a
//! fallback), this tool is purpose-built for x402 endpoints: it always expects
//! a payment challenge, surfaces pricing to the agent, and records the payment
//! in the ledger.
use async_trait::async_trait;
use base64::engine::{general_purpose::STANDARD as B64, Engine as _};
use log::debug;
use serde_json::json;
use std::time::Duration;
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCallOptions, ToolResult};
use super::store;
use super::types::*;
const LOG_PREFIX: &str = "[tool.x402_request]";
const DEFAULT_TIMEOUT_SECS: u64 = 30;
pub struct X402RequestTool;
impl X402RequestTool {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl Tool for X402RequestTool {
fn name(&self) -> &str {
"x402_request"
}
fn description(&self) -> &str {
"Make an HTTP request to an x402-payable API endpoint. Automatically handles the \
HTTP 402 payment challenge by signing a payment (EVM EIP-3009 on Base/Ethereum, or \
Solana SPL transfer) with the wallet and retrying with the payment proof. \
Returns the API response after payment. Use this for x402-enabled APIs like twit.sh. \
The wallet must be set up with USDC on the target chain."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "URL of the x402-payable API endpoint (e.g. https://x402.twit.sh/tweets/by/id?id=1110302988)"
},
"method": {
"type": "string",
"description": "HTTP method (default: GET)",
"default": "GET",
"enum": ["GET", "POST", "PUT", "DELETE", "PATCH"]
},
"headers": {
"type": "object",
"description": "Optional HTTP headers as key-value pairs",
"default": {}
},
"body": {
"type": "string",
"description": "Optional request body (for POST/PUT/PATCH)"
}
},
"required": ["url"]
})
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::Write
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.execute_with_options(args, ToolCallOptions::default())
.await
}
async fn execute_with_options(
&self,
args: serde_json::Value,
_options: ToolCallOptions,
) -> anyhow::Result<ToolResult> {
let url = match args.get("url").and_then(|v| v.as_str()) {
Some(u) => u.to_string(),
None => return Ok(ToolResult::error("Missing required 'url' parameter")),
};
let method_str = args.get("method").and_then(|v| v.as_str()).unwrap_or("GET");
let method: reqwest::Method = match method_str.parse() {
Ok(m) => m,
Err(_) => {
return Ok(ToolResult::error(format!(
"Unsupported HTTP method: {method_str}"
)))
}
};
let headers = parse_header_args(args.get("headers"));
let body = args.get("body").and_then(|v| v.as_str()).map(String::from);
debug!("{LOG_PREFIX} requesting {method} {url}");
// Step 1: Initial request to get the 402 challenge
let client = match build_client() {
Ok(c) => c,
Err(e) => {
return Ok(ToolResult::error(format!(
"Failed to build HTTP client: {e}"
)))
}
};
let initial_response =
match send_request(&client, &method, &url, &headers, body.as_deref()).await {
Ok(r) => r,
Err(e) => return Ok(ToolResult::error(format!("Initial request failed: {e}"))),
};
// If the response is not 402, return it directly
if initial_response.status() != reqwest::StatusCode::PAYMENT_REQUIRED {
let status = initial_response.status().as_u16();
debug!("{LOG_PREFIX} got {status} (not 402), returning directly");
return format_response(initial_response, &url).await;
}
// Step 2: Parse the 402 challenge
let initial_headers = initial_response.headers().clone();
if initial_headers.get(HEADER_PAYMENT_REQUIRED).is_none()
&& initial_headers.get(HEADER_PAYMENT_REQUIRED_V1).is_none()
{
return Ok(ToolResult::error(
"Server returned 402 but without a PAYMENT-REQUIRED header — not an x402 endpoint",
));
}
debug!("{LOG_PREFIX} got 402 with PAYMENT-REQUIRED header, processing payment");
// Step 3: Build and sign the payment
let payment_result = match super::handle_402_and_pay(&initial_headers, &url).await {
Ok(r) => r,
Err(e) => {
return Ok(ToolResult::error(format!("x402 payment failed: {e}")));
}
};
let amount_display = format!(
"{:.6} USDC",
payment_result.amount_atomic as f64 / 1_000_000.0
);
debug!(
"{LOG_PREFIX} payment built: {} to {} on {} for {}",
amount_display, payment_result.recipient, payment_result.network, url
);
// Record the pending payment
let record = store::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: amount_display.clone(),
recipient: payment_result.recipient.clone(),
network: payment_result.network.clone(),
tx_signature: None,
status: store::PaymentStatus::Pending,
timestamp: chrono::Utc::now(),
session_id: String::new(),
};
let record_id = record.id.clone();
let _ = store::with_ledger_mut(|l| l.record_payment(record));
// Step 4: Retry with the payment signature
let mut retry_headers = headers.clone();
retry_headers.push((
HEADER_PAYMENT_SIGNATURE.to_string(),
payment_result.header_value,
));
let paid_response =
match send_request(&client, &method, &url, &retry_headers, body.as_deref()).await {
Ok(r) => r,
Err(e) => {
let _ = store::with_ledger_mut(|l| {
let mut updated = store::PaymentRecord {
id: record_id,
url: url.clone(),
asset: payment_result.asset.clone(),
amount_atomic: payment_result.amount_atomic,
amount_display: amount_display.clone(),
recipient: payment_result.recipient.clone(),
network: payment_result.network.clone(),
tx_signature: None,
status: store::PaymentStatus::Failed,
timestamp: chrono::Utc::now(),
session_id: String::new(),
};
l.record_payment(updated);
});
return Ok(ToolResult::error(format!("x402 retry request failed: {e}")));
}
};
// Step 5: Parse settlement response and update ledger
let settled_status = if paid_response.status().is_success() {
store::PaymentStatus::Settled
} else {
store::PaymentStatus::Failed
};
let tx_sig = paid_response
.headers()
.get(HEADER_PAYMENT_RESPONSE)
.and_then(|v| v.to_str().ok())
.and_then(|b64| B64.decode(b64).ok())
.and_then(|bytes| serde_json::from_slice::<SettlementResponse>(&bytes).ok())
.and_then(|r| {
if r.success && !r.transaction.is_empty() {
Some(r.transaction)
} else {
None
}
});
let _ = store::with_ledger_mut(|l| {
let updated = store::PaymentRecord {
id: record_id.clone(),
url: url.clone(),
asset: payment_result.asset.clone(),
amount_atomic: payment_result.amount_atomic,
amount_display: amount_display.clone(),
recipient: payment_result.recipient.clone(),
network: payment_result.network.clone(),
tx_signature: tx_sig.clone(),
status: settled_status,
timestamp: chrono::Utc::now(),
session_id: String::new(),
};
l.record_payment(updated);
});
if settled_status == store::PaymentStatus::Settled {
debug!(
"{LOG_PREFIX} payment settled for {url} tx={:?} amount={}",
tx_sig, amount_display
);
} else {
log::warn!(
"{LOG_PREFIX} payment failed for {url} status={}",
paid_response.status()
);
}
// Step 6: Format and return the response with payment metadata
format_response_with_payment(
paid_response,
&url,
&amount_display,
&payment_result.network,
tx_sig.as_deref(),
)
.await
}
}
fn build_client() -> Result<reqwest::Client, reqwest::Error> {
let builder = reqwest::Client::builder()
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
.connect_timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::limited(5));
let builder =
crate::openhuman::config::apply_runtime_proxy_to_builder(builder, "tool.x402_request");
builder.build()
}
fn parse_header_args(headers_val: Option<&serde_json::Value>) -> Vec<(String, String)> {
let mut result = Vec::new();
if let Some(obj) = headers_val.and_then(|v| v.as_object()) {
for (key, value) in obj {
if let Some(str_val) = value.as_str() {
result.push((key.clone(), str_val.to_string()));
}
}
}
result
}
async fn send_request(
client: &reqwest::Client,
method: &reqwest::Method,
url: &str,
headers: &[(String, String)],
body: Option<&str>,
) -> Result<reqwest::Response, reqwest::Error> {
let mut request = client.request(method.clone(), url);
for (key, value) in headers {
request = request.header(key, value);
}
if let Some(body_str) = body {
request = request.body(body_str.to_string());
}
request.send().await
}
async fn format_response(response: reqwest::Response, url: &str) -> anyhow::Result<ToolResult> {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
let truncated = if body.len() > 50_000 {
format!("{}…(truncated)", &body[..50_000])
} else {
body
};
Ok(ToolResult::success(format!(
"HTTP {status} from {url}\n\n{truncated}"
)))
}
async fn format_response_with_payment(
response: reqwest::Response,
url: &str,
amount_display: &str,
network: &str,
tx_sig: Option<&str>,
) -> anyhow::Result<ToolResult> {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
let truncated = if body.len() > 50_000 {
format!("{}…(truncated)", &body[..50_000])
} else {
body
};
let chain_label = if network.starts_with("eip155:8453") {
"Base"
} else if network.starts_with("eip155:1") {
"Ethereum"
} else if network.starts_with("eip155:") {
"EVM"
} else if network.starts_with("solana:") {
"Solana"
} else {
network
};
let tx_line = tx_sig
.map(|sig| format!("\nTransaction: {sig}"))
.unwrap_or_default();
Ok(ToolResult::success(format!(
"HTTP {status} from {url}\n\
x402 payment: {amount_display} on {chain_label}{tx_line}\n\n\
{truncated}"
)))
}
+86 -3
View File
@@ -27,6 +27,15 @@ pub const SPL_TOKEN_PROGRAM: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
pub const SPL_MEMO_PROGRAM: &str = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
pub const COMPUTE_BUDGET_PROGRAM: &str = "ComputeBudget111111111111111111111111111111";
// EVM / Base chain constants (CAIP-2 format: eip155:<chain_id>)
pub const BASE_MAINNET_CAIP2: &str = "eip155:8453";
pub const BASE_SEPOLIA_CAIP2: &str = "eip155:84532";
pub const ETHEREUM_MAINNET_CAIP2: &str = "eip155:1";
pub const USDC_BASE_MAINNET: &str = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
pub const USDC_BASE_SEPOLIA: &str = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
pub const USDC_ETHEREUM_MAINNET: &str = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
// ---------------------------------------------------------------------------
// 402 challenge — server → client (PAYMENT-REQUIRED header)
// ---------------------------------------------------------------------------
@@ -72,12 +81,18 @@ pub struct PaymentRequirements {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaymentExtra {
/// Facilitator pubkey that will co-sign as fee payer.
/// Facilitator pubkey that will co-sign as fee payer (Solana).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fee_payer: Option<String>,
/// Required memo value for transaction uniqueness.
/// Required memo value for transaction uniqueness (Solana).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memo: Option<String>,
/// EIP-712 domain name for the token contract (EVM, e.g. "USD Coin").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// EIP-712 domain version for the token contract (EVM, e.g. "2").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
// ---------------------------------------------------------------------------
@@ -91,11 +106,21 @@ pub struct PaymentPayload {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource: Option<ResourceInfo>,
pub accepted: PaymentRequirements,
pub payload: SolanaPaymentProof,
pub payload: PaymentProof,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extensions: serde_json::Map<String, serde_json::Value>,
}
/// Chain-specific payment proof. Serializes flat (untagged) so the facilitator
/// sees either `{ "transaction": "..." }` (Solana) or
/// `{ "signature": "0x...", "authorization": {...} }` (EVM).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PaymentProof {
Solana(SolanaPaymentProof),
Evm(EvmPaymentProof),
}
/// Solana `exact` scheme payload — a partially-signed `VersionedTransaction`
/// serialized as standard base64. The facilitator adds its fee-payer signature
/// and broadcasts.
@@ -104,6 +129,27 @@ pub struct SolanaPaymentProof {
pub transaction: String,
}
/// EVM `exact` scheme payload — a signed EIP-3009 `transferWithAuthorization`
/// or plain ERC-20 transfer authorization for the facilitator to submit.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EvmPaymentProof {
pub signature: String,
pub authorization: EvmAuthorization,
}
/// EIP-3009 `transferWithAuthorization` parameters signed by the token holder.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EvmAuthorization {
pub from: String,
pub to: String,
pub value: String,
pub valid_after: String,
pub valid_before: String,
pub nonce: String,
}
// ---------------------------------------------------------------------------
// Settlement response — server → client (PAYMENT-RESPONSE header)
// ---------------------------------------------------------------------------
@@ -137,6 +183,32 @@ impl PaymentRequired {
.iter()
.find(|r| r.scheme == "exact" && r.network.starts_with("solana:"))
}
/// Find the first `accepts` entry whose network starts with `"eip155:"` and
/// whose scheme is `"exact"`.
pub fn evm_exact_requirement(&self) -> Option<&PaymentRequirements> {
self.accepts
.iter()
.find(|r| r.scheme == "exact" && r.network.starts_with("eip155:"))
}
/// Find the best payment option — prefer EVM (Base), fall back to Solana.
pub fn best_exact_requirement(&self) -> Option<(&PaymentRequirements, PaymentChain)> {
if let Some(sol) = self.solana_exact_requirement() {
Some((sol, PaymentChain::Solana))
} else if let Some(evm) = self.evm_exact_requirement() {
Some((evm, PaymentChain::Evm))
} else {
None
}
}
}
/// Which chain family a payment requirement targets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaymentChain {
Solana,
Evm,
}
impl PaymentRequirements {
@@ -144,6 +216,17 @@ impl PaymentRequirements {
self.network == SOLANA_MAINNET_CAIP2
}
pub fn is_base_mainnet(&self) -> bool {
self.network == BASE_MAINNET_CAIP2
}
/// Parse the EVM chain ID from an `eip155:<chain_id>` network string.
pub fn evm_chain_id(&self) -> Option<u64> {
self.network
.strip_prefix("eip155:")
.and_then(|s| s.parse().ok())
}
pub fn fee_payer_pubkey(&self) -> Option<&str> {
self.extra.as_ref()?.fee_payer.as_deref()
}
+394
View File
@@ -1,6 +1,7 @@
use base64::engine::{general_purpose::STANDARD as B64, Engine as _};
use serde_json::json;
use super::ops::{build_evm_payment_with_signer, eip3009_struct_hash, eip712_domain_separator};
use super::types::*;
#[test]
@@ -23,6 +24,8 @@ fn parse_payment_required_round_trips() {
extra: Some(PaymentExtra {
fee_payer: Some("EwWqGE4ZFKLofuestmU4LDdK7XM1N4ALgdZccwYugwGd".into()),
memo: Some("pi_3abc123".into()),
name: None,
version: None,
}),
}],
extensions: serde_json::Map::new(),
@@ -111,6 +114,8 @@ fn payment_extra_accessors() {
extra: Some(PaymentExtra {
fee_payer: Some("FeePayer123".into()),
memo: Some("order_456".into()),
name: None,
version: None,
}),
};
assert_eq!(req.fee_payer_pubkey(), Some("FeePayer123"));
@@ -187,3 +192,392 @@ fn base64_header_round_trip() {
let parsed: PaymentRequired = serde_json::from_slice(&decoded).unwrap();
assert_eq!(parsed.accepts[0].amount, "1000000");
}
// ---------------------------------------------------------------------------
// EVM tests
// ---------------------------------------------------------------------------
#[test]
fn evm_exact_requirement_finds_match() {
let challenge = PaymentRequired {
x402_version: 2,
error: None,
resource: ResourceInfo {
url: "https://twit.sh/post".into(),
description: None,
mime_type: None,
},
accepts: vec![
PaymentRequirements {
scheme: "exact".into(),
network: BASE_MAINNET_CAIP2.into(),
amount: "100".into(),
asset: USDC_BASE_MAINNET.into(),
pay_to: "0x1234567890abcdef1234567890abcdef12345678".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 evm = challenge.evm_exact_requirement().unwrap();
assert_eq!(evm.amount, "100");
assert!(evm.is_base_mainnet());
assert_eq!(evm.evm_chain_id(), Some(8453));
}
#[test]
fn best_exact_requirement_prefers_solana() {
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: SOLANA_MAINNET_CAIP2.into(),
amount: "5000".into(),
asset: USDC_MINT_MAINNET.into(),
pay_to: "SolRecipient".into(),
max_timeout_seconds: 60,
extra: None,
},
PaymentRequirements {
scheme: "exact".into(),
network: BASE_MAINNET_CAIP2.into(),
amount: "100".into(),
asset: USDC_BASE_MAINNET.into(),
pay_to: "0xRecipient".into(),
max_timeout_seconds: 30,
extra: None,
},
],
extensions: serde_json::Map::new(),
};
let (req, chain) = challenge.best_exact_requirement().unwrap();
assert_eq!(chain, PaymentChain::Solana);
assert_eq!(req.amount, "5000");
}
#[test]
fn best_exact_requirement_falls_back_to_evm() {
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: BASE_MAINNET_CAIP2.into(),
amount: "100".into(),
asset: USDC_BASE_MAINNET.into(),
pay_to: "0xRecipient".into(),
max_timeout_seconds: 30,
extra: None,
}],
extensions: serde_json::Map::new(),
};
let (req, chain) = challenge.best_exact_requirement().unwrap();
assert_eq!(chain, PaymentChain::Evm);
assert_eq!(req.amount, "100");
}
#[test]
fn evm_chain_id_parsing() {
let req = PaymentRequirements {
scheme: "exact".into(),
network: "eip155:8453".into(),
amount: "100".into(),
asset: USDC_BASE_MAINNET.into(),
pay_to: "0xRecipient".into(),
max_timeout_seconds: 30,
extra: None,
};
assert_eq!(req.evm_chain_id(), Some(8453));
assert!(req.is_base_mainnet());
let eth_req = PaymentRequirements {
scheme: "exact".into(),
network: "eip155:1".into(),
amount: "100".into(),
asset: USDC_ETHEREUM_MAINNET.into(),
pay_to: "0xRecipient".into(),
max_timeout_seconds: 30,
extra: None,
};
assert_eq!(eth_req.evm_chain_id(), Some(1));
assert!(!eth_req.is_base_mainnet());
let sol_req = PaymentRequirements {
scheme: "exact".into(),
network: SOLANA_MAINNET_CAIP2.into(),
amount: "100".into(),
asset: USDC_MINT_MAINNET.into(),
pay_to: "Recipient".into(),
max_timeout_seconds: 30,
extra: None,
};
assert_eq!(sol_req.evm_chain_id(), None);
}
#[test]
fn evm_payment_proof_serializes_correctly() {
let proof = EvmPaymentProof {
signature: "0xdeadbeef".into(),
authorization: EvmAuthorization {
from: "0xaaaa".into(),
to: "0xbbbb".into(),
value: "1000000".into(),
valid_after: "0".into(),
valid_before: "99999999".into(),
nonce: "0xabcd".into(),
},
};
let payload = PaymentPayload {
x402_version: 2,
resource: None,
accepted: PaymentRequirements {
scheme: "exact".into(),
network: BASE_MAINNET_CAIP2.into(),
amount: "1000000".into(),
asset: USDC_BASE_MAINNET.into(),
pay_to: "0xbbbb".into(),
max_timeout_seconds: 60,
extra: None,
},
payload: PaymentProof::Evm(proof),
extensions: serde_json::Map::new(),
};
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains("\"signature\":\"0xdeadbeef\""));
assert!(json.contains("\"authorization\""));
assert!(!json.contains("\"transaction\""));
}
#[test]
fn solana_payment_proof_serializes_correctly() {
let payload = PaymentPayload {
x402_version: 2,
resource: None,
accepted: PaymentRequirements {
scheme: "exact".into(),
network: SOLANA_MAINNET_CAIP2.into(),
amount: "5000".into(),
asset: USDC_MINT_MAINNET.into(),
pay_to: "Recipient".into(),
max_timeout_seconds: 60,
extra: None,
},
payload: PaymentProof::Solana(SolanaPaymentProof {
transaction: "base64tx".into(),
}),
extensions: serde_json::Map::new(),
};
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains("\"transaction\":\"base64tx\""));
assert!(!json.contains("\"signature\""));
}
#[test]
fn eip712_domain_separator_is_deterministic() {
use std::str::FromStr;
let contract =
ethers_core::types::Address::from_str("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")
.unwrap();
let sep1 = eip712_domain_separator(contract, 8453);
let sep2 = eip712_domain_separator(contract, 8453);
assert_eq!(sep1, sep2);
// Different chain ID produces different separator
let sep_eth = eip712_domain_separator(contract, 1);
assert_ne!(sep1, sep_eth);
}
#[test]
fn eip3009_struct_hash_is_deterministic() {
use std::str::FromStr;
let from = ethers_core::types::Address::from_str("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
.unwrap();
let to = ethers_core::types::Address::from_str("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
.unwrap();
let value = ethers_core::types::U256::from(1_000_000u64);
let valid_after = ethers_core::types::U256::zero();
let valid_before = ethers_core::types::U256::from(99999u64);
let nonce = [42u8; 32];
let h1 = eip3009_struct_hash(from, to, value, valid_after, valid_before, nonce);
let h2 = eip3009_struct_hash(from, to, value, valid_after, valid_before, nonce);
assert_eq!(h1, h2);
// Different nonce produces different hash
let h3 = eip3009_struct_hash(from, to, value, valid_after, valid_before, [43u8; 32]);
assert_ne!(h1, h3);
}
#[test]
fn parse_twit_sh_402_challenge() {
let b64 = "eyJ4NDAyVmVyc2lvbiI6MiwiZXJyb3IiOiJQYXltZW50IHJlcXVpcmVkIiwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly94NDAyLnR3aXQuc2gvdHdlZXRzL2J5L2lkIiwiZGVzY3JpcHRpb24iOiJMb29rIHVwIGEgc2luZ2xlIFR3aXR0ZXIvWCB0d2VldCBieSBpdHMgbnVtZXJpYyB0d2VldCBJRC4iLCJtaW1lVHlwZSI6ImFwcGxpY2F0aW9uL2pzb24ifSwiYWNjZXB0cyI6W3sic2NoZW1lIjoiZXhhY3QiLCJuZXR3b3JrIjoiZWlwMTU1Ojg0NTMiLCJhbW91bnQiOiIyNTAwIiwiYXNzZXQiOiIweDgzMzU4OWZDRDZlRGI2RTA4ZjRjN0MzMkQ0ZjcxYjU0YmRBMDI5MTMiLCJwYXlUbyI6IjB4OURCQTQxNDYzN2M2MTFhMTZCRWE2ZjA3OTZCRmNiY0JkYzQxMGRmOCIsIm1heFRpbWVvdXRTZWNvbmRzIjozMDAsImV4dHJhIjp7Im5hbWUiOiJVU0QgQ29pbiIsInZlcnNpb24iOiIyIn19XX0=";
let json_bytes = B64.decode(b64).unwrap();
let challenge: PaymentRequired = serde_json::from_slice(&json_bytes).unwrap();
assert_eq!(challenge.x402_version, 2);
assert_eq!(challenge.error.as_deref(), Some("Payment required"));
assert_eq!(challenge.resource.url, "https://x402.twit.sh/tweets/by/id");
assert_eq!(challenge.accepts.len(), 1);
let req = &challenge.accepts[0];
assert_eq!(req.scheme, "exact");
assert_eq!(req.network, BASE_MAINNET_CAIP2);
assert_eq!(req.amount, "2500");
assert_eq!(req.asset, USDC_BASE_MAINNET);
assert_eq!(req.pay_to, "0x9DBA414637c611a16BEa6f0796BFcbcBdc410df8");
assert_eq!(req.max_timeout_seconds, 300);
assert!(req.is_base_mainnet());
assert_eq!(req.evm_chain_id(), Some(8453));
let extra = req.extra.as_ref().unwrap();
assert_eq!(extra.name.as_deref(), Some("USD Coin"));
assert_eq!(extra.version.as_deref(), Some("2"));
// Should select EVM path
let (best, chain) = challenge.best_exact_requirement().unwrap();
assert_eq!(chain, PaymentChain::Evm);
assert_eq!(best.amount, "2500");
assert!(challenge.solana_exact_requirement().is_none());
}
#[test]
fn build_evm_payment_with_test_key_produces_valid_payload() {
use ethers_signers::{coins_bip39::English, MnemonicBuilder, Signer};
use std::str::FromStr;
let test_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
let wallet = MnemonicBuilder::<English>::default()
.phrase(test_mnemonic)
.derivation_path("m/44'/60'/0'/0/0")
.unwrap()
.build()
.unwrap();
let from_address = wallet.address();
let challenge = PaymentRequired {
x402_version: 2,
error: Some("Payment required".into()),
resource: ResourceInfo {
url: "https://x402.twit.sh/tweets/by/id".into(),
description: Some("Look up a tweet".into()),
mime_type: Some("application/json".into()),
},
accepts: vec![PaymentRequirements {
scheme: "exact".into(),
network: BASE_MAINNET_CAIP2.into(),
amount: "2500".into(),
asset: USDC_BASE_MAINNET.into(),
pay_to: "0x9DBA414637c611a16BEa6f0796BFcbcBdc410df8".into(),
max_timeout_seconds: 300,
extra: Some(PaymentExtra {
fee_payer: None,
memo: None,
name: Some("USD Coin".into()),
version: Some("2".into()),
}),
}],
extensions: serde_json::Map::new(),
};
let req = &challenge.accepts[0];
let payload = build_evm_payment_with_signer(&wallet, from_address, &challenge, req).unwrap();
assert_eq!(payload.x402_version, 2);
assert_eq!(payload.accepted.network, BASE_MAINNET_CAIP2);
assert_eq!(payload.accepted.amount, "2500");
match &payload.payload {
PaymentProof::Evm(evm) => {
assert!(evm.signature.starts_with("0x"));
// 0x prefix + 65 bytes (r=32 + s=32 + v=1) as hex = 130 chars + 2 = 132
assert_eq!(evm.signature.len(), 132);
assert_eq!(evm.authorization.value, "2500");
assert_eq!(evm.authorization.valid_after, "0");
assert!(evm.authorization.nonce.starts_with("0x"));
assert_eq!(
evm.authorization.to,
format!(
"{:#x}",
ethers_core::types::Address::from_str(
"0x9DBA414637c611a16BEa6f0796BFcbcBdc410df8"
)
.unwrap()
)
);
assert_eq!(evm.authorization.from, format!("{from_address:#x}"));
}
PaymentProof::Solana(_) => panic!("expected EVM proof, got Solana"),
}
// Verify the payload round-trips through base64 (as PAYMENT-SIGNATURE header)
let json = serde_json::to_string(&payload).unwrap();
let b64_header = B64.encode(&json);
let decoded = B64.decode(&b64_header).unwrap();
let parsed: PaymentPayload = serde_json::from_slice(&decoded).unwrap();
assert_eq!(parsed.x402_version, 2);
match &parsed.payload {
PaymentProof::Evm(evm) => assert_eq!(evm.authorization.value, "2500"),
_ => panic!("round-trip lost EVM proof"),
}
}
#[test]
fn build_evm_payment_rejects_solana_network() {
use ethers_signers::{coins_bip39::English, MnemonicBuilder, Signer};
let test_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
let wallet = MnemonicBuilder::<English>::default()
.phrase(test_mnemonic)
.derivation_path("m/44'/60'/0'/0/0")
.unwrap()
.build()
.unwrap();
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: 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 req = &challenge.accepts[0];
let result = build_evm_payment_with_signer(&wallet, wallet.address(), &challenge, req);
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(err_msg.contains("not an EVM network"));
}
+36
View File
@@ -0,0 +1,36 @@
//! Live integration test — makes a real x402 payment to twit.sh on Base.
//!
//! Run manually (requires a funded wallet):
//! GGML_NATIVE=OFF cargo test --test x402_twit_sh_live -- --ignored --nocapture
use openhuman_core::openhuman::tools::traits::Tool;
use openhuman_core::openhuman::x402;
use serde_json::json;
#[tokio::test]
#[ignore] // requires funded wallet + network access
async fn x402_pay_twit_sh_for_hal_finney_tweet() {
env_logger::init();
let tmp = tempfile::tempdir().unwrap();
x402::init_ledger(tmp.path(), "test-session");
let tool = x402::tools::X402RequestTool::new();
let result = tool
.execute(json!({
"url": "https://x402.twit.sh/tweets/by/id?id=1110302988",
"method": "GET"
}))
.await
.expect("tool execute should not panic");
println!("=== x402 tool result ===");
for content in &result.content {
if let openhuman_core::openhuman::workflows::types::ToolContent::Text { text } = content {
println!("{text}");
}
}
println!("is_error: {}", result.is_error);
assert!(!result.is_error, "x402 request should succeed");
}