Skip to main content

totalreclaw_memory/
relay.rs

1//! Relay HTTP client for the TotalReclaw managed service.
2//!
3//! All communication with the relay server at `api.totalreclaw.xyz`.
4//! Proxies bundler (JSON-RPC) and subgraph (GraphQL) requests.
5
6use serde::{Deserialize, Serialize};
7
8use crate::{Error, Result};
9
10/// Relay client for TotalReclaw managed service.
11#[derive(Clone)]
12pub struct RelayClient {
13    client: reqwest::Client,
14    relay_url: String,
15    auth_key_hex: String,
16    wallet_address: String,
17    is_test: bool,
18    chain_id: u64,
19}
20
21/// Response from POST /v1/register.
22#[derive(Deserialize)]
23pub struct RegisterResponse {
24    pub success: bool,
25    pub user_id: Option<String>,
26    pub error_code: Option<String>,
27    pub error_message: Option<String>,
28}
29
30/// Response from POST /v1/addresses/resolve.
31#[derive(Deserialize)]
32pub struct ResolveResponse {
33    pub address: Option<String>,
34    pub error: Option<String>,
35}
36
37/// A raw GraphQL response wrapper.
38#[derive(Deserialize)]
39pub struct GraphQLResponse<T> {
40    pub data: Option<T>,
41    pub errors: Option<Vec<serde_json::Value>>,
42}
43
44/// Billing status response.
45#[derive(Deserialize)]
46pub struct BillingStatus {
47    pub tier: Option<String>,
48    pub facts_used: Option<u64>,
49    pub facts_limit: Option<u64>,
50    pub features: Option<serde_json::Value>,
51}
52
53/// Configuration for the relay client.
54#[derive(Clone, Debug)]
55pub struct RelayConfig {
56    pub relay_url: String,
57    pub auth_key_hex: String,
58    pub wallet_address: String,
59    pub is_test: bool,
60    pub chain_id: u64,
61}
62
63impl Default for RelayConfig {
64    fn default() -> Self {
65        Self {
66            relay_url: String::new(),
67            auth_key_hex: String::new(),
68            wallet_address: String::new(),
69            is_test: false,
70            chain_id: 84532, // Base Sepolia (free tier default)
71        }
72    }
73}
74
75impl RelayClient {
76    /// Create a new relay client.
77    pub fn new(config: RelayConfig) -> Self {
78        let client = reqwest::Client::builder()
79            .timeout(std::time::Duration::from_secs(30))
80            .build()
81            .unwrap_or_default();
82
83        Self {
84            client,
85            relay_url: config.relay_url.trim_end_matches('/').to_string(),
86            auth_key_hex: config.auth_key_hex,
87            wallet_address: config.wallet_address,
88            is_test: config.is_test,
89            chain_id: config.chain_id,
90        }
91    }
92
93    /// Common headers for all relay requests.
94    fn headers(&self) -> reqwest::header::HeaderMap {
95        let mut headers = reqwest::header::HeaderMap::new();
96        headers.insert(
97            "X-TotalReclaw-Client",
98            "rust-client:zeroclaw".parse().unwrap(),
99        );
100        if !self.auth_key_hex.is_empty() {
101            headers.insert(
102                "Authorization",
103                format!("Bearer {}", self.auth_key_hex).parse().unwrap(),
104            );
105        }
106        if !self.wallet_address.is_empty() {
107            headers.insert(
108                "X-Wallet-Address",
109                self.wallet_address.parse().unwrap(),
110            );
111        }
112        if self.is_test {
113            headers.insert("X-TotalReclaw-Test", "true".parse().unwrap());
114        }
115        headers
116    }
117
118    /// Register with the relay server. Idempotent.
119    pub async fn register(&self, auth_key_hash: &str, salt_hex: &str) -> Result<String> {
120        #[derive(Serialize)]
121        struct Body<'a> {
122            auth_key_hash: &'a str,
123            salt: &'a str,
124        }
125
126        let resp = self
127            .client
128            .post(format!("{}/v1/register", self.relay_url))
129            .headers(self.headers())
130            .json(&Body {
131                auth_key_hash,
132                salt: salt_hex,
133            })
134            .send()
135            .await
136            .map_err(|e| Error::Http(e.to_string()))?;
137
138        let body: RegisterResponse = resp
139            .json()
140            .await
141            .map_err(|e| Error::Http(e.to_string()))?;
142
143        if body.success {
144            Ok(body.user_id.unwrap_or_default())
145        } else {
146            Err(Error::Http(
147                body.error_message
148                    .unwrap_or_else(|| "Registration failed".into()),
149            ))
150        }
151    }
152
153    /// Resolve Smart Account address from the relay.
154    pub async fn resolve_address(&self, auth_key_hex: &str) -> Result<String> {
155        #[derive(Serialize)]
156        struct Body<'a> {
157            auth_key: &'a str,
158        }
159
160        let resp = self
161            .client
162            .post(format!("{}/v1/addresses/resolve", self.relay_url))
163            .headers(self.headers())
164            .json(&Body {
165                auth_key: auth_key_hex,
166            })
167            .send()
168            .await
169            .map_err(|e| Error::Http(e.to_string()))?;
170
171        let body: ResolveResponse = resp
172            .json()
173            .await
174            .map_err(|e| Error::Http(e.to_string()))?;
175
176        body.address
177            .ok_or_else(|| Error::Http(body.error.unwrap_or_else(|| "No address returned".into())))
178    }
179
180    /// Execute a GraphQL query against the subgraph via relay proxy.
181    pub async fn graphql<T: for<'de> Deserialize<'de>>(
182        &self,
183        query: &str,
184        variables: serde_json::Value,
185    ) -> Result<T> {
186        let resp = self
187            .client
188            .post(format!("{}/v1/subgraph", self.relay_url))
189            .headers(self.headers())
190            .json(&serde_json::json!({
191                "query": query,
192                "variables": variables,
193            }))
194            .send()
195            .await
196            .map_err(|e| Error::Http(e.to_string()))?;
197
198        if resp.status().as_u16() == 403 {
199            // Quota exceeded — invalidate billing cache and return specific error
200            crate::billing::invalidate_cache();
201            let text = resp.text().await.unwrap_or_default();
202            return Err(Error::QuotaExceeded(format!(
203                "Quota exceeded (403). Billing cache invalidated. Upgrade to Pro for unlimited storage. Server: {}",
204                text
205            )));
206        }
207
208        if !resp.status().is_success() {
209            let status = resp.status();
210            let text = resp.text().await.unwrap_or_default();
211            return Err(Error::Http(format!("GraphQL HTTP {}: {}", status, text)));
212        }
213
214        let gql: GraphQLResponse<T> = resp
215            .json()
216            .await
217            .map_err(|e| Error::Http(format!("GraphQL parse error: {}", e)))?;
218
219        gql.data
220            .ok_or_else(|| Error::Http("GraphQL returned no data".into()))
221    }
222
223    /// Submit a single protobuf payload as a native UserOp.
224    pub async fn submit_fact_native(
225        &self,
226        protobuf_payload: &[u8],
227        private_key: &[u8; 32],
228    ) -> Result<crate::userop::SubmitResult> {
229        let calldata = crate::userop::encode_single_call(protobuf_payload);
230        crate::userop::submit_userop(
231            &calldata,
232            &self.wallet_address,
233            private_key,
234            &self.relay_url,
235            &self.auth_key_hex,
236            self.chain_id,
237            self.is_test,
238        )
239        .await
240    }
241
242    /// Submit multiple protobuf payloads as a single batched UserOp.
243    pub async fn submit_fact_batch_native(
244        &self,
245        protobuf_payloads: &[Vec<u8>],
246        private_key: &[u8; 32],
247    ) -> Result<crate::userop::SubmitResult> {
248        let calldata = crate::userop::encode_batch_call(protobuf_payloads)?;
249        crate::userop::submit_userop(
250            &calldata,
251            &self.wallet_address,
252            private_key,
253            &self.relay_url,
254            &self.auth_key_hex,
255            self.chain_id,
256            self.is_test,
257        )
258        .await
259    }
260
261    /// Submit a protobuf payload via the bundler proxy (legacy, non-native).
262    pub async fn submit_protobuf(&self, payload: &[u8]) -> Result<SubmitResult> {
263        let payload_hex = hex::encode(payload);
264
265        let resp = self
266            .client
267            .post(format!("{}/v1/bundler", self.relay_url))
268            .headers(self.headers())
269            .json(&serde_json::json!({
270                "jsonrpc": "2.0",
271                "method": "eth_sendUserOperation",
272                "params": [{
273                    "callData": format!("0x{}", payload_hex),
274                }],
275                "id": 1,
276            }))
277            .send()
278            .await
279            .map_err(|e| Error::Http(e.to_string()))?;
280
281        let body: serde_json::Value = resp
282            .json()
283            .await
284            .map_err(|e| Error::Http(e.to_string()))?;
285
286        Ok(SubmitResult {
287            tx_hash: body["result"]["txHash"]
288                .as_str()
289                .unwrap_or("")
290                .to_string(),
291            user_op_hash: body["result"]["userOpHash"]
292                .as_str()
293                .unwrap_or("")
294                .to_string(),
295            success: body["result"]["success"].as_bool().unwrap_or(false),
296        })
297    }
298
299    /// Health check against the relay.
300    pub async fn health_check(&self) -> Result<bool> {
301        let resp = self
302            .client
303            .get(format!("{}/health", self.relay_url))
304            .send()
305            .await
306            .map_err(|e| Error::Http(e.to_string()))?;
307
308        Ok(resp.status().is_success())
309    }
310
311    /// Get billing status.
312    pub async fn billing_status(&self) -> Result<BillingStatus> {
313        let resp = self
314            .client
315            .get(format!(
316                "{}/v1/billing/status?wallet_address={}",
317                self.relay_url, self.wallet_address
318            ))
319            .headers(self.headers())
320            .send()
321            .await
322            .map_err(|e| Error::Http(e.to_string()))?;
323
324        resp.json()
325            .await
326            .map_err(|e| Error::Http(e.to_string()))
327    }
328
329    /// Create a Stripe checkout session for upgrading to Pro.
330    pub async fn create_checkout(&self) -> Result<String> {
331        let resp = self
332            .client
333            .post(format!("{}/v1/billing/checkout", self.relay_url))
334            .headers(self.headers())
335            .json(&serde_json::json!({
336                "wallet_address": self.wallet_address,
337                "tier": "pro",
338            }))
339            .send()
340            .await
341            .map_err(|e| Error::Http(e.to_string()))?;
342
343        let body: serde_json::Value = resp
344            .json()
345            .await
346            .map_err(|e| Error::Http(e.to_string()))?;
347
348        body["checkout_url"]
349            .as_str()
350            .map(|s| s.to_string())
351            .ok_or_else(|| Error::Http("No checkout_url in response".into()))
352    }
353
354    /// Get the relay URL.
355    pub fn relay_url(&self) -> &str {
356        &self.relay_url
357    }
358
359    /// Get the wallet address.
360    pub fn wallet_address(&self) -> &str {
361        &self.wallet_address
362    }
363
364    /// Get the auth key hex.
365    pub fn auth_key_hex(&self) -> &str {
366        &self.auth_key_hex
367    }
368
369    /// Whether this is a test client.
370    pub fn is_test(&self) -> bool {
371        self.is_test
372    }
373
374    /// Get the chain ID.
375    pub fn chain_id(&self) -> u64 {
376        self.chain_id
377    }
378
379    /// Set the chain ID (used for Pro tier auto-detection).
380    pub fn set_chain_id(&mut self, chain_id: u64) {
381        self.chain_id = chain_id;
382    }
383}
384
385/// Result of submitting a UserOp.
386#[derive(Debug)]
387pub struct SubmitResult {
388    pub tx_hash: String,
389    pub user_op_hash: String,
390    pub success: bool,
391}