use std::sync::Arc;
use async_trait::async_trait;
use r402_evm::Eip155ExactClient;
use r402_http::client::{WithPayments, X402Client};
use reqwest::Client;
use serde_json::Value;
use tracing::debug;
use super::wallet::EvmWallet;
use crate::tool::{BoxedTool, DynTool, ToolDefinition, ToolError};
use crate::wallet::WalletError;
#[derive(Debug, Clone)]
pub struct X402HttpClient {
inner: reqwest_middleware::ClientWithMiddleware,
}
impl X402HttpClient {
#[must_use]
pub fn from_wallet(wallet: &EvmWallet) -> Self {
let signer = Arc::new(wallet.signer().clone());
let x402_client = X402Client::new().register(Eip155ExactClient::new(signer));
let inner = Client::new().with_payments(x402_client);
debug!(
address = %wallet.address(),
chain = %wallet.chain_name(),
"x402 HTTP client created",
);
Self { inner }
}
#[must_use]
pub fn from_wallet_arc(wallet: &Arc<EvmWallet>) -> Self {
Self::from_wallet(wallet)
}
#[must_use]
pub const fn client(&self) -> &reqwest_middleware::ClientWithMiddleware {
&self.inner
}
pub async fn get(&self, url: &str) -> Result<String, WalletError> {
let response = self
.inner
.get(url)
.send()
.await
.map_err(|e| WalletError::payment(format!("GET request failed: {e}")))?;
let status = response.status();
if !status.is_success() {
return Err(WalletError::payment(format!(
"request returned status {status}"
)));
}
response
.text()
.await
.map_err(|e| WalletError::payment(format!("failed to read response body: {e}")))
}
pub async fn post(&self, url: &str, body: impl Into<String>) -> Result<String, WalletError> {
let response = self
.inner
.post(url)
.header("content-type", "application/json")
.body(body.into())
.send()
.await
.map_err(|e| WalletError::payment(format!("POST request failed: {e}")))?;
let status = response.status();
if !status.is_success() {
return Err(WalletError::payment(format!(
"request returned status {status}"
)));
}
response
.text()
.await
.map_err(|e| WalletError::payment(format!("failed to read response body: {e}")))
}
}
#[derive(Debug)]
pub(crate) struct X402FetchTool(Arc<X402HttpClient>);
impl X402FetchTool {
pub const fn new(client: Arc<X402HttpClient>) -> Self {
Self(client)
}
}
#[async_trait]
impl DynTool for X402FetchTool {
fn name(&self) -> &'static str {
"x402_fetch"
}
fn description(&self) -> String {
String::from(
"Fetch a URL with automatic x402 payment. If the server requires \
payment (HTTP 402), this tool transparently signs an ERC-3009 \
payment authorization using the wallet and retries the request. \
Supports GET and POST methods.",
)
}
fn definition(&self) -> ToolDefinition {
ToolDefinition::new(
self.name(),
self.description(),
serde_json::json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to fetch (must include scheme, e.g. https://...)."
},
"method": {
"type": "string",
"enum": ["GET", "POST"],
"description": "HTTP method. Defaults to GET."
},
"body": {
"type": "string",
"description": "Request body for POST requests (JSON string)."
}
},
"required": ["url"],
"additionalProperties": false
}),
)
}
async fn call_json(&self, args: Value) -> Result<Value, ToolError> {
const MAX_BODY_LEN: usize = 100_000;
let url = args
.get("url")
.and_then(Value::as_str)
.ok_or_else(|| ToolError::InvalidArguments("missing required field 'url'".into()))?;
let method = args.get("method").and_then(Value::as_str).unwrap_or("GET");
let body = match method.to_uppercase().as_str() {
"POST" => {
let post_body = args.get("body").and_then(Value::as_str).unwrap_or("{}");
self.0.post(url, post_body).await?
}
_ => self.0.get(url).await?,
};
let truncated = body.len() > MAX_BODY_LEN;
let content = if truncated {
&body[..MAX_BODY_LEN]
} else {
body.as_str()
};
Ok(serde_json::json!({
"url": url,
"method": method.to_uppercase(),
"content": content,
"truncated": truncated,
"content_length": body.len(),
}))
}
}
pub(crate) fn create_tools(wallet: &Arc<EvmWallet>) -> Vec<BoxedTool> {
let client = Arc::new(X402HttpClient::from_wallet_arc(wallet));
vec![Box::new(X402FetchTool::new(client))]
}