use serde::{Deserialize, Serialize};
use crate::{Error, Result};
#[derive(Clone)]
pub struct RelayClient {
client: reqwest::Client,
relay_url: String,
auth_key_hex: String,
wallet_address: String,
is_test: bool,
chain_id: u64,
}
#[derive(Deserialize)]
pub struct RegisterResponse {
pub success: bool,
pub user_id: Option<String>,
pub error_code: Option<String>,
pub error_message: Option<String>,
}
#[derive(Deserialize)]
pub struct ResolveResponse {
pub address: Option<String>,
pub error: Option<String>,
}
#[derive(Deserialize)]
pub struct GraphQLResponse<T> {
pub data: Option<T>,
pub errors: Option<Vec<serde_json::Value>>,
}
#[derive(Deserialize)]
pub struct BillingStatus {
pub tier: Option<String>,
pub facts_used: Option<u64>,
pub facts_limit: Option<u64>,
pub features: Option<serde_json::Value>,
}
#[derive(Clone, Debug)]
pub struct RelayConfig {
pub relay_url: String,
pub auth_key_hex: String,
pub wallet_address: String,
pub is_test: bool,
pub chain_id: u64,
}
impl Default for RelayConfig {
fn default() -> Self {
Self {
relay_url: String::new(),
auth_key_hex: String::new(),
wallet_address: String::new(),
is_test: false,
chain_id: 84532, }
}
}
impl RelayClient {
pub fn new(config: RelayConfig) -> Self {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_default();
Self {
client,
relay_url: config.relay_url.trim_end_matches('/').to_string(),
auth_key_hex: config.auth_key_hex,
wallet_address: config.wallet_address,
is_test: config.is_test,
chain_id: config.chain_id,
}
}
fn headers(&self) -> reqwest::header::HeaderMap {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
"X-TotalReclaw-Client",
"rust-client:zeroclaw".parse().unwrap(),
);
if !self.auth_key_hex.is_empty() {
headers.insert(
"Authorization",
format!("Bearer {}", self.auth_key_hex).parse().unwrap(),
);
}
if !self.wallet_address.is_empty() {
headers.insert(
"X-Wallet-Address",
self.wallet_address.parse().unwrap(),
);
}
if self.is_test {
headers.insert("X-TotalReclaw-Test", "true".parse().unwrap());
}
headers
}
pub async fn register(&self, auth_key_hash: &str, salt_hex: &str) -> Result<String> {
#[derive(Serialize)]
struct Body<'a> {
auth_key_hash: &'a str,
salt: &'a str,
}
let resp = self
.client
.post(format!("{}/v1/register", self.relay_url))
.headers(self.headers())
.json(&Body {
auth_key_hash,
salt: salt_hex,
})
.send()
.await
.map_err(|e| Error::Http(e.to_string()))?;
let body: RegisterResponse = resp
.json()
.await
.map_err(|e| Error::Http(e.to_string()))?;
if body.success {
Ok(body.user_id.unwrap_or_default())
} else {
Err(Error::Http(
body.error_message
.unwrap_or_else(|| "Registration failed".into()),
))
}
}
pub async fn resolve_address(&self, auth_key_hex: &str) -> Result<String> {
#[derive(Serialize)]
struct Body<'a> {
auth_key: &'a str,
}
let resp = self
.client
.post(format!("{}/v1/addresses/resolve", self.relay_url))
.headers(self.headers())
.json(&Body {
auth_key: auth_key_hex,
})
.send()
.await
.map_err(|e| Error::Http(e.to_string()))?;
let body: ResolveResponse = resp
.json()
.await
.map_err(|e| Error::Http(e.to_string()))?;
body.address
.ok_or_else(|| Error::Http(body.error.unwrap_or_else(|| "No address returned".into())))
}
pub async fn graphql<T: for<'de> Deserialize<'de>>(
&self,
query: &str,
variables: serde_json::Value,
) -> Result<T> {
let resp = self
.client
.post(format!("{}/v1/subgraph", self.relay_url))
.headers(self.headers())
.json(&serde_json::json!({
"query": query,
"variables": variables,
}))
.send()
.await
.map_err(|e| Error::Http(e.to_string()))?;
if resp.status().as_u16() == 403 {
crate::billing::invalidate_cache();
let text = resp.text().await.unwrap_or_default();
return Err(Error::QuotaExceeded(format!(
"Quota exceeded (403). Billing cache invalidated. Upgrade to Pro for unlimited storage. Server: {}",
text
)));
}
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(Error::Http(format!("GraphQL HTTP {}: {}", status, text)));
}
let gql: GraphQLResponse<T> = resp
.json()
.await
.map_err(|e| Error::Http(format!("GraphQL parse error: {}", e)))?;
gql.data
.ok_or_else(|| Error::Http("GraphQL returned no data".into()))
}
pub async fn submit_fact_native(
&self,
protobuf_payload: &[u8],
private_key: &[u8; 32],
) -> Result<crate::userop::SubmitResult> {
let calldata = crate::userop::encode_single_call(protobuf_payload);
crate::userop::submit_userop(
&calldata,
&self.wallet_address,
private_key,
&self.relay_url,
&self.auth_key_hex,
self.chain_id,
self.is_test,
)
.await
}
pub async fn submit_fact_batch_native(
&self,
protobuf_payloads: &[Vec<u8>],
private_key: &[u8; 32],
) -> Result<crate::userop::SubmitResult> {
let calldata = crate::userop::encode_batch_call(protobuf_payloads)?;
crate::userop::submit_userop(
&calldata,
&self.wallet_address,
private_key,
&self.relay_url,
&self.auth_key_hex,
self.chain_id,
self.is_test,
)
.await
}
pub async fn submit_protobuf(&self, payload: &[u8]) -> Result<SubmitResult> {
let payload_hex = hex::encode(payload);
let resp = self
.client
.post(format!("{}/v1/bundler", self.relay_url))
.headers(self.headers())
.json(&serde_json::json!({
"jsonrpc": "2.0",
"method": "eth_sendUserOperation",
"params": [{
"callData": format!("0x{}", payload_hex),
}],
"id": 1,
}))
.send()
.await
.map_err(|e| Error::Http(e.to_string()))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| Error::Http(e.to_string()))?;
Ok(SubmitResult {
tx_hash: body["result"]["txHash"]
.as_str()
.unwrap_or("")
.to_string(),
user_op_hash: body["result"]["userOpHash"]
.as_str()
.unwrap_or("")
.to_string(),
success: body["result"]["success"].as_bool().unwrap_or(false),
})
}
pub async fn health_check(&self) -> Result<bool> {
let resp = self
.client
.get(format!("{}/health", self.relay_url))
.send()
.await
.map_err(|e| Error::Http(e.to_string()))?;
Ok(resp.status().is_success())
}
pub async fn billing_status(&self) -> Result<BillingStatus> {
let resp = self
.client
.get(format!(
"{}/v1/billing/status?wallet_address={}",
self.relay_url, self.wallet_address
))
.headers(self.headers())
.send()
.await
.map_err(|e| Error::Http(e.to_string()))?;
resp.json()
.await
.map_err(|e| Error::Http(e.to_string()))
}
pub async fn create_checkout(&self) -> Result<String> {
let resp = self
.client
.post(format!("{}/v1/billing/checkout", self.relay_url))
.headers(self.headers())
.json(&serde_json::json!({
"wallet_address": self.wallet_address,
"tier": "pro",
}))
.send()
.await
.map_err(|e| Error::Http(e.to_string()))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| Error::Http(e.to_string()))?;
body["checkout_url"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| Error::Http("No checkout_url in response".into()))
}
pub fn relay_url(&self) -> &str {
&self.relay_url
}
pub fn wallet_address(&self) -> &str {
&self.wallet_address
}
pub fn auth_key_hex(&self) -> &str {
&self.auth_key_hex
}
pub fn is_test(&self) -> bool {
self.is_test
}
pub fn chain_id(&self) -> u64 {
self.chain_id
}
pub fn set_chain_id(&mut self, chain_id: u64) {
self.chain_id = chain_id;
}
}
#[derive(Debug)]
pub struct SubmitResult {
pub tx_hash: String,
pub user_op_hash: String,
pub success: bool,
}