Skip to main content

mina_node_native/graphql/
mod.rs

1use account::{create_account_loader, AccountLoader, GraphQLAccount};
2use block::{GraphQLBlock, GraphQLSnarkJob, GraphQLUserCommands};
3use juniper::{graphql_value, FieldError, GraphQLEnum};
4use ledger::{Account, AccountId};
5use mina_core::{
6    block::AppliedBlock, consensus::ConsensusConstants, constants::constraint_constants,
7    NetworkConfig,
8};
9use mina_node::{
10    account::AccountPublicKey,
11    ledger::read::LedgerStatus,
12    rpc::{
13        AccountQuery, GetBlockQuery, PooledCommandsQuery, RpcBestChainResponse,
14        RpcGenesisBlockResponse, RpcGetBlockResponse, RpcLedgerAccountDelegatorsGetResponse,
15        RpcLedgerStatusGetResponse, RpcNodeStatus, RpcPooledUserCommandsResponse,
16        RpcPooledZkappCommandsResponse, RpcRequest, RpcSnarkPoolCompletedJobsResponse,
17        RpcSnarkPoolPendingJobsGetResponse, RpcSnarkerConfig, RpcStatusGetResponse,
18        RpcSyncStatsGetResponse, RpcTransactionInjectResponse, RpcTransactionStatusGetResponse,
19        SyncStatsQuery,
20    },
21    stats::sync::SyncKind,
22    BuildEnv,
23};
24use mina_node_common::rpc::RpcSender;
25use mina_p2p_messages::v2::{
26    conv, LedgerHash, MinaBaseSignedCommandStableV2, MinaBaseUserCommandStableV2,
27    MinaBaseZkappCommandTStableV1WireStableV1, TokenIdKeyHash, TransactionHash,
28};
29use mina_signer::CompressedPubKey;
30use o1_utils::field_helpers::FieldHelpersError;
31use snark::{GraphQLPendingSnarkWork, GraphQLSnarkWorker};
32use std::str::FromStr;
33use tokio::sync::OnceCell;
34use transaction::GraphQLTransactionStatus;
35use zkapp::GraphQLZkapp;
36
37pub mod account;
38pub mod block;
39pub mod constants;
40pub mod snark;
41pub mod transaction;
42pub mod user_command;
43pub mod zkapp;
44
45/// Base58 encoded public key
46pub type GraphQLPublicKey = String;
47
48#[derive(Debug, thiserror::Error)]
49pub enum Error {
50    #[error("Conversion error: {0}")]
51    Conversion(ConversionError),
52    #[error("State machine empty response")]
53    StateMachineEmptyResponse,
54    #[error("Custom: {0}")]
55    Custom(String),
56}
57
58#[derive(Debug, thiserror::Error)]
59pub enum ConversionError {
60    #[error(transparent)]
61    Io(#[from] std::io::Error),
62    #[error(transparent)]
63    Conversion(#[from] mina_p2p_messages::v2::conv::Error),
64    #[error("Wrong variant")]
65    WrongVariant,
66    #[error("SerdeJson: {0}")]
67    SerdeJson(#[from] serde_json::Error),
68    #[error("Base58Check: {0}")]
69    Base58Check(#[from] mina_p2p_messages::b58::FromBase58CheckError),
70    #[error("Base58 error: {0}")]
71    Base58(#[from] bs58::decode::Error),
72    #[error(transparent)]
73    InvalidDecimalNumber(#[from] mina_p2p_messages::bigint::InvalidDecimalNumber),
74    #[error("Invalid bigint")]
75    InvalidBigInt,
76    #[error("Invalid hex")]
77    InvalidHex,
78    #[error(transparent)]
79    ParseInt(#[from] std::num::ParseIntError),
80    #[error(transparent)]
81    EnumParse(#[from] strum::ParseError),
82    #[error(transparent)]
83    TryFromInt(#[from] std::num::TryFromIntError),
84    #[error("Missing field: {0}")]
85    MissingField(String),
86    #[error("Invalid length")]
87    InvalidLength,
88    #[error("Custom: {0}")]
89    Custom(String),
90    #[error(transparent)]
91    FieldHelpers(#[from] FieldHelpersError),
92    #[error("Failed to convert integer to i32")]
93    Integer,
94}
95
96impl From<ConversionError> for Error {
97    fn from(value: ConversionError) -> Self {
98        Error::Conversion(value)
99    }
100}
101
102/// Context for the GraphQL API
103///
104/// This is used to share state between the GraphQL queries and mutations.
105///
106/// The caching used here is only valid for the lifetime of the context i.e. for
107/// one request which is the goal as we can have multiple sources for one
108/// request.
109/// This optimizes the number of request to the state machine
110pub struct Context {
111    rpc_sender: RpcSender,
112    account_loader: AccountLoader,
113    // Caches
114    statemachine_status_cache: OnceCell<Option<RpcNodeStatus>>,
115    best_tip_cache: OnceCell<Option<AppliedBlock>>,
116    ledger_status_cache: OnceCell<Option<LedgerStatus>>,
117}
118
119impl juniper::Context for Context {}
120
121impl Context {
122    pub fn new(rpc_sender: RpcSender) -> Self {
123        Self {
124            rpc_sender: rpc_sender.clone(),
125            statemachine_status_cache: OnceCell::new(),
126            best_tip_cache: OnceCell::new(),
127            ledger_status_cache: OnceCell::new(),
128            account_loader: create_account_loader(rpc_sender.clone()),
129        }
130    }
131
132    pub(crate) async fn get_or_fetch_status(&self) -> RpcStatusGetResponse {
133        self.statemachine_status_cache
134            .get_or_init(|| async {
135                self.rpc_sender
136                    .oneshot_request(RpcRequest::StatusGet)
137                    .await
138                    .flatten()
139            })
140            .await
141            .clone()
142    }
143
144    pub(crate) async fn get_or_fetch_best_tip(&self) -> Option<AppliedBlock> {
145        self.best_tip_cache
146            .get_or_init(|| async {
147                self.rpc_sender
148                    .oneshot_request(RpcRequest::BestChain(1))
149                    .await
150                    .and_then(|blocks: RpcBestChainResponse| blocks.first().cloned())
151            })
152            .await
153            .clone()
154    }
155
156    pub(crate) async fn get_or_fetch_ledger_status(
157        &self,
158        ledger_hash: &LedgerHash,
159    ) -> RpcLedgerStatusGetResponse {
160        self.ledger_status_cache
161            .get_or_init(|| async {
162                self.rpc_sender
163                    .oneshot_request(RpcRequest::LedgerStatusGet(ledger_hash.clone()))
164                    .await
165                    .flatten()
166            })
167            .await
168            .clone()
169    }
170
171    pub(crate) async fn load_account(&self, account_id: AccountId) -> Option<GraphQLAccount> {
172        self.account_loader.try_load(account_id).await.ok()?.ok()
173    }
174
175    pub async fn fetch_delegators(
176        &self,
177        ledger_hash: LedgerHash,
178        account_id: AccountId,
179    ) -> RpcLedgerAccountDelegatorsGetResponse {
180        self.rpc_sender
181            .oneshot_request(RpcRequest::LedgerAccountDelegatorsGet(
182                ledger_hash.clone(),
183                account_id.clone(),
184            ))
185            .await
186            .flatten()
187    }
188}
189
190#[derive(Clone, Copy, Debug, GraphQLEnum)]
191#[allow(clippy::upper_case_acronyms)]
192enum SyncStatus {
193    CONNECTING,
194    LISTENING,
195    OFFLINE,
196    BOOTSTRAP,
197    SYNCED,
198    CATCHUP,
199}
200
201#[derive(Clone, Copy, Debug)]
202pub struct Query;
203
204/// GraphQL Query endpoints exposed by the Mina node
205///
206/// # Available Queries:
207///
208/// ## Account Management
209/// - `account` - Retrieve account information for a public key
210/// - `current_snark_worker` - Get information about the current SNARK worker
211///
212/// ## Blockchain State
213/// - `sync_status` - Get the synchronization status of the node
214/// - `best_chain` - Retrieve the best chain of blocks
215/// - `block` - Get a specific block by hash or height
216/// - `genesis_block` - Retrieve the genesis block
217/// - `genesis_constants` - Get genesis configuration constants
218/// - `daemon_status` - Get the daemon status information
219///
220/// ## Transaction Pool
221/// - `pooled_user_commands` - Query pending user commands in the transaction
222///   pool
223/// - `pooled_zkapp_commands` - Query pending zkApp commands in the transaction
224///   pool
225/// - `transaction_status` - Check the status of a transaction
226///
227/// ## SNARK Pool
228/// - `snark_pool` - Get completed SNARK jobs
229/// - `pending_snark_work` - Get pending SNARK work items
230///
231/// ## Network Information
232/// - `network_id` - Get the chain-agnostic network identifier
233/// - `version` - Get the node version (git commit hash)
234#[juniper::graphql_object(context = Context)]
235impl Query {
236    /// Retrieve account information for a given public key
237    ///
238    /// # Arguments
239    /// - `public_key`: Base58-encoded public key
240    /// - `token`: Optional token ID (defaults to MINA token if not provided)
241    ///
242    /// # Returns
243    /// Account information including balance, nonce, delegate, and other fields
244    async fn account(
245        public_key: String,
246        token: Option<String>,
247        context: &Context,
248    ) -> juniper::FieldResult<account::GraphQLAccount> {
249        let public_key = AccountPublicKey::from_str(&public_key)?;
250        let req = match token {
251            None => AccountQuery::SinglePublicKey(public_key),
252            Some(token) => {
253                let token_id = TokenIdKeyHash::from_str(&token)?;
254                AccountQuery::PubKeyWithTokenId(public_key, token_id)
255            }
256        };
257        let accounts: Vec<Account> = context
258            .rpc_sender
259            .oneshot_request(RpcRequest::LedgerAccountsGet(req))
260            .await
261            .ok_or(Error::StateMachineEmptyResponse)?;
262
263        Ok(accounts
264            .first()
265            .cloned()
266            .ok_or(Error::StateMachineEmptyResponse)?
267            .try_into()?)
268    }
269
270    /// Get the current synchronization status of the node
271    ///
272    /// # Returns
273    /// One of: CONNECTING, LISTENING, OFFLINE, BOOTSTRAP, SYNCED, CATCHUP
274    async fn sync_status(context: &Context) -> juniper::FieldResult<SyncStatus> {
275        let state: RpcSyncStatsGetResponse = context
276            .rpc_sender
277            .oneshot_request(RpcRequest::SyncStatsGet(SyncStatsQuery { limit: Some(1) }))
278            .await
279            .ok_or(Error::StateMachineEmptyResponse)?;
280
281        if let Some(state) = state.as_ref().and_then(|s| s.first()) {
282            if state.synced.is_some() {
283                Ok(SyncStatus::SYNCED)
284            } else {
285                match &state.kind {
286                    SyncKind::Bootstrap => Ok(SyncStatus::BOOTSTRAP),
287                    SyncKind::Catchup => Ok(SyncStatus::CATCHUP),
288                }
289            }
290        } else {
291            Ok(SyncStatus::LISTENING)
292        }
293    }
294
295    /// Retrieve the best chain of blocks from the transition frontier
296    ///
297    /// # Arguments
298    /// - `max_length`: Maximum number of blocks to return
299    ///
300    /// # Returns
301    /// List of blocks in the best chain, ordered from newest to oldest
302    async fn best_chain(
303        max_length: i32,
304        context: &Context,
305    ) -> juniper::FieldResult<Vec<GraphQLBlock>> {
306        let best_chain: Vec<AppliedBlock> = context
307            .rpc_sender
308            .oneshot_request(RpcRequest::BestChain(max_length as u32))
309            .await
310            .ok_or(Error::StateMachineEmptyResponse)?;
311
312        Ok(best_chain
313            .into_iter()
314            .map(|v| v.try_into())
315            .collect::<Result<Vec<_>, _>>()?)
316    }
317
318    /// Get daemon status information including chain ID and configuration
319    ///
320    /// # Returns
321    /// Daemon status with chain ID, configuration, and other metadata
322    async fn daemon_status(
323        _context: &Context,
324    ) -> juniper::FieldResult<constants::GraphQLDaemonStatus> {
325        Ok(constants::GraphQLDaemonStatus)
326    }
327
328    /// Retrieve genesis configuration constants
329    ///
330    /// # Returns
331    /// Genesis constants including protocol parameters and constraints
332    async fn genesis_constants(
333        context: &Context,
334    ) -> juniper::FieldResult<constants::GraphQLGenesisConstants> {
335        let consensus_constants: ConsensusConstants = context
336            .rpc_sender
337            .oneshot_request(RpcRequest::ConsensusConstantsGet)
338            .await
339            .ok_or(Error::StateMachineEmptyResponse)?;
340        let constraint_constants = constraint_constants();
341
342        Ok(constants::GraphQLGenesisConstants::try_new(
343            constraint_constants.clone(),
344            consensus_constants,
345        )?)
346    }
347
348    /// Check the status of a transaction
349    ///
350    /// # Arguments
351    /// - `payment`: Base64-encoded signed command (mutually exclusive with
352    ///   zkapp_transaction)
353    /// - `zkapp_transaction`: Base64-encoded zkApp command (mutually exclusive with payment)
354    ///
355    /// # Returns
356    /// Transaction status (PENDING, INCLUDED, or UNKNOWN)
357    async fn transaction_status(
358        payment: Option<String>,
359        zkapp_transaction: Option<String>,
360        context: &Context,
361    ) -> juniper::FieldResult<GraphQLTransactionStatus> {
362        if payment.is_some() && zkapp_transaction.is_some() {
363            return Err(Error::Custom(
364                "Cannot provide both payment and zkapp transaction".to_string(),
365            )
366            .into());
367        }
368
369        let tx = if let Some(payment) = payment {
370            MinaBaseUserCommandStableV2::SignedCommand(MinaBaseSignedCommandStableV2::from_base64(
371                &payment,
372            )?)
373        } else if let Some(zkapp_transaction) = zkapp_transaction {
374            MinaBaseUserCommandStableV2::ZkappCommand(
375                MinaBaseZkappCommandTStableV1WireStableV1::from_base64(&zkapp_transaction)?,
376            )
377        } else {
378            return Err(Error::Custom(
379                "Must provide either payment or zkapp transaction".to_string(),
380            )
381            .into());
382        };
383        let res: RpcTransactionStatusGetResponse = context
384            .rpc_sender
385            .oneshot_request(RpcRequest::TransactionStatusGet(tx))
386            .await
387            .ok_or(Error::StateMachineEmptyResponse)?;
388
389        Ok(GraphQLTransactionStatus::from(res))
390    }
391
392    /// Retrieve a block with the given state hash or height from the transition frontier
393    ///
394    /// # Arguments
395    /// - `height`: Block height (mutually exclusive with state_hash)
396    /// - `state_hash`: Block state hash (mutually exclusive with height)
397    ///
398    /// # Returns
399    /// Block data including transactions, SNARK jobs, and metadata
400    async fn block(
401        height: Option<i32>,
402        state_hash: Option<String>,
403        context: &Context,
404    ) -> juniper::FieldResult<GraphQLBlock> {
405        let query = match (height, state_hash) {
406            (Some(height), None) => GetBlockQuery::Height(height.try_into().unwrap_or(u32::MAX)),
407            (None, Some(state_hash)) => GetBlockQuery::Hash(state_hash.parse()?),
408            _ => {
409                return Err(Error::Custom(
410                    "Must provide exactly one of state hash, height".to_owned(),
411                )
412                .into());
413            }
414        };
415
416        let res: Option<RpcGetBlockResponse> = context
417            .rpc_sender
418            .oneshot_request(RpcRequest::GetBlock(query.clone()))
419            .await;
420
421        match res {
422            None => Err(Error::Custom("response channel dropped".to_owned()).into()),
423            Some(None) => match query {
424                GetBlockQuery::Hash(hash) => Err(Error::Custom(format!(
425                    "Could not find block with hash: `{}` in transition frontier",
426                    hash
427                ))
428                .into()),
429                GetBlockQuery::Height(height) => Err(Error::Custom(format!(
430                    "Could not find block with height: `{}` in transition frontier",
431                    height
432                ))
433                .into()),
434            },
435            Some(Some(block)) => Ok(GraphQLBlock::try_from(block)?),
436        }
437    }
438
439    /// Retrieve all the scheduled user commands for a specified sender that
440    /// the current daemon sees in its transaction pool. All scheduled
441    /// commands are queried if no sender is specified
442    ///
443    /// Arguments:
444    /// - `public_key`: base58 encoded [`AccountPublicKey`]
445    /// - `hashes`: list of base58 encoded [`TransactionHash`]es
446    /// - `ids`: list of base64 encoded [`MinaBaseZkappCommandTStableV1WireStableV1`]
447    async fn pooled_user_commands(
448        &self,
449        public_key: Option<String>,
450        hashes: Option<Vec<String>>,
451        ids: Option<Vec<String>>,
452        context: &Context,
453    ) -> juniper::FieldResult<Vec<GraphQLUserCommands>> {
454        let query = parse_pooled_commands_query(
455            public_key,
456            hashes,
457            ids,
458            MinaBaseSignedCommandStableV2::from_base64,
459        )?;
460
461        let res: RpcPooledUserCommandsResponse = context
462            .rpc_sender
463            .oneshot_request(RpcRequest::PooledUserCommands(query))
464            .await
465            .ok_or(Error::StateMachineEmptyResponse)?;
466
467        Ok(res
468            .into_iter()
469            .map(GraphQLUserCommands::try_from)
470            .collect::<Result<Vec<_>, _>>()?)
471    }
472
473    /// Retrieve all the scheduled zkApp commands for a specified sender that
474    ///  the current daemon sees in its transaction pool. All scheduled
475    ///  commands are queried if no sender is specified
476    ///
477    /// Arguments:
478    /// - `public_key`: base58 encoded [`AccountPublicKey`]
479    /// - `hashes`: list of base58 encoded [`TransactionHash`]es
480    /// - `ids`: list of base64 encoded [`MinaBaseZkappCommandTStableV1WireStableV1`]
481    async fn pooled_zkapp_commands(
482        public_key: Option<String>,
483        hashes: Option<Vec<String>>,
484        ids: Option<Vec<String>>,
485        context: &Context,
486    ) -> juniper::FieldResult<Vec<GraphQLZkapp>> {
487        let query = parse_pooled_commands_query(
488            public_key,
489            hashes,
490            ids,
491            MinaBaseZkappCommandTStableV1WireStableV1::from_base64,
492        )?;
493
494        let res: RpcPooledZkappCommandsResponse = context
495            .rpc_sender
496            .oneshot_request(RpcRequest::PooledZkappCommands(query))
497            .await
498            .ok_or(Error::StateMachineEmptyResponse)?;
499
500        Ok(res
501            .into_iter()
502            .map(GraphQLZkapp::try_from)
503            .collect::<Result<Vec<_>, _>>()?)
504    }
505
506    /// Retrieve the genesis block
507    ///
508    /// # Returns
509    /// The genesis block data
510    async fn genesis_block(context: &Context) -> juniper::FieldResult<GraphQLBlock> {
511        let block = context
512            .rpc_sender
513            .oneshot_request::<RpcGenesisBlockResponse>(RpcRequest::GenesisBlockGet)
514            .await
515            .ok_or(Error::StateMachineEmptyResponse)?
516            .ok_or(Error::StateMachineEmptyResponse)?;
517
518        Ok(GraphQLBlock::try_from(AppliedBlock {
519            block,
520            just_emitted_a_proof: false,
521        })?)
522    }
523
524    /// Get completed SNARK jobs from the SNARK pool
525    ///
526    /// # Returns
527    /// List of completed SNARK jobs with proofs and fees
528    async fn snark_pool(context: &Context) -> juniper::FieldResult<Vec<GraphQLSnarkJob>> {
529        let jobs: RpcSnarkPoolCompletedJobsResponse = context
530            .rpc_sender
531            .oneshot_request(RpcRequest::SnarkPoolCompletedJobsGet)
532            .await
533            .ok_or(Error::StateMachineEmptyResponse)?;
534
535        Ok(jobs.iter().map(GraphQLSnarkJob::from).collect())
536    }
537
538    /// Get pending SNARK work items that need to be completed
539    ///
540    /// # Returns
541    /// List of pending SNARK work items with job specifications
542    async fn pending_snark_work(
543        context: &Context,
544    ) -> juniper::FieldResult<Vec<GraphQLPendingSnarkWork>> {
545        let jobs: RpcSnarkPoolPendingJobsGetResponse = context
546            .rpc_sender
547            .oneshot_request(RpcRequest::SnarkPoolPendingJobsGet)
548            .await
549            .ok_or(Error::StateMachineEmptyResponse)?;
550
551        Ok(jobs
552            .into_iter()
553            .map(GraphQLPendingSnarkWork::try_from)
554            .collect::<Result<Vec<_>, _>>()?)
555    }
556
557    /// The chain-agnostic identifier of the network
558    ///
559    /// # Returns
560    /// Network identifier in the format "mina:<network_name>"
561    #[graphql(name = "networkID")]
562    async fn network_id(_context: &Context) -> juniper::FieldResult<String> {
563        let res = format!("mina:{}", NetworkConfig::global().name);
564        Ok(res)
565    }
566
567    /// The version of the node (git commit hash)
568    ///
569    /// # Returns
570    /// Git commit hash of the current node build
571    async fn version(_context: &Context) -> juniper::FieldResult<String> {
572        let res = BuildEnv::get().git.commit_hash;
573        Ok(res)
574    }
575
576    /// Get information about the current SNARK worker if configured
577    ///
578    /// # Returns
579    /// SNARK worker configuration including public key, account, and fee
580    async fn current_snark_worker(
581        &self,
582        context: &Context,
583    ) -> juniper::FieldResult<Option<GraphQLSnarkWorker>> {
584        let config: Option<RpcSnarkerConfig> = context
585            .rpc_sender
586            .oneshot_request(RpcRequest::SnarkerConfig)
587            .await
588            .ok_or(Error::StateMachineEmptyResponse)?;
589
590        let Some(config) = config else {
591            return Ok(None);
592        };
593
594        let account = context
595            .load_account(AccountId {
596                public_key: CompressedPubKey::try_from(&config.public_key)?,
597                token_id: TokenIdKeyHash::default().into(),
598            })
599            .await;
600
601        Ok(Some(GraphQLSnarkWorker {
602            key: config.public_key.to_string(),
603            account,
604            fee: config.fee.to_string(),
605        }))
606    }
607}
608
609async fn inject_tx<R>(
610    cmd: MinaBaseUserCommandStableV2,
611    context: &Context,
612) -> juniper::FieldResult<R>
613where
614    R: TryFrom<MinaBaseUserCommandStableV2>,
615{
616    let res: RpcTransactionInjectResponse = context
617        .rpc_sender
618        .oneshot_request(RpcRequest::TransactionInject(vec![cmd]))
619        .await
620        .ok_or(Error::StateMachineEmptyResponse)?;
621
622    match res {
623        RpcTransactionInjectResponse::Success(res) => {
624            let cmd: MinaBaseUserCommandStableV2 = match res.first().cloned() {
625                Some(cmd) => cmd.into(),
626                _ => unreachable!(),
627            };
628            cmd.try_into().map_err(|_| {
629                FieldError::new(
630                    "Failed to convert transaction to the required type".to_string(),
631                    graphql_value!(null),
632                )
633            })
634        }
635        RpcTransactionInjectResponse::Rejected(rejected) => {
636            let error_list = rejected
637                .into_iter()
638                .map(|(_, err)| graphql_value!({ "message": err.to_string() }))
639                .collect::<Vec<_>>();
640
641            Err(FieldError::new(
642                "Transaction rejected",
643                graphql_value!(juniper::Value::List(error_list)),
644            ))
645        }
646        RpcTransactionInjectResponse::Failure(failure) => {
647            let error_list = failure
648                .into_iter()
649                .map(|err| graphql_value!({ "message": err.to_string() }))
650                .collect::<Vec<_>>();
651
652            Err(FieldError::new(
653                "Transaction failed",
654                graphql_value!(juniper::Value::List(error_list)),
655            ))
656        }
657    }
658}
659
660#[derive(Clone, Debug)]
661pub struct Mutation;
662
663/// GraphQL Mutation endpoints exposed by the Mina node
664///
665/// # Available Mutations:
666///
667/// ## Transaction Submission
668/// - `send_zkapp` - Submit a zkApp transaction to the network
669/// - `send_payment` - Send a payment transaction
670/// - `send_delegation` - Send a delegation transaction
671#[juniper::graphql_object(context = Context)]
672impl Mutation {
673    /// Submit a zkApp transaction to the network
674    ///
675    /// # Arguments
676    /// - `input`: zkApp command with account updates and fee payer information
677    ///
678    /// # Returns
679    /// Transaction response with hash and zkApp command details
680    async fn send_zkapp(
681        input: zkapp::SendZkappInput,
682        context: &Context,
683    ) -> juniper::FieldResult<zkapp::GraphQLSendZkappResponse> {
684        inject_tx(input.try_into()?, context).await
685    }
686
687    /// Send a payment transaction
688    ///
689    /// # Arguments
690    /// - `input`: Payment details including sender, receiver, amount, fee, and memo
691    /// - `signature`: Transaction signature
692    ///
693    /// # Returns
694    /// Payment response with transaction hash and details
695    async fn send_payment(
696        input: user_command::InputGraphQLPayment,
697        signature: user_command::UserCommandSignature,
698        context: &Context,
699    ) -> juniper::FieldResult<user_command::GraphQLSendPaymentResponse> {
700        // Grab the sender's account to get the infered nonce
701        let token_id = TokenIdKeyHash::default();
702        let public_key = AccountPublicKey::from_str(&input.from)
703            .map_err(|e| Error::Conversion(ConversionError::Base58Check(e)))?;
704
705        let accounts: Vec<Account> = context
706            .rpc_sender
707            .oneshot_request(RpcRequest::LedgerAccountsGet(
708                AccountQuery::PubKeyWithTokenId(public_key, token_id),
709            ))
710            .await
711            .ok_or(Error::StateMachineEmptyResponse)?;
712
713        let infered_nonce = accounts
714            .first()
715            .ok_or(Error::StateMachineEmptyResponse)?
716            .nonce;
717
718        let command = input
719            .create_user_command(infered_nonce, signature)
720            .map_err(Error::Conversion)?;
721
722        inject_tx(command, context).await
723    }
724
725    /// Send a delegation transaction
726    ///
727    /// # Arguments
728    /// - `input`: Delegation details including delegator, delegate, fee, and memo
729    /// - `signature`: Transaction signature
730    ///
731    /// # Returns
732    /// Delegation response with transaction hash and details
733    async fn send_delegation(
734        input: user_command::InputGraphQLDelegation,
735        signature: user_command::UserCommandSignature,
736        context: &Context,
737    ) -> juniper::FieldResult<user_command::GraphQLSendDelegationResponse> {
738        // Payment commands are always for the default (MINA) token
739        let token_id = TokenIdKeyHash::default();
740        let public_key = AccountPublicKey::from_str(&input.from)?;
741
742        // Grab the sender's account to get the infered nonce
743        let accounts: Vec<Account> = context
744            .rpc_sender
745            .oneshot_request(RpcRequest::LedgerAccountsGet(
746                AccountQuery::PubKeyWithTokenId(public_key, token_id),
747            ))
748            .await
749            .ok_or(Error::StateMachineEmptyResponse)?;
750
751        let infered_nonce = accounts
752            .first()
753            .ok_or(Error::StateMachineEmptyResponse)?
754            .nonce;
755        let command = input.create_user_command(infered_nonce, signature)?;
756
757        inject_tx(command, context).await
758    }
759}
760
761/// Helper function used by [`Query::pooled_user_commands`] and
762/// [`Query::pooled_zkapp_commands`] to parse public key, transaction hashes and
763/// command ids
764fn parse_pooled_commands_query<ID, F>(
765    public_key: Option<String>,
766    hashes: Option<Vec<String>>,
767    ids: Option<Vec<String>>,
768    id_map_fn: F,
769) -> Result<PooledCommandsQuery<ID>, ConversionError>
770where
771    F: Fn(&str) -> Result<ID, conv::Error>,
772{
773    let public_key = match public_key {
774        Some(public_key) => Some(AccountPublicKey::from_str(&public_key)?),
775        None => None,
776    };
777
778    let hashes = match hashes {
779        Some(hashes) => Some(
780            hashes
781                .into_iter()
782                .map(|tx| TransactionHash::from_str(tx.as_str()))
783                .collect::<Result<Vec<_>, _>>()?,
784        ),
785        None => None,
786    };
787
788    let ids = match ids {
789        Some(ids) => Some(
790            ids.into_iter()
791                .map(|id| id_map_fn(id.as_str()))
792                .collect::<Result<Vec<_>, _>>()?,
793        ),
794        None => None,
795    };
796
797    Ok(PooledCommandsQuery {
798        public_key,
799        hashes,
800        ids,
801    })
802}