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        #[graphql(
448            desc = "The account on the new chain credited with the balance. The chain account \
449                    itself if `None`."
450        )]
451        account: Option<AccountOwner>,
452    ) -> Result<ChainId, Error> {
453        let ownership = ChainOwnership::single(owner);
454        let balance = balance.unwrap_or(Amount::ZERO);
455        let account = account.unwrap_or(AccountOwner::CHAIN);
456        let description = self
457            .apply_client_command(&chain_id, move |client| {
458                let ownership = ownership.clone();
459                async move {
460                    let result = client
461                        .open_chain(
462                            ownership,
463                            ApplicationPermissions::default(),
464                            account,
465                            balance,
466                        )
467                        .await
468                        .map_err(Error::from)
469                        .map(|outcome| outcome.map(|(chain_id, _)| chain_id));
470                    (result, client)
471                }
472            })
473            .await?;
474        Ok(description.id())
475    }
476
477    /// Creates a new multi-owner chain.
478    #[expect(clippy::too_many_arguments)]
479    async fn open_multi_owner_chain(
480        &self,
481        #[graphql(desc = "The chain paying for the creation of the new chain.")] chain_id: ChainId,
482        #[graphql(desc = "Permissions for applications on the new chain")]
483        application_permissions: Option<ApplicationPermissions>,
484        #[graphql(desc = "The owners of the chain")] owners: Vec<AccountOwner>,
485        #[graphql(desc = "The weights of the owners")] weights: Option<Vec<u64>>,
486        #[graphql(desc = "The number of multi-leader rounds")] multi_leader_rounds: Option<u32>,
487        #[graphql(desc = "The balance of the chain. Zero if `None`")] balance: Option<Amount>,
488        #[graphql(
489            desc = "The account on the new chain credited with the balance. The chain account \
490                    itself if `None`."
491        )]
492        account: Option<AccountOwner>,
493        #[graphql(desc = "The duration of the fast round, in milliseconds; default: no timeout")]
494        fast_round_ms: Option<u64>,
495        #[graphql(
496            desc = "The duration of the first single-leader and all multi-leader rounds",
497            default = 10_000
498        )]
499        base_timeout_ms: u64,
500        #[graphql(
501            desc = "The number of milliseconds by which the timeout increases after each \
502                    single-leader round",
503            default = 1_000
504        )]
505        timeout_increment_ms: u64,
506        #[graphql(
507            desc = "The age of an incoming tracked or protected message after which the \
508                    validators start transitioning the chain to fallback mode, in milliseconds.",
509            default = 86_400_000
510        )]
511        fallback_duration_ms: u64,
512    ) -> Result<ChainId, Error> {
513        let owners = if let Some(weights) = weights {
514            if weights.len() != owners.len() {
515                return Err(Error::new(format!(
516                    "There are {} owners but {} weights.",
517                    owners.len(),
518                    weights.len()
519                )));
520            }
521            owners.into_iter().zip(weights).collect::<Vec<_>>()
522        } else {
523            owners
524                .into_iter()
525                .zip(iter::repeat(100))
526                .collect::<Vec<_>>()
527        };
528        let multi_leader_rounds = multi_leader_rounds.unwrap_or(u32::MAX);
529        let timeout_config = TimeoutConfig {
530            fast_round_duration: fast_round_ms.map(TimeDelta::from_millis),
531            base_timeout: TimeDelta::from_millis(base_timeout_ms),
532            timeout_increment: TimeDelta::from_millis(timeout_increment_ms),
533            fallback_duration: TimeDelta::from_millis(fallback_duration_ms),
534        };
535        let ownership = ChainOwnership::multiple(owners, multi_leader_rounds, timeout_config);
536        let balance = balance.unwrap_or(Amount::ZERO);
537        let account = account.unwrap_or(AccountOwner::CHAIN);
538        let description = self
539            .apply_client_command(&chain_id, move |client| {
540                let ownership = ownership.clone();
541                let application_permissions = application_permissions.clone().unwrap_or_default();
542                async move {
543                    let result = client
544                        .open_chain(ownership, application_permissions, account, balance)
545                        .await
546                        .map_err(Error::from)
547                        .map(|outcome| outcome.map(|(chain_id, _)| chain_id));
548                    (result, client)
549                }
550            })
551            .await?;
552        Ok(description.id())
553    }
554
555    /// Closes the chain. Returns the new block hash if successful or `None` if it was already closed.
556    async fn close_chain(
557        &self,
558        #[graphql(desc = "The chain being closed.")] chain_id: ChainId,
559    ) -> Result<Option<CryptoHash>, Error> {
560        let maybe_cert = self
561            .apply_client_command(&chain_id, |client| async move {
562                let result = client.close_chain().await.map_err(Error::from);
563                (result, client)
564            })
565            .await?;
566        Ok(maybe_cert.as_ref().map(|cert| cert.hash()))
567    }
568
569    /// Changes the chain to a single-owner chain
570    async fn change_owner(
571        &self,
572        #[graphql(desc = "The chain whose ownership changes")] chain_id: ChainId,
573        #[graphql(desc = "The new single owner of the chain")] new_owner: AccountOwner,
574    ) -> Result<CryptoHash, Error> {
575        let new_ownership = ChainOwnership::single_super(new_owner);
576        let operation = SystemOperation::ChangeOwnership {
577            super_owners: vec![new_owner],
578            owners: Vec::new(),
579            first_leader: None,
580            multi_leader_rounds: 5,
581            open_multi_leader_rounds: false,
582            timeout_config: TimeoutConfig::default(),
583        };
584        let hash = self.execute_system_operation(operation, chain_id).await?;
585        self.maybe_auto_assign_preferred_owner(chain_id, &new_ownership)
586            .await?;
587        Ok(hash)
588    }
589
590    /// Changes the ownership of the chain
591    #[expect(clippy::too_many_arguments)]
592    async fn change_multiple_owners(
593        &self,
594        #[graphql(desc = "The chain whose ownership changes")] chain_id: ChainId,
595        #[graphql(desc = "The new list of owners of the chain")] new_owners: Vec<AccountOwner>,
596        #[graphql(desc = "The new list of weights of the owners")] new_weights: Vec<u64>,
597        #[graphql(desc = "The multi-leader round of the chain")] multi_leader_rounds: u32,
598        #[graphql(
599            desc = "Whether multi-leader rounds are unrestricted, that is not limited to chain owners."
600        )]
601        open_multi_leader_rounds: bool,
602        #[graphql(desc = "The leader of the first single-leader round. \
603                          If not set, this is random like other rounds.")]
604        first_leader: Option<AccountOwner>,
605        #[graphql(desc = "The duration of the fast round, in milliseconds; default: no timeout")]
606        fast_round_ms: Option<u64>,
607        #[graphql(
608            desc = "The duration of the first single-leader and all multi-leader rounds",
609            default = 10_000
610        )]
611        base_timeout_ms: u64,
612        #[graphql(
613            desc = "The number of milliseconds by which the timeout increases after each \
614                    single-leader round",
615            default = 1_000
616        )]
617        timeout_increment_ms: u64,
618        #[graphql(
619            desc = "The age of an incoming tracked or protected message after which the \
620                    validators start transitioning the chain to fallback mode, in milliseconds.",
621            default = 86_400_000
622        )]
623        fallback_duration_ms: u64,
624    ) -> Result<CryptoHash, Error> {
625        let timeout_config = TimeoutConfig {
626            fast_round_duration: fast_round_ms.map(TimeDelta::from_millis),
627            base_timeout: TimeDelta::from_millis(base_timeout_ms),
628            timeout_increment: TimeDelta::from_millis(timeout_increment_ms),
629            fallback_duration: TimeDelta::from_millis(fallback_duration_ms),
630        };
631        let owners = new_owners.into_iter().zip(new_weights).collect::<Vec<_>>();
632        let new_ownership = ChainOwnership {
633            super_owners: BTreeSet::new(),
634            owners: owners.iter().cloned().collect(),
635            first_leader,
636            multi_leader_rounds,
637            open_multi_leader_rounds,
638            timeout_config: timeout_config.clone(),
639        };
640        let operation = SystemOperation::ChangeOwnership {
641            super_owners: Vec::new(),
642            owners,
643            first_leader,
644            multi_leader_rounds,
645            open_multi_leader_rounds,
646            timeout_config,
647        };
648        let hash = self.execute_system_operation(operation, chain_id).await?;
649        self.maybe_auto_assign_preferred_owner(chain_id, &new_ownership)
650            .await?;
651        Ok(hash)
652    }
653
654    /// Changes the application permissions configuration on this chain.
655    #[expect(clippy::too_many_arguments)]
656    async fn change_application_permissions(
657        &self,
658        #[graphql(desc = "The chain whose permissions are being changed")] chain_id: ChainId,
659        #[graphql(
660            desc = "These applications are allowed to manage the chain: close it, change \
661                    application permissions, and change ownership."
662        )]
663        manage_chain: Vec<ApplicationId>,
664        #[graphql(
665            desc = "If this is `None`, all system operations and application operations are allowed.
666If it is `Some`, only operations from the specified applications are allowed,
667and no system operations."
668        )]
669        execute_operations: Option<Vec<ApplicationId>>,
670        #[graphql(
671            desc = "At least one operation or incoming message from each of these applications must occur in every block."
672        )]
673        mandatory_applications: Vec<ApplicationId>,
674        #[graphql(
675            desc = "These applications are allowed to perform calls to services as oracles."
676        )]
677        call_service_as_oracle: Option<Vec<ApplicationId>>,
678        #[graphql(desc = "These applications are allowed to perform HTTP requests.")]
679        make_http_requests: Option<Vec<ApplicationId>>,
680    ) -> Result<CryptoHash, Error> {
681        let operation = SystemOperation::ChangeApplicationPermissions(ApplicationPermissions {
682            execute_operations,
683            mandatory_applications,
684            manage_chain,
685            call_service_as_oracle,
686            make_http_requests,
687        });
688        self.execute_system_operation(operation, chain_id).await
689    }
690
691    /// (admin chain only) Registers a new committee. This will notify the subscribers of
692    /// the admin chain so that they can migrate to the new epoch (by accepting the
693    /// notification as an "incoming message" in a next block).
694    async fn create_committee(
695        &self,
696        chain_id: ChainId,
697        committee: Committee,
698    ) -> Result<CryptoHash, Error> {
699        Ok(self
700            .apply_client_command(&chain_id, move |client| {
701                let committee = committee.clone();
702                async move {
703                    let result = client
704                        .stage_new_committee(committee)
705                        .await
706                        .map_err(Error::from);
707                    (result, client)
708                }
709            })
710            .await?
711            .hash())
712    }
713
714    /// (admin chain only) Removes a committee. Once this message is accepted by a chain,
715    /// blocks from the retired epoch will not be accepted until they are followed (hence
716    /// re-certified) by a block certified by a recent committee.
717    async fn remove_committee(&self, chain_id: ChainId, epoch: Epoch) -> Result<CryptoHash, Error> {
718        let operation = SystemOperation::Admin(AdminOperation::RemoveCommittee { epoch });
719        self.execute_system_operation(operation, chain_id).await
720    }
721
722    /// Publishes a new application module, optionally along with a JSON-encoded
723    /// `Formats` description that becomes a third blob alongside the contract
724    /// and service blobs.
725    async fn publish_module(
726        &self,
727        #[graphql(desc = "The chain publishing the module")] chain_id: ChainId,
728        #[graphql(desc = "The bytecode of the contract code")] contract: Bytecode,
729        #[graphql(desc = "The bytecode of the service code (only relevant for WebAssembly)")]
730        service: Bytecode,
731        #[graphql(desc = "The virtual machine being used (either Wasm or Evm)")]
732        vm_runtime: VmRuntime,
733        #[graphql(desc = "Optional BCS-encoded `Formats` description bytes")] formats: Option<
734            Vec<u8>,
735        >,
736    ) -> Result<ModuleId, Error> {
737        self.apply_client_command(&chain_id, move |client| {
738            let contract = contract.clone();
739            let service = service.clone();
740            let formats = formats.clone();
741            async move {
742                let result = client
743                    .publish_module(contract, service, vm_runtime, formats)
744                    .await
745                    .map_err(Error::from)
746                    .map(|outcome| outcome.map(|(module_id, _)| module_id));
747                (result, client)
748            }
749        })
750        .await
751    }
752
753    /// Publishes a new data blob.
754    async fn publish_data_blob(
755        &self,
756        #[graphql(desc = "The chain paying for the blob publication")] chain_id: ChainId,
757        #[graphql(desc = "The content of the data blob being created")] bytes: Vec<u8>,
758    ) -> Result<CryptoHash, Error> {
759        self.apply_client_command(&chain_id, |client| {
760            let bytes = bytes.clone();
761            async move {
762                let result = client.publish_data_blob(bytes).await.map_err(Error::from);
763                (result, client)
764            }
765        })
766        .await
767        .map(|_| CryptoHash::new(&BlobContent::new_data(bytes)))
768    }
769
770    /// Creates a new application.
771    async fn create_application(
772        &self,
773        #[graphql(desc = "The chain paying for the creation of the application")] chain_id: ChainId,
774        #[graphql(desc = "The module ID of the application being created")] module_id: ModuleId,
775        #[graphql(desc = "The JSON serialization of the parameters of the application")]
776        parameters: String,
777        #[graphql(
778            desc = "The JSON serialization of the instantiation argument of the application"
779        )]
780        instantiation_argument: String,
781        #[graphql(desc = "The dependencies of the application being created")]
782        required_application_ids: Vec<ApplicationId>,
783    ) -> Result<ApplicationId, Error> {
784        self.apply_client_command(&chain_id, move |client| {
785            let parameters = parameters.as_bytes().to_vec();
786            let instantiation_argument = instantiation_argument.as_bytes().to_vec();
787            let required_application_ids = required_application_ids.clone();
788            async move {
789                let result = client
790                    .create_application_untyped(
791                        module_id,
792                        parameters,
793                        instantiation_argument,
794                        required_application_ids,
795                    )
796                    .await
797                    .map_err(Error::from)
798                    .map(|outcome| outcome.map(|(application_id, _)| application_id));
799                (result, client)
800            }
801        })
802        .await
803    }
804}
805
806#[async_graphql::Object(cache_control(no_cache))]
807impl<C> QueryRoot<C>
808where
809    C: ClientContext + 'static,
810{
811    async fn chain(
812        &self,
813        chain_id: ChainId,
814    ) -> Result<ChainStateExtendedView<<C::Environment as linera_core::Environment>::Storage>, Error>
815    {
816        let client = self
817            .context
818            .lock()
819            .await
820            .make_chain_client(chain_id)
821            .await?;
822        let view = client.chain_state_view().await?;
823        Ok(ChainStateExtendedView::new(view))
824    }
825
826    async fn applications(&self, chain_id: ChainId) -> Result<Vec<ApplicationOverview>, Error> {
827        let client = self
828            .context
829            .lock()
830            .await
831            .make_chain_client(chain_id)
832            .await?;
833        let applications = client
834            .chain_state_view()
835            .await?
836            .execution_state
837            .list_applications()
838            .await?;
839
840        let overviews = applications
841            .into_iter()
842            .map(|(id, description)| ApplicationOverview::new(id, description, self.port, chain_id))
843            .collect();
844
845        Ok(overviews)
846    }
847
848    async fn chains(&self) -> Result<Chains, Error> {
849        Ok(Chains {
850            list: self
851                .context
852                .lock()
853                .await
854                .wallet()
855                .chain_ids()
856                .try_collect()
857                .await?,
858            default: self.default_chain,
859        })
860    }
861
862    async fn block(
863        &self,
864        hash: Option<CryptoHash>,
865        chain_id: ChainId,
866    ) -> Result<Option<Arc<ConfirmedBlock>>, Error> {
867        let client = self
868            .context
869            .lock()
870            .await
871            .make_chain_client(chain_id)
872            .await?;
873        let hash = match hash {
874            Some(hash) => Some(hash),
875            None => client.chain_info().await?.block_hash,
876        };
877        if let Some(hash) = hash {
878            Ok(Some(client.read_confirmed_block(hash).await?))
879        } else {
880            Ok(None)
881        }
882    }
883
884    async fn events_from_index(
885        &self,
886        chain_id: ChainId,
887        stream_id: StreamId,
888        start_index: u32,
889    ) -> Result<Vec<IndexAndEvent>, Error> {
890        Ok(self
891            .context
892            .lock()
893            .await
894            .make_chain_client(chain_id)
895            .await?
896            .events_from_index(stream_id, start_index)
897            .await?)
898    }
899
900    async fn blocks(
901        &self,
902        from: Option<CryptoHash>,
903        chain_id: ChainId,
904        limit: Option<u32>,
905    ) -> Result<Vec<Arc<ConfirmedBlock>>, Error> {
906        let client = self
907            .context
908            .lock()
909            .await
910            .make_chain_client(chain_id)
911            .await?;
912        let limit = limit.unwrap_or(10);
913        let from = match from {
914            Some(from) => Some(from),
915            None => client.chain_info().await?.block_hash,
916        };
917        let Some(from) = from else {
918            return Ok(vec![]);
919        };
920        let mut hash = Some(from);
921        let mut values = Vec::new();
922        for _ in 0..limit {
923            let Some(next_hash) = hash else {
924                break;
925            };
926            let value = client.read_confirmed_block(next_hash).await?;
927            hash = value.block().header.previous_block_hash;
928            values.push(value);
929        }
930        Ok(values)
931    }
932
933    /// Returns the version information on this node service.
934    async fn version(&self) -> linera_version::VersionInfo {
935        linera_version::VersionInfo::default()
936    }
937
938    /// Returns the bytes of an application formats blob (BCS-encoded `Formats`)
939    /// stored in the local node, given the formats blob hash carried by a
940    /// `ModuleId`. Returns `None` if the blob is not present locally.
941    async fn application_formats(
942        &self,
943        chain_id: ChainId,
944        formats_blob_hash: CryptoHash,
945    ) -> Result<Option<Vec<u8>>, Error> {
946        let client = self
947            .context
948            .lock()
949            .await
950            .make_chain_client(chain_id)
951            .await?;
952        let blob_id = linera_base::identifiers::BlobId::new(
953            formats_blob_hash,
954            linera_base::identifiers::BlobType::ApplicationFormats,
955        );
956        let blob = client.storage_client().read_blob(blob_id).await?;
957        Ok(blob.map(|b| b.bytes().to_vec()))
958    }
959}
960
961// What follows is a hack to add a chain_id field to `ChainStateView` based on
962// https://async-graphql.github.io/async-graphql/en/merging_objects.html
963
964struct ChainStateViewExtension(ChainId);
965
966#[async_graphql::Object(cache_control(no_cache))]
967impl ChainStateViewExtension {
968    async fn chain_id(&self) -> ChainId {
969        self.0
970    }
971}
972
973#[derive(MergedObject)]
974struct ChainStateExtendedView<S: Storage>(ChainStateViewExtension, ReadOnlyChainStateView<S>);
975
976/// A wrapper type that allows proxying GraphQL queries to a [`ChainStateView`] that's behind
977/// a read guard.
978pub struct ReadOnlyChainStateView<S: Storage>(ChainStateViewReadGuard<S>);
979
980impl<S: Storage> ContainerType for ReadOnlyChainStateView<S>
981where
982    ChainStateView<S::Context>: ContainerType,
983{
984    async fn resolve_field(
985        &self,
986        context: &async_graphql::Context<'_>,
987    ) -> async_graphql::ServerResult<Option<async_graphql::Value>> {
988        self.0.resolve_field(context).await
989    }
990}
991
992impl<S: Storage> OutputType for ReadOnlyChainStateView<S>
993where
994    ChainStateView<S::Context>: OutputType,
995{
996    fn type_name() -> Cow<'static, str> {
997        ChainStateView::<S::Context>::type_name()
998    }
999
1000    fn create_type_info(registry: &mut async_graphql::registry::Registry) -> String {
1001        ChainStateView::<S::Context>::create_type_info(registry)
1002    }
1003
1004    async fn resolve(
1005        &self,
1006        context: &async_graphql::ContextSelectionSet<'_>,
1007        field: &async_graphql::Positioned<async_graphql::parser::types::Field>,
1008    ) -> async_graphql::ServerResult<async_graphql::Value> {
1009        self.0.resolve(context, field).await
1010    }
1011}
1012
1013impl<S: Storage> ChainStateExtendedView<S> {
1014    fn new(view: ChainStateViewReadGuard<S>) -> Self {
1015        Self(
1016            ChainStateViewExtension(view.chain_id()),
1017            ReadOnlyChainStateView(view),
1018        )
1019    }
1020}
1021
1022/// A summary of an application registered on a chain.
1023#[derive(SimpleObject)]
1024pub struct ApplicationOverview {
1025    id: ApplicationId,
1026    description: ApplicationDescription,
1027    link: String,
1028}
1029
1030impl ApplicationOverview {
1031    fn new(
1032        id: ApplicationId,
1033        description: ApplicationDescription,
1034        port: NonZeroU16,
1035        chain_id: ChainId,
1036    ) -> Self {
1037        Self {
1038            id,
1039            description,
1040            link: format!(
1041                "http://localhost:{}/chains/{}/applications/{}",
1042                port.get(),
1043                chain_id,
1044                id
1045            ),
1046        }
1047    }
1048}
1049
1050/// Schema type that can be either full (with mutations) or read-only.
1051pub enum NodeServiceSchema<C>
1052where
1053    C: ClientContext + 'static,
1054{
1055    /// Full schema with mutations enabled.
1056    Full(Schema<QueryRoot<C>, MutationRoot<C>, SubscriptionRoot<C>>),
1057    /// Read-only schema with mutations disabled.
1058    ReadOnly(Schema<QueryRoot<C>, EmptyMutation, SubscriptionRoot<C>>),
1059}
1060
1061impl<C> NodeServiceSchema<C>
1062where
1063    C: ClientContext,
1064{
1065    /// Executes a GraphQL request.
1066    pub async fn execute(&self, request: impl Into<Request>) -> Response {
1067        match self {
1068            Self::Full(schema) => schema.execute(request).await,
1069            Self::ReadOnly(schema) => schema.execute(request).await,
1070        }
1071    }
1072
1073    /// Returns the SDL (Schema Definition Language) representation.
1074    pub fn sdl(&self) -> String {
1075        match self {
1076            Self::Full(schema) => schema.sdl(),
1077            Self::ReadOnly(schema) => schema.sdl(),
1078        }
1079    }
1080}
1081
1082impl<C> Clone for NodeServiceSchema<C>
1083where
1084    C: ClientContext,
1085{
1086    fn clone(&self) -> Self {
1087        match self {
1088            Self::Full(schema) => Self::Full(schema.clone()),
1089            Self::ReadOnly(schema) => Self::ReadOnly(schema.clone()),
1090        }
1091    }
1092}
1093
1094#[cfg(with_metrics)]
1095pub(crate) mod query_cache_metrics {
1096    use linera_base::prometheus_util::{register_int_counter_vec, register_int_gauge};
1097    use prometheus::{IntCounterVec, IntGauge};
1098
1099    linera_base::declare_metrics! {
1100        pub static QUERY_CACHE_HIT: IntCounterVec =
1101            register_int_counter_vec("query_response_cache_hit", "Query response cache hits", &[]);
1102
1103        pub static QUERY_CACHE_MISS: IntCounterVec =
1104            register_int_counter_vec(
1105                "query_response_cache_miss",
1106                "Query response cache misses",
1107                &[],
1108            );
1109
1110        pub static QUERY_CACHE_INVALIDATION: IntCounterVec =
1111            register_int_counter_vec(
1112                "query_response_cache_invalidation",
1113                "Query response cache invalidations (per chain)",
1114                &[],
1115            );
1116
1117        pub static QUERY_CACHE_ENTRIES: IntGauge =
1118            register_int_gauge(
1119                "query_response_cache_entries",
1120                "Current number of cached query responses across all chains",
1121            );
1122    }
1123}
1124
1125/// Per-chain cache state: an LRU map plus the `next_block_height` at the time the
1126/// cache was last invalidated. Both are behind the same mutex.
1127struct PerChainCache {
1128    lru: LruCache<(ApplicationId, Vec<u8>), Vec<u8>>,
1129    next_block_height: BlockHeight,
1130}
1131
1132/// An LRU cache for application query responses, keyed per chain.
1133///
1134/// Caches serialized response bytes keyed on `(chain_id, application_id, request_bytes)`.
1135/// The entire per-chain cache is invalidated when a `NewBlock` notification arrives.
1136///
1137/// To prevent a race where a slow query inserts stale data *after* an invalidation,
1138/// each insert carries the chain's `next_block_height` at query time.
1139/// If a newer block has since been processed, the insert is silently dropped.
1140struct QueryResponseCache {
1141    chains: papaya::HashMap<ChainId, StdMutex<PerChainCache>>,
1142    /// Chains for which we have registered a notification subscription.
1143    subscribed: papaya::HashSet<ChainId>,
1144    /// Sender half of the notification channel, used to subscribe new chains lazily.
1145    notification_sender: StdMutex<Option<tokio::sync::mpsc::UnboundedSender<Notification>>>,
1146    capacity_per_chain: std::num::NonZeroUsize,
1147}
1148
1149impl QueryResponseCache {
1150    fn new(capacity_per_chain: usize) -> Self {
1151        Self {
1152            chains: papaya::HashMap::new(),
1153            subscribed: papaya::HashSet::new(),
1154            notification_sender: StdMutex::new(None),
1155            capacity_per_chain: std::num::NonZeroUsize::new(capacity_per_chain)
1156                .expect("capacity must be > 0"),
1157        }
1158    }
1159
1160    /// Stores the notification sender (called once during startup).
1161    fn set_notification_sender(&self, sender: tokio::sync::mpsc::UnboundedSender<Notification>) {
1162        *self
1163            .notification_sender
1164            .lock()
1165            .expect("sender mutex poisoned") = Some(sender);
1166    }
1167
1168    /// Returns the notification sender, if set.
1169    fn notification_sender(&self) -> Option<tokio::sync::mpsc::UnboundedSender<Notification>> {
1170        self.notification_sender
1171            .lock()
1172            .expect("sender mutex poisoned")
1173            .clone()
1174    }
1175
1176    /// Marks a chain as subscribed to notifications.
1177    fn mark_subscribed(&self, chain_id: ChainId) {
1178        self.subscribed.pin().insert(chain_id);
1179    }
1180
1181    /// Returns `true` if the chain is not yet subscribed to notifications.
1182    fn needs_subscription(&self, chain_id: &ChainId) -> bool {
1183        !self.subscribed.pin().contains(chain_id)
1184    }
1185
1186    /// Marks initial chains as subscribed (called during startup).
1187    fn mark_all_subscribed(&self, chain_ids: &[ChainId]) {
1188        let pinned = self.subscribed.pin();
1189        for &chain_id in chain_ids {
1190            pinned.insert(chain_id);
1191        }
1192    }
1193
1194    /// Looks up a cached response. Returns `Some(bytes)` on hit, `None` on miss
1195    /// (including when the chain has no cache entry yet).
1196    fn get(&self, chain_id: ChainId, app_id: &ApplicationId, request: &[u8]) -> Option<Vec<u8>> {
1197        let pinned = self.chains.pin();
1198        let result = pinned.get(&chain_id).and_then(|mutex| {
1199            mutex
1200                .lock()
1201                .expect("LRU mutex poisoned")
1202                .lru
1203                .get(&(*app_id, request.to_vec()))
1204                .cloned()
1205        });
1206        #[cfg(with_metrics)]
1207        {
1208            let metric = if result.is_some() {
1209                &query_cache_metrics::QUERY_CACHE_HIT
1210            } else {
1211                &query_cache_metrics::QUERY_CACHE_MISS
1212            };
1213            metric.with_label_values(&[]).inc();
1214        }
1215        result
1216    }
1217
1218    /// Inserts a response into the cache, unless the chain's `next_block_height` has
1219    /// advanced past the caller's snapshot (which would mean a new block arrived and
1220    /// this response is potentially stale).
1221    fn insert(
1222        &self,
1223        chain_id: ChainId,
1224        app_id: ApplicationId,
1225        request: Vec<u8>,
1226        response: Vec<u8>,
1227        next_block_height: BlockHeight,
1228    ) {
1229        let pinned = self.chains.pin();
1230        let capacity = self.capacity_per_chain;
1231        let mutex = pinned.get_or_insert_with(chain_id, || {
1232            StdMutex::new(PerChainCache {
1233                lru: LruCache::new(capacity),
1234                next_block_height,
1235            })
1236        });
1237        let mut cache = mutex.lock().expect("LRU mutex poisoned");
1238        if next_block_height < cache.next_block_height {
1239            return; // A new block arrived since this query started; discard stale response.
1240        }
1241        // If the chain has advanced since the last cache update, also clear stale entries.
1242        // Note: This should not happen if notifications are timely. Also, this only
1243        // works when we have a cache miss.
1244        if next_block_height > cache.next_block_height {
1245            debug!(
1246                "Unexpected query cache invalidation for chain {chain_id}:\
1247                 {next_block_height} > {}",
1248                cache.next_block_height
1249            );
1250            #[cfg(with_metrics)]
1251            {
1252                #[expect(
1253                    clippy::cast_possible_wrap,
1254                    reason = "LRU cache size fits in i64 for any realistic cache"
1255                )]
1256                let cache_len = cache.lru.len() as i64;
1257                query_cache_metrics::QUERY_CACHE_ENTRIES.sub(cache_len);
1258                query_cache_metrics::QUERY_CACHE_INVALIDATION
1259                    .with_label_values(&[])
1260                    .inc();
1261            }
1262            cache.lru.clear();
1263            cache.next_block_height = next_block_height;
1264        }
1265        #[cfg(with_metrics)]
1266        let prev_len = cache.lru.len();
1267        cache.lru.put((app_id, request), response);
1268        #[cfg(with_metrics)]
1269        if cache.lru.len() != prev_len {
1270            query_cache_metrics::QUERY_CACHE_ENTRIES.inc();
1271        }
1272    }
1273
1274    /// Called when a `NewBlock` notification arrives. Records the new
1275    /// `next_block_height` and clears all cached responses for the chain.
1276    fn invalidate_chain(&self, chain_id: &ChainId, next_block_height: BlockHeight) {
1277        let pinned = self.chains.pin();
1278        let capacity = self.capacity_per_chain;
1279        let mutex = pinned.get_or_insert_with(*chain_id, || {
1280            StdMutex::new(PerChainCache {
1281                lru: LruCache::new(capacity),
1282                next_block_height,
1283            })
1284        });
1285        let mut cache = mutex.lock().expect("LRU mutex poisoned");
1286        if next_block_height > cache.next_block_height {
1287            #[cfg(with_metrics)]
1288            {
1289                #[expect(
1290                    clippy::cast_possible_wrap,
1291                    reason = "LRU cache size fits in i64 for any realistic cache"
1292                )]
1293                let cache_len = cache.lru.len() as i64;
1294                query_cache_metrics::QUERY_CACHE_ENTRIES.sub(cache_len);
1295                query_cache_metrics::QUERY_CACHE_INVALIDATION
1296                    .with_label_values(&[])
1297                    .inc();
1298            }
1299            cache.lru.clear();
1300            cache.next_block_height = next_block_height;
1301        } else {
1302            debug!(
1303                "Query cache for chain {chain_id} was already invalidated:\
1304                 {next_block_height} <= {}",
1305                cache.next_block_height
1306            );
1307        }
1308    }
1309}
1310
1311/// The `NodeService` is a server that exposes a web-server to the client.
1312/// The node service is primarily used to explore the state of a chain in GraphQL.
1313pub struct NodeService<C>
1314where
1315    C: ClientContext + 'static,
1316{
1317    config: ChainListenerConfig,
1318    port: NonZeroU16,
1319    #[cfg(with_metrics)]
1320    metrics_port: NonZeroU16,
1321    default_chain: Option<ChainId>,
1322    context: Arc<Mutex<C>>,
1323    /// If true, disallow mutations and prevent queries from scheduling operations.
1324    read_only: bool,
1325    /// Optional LRU cache for application query responses. `None` when caching is disabled.
1326    query_cache: Option<Arc<QueryResponseCache>>,
1327    query_subscriptions: Option<Arc<crate::query_subscription::QuerySubscriptionManager>>,
1328    cancellation_token: CancellationToken,
1329    enable_memory_profiling: bool,
1330    /// If true, do not start the chain listener; serve queries from local state only.
1331    pause: bool,
1332}
1333
1334impl<C> Clone for NodeService<C>
1335where
1336    C: ClientContext + 'static,
1337{
1338    fn clone(&self) -> Self {
1339        Self {
1340            config: self.config.clone(),
1341            port: self.port,
1342            #[cfg(with_metrics)]
1343            metrics_port: self.metrics_port,
1344            default_chain: self.default_chain,
1345            context: Arc::clone(&self.context),
1346            read_only: self.read_only,
1347            query_cache: self.query_cache.clone(),
1348            query_subscriptions: self.query_subscriptions.clone(),
1349            cancellation_token: self.cancellation_token.clone(),
1350            enable_memory_profiling: self.enable_memory_profiling,
1351            pause: self.pause,
1352        }
1353    }
1354}
1355
1356impl<C> NodeService<C>
1357where
1358    C: ClientContext,
1359{
1360    /// Creates a new instance of the node service given a client chain and a port.
1361    ///
1362    /// `query_cache_size` controls the per-chain LRU cache capacity for application query
1363    /// responses. Pass `None` to disable the cache (the default). Enable with
1364    /// `--query-cache-size <N>`. Incompatible with `--long-lived-services`.
1365    #[expect(clippy::too_many_arguments)]
1366    pub fn new(
1367        config: ChainListenerConfig,
1368        port: NonZeroU16,
1369        #[cfg(with_metrics)] metrics_port: NonZeroU16,
1370        default_chain: Option<ChainId>,
1371        context: Arc<Mutex<C>>,
1372        read_only: bool,
1373        query_cache_size: Option<usize>,
1374        query_subscriptions: Option<Arc<crate::query_subscription::QuerySubscriptionManager>>,
1375        cancellation_token: CancellationToken,
1376        enable_memory_profiling: bool,
1377        pause: bool,
1378    ) -> Self {
1379        let query_cache = query_cache_size.map(|size| Arc::new(QueryResponseCache::new(size)));
1380        Self {
1381            config,
1382            port,
1383            #[cfg(with_metrics)]
1384            metrics_port,
1385            default_chain,
1386            context,
1387            read_only,
1388            query_cache,
1389            query_subscriptions,
1390            cancellation_token,
1391            enable_memory_profiling,
1392            pause,
1393        }
1394    }
1395
1396    /// Returns the socket address on which the metrics endpoint is served.
1397    #[cfg(with_metrics)]
1398    pub fn metrics_address(&self) -> SocketAddr {
1399        SocketAddr::from(([0, 0, 0, 0], self.metrics_port.get()))
1400    }
1401
1402    /// Builds the GraphQL schema served by the node service.
1403    pub fn schema(&self) -> NodeServiceSchema<C> {
1404        let query = QueryRoot {
1405            context: Arc::clone(&self.context),
1406            port: self.port,
1407            default_chain: self.default_chain,
1408        };
1409        let subscription = SubscriptionRoot {
1410            context: Arc::clone(&self.context),
1411            query_subscriptions: self.query_subscriptions.clone(),
1412            cancellation_token: self.cancellation_token.clone(),
1413        };
1414
1415        if self.read_only {
1416            NodeServiceSchema::ReadOnly(Schema::build(query, EmptyMutation, subscription).finish())
1417        } else {
1418            NodeServiceSchema::Full(
1419                Schema::build(
1420                    query,
1421                    MutationRoot {
1422                        context: Arc::clone(&self.context),
1423                    },
1424                    subscription,
1425                )
1426                .finish(),
1427            )
1428        }
1429    }
1430
1431    /// Runs the node service.
1432    #[instrument(name = "node_service", level = "info", skip_all, fields(port = ?self.port))]
1433    pub async fn run(
1434        self,
1435        cancellation_token: CancellationToken,
1436        command_receiver: UnboundedReceiver<ListenerCommand>,
1437    ) -> Result<(), anyhow::Error> {
1438        let port = self.port.get();
1439        let index_handler = axum::routing::get(util::graphiql).post(Self::index_handler);
1440        let application_handler =
1441            axum::routing::get(util::graphiql).post(Self::application_handler);
1442
1443        #[cfg(with_metrics)]
1444        monitoring_server::start_metrics_with_profiling(
1445            self.metrics_address(),
1446            cancellation_token.clone(),
1447            self.enable_memory_profiling,
1448            crate::init_metrics,
1449        )
1450        .await;
1451
1452        let base_router = Router::new()
1453            .route("/", index_handler)
1454            .route(
1455                "/chains/{chain_id}/applications/{application_id}",
1456                application_handler,
1457            )
1458            .route("/ready", axum::routing::get(|| async { "ready!" }));
1459
1460        // Create router with appropriate schema for WebSocket subscriptions.
1461        let app = match self.schema() {
1462            NodeServiceSchema::Full(schema) => {
1463                base_router.route_service("/ws", GraphQLSubscription::new(schema))
1464            }
1465            NodeServiceSchema::ReadOnly(schema) => {
1466                base_router.route_service("/ws", GraphQLSubscription::new(schema))
1467            }
1468        }
1469        .layer(Extension(self.clone()))
1470        // TODO(#551): Provide application authentication.
1471        .layer(CorsLayer::permissive());
1472
1473        info!("GraphiQL IDE: http://localhost:{}", port);
1474
1475        // Spawn the cache invalidation listener if caching is enabled.
1476        if let Some(cache) = &self.query_cache {
1477            let guard = self.context.lock().await;
1478            let chain_ids: Vec<ChainId> = guard.wallet().chain_ids().try_collect().await?;
1479            let (tx, mut receiver) = tokio::sync::mpsc::unbounded_channel();
1480            guard.client().subscribe_extra(chain_ids.clone(), &tx);
1481            cache.mark_all_subscribed(&chain_ids);
1482            cache.set_notification_sender(tx);
1483            drop(guard);
1484            let cache = Arc::clone(cache);
1485            tokio::spawn(async move {
1486                while let Some(notification) = receiver.recv().await {
1487                    if let Reason::NewBlock { height, .. } = notification.reason {
1488                        let next_block_height = height
1489                            .try_add_one()
1490                            .expect("block height should not overflow");
1491                        cache.invalidate_chain(&notification.chain_id, next_block_height);
1492                    }
1493                }
1494            });
1495        }
1496
1497        let tcp_listener =
1498            tokio::net::TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port))).await?;
1499        let server = axum::serve(tcp_listener, app)
1500            .with_graceful_shutdown(cancellation_token.clone().cancelled_owned())
1501            .into_future();
1502
1503        if self.pause {
1504            info!("Running in paused mode: chain synchronization is disabled");
1505            server.await?;
1506        } else {
1507            let storage = self.context.lock().await.storage().clone();
1508            let chain_listener = ChainListener::new(
1509                self.config,
1510                self.context,
1511                storage,
1512                cancellation_token.clone(),
1513                command_receiver,
1514                true,
1515            )
1516            .run()
1517            .await?;
1518            let mut chain_listener = Box::pin(chain_listener).fuse();
1519            futures::select! {
1520                result = chain_listener => result?,
1521                result = Box::pin(server).fuse() => result?,
1522            };
1523        }
1524
1525        Ok(())
1526    }
1527
1528    /// Handles service queries for user applications (including mutations).
1529    async fn handle_service_request(
1530        &self,
1531        application_id: ApplicationId,
1532        request: Vec<u8>,
1533        chain_id: ChainId,
1534        block_hash: Option<CryptoHash>,
1535    ) -> Result<Vec<u8>, NodeServiceError> {
1536        // Only cache read-only queries against the latest state (block_hash == None).
1537        let cache = block_hash
1538            .is_none()
1539            .then_some(self.query_cache.as_ref())
1540            .flatten();
1541
1542        // Return immediately on cache hit.
1543        if let Some(cache) = cache {
1544            if let Some(cached) = cache.get(chain_id, &application_id, &request) {
1545                return Ok(cached);
1546            }
1547        }
1548
1549        let (
1550            QueryOutcome {
1551                response,
1552                operations,
1553            },
1554            block_height,
1555        ) = self
1556            .query_user_application(application_id, request.clone(), chain_id, block_hash)
1557            .await?;
1558        if operations.is_empty() {
1559            if let Some(cache) = cache {
1560                // Lazily subscribe to notifications for chains discovered after startup.
1561                if cache.needs_subscription(&chain_id) {
1562                    if let Some(sender) = cache.notification_sender() {
1563                        self.context
1564                            .lock()
1565                            .await
1566                            .client()
1567                            .subscribe_extra(vec![chain_id], &sender);
1568                        cache.mark_subscribed(chain_id);
1569                    }
1570                }
1571                cache.insert(
1572                    chain_id,
1573                    application_id,
1574                    request,
1575                    response.clone(),
1576                    block_height,
1577                );
1578            }
1579            return Ok(response);
1580        }
1581
1582        if self.read_only {
1583            return Err(NodeServiceError::ReadOnlyModeOperationsNotAllowed);
1584        }
1585
1586        trace!("Query requested a new block with operations: {operations:?}");
1587        let client = self
1588            .context
1589            .lock()
1590            .await
1591            .make_chain_client(chain_id)
1592            .await?;
1593        let hash = loop {
1594            let timeout = match client
1595                .execute_operations(operations.clone(), vec![])
1596                .await?
1597            {
1598                ClientOutcome::Committed(certificate) => break certificate.hash(),
1599                ClientOutcome::Conflict(certificate) => {
1600                    return Err(chain_client::Error::Conflict(certificate.hash()).into());
1601                }
1602                ClientOutcome::WaitForTimeout(timeout) => timeout,
1603            };
1604            let mut stream = client.subscribe().map_err(|_| {
1605                chain_client::Error::InternalError("Could not subscribe to the local node.")
1606            })?;
1607            util::wait_for_next_round(&mut stream, timeout).await;
1608        };
1609        let response = async_graphql::Response::new(hash.to_value());
1610        Ok(serde_json::to_vec(&response)?)
1611    }
1612
1613    /// Queries a user application, returning the raw [`QueryOutcome`] and the height of the
1614    /// chain's latest block at the time of the query (used for cache staleness detection).
1615    async fn query_user_application(
1616        &self,
1617        application_id: ApplicationId,
1618        bytes: Vec<u8>,
1619        chain_id: ChainId,
1620        block_hash: Option<CryptoHash>,
1621    ) -> Result<(QueryOutcome<Vec<u8>>, BlockHeight), NodeServiceError> {
1622        let query = Query::User {
1623            application_id,
1624            bytes,
1625        };
1626        let client = self
1627            .context
1628            .lock()
1629            .await
1630            .make_chain_client(chain_id)
1631            .await?;
1632        let (
1633            QueryOutcome {
1634                response,
1635                operations,
1636            },
1637            next_block_height,
1638        ) = client.query_application(query, block_hash).await?;
1639        match response {
1640            QueryResponse::System(_) => {
1641                unreachable!("cannot get a system response for a user query")
1642            }
1643            QueryResponse::User(user_response_bytes) => Ok((
1644                QueryOutcome {
1645                    response: user_response_bytes,
1646                    operations,
1647                },
1648                next_block_height,
1649            )),
1650        }
1651    }
1652
1653    /// Executes a GraphQL query and generates a response for our `Schema`.
1654    async fn index_handler(service: Extension<Self>, request: GraphQLRequest) -> GraphQLResponse {
1655        service
1656            .0
1657            .schema()
1658            .execute(request.into_inner())
1659            .await
1660            .into()
1661    }
1662
1663    /// Executes a GraphQL query against an application.
1664    /// Pattern matches on the `OperationType` of the query and routes the query
1665    /// accordingly.
1666    async fn application_handler(
1667        Path((chain_id, application_id)): Path<(String, String)>,
1668        service: Extension<Self>,
1669        request: String,
1670    ) -> Result<Vec<u8>, NodeServiceError> {
1671        let chain_id: ChainId = chain_id.parse().map_err(NodeServiceError::InvalidChainId)?;
1672        let application_id: ApplicationId = application_id.parse()?;
1673
1674        debug!(
1675            %chain_id,
1676            %application_id,
1677            "processing request for application:\n{:?}",
1678            &request
1679        );
1680        let response = service
1681            .0
1682            .handle_service_request(application_id, request.into_bytes(), chain_id, None)
1683            .await?;
1684
1685        Ok(response)
1686    }
1687}
1688
1689#[cfg(test)]
1690mod tests {
1691    use linera_base::{
1692        crypto::CryptoHash,
1693        data_types::BlockHeight,
1694        identifiers::{ApplicationId, ChainId},
1695    };
1696
1697    use super::QueryResponseCache;
1698
1699    fn test_chain(n: u64) -> ChainId {
1700        ChainId(CryptoHash::test_hash(format!("chain-{n}")))
1701    }
1702
1703    fn test_app(n: u64) -> ApplicationId {
1704        ApplicationId::new(CryptoHash::test_hash(format!("app-{n}")))
1705    }
1706
1707    #[test]
1708    fn cache_hit_and_miss() {
1709        let cache = QueryResponseCache::new(100);
1710        let chain = test_chain(0);
1711        let app = test_app(0);
1712        let request = b"query { balance }".to_vec();
1713        let response = b"{ \"balance\": 42 }".to_vec();
1714
1715        // Unknown chain — get returns None.
1716        assert!(cache.get(chain, &app, &request).is_none());
1717
1718        // Insert creates the per-chain entry.
1719        cache.insert(
1720            chain,
1721            app,
1722            request.clone(),
1723            response.clone(),
1724            BlockHeight(1),
1725        );
1726
1727        // Hit after insert.
1728        assert_eq!(cache.get(chain, &app, &request), Some(response));
1729    }
1730
1731    #[test]
1732    fn per_chain_isolation() {
1733        let cache = QueryResponseCache::new(100);
1734        let chain_a = test_chain(0);
1735        let chain_b = test_chain(1);
1736        let app = test_app(0);
1737        let request = b"q".to_vec();
1738        let response = b"r".to_vec();
1739
1740        cache.insert(
1741            chain_a,
1742            app,
1743            request.clone(),
1744            response.clone(),
1745            BlockHeight(1),
1746        );
1747
1748        // Invalidating chain B must not affect chain A.
1749        cache.invalidate_chain(&chain_b, BlockHeight(1));
1750        assert_eq!(cache.get(chain_a, &app, &request), Some(response));
1751    }
1752
1753    #[test]
1754    fn invalidation_clears_all_entries() {
1755        let cache = QueryResponseCache::new(100);
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
1762        cache.invalidate_chain(&chain, BlockHeight(2));
1763        assert!(cache.get(chain, &app, b"q1").is_none());
1764        assert!(cache.get(chain, &app, b"q2").is_none());
1765    }
1766
1767    #[test]
1768    fn lru_eviction() {
1769        let cache = QueryResponseCache::new(2);
1770        let chain = test_chain(0);
1771        let app = test_app(0);
1772
1773        cache.insert(chain, app, b"q1".to_vec(), b"r1".to_vec(), BlockHeight(1));
1774        cache.insert(chain, app, b"q2".to_vec(), b"r2".to_vec(), BlockHeight(1));
1775        // Third insert evicts q1 (least recently used).
1776        cache.insert(chain, app, b"q3".to_vec(), b"r3".to_vec(), BlockHeight(1));
1777
1778        assert!(cache.get(chain, &app, b"q1").is_none());
1779        assert!(cache.get(chain, &app, b"q2").is_some());
1780        assert!(cache.get(chain, &app, b"q3").is_some());
1781    }
1782
1783    #[test]
1784    fn stale_insert_rejected_after_invalidation() {
1785        let cache = QueryResponseCache::new(100);
1786        let chain = test_chain(0);
1787        let app = test_app(0);
1788
1789        // Chain is at block 3. A query starts and snapshots this height.
1790        cache.insert(chain, app, b"q0".to_vec(), b"r0".to_vec(), BlockHeight(3));
1791        let stale_height = BlockHeight(3);
1792
1793        // Block 4 arrives while the query is in flight.
1794        cache.invalidate_chain(&chain, BlockHeight(4));
1795
1796        // Slow query finishes and tries to insert with the stale height.
1797        cache.insert(chain, app, b"q".to_vec(), b"stale".to_vec(), stale_height);
1798
1799        // The stale insert should have been rejected.
1800        assert!(cache.get(chain, &app, b"q").is_none());
1801    }
1802}