From 6a194a2be3d5ddcb15baf97b1d6395ebc41c59be Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:36:47 -0700 Subject: [PATCH] feat(x402): add EVM/Base chain support and dedicated agent tool (#3564) --- .../agents/crypto_agent/agent.toml | 6 +- .../agents/crypto_agent/prompt.md | 3 +- src/openhuman/agent_registry/agents/loader.rs | 6 + src/openhuman/tools/ops.rs | 7 + src/openhuman/x402/mod.rs | 4 +- src/openhuman/x402/ops.rs | 341 +++++++++++++-- src/openhuman/x402/tools.rs | 358 ++++++++++++++++ src/openhuman/x402/types.rs | 89 +++- src/openhuman/x402/x402_tests.rs | 394 ++++++++++++++++++ tests/x402_twit_sh_live.rs | 36 ++ 10 files changed, 1209 insertions(+), 35 deletions(-) create mode 100644 src/openhuman/x402/tools.rs create mode 100644 tests/x402_twit_sh_live.rs diff --git a/src/openhuman/agent_registry/agents/crypto_agent/agent.toml b/src/openhuman/agent_registry/agents/crypto_agent/agent.toml index 9e6ae472b..120d24943 100644 --- a/src/openhuman/agent_registry/agents/crypto_agent/agent.toml +++ b/src/openhuman/agent_registry/agents/crypto_agent/agent.toml @@ -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 " 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", ] diff --git a/src/openhuman/agent_registry/agents/crypto_agent/prompt.md b/src/openhuman/agent_registry/agents/crypto_agent/prompt.md index 719dc3371..eef67c2ee 100644 --- a/src/openhuman/agent_registry/agents/crypto_agent/prompt.md +++ b/src/openhuman/agent_registry/agents/crypto_agent/prompt.md @@ -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. diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index 661bb4ae8..5a499870e 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -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 diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index d6b938711..d26242343 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -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 diff --git a/src/openhuman/x402/mod.rs b/src/openhuman/x402/mod.rs index a2f230551..3735ae31a 100644 --- a/src/openhuman/x402/mod.rs +++ b/src/openhuman/x402/mod.rs @@ -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, }; diff --git a/src/openhuman/x402/ops.rs b/src/openhuman/x402/ops.rs index fdebb5a94..5a9f72880 100644 --- a/src/openhuman/x402/ops.rs +++ b/src/openhuman/x402/ops.rs @@ -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 { - 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 { - 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, 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 { + 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: ðers_signers::LocalWallet, + from_address: ethers_core::types::Address, + challenge: &PaymentRequired, + req: &PaymentRequirements, +) -> Result { + 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::::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) // --------------------------------------------------------------------------- diff --git a/src/openhuman/x402/tools.rs b/src/openhuman/x402/tools.rs new file mode 100644 index 000000000..dc91d3e43 --- /dev/null +++ b/src/openhuman/x402/tools.rs @@ -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 { + self.execute_with_options(args, ToolCallOptions::default()) + .await + } + + async fn execute_with_options( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + ) -> anyhow::Result { + 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::(&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 { + 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 { + 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 { + 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 { + 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}" + ))) +} diff --git a/src/openhuman/x402/types.rs b/src/openhuman/x402/types.rs index aee4cab77..0ee688d76 100644 --- a/src/openhuman/x402/types.rs +++ b/src/openhuman/x402/types.rs @@ -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:) +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, - /// Required memo value for transaction uniqueness. + /// Required memo value for transaction uniqueness (Solana). #[serde(default, skip_serializing_if = "Option::is_none")] pub memo: Option, + /// EIP-712 domain name for the token contract (EVM, e.g. "USD Coin"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// EIP-712 domain version for the token contract (EVM, e.g. "2"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, } // --------------------------------------------------------------------------- @@ -91,11 +106,21 @@ pub struct PaymentPayload { #[serde(default, skip_serializing_if = "Option::is_none")] pub resource: Option, pub accepted: PaymentRequirements, - pub payload: SolanaPaymentProof, + pub payload: PaymentProof, #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] pub extensions: serde_json::Map, } +/// 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:` network string. + pub fn evm_chain_id(&self) -> Option { + 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() } diff --git a/src/openhuman/x402/x402_tests.rs b/src/openhuman/x402/x402_tests.rs index 1ab7e3e55..9cfc74cbc 100644 --- a/src/openhuman/x402/x402_tests.rs +++ b/src/openhuman/x402/x402_tests.rs @@ -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::::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::::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")); +} diff --git a/tests/x402_twit_sh_live.rs b/tests/x402_twit_sh_live.rs new file mode 100644 index 000000000..42c85a284 --- /dev/null +++ b/tests/x402_twit_sh_live.rs @@ -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"); +}