1use std::time::Duration;
2
3use serde_json::{json, Value};
4use tracing::{debug, warn};
5
6use crate::error::{Error, GraphqlErrorEntry, Result};
7use crate::types::*;
8use crate::{queries, Currency};
9
10#[derive(Debug, Clone)]
12pub struct ClientConfig {
13 pub graphql_uri: String,
15 pub retries: u32,
17 pub retry_delay: Duration,
19 pub timeout: Duration,
21}
22
23impl Default for ClientConfig {
24 fn default() -> Self {
25 Self {
26 graphql_uri: "http://127.0.0.1:3085/graphql".to_string(),
27 retries: 3,
28 retry_delay: Duration::from_secs(5),
29 timeout: Duration::from_secs(30),
30 }
31 }
32}
33
34pub struct MinaClient {
48 config: ClientConfig,
49 http: reqwest::Client,
50}
51
52impl MinaClient {
53 pub fn new(graphql_uri: &str) -> Self {
55 Self::with_config(ClientConfig {
56 graphql_uri: graphql_uri.to_string(),
57 ..Default::default()
58 })
59 }
60
61 pub fn with_config(config: ClientConfig) -> Self {
67 assert!(config.retries >= 1, "retries must be at least 1");
68 assert!(
69 !config.timeout.is_zero(),
70 "timeout must be greater than zero"
71 );
72 let http = reqwest::Client::builder()
73 .timeout(config.timeout)
74 .build()
75 .expect("failed to build HTTP client");
76 Self { config, http }
77 }
78
79 pub async fn execute_query(
84 &self,
85 query: &str,
86 variables: Option<Value>,
87 query_name: &str,
88 ) -> Result<Value> {
89 let mut payload = json!({ "query": query });
90 if let Some(vars) = variables {
91 payload["variables"] = vars;
92 }
93
94 let mut last_err: Option<reqwest::Error> = None;
95
96 for attempt in 1..=self.config.retries {
97 debug!(
98 query_name,
99 attempt,
100 max = self.config.retries,
101 "GraphQL request"
102 );
103
104 match self
105 .http
106 .post(&self.config.graphql_uri)
107 .json(&payload)
108 .send()
109 .await
110 {
111 Ok(resp) => {
112 let status = resp.status();
113 if !status.is_success() {
114 warn!(query_name, attempt, %status, "HTTP error");
115 last_err = Some(resp.error_for_status().unwrap_err());
116 if attempt < self.config.retries {
117 tokio::time::sleep(self.config.retry_delay).await;
118 }
119 continue;
120 }
121 match resp.json::<Value>().await {
122 Ok(body) => {
123 if let Some(errors) = body.get("errors").and_then(|e| e.as_array()) {
124 let entries: Vec<GraphqlErrorEntry> = errors
125 .iter()
126 .map(|e| GraphqlErrorEntry {
127 message: e
128 .get("message")
129 .and_then(|m| m.as_str())
130 .unwrap_or("unknown error")
131 .to_string(),
132 })
133 .collect();
134 let messages = entries
135 .iter()
136 .map(|e| e.message.as_str())
137 .collect::<Vec<_>>()
138 .join("; ");
139 return Err(Error::Graphql {
140 query_name: query_name.to_string(),
141 messages,
142 errors: entries,
143 });
144 }
145 return Ok(body
146 .get("data")
147 .cloned()
148 .unwrap_or(Value::Object(Default::default())));
149 }
150 Err(e) => {
151 warn!(query_name, attempt, error = %e, "failed to parse response");
152 last_err = Some(e);
153 }
154 }
155 }
156 Err(e) => {
157 warn!(query_name, attempt, error = %e, "connection error");
158 last_err = Some(e);
159 }
160 }
161
162 if attempt < self.config.retries {
163 tokio::time::sleep(self.config.retry_delay).await;
164 }
165 }
166
167 Err(Error::Connection {
168 query_name: query_name.to_string(),
169 attempts: self.config.retries,
170 source: last_err.expect("at least one attempt must have been made"),
171 })
172 }
173
174 pub fn graphql_uri(&self) -> &str {
176 &self.config.graphql_uri
177 }
178
179 pub async fn get_sync_status(&self) -> Result<SyncStatus> {
183 let data = self
184 .execute_query(queries::SYNC_STATUS, None, "get_sync_status")
185 .await?;
186 let s = data["syncStatus"]
187 .as_str()
188 .ok_or_else(|| Error::MissingField {
189 query_name: "get_sync_status".into(),
190 field: "syncStatus".into(),
191 })?;
192 serde_json::from_value(Value::String(s.to_string())).map_err(|_| Error::MissingField {
193 query_name: "get_sync_status".into(),
194 field: "syncStatus".into(),
195 })
196 }
197
198 pub async fn get_daemon_status(&self) -> Result<DaemonStatus> {
200 let data = self
201 .execute_query(queries::DAEMON_STATUS, None, "get_daemon_status")
202 .await?;
203 let status = &data["daemonStatus"];
204
205 let sync_status: SyncStatus =
206 serde_json::from_value(status.get("syncStatus").cloned().unwrap_or(Value::Null))
207 .map_err(|_| Error::MissingField {
208 query_name: "get_daemon_status".into(),
209 field: "syncStatus".into(),
210 })?;
211
212 let peers = status.get("peers").and_then(|p| p.as_array()).map(|arr| {
213 arr.iter()
214 .map(|p| PeerInfo {
215 peer_id: p["peerId"].as_str().unwrap_or_default().to_string(),
216 host: p["host"].as_str().unwrap_or_default().to_string(),
217 port: p["libp2pPort"].as_i64().unwrap_or_default(),
218 })
219 .collect()
220 });
221
222 Ok(DaemonStatus {
223 sync_status,
224 blockchain_length: status["blockchainLength"].as_i64(),
225 highest_block_length_received: status["highestBlockLengthReceived"].as_i64(),
226 uptime_secs: status["uptimeSecs"].as_i64(),
227 state_hash: status["stateHash"].as_str().map(String::from),
228 commit_id: status["commitId"].as_str().map(String::from),
229 peers,
230 })
231 }
232
233 pub async fn get_network_id(&self) -> Result<String> {
235 let data = self
236 .execute_query(queries::NETWORK_ID, None, "get_network_id")
237 .await?;
238 data["networkID"]
239 .as_str()
240 .map(String::from)
241 .ok_or_else(|| Error::MissingField {
242 query_name: "get_network_id".into(),
243 field: "networkID".into(),
244 })
245 }
246
247 pub async fn get_account(
249 &self,
250 public_key: &str,
251 token_id: Option<&str>,
252 ) -> Result<AccountData> {
253 let (query, vars) = match token_id {
254 Some(token) => (
255 queries::GET_ACCOUNT_WITH_TOKEN,
256 json!({ "publicKey": public_key, "token": token }),
257 ),
258 None => (queries::GET_ACCOUNT, json!({ "publicKey": public_key })),
259 };
260
261 let data = self.execute_query(query, Some(vars), "get_account").await?;
262
263 let acc = data
264 .get("account")
265 .filter(|v| !v.is_null())
266 .ok_or_else(|| Error::AccountNotFound(public_key.to_string()))?;
267
268 let balance = &acc["balance"];
269 let total = Currency::from_graphql(balance["total"].as_str().unwrap_or("0"))?;
270 let liquid = balance["liquid"]
271 .as_str()
272 .map(Currency::from_graphql)
273 .transpose()?;
274 let locked = balance["locked"]
275 .as_str()
276 .map(Currency::from_graphql)
277 .transpose()?;
278
279 Ok(AccountData {
280 public_key: acc["publicKey"].as_str().unwrap_or_default().to_string(),
281 nonce: acc["nonce"]
282 .as_str()
283 .and_then(|s| s.parse().ok())
284 .or_else(|| acc["nonce"].as_u64())
285 .unwrap_or(0),
286 delegate: acc["delegate"].as_str().map(String::from),
287 token_id: acc["tokenId"].as_str().map(String::from),
288 balance: AccountBalance {
289 total,
290 liquid,
291 locked,
292 },
293 })
294 }
295
296 pub async fn get_best_chain(&self, max_length: Option<u32>) -> Result<Vec<BlockInfo>> {
298 let vars = max_length.map(|n| json!({ "maxLength": n }));
299 let data = self
300 .execute_query(queries::BEST_CHAIN, vars, "get_best_chain")
301 .await?;
302
303 let chain = match data.get("bestChain").and_then(|c| c.as_array()) {
304 Some(arr) => arr,
305 None => return Ok(vec![]),
306 };
307
308 let blocks = chain
309 .iter()
310 .map(|block| {
311 let consensus = &block["protocolState"]["consensusState"];
312 let creator_pk = block
313 .get("creatorAccount")
314 .and_then(|c| c["publicKey"].as_str())
315 .unwrap_or("unknown")
316 .to_string();
317
318 BlockInfo {
319 state_hash: block["stateHash"].as_str().unwrap_or_default().to_string(),
320 height: parse_u64(&consensus["blockHeight"]),
321 global_slot_since_hard_fork: parse_u64(&consensus["slot"]),
322 global_slot_since_genesis: parse_u64(&consensus["slotSinceGenesis"]),
323 creator_pk,
324 command_transaction_count: block["commandTransactionCount"]
325 .as_i64()
326 .unwrap_or(0),
327 }
328 })
329 .collect();
330
331 Ok(blocks)
332 }
333
334 pub async fn get_peers(&self) -> Result<Vec<PeerInfo>> {
336 let data = self
337 .execute_query(queries::GET_PEERS, None, "get_peers")
338 .await?;
339 let peers = data
340 .get("getPeers")
341 .and_then(|p| p.as_array())
342 .map(|arr| {
343 arr.iter()
344 .map(|p| PeerInfo {
345 peer_id: p["peerId"].as_str().unwrap_or_default().to_string(),
346 host: p["host"].as_str().unwrap_or_default().to_string(),
347 port: p["libp2pPort"].as_i64().unwrap_or_default(),
348 })
349 .collect()
350 })
351 .unwrap_or_default();
352 Ok(peers)
353 }
354
355 pub async fn get_pooled_user_commands(
357 &self,
358 public_key: Option<&str>,
359 ) -> Result<Vec<PooledUserCommand>> {
360 let vars = json!({ "publicKey": public_key });
361 let data = self
362 .execute_query(
363 queries::POOLED_USER_COMMANDS,
364 Some(vars),
365 "get_pooled_user_commands",
366 )
367 .await?;
368
369 let commands: Vec<PooledUserCommand> = data
370 .get("pooledUserCommands")
371 .and_then(|c| serde_json::from_value(c.clone()).ok())
372 .unwrap_or_default();
373 Ok(commands)
374 }
375
376 pub async fn send_payment(
382 &self,
383 sender: &str,
384 receiver: &str,
385 amount: Currency,
386 fee: Currency,
387 memo: Option<&str>,
388 nonce: Option<u64>,
389 ) -> Result<SendPaymentResult> {
390 let mut input = json!({
391 "from": sender,
392 "to": receiver,
393 "amount": amount.to_nanomina_str(),
394 "fee": fee.to_nanomina_str(),
395 });
396 if let Some(m) = memo {
397 input["memo"] = Value::String(m.to_string());
398 }
399 if let Some(n) = nonce {
400 input["nonce"] = Value::String(n.to_string());
401 }
402
403 let data = self
404 .execute_query(
405 queries::SEND_PAYMENT,
406 Some(json!({ "input": input })),
407 "send_payment",
408 )
409 .await?;
410
411 let payment = &data["sendPayment"]["payment"];
412 Ok(SendPaymentResult {
413 id: payment["id"].as_str().unwrap_or_default().to_string(),
414 hash: payment["hash"].as_str().unwrap_or_default().to_string(),
415 nonce: parse_u64(&payment["nonce"]),
416 })
417 }
418
419 pub async fn send_delegation(
423 &self,
424 sender: &str,
425 delegate_to: &str,
426 fee: Currency,
427 memo: Option<&str>,
428 nonce: Option<u64>,
429 ) -> Result<SendDelegationResult> {
430 let mut input = json!({
431 "from": sender,
432 "to": delegate_to,
433 "fee": fee.to_nanomina_str(),
434 });
435 if let Some(m) = memo {
436 input["memo"] = Value::String(m.to_string());
437 }
438 if let Some(n) = nonce {
439 input["nonce"] = Value::String(n.to_string());
440 }
441
442 let data = self
443 .execute_query(
444 queries::SEND_DELEGATION,
445 Some(json!({ "input": input })),
446 "send_delegation",
447 )
448 .await?;
449
450 let delegation = &data["sendDelegation"]["delegation"];
451 Ok(SendDelegationResult {
452 id: delegation["id"].as_str().unwrap_or_default().to_string(),
453 hash: delegation["hash"].as_str().unwrap_or_default().to_string(),
454 nonce: parse_u64(&delegation["nonce"]),
455 })
456 }
457
458 pub async fn set_snark_worker(&self, public_key: Option<&str>) -> Result<Option<String>> {
462 let vars = json!({ "input": public_key });
463 let data = self
464 .execute_query(queries::SET_SNARK_WORKER, Some(vars), "set_snark_worker")
465 .await?;
466 Ok(data["setSnarkWorker"]["lastSnarkWorker"]
467 .as_str()
468 .map(String::from))
469 }
470
471 pub async fn set_snark_work_fee(&self, fee: Currency) -> Result<String> {
473 let vars = json!({ "fee": fee.to_nanomina_str() });
474 let data = self
475 .execute_query(
476 queries::SET_SNARK_WORK_FEE,
477 Some(vars),
478 "set_snark_work_fee",
479 )
480 .await?;
481 Ok(data["setSnarkWorkFee"]["lastFee"]
482 .as_str()
483 .unwrap_or_default()
484 .to_string())
485 }
486}
487
488fn parse_u64(v: &Value) -> u64 {
490 v.as_str()
491 .and_then(|s| s.parse().ok())
492 .or_else(|| v.as_u64())
493 .unwrap_or(0)
494}