Skip to main content

linera_service/
node_service.rs

1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::{
5    borrow::Cow,
6    collections::BTreeSet,
7    future::IntoFuture,
8    iter,
9    net::SocketAddr,
10    num::NonZeroU16,
11    sync::{Arc, Mutex as StdMutex},
12};
13
14use async_graphql::{
15    futures_util::Stream,
16    registry::{MetaType, MetaTypeId, Registry},
17    resolver_utils::ContainerType,
18    EmptyMutation, Error, MergedObject, OutputType, Positioned, Request, Response, ScalarType,
19    Schema, SimpleObject, Subscription,
20};
21use async_graphql_axum::{GraphQLRequest, GraphQLResponse, GraphQLSubscription};
22use axum::{extract::Path, http::StatusCode, response, response::IntoResponse, Extension, Router};
23use futures::{lock::Mutex, Future, FutureExt as _, StreamExt as _, TryStreamExt as _};
24use linera_base::{
25    crypto::{CryptoError, CryptoHash},
26    data_types::{
27        Amount, ApplicationDescription, ApplicationPermissions, BlockHeight, Bytecode, Epoch,
28        TimeDelta,
29    },
30    identifiers::{
31        Account, AccountOwner, ApplicationId, ChainId, IndexAndEvent, ModuleId, StreamId,
32    },
33    ownership::{ChainOwnership, TimeoutConfig},
34    vm::VmRuntime,
35    BcsHexParseError,
36};
37use linera_chain::{types::ConfirmedBlock, ChainStateView};
38use linera_client::chain_listener::{
39    ChainListener, ChainListenerConfig, ClientContext, ClientContextExt as _, ListenerCommand,
40};
41use linera_core::{
42    client::chain_client::{self, ChainClient},
43    data_types::ClientOutcome,
44    wallet::Wallet as _,
45    worker::{ChainStateViewReadGuard, Notification, Reason},
46};
47use linera_execution::{
48    committee::Committee, system::AdminOperation, Operation, Query, QueryOutcome, QueryResponse,
49    SystemOperation,
50};
51#[cfg(with_metrics)]
52use linera_metrics::monitoring_server;
53use linera_sdk::linera_base_types::BlobContent;
54use linera_storage::Storage;
55use lru::LruCache;
56use serde::{Deserialize, Serialize};
57use serde_json::json;
58use tokio::sync::mpsc::UnboundedReceiver;
59use tokio_util::sync::CancellationToken;
60use tower_http::cors::CorsLayer;
61use tracing::{debug, info, instrument, trace};
62
63use crate::util;
64
65/// A pre-serialized JSON string that implements [`OutputType`] as the `JSON` scalar.
66///
67/// When the `raw_value` feature of `async-graphql` is enabled, the string is
68/// emitted directly into the GraphQL response without any parsing or
69/// intermediate tree construction.
70#[derive(Clone)]
71struct RawJson(String);
72
73impl OutputType for RawJson {
74    fn type_name() -> Cow<'static, str> {
75        Cow::Borrowed("JSON")
76    }
77
78    fn create_type_info(registry: &mut Registry) -> String {
79        registry.create_output_type::<Self, _>(MetaTypeId::Scalar, |_| MetaType::Scalar {
80            name: "JSON".to_string(),
81            description: Some("A scalar that can represent any JSON value.".to_string()),
82            is_valid: None,
83            visible: None,
84            inaccessible: false,
85            tags: Default::default(),
86            specified_by_url: None,
87            directive_invocations: Default::default(),
88            requires_scopes: Default::default(),
89        })
90    }
91
92    async fn resolve(
93        &self,
94        _ctx: &async_graphql::ContextSelectionSet<'_>,
95        _field: &Positioned<async_graphql::parser::types::Field>,
96    ) -> async_graphql::ServerResult<async_graphql::Value> {
97        // Wrap the raw JSON string with the magic token that async-graphql's
98        // ConstValue serializer recognises (with feature `raw_value`).
99        // When the response is serialised to JSON the raw string is emitted
100        // verbatim, avoiding any parsing or tree conversion.
101        Ok(async_graphql::Value::Object(
102            std::iter::once((
103                async_graphql::Name::new(async_graphql_value::RAW_VALUE_TOKEN),
104                async_graphql::Value::String(self.0.clone()),
105            ))
106            .collect(),
107        ))
108    }
109}
110
111/// The set of chains tracked by the wallet.
112#[derive(SimpleObject, Serialize, Deserialize, Clone)]
113pub struct Chains {
114    /// The IDs of the tracked chains.
115    pub list: Vec<ChainId>,
116    /// The default chain of the wallet, if one is set.
117    pub default: Option<ChainId>,
118}
119
120/// Our root GraphQL query type.
121pub struct QueryRoot<C> {
122    context: Arc<Mutex<C>>,
123    port: NonZeroU16,
124    default_chain: Option<ChainId>,
125}
126
127/// Our root GraphQL subscription type.
128pub struct SubscriptionRoot<C> {
129    context: Arc<Mutex<C>>,
130    query_subscriptions: Option<Arc<crate::query_subscription::QuerySubscriptionManager>>,
131    cancellation_token: CancellationToken,
132}
133
134/// Our root GraphQL mutation type.
135pub struct MutationRoot<C> {
136    context: Arc<Mutex<C>>,
137}
138
139#[derive(Debug, thiserror::Error)]
140enum NodeServiceError {
141    #[error(transparent)]
142    ChainClient(#[from] chain_client::Error),
143    #[error(transparent)]
144    BcsHex(#[from] BcsHexParseError),
145    #[error(transparent)]
146    Json(#[from] serde_json::Error),
147    #[error("malformed chain ID: {0}")]
148    InvalidChainId(CryptoError),
149    #[error(transparent)]
150    Client(#[from] linera_client::Error),
151    #[error("scheduling operations from queries is disabled in read-only mode")]
152    ReadOnlyModeOperationsNotAllowed,
153}
154
155impl IntoResponse for NodeServiceError {
156    fn into_response(self) -> response::Response {
157        let status = match self {
158            NodeServiceError::InvalidChainId(_) | NodeServiceError::BcsHex(_) => {
159                StatusCode::BAD_REQUEST
160            }
161            NodeServiceError::ReadOnlyModeOperationsNotAllowed => StatusCode::FORBIDDEN,
162            _ => StatusCode::INTERNAL_SERVER_ERROR,
163        };
164        let body = json!({"error": self.to_string()}).to_string();
165        (status, body).into_response()
166    }
167}
168
169#[Subscription]
170impl<C> SubscriptionRoot<C>
171where
172    C: ClientContext + 'static,
173{
174    /// Subscribes to notifications from the specified chain.
175    async fn notifications(
176        &self,
177        chain_id: ChainId,
178    ) -> Result<impl Stream<Item = Notification>, Error> {
179        let client = self
180            .context
181            .lock()
182            .await
183            .make_chain_client(chain_id)
184            .await?;
185        Ok(client.subscribe()?)
186    }
187
188    /// Subscribes to the result of a pre-registered GraphQL query.
189    /// Re-executes the query on every new block and pushes changed results.
190    async fn query_result(
191        &self,
192        #[graphql(desc = "Name of the registered subscription query.")] name: String,
193        #[graphql(desc = "The chain to watch.")] chain_id: ChainId,
194        #[graphql(desc = "The application to query.")] application_id: ApplicationId,
195    ) -> Result<impl Stream<Item = RawJson>, Error> {
196        let manager = self
197            .query_subscriptions
198            .as_ref()
199            .ok_or_else(|| Error::new("no subscription queries registered"))?;
200
201        let key = crate::query_subscription::SubscriptionKey {
202            name,
203            chain_id,
204            application_id,
205        };
206
207        let receiver = manager
208            .subscribe(
209                &key,
210                Arc::clone(&self.context),
211                self.cancellation_token.clone(),
212            )
213            .map_err(|e| Error::new(e.to_string()))?;
214
215        // `sender.subscribe()` marks the current value as "already seen", so
216        // `WatchStream` would skip it and wait for the next change.  Grab the
217        // current snapshot first and prepend it to the stream so that every new
218        // subscriber gets the latest cached result immediately.
219        let current = receiver.borrow().clone();
220        let changes = tokio_stream::wrappers::WatchStream::from_changes(receiver)
221            .filter_map(|value| async move { value });
222        Ok(futures::stream::iter(current).chain(changes).map(RawJson))
223    }
224}
225
226impl<C> MutationRoot<C>
227where
228    C: ClientContext,
229{
230    async fn execute_system_operation(
231        &self,
232        system_operation: SystemOperation,
233        chain_id: ChainId,
234    ) -> Result<CryptoHash, Error> {
235        let certificate = self
236            .apply_client_command(&chain_id, move |client| {
237                let operation = Operation::system(system_operation.clone());
238                async move {
239                    let result = client
240                        .execute_operation(operation)
241                        .await
242                        .map_err(Error::from);
243                    (result, client)
244                }
245            })
246            .await?;
247        Ok(certificate.hash())
248    }
249
250    /// Sets the preferred owner of the chain if the current one is no longer in the owner
251    /// set and we have a key pair for exactly one of the new owners.
252    async fn maybe_auto_assign_preferred_owner(
253        &self,
254        chain_id: ChainId,
255        new_ownership: &ChainOwnership,
256    ) -> Result<(), Error> {
257        let context = self.context.lock().await;
258        let mut chain_client = context.make_chain_client(chain_id).await?;
259        context
260            .maybe_auto_assign_preferred_owner(&mut chain_client, new_ownership)
261            .await?;
262        Ok(())
263    }
264
265    /// Applies the given function to the chain client.
266    /// Updates the wallet regardless of the outcome. As long as the function returns a round
267    /// timeout, it will wait and retry.
268    async fn apply_client_command<F, Fut, T>(
269        &self,
270        chain_id: &ChainId,
271        mut f: F,
272    ) -> Result<T, Error>
273    where
274        F: FnMut(ChainClient<C::Environment>) -> Fut,
275        Fut: Future<Output = (Result<ClientOutcome<T>, Error>, ChainClient<C::Environment>)>,
276    {
277        loop {
278            let client = self
279                .context
280                .lock()
281                .await
282                .make_chain_client(*chain_id)
283                .await?;
284            let mut stream = client.subscribe()?;
285            let (result, client) = f(client).await;
286            self.context.lock().await.update_wallet(&client).await?;
287            let timeout = match result? {
288                ClientOutcome::Committed(t) => return Ok(t),
289                ClientOutcome::Conflict(certificate) => {
290                    return Err(chain_client::Error::Conflict(certificate.hash()).into());
291                }
292                ClientOutcome::WaitForTimeout(timeout) => timeout,
293            };
294            drop(client);
295            util::wait_for_next_round(&mut stream, timeout).await;
296        }
297    }
298}
299
300#[async_graphql::Object(cache_control(no_cache))]
301impl<C> MutationRoot<C>
302where
303    C: ClientContext + 'static,
304{
305    /// Processes the inbox and returns the lists of certificate hashes that were created, if any.
306    async fn process_inbox(
307        &self,
308        #[graphql(desc = "The chain whose inbox is being processed.")] chain_id: ChainId,
309    ) -> Result<Vec<CryptoHash>, Error> {
310        let mut hashes = Vec::new();
311        loop {
312            let client = self
313                .context
314                .lock()
315                .await
316                .make_chain_client(chain_id)
317                .await?;
318            let result = client.process_inbox().await;
319            self.context.lock().await.update_wallet(&client).await?;
320            let (certificates, maybe_timeout) = result?;
321            hashes.extend(certificates.into_iter().map(|cert| cert.hash()));
322            match maybe_timeout {
323                None => return Ok(hashes),
324                Some(timestamp) => {
325                    let mut stream = client.subscribe()?;
326                    drop(client);
327                    util::wait_for_next_round(&mut stream, timestamp).await;
328                }
329            }
330        }
331    }
332
333    /// Synchronizes the chain with the validators. Returns the chain's length.
334    ///
335    /// This is only used for testing, to make sure that a client is up to date.
336    // TODO(#4718): Remove this mutation.
337    async fn sync(
338        &self,
339        #[graphql(desc = "The chain being synchronized.")] chain_id: ChainId,
340    ) -> Result<u64, Error> {
341        let client = self
342            .context
343            .lock()
344            .await
345            .make_chain_client(chain_id)
346            .await?;
347        let info = client.synchronize_from_validators().await?;
348        self.context.lock().await.update_wallet(&client).await?;
349        Ok(info.next_block_height.0)
350    }
351
352    /// Retries the pending block that was unsuccessfully proposed earlier.
353    async fn retry_pending_block(
354        &self,
355        #[graphql(desc = "The chain on whose block is being retried.")] chain_id: ChainId,
356    ) -> Result<Option<CryptoHash>, Error> {
357        let client = self
358            .context
359            .lock()
360            .await
361            .make_chain_client(chain_id)
362            .await?;
363        let outcome = client.process_pending_block().await?;
364        self.context.lock().await.update_wallet(&client).await?;
365        match outcome {
366            ClientOutcome::Committed(Some(certificate)) => Ok(Some(certificate.hash())),
367            ClientOutcome::Committed(None) => Ok(None),
368            ClientOutcome::WaitForTimeout(timeout) => Err(Error::from(format!(
369                "Please try again at {}",
370                timeout.timestamp
371            ))),
372            ClientOutcome::Conflict(certificate) => Err(Error::from(format!(
373                "A different block was committed: {}",
374                certificate.hash()
375            ))),
376        }
377    }
378
379    /// Transfers `amount` units of value from the given owner's account to the recipient.
380    /// If no owner is given, try to take the units out of the chain account.
381    async fn transfer(
382        &self,
383        #[graphql(desc = "The chain which native tokens are being transferred from.")]
384        chain_id: ChainId,
385        #[graphql(desc = "The account being debited on the chain.")] owner: AccountOwner,
386        #[graphql(desc = "The recipient of the transfer.")] recipient: Account,
387        #[graphql(desc = "The amount being transferred.")] amount: Amount,
388    ) -> Result<CryptoHash, Error> {
389        self.apply_client_command(&chain_id, move |client| async move {
390            let result = client
391                .transfer(owner, amount, recipient)
392                .await
393                .map_err(Error::from)
394                .map(|outcome| outcome.map(|certificate| certificate.hash()));
395            (result, client)
396        })
397        .await
398    }
399
400    /// Claims `amount` units of value from the given owner's account in the remote
401    /// `target` chain. Depending on its configuration, the `target` chain may refuse to
402    /// process the message.
403    async fn claim(
404        &self,
405        #[graphql(desc = "The chain for whom owner is one of the owner.")] chain_id: ChainId,
406        #[graphql(desc = "The owner of chain targetId being debited.")] owner: AccountOwner,
407        #[graphql(desc = "The chain whose owner is being debited.")] target_id: ChainId,
408        #[graphql(desc = "The recipient of the transfer.")] recipient: Account,
409        #[graphql(desc = "The amount being transferred.")] amount: Amount,
410    ) -> Result<CryptoHash, Error> {
411        self.apply_client_command(&chain_id, move |client| async move {
412            let result = client
413                .claim(owner, target_id, recipient, amount)
414                .await
415                .map_err(Error::from)
416                .map(|outcome| outcome.map(|certificate| certificate.hash()));
417            (result, client)
418        })
419        .await
420    }
421
422    /// Test if a data blob is readable from a transaction in the current chain.
423    // TODO(#2490): Consider removing or renaming this.
424    async fn read_data_blob(
425        &self,
426        chain_id: ChainId,
427        hash: CryptoHash,
428    ) -> Result<CryptoHash, Error> {
429        self.apply_client_command(&chain_id, move |client| async move {
430            let result = client
431                .read_data_blob(hash)
432                .await
433                .map_err(Error::from)
434                .map(|outcome| outcome.map(|certificate| certificate.hash()));
435            (result, client)
436        })
437        .await
438    }
439
440    /// Creates a new single-owner chain.
441    async fn open_chain(
442        &self,
443        #[graphql(desc = "The chain paying for the creation of the new chain.")] chain_id: ChainId,
444        #[graphql(desc = "The owner of the new chain.")] owner: AccountOwner,
445        #[graphql(desc = "The balance of the chain being created. Zero if `None`.")]
446        balance: Option<Amount>,
447    ) -> Result<ChainId, Error> {
448        let ownership = ChainOwnership::single(owner);
449        let balance = balance.unwrap_or(Amount::ZERO);
450        let description = self
451            .apply_client_command(&chain_id, move |client| {
452                let ownership = ownership.clone();
453                async move {
454                    let result = client
455                        .open_chain(ownership, ApplicationPermissions::default(), balance)
456                        .await
457                        .map_err(Error::from)
458                        .map(|outcome| outcome.map(|(chain_id, _)| chain_id));
459                    (result, client)
460                }
461            })
462            .await?;
463        Ok(description.id())
464    }
465
466    /// Creates a new multi-owner chain.
467    #[expect(clippy::too_many_arguments)]
468    async fn open_multi_owner_chain(
469        &self,
470        #[graphql(desc = "The chain paying for the creation of the new chain.")] chain_id: ChainId,
471        #[graphql(desc = "Permissions for applications on the new chain")]
472        application_permissions: Option<ApplicationPermissions>,
473        #[graphql(desc = "The owners of the chain")] owners: Vec<AccountOwner>,
474        #[graphql(desc = "The weights of the owners")] weights: Option<Vec<u64>>,
475        #[graphql(desc = "The number of multi-leader rounds")] multi_leader_rounds: Option<u32>,
476        #[graphql(desc = "The balance of the chain. Zero if `None`")] balance: Option<Amount>,
477        #[graphql(desc = "The duration of the fast round, in milliseconds; default: no timeout")]
478        fast_round_ms: Option<u64>,
479        #[graphql(
480            desc = "The duration of the first single-leader and all multi-leader rounds",
481            default = 10_000
482        )]
483        base_timeout_ms: u64,
484        #[graphql(
485            desc = "The number of milliseconds by which the timeout increases after each \
486                    single-leader round",
487            default = 1_000
488        )]
489        timeout_increment_ms: u64,
490        #[graphql(
491            desc = "The age of an incoming tracked or protected message after which the \
492                    validators start transitioning the chain to fallback mode, in milliseconds.",
493            default = 86_400_000
494        )]
495        fallback_duration_ms: u64,
496    ) -> Result<ChainId, Error> {
497        let owners = if let Some(weights) = weights {
498            if weights.len() != owners.len() {
499                return Err(Error::new(format!(
500                    "There are {} owners but {} weights.",
501                    owners.len(),
502                    weights.len()
503                )));
504            }
505            owners.into_iter().zip(weights).collect::<Vec<_>>()
506        } else {
507            owners
508                .into_iter()
509                .zip(iter::repeat(100))
510                .collect::<Vec<_>>()
511        };
512        let multi_leader_rounds = multi_leader_rounds.unwrap_or(u32::MAX);
513        let timeout_config = TimeoutConfig {
514            fast_round_duration: fast_round_ms.map(TimeDelta::from_millis),
515            base_timeout: TimeDelta::from_millis(base_timeout_ms),
516            timeout_increment: TimeDelta::from_millis(timeout_increment_ms),
517            fallback_duration: TimeDelta::from_millis(fallback_duration_ms),
518        };
519        let ownership = ChainOwnership::multiple(owners, multi_leader_rounds, timeout_config);
520        let balance = balance.unwrap_or(Amount::ZERO);
521        let description = self
522            .apply_client_command(&chain_id, move |client| {
523                let ownership = ownership.clone();
524                let application_permissions = application_permissions.clone().unwrap_or_default();
525                async move {
526                    let result = client
527                        .open_chain(ownership, application_permissions, balance)
528                        .await
529                        .map_err(Error::from)
530                        .map(|outcome| outcome.map(|(chain_id, _)| chain_id));
531                    (result, client)
532                }
533            })
534            .await?;
535        Ok(description.id())
536    }
537
538    /// Closes the chain. Returns the new block hash if successful or `None` if it was already closed.
539    async fn close_chain(
540        &self,
541        #[graphql(desc = "The chain being closed.")] chain_id: ChainId,
542    ) -> Result<Option<CryptoHash>, Error> {
543        let maybe_cert = self
544            .apply_client_command(&chain_id, |client| async move {
545                let result = client.close_chain().await.map_err(Error::from);
546                (result, client)
547            })
548            .await?;
549        Ok(maybe_cert.as_ref().map(|cert| cert.hash()))
550    }
551
552    /// Changes the chain to a single-owner chain
553    async fn change_owner(
554        &self,
555        #[graphql(desc = "The chain whose ownership changes")] chain_id: ChainId,
556        #[graphql(desc = "The new single owner of the chain")] new_owner: AccountOwner,
557    ) -> Result<CryptoHash, Error> {
558        let new_ownership = ChainOwnership::single_super(new_owner);
559        let operation = SystemOperation::ChangeOwnership {
560            super_owners: vec![new_owner],
561            owners: Vec::new(),
562            first_leader: None,
563            multi_leader_rounds: 5,
564            open_multi_leader_rounds: false,
565            timeout_config: TimeoutConfig::default(),
566        };
567        let hash = self.execute_system_operation(operation, chain_id).await?;
568        self.maybe_auto_assign_preferred_owner(chain_id, &new_ownership)
569            .await?;
570        Ok(hash)
571    }
572
573    /// Changes the ownership of the chain
574    #[expect(clippy::too_many_arguments)]
575    async fn change_multiple_owners(
576        &self,
577        #[graphql(desc = "The chain whose ownership changes")] chain_id: ChainId,
578        #[graphql(desc = "The new list of owners of the chain")] new_owners: Vec<AccountOwner>,
579        #[graphql(desc = "The new list of weights of the owners")] new_weights: Vec<u64>,
580        #[graphql(desc = "The multi-leader round of the chain")] multi_leader_rounds: u32,
581        #[graphql(
582            desc = "Whether multi-leader rounds are unrestricted, that is not limited to chain owners."
583        )]
584        open_multi_leader_rounds: bool,
585        #[graphql(desc = "The leader of the first single-leader round. \
586                          If not set, this is random like other rounds.")]
587        first_leader: Option<AccountOwner>,
588        #[graphql(desc = "The duration of the fast round, in milliseconds; default: no timeout")]
589        fast_round_ms: Option<u64>,
590        #[graphql(
591            desc = "The duration of the first single-leader and all multi-leader rounds",
592            default = 10_000
593        )]
594        base_timeout_ms: u64,
595        #[graphql(
596            desc = "The number of milliseconds by which the timeout increases after each \
597                    single-leader round",
598            default = 1_000
599        )]
600        timeout_increment_ms: u64,
601        #[graphql(
602            desc = "The age of an incoming tracked or protected message after which the \
603                    validators start transitioning the chain to fallback mode, in milliseconds.",
604            default = 86_400_000
605        )]
606        fallback_duration_ms: u64,
607    ) -> Result<CryptoHash, Error> {
608        let timeout_config = TimeoutConfig {
609            fast_round_duration: fast_round_ms.map(TimeDelta::from_millis),
610            base_timeout: TimeDelta::from_millis(base_timeout_ms),
611            timeout_increment: TimeDelta::from_millis(timeout_increment_ms),
612            fallback_duration: TimeDelta::from_millis(fallback_duration_ms),
613        };
614        let owners = new_owners.into_iter().zip(new_weights).collect::<Vec<_>>();
615        let new_ownership = ChainOwnership {
616            super_owners: BTreeSet::new(),
617            owners: owners.iter().cloned().collect(),
618            first_leader,
619            multi_leader_rounds,
620            open_multi_leader_rounds,
621            timeout_config: timeout_config.clone(),
622        };
623        let operation = SystemOperation::ChangeOwnership {
624            super_owners: Vec::new(),
625            owners,
626            first_leader,
627            multi_leader_rounds,
628            open_multi_leader_rounds,
629            timeout_config,
630        };
631        let hash = self.execute_system_operation(operation, chain_id).await?;
632        self.maybe_auto_assign_preferred_owner(chain_id, &new_ownership)
633            .await?;
634        Ok(hash)
635    }
636
637    /// Changes the application permissions configuration on this chain.
638    #[expect(clippy::too_many_arguments)]
639    async fn change_application_permissions(
640        &self,
641        #[graphql(desc = "The chain whose permissions are being changed")] chain_id: ChainId,
642        #[graphql(
643            desc = "These applications are allowed to manage the chain: close it, change \
644                    application permissions, and change ownership."
645        )]
646        manage_chain: Vec<ApplicationId>,
647        #[graphql(
648            desc = "If this is `None`, all system operations and application operations are allowed.
649If it is `Some`, only operations from the specified applications are allowed,
650and no system operations."
651        )]
652        execute_operations: Option<Vec<ApplicationId>>,
653        #[graphql(
654            desc = "At least one operation or incoming message from each of these applications must occur in every block."
655        )]
656        mandatory_applications: Vec<ApplicationId>,
657        #[graphql(
658            desc = "These applications are allowed to perform calls to services as oracles."
659        )]
660        call_service_as_oracle: Option<Vec<ApplicationId>>,
661        #[graphql(desc = "These applications are allowed to perform HTTP requests.")]
662        make_http_requests: Option<Vec<ApplicationId>>,
663    ) -> Result<CryptoHash, Error> {
664        let operation = SystemOperation::ChangeApplicationPermissions(ApplicationPermissions {
665            execute_operations,
666            mandatory_applications,
667            manage_chain,
668            call_service_as_oracle,
669            make_http_requests,
670        });
671        self.execute_system_operation(operation, chain_id).await
672    }
673
674    /// (admin chain only) Registers a new committee. This will notify the subscribers of
675    /// the admin chain so that they can migrate to the new epoch (by accepting the
676    /// notification as an "incoming message" in a next block).
677    async fn create_committee(
678        &self,
679        chain_id: ChainId,
680        committee: Committee,
681    ) -> Result<CryptoHash, Error> {
682        Ok(self
683            .apply_client_command(&chain_id, move |client| {
684                let committee = committee.clone();
685                async move {
686                    let result = client
687                        .stage_new_committee(committee)
688                        .await
689                        .map_err(Error::from);
690                    (result, client)
691                }
692            })
693            .await?
694            .hash())
695    }
696
697    /// (admin chain only) Removes a committee. Once this message is accepted by a chain,
698    /// blocks from the retired epoch will not be accepted until they are followed (hence
699    /// re-certified) by a block certified by a recent committee.
700    async fn remove_committee(&self, chain_id: ChainId, epoch: Epoch) -> Result<CryptoHash, Error> {
701        let operation = SystemOperation::Admin(AdminOperation::RemoveCommittee { epoch });
702        self.execute_system_operation(operation, chain_id).await
703    }
704
705    /// Publishes a new application module, optionally along with a JSON-encoded
706    /// `Formats` description that becomes a third blob alongside the contract
707    /// and service blobs.
708    async fn publish_module(
709        &self,
710        #[graphql(desc = "The chain publishing the module")] chain_id: ChainId,
711        #[graphql(desc = "The bytecode of the contract code")] contract: Bytecode,
712        #[graphql(desc = "The bytecode of the service code (only relevant for WebAssembly)")]
713        service: Bytecode,
714        #[graphql(desc = "The virtual machine being used (either Wasm or Evm)")]
715        vm_runtime: VmRuntime,
716        #[graphql(desc = "Optional BCS-encoded `Formats` description bytes")] formats: Option<
717            Vec<u8>,
718        >,
719    ) -> Result<ModuleId, Error> {
720        self.apply_client_command(&chain_id, move |client| {
721            let contract = contract.clone();
722            let service = service.clone();
723            let formats = formats.clone();
724            async move {
725                let result = client
726                    .publish_module(contract, service, vm_runtime, formats)
727                    .await
728                    .map_err(Error::from)
729                    .map(|outcome| outcome.map(|(module_id, _)| module_id));
730                (result, client)
731            }
732        })
733        .await
734    }
735
736    /// Publishes a new data blob.
737    async fn publish_data_blob(
738        &self,
739        #[graphql(desc = "The chain paying for the blob publication")] chain_id: ChainId,
740        #[graphql(desc = "The content of the data blob being created")] bytes: Vec<u8>,
741    ) -> Result<CryptoHash, Error> {
742        self.apply_client_command(&chain_id, |client| {
743            let bytes = bytes.clone();
744            async move {
745                let result = client.publish_data_blob(bytes).await.map_err(Error::from);
746                (result, client)
747            }
748        })
749        .await
750        .map(|_| CryptoHash::new(&BlobContent::new_data(bytes)))
751    }
752
753    /// Creates a new application.
754    async fn create_application(
755        &self,
756        #[graphql(desc = "The chain paying for the creation of the application")] chain_id: ChainId,
757        #[graphql(desc = "The module ID of the application being created")] module_id: ModuleId,
758        #[graphql(desc = "The JSON serialization of the parameters of the application")]
759        parameters: String,
760        #[graphql(
761            desc = "The JSON serialization of the instantiation argument of the application"
762        )]
763        instantiation_argument: String,
764        #[graphql(desc = "The dependencies of the application being created")]
765        required_application_ids: Vec<ApplicationId>,
766    ) -> Result<ApplicationId, Error> {
767        self.apply_client_command(&chain_id, move |client| {
768            let parameters = parameters.as_bytes().to_vec();
769            let instantiation_argument = instantiation_argument.as_bytes().to_vec();
770            let required_application_ids = required_application_ids.clone();
771            async move {
772                let result = client
773                    .create_application_untyped(
774                        module_id,
775                        parameters,
776                        instantiation_argument,
777                        required_application_ids,
778                    )
779                    .await
780                    .map_err(Error::from)
781                    .map(|outcome| outcome.map(|(application_id, _)| application_id));
782                (result, client)
783            }
784        })
785        .await
786    }
787}
788
789#[async_graphql::Object(cache_control(no_cache))]
790impl<C> QueryRoot<C>
791where
792    C: ClientContext + 'static,
793{
794    async fn chain(
795        &self,
796        chain_id: ChainId,
797    ) -> Result<ChainStateExtendedView<<C::Environment as linera_core::Environment>::Storage>, Error>
798    {
799        let client = self
800            .context
801            .lock()
802            .await
803            .make_chain_client(chain_id)
804            .await?;
805        let view = client.chain_state_view().await?;
806        Ok(ChainStateExtendedView::new(view))
807    }
808
809    async fn applications(&self, chain_id: ChainId) -> Result<Vec<ApplicationOverview>, Error> {
810        let client = self
811            .context
812            .lock()
813            .await
814            .make_chain_client(chain_id)
815            .await?;
816        let applications = client
817            .chain_state_view()
818            .await?
819            .execution_state
820            .list_applications()
821            .await?;
822
823        let overviews = applications
824            .into_iter()
825            .map(|(id, description)| ApplicationOverview::new(id, description, self.port, chain_id))
826            .collect();
827
828        Ok(overviews)
829    }
830
831    async fn chains(&self) -> Result<Chains, Error> {
832        Ok(Chains {
833            list: self
834                .context
835                .lock()
836                .await
837                .wallet()
838                .chain_ids()
839                .try_collect()
840                .await?,
841            default: self.default_chain,
842        })
843    }
844
845    async fn block(
846        &self,
847        hash: Option<CryptoHash>,
848        chain_id: ChainId,
849    ) -> Result<Option<Arc<ConfirmedBlock>>, Error> {
850        let client = self
851            .context
852            .lock()
853            .await
854            .make_chain_client(chain_id)
855            .await?;
856        let hash = match hash {
857            Some(hash) => Some(hash),
858            None => client.chain_info().await?.block_hash,
859        };
860        if let Some(hash) = hash {
861            Ok(Some(client.read_confirmed_block(hash).await?))
862        } else {
863            Ok(None)
864        }
865    }
866
867    async fn events_from_index(
868        &self,
869        chain_id: ChainId,
870        stream_id: StreamId,
871        start_index: u32,
872    ) -> Result<Vec<IndexAndEvent>, Error> {
873        Ok(self
874            .context
875            .lock()
876            .await
877            .make_chain_client(chain_id)
878            .await?
879            .events_from_index(stream_id, start_index)
880            .await?)
881    }
882
883    async fn blocks(
884        &self,
885        from: Option<CryptoHash>,
886        chain_id: ChainId,
887        limit: Option<u32>,
888    ) -> Result<Vec<Arc<ConfirmedBlock>>, Error> {
889        let client = self
890            .context
891            .lock()
892            .await
893            .make_chain_client(chain_id)
894            .await?;
895        let limit = limit.unwrap_or(10);
896        let from = match from {
897            Some(from) => Some(from),
898            None => client.chain_info().await?.block_hash,
899        };
900        let Some(from) = from else {
901            return Ok(vec![]);
902        };
903        let mut hash = Some(from);
904        let mut values = Vec::new();
905        for _ in 0..limit {
906            let Some(next_hash) = hash else {
907                break;
908            };
909            let value = client.read_confirmed_block(next_hash).await?;
910            hash = value.block().header.previous_block_hash;
911            values.push(value);
912        }
913        Ok(values)
914    }
915
916    /// Returns the version information on this node service.
917    async fn version(&self) -> linera_version::VersionInfo {
918        linera_version::VersionInfo::default()
919    }
920
921    /// Returns the bytes of an application formats blob (BCS-encoded `Formats`)
922    /// stored in the local node, given the formats blob hash carried by a
923    /// `ModuleId`. Returns `None` if the blob is not present locally.
924    async fn application_formats(
925        &self,
926        chain_id: ChainId,
927        formats_blob_hash: CryptoHash,
928    ) -> Result<Option<Vec<u8>>, Error> {
929        let client = self
930            .context
931            .lock()
932            .await
933            .make_chain_client(chain_id)
934            .await?;
935        let blob_id = linera_base::identifiers::BlobId::new(
936            formats_blob_hash,
937            linera_base::identifiers::BlobType::ApplicationFormats,
938        );
939        let blob = client.storage_client().read_blob(blob_id).await?;
940        Ok(blob.map(|b| b.bytes().to_vec()))
941    }
942}
943
944// What follows is a hack to add a chain_id field to `ChainStateView` based on
945// https://async-graphql.github.io/async-graphql/en/merging_objects.html
946
947struct ChainStateViewExtension(ChainId);
948
949#[async_graphql::Object(cache_control(no_cache))]
950impl ChainStateViewExtension {
951    async fn chain_id(&self) -> ChainId {
952        self.0
953    }
954}
955
956#[derive(MergedObject)]
957struct ChainStateExtendedView<S: Storage>(ChainStateViewExtension, ReadOnlyChainStateView<S>);
958
959/// A wrapper type that allows proxying GraphQL queries to a [`ChainStateView`] that's behind
960/// a read guard.
961pub struct ReadOnlyChainStateView<S: Storage>(ChainStateViewReadGuard<S>);
962
963impl<S: Storage> ContainerType for ReadOnlyChainStateView<S>
964where
965    ChainStateView<S::Context>: ContainerType,
966{
967    async fn resolve_field(
968        &self,
969        context: &async_graphql::Context<'_>,
970    ) -> async_graphql::ServerResult<Option<async_graphql::Value>> {
971        self.0.resolve_field(context).await
972    }
973}
974
975impl<S: Storage> OutputType for ReadOnlyChainStateView<S>
976where
977    ChainStateView<S::Context>: OutputType,
978{
979    fn type_name() -> Cow<'static, str> {
980        ChainStateView::<S::Context>::type_name()
981    }
982
983    fn create_type_info(registry: &mut async_graphql::registry::Registry) -> String {
984        ChainStateView::<S::Context>::create_type_info(registry)
985    }
986
987    async fn resolve(
988        &self,
989        context: &async_graphql::ContextSelectionSet<'_>,
990        field: &async_graphql::Positioned<async_graphql::parser::types::Field>,
991    ) -> async_graphql::ServerResult<async_graphql::Value> {
992        self.0.resolve(context, field).await
993    }
994}
995
996impl<S: Storage> ChainStateExtendedView<S> {
997    fn new(view: ChainStateViewReadGuard<S>) -> Self {
998        Self(
999            ChainStateViewExtension(view.chain_id()),
1000            ReadOnlyChainStateView(view),
1001        )
1002    }
1003}
1004
1005/// A summary of an application registered on a chain.
1006#[derive(SimpleObject)]
1007pub struct ApplicationOverview {
1008    id: ApplicationId,
1009    description: ApplicationDescription,
1010    link: String,
1011}
1012
1013impl ApplicationOverview {
1014    fn new(
1015        id: ApplicationId,
1016        description: ApplicationDescription,
1017        port: NonZeroU16,
1018        chain_id: ChainId,
1019    ) -> Self {
1020        Self {
1021            id,
1022            description,
1023            link: format!(
1024                "http://localhost:{}/chains/{}/applications/{}",
1025                port.get(),
1026                chain_id,
1027                id
1028            ),
1029        }
1030    }
1031}
1032
1033/// Schema type that can be either full (with mutations) or read-only.
1034pub enum NodeServiceSchema<C>
1035where
1036    C: ClientContext + 'static,
1037{
1038    /// Full schema with mutations enabled.
1039    Full(Schema<QueryRoot<C>, MutationRoot<C>, SubscriptionRoot<C>>),
1040    /// Read-only schema with mutations disabled.
1041    ReadOnly(Schema<QueryRoot<C>, EmptyMutation, SubscriptionRoot<C>>),
1042}
1043
1044impl<C> NodeServiceSchema<C>
1045where
1046    C: ClientContext,
1047{
1048    /// Executes a GraphQL request.
1049    pub async fn execute(&self, request: impl Into<Request>) -> Response {
1050        match self {
1051            Self::Full(schema) => schema.execute(request).await,
1052            Self::ReadOnly(schema) => schema.execute(request).await,
1053        }
1054    }
1055
1056    /// Returns the SDL (Schema Definition Language) representation.
1057    pub fn sdl(&self) -> String {
1058        match self {
1059            Self::Full(schema) => schema.sdl(),
1060            Self::ReadOnly(schema) => schema.sdl(),
1061        }
1062    }
1063}
1064
1065impl<C> Clone for NodeServiceSchema<C>
1066where
1067    C: ClientContext,
1068{
1069    fn clone(&self) -> Self {
1070        match self {
1071            Self::Full(schema) => Self::Full(schema.clone()),
1072            Self::ReadOnly(schema) => Self::ReadOnly(schema.clone()),
1073        }
1074    }
1075}
1076
1077#[cfg(with_metrics)]
1078mod query_cache_metrics {
1079    use std::sync::LazyLock;
1080
1081    use linera_base::prometheus_util::{register_int_counter_vec, register_int_gauge};
1082    use prometheus::{IntCounterVec, IntGauge};
1083
1084    pub static QUERY_CACHE_HIT: LazyLock<IntCounterVec> = LazyLock::new(|| {
1085        register_int_counter_vec("query_response_cache_hit", "Query response cache hits", &[])
1086    });
1087
1088    pub static QUERY_CACHE_MISS: LazyLock<IntCounterVec> = LazyLock::new(|| {
1089        register_int_counter_vec(
1090            "query_response_cache_miss",
1091            "Query response cache misses",
1092            &[],
1093        )
1094    });
1095
1096    pub static QUERY_CACHE_INVALIDATION: LazyLock<IntCounterVec> = LazyLock::new(|| {
1097        register_int_counter_vec(
1098            "query_response_cache_invalidation",
1099            "Query response cache invalidations (per chain)",
1100            &[],
1101        )
1102    });
1103
1104    pub static QUERY_CACHE_ENTRIES: LazyLock<IntGauge> = LazyLock::new(|| {
1105        register_int_gauge(
1106            "query_response_cache_entries",
1107            "Current number of cached query responses across all chains",
1108        )
1109    });
1110}
1111
1112/// Per-chain cache state: an LRU map plus the `next_block_height` at the time the
1113/// cache was last invalidated. Both are behind the same mutex.
1114struct PerChainCache {
1115    lru: LruCache<(ApplicationId, Vec<u8>), Vec<u8>>,
1116    next_block_height: BlockHeight,
1117}
1118
1119/// An LRU cache for application query responses, keyed per chain.
1120///
1121/// Caches serialized response bytes keyed on `(chain_id, application_id, request_bytes)`.
1122/// The entire per-chain cache is invalidated when a `NewBlock` notification arrives.
1123///
1124/// To prevent a race where a slow query inserts stale data *after* an invalidation,
1125/// each insert carries the chain's `next_block_height` at query time.
1126/// If a newer block has since been processed, the insert is silently dropped.
1127struct QueryResponseCache {
1128    chains: papaya::HashMap<ChainId, StdMutex<PerChainCache>>,
1129    /// Chains for which we have registered a notification subscription.
1130    subscribed: papaya::HashSet<ChainId>,
1131    /// Sender half of the notification channel, used to subscribe new chains lazily.
1132    notification_sender: StdMutex<Option<tokio::sync::mpsc::UnboundedSender<Notification>>>,
1133    capacity_per_chain: std::num::NonZeroUsize,
1134}
1135
1136impl QueryResponseCache {
1137    fn new(capacity_per_chain: usize) -> Self {
1138        Self {
1139            chains: papaya::HashMap::new(),
1140            subscribed: papaya::HashSet::new(),
1141            notification_sender: StdMutex::new(None),
1142            capacity_per_chain: std::num::NonZeroUsize::new(capacity_per_chain)
1143                .expect("capacity must be > 0"),
1144        }
1145    }
1146
1147    /// Stores the notification sender (called once during startup).
1148    fn set_notification_sender(&self, sender: tokio::sync::mpsc::UnboundedSender<Notification>) {
1149        *self
1150            .notification_sender
1151            .lock()
1152            .expect("sender mutex poisoned") = Some(sender);
1153    }
1154
1155    /// Returns the notification sender, if set.
1156    fn notification_sender(&self) -> Option<tokio::sync::mpsc::UnboundedSender<Notification>> {
1157        self.notification_sender
1158            .lock()
1159            .expect("sender mutex poisoned")
1160            .clone()
1161    }
1162
1163    /// Marks a chain as subscribed to notifications.
1164    fn mark_subscribed(&self, chain_id: ChainId) {
1165        self.subscribed.pin().insert(chain_id);
1166    }
1167
1168    /// Returns `true` if the chain is not yet subscribed to notifications.
1169    fn needs_subscription(&self, chain_id: &ChainId) -> bool {
1170        !self.subscribed.pin().contains(chain_id)
1171    }
1172
1173    /// Marks initial chains as subscribed (called during startup).
1174    fn mark_all_subscribed(&self, chain_ids: &[ChainId]) {
1175        let pinned = self.subscribed.pin();
1176        for &chain_id in chain_ids {
1177            pinned.insert(chain_id);
1178        }
1179    }
1180
1181    /// Looks up a cached response. Returns `Some(bytes)` on hit, `None` on miss
1182    /// (including when the chain has no cache entry yet).
1183    fn get(&self, chain_id: ChainId, app_id: &ApplicationId, request: &[u8]) -> Option<Vec<u8>> {
1184        let pinned = self.chains.pin();
1185        let result = pinned.get(&chain_id).and_then(|mutex| {
1186            mutex
1187                .lock()
1188                .expect("LRU mutex poisoned")
1189                .lru
1190                .get(&(*app_id, request.to_vec()))
1191                .cloned()
1192        });
1193        #[cfg(with_metrics)]
1194        {
1195            let metric = if result.is_some() {
1196                &query_cache_metrics::QUERY_CACHE_HIT
1197            } else {
1198                &query_cache_metrics::QUERY_CACHE_MISS
1199            };
1200            metric.with_label_values(&[]).inc();
1201        }
1202        result
1203    }
1204
1205    /// Inserts a response into the cache, unless the chain's `next_block_height` has
1206    /// advanced past the caller's snapshot (which would mean a new block arrived and
1207    /// this response is potentially stale).
1208    fn insert(
1209        &self,
1210        chain_id: ChainId,
1211        app_id: ApplicationId,
1212        request: Vec<u8>,
1213        response: Vec<u8>,
1214        next_block_height: BlockHeight,
1215    ) {
1216        let pinned = self.chains.pin();
1217        let capacity = self.capacity_per_chain;
1218        let mutex = pinned.get_or_insert_with(chain_id, || {
1219            StdMutex::new(PerChainCache {
1220                lru: LruCache::new(capacity),
1221                next_block_height,
1222            })
1223        });
1224        let mut cache = mutex.lock().expect("LRU mutex poisoned");
1225        if next_block_height < cache.next_block_height {
1226            return; // A new block arrived since this query started; discard stale response.
1227        }
1228        // If the chain has advanced since the last cache update, also clear stale entries.
1229        // Note: This should not happen if notifications are timely. Also, this only
1230        // works when we have a cache miss.
1231        if next_block_height > cache.next_block_height {
1232            debug!(
1233                "Unexpected query cache invalidation for chain {chain_id}:\
1234                 {next_block_height} > {}",
1235                cache.next_block_height
1236            );
1237            #[cfg(with_metrics)]
1238            {
1239                #[expect(
1240                    clippy::cast_possible_wrap,
1241                    reason = "LRU cache size fits in i64 for any realistic cache"
1242                )]
1243                let cache_len = cache.lru.len() as i64;
1244                query_cache_metrics::QUERY_CACHE_ENTRIES.sub(cache_len);
1245                query_cache_metrics::QUERY_CACHE_INVALIDATION
1246                    .with_label_values(&[])
1247                    .inc();
1248            }
1249            cache.lru.clear();
1250            cache.next_block_height = next_block_height;
1251        }
1252        #[cfg(with_metrics)]
1253        let prev_len = cache.lru.len();
1254        cache.lru.put((app_id, request), response);
1255        #[cfg(with_metrics)]
1256        if cache.lru.len() != prev_len {
1257            query_cache_metrics::QUERY_CACHE_ENTRIES.inc();
1258        }
1259    }
1260
1261    /// Called when a `NewBlock` notification arrives. Records the new
1262    /// `next_block_height` and clears all cached responses for the chain.
1263    fn invalidate_chain(&self, chain_id: &ChainId, next_block_height: BlockHeight) {
1264        let pinned = self.chains.pin();
1265        let capacity = self.capacity_per_chain;
1266        let mutex = pinned.get_or_insert_with(*chain_id, || {
1267            StdMutex::new(PerChainCache {
1268                lru: LruCache::new(capacity),
1269                next_block_height,
1270            })
1271        });
1272        let mut cache = mutex.lock().expect("LRU mutex poisoned");
1273        if next_block_height > cache.next_block_height {
1274            #[cfg(with_metrics)]
1275            {
1276                #[expect(
1277                    clippy::cast_possible_wrap,
1278                    reason = "LRU cache size fits in i64 for any realistic cache"
1279                )]
1280                let cache_len = cache.lru.len() as i64;
1281                query_cache_metrics::QUERY_CACHE_ENTRIES.sub(cache_len);
1282                query_cache_metrics::QUERY_CACHE_INVALIDATION
1283                    .with_label_values(&[])
1284                    .inc();
1285            }
1286            cache.lru.clear();
1287            cache.next_block_height = next_block_height;
1288        } else {
1289            debug!(
1290                "Query cache for chain {chain_id} was already invalidated:\
1291                 {next_block_height} <= {}",
1292                cache.next_block_height
1293            );
1294        }
1295    }
1296}
1297
1298/// The `NodeService` is a server that exposes a web-server to the client.
1299/// The node service is primarily used to explore the state of a chain in GraphQL.
1300pub struct NodeService<C>
1301where
1302    C: ClientContext + 'static,
1303{
1304    config: ChainListenerConfig,
1305    port: NonZeroU16,
1306    #[cfg(with_metrics)]
1307    metrics_port: NonZeroU16,
1308    default_chain: Option<ChainId>,
1309    context: Arc<Mutex<C>>,
1310    /// If true, disallow mutations and prevent queries from scheduling operations.
1311    read_only: bool,
1312    /// Optional LRU cache for application query responses. `None` when caching is disabled.
1313    query_cache: Option<Arc<QueryResponseCache>>,
1314    query_subscriptions: Option<Arc<crate::query_subscription::QuerySubscriptionManager>>,
1315    cancellation_token: CancellationToken,
1316    enable_memory_profiling: bool,
1317    /// If true, do not start the chain listener; serve queries from local state only.
1318    pause: bool,
1319}
1320
1321impl<C> Clone for NodeService<C>
1322where
1323    C: ClientContext + 'static,
1324{
1325    fn clone(&self) -> Self {
1326        Self {
1327            config: self.config.clone(),
1328            port: self.port,
1329            #[cfg(with_metrics)]
1330            metrics_port: self.metrics_port,
1331            default_chain: self.default_chain,
1332            context: Arc::clone(&self.context),
1333            read_only: self.read_only,
1334            query_cache: self.query_cache.clone(),
1335            query_subscriptions: self.query_subscriptions.clone(),
1336            cancellation_token: self.cancellation_token.clone(),
1337            enable_memory_profiling: self.enable_memory_profiling,
1338            pause: self.pause,
1339        }
1340    }
1341}
1342
1343impl<C> NodeService<C>
1344where
1345    C: ClientContext,
1346{
1347    /// Creates a new instance of the node service given a client chain and a port.
1348    ///
1349    /// `query_cache_size` controls the per-chain LRU cache capacity for application query
1350    /// responses. Pass `None` to disable the cache (the default). Enable with
1351    /// `--query-cache-size <N>`. Incompatible with `--long-lived-services`.
1352    #[expect(clippy::too_many_arguments)]
1353    pub fn new(
1354        config: ChainListenerConfig,
1355        port: NonZeroU16,
1356        #[cfg(with_metrics)] metrics_port: NonZeroU16,
1357        default_chain: Option<ChainId>,
1358        context: Arc<Mutex<C>>,
1359        read_only: bool,
1360        query_cache_size: Option<usize>,
1361        query_subscriptions: Option<Arc<crate::query_subscription::QuerySubscriptionManager>>,
1362        cancellation_token: CancellationToken,
1363        enable_memory_profiling: bool,
1364        pause: bool,
1365    ) -> Self {
1366        let query_cache = query_cache_size.map(|size| Arc::new(QueryResponseCache::new(size)));
1367        Self {
1368            config,
1369            port,
1370            #[cfg(with_metrics)]
1371            metrics_port,
1372            default_chain,
1373            context,
1374            read_only,
1375            query_cache,
1376            query_subscriptions,
1377            cancellation_token,
1378            enable_memory_profiling,
1379            pause,
1380        }
1381    }
1382
1383    /// Returns the socket address on which the metrics endpoint is served.
1384    #[cfg(with_metrics)]
1385    pub fn metrics_address(&self) -> SocketAddr {
1386        SocketAddr::from(([0, 0, 0, 0], self.metrics_port.get()))
1387    }
1388
1389    /// Builds the GraphQL schema served by the node service.
1390    pub fn schema(&self) -> NodeServiceSchema<C> {
1391        let query = QueryRoot {
1392            context: Arc::clone(&self.context),
1393            port: self.port,
1394            default_chain: self.default_chain,
1395        };
1396        let subscription = SubscriptionRoot {
1397            context: Arc::clone(&self.context),
1398            query_subscriptions: self.query_subscriptions.clone(),
1399            cancellation_token: self.cancellation_token.clone(),
1400        };
1401
1402        if self.read_only {
1403            NodeServiceSchema::ReadOnly(Schema::build(query, EmptyMutation, subscription).finish())
1404        } else {
1405            NodeServiceSchema::Full(
1406                Schema::build(
1407                    query,
1408                    MutationRoot {
1409                        context: Arc::clone(&self.context),
1410                    },
1411                    subscription,
1412                )
1413                .finish(),
1414            )
1415        }
1416    }
1417
1418    /// Runs the node service.
1419    #[instrument(name = "node_service", level = "info", skip_all, fields(port = ?self.port))]
1420    pub async fn run(
1421        self,
1422        cancellation_token: CancellationToken,
1423        command_receiver: UnboundedReceiver<ListenerCommand>,
1424    ) -> Result<(), anyhow::Error> {
1425        let port = self.port.get();
1426        let index_handler = axum::routing::get(util::graphiql).post(Self::index_handler);
1427        let application_handler =
1428            axum::routing::get(util::graphiql).post(Self::application_handler);
1429
1430        #[cfg(with_metrics)]
1431        monitoring_server::start_metrics_with_profiling(
1432            self.metrics_address(),
1433            cancellation_token.clone(),
1434            self.enable_memory_profiling,
1435        )
1436        .await;
1437
1438        let base_router = Router::new()
1439            .route("/", index_handler)
1440            .route(
1441                "/chains/{chain_id}/applications/{application_id}",
1442                application_handler,
1443            )
1444            .route("/ready", axum::routing::get(|| async { "ready!" }));
1445
1446        // Create router with appropriate schema for WebSocket subscriptions.
1447        let app = match self.schema() {
1448            NodeServiceSchema::Full(schema) => {
1449                base_router.route_service("/ws", GraphQLSubscription::new(schema))
1450            }
1451            NodeServiceSchema::ReadOnly(schema) => {
1452                base_router.route_service("/ws", GraphQLSubscription::new(schema))
1453            }
1454        }
1455        .layer(Extension(self.clone()))
1456        // TODO(#551): Provide application authentication.
1457        .layer(CorsLayer::permissive());
1458
1459        info!("GraphiQL IDE: http://localhost:{}", port);
1460
1461        // Spawn the cache invalidation listener if caching is enabled.
1462        if let Some(cache) = &self.query_cache {
1463            let guard = self.context.lock().await;
1464            let chain_ids: Vec<ChainId> = guard.wallet().chain_ids().try_collect().await?;
1465            let (tx, mut receiver) = tokio::sync::mpsc::unbounded_channel();
1466            guard.client().subscribe_extra(chain_ids.clone(), &tx);
1467            cache.mark_all_subscribed(&chain_ids);
1468            cache.set_notification_sender(tx);
1469            drop(guard);
1470            let cache = Arc::clone(cache);
1471            tokio::spawn(async move {
1472                while let Some(notification) = receiver.recv().await {
1473                    if let Reason::NewBlock { height, .. } = notification.reason {
1474                        let next_block_height = height
1475                            .try_add_one()
1476                            .expect("block height should not overflow");
1477                        cache.invalidate_chain(&notification.chain_id, next_block_height);
1478                    }
1479                }
1480            });
1481        }
1482
1483        let tcp_listener =
1484            tokio::net::TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port))).await?;
1485        let server = axum::serve(tcp_listener, app)
1486            .with_graceful_shutdown(cancellation_token.clone().cancelled_owned())
1487            .into_future();
1488
1489        if self.pause {
1490            info!("Running in paused mode: chain synchronization is disabled");
1491            server.await?;
1492        } else {
1493            let storage = self.context.lock().await.storage().clone();
1494            let chain_listener = ChainListener::new(
1495                self.config,
1496                self.context,
1497                storage,
1498                cancellation_token.clone(),
1499                command_receiver,
1500                true,
1501            )
1502            .run()
1503            .await?;
1504            let mut chain_listener = Box::pin(chain_listener).fuse();
1505            futures::select! {
1506                result = chain_listener => result?,
1507                result = Box::pin(server).fuse() => result?,
1508            };
1509        }
1510
1511        Ok(())
1512    }
1513
1514    /// Handles service queries for user applications (including mutations).
1515    async fn handle_service_request(
1516        &self,
1517        application_id: ApplicationId,
1518        request: Vec<u8>,
1519        chain_id: ChainId,
1520        block_hash: Option<CryptoHash>,
1521    ) -> Result<Vec<u8>, NodeServiceError> {
1522        // Only cache read-only queries against the latest state (block_hash == None).
1523        let cache = block_hash
1524            .is_none()
1525            .then_some(self.query_cache.as_ref())
1526            .flatten();
1527
1528        // Return immediately on cache hit.
1529        if let Some(cache) = cache {
1530            if let Some(cached) = cache.get(chain_id, &application_id, &request) {
1531                return Ok(cached);
1532            }
1533        }
1534
1535        let (
1536            QueryOutcome {
1537                response,
1538                operations,
1539            },
1540            block_height,
1541        ) = self
1542            .query_user_application(application_id, request.clone(), chain_id, block_hash)
1543            .await?;
1544        if operations.is_empty() {
1545            if let Some(cache) = cache {
1546                // Lazily subscribe to notifications for chains discovered after startup.
1547                if cache.needs_subscription(&chain_id) {
1548                    if let Some(sender) = cache.notification_sender() {
1549                        self.context
1550                            .lock()
1551                            .await
1552                            .client()
1553                            .subscribe_extra(vec![chain_id], &sender);
1554                        cache.mark_subscribed(chain_id);
1555                    }
1556                }
1557                cache.insert(
1558                    chain_id,
1559                    application_id,
1560                    request,
1561                    response.clone(),
1562                    block_height,
1563                );
1564            }
1565            return Ok(response);
1566        }
1567
1568        if self.read_only {
1569            return Err(NodeServiceError::ReadOnlyModeOperationsNotAllowed);
1570        }
1571
1572        trace!("Query requested a new block with operations: {operations:?}");
1573        let client = self
1574            .context
1575            .lock()
1576            .await
1577            .make_chain_client(chain_id)
1578            .await?;
1579        let hash = loop {
1580            let timeout = match client
1581                .execute_operations(operations.clone(), vec![])
1582                .await?
1583            {
1584                ClientOutcome::Committed(certificate) => break certificate.hash(),
1585                ClientOutcome::Conflict(certificate) => {
1586                    return Err(chain_client::Error::Conflict(certificate.hash()).into());
1587                }
1588                ClientOutcome::WaitForTimeout(timeout) => timeout,
1589            };
1590            let mut stream = client.subscribe().map_err(|_| {
1591                chain_client::Error::InternalError("Could not subscribe to the local node.")
1592            })?;
1593            util::wait_for_next_round(&mut stream, timeout).await;
1594        };
1595        let response = async_graphql::Response::new(hash.to_value());
1596        Ok(serde_json::to_vec(&response)?)
1597    }
1598
1599    /// Queries a user application, returning the raw [`QueryOutcome`] and the height of the
1600    /// chain's latest block at the time of the query (used for cache staleness detection).
1601    async fn query_user_application(
1602        &self,
1603        application_id: ApplicationId,
1604        bytes: Vec<u8>,
1605        chain_id: ChainId,
1606        block_hash: Option<CryptoHash>,
1607    ) -> Result<(QueryOutcome<Vec<u8>>, BlockHeight), NodeServiceError> {
1608        let query = Query::User {
1609            application_id,
1610            bytes,
1611        };
1612        let client = self
1613            .context
1614            .lock()
1615            .await
1616            .make_chain_client(chain_id)
1617            .await?;
1618        let (
1619            QueryOutcome {
1620                response,
1621                operations,
1622            },
1623            next_block_height,
1624        ) = client.query_application(query, block_hash).await?;
1625        match response {
1626            QueryResponse::System(_) => {
1627                unreachable!("cannot get a system response for a user query")
1628            }
1629            QueryResponse::User(user_response_bytes) => Ok((
1630                QueryOutcome {
1631                    response: user_response_bytes,
1632                    operations,
1633                },
1634                next_block_height,
1635            )),
1636        }
1637    }
1638
1639    /// Executes a GraphQL query and generates a response for our `Schema`.
1640    async fn index_handler(service: Extension<Self>, request: GraphQLRequest) -> GraphQLResponse {
1641        service
1642            .0
1643            .schema()
1644            .execute(request.into_inner())
1645            .await
1646            .into()
1647    }
1648
1649    /// Executes a GraphQL query against an application.
1650    /// Pattern matches on the `OperationType` of the query and routes the query
1651    /// accordingly.
1652    async fn application_handler(
1653        Path((chain_id, application_id)): Path<(String, String)>,
1654        service: Extension<Self>,
1655        request: String,
1656    ) -> Result<Vec<u8>, NodeServiceError> {
1657        let chain_id: ChainId = chain_id.parse().map_err(NodeServiceError::InvalidChainId)?;
1658        let application_id: ApplicationId = application_id.parse()?;
1659
1660        debug!(
1661            %chain_id,
1662            %application_id,
1663            "processing request for application:\n{:?}",
1664            &request
1665        );
1666        let response = service
1667            .0
1668            .handle_service_request(application_id, request.into_bytes(), chain_id, None)
1669            .await?;
1670
1671        Ok(response)
1672    }
1673}
1674
1675#[cfg(test)]
1676mod tests {
1677    use linera_base::{
1678        crypto::CryptoHash,
1679        data_types::BlockHeight,
1680        identifiers::{ApplicationId, ChainId},
1681    };
1682
1683    use super::QueryResponseCache;
1684
1685    fn test_chain(n: u64) -> ChainId {
1686        ChainId(CryptoHash::test_hash(format!("chain-{n}")))
1687    }
1688
1689    fn test_app(n: u64) -> ApplicationId {
1690        ApplicationId::new(CryptoHash::test_hash(format!("app-{n}")))
1691    }
1692
1693    #[test]
1694    fn cache_hit_and_miss() {
1695        let cache = QueryResponseCache::new(100);
1696        let chain = test_chain(0);
1697        let app = test_app(0);
1698        let request = b"query { balance }".to_vec();
1699        let response = b"{ \"balance\": 42 }".to_vec();
1700
1701        // Unknown chain — get returns None.
1702        assert!(cache.get(chain, &app, &request).is_none());
1703
1704        // Insert creates the per-chain entry.
1705        cache.insert(
1706            chain,
1707            app,
1708            request.clone(),
1709            response.clone(),
1710            BlockHeight(1),
1711        );
1712
1713        // Hit after insert.
1714        assert_eq!(cache.get(chain, &app, &request), Some(response));
1715    }
1716
1717    #[test]
1718    fn per_chain_isolation() {
1719        let cache = QueryResponseCache::new(100);
1720        let chain_a = test_chain(0);
1721        let chain_b = test_chain(1);
1722        let app = test_app(0);
1723        let request = b"q".to_vec();
1724        let response = b"r".to_vec();
1725
1726        cache.insert(
1727            chain_a,
1728            app,
1729            request.clone(),
1730            response.clone(),
1731            BlockHeight(1),
1732        );
1733
1734        // Invalidating chain B must not affect chain A.
1735        cache.invalidate_chain(&chain_b, BlockHeight(1));
1736        assert_eq!(cache.get(chain_a, &app, &request), Some(response));
1737    }
1738
1739    #[test]
1740    fn invalidation_clears_all_entries() {
1741        let cache = QueryResponseCache::new(100);
1742        let chain = test_chain(0);
1743        let app = test_app(0);
1744
1745        cache.insert(chain, app, b"q1".to_vec(), b"r1".to_vec(), BlockHeight(1));
1746        cache.insert(chain, app, b"q2".to_vec(), b"r2".to_vec(), BlockHeight(1));
1747
1748        cache.invalidate_chain(&chain, BlockHeight(2));
1749        assert!(cache.get(chain, &app, b"q1").is_none());
1750        assert!(cache.get(chain, &app, b"q2").is_none());
1751    }
1752
1753    #[test]
1754    fn lru_eviction() {
1755        let cache = QueryResponseCache::new(2);
1756        let chain = test_chain(0);
1757        let app = test_app(0);
1758
1759        cache.insert(chain, app, b"q1".to_vec(), b"r1".to_vec(), BlockHeight(1));
1760        cache.insert(chain, app, b"q2".to_vec(), b"r2".to_vec(), BlockHeight(1));
1761        // Third insert evicts q1 (least recently used).
1762        cache.insert(chain, app, b"q3".to_vec(), b"r3".to_vec(), BlockHeight(1));
1763
1764        assert!(cache.get(chain, &app, b"q1").is_none());
1765        assert!(cache.get(chain, &app, b"q2").is_some());
1766        assert!(cache.get(chain, &app, b"q3").is_some());
1767    }
1768
1769    #[test]
1770    fn stale_insert_rejected_after_invalidation() {
1771        let cache = QueryResponseCache::new(100);
1772        let chain = test_chain(0);
1773        let app = test_app(0);
1774
1775        // Chain is at block 3. A query starts and snapshots this height.
1776        cache.insert(chain, app, b"q0".to_vec(), b"r0".to_vec(), BlockHeight(3));
1777        let stale_height = BlockHeight(3);
1778
1779        // Block 4 arrives while the query is in flight.
1780        cache.invalidate_chain(&chain, BlockHeight(4));
1781
1782        // Slow query finishes and tries to insert with the stale height.
1783        cache.insert(chain, app, b"q".to_vec(), b"stale".to_vec(), stale_height);
1784
1785        // The stale insert should have been rejected.
1786        assert!(cache.get(chain, &app, b"q").is_none());
1787    }
1788}