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 MinaClient {
53    /// Create a new client with default settings.
54    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    /// Create a new client with custom configuration.
62    ///
63    /// # Panics
64    ///
65    /// Panics if `retries` is 0, `retry_delay` is negative, or `timeout` is zero.
66    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    /// Execute a raw GraphQL query and return the `data` field of the response.
80    ///
81    /// This method is public to allow downstream crates (e.g. mina-perf-testing)
82    /// to run custom queries through the same client with retry logic.
83    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    /// Get the GraphQL endpoint URI.
175    pub fn graphql_uri(&self) -> &str {
176        &self.config.graphql_uri
177    }
178
179    // -- Queries --
180
181    /// Get the node's sync status.
182    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    /// Get comprehensive daemon status.
199    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    /// Get the network identifier.
234    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    /// Get account data for a public key.
248    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    /// Get blocks from the best chain.
297    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    /// Get the list of connected peers.
335    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    /// Get pending user commands from the transaction pool.
356    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    // -- Mutations --
377
378    /// Send a payment transaction.
379    ///
380    /// Requires the sender's account to be unlocked on the node.
381    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    /// Send a stake delegation transaction.
420    ///
421    /// Requires the sender's account to be unlocked on the node.
422    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    /// Set or unset the SNARK worker key.
459    ///
460    /// Pass `None` to disable the SNARK worker.
461    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    /// Set the fee for SNARK work.
472    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
488/// Parse a JSON value that may be a string or number into u64.
489fn 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}