diff --git a/CHANGELOG.md b/CHANGELOG.md index bb7c2f9ca6c..86cb5753779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - [2648](https://github.com/FuelLabs/fuel-core/pull/2648): Add feature-flagged field to block header `fault_proving_header` that contains a commitment to all transaction ids. +### Changed +- [2473](https://github.com/FuelLabs/fuel-core/pull/2473): Graphql requests and responses make use of a new `extensions` object to specify request/response metadata. A request `extensions` object can contain an integer-valued `required_fuel_block_height` field. When specified, the request will return an error unless the node's current fuel block height is at least the value specified in the `required_fuel_block_height` field. All graphql responses now contain an integer-valued `current_fuel_block_height` field in the `extensions` object, which contains the block height of the last block processed by the node. + ## [Version 0.41.6] ### Added diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index e47bdded9ce..cdf346f452f 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -39,7 +39,6 @@ use base64::prelude::{ #[cfg(feature = "subscriptions")] use cynic::StreamingOperation; use cynic::{ - http::ReqwestExt, GraphQlResponse, Id, MutationBuilder, @@ -78,6 +77,7 @@ use pagination::{ PaginatedResult, PaginationRequest, }; +use reqwest::header::CONTENT_TYPE; use schema::{ assets::AssetInfoArg, balance::BalanceArgs, @@ -119,6 +119,7 @@ use schema::{ #[cfg(feature = "subscriptions")] use std::future; use std::{ + collections::HashMap, convert::TryInto, io::{ self, @@ -151,12 +152,24 @@ pub mod types; type RegisterId = u32; +#[derive(Debug, derive_more::Display, derive_more::From)] +#[non_exhaustive] +/// Error occurring during interaction with the FuelClient +// anyhow::Error is wrapped inside a custom Error type, +// so that we can specific error variants in the future. +pub enum Error { + /// Unknown or not expected(by architecture) error. + #[from] + Other(anyhow::Error), +} + #[derive(Debug, Clone)] pub struct FuelClient { client: reqwest::Client, #[cfg(feature = "subscriptions")] cookie: std::sync::Arc, url: reqwest::Url, + extensions: HashMap<&'static str, serde_json::Value>, } impl FromStr for FuelClient { @@ -184,13 +197,18 @@ impl FromStr for FuelClient { client, cookie, url, + extensions: HashMap::new(), }) } #[cfg(not(feature = "subscriptions"))] { let client = reqwest::Client::new(); - Ok(Self { client, url }) + Ok(Self { + client, + url, + extensions: HashMap::new(), + }) } } } @@ -223,6 +241,17 @@ impl FuelClient { Self::from_str(url.as_ref()) } + pub fn with_required_fuel_block_height(&mut self, height: u64) -> &mut Self { + self.extensions + .insert("required_fuel_block_height", height.into()); + self + } + + pub fn without_required_fuel_block_height(&mut self) -> &mut Self { + self.extensions.remove("required_fuel_block_height"); + self + } + /// Send the GraphQL query to the client. pub async fn query( &self, @@ -232,13 +261,33 @@ impl FuelClient { Vars: serde::Serialize, ResponseData: serde::de::DeserializeOwned + 'static, { - let response = self + let mut operation_json = serde_json::to_value(&q)?; + let Some(operation_object) = operation_json.as_object_mut() else { + return Err(from_strings_errors_to_std_error(vec![format!( + "Graphql operation is not valid json object {}", + operation_json + )])) + }; + operation_object.insert( + "extensions".to_owned(), + serde_json::to_value(self.extensions.clone())?, + ); + + let request_builder = self .client .post(self.url.clone()) - .run_graphql(q) + .header(CONTENT_TYPE, "application/json") + .body(serde_json::to_string(&operation_json)?); + + let response = request_builder + .send() + .await + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))? + .text() .await .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + let response = serde_json::from_str(&response)?; Self::decode_response(response) } @@ -271,7 +320,17 @@ impl FuelClient { use reqwest::cookie::CookieStore; let mut url = self.url.clone(); url.set_path("/v1/graphql-sub"); - let json_query = serde_json::to_string(&q)?; + let mut json_query = serde_json::to_value(&q)?; + let Some(json_query) = json_query.as_object_mut() else { + return Err(from_strings_errors_to_std_error(vec![format!( + "Graphql operation is not valid json object {}", + json_query + )])) + }; + json_query.insert( + "extensions".to_owned(), + serde_json::to_value(self.extensions.clone())?, + ); let mut client_builder = es::ClientBuilder::for_url(url.as_str()) .map_err(|e| { io::Error::new( @@ -279,7 +338,7 @@ impl FuelClient { format!("Failed to start client {e:?}"), ) })? - .body(json_query) + .body(serde_json::to_string(&json_query)?) .method("POST".to_string()) .header("content-type", "application/json") .map_err(|e| { diff --git a/crates/fuel-core/src/graphql_api.rs b/crates/fuel-core/src/graphql_api.rs index d85647845eb..64acdc52337 100644 --- a/crates/fuel-core/src/graphql_api.rs +++ b/crates/fuel-core/src/graphql_api.rs @@ -14,6 +14,7 @@ pub mod database; pub(crate) mod indexation; pub(crate) mod metrics_extension; pub mod ports; +pub(crate) mod required_fuel_block_height_extension; pub mod storage; pub(crate) mod validation_extension; pub(crate) mod view_extension; diff --git a/crates/fuel-core/src/graphql_api/api_service.rs b/crates/fuel-core/src/graphql_api/api_service.rs index f8baa21e58e..c34912ee359 100644 --- a/crates/fuel-core/src/graphql_api/api_service.rs +++ b/crates/fuel-core/src/graphql_api/api_service.rs @@ -15,7 +15,10 @@ use crate::{ view_extension::ViewExtension, Config, }, - graphql_api, + graphql_api::{ + self, + required_fuel_block_height_extension::RequiredFuelBlockHeightExtension, + }, schema::{ CoreSchema, CoreSchemaBuilder, @@ -85,6 +88,9 @@ use tower_http::{ trace::TraceLayer, }; +pub(crate) const REQUIRED_FUEL_BLOCK_HEIGHT: &str = "required_fuel_block_height"; +pub(crate) const CURRENT_FUEL_BLOCK_HEIGHT: &str = "current_fuel_block_height"; + pub type Service = fuel_core_services::ServiceRunner; pub use super::database::ReadDatabase; @@ -281,6 +287,9 @@ where )) .extension(async_graphql::extensions::Tracing) .extension(ViewExtension::new()) + // `RequiredFuelBlockHeightExtension` uses the view set by the ViewExtension. + // Do not reorder this line before adding the `ViewExtension`. + .extension(RequiredFuelBlockHeightExtension::new()) .finish(); let graphql_endpoint = "/v1/graphql"; diff --git a/crates/fuel-core/src/graphql_api/required_fuel_block_height_extension.rs b/crates/fuel-core/src/graphql_api/required_fuel_block_height_extension.rs new file mode 100644 index 00000000000..0e597fbb26d --- /dev/null +++ b/crates/fuel-core/src/graphql_api/required_fuel_block_height_extension.rs @@ -0,0 +1,148 @@ +use super::database::ReadView; +use crate::fuel_core_graphql_api::api_service::{ + CURRENT_FUEL_BLOCK_HEIGHT, + REQUIRED_FUEL_BLOCK_HEIGHT, +}; +use async_graphql::{ + extensions::{ + Extension, + ExtensionContext, + ExtensionFactory, + NextExecute, + NextPrepareRequest, + }, + Pos, + Request, + Response, + ServerError, + ServerResult, + Value, +}; +use async_graphql_value::ConstValue; +use fuel_core_types::fuel_types::BlockHeight; +use std::sync::{ + Arc, + OnceLock, +}; + +/// The extension that implements the logic for checking whether +/// the precondition that REQUIRED_FUEL_BLOCK_HEADER must +/// be higher than the current block height is met. +/// The value of the REQUIRED_FUEL_BLOCK_HEADER is set in +/// the request data by the graphql handler as a value of type +/// `RequiredHeight`. +#[derive(Debug, derive_more::Display, derive_more::From)] +pub(crate) struct RequiredFuelBlockHeightExtension; + +impl RequiredFuelBlockHeightExtension { + pub fn new() -> Self { + Self + } +} + +pub(crate) struct RequiredFuelBlockHeightInner { + required_height: OnceLock, +} + +impl RequiredFuelBlockHeightInner { + pub fn new() -> Self { + Self { + required_height: OnceLock::new(), + } + } +} + +impl ExtensionFactory for RequiredFuelBlockHeightExtension { + fn create(&self) -> Arc { + Arc::new(RequiredFuelBlockHeightInner::new()) + } +} + +#[async_trait::async_trait] +impl Extension for RequiredFuelBlockHeightInner { + async fn prepare_request( + &self, + ctx: &ExtensionContext<'_>, + request: Request, + next: NextPrepareRequest<'_>, + ) -> ServerResult { + let required_fuel_block_height = + request.extensions.get(REQUIRED_FUEL_BLOCK_HEIGHT); + + if let Some(ConstValue::Number(required_fuel_block_height)) = + required_fuel_block_height + { + if let Some(required_fuel_block_height) = required_fuel_block_height.as_u64() + { + let required_fuel_block_height: u32 = + required_fuel_block_height.try_into().unwrap_or(u32::MAX); + let required_block_height: BlockHeight = + required_fuel_block_height.into(); + self.required_height + .set(required_block_height) + .expect("`prepare_request` called only once; qed"); + } + } + + next.run(ctx, request).await + } + + async fn execute( + &self, + ctx: &ExtensionContext<'_>, + operation_name: Option<&str>, + next: NextExecute<'_>, + ) -> Response { + let view: &ReadView = ctx.data_unchecked(); + + let current_block_height = view.latest_block_height(); + + if let Some(required_block_height) = self.required_height.get() { + if let Ok(current_block_height) = current_block_height { + if *required_block_height > current_block_height { + let (line, column) = (line!(), column!()); + let mut response = Response::from_errors(vec![ServerError::new( + format!( + "The required fuel block height is higher than the current block height. \ + Required: {}, Current: {}", + // required_block_height: &BlockHeight, dereference twice to get the + // corresponding value as u32. This is necessary because the Display + // implementation for BlockHeight displays values in hexadecimal format. + **required_block_height, + // current_fuel_block_height: BlockHeight, dereference once to get the + // corresponding value as u32. + *current_block_height + ), + Some(Pos { + line: line as usize, + column: column as usize, + }), + )]); + + response.extensions.insert( + CURRENT_FUEL_BLOCK_HEIGHT.to_string(), + Value::Number((*current_block_height).into()), + ); + + return response + } + } + } + + let mut response = next.run(ctx, operation_name).await; + + let current_block_height = view.latest_block_height(); + + if let Ok(current_block_height) = current_block_height { + let current_block_height: u32 = *current_block_height; + response.extensions.insert( + CURRENT_FUEL_BLOCK_HEIGHT.to_string(), + Value::Number(current_block_height.into()), + ); + } else { + tracing::error!("Failed to get the current block height"); + } + + response + } +} diff --git a/tests/tests/lib.rs b/tests/tests/lib.rs index 42b08f387b9..3b67733db95 100644 --- a/tests/tests/lib.rs +++ b/tests/tests/lib.rs @@ -50,6 +50,8 @@ mod regenesis; #[cfg(not(feature = "only-p2p"))] mod relayer; #[cfg(not(feature = "only-p2p"))] +mod required_fuel_block_height_extension; +#[cfg(not(feature = "only-p2p"))] mod snapshot; #[cfg(not(feature = "only-p2p"))] mod state_rewind; diff --git a/tests/tests/required_fuel_block_height_extension.rs b/tests/tests/required_fuel_block_height_extension.rs new file mode 100644 index 00000000000..f978bf96e9f --- /dev/null +++ b/tests/tests/required_fuel_block_height_extension.rs @@ -0,0 +1,187 @@ +use fuel_core::{ + chain_config::StateConfig, + service::{ + Config, + FuelService, + }, +}; +use fuel_core_client::client::{ + types::primitives::{ + Address, + AssetId, + }, + FuelClient, +}; +use reqwest::{ + header::CONTENT_TYPE, + StatusCode, +}; + +#[tokio::test] +async fn request_with_required_block_height_extension_field_works() { + let owner = Address::default(); + let asset_id = AssetId::BASE; + + // setup config + let state_config = StateConfig::default(); + let config = Config::local_node_with_state_config(state_config); + + // setup server & client + let srv = FuelService::new_node(config).await.unwrap(); + let mut client: FuelClient = FuelClient::from(srv.bound_address); + + client.with_required_fuel_block_height(100); + // Issue a request with wrong precondition + let error = client.balance(&owner, Some(&asset_id)).await.unwrap_err(); + + assert!(error.to_string().contains( + "The required fuel block height is higher than the current block height" + ),); + + // Disable extension meratadata, otherwise the request fails + client.without_required_fuel_block_height(); + + // Meet precondition on server side + client.produce_blocks(100, None).await.unwrap(); + + // Set the header and issue request again + client.with_required_fuel_block_height(100); + let result = client.balance(&owner, Some(&asset_id)).await; + + assert!(result.is_ok()); +} + +#[tokio::test] +async fn current_fuel_block_height_extension_field_is_present_on_failed_request() { + // TODO: https://github.com/FuelLabs/fuel-core/issues/2605 + // Figure out a way to get the current fuel block height from FuelClient queries + let query = r#"{ "query": "{ contract(id:\"0x7e2becd64cd598da59b4d1064b711661898656c6b1f4918a787156b8965dc83c\") { id bytecode } }", "extensions": {"required_fuel_block_height": 100} }"#; + + // setup config + let state_config = StateConfig::default(); + let config = Config::local_node_with_state_config(state_config); + + // setup server & client + let srv = FuelService::new_node(config).await.unwrap(); + let url: reqwest::Url = format!("http://{}/v1/graphql", srv.bound_address) + .as_str() + .parse() + .unwrap(); + + let client = reqwest::Client::new(); + + let request = client + .post(url) + .body(query) + .header(CONTENT_TYPE, "application/json") + .build() + .unwrap(); + let response = client.execute(request).await.unwrap(); + + assert!(response.status() == StatusCode::OK); + let response_body: serde_json::Value = response.json().await.unwrap(); + + let is_failed_request = response_body.as_object().unwrap().get("errors").is_some(); + assert!(is_failed_request); + + let current_fuel_block_height = response_body + .as_object() + .unwrap() + .get("extensions") + .unwrap() + .as_object() + .unwrap() + .get("current_fuel_block_height") + .unwrap() + .as_u64() + .unwrap(); + assert_eq!(current_fuel_block_height, 0); +} + +#[tokio::test] +async fn current_fuel_block_height_header_is_present_on_successful_request() { + // TODO: https://github.com/FuelLabs/fuel-core/issues/2605 + // Figure out a way to get the current fuel block height from FuelClient queries + let query = r#"{ "query": "{ contract(id:\"0x7e2becd64cd598da59b4d1064b711661898656c6b1f4918a787156b8965dc83c\") { id bytecode } }", "extensions": {"required_fuel_block_height": 0} }"#; + + // setup config + let state_config = StateConfig::default(); + let config = Config::local_node_with_state_config(state_config); + + // setup server & client + let srv = FuelService::new_node(config).await.unwrap(); + let url: reqwest::Url = format!("http://{}/v1/graphql", srv.bound_address) + .as_str() + .parse() + .unwrap(); + + let client = reqwest::Client::new(); + + let request = client + .post(url) + .body(query) + .header(CONTENT_TYPE, "application/json") + .build() + .unwrap(); + let response = client.execute(request).await.unwrap(); + + assert!(response.status() == StatusCode::OK); + let response_body: serde_json::Value = response.json().await.unwrap(); + let is_successful = response_body.as_object().unwrap().get("errors").is_none(); + assert!(is_successful); + let current_fuel_block_height = response_body + .as_object() + .unwrap() + .get("extensions") + .unwrap() + .as_object() + .unwrap() + .get("current_fuel_block_height") + .unwrap() + .as_u64() + .unwrap(); + assert_eq!(current_fuel_block_height, 0); +} + +#[tokio::test] +async fn current_fuel_block_height_header_is_present_on_no_required_fuel_block_height() { + // TODO: https://github.com/FuelLabs/fuel-core/issues/2605 + // Figure out a way to get the current fuel block height from FuelClient queries + let query = r#"{ "query": "{ contract(id:\"0x7e2becd64cd598da59b4d1064b711661898656c6b1f4918a787156b8965dc83c\") { id bytecode } }" }"#; + + // setup config + let state_config = StateConfig::default(); + let config = Config::local_node_with_state_config(state_config); + + // setup server & client + let srv = FuelService::new_node(config).await.unwrap(); + let url: reqwest::Url = format!("http://{}/v1/graphql", srv.bound_address) + .as_str() + .parse() + .unwrap(); + + let client = reqwest::Client::new(); + + let request = client + .post(url) + .body(query) + .header(CONTENT_TYPE, "application/json") + .build() + .unwrap(); + let response = client.execute(request).await.unwrap(); + + assert!(response.status() == StatusCode::OK); + let response_body: serde_json::Value = response.json().await.unwrap(); + let current_fuel_block_height = response_body + .as_object() + .unwrap() + .get("extensions") + .unwrap() + .as_object() + .unwrap() + .get("current_fuel_block_height") + .unwrap() + .as_u64() + .unwrap(); + assert_eq!(current_fuel_block_height, 0); +}