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 Default for MinaClient {
53 fn default() -> Self {
56 Self::with_config(ClientConfig::default())
57 }
58}
59
60impl MinaClient {
61 pub fn new(graphql_uri: &str) -> Self {
63 Self::with_config(ClientConfig {
64 graphql_uri: graphql_uri.to_string(),
65 ..Default::default()
66 })
67 }
68
69 pub fn from_host_and_port(host: &str, port: u16) -> Self {
76 Self::new(&format!("http://{host}:{port}/graphql"))
77 }
78
79 pub fn with_config(config: ClientConfig) -> Self {
85 assert!(config.retries >= 1, "retries must be at least 1");
86 assert!(
87 !config.timeout.is_zero(),
88 "timeout must be greater than zero"
89 );
90 let http = reqwest::Client::builder()
91 .timeout(config.timeout)
92 .build()
93 .expect("failed to build HTTP client");
94 Self { config, http }
95 }
96
97 pub fn query<'a>(&'a self, query: &'a str) -> QueryBuilder<'a> {
123 QueryBuilder {
124 client: self,
125 query,
126 variables: None,
127 name: None,
128 }
129 }
130
131 pub async fn execute_query(
137 &self,
138 query: &str,
139 variables: Option<Value>,
140 query_name: &str,
141 ) -> Result<Value> {
142 let mut payload = json!({ "query": query });
143 if let Some(vars) = variables {
144 payload["variables"] = vars;
145 }
146
147 let mut last_err: Option<reqwest::Error> = None;
148
149 for attempt in 1..=self.config.retries {
150 debug!(
151 query_name,
152 attempt,
153 max = self.config.retries,
154 "GraphQL request"
155 );
156
157 match self
158 .http
159 .post(&self.config.graphql_uri)
160 .json(&payload)
161 .send()
162 .await
163 {
164 Ok(resp) => {
165 let status = resp.status();
166 if !status.is_success() {
167 warn!(query_name, attempt, %status, "HTTP error");
168 last_err = Some(resp.error_for_status().unwrap_err());
169 if attempt < self.config.retries {
170 tokio::time::sleep(self.config.retry_delay).await;
171 }
172 continue;
173 }
174 match resp.json::<Value>().await {
175 Ok(body) => {
176 if let Some(errors) = body.get("errors").and_then(|e| e.as_array()) {
177 let entries: Vec<GraphqlErrorEntry> = errors
178 .iter()
179 .map(|e| GraphqlErrorEntry {
180 message: e
181 .get("message")
182 .and_then(|m| m.as_str())
183 .unwrap_or("unknown error")
184 .to_string(),
185 })
186 .collect();
187 let messages = entries
188 .iter()
189 .map(|e| e.message.as_str())
190 .collect::<Vec<_>>()
191 .join("; ");
192 return Err(Error::Graphql {
193 query_name: query_name.to_string(),
194 messages,
195 errors: entries,
196 });
197 }
198 return Ok(body
199 .get("data")
200 .cloned()
201 .unwrap_or(Value::Object(Default::default())));
202 }
203 Err(e) => {
204 warn!(query_name, attempt, error = %e, "failed to parse response");
205 last_err = Some(e);
206 }
207 }
208 }
209 Err(e) => {
210 warn!(query_name, attempt, error = %e, "connection error");
211 last_err = Some(e);
212 }
213 }
214
215 if attempt < self.config.retries {
216 tokio::time::sleep(self.config.retry_delay).await;
217 }
218 }
219
220 Err(Error::Connection {
221 query_name: query_name.to_string(),
222 attempts: self.config.retries,
223 source: last_err.expect("at least one attempt must have been made"),
224 })
225 }
226
227 pub fn graphql_uri(&self) -> &str {
229 &self.config.graphql_uri
230 }
231
232 pub async fn get_sync_status(&self) -> Result<SyncStatus> {
236 let data = self
237 .execute_query(queries::SYNC_STATUS, None, "get_sync_status")
238 .await?;
239 let s = data["syncStatus"]
240 .as_str()
241 .ok_or_else(|| Error::MissingField {
242 query_name: "get_sync_status".into(),
243 field: "syncStatus".into(),
244 })?;
245 serde_json::from_value(Value::String(s.to_string())).map_err(|_| Error::MissingField {
246 query_name: "get_sync_status".into(),
247 field: "syncStatus".into(),
248 })
249 }
250
251 pub async fn get_daemon_status(&self) -> Result<DaemonStatus> {
253 let data = self
254 .execute_query(queries::DAEMON_STATUS, None, "get_daemon_status")
255 .await?;
256 let status = &data["daemonStatus"];
257
258 let sync_status: SyncStatus =
259 serde_json::from_value(status.get("syncStatus").cloned().unwrap_or(Value::Null))
260 .map_err(|_| Error::MissingField {
261 query_name: "get_daemon_status".into(),
262 field: "syncStatus".into(),
263 })?;
264
265 let peers = status.get("peers").and_then(|p| p.as_array()).map(|arr| {
266 arr.iter()
267 .map(|p| PeerInfo {
268 peer_id: p["peerId"].as_str().unwrap_or_default().to_string(),
269 host: p["host"].as_str().unwrap_or_default().to_string(),
270 port: p["libp2pPort"].as_i64().unwrap_or_default(),
271 })
272 .collect()
273 });
274
275 Ok(DaemonStatus {
276 sync_status,
277 blockchain_length: status["blockchainLength"].as_i64(),
278 highest_block_length_received: status["highestBlockLengthReceived"].as_i64(),
279 uptime_secs: status["uptimeSecs"].as_i64(),
280 state_hash: status["stateHash"].as_str().map(String::from),
281 commit_id: status["commitId"].as_str().map(String::from),
282 peers,
283 })
284 }
285
286 pub async fn get_network_id(&self) -> Result<String> {
288 let data = self
289 .execute_query(queries::NETWORK_ID, None, "get_network_id")
290 .await?;
291 data["networkID"]
292 .as_str()
293 .map(String::from)
294 .ok_or_else(|| Error::MissingField {
295 query_name: "get_network_id".into(),
296 field: "networkID".into(),
297 })
298 }
299
300 pub async fn get_account(
302 &self,
303 public_key: &str,
304 token_id: Option<&str>,
305 ) -> Result<AccountData> {
306 let (query, vars) = match token_id {
307 Some(token) => (
308 queries::GET_ACCOUNT_WITH_TOKEN,
309 json!({ "publicKey": public_key, "token": token }),
310 ),
311 None => (queries::GET_ACCOUNT, json!({ "publicKey": public_key })),
312 };
313
314 let data = self.execute_query(query, Some(vars), "get_account").await?;
315
316 let acc = data
317 .get("account")
318 .filter(|v| !v.is_null())
319 .ok_or_else(|| Error::AccountNotFound(public_key.to_string()))?;
320
321 let balance = &acc["balance"];
322 let total = Currency::from_graphql(balance["total"].as_str().unwrap_or("0"))?;
323 let liquid = balance["liquid"]
324 .as_str()
325 .map(Currency::from_graphql)
326 .transpose()?;
327 let locked = balance["locked"]
328 .as_str()
329 .map(Currency::from_graphql)
330 .transpose()?;
331
332 Ok(AccountData {
333 public_key: acc["publicKey"].as_str().unwrap_or_default().to_string(),
334 nonce: acc["nonce"]
335 .as_str()
336 .and_then(|s| s.parse().ok())
337 .or_else(|| acc["nonce"].as_u64())
338 .unwrap_or(0),
339 delegate: acc["delegate"].as_str().map(String::from),
340 token_id: acc["tokenId"].as_str().map(String::from),
341 balance: AccountBalance {
342 total,
343 liquid,
344 locked,
345 },
346 })
347 }
348
349 pub async fn get_best_chain(&self, max_length: Option<u32>) -> Result<Vec<BlockInfo>> {
351 let vars = max_length.map(|n| json!({ "maxLength": n }));
352 let data = self
353 .execute_query(queries::BEST_CHAIN, vars, "get_best_chain")
354 .await?;
355
356 let chain = match data.get("bestChain").and_then(|c| c.as_array()) {
357 Some(arr) => arr,
358 None => return Ok(vec![]),
359 };
360
361 let blocks = chain
362 .iter()
363 .map(|block| {
364 let consensus = &block["protocolState"]["consensusState"];
365 let creator_pk = block
366 .get("creatorAccount")
367 .and_then(|c| c["publicKey"].as_str())
368 .unwrap_or("unknown")
369 .to_string();
370
371 BlockInfo {
372 state_hash: block["stateHash"].as_str().unwrap_or_default().to_string(),
373 height: parse_u64(&consensus["blockHeight"]),
374 global_slot_since_hard_fork: parse_u64(&consensus["slot"]),
375 global_slot_since_genesis: parse_u64(&consensus["slotSinceGenesis"]),
376 creator_pk,
377 command_transaction_count: block["commandTransactionCount"]
378 .as_i64()
379 .unwrap_or(0),
380 }
381 })
382 .collect();
383
384 Ok(blocks)
385 }
386
387 pub async fn get_peers(&self) -> Result<Vec<PeerInfo>> {
389 let data = self
390 .execute_query(queries::GET_PEERS, None, "get_peers")
391 .await?;
392 let peers = data
393 .get("getPeers")
394 .and_then(|p| p.as_array())
395 .map(|arr| {
396 arr.iter()
397 .map(|p| PeerInfo {
398 peer_id: p["peerId"].as_str().unwrap_or_default().to_string(),
399 host: p["host"].as_str().unwrap_or_default().to_string(),
400 port: p["libp2pPort"].as_i64().unwrap_or_default(),
401 })
402 .collect()
403 })
404 .unwrap_or_default();
405 Ok(peers)
406 }
407
408 pub async fn get_pooled_user_commands(
410 &self,
411 public_key: Option<&str>,
412 ) -> Result<Vec<PooledUserCommand>> {
413 let vars = json!({ "publicKey": public_key });
414 let data = self
415 .execute_query(
416 queries::POOLED_USER_COMMANDS,
417 Some(vars),
418 "get_pooled_user_commands",
419 )
420 .await?;
421
422 let commands: Vec<PooledUserCommand> = data
423 .get("pooledUserCommands")
424 .and_then(|c| serde_json::from_value(c.clone()).ok())
425 .unwrap_or_default();
426 Ok(commands)
427 }
428
429 pub async fn send_payment(&self, payment: Payment) -> Result<SendPaymentResult> {
453 let mut input = json!({
454 "from": payment.sender,
455 "to": payment.receiver,
456 "amount": payment.amount.to_nanomina_str(),
457 "fee": payment.fee.to_nanomina_str(),
458 });
459 if let Some(m) = &payment.memo {
460 input["memo"] = Value::String(m.clone());
461 }
462 if let Some(n) = payment.nonce {
463 input["nonce"] = Value::String(n.to_string());
464 }
465
466 let data = self
467 .execute_query(
468 queries::SEND_PAYMENT,
469 Some(json!({ "input": input })),
470 "send_payment",
471 )
472 .await?;
473
474 let result = &data["sendPayment"]["payment"];
475 Ok(SendPaymentResult {
476 id: result["id"].as_str().unwrap_or_default().to_string(),
477 hash: result["hash"].as_str().unwrap_or_default().to_string(),
478 nonce: parse_u64(&result["nonce"]),
479 })
480 }
481
482 pub async fn send_delegation(&self, delegation: Delegation) -> Result<SendDelegationResult> {
501 let mut input = json!({
502 "from": delegation.sender,
503 "to": delegation.delegate_to,
504 "fee": delegation.fee.to_nanomina_str(),
505 });
506 if let Some(m) = &delegation.memo {
507 input["memo"] = Value::String(m.clone());
508 }
509 if let Some(n) = delegation.nonce {
510 input["nonce"] = Value::String(n.to_string());
511 }
512
513 let data = self
514 .execute_query(
515 queries::SEND_DELEGATION,
516 Some(json!({ "input": input })),
517 "send_delegation",
518 )
519 .await?;
520
521 let result = &data["sendDelegation"]["delegation"];
522 Ok(SendDelegationResult {
523 id: result["id"].as_str().unwrap_or_default().to_string(),
524 hash: result["hash"].as_str().unwrap_or_default().to_string(),
525 nonce: parse_u64(&result["nonce"]),
526 })
527 }
528
529 pub async fn set_snark_worker(&self, public_key: Option<&str>) -> Result<Option<String>> {
533 let vars = json!({ "input": public_key });
534 let data = self
535 .execute_query(queries::SET_SNARK_WORKER, Some(vars), "set_snark_worker")
536 .await?;
537 Ok(data["setSnarkWorker"]["lastSnarkWorker"]
538 .as_str()
539 .map(String::from))
540 }
541
542 pub async fn set_snark_work_fee(&self, fee: Currency) -> Result<String> {
544 let vars = json!({ "fee": fee.to_nanomina_str() });
545 let data = self
546 .execute_query(
547 queries::SET_SNARK_WORK_FEE,
548 Some(vars),
549 "set_snark_work_fee",
550 )
551 .await?;
552 Ok(data["setSnarkWorkFee"]["lastFee"]
553 .as_str()
554 .unwrap_or_default()
555 .to_string())
556 }
557}
558
559fn parse_u64(v: &Value) -> u64 {
561 v.as_str()
562 .and_then(|s| s.parse().ok())
563 .or_else(|| v.as_u64())
564 .unwrap_or(0)
565}
566
567#[must_use = "call .send() to execute the query"]
572pub struct QueryBuilder<'a> {
573 client: &'a MinaClient,
574 query: &'a str,
575 variables: Option<Value>,
576 name: Option<&'a str>,
577}
578
579impl<'a> QueryBuilder<'a> {
580 pub fn variables(mut self, variables: Value) -> Self {
582 self.variables = Some(variables);
583 self
584 }
585
586 pub fn name(mut self, name: &'a str) -> Self {
588 self.name = Some(name);
589 self
590 }
591
592 pub async fn send(self) -> Result<Value> {
594 self.client
595 .execute_query(self.query, self.variables, self.name.unwrap_or("custom"))
596 .await
597 }
598}