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, Copy, Debug)]
203pub struct Query;
204
205#[juniper::graphql_object(context = Context)]
236impl Query {
237 async fn account(
246 public_key: String,
247 token: Option<String>,
248 context: &Context,
249 ) -> juniper::FieldResult<account::GraphQLAccount> {
250 let public_key = AccountPublicKey::from_str(&public_key)?;
251 let req = match token {
252 None => AccountQuery::SinglePublicKey(public_key),
253 Some(token) => {
254 let token_id = TokenIdKeyHash::from_str(&token)?;
255 AccountQuery::PubKeyWithTokenId(public_key, token_id)
256 }
257 };
258 let accounts: Vec<Account> = context
259 .rpc_sender
260 .oneshot_request(RpcRequest::LedgerAccountsGet(req))
261 .await
262 .ok_or(Error::StateMachineEmptyResponse)?;
263
264 Ok(accounts
265 .first()
266 .cloned()
267 .ok_or(Error::StateMachineEmptyResponse)?
268 .try_into()?)
269 }
270
271 async fn sync_status(context: &Context) -> juniper::FieldResult<SyncStatus> {
276 let state: RpcSyncStatsGetResponse = context
277 .rpc_sender
278 .oneshot_request(RpcRequest::SyncStatsGet(SyncStatsQuery { limit: Some(1) }))
279 .await
280 .ok_or(Error::StateMachineEmptyResponse)?;
281
282 if let Some(state) = state.as_ref().and_then(|s| s.first()) {
283 if state.synced.is_some() {
284 Ok(SyncStatus::SYNCED)
285 } else {
286 match &state.kind {
287 SyncKind::Bootstrap => Ok(SyncStatus::BOOTSTRAP),
288 SyncKind::Catchup => Ok(SyncStatus::CATCHUP),
289 }
290 }
291 } else {
292 Ok(SyncStatus::LISTENING)
293 }
294 }
295
296 async fn best_chain(
304 max_length: i32,
305 context: &Context,
306 ) -> juniper::FieldResult<Vec<GraphQLBlock>> {
307 let best_chain: Vec<AppliedBlock> = context
308 .rpc_sender
309 .oneshot_request(RpcRequest::BestChain(max_length as u32))
310 .await
311 .ok_or(Error::StateMachineEmptyResponse)?;
312
313 Ok(best_chain
314 .into_iter()
315 .map(|v| v.try_into())
316 .collect::<Result<Vec<_>, _>>()?)
317 }
318
319 async fn daemon_status(
324 _context: &Context,
325 ) -> juniper::FieldResult<constants::GraphQLDaemonStatus> {
326 Ok(constants::GraphQLDaemonStatus)
327 }
328
329 async fn genesis_constants(
334 context: &Context,
335 ) -> juniper::FieldResult<constants::GraphQLGenesisConstants> {
336 let consensus_constants: ConsensusConstants = context
337 .rpc_sender
338 .oneshot_request(RpcRequest::ConsensusConstantsGet)
339 .await
340 .ok_or(Error::StateMachineEmptyResponse)?;
341 let constraint_constants = constraint_constants();
342
343 Ok(constants::GraphQLGenesisConstants::try_new(
344 constraint_constants.clone(),
345 consensus_constants,
346 )?)
347 }
348
349 async fn transaction_status(
359 payment: Option<String>,
360 zkapp_transaction: Option<String>,
361 context: &Context,
362 ) -> juniper::FieldResult<GraphQLTransactionStatus> {
363 if payment.is_some() && zkapp_transaction.is_some() {
364 return Err(Error::Custom(
365 "Cannot provide both payment and zkapp transaction".to_string(),
366 )
367 .into());
368 }
369
370 let tx = if let Some(payment) = payment {
371 MinaBaseUserCommandStableV2::SignedCommand(MinaBaseSignedCommandStableV2::from_base64(
372 &payment,
373 )?)
374 } else if let Some(zkapp_transaction) = zkapp_transaction {
375 MinaBaseUserCommandStableV2::ZkappCommand(
376 MinaBaseZkappCommandTStableV1WireStableV1::from_base64(&zkapp_transaction)?,
377 )
378 } else {
379 return Err(Error::Custom(
380 "Must provide either payment or zkapp transaction".to_string(),
381 )
382 .into());
383 };
384 let res: RpcTransactionStatusGetResponse = context
385 .rpc_sender
386 .oneshot_request(RpcRequest::TransactionStatusGet(tx))
387 .await
388 .ok_or(Error::StateMachineEmptyResponse)?;
389
390 Ok(GraphQLTransactionStatus::from(res))
391 }
392
393 async fn block(
402 height: Option<i32>,
403 state_hash: Option<String>,
404 context: &Context,
405 ) -> juniper::FieldResult<GraphQLBlock> {
406 let query = match (height, state_hash) {
407 (Some(height), None) => GetBlockQuery::Height(height.try_into().unwrap_or(u32::MAX)),
408 (None, Some(state_hash)) => GetBlockQuery::Hash(state_hash.parse()?),
409 _ => {
410 return Err(Error::Custom(
411 "Must provide exactly one of state hash, height".to_owned(),
412 )
413 .into());
414 }
415 };
416
417 let res: Option<RpcGetBlockResponse> = context
418 .rpc_sender
419 .oneshot_request(RpcRequest::GetBlock(query.clone()))
420 .await;
421
422 match res {
423 None => Err(Error::Custom("response channel dropped".to_owned()).into()),
424 Some(None) => match query {
425 GetBlockQuery::Hash(hash) => Err(Error::Custom(format!(
426 "Could not find block with hash: `{}` in transition frontier",
427 hash
428 ))
429 .into()),
430 GetBlockQuery::Height(height) => Err(Error::Custom(format!(
431 "Could not find block with height: `{}` in transition frontier",
432 height
433 ))
434 .into()),
435 },
436 Some(Some(block)) => Ok(GraphQLBlock::try_from(block)?),
437 }
438 }
439
440 async fn pooled_user_commands(
449 &self,
450 public_key: Option<String>,
451 hashes: Option<Vec<String>>,
452 ids: Option<Vec<String>>,
453 context: &Context,
454 ) -> juniper::FieldResult<Vec<GraphQLUserCommands>> {
455 let query = parse_pooled_commands_query(
456 public_key,
457 hashes,
458 ids,
459 MinaBaseSignedCommandStableV2::from_base64,
460 )?;
461
462 let res: RpcPooledUserCommandsResponse = context
463 .rpc_sender
464 .oneshot_request(RpcRequest::PooledUserCommands(query))
465 .await
466 .ok_or(Error::StateMachineEmptyResponse)?;
467
468 Ok(res
469 .into_iter()
470 .map(GraphQLUserCommands::try_from)
471 .collect::<Result<Vec<_>, _>>()?)
472 }
473
474 async fn pooled_zkapp_commands(
483 public_key: Option<String>,
484 hashes: Option<Vec<String>>,
485 ids: Option<Vec<String>>,
486 context: &Context,
487 ) -> juniper::FieldResult<Vec<GraphQLZkapp>> {
488 let query = parse_pooled_commands_query(
489 public_key,
490 hashes,
491 ids,
492 MinaBaseZkappCommandTStableV1WireStableV1::from_base64,
493 )?;
494
495 let res: RpcPooledZkappCommandsResponse = context
496 .rpc_sender
497 .oneshot_request(RpcRequest::PooledZkappCommands(query))
498 .await
499 .ok_or(Error::StateMachineEmptyResponse)?;
500
501 Ok(res
502 .into_iter()
503 .map(GraphQLZkapp::try_from)
504 .collect::<Result<Vec<_>, _>>()?)
505 }
506
507 async fn genesis_block(context: &Context) -> juniper::FieldResult<GraphQLBlock> {
512 let block = context
513 .rpc_sender
514 .oneshot_request::<RpcGenesisBlockResponse>(RpcRequest::GenesisBlockGet)
515 .await
516 .ok_or(Error::StateMachineEmptyResponse)?
517 .ok_or(Error::StateMachineEmptyResponse)?;
518
519 Ok(GraphQLBlock::try_from(AppliedBlock {
520 block,
521 just_emitted_a_proof: false,
522 })?)
523 }
524
525 async fn snark_pool(context: &Context) -> juniper::FieldResult<Vec<GraphQLSnarkJob>> {
530 let jobs: RpcSnarkPoolCompletedJobsResponse = context
531 .rpc_sender
532 .oneshot_request(RpcRequest::SnarkPoolCompletedJobsGet)
533 .await
534 .ok_or(Error::StateMachineEmptyResponse)?;
535
536 Ok(jobs.iter().map(GraphQLSnarkJob::from).collect())
537 }
538
539 async fn pending_snark_work(
544 context: &Context,
545 ) -> juniper::FieldResult<Vec<GraphQLPendingSnarkWork>> {
546 let jobs: RpcSnarkPoolPendingJobsGetResponse = context
547 .rpc_sender
548 .oneshot_request(RpcRequest::SnarkPoolPendingJobsGet)
549 .await
550 .ok_or(Error::StateMachineEmptyResponse)?;
551
552 Ok(jobs
553 .into_iter()
554 .map(GraphQLPendingSnarkWork::try_from)
555 .collect::<Result<Vec<_>, _>>()?)
556 }
557
558 #[graphql(name = "networkID")]
563 async fn network_id(_context: &Context) -> juniper::FieldResult<String> {
564 let res = format!("mina:{}", NetworkConfig::global().name);
565 Ok(res)
566 }
567
568 async fn version(_context: &Context) -> juniper::FieldResult<String> {
573 let res = BuildEnv::get().git.commit_hash;
574 Ok(res)
575 }
576
577 async fn current_snark_worker(
582 &self,
583 context: &Context,
584 ) -> juniper::FieldResult<Option<GraphQLSnarkWorker>> {
585 let config: Option<RpcSnarkerConfig> = context
586 .rpc_sender
587 .oneshot_request(RpcRequest::SnarkerConfig)
588 .await
589 .ok_or(Error::StateMachineEmptyResponse)?;
590
591 let Some(config) = config else {
592 return Ok(None);
593 };
594
595 let account = context
596 .load_account(AccountId {
597 public_key: CompressedPubKey::try_from(&config.public_key)?,
598 token_id: TokenIdKeyHash::default().into(),
599 })
600 .await;
601
602 Ok(Some(GraphQLSnarkWorker {
603 key: config.public_key.to_string(),
604 account,
605 fee: config.fee.to_string(),
606 }))
607 }
608}
609
610async fn inject_tx<R>(
611 cmd: MinaBaseUserCommandStableV2,
612 context: &Context,
613) -> juniper::FieldResult<R>
614where
615 R: TryFrom<MinaBaseUserCommandStableV2>,
616{
617 let res: RpcTransactionInjectResponse = context
618 .rpc_sender
619 .oneshot_request(RpcRequest::TransactionInject(vec![cmd]))
620 .await
621 .ok_or(Error::StateMachineEmptyResponse)?;
622
623 match res {
624 RpcTransactionInjectResponse::Success(res) => {
625 let cmd: MinaBaseUserCommandStableV2 = match res.first().cloned() {
626 Some(cmd) => cmd.into(),
627 _ => unreachable!(),
628 };
629 cmd.try_into().map_err(|_| {
630 FieldError::new(
631 "Failed to convert transaction to the required type".to_string(),
632 graphql_value!(null),
633 )
634 })
635 }
636 RpcTransactionInjectResponse::Rejected(rejected) => {
637 let error_list = rejected
638 .into_iter()
639 .map(|(_, err)| graphql_value!({ "message": err.to_string() }))
640 .collect::<Vec<_>>();
641
642 Err(FieldError::new(
643 "Transaction rejected",
644 graphql_value!(juniper::Value::List(error_list)),
645 ))
646 }
647 RpcTransactionInjectResponse::Failure(failure) => {
648 let error_list = failure
649 .into_iter()
650 .map(|err| graphql_value!({ "message": err.to_string() }))
651 .collect::<Vec<_>>();
652
653 Err(FieldError::new(
654 "Transaction failed",
655 graphql_value!(juniper::Value::List(error_list)),
656 ))
657 }
658 }
659}
660
661#[derive(Clone, Debug)]
662pub struct Mutation;
663
664#[juniper::graphql_object(context = Context)]
673impl Mutation {
674 async fn send_zkapp(
682 input: zkapp::SendZkappInput,
683 context: &Context,
684 ) -> juniper::FieldResult<zkapp::GraphQLSendZkappResponse> {
685 inject_tx(input.try_into()?, context).await
686 }
687
688 async fn send_payment(
697 input: user_command::InputGraphQLPayment,
698 signature: user_command::UserCommandSignature,
699 context: &Context,
700 ) -> juniper::FieldResult<user_command::GraphQLSendPaymentResponse> {
701 let token_id = TokenIdKeyHash::default();
703 let public_key = AccountPublicKey::from_str(&input.from)
704 .map_err(|e| Error::Conversion(ConversionError::Base58Check(e)))?;
705
706 let accounts: Vec<Account> = context
707 .rpc_sender
708 .oneshot_request(RpcRequest::LedgerAccountsGet(
709 AccountQuery::PubKeyWithTokenId(public_key, token_id),
710 ))
711 .await
712 .ok_or(Error::StateMachineEmptyResponse)?;
713
714 let infered_nonce = accounts
715 .first()
716 .ok_or(Error::StateMachineEmptyResponse)?
717 .nonce;
718
719 let command = input
720 .create_user_command(infered_nonce, signature)
721 .map_err(Error::Conversion)?;
722
723 inject_tx(command, context).await
724 }
725
726 async fn send_delegation(
735 input: user_command::InputGraphQLDelegation,
736 signature: user_command::UserCommandSignature,
737 context: &Context,
738 ) -> juniper::FieldResult<user_command::GraphQLSendDelegationResponse> {
739 let token_id = TokenIdKeyHash::default();
741 let public_key = AccountPublicKey::from_str(&input.from)?;
742
743 let accounts: Vec<Account> = context
745 .rpc_sender
746 .oneshot_request(RpcRequest::LedgerAccountsGet(
747 AccountQuery::PubKeyWithTokenId(public_key, token_id),
748 ))
749 .await
750 .ok_or(Error::StateMachineEmptyResponse)?;
751
752 let infered_nonce = accounts
753 .first()
754 .ok_or(Error::StateMachineEmptyResponse)?
755 .nonce;
756 let command = input.create_user_command(infered_nonce, signature)?;
757
758 inject_tx(command, context).await
759 }
760}
761
762pub fn routes(
763 rpc_sernder: RpcSender,
764) -> impl Filter<Error = Rejection, Extract = impl Reply> + Clone {
765 let state = warp::any().map(move || Context::new(rpc_sernder.clone()));
766 let schema = RootNode::new(Query, Mutation, EmptySubscription::<Context>::new());
767 let graphql_filter = juniper_warp::make_graphql_filter(schema, state.boxed());
768 let graphiql_filter = juniper_warp::graphiql_filter("/graphql", None);
769 let playground_filter = juniper_warp::playground_filter("/graphql", None);
770
771 (warp::post().and(warp::path("graphql")).and(graphql_filter))
772 .or(warp::get()
773 .and(warp::path("playground"))
774 .and(playground_filter))
775 .or(warp::get().and(warp::path("graphiql")).and(graphiql_filter))
776}
777
778fn parse_pooled_commands_query<ID, F>(
782 public_key: Option<String>,
783 hashes: Option<Vec<String>>,
784 ids: Option<Vec<String>>,
785 id_map_fn: F,
786) -> Result<PooledCommandsQuery<ID>, ConversionError>
787where
788 F: Fn(&str) -> Result<ID, conv::Error>,
789{
790 let public_key = match public_key {
791 Some(public_key) => Some(AccountPublicKey::from_str(&public_key)?),
792 None => None,
793 };
794
795 let hashes = match hashes {
796 Some(hashes) => Some(
797 hashes
798 .into_iter()
799 .map(|tx| TransactionHash::from_str(tx.as_str()))
800 .collect::<Result<Vec<_>, _>>()?,
801 ),
802 None => None,
803 };
804
805 let ids = match ids {
806 Some(ids) => Some(
807 ids.into_iter()
808 .map(|id| id_map_fn(id.as_str()))
809 .collect::<Result<Vec<_>, _>>()?,
810 ),
811 None => None,
812 };
813
814 Ok(PooledCommandsQuery {
815 public_key,
816 hashes,
817 ids,
818 })
819}