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_p2p_messages::v2::{
6 conv, LedgerHash, MinaBaseSignedCommandStableV2, MinaBaseUserCommandStableV2,
7 MinaBaseZkappCommandTStableV1WireStableV1, TokenIdKeyHash, TransactionHash,
8};
9use mina_signer::CompressedPubKey;
10use node::{
11 account::AccountPublicKey,
12 ledger::read::LedgerStatus,
13 rpc::{
14 AccountQuery, GetBlockQuery, PooledCommandsQuery, RpcBestChainResponse,
15 RpcGenesisBlockResponse, RpcGetBlockResponse, RpcLedgerAccountDelegatorsGetResponse,
16 RpcLedgerStatusGetResponse, RpcNodeStatus, RpcPooledUserCommandsResponse,
17 RpcPooledZkappCommandsResponse, RpcRequest, RpcSnarkPoolCompletedJobsResponse,
18 RpcSnarkPoolPendingJobsGetResponse, RpcSnarkerConfig, RpcStatusGetResponse,
19 RpcSyncStatsGetResponse, RpcTransactionInjectResponse, RpcTransactionStatusGetResponse,
20 SyncStatsQuery,
21 },
22 stats::sync::SyncKind,
23 BuildEnv,
24};
25use o1_utils::field_helpers::FieldHelpersError;
26use openmina_core::{
27 block::AppliedBlock, consensus::ConsensusConstants, constants::constraint_constants,
28 NetworkConfig,
29};
30use openmina_node_common::rpc::RpcSender;
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(crate) struct Context {
111 rpc_sender: RpcSender,
112 account_loader: AccountLoader,
113 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, Debug)]
202struct ProtocolState {
203 consensus_state: ConsensusState,
204 blockchain_state: BlockchainState,
205}
206
207#[juniper::graphql_object(context = Context)]
208impl ProtocolState {
209 fn consensus_state(&self) -> &ConsensusState {
210 &self.consensus_state
211 }
212
213 fn blockchain_state(&self) -> &BlockchainState {
214 &self.blockchain_state
215 }
216}
217
218#[derive(Clone, Debug)]
219struct ConsensusState {
220 block_height: i32,
221}
222
223#[juniper::graphql_object(context = Context)]
224impl ConsensusState {
225 fn block_height(&self) -> i32 {
226 self.block_height
227 }
228}
229
230#[derive(Clone, Debug)]
231struct BlockchainState {
232 snarked_ledger_hash: String,
233}
234
235#[juniper::graphql_object(context = Context)]
236impl BlockchainState {
237 fn snarked_ledger_hash(&self) -> &str {
238 &self.snarked_ledger_hash
239 }
240}
241
242#[derive(Clone, Debug)]
243struct BestChain {
244 state_hash: String,
245 protocol_state: ProtocolState,
246}
247
248#[juniper::graphql_object(context = Context)]
249impl BestChain {
250 fn state_hash(&self) -> &str {
251 &self.state_hash
252 }
253
254 fn protocol_state(&self) -> &ProtocolState {
255 &self.protocol_state
256 }
257}
258
259#[derive(Clone, Copy, Debug)]
260struct Query;
261
262#[juniper::graphql_object(context = Context)]
263impl Query {
264 async fn account(
265 public_key: String,
266 token: Option<String>,
267 context: &Context,
268 ) -> juniper::FieldResult<account::GraphQLAccount> {
269 let public_key = AccountPublicKey::from_str(&public_key)?;
270 let req = match token {
271 None => AccountQuery::SinglePublicKey(public_key),
272 Some(token) => {
273 let token_id = TokenIdKeyHash::from_str(&token)?;
274 AccountQuery::PubKeyWithTokenId(public_key, token_id)
275 }
276 };
277 let accounts: Vec<Account> = context
278 .rpc_sender
279 .oneshot_request(RpcRequest::LedgerAccountsGet(req))
280 .await
281 .ok_or(Error::StateMachineEmptyResponse)?;
282
283 Ok(accounts
284 .first()
285 .cloned()
286 .ok_or(Error::StateMachineEmptyResponse)?
287 .try_into()?)
288 }
289
290 async fn sync_status(context: &Context) -> juniper::FieldResult<SyncStatus> {
291 let state: RpcSyncStatsGetResponse = context
292 .rpc_sender
293 .oneshot_request(RpcRequest::SyncStatsGet(SyncStatsQuery { limit: Some(1) }))
294 .await
295 .ok_or(Error::StateMachineEmptyResponse)?;
296
297 if let Some(state) = state.as_ref().and_then(|s| s.first()) {
298 if state.synced.is_some() {
299 Ok(SyncStatus::SYNCED)
300 } else {
301 match &state.kind {
302 SyncKind::Bootstrap => Ok(SyncStatus::BOOTSTRAP),
303 SyncKind::Catchup => Ok(SyncStatus::CATCHUP),
304 }
305 }
306 } else {
307 Ok(SyncStatus::LISTENING)
308 }
309 }
310
311 async fn best_chain(
312 max_length: i32,
313 context: &Context,
314 ) -> juniper::FieldResult<Vec<GraphQLBlock>> {
315 let best_chain: Vec<AppliedBlock> = context
316 .rpc_sender
317 .oneshot_request(RpcRequest::BestChain(max_length as u32))
318 .await
319 .ok_or(Error::StateMachineEmptyResponse)?;
320
321 Ok(best_chain
322 .into_iter()
323 .map(|v| v.try_into())
324 .collect::<Result<Vec<_>, _>>()?)
325 }
326
327 async fn daemon_status(
328 _context: &Context,
329 ) -> juniper::FieldResult<constants::GraphQLDaemonStatus> {
330 Ok(constants::GraphQLDaemonStatus)
331 }
332
333 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(
350 payment: Option<String>,
351 zkapp_transaction: Option<String>,
352 context: &Context,
353 ) -> juniper::FieldResult<GraphQLTransactionStatus> {
354 if payment.is_some() && zkapp_transaction.is_some() {
355 return Err(Error::Custom(
356 "Cannot provide both payment and zkapp transaction".to_string(),
357 )
358 .into());
359 }
360
361 let tx = if let Some(payment) = payment {
362 MinaBaseUserCommandStableV2::SignedCommand(MinaBaseSignedCommandStableV2::from_base64(
363 &payment,
364 )?)
365 } else if let Some(zkapp_transaction) = zkapp_transaction {
366 MinaBaseUserCommandStableV2::ZkappCommand(
367 MinaBaseZkappCommandTStableV1WireStableV1::from_base64(&zkapp_transaction)?,
368 )
369 } else {
370 return Err(Error::Custom(
371 "Must provide either payment or zkapp transaction".to_string(),
372 )
373 .into());
374 };
375 let res: RpcTransactionStatusGetResponse = context
376 .rpc_sender
377 .oneshot_request(RpcRequest::TransactionStatusGet(tx))
378 .await
379 .ok_or(Error::StateMachineEmptyResponse)?;
380
381 Ok(GraphQLTransactionStatus::from(res))
382 }
383
384 async fn block(
386 height: Option<i32>,
387 state_hash: Option<String>,
388 context: &Context,
389 ) -> juniper::FieldResult<GraphQLBlock> {
390 let query = match (height, state_hash) {
391 (Some(height), None) => GetBlockQuery::Height(height.try_into().unwrap_or(u32::MAX)),
392 (None, Some(state_hash)) => GetBlockQuery::Hash(state_hash.parse()?),
393 _ => {
394 return Err(Error::Custom(
395 "Must provide exactly one of state hash, height".to_owned(),
396 )
397 .into());
398 }
399 };
400
401 let res: Option<RpcGetBlockResponse> = context
402 .rpc_sender
403 .oneshot_request(RpcRequest::GetBlock(query.clone()))
404 .await;
405
406 match res {
407 None => Err(Error::Custom("response channel dropped".to_owned()).into()),
408 Some(None) => match query {
409 GetBlockQuery::Hash(hash) => Err(Error::Custom(format!(
410 "Could not find block with hash: `{}` in transition frontier",
411 hash
412 ))
413 .into()),
414 GetBlockQuery::Height(height) => Err(Error::Custom(format!(
415 "Could not find block with height: `{}` in transition frontier",
416 height
417 ))
418 .into()),
419 },
420 Some(Some(block)) => Ok(GraphQLBlock::try_from(block)?),
421 }
422 }
423
424 async fn pooled_user_commands(
433 &self,
434 public_key: Option<String>,
435 hashes: Option<Vec<String>>,
436 ids: Option<Vec<String>>,
437 context: &Context,
438 ) -> juniper::FieldResult<Vec<GraphQLUserCommands>> {
439 let query = parse_pooled_commands_query(
440 public_key,
441 hashes,
442 ids,
443 MinaBaseSignedCommandStableV2::from_base64,
444 )?;
445
446 let res: RpcPooledUserCommandsResponse = context
447 .rpc_sender
448 .oneshot_request(RpcRequest::PooledUserCommands(query))
449 .await
450 .ok_or(Error::StateMachineEmptyResponse)?;
451
452 Ok(res
453 .into_iter()
454 .map(GraphQLUserCommands::try_from)
455 .collect::<Result<Vec<_>, _>>()?)
456 }
457
458 async fn pooled_zkapp_commands(
467 public_key: Option<String>,
468 hashes: Option<Vec<String>>,
469 ids: Option<Vec<String>>,
470 context: &Context,
471 ) -> juniper::FieldResult<Vec<GraphQLZkapp>> {
472 let query = parse_pooled_commands_query(
473 public_key,
474 hashes,
475 ids,
476 MinaBaseZkappCommandTStableV1WireStableV1::from_base64,
477 )?;
478
479 let res: RpcPooledZkappCommandsResponse = context
480 .rpc_sender
481 .oneshot_request(RpcRequest::PooledZkappCommands(query))
482 .await
483 .ok_or(Error::StateMachineEmptyResponse)?;
484
485 Ok(res
486 .into_iter()
487 .map(GraphQLZkapp::try_from)
488 .collect::<Result<Vec<_>, _>>()?)
489 }
490
491 async fn genesis_block(context: &Context) -> juniper::FieldResult<GraphQLBlock> {
492 let block = context
493 .rpc_sender
494 .oneshot_request::<RpcGenesisBlockResponse>(RpcRequest::GenesisBlockGet)
495 .await
496 .ok_or(Error::StateMachineEmptyResponse)?
497 .ok_or(Error::StateMachineEmptyResponse)?;
498
499 Ok(GraphQLBlock::try_from(AppliedBlock {
500 block,
501 just_emitted_a_proof: false,
502 })?)
503 }
504
505 async fn snark_pool(context: &Context) -> juniper::FieldResult<Vec<GraphQLSnarkJob>> {
506 let jobs: RpcSnarkPoolCompletedJobsResponse = context
507 .rpc_sender
508 .oneshot_request(RpcRequest::SnarkPoolCompletedJobsGet)
509 .await
510 .ok_or(Error::StateMachineEmptyResponse)?;
511
512 Ok(jobs.iter().map(GraphQLSnarkJob::from).collect())
513 }
514
515 async fn pending_snark_work(
516 context: &Context,
517 ) -> juniper::FieldResult<Vec<GraphQLPendingSnarkWork>> {
518 let jobs: RpcSnarkPoolPendingJobsGetResponse = context
519 .rpc_sender
520 .oneshot_request(RpcRequest::SnarkPoolPendingJobsGet)
521 .await
522 .ok_or(Error::StateMachineEmptyResponse)?;
523
524 Ok(jobs
525 .into_iter()
526 .map(GraphQLPendingSnarkWork::try_from)
527 .collect::<Result<Vec<_>, _>>()?)
528 }
529
530 #[graphql(name = "networkID")]
532 async fn network_id(_context: &Context) -> juniper::FieldResult<String> {
533 let res = format!("mina:{}", NetworkConfig::global().name);
534 Ok(res)
535 }
536
537 async fn version(_context: &Context) -> juniper::FieldResult<String> {
539 let res = BuildEnv::get().git.commit_hash;
540 Ok(res)
541 }
542
543 async fn current_snark_worker(
544 &self,
545 context: &Context,
546 ) -> juniper::FieldResult<Option<GraphQLSnarkWorker>> {
547 let config: Option<RpcSnarkerConfig> = context
548 .rpc_sender
549 .oneshot_request(RpcRequest::SnarkerConfig)
550 .await
551 .ok_or(Error::StateMachineEmptyResponse)?;
552
553 let Some(config) = config else {
554 return Ok(None);
555 };
556
557 let account = context
558 .load_account(AccountId {
559 public_key: CompressedPubKey::try_from(&config.public_key)?,
560 token_id: TokenIdKeyHash::default().into(),
561 })
562 .await;
563
564 Ok(Some(GraphQLSnarkWorker {
565 key: config.public_key.to_string(),
566 account,
567 fee: config.fee.to_string(),
568 }))
569 }
570}
571
572async fn inject_tx<R>(
573 cmd: MinaBaseUserCommandStableV2,
574 context: &Context,
575) -> juniper::FieldResult<R>
576where
577 R: TryFrom<MinaBaseUserCommandStableV2>,
578{
579 let res: RpcTransactionInjectResponse = context
580 .rpc_sender
581 .oneshot_request(RpcRequest::TransactionInject(vec![cmd]))
582 .await
583 .ok_or(Error::StateMachineEmptyResponse)?;
584
585 match res {
586 RpcTransactionInjectResponse::Success(res) => {
587 let cmd: MinaBaseUserCommandStableV2 = match res.first().cloned() {
588 Some(cmd) => cmd.into(),
589 _ => unreachable!(),
590 };
591 cmd.try_into().map_err(|_| {
592 FieldError::new(
593 "Failed to convert transaction to the required type".to_string(),
594 graphql_value!(null),
595 )
596 })
597 }
598 RpcTransactionInjectResponse::Rejected(rejected) => {
599 let error_list = rejected
600 .into_iter()
601 .map(|(_, err)| graphql_value!({ "message": err.to_string() }))
602 .collect::<Vec<_>>();
603
604 Err(FieldError::new(
605 "Transaction rejected",
606 graphql_value!(juniper::Value::List(error_list)),
607 ))
608 }
609 RpcTransactionInjectResponse::Failure(failure) => {
610 let error_list = failure
611 .into_iter()
612 .map(|err| graphql_value!({ "message": err.to_string() }))
613 .collect::<Vec<_>>();
614
615 Err(FieldError::new(
616 "Transaction failed",
617 graphql_value!(juniper::Value::List(error_list)),
618 ))
619 }
620 }
621}
622
623#[derive(Clone, Debug)]
624struct Mutation;
625
626#[juniper::graphql_object(context = Context)]
627impl Mutation {
628 async fn send_zkapp(
629 input: zkapp::SendZkappInput,
630 context: &Context,
631 ) -> juniper::FieldResult<zkapp::GraphQLSendZkappResponse> {
632 inject_tx(input.try_into()?, context).await
633 }
634
635 async fn send_payment(
636 input: user_command::InputGraphQLPayment,
637 signature: user_command::UserCommandSignature,
638 context: &Context,
639 ) -> juniper::FieldResult<user_command::GraphQLSendPaymentResponse> {
640 let token_id = TokenIdKeyHash::default();
642 let public_key = AccountPublicKey::from_str(&input.from)
643 .map_err(|e| Error::Conversion(ConversionError::Base58Check(e)))?;
644
645 let accounts: Vec<Account> = context
646 .rpc_sender
647 .oneshot_request(RpcRequest::LedgerAccountsGet(
648 AccountQuery::PubKeyWithTokenId(public_key, token_id),
649 ))
650 .await
651 .ok_or(Error::StateMachineEmptyResponse)?;
652
653 let infered_nonce = accounts
654 .first()
655 .ok_or(Error::StateMachineEmptyResponse)?
656 .nonce;
657
658 let command = input
659 .create_user_command(infered_nonce, signature)
660 .map_err(Error::Conversion)?;
661
662 inject_tx(command, context).await
663 }
664
665 async fn send_delegation(
666 input: user_command::InputGraphQLDelegation,
667 signature: user_command::UserCommandSignature,
668 context: &Context,
669 ) -> juniper::FieldResult<user_command::GraphQLSendDelegationResponse> {
670 let token_id = TokenIdKeyHash::default();
672 let public_key = AccountPublicKey::from_str(&input.from)?;
673
674 let accounts: Vec<Account> = context
676 .rpc_sender
677 .oneshot_request(RpcRequest::LedgerAccountsGet(
678 AccountQuery::PubKeyWithTokenId(public_key, token_id),
679 ))
680 .await
681 .ok_or(Error::StateMachineEmptyResponse)?;
682
683 let infered_nonce = accounts
684 .first()
685 .ok_or(Error::StateMachineEmptyResponse)?
686 .nonce;
687 let command = input.create_user_command(infered_nonce, signature)?;
688
689 inject_tx(command, context).await
690 }
691}
692
693pub fn routes(
694 rpc_sernder: RpcSender,
695) -> impl Filter<Error = Rejection, Extract = impl Reply> + Clone {
696 let state = warp::any().map(move || Context::new(rpc_sernder.clone()));
697 let schema = RootNode::new(Query, Mutation, EmptySubscription::<Context>::new());
698 let graphql_filter = juniper_warp::make_graphql_filter(schema, state.boxed());
699 let graphiql_filter = juniper_warp::graphiql_filter("/graphql", None);
700 let playground_filter = juniper_warp::playground_filter("/graphql", None);
701
702 (warp::post().and(warp::path("graphql")).and(graphql_filter))
703 .or(warp::get()
704 .and(warp::path("playground"))
705 .and(playground_filter))
706 .or(warp::get().and(warp::path("graphiql")).and(graphiql_filter))
707
708 }
713
714fn parse_pooled_commands_query<ID, F>(
743 public_key: Option<String>,
744 hashes: Option<Vec<String>>,
745 ids: Option<Vec<String>>,
746 id_map_fn: F,
747) -> Result<PooledCommandsQuery<ID>, ConversionError>
748where
749 F: Fn(&str) -> Result<ID, conv::Error>,
750{
751 let public_key = match public_key {
752 Some(public_key) => Some(AccountPublicKey::from_str(&public_key)?),
753 None => None,
754 };
755
756 let hashes = match hashes {
757 Some(hashes) => Some(
758 hashes
759 .into_iter()
760 .map(|tx| TransactionHash::from_str(tx.as_str()))
761 .collect::<Result<Vec<_>, _>>()?,
762 ),
763 None => None,
764 };
765
766 let ids = match ids {
767 Some(ids) => Some(
768 ids.into_iter()
769 .map(|id| id_map_fn(id.as_str()))
770 .collect::<Result<Vec<_>, _>>()?,
771 ),
772 None => None,
773 };
774
775 Ok(PooledCommandsQuery {
776 public_key,
777 hashes,
778 ids,
779 })
780}