Skip to main content

mina_sdk/
client.rs

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/// Configuration for the Mina daemon client.
11#[derive(Debug, Clone)]
12pub struct ClientConfig {
13    /// The daemon's GraphQL endpoint URL.
14    pub graphql_uri: String,
15    /// Number of retry attempts for failed requests.
16    pub retries: u32,
17    /// Duration to wait between retries.
18    pub retry_delay: Duration,
19    /// HTTP request timeout.
20    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
34/// Client for interacting with a Mina daemon via its GraphQL API.
35///
36/// # Examples
37/// ```no_run
38/// # async fn example() -> mina_sdk::Result<()> {
39/// use mina_sdk::MinaClient;
40///
41/// let client = MinaClient::new("http://127.0.0.1:3085/graphql");
42/// let status = client.get_sync_status().await?;
43/// println!("Sync status: {status}");
44/// # Ok(())
45/// # }
46/// ```
47pub struct MinaClient {
48    config: ClientConfig,
49    http: reqwest::Client,
50}
51
52impl Default for MinaClient {
53    /// Create a client connected to the default local daemon
54    /// (`http://127.0.0.1:3085/graphql`) with default retry/timeout settings.
55    fn default() -> Self {
56        Self::with_config(ClientConfig::default())
57    }
58}
59
60impl MinaClient {
61    /// Create a new client with default settings.
62    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    /// Create a client targeting `http://{host}:{port}/graphql`.
70    ///
71    /// ```
72    /// use mina_sdk::MinaClient;
73    /// let client = MinaClient::from_host_and_port("127.0.0.1", 3085);
74    /// ```
75    pub fn from_host_and_port(host: &str, port: u16) -> Self {
76        Self::new(&format!("http://{host}:{port}/graphql"))
77    }
78
79    /// Create a new client with custom configuration.
80    ///
81    /// # Panics
82    ///
83    /// Panics if `retries` is 0, `retry_delay` is negative, or `timeout` is zero.
84    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    /// Start building a custom GraphQL query with named options.
98    ///
99    /// Preferred entry point for arbitrary queries — variables and name are
100    /// opt-in via the builder, so callers don't have to spell out `None`:
101    ///
102    /// ```no_run
103    /// # async fn example() -> mina_sdk::Result<()> {
104    /// use mina_sdk::MinaClient;
105    /// use serde_json::json;
106    ///
107    /// let client = MinaClient::default();
108    ///
109    /// let data = client.query("query { version }").send().await?;
110    /// println!("{}", data["version"]);
111    ///
112    /// let data = client
113    ///     .query("query ($len: Int) { bestChain(maxLength: $len) { stateHash } }")
114    ///     .variables(json!({ "len": 3 }))
115    ///     .name("best_chain_custom")
116    ///     .send()
117    ///     .await?;
118    /// # let _ = data;
119    /// # Ok(())
120    /// # }
121    /// ```
122    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    /// Execute a raw GraphQL query and return the `data` field of the response.
132    ///
133    /// This low-level method stays public so downstream crates
134    /// (e.g. mina-perf-testing) can reuse the client's retry logic. For new
135    /// code prefer [`MinaClient::query`], which exposes a builder.
136    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    /// Get the GraphQL endpoint URI.
228    pub fn graphql_uri(&self) -> &str {
229        &self.config.graphql_uri
230    }
231
232    // -- Queries --
233
234    /// Get the node's sync status.
235    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    /// Get comprehensive daemon status.
252    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    /// Get the network identifier.
287    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    /// Get account data for a public key.
301    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    /// Get blocks from the best chain.
350    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    /// Get the list of connected peers.
388    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    /// Get pending user commands from the transaction pool.
409    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    // -- Mutations --
430
431    /// Send a payment transaction.
432    ///
433    /// Requires the sender's account to be unlocked on the node.
434    ///
435    /// # Examples
436    ///
437    /// ```no_run
438    /// # async fn example(client: &mina_sdk::MinaClient) -> mina_sdk::Result<()> {
439    /// use mina_sdk::{Payment, Currency};
440    ///
441    /// let result = client.send_payment(
442    ///     Payment::sender("B62qsender...")
443    ///         .to("B62qreceiver...")
444    ///         .amount(Currency::from_mina("1.5")?)
445    ///         .fee(Currency::from_mina("0.01")?)
446    ///         .memo("coffee"),
447    /// ).await?;
448    /// println!("Tx hash: {}", result.hash);
449    /// # Ok(())
450    /// # }
451    /// ```
452    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    /// Send a stake delegation transaction.
483    ///
484    /// Requires the sender's account to be unlocked on the node.
485    ///
486    /// # Examples
487    ///
488    /// ```no_run
489    /// # async fn example(client: &mina_sdk::MinaClient) -> mina_sdk::Result<()> {
490    /// use mina_sdk::{Delegation, Currency};
491    ///
492    /// let result = client.send_delegation(
493    ///     Delegation::sender("B62qsender...")
494    ///         .to("B62qdelegate...")
495    ///         .fee(Currency::from_mina("0.01")?),
496    /// ).await?;
497    /// # Ok(())
498    /// # }
499    /// ```
500    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    /// Set or unset the SNARK worker key.
530    ///
531    /// Pass `None` to disable the SNARK worker.
532    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    /// Set the fee for SNARK work.
543    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
559/// Parse a JSON value that may be a string or number into u64.
560fn 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/// Builder returned by [`MinaClient::query`] for running arbitrary GraphQL.
568///
569/// Set optional `variables` and a log-friendly `name`, then `send()` to run
570/// the query through the client's retry logic.
571#[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    /// Attach GraphQL `$variables` to the query.
581    pub fn variables(mut self, variables: Value) -> Self {
582        self.variables = Some(variables);
583        self
584    }
585
586    /// Set a name for this query — used only in tracing/log messages.
587    pub fn name(mut self, name: &'a str) -> Self {
588        self.name = Some(name);
589        self
590    }
591
592    /// Execute the query. Returns the GraphQL `data` field as a `serde_json::Value`.
593    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}