diff --git a/CHANGELOG.md b/CHANGELOG.md index bb7c2f9ca6c..550f5db70bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added - - [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. +- [2491](https://github.com/FuelLabs/fuel-core/pull/2491): Storage read replays of historical blocks for execution tracing. Only available behind `--debug` flag. ## [Version 0.41.6] diff --git a/Cargo.lock b/Cargo.lock index 6cd3197b138..508ed8ced6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4079,6 +4079,7 @@ dependencies = [ "derive_more 0.99.19", "enum_dispatch", "fuel-vm 0.59.1", + "hex", "k256", "rand", "secrecy", diff --git a/Cargo.toml b/Cargo.toml index 29c2288e446..c26b522c079 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,6 +126,7 @@ postcard = "1.0" tracing-attributes = "0.1" tracing-subscriber = "0.3" serde = "1.0" +serde-big-array = { version = "0.5", default-features = false } serde_json = { version = "1.0", default-features = false, features = ["alloc"] } serde_with = { version = "3.4", default-features = false } strum = { version = "0.25" } diff --git a/bin/fuel-core/src/cli/run.rs b/bin/fuel-core/src/cli/run.rs index bd67af39390..706519789b2 100644 --- a/bin/fuel-core/src/cli/run.rs +++ b/bin/fuel-core/src/cli/run.rs @@ -602,6 +602,7 @@ impl Command { get_peers: graphql.costs.get_peers, estimate_predicates: graphql.costs.estimate_predicates, dry_run: graphql.costs.dry_run, + storage_read_replay: graphql.costs.storage_read_replay, submit: graphql.costs.submit, submit_and_await: graphql.costs.submit_and_await, status_change: graphql.costs.status_change, diff --git a/bin/fuel-core/src/cli/run/graphql.rs b/bin/fuel-core/src/cli/run/graphql.rs index 4abce8922a1..cfaee2d3592 100644 --- a/bin/fuel-core/src/cli/run/graphql.rs +++ b/bin/fuel-core/src/cli/run/graphql.rs @@ -112,6 +112,14 @@ pub struct QueryCosts { )] pub dry_run: usize, + /// Query costs for generating execution trace for a block. + #[clap( + long = "query-cost-storage-read-replay", + default_value = DEFAULT_QUERY_COSTS.storage_read_replay.to_string(), + env + )] + pub storage_read_replay: usize, + /// Query costs for submitting a transaction. #[clap( long = "query-cost-submit", diff --git a/crates/client/assets/schema.sdl b/crates/client/assets/schema.sdl index a219e40de12..6de793de6f6 100644 --- a/crates/client/assets/schema.sdl +++ b/crates/client/assets/schema.sdl @@ -762,6 +762,10 @@ type Mutation { """ dryRun(txs: [HexString!]!, utxoValidation: Boolean, gasPrice: U64): [DryRunTransactionExecutionStatus!]! """ + Get execution trace for an already-executed transaction. + """ + storageReadReplay(height: U32!): [StorageReadReplayEvent!]! + """ Submits transaction to the `TxPool`. Returns submitted transaction if the transaction is included in the `TxPool` without problems. @@ -1156,6 +1160,12 @@ type StateTransitionPurpose { root: Bytes32! } +type StorageReadReplayEvent { + column: String! + key: HexString! + value: HexString +} + scalar SubId diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index e47bdded9ce..5f77f6231d7 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -65,7 +65,10 @@ use fuel_core_types::{ BlockHeight, Nonce, }, - services::executor::TransactionExecutionStatus, + services::executor::{ + StorageReadReplayEvent, + TransactionExecutionStatus, + }, }; #[cfg(feature = "subscriptions")] use futures::{ @@ -93,6 +96,10 @@ use schema::{ }, da_compressed::DaCompressedBlockByHeightArgs, gas_price::BlockHorizonArgs, + storage_read_replay::{ + StorageReadReplay, + StorageReadReplayArgs, + }, tx::{ TransactionsByOwnerConnectionArgs, TxArg, @@ -508,6 +515,24 @@ impl FuelClient { .collect() } + /// Get storage read replay for a block + pub async fn storage_read_replay( + &self, + height: &BlockHeight, + ) -> io::Result> { + let query: Operation = + StorageReadReplay::build(StorageReadReplayArgs { + height: (*height).into(), + }); + Ok(self + .query(query) + .await + .map(|r| r.storage_read_replay)? + .into_iter() + .map(Into::into) + .collect()) + } + /// Estimate predicates for the transaction pub async fn estimate_predicates(&self, tx: &mut Transaction) -> io::Result<()> { let serialized_tx = tx.to_bytes(); diff --git a/crates/client/src/client/schema.rs b/crates/client/src/client/schema.rs index aec28be17c8..8b116cbdbcc 100644 --- a/crates/client/src/client/schema.rs +++ b/crates/client/src/client/schema.rs @@ -36,6 +36,7 @@ pub mod contract; pub mod da_compressed; pub mod message; pub mod node_info; +pub mod storage_read_replay; pub mod upgrades; pub mod gas_price; diff --git a/crates/client/src/client/schema/snapshots/fuel_core_client__client__schema__storage_read_replay__tests__execution_trace_block_tx_gql_output.snap b/crates/client/src/client/schema/snapshots/fuel_core_client__client__schema__storage_read_replay__tests__execution_trace_block_tx_gql_output.snap new file mode 100644 index 00000000000..4792798eea3 --- /dev/null +++ b/crates/client/src/client/schema/snapshots/fuel_core_client__client__schema__storage_read_replay__tests__execution_trace_block_tx_gql_output.snap @@ -0,0 +1,11 @@ +--- +source: crates/client/src/client/schema/storage_read_replay.rs +expression: query.query +--- +mutation StorageReadReplay($height: U32!) { + storageReadReplay(height: $height) { + column + key + value + } +} diff --git a/crates/client/src/client/schema/storage_read_replay.rs b/crates/client/src/client/schema/storage_read_replay.rs new file mode 100644 index 00000000000..2736de16f81 --- /dev/null +++ b/crates/client/src/client/schema/storage_read_replay.rs @@ -0,0 +1,58 @@ +use super::HexString; +use crate::client::schema::{ + schema, + U32, +}; + +#[derive(cynic::QueryFragment, Clone, Debug)] +#[cynic(schema_path = "./assets/schema.sdl")] +pub struct StorageReadReplayEvent { + pub column: String, + pub key: HexString, + pub value: Option, +} +impl From + for fuel_core_types::services::executor::StorageReadReplayEvent +{ + fn from(event: StorageReadReplayEvent) -> Self { + fuel_core_types::services::executor::StorageReadReplayEvent { + column: event.column, + key: event.key.into(), + value: event.value.map(Into::into), + } + } +} + +// mutations + +#[derive(cynic::QueryVariables, Debug)] +pub struct StorageReadReplayArgs { + pub height: U32, +} + +/// Retrieves the transaction in opaque form +#[derive(cynic::QueryFragment, Clone, Debug)] +#[cynic( + schema_path = "./assets/schema.sdl", + graphql_type = "Mutation", + variables = "StorageReadReplayArgs" +)] +pub struct StorageReadReplay { + #[arguments(height: $height)] + pub storage_read_replay: Vec, +} + +#[cfg(test)] +pub mod tests { + use super::*; + use fuel_core_types::fuel_types::BlockHeight; + + #[test] + fn storage_read_replay_gql_output() { + use cynic::MutationBuilder; + let query = StorageReadReplay::build(StorageReadReplayArgs { + height: BlockHeight::new(1234).into(), + }); + insta::assert_snapshot!(query.query) + } +} diff --git a/crates/client/src/client/schema/tx.rs b/crates/client/src/client/schema/tx.rs index 01f9184ff8d..0503301223c 100644 --- a/crates/client/src/client/schema/tx.rs +++ b/crates/client/src/client/schema/tx.rs @@ -310,7 +310,7 @@ impl TryFrom for TransactionExecutionResult { } } DryRunTransactionStatus::Unknown => { - return Err(Self::Error::UnknownVariant("DryRuynTxStatus")) + return Err(Self::Error::UnknownVariant("DryRunTxStatus")) } }) } diff --git a/crates/fuel-core/src/graphql_api.rs b/crates/fuel-core/src/graphql_api.rs index d85647845eb..dbe661d3d4a 100644 --- a/crates/fuel-core/src/graphql_api.rs +++ b/crates/fuel-core/src/graphql_api.rs @@ -58,6 +58,7 @@ pub struct Costs { pub get_peers: usize, pub estimate_predicates: usize, pub dry_run: usize, + pub storage_read_replay: usize, pub submit: usize, pub submit_and_await: usize, pub status_change: usize, @@ -90,6 +91,7 @@ pub const DEFAULT_QUERY_COSTS: Costs = Costs { get_peers: 40001, estimate_predicates: 40001, dry_run: 12000, + storage_read_replay: 12000, submit: 40001, submit_and_await: 40001, status_change: 40001, diff --git a/crates/fuel-core/src/graphql_api/ports.rs b/crates/fuel-core/src/graphql_api/ports.rs index 0a142e35448..2cd42b0d1f1 100644 --- a/crates/fuel-core/src/graphql_api/ports.rs +++ b/crates/fuel-core/src/graphql_api/ports.rs @@ -63,7 +63,10 @@ use fuel_core_types::{ }, fuel_vm::interpreter::Memory, services::{ - executor::TransactionExecutionStatus, + executor::{ + StorageReadReplayEvent, + TransactionExecutionStatus, + }, graphql_api::ContractBalance, p2p::PeerInfo, txpool::TransactionStatus, @@ -256,6 +259,11 @@ pub trait BlockProducerPort: Send + Sync { utxo_validation: Option, gas_price: Option, ) -> anyhow::Result>; + + async fn storage_read_replay( + &self, + height: BlockHeight, + ) -> anyhow::Result>; } #[async_trait::async_trait] diff --git a/crates/fuel-core/src/schema/tx.rs b/crates/fuel-core/src/schema/tx.rs index 8ee21edf972..a2e2664eedd 100644 --- a/crates/fuel-core/src/schema/tx.rs +++ b/crates/fuel-core/src/schema/tx.rs @@ -1,4 +1,7 @@ -use super::scalars::U64; +use super::scalars::{ + U32, + U64, +}; use crate::{ fuel_core_graphql_api::{ api_service::{ @@ -7,6 +10,7 @@ use crate::{ TxPool, }, query_costs, + Config as GraphQLConfig, IntoApiResult, }, graphql_api::{ @@ -73,6 +77,7 @@ use std::{ }; use types::{ DryRunTransactionExecutionStatus, + StorageReadReplayEvent, Transaction, }; @@ -331,6 +336,30 @@ impl TxMutation { Ok(tx_statuses) } + /// Get execution trace for an already-executed transaction. + #[graphql(complexity = "query_costs().storage_read_replay + child_complexity")] + async fn storage_read_replay( + &self, + ctx: &Context<'_>, + height: U32, + ) -> async_graphql::Result> { + let config = ctx.data_unchecked::(); + if !config.debug { + return Err( + anyhow::anyhow!("`debug` must be enabled to use this endpoint").into(), + ); + } + + let block_height = height.into(); + let block_producer = ctx.data_unchecked::(); + Ok(block_producer + .storage_read_replay(block_height) + .await? + .into_iter() + .map(StorageReadReplayEvent::from) + .collect()) + } + /// Submits transaction to the `TxPool`. /// /// Returns submitted transaction if the transaction is included in the `TxPool` without problems. diff --git a/crates/fuel-core/src/schema/tx/types.rs b/crates/fuel-core/src/schema/tx/types.rs index effbc463d0c..0d313930cd2 100644 --- a/crates/fuel-core/src/schema/tx/types.rs +++ b/crates/fuel-core/src/schema/tx/types.rs @@ -980,6 +980,39 @@ impl DryRunTransactionExecutionStatus { } } +pub struct StorageReadReplayEvent { + column: String, + key: HexString, + value: Option, +} + +impl From + for StorageReadReplayEvent +{ + fn from(event: fuel_core_types::services::executor::StorageReadReplayEvent) -> Self { + Self { + column: event.column, + key: HexString(event.key), + value: event.value.map(HexString), + } + } +} + +#[Object] +impl StorageReadReplayEvent { + async fn column(&self) -> String { + self.column.clone() + } + + async fn key(&self) -> HexString { + self.key.clone() + } + + async fn value(&self) -> Option { + self.value.clone() + } +} + #[tracing::instrument(level = "debug", skip(query, txpool), ret, err)] pub(crate) async fn get_tx_status( id: fuel_core_types::fuel_types::Bytes32, diff --git a/crates/fuel-core/src/service/adapters/graphql_api.rs b/crates/fuel-core/src/service/adapters/graphql_api.rs index e2808d7edaf..064dbffb477 100644 --- a/crates/fuel-core/src/service/adapters/graphql_api.rs +++ b/crates/fuel-core/src/service/adapters/graphql_api.rs @@ -46,7 +46,10 @@ use fuel_core_types::{ fuel_types::BlockHeight, services::{ block_importer::SharedImportResult, - executor::TransactionExecutionStatus, + executor::{ + StorageReadReplayEvent, + TransactionExecutionStatus, + }, p2p::PeerInfo, txpool::TransactionStatus, }, @@ -130,6 +133,13 @@ impl BlockProducerPort for BlockProducerAdapter { .dry_run(transactions, height, time, utxo_validation, gas_price) .await } + + async fn storage_read_replay( + &self, + height: BlockHeight, + ) -> anyhow::Result> { + self.block_producer.storage_read_replay(height).await + } } #[async_trait::async_trait] diff --git a/crates/fuel-core/src/service/adapters/producer.rs b/crates/fuel-core/src/service/adapters/producer.rs index 759e7e38263..96c4575a2ff 100644 --- a/crates/fuel-core/src/service/adapters/producer.rs +++ b/crates/fuel-core/src/service/adapters/producer.rs @@ -33,6 +33,7 @@ use fuel_core_storage::{ ConsensusParametersVersions, FuelBlocks, StateTransitionBytecodeVersions, + Transactions, }, transactional::Changes, Result as StorageResult, @@ -40,15 +41,18 @@ use fuel_core_storage::{ }; use fuel_core_types::{ blockchain::{ - block::CompressedBlock, + block::{ + Block, + CompressedBlock, + }, header::{ ConsensusParametersVersion, StateTransitionBytecodeVersion, }, primitives::DaBlockHeight, }, - fuel_tx, fuel_tx::{ + self, ConsensusParameters, Transaction, }, @@ -60,6 +64,7 @@ use fuel_core_types::{ block_producer::Components, executor::{ Result as ExecutorResult, + StorageReadReplayEvent, TransactionExecutionStatus, UncommittedResult, }, @@ -124,6 +129,15 @@ impl fuel_core_producer::ports::DryRunner for ExecutorAdapter { } } +impl fuel_core_producer::ports::StorageReadReplayRecorder for ExecutorAdapter { + fn storage_read_replay( + &self, + block: &Block, + ) -> ExecutorResult> { + self.executor.storage_read_replay(block) + } +} + #[async_trait::async_trait] impl fuel_core_producer::ports::Relayer for MaybeRelayerAdapter { async fn wait_for_at_least_height( @@ -221,6 +235,12 @@ impl fuel_core_producer::ports::BlockProducerDatabase for OnChainIterableKeyValu .ok_or(not_found!(FuelBlocks)) } + fn get_transaction(&self, id: &fuel_tx::TxId) -> StorageResult> { + self.storage::() + .get(id)? + .ok_or(not_found!(Transactions)) + } + fn block_header_merkle_root(&self, height: &BlockHeight) -> StorageResult { self.storage::().root(height).map(Into::into) } diff --git a/crates/fuel-core/src/service/config.rs b/crates/fuel-core/src/service/config.rs index 92a19cae79d..4941aec01d2 100644 --- a/crates/fuel-core/src/service/config.rs +++ b/crates/fuel-core/src/service/config.rs @@ -52,6 +52,7 @@ pub struct Config { /// When `true`: /// - Enables manual block production. /// - Enables debugger endpoint. + /// - Enables storage read replay for historical blocks. /// - Allows setting `utxo_validation` to `false`. pub debug: bool, // default to false until downstream consumers stabilize diff --git a/crates/services/executor/src/executor.rs b/crates/services/executor/src/executor.rs index d4ce6102b17..5b7ca9a22fe 100644 --- a/crates/services/executor/src/executor.rs +++ b/crates/services/executor/src/executor.rs @@ -1276,7 +1276,7 @@ where checked_tx = self.extra_tx_checks(checked_tx, header, storage_tx, memory)?; } - let (reverted, state, tx, receipts) = self.attempt_tx_execution_with_vm( + let (reverted, state, tx, receipts) = self.attempt_tx_execution_with_vm::<_, _>( checked_tx, header, coinbase_contract_id, @@ -1614,6 +1614,7 @@ where Ok(checked_tx) } + #[allow(clippy::type_complexity)] fn attempt_tx_execution_with_vm( &self, checked_tx: Checked, @@ -1657,7 +1658,7 @@ where Some(*header.height()), )?; - let mut vm = Interpreter::with_storage( + let mut vm = Interpreter::<_, _, _>::with_storage( memory, vm_db, InterpreterParams::new(gas_price, &self.consensus_params), @@ -1693,7 +1694,7 @@ where } self.update_tx_outputs(storage_tx, tx_id, &mut tx)?; - Ok((reverted, state, tx, receipts)) + Ok((reverted, state, tx, receipts.to_vec())) } fn verify_inputs_exist_and_values_match( @@ -1998,9 +1999,9 @@ where } /// Log a VM backtrace if configured to do so - fn log_backtrace( + fn log_backtrace( &self, - vm: &Interpreter, Tx>, + vm: &Interpreter, Tx, Ecal>, receipts: &[Receipt], ) where M: Memory, diff --git a/crates/services/producer/src/block_producer.rs b/crates/services/producer/src/block_producer.rs index d8dd3bcf133..f4e43c1da04 100644 --- a/crates/services/producer/src/block_producer.rs +++ b/crates/services/producer/src/block_producer.rs @@ -17,6 +17,7 @@ use anyhow::{ use fuel_core_storage::transactional::{ AtomicView, Changes, + HistoricalView, }; use fuel_core_types::{ blockchain::{ @@ -42,6 +43,7 @@ use fuel_core_types::{ services::{ block_producer::Components, executor::{ + StorageReadReplayEvent, TransactionExecutionStatus, UncommittedResult, }, @@ -382,6 +384,34 @@ where } } +impl + Producer +where + ViewProvider: HistoricalView + 'static, + ViewProvider::LatestView: BlockProducerDatabase, + Executor: ports::StorageReadReplayRecorder + 'static, + GasPriceProvider: GasPriceProviderConstraint, + ConsensusProvider: ConsensusParametersProvider, +{ + /// Re-executes an old block, getting the storage read events. + pub async fn storage_read_replay( + &self, + height: BlockHeight, + ) -> anyhow::Result> { + let view = self.view_provider.latest_view()?; + + let block = view.get_block(&height)?; + let transactions = block + .transactions() + .iter() + .map(|id| view.get_transaction(id).map(|tx| tx.into_owned())) + .collect::, _>>()?; + let block = block.into_owned().uncompress(transactions); + + Ok(self.executor.storage_read_replay(&block)?) + } +} + pub const NO_NEW_DA_HEIGHT_FOUND: &str = "No new da_height found"; impl diff --git a/crates/services/producer/src/mocks.rs b/crates/services/producer/src/mocks.rs index ecf542ee5c6..e797320bbf2 100644 --- a/crates/services/producer/src/mocks.rs +++ b/crates/services/producer/src/mocks.rs @@ -250,6 +250,13 @@ impl BlockProducerDatabase for MockDb { .ok_or(not_found!("Didn't find block for test")) } + fn get_transaction( + &self, + _id: &fuel_core_types::fuel_tx::TxId, + ) -> StorageResult> { + todo!(); + } + fn block_header_merkle_root(&self, height: &BlockHeight) -> StorageResult { Ok(Bytes32::new( [u8::try_from(*height.deref()).expect("Test use small values"); 32], diff --git a/crates/services/producer/src/ports.rs b/crates/services/producer/src/ports.rs index 6875099951a..42a9c15c2c0 100644 --- a/crates/services/producer/src/ports.rs +++ b/crates/services/producer/src/ports.rs @@ -4,7 +4,10 @@ use fuel_core_storage::{ }; use fuel_core_types::{ blockchain::{ - block::CompressedBlock, + block::{ + Block, + CompressedBlock, + }, header::{ ConsensusParametersVersion, StateTransitionBytecodeVersion, @@ -14,12 +17,14 @@ use fuel_core_types::{ fuel_tx::{ Bytes32, Transaction, + TxId, }, fuel_types::BlockHeight, services::{ block_producer::Components, executor::{ Result as ExecutorResult, + StorageReadReplayEvent, TransactionExecutionStatus, UncommittedResult, }, @@ -34,6 +39,9 @@ pub trait BlockProducerDatabase: Send + Sync { /// Gets the committed block at the `height`. fn get_block(&self, height: &BlockHeight) -> StorageResult>; + /// Gets the transaction by id + fn get_transaction(&self, id: &TxId) -> StorageResult>; + /// Gets the block header BMT MMR root at `height`. fn block_header_merkle_root(&self, height: &BlockHeight) -> StorageResult; @@ -101,3 +109,10 @@ pub trait DryRunner: Send + Sync { utxo_validation: Option, ) -> ExecutorResult>; } + +pub trait StorageReadReplayRecorder: Send + Sync { + fn storage_read_replay( + &self, + block: &Block, + ) -> ExecutorResult>; +} diff --git a/crates/services/upgradable-executor/Cargo.toml b/crates/services/upgradable-executor/Cargo.toml index 4bc44301a5c..cb9fe96b65b 100644 --- a/crates/services/upgradable-executor/Cargo.toml +++ b/crates/services/upgradable-executor/Cargo.toml @@ -19,7 +19,7 @@ fuel-core-types = { workspace = true, features = ["std"] } fuel-core-wasm-executor = { workspace = true, features = [ "std", ], optional = true } -parking_lot = { workspace = true, optional = true } +parking_lot = { workspace = true } postcard = { workspace = true, optional = true } tracing = { workspace = true, optional = true } wasmtime = { version = "23.0.2", default-features = false, features = [ @@ -49,7 +49,6 @@ smt = [ wasm-executor = [ "dep:anyhow", "dep:derive_more", - "dep:parking_lot", "dep:postcard", "dep:tracing", "dep:fuel-core-wasm-executor", diff --git a/crates/services/upgradable-executor/src/executor.rs b/crates/services/upgradable-executor/src/executor.rs index 426c8479cf8..fd01b0a8df0 100644 --- a/crates/services/upgradable-executor/src/executor.rs +++ b/crates/services/upgradable-executor/src/executor.rs @@ -1,6 +1,9 @@ -use crate::config::Config; #[cfg(feature = "wasm-executor")] use crate::error::UpgradableError; +use crate::{ + config::Config, + storage_access_recorder::StorageAccessRecorder, +}; use fuel_core_executor::{ executor::{ @@ -41,6 +44,7 @@ use fuel_core_types::{ Error as ExecutorError, ExecutionResult, Result as ExecutorResult, + StorageReadReplayEvent, TransactionExecutionStatus, ValidationResult, }, @@ -379,6 +383,124 @@ where self.validate_inner(block, options) } + #[cfg(not(feature = "wasm-executor"))] + pub fn storage_read_replay( + &self, + block: &Block, + ) -> ExecutorResult> { + let block_version = block.header().state_transition_bytecode_version; + let native_executor_version = self.native_executor_version(); + if block_version == native_executor_version { + self.native_storage_read_replay(block) + } else { + Err(ExecutorError::Other(format!( + "Not supported version `{block_version}`. Expected version is `{}`", + Self::VERSION + ))) + } + } + + #[cfg(feature = "wasm-executor")] + pub fn storage_read_replay( + &self, + block: &Block, + ) -> ExecutorResult> { + let block_version = block.header().state_transition_bytecode_version; + let native_executor_version = self.native_executor_version(); + if block_version == native_executor_version { + match &self.execution_strategy { + ExecutionStrategy::Native => self.native_storage_read_replay(block), + ExecutionStrategy::Wasm { module } => { + if let Ok(module) = self.get_module(block_version) { + self.wasm_storage_read_replay(&module, block) + } else { + self.wasm_storage_read_replay(module, block) + } + } + } + } else { + let module = self.get_module(block_version)?; + self.wasm_storage_read_replay(&module, block) + } + } + + pub fn native_storage_read_replay( + &self, + block: &Block, + ) -> ExecutorResult> { + let previous_block_height = block.header().height().pred(); + let relayer = self.relayer_view_provider.latest_view()?; + + let storage_rec = if let Some(previous_block_height) = previous_block_height { + let database = self.storage_view_provider.view_at(&previous_block_height)?; + let database = StorageAccessRecorder::new(database); + let database_rec = database.record.clone(); + + let executor = + ExecutionInstance::new(relayer, database, self.config.as_ref().into()); + let _ = executor.validate_without_commit(block)?; + database_rec + } else { + let database = self.storage_view_provider.latest_view()?; + let database = StorageAccessRecorder::new(database); + let database_rec = database.record.clone(); + let executor = + ExecutionInstance::new(relayer, database, self.config.as_ref().into()); + let _ = executor.validate_without_commit(block)?; + database_rec + }; + + let mut g = storage_rec.lock(); + Ok(core::mem::take(&mut g)) + } + + #[cfg(feature = "wasm-executor")] + fn wasm_storage_read_replay( + &self, + module: &wasmtime::Module, + block: &Block, + ) -> ExecutorResult> { + let options = self.config.as_ref().into(); + let previous_block_height = block.header().height().pred(); + + let instance = crate::instance::Instance::new(&self.engine).no_source()?; + + let (instance, storage_rec) = if let Some(previous_block_height) = + previous_block_height + { + let storage = self.storage_view_provider.view_at(&previous_block_height)?; + let storage = StorageAccessRecorder::new(storage); + let storage_rec = storage.record.clone(); + (instance.add_storage(storage)?, storage_rec) + } else { + let storage = self.storage_view_provider.latest_view()?; + let storage = StorageAccessRecorder::new(storage); + let storage_rec = storage.record.clone(); + (instance.add_storage(storage)?, storage_rec) + }; + + let relayer = self.relayer_view_provider.latest_view()?; + let instance = instance + .add_relayer(relayer)? + .add_validation_input_data(block, options)?; + + let output = instance.run(module)?; + + match output { + ReturnType::ExecutionV0(result) => { + let _ = convert_from_v0_execution_result(result)?; + } + ReturnType::ExecutionV1(result) => { + let _ = convert_from_v1_execution_result(result)?; + } + ReturnType::Validation(result) => { + let _ = result?; + } + } + let mut g = storage_rec.lock(); + Ok(core::mem::take(&mut g)) + } + #[cfg(feature = "wasm-executor")] fn produce_inner( &self, diff --git a/crates/services/upgradable-executor/src/lib.rs b/crates/services/upgradable-executor/src/lib.rs index 3d5cd4422f4..7297df029c8 100644 --- a/crates/services/upgradable-executor/src/lib.rs +++ b/crates/services/upgradable-executor/src/lib.rs @@ -7,6 +7,7 @@ pub mod config; pub mod error; pub mod executor; +mod storage_access_recorder; pub use fuel_core_executor as native_executor; #[cfg(feature = "wasm-executor")] diff --git a/crates/services/upgradable-executor/src/storage_access_recorder.rs b/crates/services/upgradable-executor/src/storage_access_recorder.rs new file mode 100644 index 00000000000..f495360d8ef --- /dev/null +++ b/crates/services/upgradable-executor/src/storage_access_recorder.rs @@ -0,0 +1,49 @@ +use fuel_core_storage::{ + kv_store::{ + KeyValueInspect, + StorageColumn, + Value, + }, + Result as StorageResult, +}; +use fuel_core_types::services::executor::StorageReadReplayEvent; +use parking_lot::Mutex; +use std::sync::Arc; + +#[derive(Clone, Debug)] +pub struct StorageAccessRecorder +where + S: KeyValueInspect, +{ + pub storage: S, + pub record: Arc>>, +} + +impl StorageAccessRecorder +where + S: KeyValueInspect, +{ + pub fn new(storage: S) -> Self { + Self { + storage, + record: Default::default(), + } + } +} + +impl KeyValueInspect for StorageAccessRecorder +where + S: KeyValueInspect, +{ + type Column = S::Column; + + fn get(&self, key: &[u8], column: Self::Column) -> StorageResult> { + let value = self.storage.get(key, column)?; + self.record.lock().push(StorageReadReplayEvent { + column: column.name(), + key: key.to_vec(), + value: value.as_ref().map(|v| v.to_vec()), + }); + Ok(value) + } +} diff --git a/crates/types/Cargo.toml b/crates/types/Cargo.toml index cbf9cd7753c..479c0f64168 100644 --- a/crates/types/Cargo.toml +++ b/crates/types/Cargo.toml @@ -28,6 +28,7 @@ enum_dispatch = { workspace = true } fuel-vm-private = { workspace = true, default-features = false, features = [ "alloc", ] } +hex = { workspace = true } k256 = { version = "0.13", default-features = false, features = ["ecdsa"] } rand = { workspace = true, optional = true } secrecy = "0.8" diff --git a/crates/types/src/services/executor.rs b/crates/types/src/services/executor.rs index 810bcdb44c3..80baa97adca 100644 --- a/crates/types/src/services/executor.rs +++ b/crates/types/src/services/executor.rs @@ -1,5 +1,7 @@ //! Types related to executor service. +use core::fmt; + use crate::{ blockchain::{ block::Block, @@ -256,6 +258,27 @@ impl TransactionExecutionResult { } } +/// When storage in column:key was read, it contained this value. +#[derive(Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct StorageReadReplayEvent { + /// Column in the storage, identified by name. + pub column: String, + /// Key in the column. + pub key: Vec, + /// Value at the column:key pair. None if the key was not found. + pub value: Option>, +} +impl fmt::Debug for StorageReadReplayEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("StorageReadReplayEvent") + .field("column", &self.column) + .field("key", &hex::encode(&self.key)) + .field("value", &self.value.as_ref().map(hex::encode)) + .finish() + } +} + #[allow(missing_docs)] #[derive(Debug, Clone, PartialEq, derive_more::Display, derive_more::From)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/tests/tests/assets.rs b/tests/tests/assets.rs index 1d17577555f..420fe444528 100644 --- a/tests/tests/assets.rs +++ b/tests/tests/assets.rs @@ -102,7 +102,7 @@ async fn asset_info_mint_burn() { .copied() .collect(); let script = TransactionBuilder::script( - script_ops.into_iter().collect::>().into(), + script_ops.into_iter().collect::>(), script_data, ) // Add contract as input of the transaction @@ -150,7 +150,7 @@ async fn asset_info_mint_burn() { .copied() .collect(); let script = TransactionBuilder::script( - script_ops.into_iter().collect::>().into(), + script_ops.into_iter().collect::>(), script_data, ) // Add contract as input of the transaction diff --git a/tests/tests/gas_price.rs b/tests/tests/gas_price.rs index 975cdc26b95..b995162284d 100644 --- a/tests/tests/gas_price.rs +++ b/tests/tests/gas_price.rs @@ -326,8 +326,7 @@ async fn produce_block__dont_raises_gas_price_with_default_parameters() { .await .unwrap() .unwrap() - .base_asset_id() - .clone(); + .base_asset_id(); // when let arb_tx_count = 20; diff --git a/tests/tests/lib.rs b/tests/tests/lib.rs index 42b08f387b9..61e52ab872f 100644 --- a/tests/tests/lib.rs +++ b/tests/tests/lib.rs @@ -54,6 +54,8 @@ mod snapshot; #[cfg(not(feature = "only-p2p"))] mod state_rewind; #[cfg(not(feature = "only-p2p"))] +mod storage_read_replay; +#[cfg(not(feature = "only-p2p"))] mod trigger_integration; #[cfg(not(feature = "only-p2p"))] mod tx; diff --git a/tests/tests/storage_read_replay.rs b/tests/tests/storage_read_replay.rs new file mode 100644 index 00000000000..d61b2da815b --- /dev/null +++ b/tests/tests/storage_read_replay.rs @@ -0,0 +1,181 @@ +use fuel_core::service::{ + Config, + FuelService, +}; +use fuel_core_client::client::{ + types::StatusWithTransaction, + FuelClient, +}; +use fuel_core_types::{ + fuel_asm::{ + op, + GTFArgs, + RegId, + }, + fuel_tx::{ + Bytes32, + ContractId, + CreateMetadata, + Finalizable, + Input, + Output, + StorageSlot, + TransactionBuilder, + }, + fuel_types::BlockHeight, + fuel_vm::{ + Salt, + SecretKey, + }, +}; +use rand::{ + Rng, + SeedableRng, +}; + +async fn make_counter_contract( + client: &FuelClient, + rng: &mut rand::rngs::StdRng, +) -> (ContractId, BlockHeight) { + let maturity = Default::default(); + + let code: Vec<_> = [ + // Make zero key + op::movi(0x12, 32), + op::aloc(0x12), + // Read value + op::srw(0x10, 0x11, 0x12), + // Increment value + op::addi(0x10, 0x10, 1), + // Write value + op::sww(0x12, 0x11, 0x10), + // Return new counter value + op::ret(0x10), + ] + .into_iter() + .collect(); + + let salt: Salt = rng.gen(); + let tx = TransactionBuilder::create( + code.into(), + salt, + vec![StorageSlot::new(Bytes32::zeroed(), Bytes32::zeroed())], + ) + .maturity(maturity) + .add_fee_input() + .add_contract_created() + .finalize(); + + let contract_id = CreateMetadata::compute(&tx).unwrap().contract_id; + + let status = client.submit_and_await_commit_with_tx(&tx.into()).await; + let Ok(StatusWithTransaction::Success { block_height, .. }) = status else { + panic!("Tx wasn't included in a block: {status:?}"); + }; + (contract_id, block_height) +} + +async fn increment_counter( + client: &FuelClient, + rng: &mut rand::rngs::StdRng, + contract_id: ContractId, +) -> BlockHeight { + let gas_limit = 1_000_000; + let maturity = Default::default(); + + let script = [ + op::gtf_args(0x10, RegId::ZERO, GTFArgs::ScriptData), + op::call(0x10, RegId::ZERO, RegId::ZERO, RegId::CGAS), + op::log(0x10, RegId::ZERO, RegId::ZERO, RegId::ZERO), + op::ret(RegId::ONE), + ]; + + let mut script_data = contract_id.to_vec(); + script_data.extend(0u64.to_be_bytes()); + script_data.extend(0u64.to_be_bytes()); + + let tx = TransactionBuilder::script(script.into_iter().collect(), script_data) + .script_gas_limit(gas_limit) + .maturity(maturity) + .add_unsigned_coin_input( + SecretKey::random(rng), + rng.gen(), + u32::MAX as u64, + Default::default(), + Default::default(), + ) + .add_input(Input::contract( + rng.gen(), + rng.gen(), + rng.gen(), + Default::default(), + contract_id, + )) + .add_output(Output::contract(1, Default::default(), Default::default())) + .finalize_as_transaction(); + + let status = client.submit_and_await_commit_with_tx(&tx.into()).await; + let Ok(StatusWithTransaction::Success { block_height, .. }) = status else { + panic!("Tx wasn't included in a block: {status:?}"); + }; + block_height +} + +fn get_counter_from_storage_bytes(storage_bytes: &[u8]) -> u64 { + assert!(storage_bytes.len() == 32, "Storage slot size mismatch"); + assert!( + storage_bytes[8..].iter().all(|v| *v == 0), + "Counter values cannot be over u64::MAX" + ); + let mut buffer = [0; 8]; + buffer.copy_from_slice(&storage_bytes[..8]); + u64::from_be_bytes(buffer) +} + +/// Create a counter contract. +/// Increment it multiple times, and make sure the replay gives correct storage state every time. +#[tokio::test(flavor = "multi_thread")] +async fn storage_read_replay__returns_counter_state() { + let mut rng = rand::rngs::StdRng::seed_from_u64(0xBAADF00D); + + // given + let mut node_config = Config::local_node(); + node_config.debug = true; + let srv = FuelService::new_node(node_config.clone()).await.unwrap(); + let client = FuelClient::from(srv.bound_address); + + let (contract_id, block_height) = make_counter_contract(&client, &mut rng).await; + + let _replay = client + .storage_read_replay(&block_height) + .await + .expect("Failed to replay storage read"); + + let mut storage_slot_key = contract_id.to_vec(); + storage_slot_key.extend(Bytes32::zeroed().to_vec()); + + for i in 0..10u64 { + // when + let block_height = increment_counter(&client, &mut rng, contract_id).await; + + let replay = client + .storage_read_replay(&block_height) + .await + .expect("Failed to replay storage read"); + + // then + let storage_bytes = replay + .iter() + .find(|item| item.column == "ContractsState" && item.key == storage_slot_key) + .expect("No storage read found") + .value + .clone() + .expect("Storage read was unexpectedly empty"); + + assert_eq!( + i, + get_counter_from_storage_bytes(&storage_bytes), + "Counter value mismatch" + ); + } +}