1use account::{create_account_loader, AccountLoader, GraphQLAccount};
2use block::{GraphQLBlock, GraphQLSnarkJob, GraphQLUserCommands};
3use juniper::{graphql_value, EmptySubscription, FieldError, GraphQLEnum, RootNode};
4use ledger::{Account, AccountId};
5use mina_core::{
6 block::AppliedBlock, consensus::ConsensusConstants, constants::constraint_constants,
7 NetworkConfig,
8};
9use mina_node_common::rpc::RpcSender;
10use mina_p2p_messages::v2::{
11 conv, LedgerHash, MinaBaseSignedCommandStableV2, MinaBaseUserCommandStableV2,
12 MinaBaseZkappCommandTStableV1WireStableV1, TokenIdKeyHash, TransactionHash,
13};
14use mina_signer::CompressedPubKey;
15use node::{
16 account::AccountPublicKey,
17 ledger::read::LedgerStatus,
18 rpc::{
19 AccountQuery, GetBlockQuery, PooledCommandsQuery, RpcBestChainResponse,
20 RpcGenesisBlockResponse, RpcGetBlockResponse, RpcLedgerAccountDelegatorsGetResponse,
21 RpcLedgerStatusGetResponse, RpcNodeStatus, RpcPooledUserCommandsResponse,
22 RpcPooledZkappCommandsResponse, RpcRequest, RpcSnarkPoolCompletedJobsResponse,
23 RpcSnarkPoolPendingJobsGetResponse, RpcSnarkerConfig, RpcStatusGetResponse,
24 RpcSyncStatsGetResponse, RpcTransactionInjectResponse, RpcTransactionStatusGetResponse,
25 SyncStatsQuery,
26 },
27 stats::sync::SyncKind,
28 BuildEnv,
29};
30use o1_utils::field_helpers::FieldHelpersError;
31use snark::{GraphQLPendingSnarkWork, GraphQLSnarkWorker};
32use std::str::FromStr;
33use tokio::sync::OnceCell;
34use transaction::GraphQLTransactionStatus;
35use warp::{Filter, Rejection, Reply};
36use zkapp::GraphQLZkapp;
37
38pub mod account;
39pub mod block;
40pub mod constants;
41pub mod snark;
42pub mod transaction;
43pub mod user_command;
44pub mod zkapp;
45
46pub type GraphQLPublicKey = String;
48
49#[derive(Debug, thiserror::Error)]
50pub enum Error {
51 #[error("Conversion error: {0}")]
52 Conversion(ConversionError),
53 #[error("State machine empty response")]
54 StateMachineEmptyResponse,
55 #[error("Custom: {0}")]
56 Custom(String),
57}
58
59#[derive(Debug, thiserror::Error)]
60pub enum ConversionError {
61 #[error(transparent)]
62 Io(#[from] std::io::Error),
63 #[error(transparent)]
64 Conversion(#[from] mina_p2p_messages::v2::conv::Error),
65 #[error("Wrong variant")]
66 WrongVariant,
67 #[error("SerdeJson: {0}")]
68 SerdeJson(#[from] serde_json::Error),
69 #[error("Base58Check: {0}")]
70 Base58Check(#[from] mina_p2p_messages::b58::FromBase58CheckError),
71 #[error("Base58 error: {0}")]
72 Base58(#[from] bs58::decode::Error),
73 #[error(transparent)]
74 InvalidDecimalNumber(#[from] mina_p2p_messages::bigint::InvalidDecimalNumber),
75 #[error("Invalid bigint")]
76 InvalidBigInt,
77 #[error("Invalid hex")]
78 InvalidHex,
79 #[error(transparent)]
80 ParseInt(#[from] std::num::ParseIntError),
81 #[error(transparent)]
82 EnumParse(#[from] strum::ParseError),
83 #[error(transparent)]
84 TryFromInt(#[from] std::num::TryFromIntError),
85 #[error("Missing field: {0}")]
86 MissingField(String),
87 #[error("Invalid length")]
88 InvalidLength,
89 #[error("Custom: {0}")]
90 Custom(String),
91 #[error(transparent)]
92 FieldHelpers(#[from] FieldHelpersError),
93 #[error("Failed to convert integer to i32")]
94 Integer,
95}
96
97impl From<ConversionError> for Error {
98 fn from(value: ConversionError) -> Self {
99 Error::Conversion(value)
100 }
101}
102
103pub struct Context {
112 rpc_sender: RpcSender,
113 account_loader: AccountLoader,
114 statemachine_status_cache: OnceCell<Option<RpcNodeStatus>>,
116 best_tip_cache: OnceCell<Option<AppliedBlock>>,
117 ledger_status_cache: OnceCell<Option<LedgerStatus>>,
118}
119
120impl juniper::Context for Context {}
121
122impl Context {
123 pub fn new(rpc_sender: RpcSender) -> Self {
124 Self {
125 rpc_sender: rpc_sender.clone(),
126 statemachine_status_cache: OnceCell::new(),
127 best_tip_cache: OnceCell::new(),
128 ledger_status_cache: OnceCell::new(),
129 account_loader: create_account_loader(rpc_sender.clone()),
130 }
131 }
132
133 pub(crate) async fn get_or_fetch_status(&self) -> RpcStatusGetResponse {
134 self.statemachine_status_cache
135 .get_or_init(|| async {
136 self.rpc_sender
137 .oneshot_request(RpcRequest::StatusGet)
138 .await
139 .flatten()
140 })
141 .await
142 .clone()
143 }
144
145 pub(crate) async fn get_or_fetch_best_tip(&self) -> Option<AppliedBlock> {
146 self.best_tip_cache
147 .get_or_init(|| async {
148 self.rpc_sender
149 .oneshot_request(RpcRequest::BestChain(1))
150 .await
151 .and_then(|blocks: RpcBestChainResponse| blocks.first().cloned())
152 })
153 .await
154 .clone()
155 }
156
157 pub(crate) async fn get_or_fetch_ledger_status(
158 &self,
159 ledger_hash: &LedgerHash,
160 ) -> RpcLedgerStatusGetResponse {
161 self.ledger_status_cache
162 .get_or_init(|| async {
163 self.rpc_sender
164 .oneshot_request(RpcRequest::LedgerStatusGet(ledger_hash.clone()))
165 .await
166 .flatten()
167 })
168 .await
169 .clone()
170 }
171
172 pub(crate) async fn load_account(&self, account_id: AccountId) -> Option<GraphQLAccount> {
173 self.account_loader.try_load(account_id).await.ok()?.ok()
174 }
175
176 pub async fn fetch_delegators(
177 &self,
178 ledger_hash: LedgerHash,
179 account_id: AccountId,
180 ) -> RpcLedgerAccountDelegatorsGetResponse {
181 self.rpc_sender
182 .oneshot_request(RpcRequest::LedgerAccountDelegatorsGet(
183 ledger_hash.clone(),
184 account_id.clone(),
185 ))
186 .await
187 .flatten()
188 }
189}
190
191#[derive(Clone, Copy, Debug, GraphQLEnum)]
192#[allow(clippy::upper_case_acronyms)]
193enum SyncStatus {
194 CONNECTING,
195 LISTENING,
196 OFFLINE,
197 BOOTSTRAP,
198 SYNCED,
199 CATCHUP,
200}
201
202#[derive(Clone, Debug)]
203struct ProtocolState {
204 consensus_state: ConsensusState,
205 blockchain_state: BlockchainState,
206}
207
208#[juniper::graphql_object(context = Context)]
209impl ProtocolState {
210 fn consensus_state(&self) -> &ConsensusState {
211 &self.consensus_state
212 }
213
214 fn blockchain_state(&self) -> &BlockchainState {
215 &self.blockchain_state
216 }
217}
218
219#[derive(Clone, Debug)]
220struct ConsensusState {
221 block_height: i32,
222}
223
224#[juniper::graphql_object(context = Context)]
225impl ConsensusState {
226 fn block_height(&self) -> i32 {
227 self.block_height
228 }
229}
230
231#[derive(Clone, Debug)]
232struct BlockchainState {
233 snarked_ledger_hash: String,
234}
235
236#[juniper::graphql_object(context = Context)]
237impl BlockchainState {
238 fn snarked_ledger_hash(&self) -> &str {
239 &self.snarked_ledger_hash
240 }
241}
242
243#[derive(Clone, Debug)]
244struct BestChain {
245 state_hash: String,
246 protocol_state: ProtocolState,
247}
248
249#[juniper::graphql_object(context = Context)]
250impl BestChain {
251 fn state_hash(&self) -> &str {
252 &self.state_hash
253 }
254
255 fn protocol_state(&self) -> &ProtocolState {
256 &self.protocol_state
257 }
258}
259
260#[derive(Clone, Copy, Debug)]
261pub struct Query;
262
263#[juniper::graphql_object(context = Context)]
294impl Query {
295 async fn account(
304 public_key: String,
305 token: Option<String>,
306 context: &Context,
307 ) -> juniper::FieldResult<account::GraphQLAccount> {
308 let public_key = AccountPublicKey::from_str(&public_key)?;
309 let req = match token {
310 None => AccountQuery::SinglePublicKey(public_key),
311 Some(token) => {
312 let token_id = TokenIdKeyHash::from_str(&token)?;
313 AccountQuery::PubKeyWithTokenId(public_key, token_id)
314 }
315 };
316 let accounts: Vec<Account> = context
317 .rpc_sender
318 .oneshot_request(RpcRequest::LedgerAccountsGet(req))
319 .await
320 .ok_or(Error::StateMachineEmptyResponse)?;
321
322 Ok(accounts
323 .first()
324 .cloned()
325 .ok_or(Error::StateMachineEmptyResponse)?
326 .try_into()?)
327 }
328
329 async fn sync_status(context: &Context) -> juniper::FieldResult<SyncStatus> {
334 let state: RpcSyncStatsGetResponse = context
335 .rpc_sender
336 .oneshot_request(RpcRequest::SyncStatsGet(SyncStatsQuery { limit: Some(1) }))
337 .await
338 .ok_or(Error::StateMachineEmptyResponse)?;
339
340 if let Some(state) = state.as_ref().and_then(|s| s.first()) {
341 if state.synced.is_some() {
342 Ok(SyncStatus::SYNCED)
343 } else {
344 match &state.kind {
345 SyncKind::Bootstrap => Ok(SyncStatus::BOOTSTRAP),
346 SyncKind::Catchup => Ok(SyncStatus::CATCHUP),
347 }
348 }
349 } else {
350 Ok(SyncStatus::LISTENING)
351 }
352 }
353
354 async fn best_chain(
362 max_length: i32,
363 context: &Context,
364 ) -> juniper::FieldResult<Vec<GraphQLBlock>> {
365 let best_chain: Vec<AppliedBlock> = context
366 .rpc_sender
367 .oneshot_request(RpcRequest::BestChain(max_length as u32))
368 .await
369 .ok_or(Error::StateMachineEmptyResponse)?;
370
371 Ok(best_chain
372 .into_iter()
373 .map(|v| v.try_into())
374 .collect::<Result<Vec<_>, _>>()?)
375 }
376
377 async fn daemon_status(
382 _context: &Context,
383 ) -> juniper::FieldResult<constants::GraphQLDaemonStatus> {
384 Ok(constants::GraphQLDaemonStatus)
385 }
386
387 async fn genesis_constants(
392 context: &Context,
393 ) -> juniper::FieldResult<constants::GraphQLGenesisConstants> {
394 let consensus_constants: ConsensusConstants = context
395 .rpc_sender
396 .oneshot_request(RpcRequest::ConsensusConstantsGet)
397 .await
398 .ok_or(Error::StateMachineEmptyResponse)?;
399 let constraint_constants = constraint_constants();
400
401 Ok(constants::GraphQLGenesisConstants::try_new(
402 constraint_constants.clone(),
403 consensus_constants,
404 )?)
405 }
406
407 async fn transaction_status(
417 payment: Option<String>,
418 zkapp_transaction: Option<String>,
419 context: &Context,
420 ) -> juniper::FieldResult<GraphQLTransactionStatus> {
421 if payment.is_some() && zkapp_transaction.is_some() {
422 return Err(Error::Custom(
423 "Cannot provide both payment and zkapp transaction".to_string(),
424 )
425 .into());
426 }
427
428 let tx = if let Some(payment) = payment {
429 MinaBaseUserCommandStableV2::SignedCommand(MinaBaseSignedCommandStableV2::from_base64(
430 &payment,
431 )?)
432 } else if let Some(zkapp_transaction) = zkapp_transaction {
433 MinaBaseUserCommandStableV2::ZkappCommand(
434 MinaBaseZkappCommandTStableV1WireStableV1::from_base64(&zkapp_transaction)?,
435 )
436 } else {
437 return Err(Error::Custom(
438 "Must provide either payment or zkapp transaction".to_string(),
439 )
440 .into());
441 };
442 let res: RpcTransactionStatusGetResponse = context
443 .rpc_sender
444 .oneshot_request(RpcRequest::TransactionStatusGet(tx))
445 .await
446 .ok_or(Error::StateMachineEmptyResponse)?;
447
448 Ok(GraphQLTransactionStatus::from(res))
449 }
450
451 async fn block(
460 height: Option<i32>,
461 state_hash: Option<String>,
462 context: &Context,
463 ) -> juniper::FieldResult<GraphQLBlock> {
464 let query = match (height, state_hash) {
465 (Some(height), None) => GetBlockQuery::Height(height.try_into().unwrap_or(u32::MAX)),
466 (None, Some(state_hash)) => GetBlockQuery::Hash(state_hash.parse()?),
467 _ => {
468 return Err(Error::Custom(
469 "Must provide exactly one of state hash, height".to_owned(),
470 )
471 .into());
472 }
473 };
474
475 let res: Option<RpcGetBlockResponse> = context
476 .rpc_sender
477 .oneshot_request(RpcRequest::GetBlock(query.clone()))
478 .await;
479
480 match res {
481 None => Err(Error::Custom("response channel dropped".to_owned()).into()),
482 Some(None) => match query {
483 GetBlockQuery::Hash(hash) => Err(Error::Custom(format!(
484 "Could not find block with hash: `{}` in transition frontier",
485 hash
486 ))
487 .into()),
488 GetBlockQuery::Height(height) => Err(Error::Custom(format!(
489 "Could not find block with height: `{}` in transition frontier",
490 height
491 ))
492 .into()),
493 },
494 Some(Some(block)) => Ok(GraphQLBlock::try_from(block)?),
495 }
496 }
497
498 async fn pooled_user_commands(
507 &self,
508 public_key: Option<String>,
509 hashes: Option<Vec<String>>,
510 ids: Option<Vec<String>>,
511 context: &Context,
512 ) -> juniper::FieldResult<Vec<GraphQLUserCommands>> {
513 let query = parse_pooled_commands_query(
514 public_key,
515 hashes,
516 ids,
517 MinaBaseSignedCommandStableV2::from_base64,
518 )?;
519
520 let res: RpcPooledUserCommandsResponse = context
521 .rpc_sender
522 .oneshot_request(RpcRequest::PooledUserCommands(query))
523 .await
524 .ok_or(Error::StateMachineEmptyResponse)?;
525
526 Ok(res
527 .into_iter()
528 .map(GraphQLUserCommands::try_from)
529 .collect::<Result<Vec<_>, _>>()?)
530 }
531
532 async fn pooled_zkapp_commands(
541 public_key: Option<String>,
542 hashes: Option<Vec<String>>,
543 ids: Option<Vec<String>>,
544 context: &Context,
545 ) -> juniper::FieldResult<Vec<GraphQLZkapp>> {
546 let query = parse_pooled_commands_query(
547 public_key,
548 hashes,
549 ids,
550 MinaBaseZkappCommandTStableV1WireStableV1::from_base64,
551 )?;
552
553 let res: RpcPooledZkappCommandsResponse = context
554 .rpc_sender
555 .oneshot_request(RpcRequest::PooledZkappCommands(query))
556 .await
557 .ok_or(Error::StateMachineEmptyResponse)?;
558
559 Ok(res
560 .into_iter()
561 .map(GraphQLZkapp::try_from)
562 .collect::<Result<Vec<_>, _>>()?)
563 }
564
565 async fn genesis_block(context: &Context) -> juniper::FieldResult<GraphQLBlock> {
570 let block = context
571 .rpc_sender
572 .oneshot_request::<RpcGenesisBlockResponse>(RpcRequest::GenesisBlockGet)
573 .await
574 .ok_or(Error::StateMachineEmptyResponse)?
575 .ok_or(Error::StateMachineEmptyResponse)?;
576
577 Ok(GraphQLBlock::try_from(AppliedBlock {
578 block,
579 just_emitted_a_proof: false,
580 })?)
581 }
582
583 async fn snark_pool(context: &Context) -> juniper::FieldResult<Vec<GraphQLSnarkJob>> {
588 let jobs: RpcSnarkPoolCompletedJobsResponse = context
589 .rpc_sender
590 .oneshot_request(RpcRequest::SnarkPoolCompletedJobsGet)
591 .await
592 .ok_or(Error::StateMachineEmptyResponse)?;
593
594 Ok(jobs.iter().map(GraphQLSnarkJob::from).collect())
595 }
596
597 async fn pending_snark_work(
602 context: &Context,
603 ) -> juniper::FieldResult<Vec<GraphQLPendingSnarkWork>> {
604 let jobs: RpcSnarkPoolPendingJobsGetResponse = context
605 .rpc_sender
606 .oneshot_request(RpcRequest::SnarkPoolPendingJobsGet)
607 .await
608 .ok_or(Error::StateMachineEmptyResponse)?;
609
610 Ok(jobs
611 .into_iter()
612 .map(GraphQLPendingSnarkWork::try_from)
613 .collect::<Result<Vec<_>, _>>()?)
614 }
615
616 #[graphql(name = "networkID")]
621 async fn network_id(_context: &Context) -> juniper::FieldResult<String> {
622 let res = format!("mina:{}", NetworkConfig::global().name);
623 Ok(res)
624 }
625
626 async fn version(_context: &Context) -> juniper::FieldResult<String> {
631 let res = BuildEnv::get().git.commit_hash;
632 Ok(res)
633 }
634
635 async fn current_snark_worker(
640 &self,
641 context: &Context,
642 ) -> juniper::FieldResult<Option<GraphQLSnarkWorker>> {
643 let config: Option<RpcSnarkerConfig> = context
644 .rpc_sender
645 .oneshot_request(RpcRequest::SnarkerConfig)
646 .await
647 .ok_or(Error::StateMachineEmptyResponse)?;
648
649 let Some(config) = config else {
650 return Ok(None);
651 };
652
653 let account = context
654 .load_account(AccountId {
655 public_key: CompressedPubKey::try_from(&config.public_key)?,
656 token_id: TokenIdKeyHash::default().into(),
657 })
658 .await;
659
660 Ok(Some(GraphQLSnarkWorker {
661 key: config.public_key.to_string(),
662 account,
663 fee: config.fee.to_string(),
664 }))
665 }
666}
667
668async fn inject_tx<R>(
669 cmd: MinaBaseUserCommandStableV2,
670 context: &Context,
671) -> juniper::FieldResult<R>
672where
673 R: TryFrom<MinaBaseUserCommandStableV2>,
674{
675 let res: RpcTransactionInjectResponse = context
676 .rpc_sender
677 .oneshot_request(RpcRequest::TransactionInject(vec![cmd]))
678 .await
679 .ok_or(Error::StateMachineEmptyResponse)?;
680
681 match res {
682 RpcTransactionInjectResponse::Success(res) => {
683 let cmd: MinaBaseUserCommandStableV2 = match res.first().cloned() {
684 Some(cmd) => cmd.into(),
685 _ => unreachable!(),
686 };
687 cmd.try_into().map_err(|_| {
688 FieldError::new(
689 "Failed to convert transaction to the required type".to_string(),
690 graphql_value!(null),
691 )
692 })
693 }
694 RpcTransactionInjectResponse::Rejected(rejected) => {
695 let error_list = rejected
696 .into_iter()
697 .map(|(_, err)| graphql_value!({ "message": err.to_string() }))
698 .collect::<Vec<_>>();
699
700 Err(FieldError::new(
701 "Transaction rejected",
702 graphql_value!(juniper::Value::List(error_list)),
703 ))
704 }
705 RpcTransactionInjectResponse::Failure(failure) => {
706 let error_list = failure
707 .into_iter()
708 .map(|err| graphql_value!({ "message": err.to_string() }))
709 .collect::<Vec<_>>();
710
711 Err(FieldError::new(
712 "Transaction failed",
713 graphql_value!(juniper::Value::List(error_list)),
714 ))
715 }
716 }
717}
718
719#[derive(Clone, Debug)]
720pub struct Mutation;
721
722#[juniper::graphql_object(context = Context)]
731impl Mutation {
732 async fn send_zkapp(
740 input: zkapp::SendZkappInput,
741 context: &Context,
742 ) -> juniper::FieldResult<zkapp::GraphQLSendZkappResponse> {
743 inject_tx(input.try_into()?, context).await
744 }
745
746 async fn send_payment(
755 input: user_command::InputGraphQLPayment,
756 signature: user_command::UserCommandSignature,
757 context: &Context,
758 ) -> juniper::FieldResult<user_command::GraphQLSendPaymentResponse> {
759 let token_id = TokenIdKeyHash::default();
761 let public_key = AccountPublicKey::from_str(&input.from)
762 .map_err(|e| Error::Conversion(ConversionError::Base58Check(e)))?;
763
764 let accounts: Vec<Account> = context
765 .rpc_sender
766 .oneshot_request(RpcRequest::LedgerAccountsGet(
767 AccountQuery::PubKeyWithTokenId(public_key, token_id),
768 ))
769 .await
770 .ok_or(Error::StateMachineEmptyResponse)?;
771
772 let infered_nonce = accounts
773 .first()
774 .ok_or(Error::StateMachineEmptyResponse)?
775 .nonce;
776
777 let command = input
778 .create_user_command(infered_nonce, signature)
779 .map_err(Error::Conversion)?;
780
781 inject_tx(command, context).await
782 }
783
784 async fn send_delegation(
793 input: user_command::InputGraphQLDelegation,
794 signature: user_command::UserCommandSignature,
795 context: &Context,
796 ) -> juniper::FieldResult<user_command::GraphQLSendDelegationResponse> {
797 let token_id = TokenIdKeyHash::default();
799 let public_key = AccountPublicKey::from_str(&input.from)?;
800
801 let accounts: Vec<Account> = context
803 .rpc_sender
804 .oneshot_request(RpcRequest::LedgerAccountsGet(
805 AccountQuery::PubKeyWithTokenId(public_key, token_id),
806 ))
807 .await
808 .ok_or(Error::StateMachineEmptyResponse)?;
809
810 let infered_nonce = accounts
811 .first()
812 .ok_or(Error::StateMachineEmptyResponse)?
813 .nonce;
814 let command = input.create_user_command(infered_nonce, signature)?;
815
816 inject_tx(command, context).await
817 }
818}
819
820pub fn routes(
821 rpc_sernder: RpcSender,
822) -> impl Filter<Error = Rejection, Extract = impl Reply> + Clone {
823 let state = warp::any().map(move || Context::new(rpc_sernder.clone()));
824 let schema = RootNode::new(Query, Mutation, EmptySubscription::<Context>::new());
825 let graphql_filter = juniper_warp::make_graphql_filter(schema, state.boxed());
826 let graphiql_filter = juniper_warp::graphiql_filter("/graphql", None);
827 let playground_filter = juniper_warp::playground_filter("/graphql", None);
828
829 (warp::post().and(warp::path("graphql")).and(graphql_filter))
830 .or(warp::get()
831 .and(warp::path("playground"))
832 .and(playground_filter))
833 .or(warp::get().and(warp::path("graphiql")).and(graphiql_filter))
834}
835
836fn parse_pooled_commands_query<ID, F>(
840 public_key: Option<String>,
841 hashes: Option<Vec<String>>,
842 ids: Option<Vec<String>>,
843 id_map_fn: F,
844) -> Result<PooledCommandsQuery<ID>, ConversionError>
845where
846 F: Fn(&str) -> Result<ID, conv::Error>,
847{
848 let public_key = match public_key {
849 Some(public_key) => Some(AccountPublicKey::from_str(&public_key)?),
850 None => None,
851 };
852
853 let hashes = match hashes {
854 Some(hashes) => Some(
855 hashes
856 .into_iter()
857 .map(|tx| TransactionHash::from_str(tx.as_str()))
858 .collect::<Result<Vec<_>, _>>()?,
859 ),
860 None => None,
861 };
862
863 let ids = match ids {
864 Some(ids) => Some(
865 ids.into_iter()
866 .map(|id| id_map_fn(id.as_str()))
867 .collect::<Result<Vec<_>, _>>()?,
868 ),
869 None => None,
870 };
871
872 Ok(PooledCommandsQuery {
873 public_key,
874 hashes,
875 ids,
876 })
877}