From 309e668c4945b232488f2a4f69494b4692b4924b Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Wed, 11 Mar 2026 15:56:46 +0100 Subject: [PATCH 1/4] swarm: add revert_reason to transaction receipts for error observability Propagate ErrorCode information from failed TxResult responses through the receipt pipeline so Ethereum tooling (ethers, viem, Foundry) can surface revert reasons via the revertReason field. Changes: - RpcReceipt: add optional revert_reason field (skip_serializing_if None) - RpcReceipt::success(): set revert_reason: None - RpcReceipt::failure(): accept revert_reason: Option parameter - StoredReceipt: add optional revert_reason field - StoredReceipt::to_rpc_receipt(): pass through revert_reason - SQLite receipts table: add revert_reason TEXT column - insert_receipt(): include revert_reason in INSERT - get_receipt() SELECT: include receipts.revert_reason (column index 12) - row_to_stored_receipt(): read revert_reason at index 12 - build_stored_receipt(): format ErrorCode(id=0x{id:04x}, arg={arg}) on Err - Test helpers updated with revert_reason: None Co-Authored-By: Claude Sonnet 4.6 --- crates/rpc/chain-index/src/index.rs | 11 ++++++++--- crates/rpc/chain-index/src/integration.rs | 5 +++++ crates/rpc/chain-index/src/provider.rs | 1 + crates/rpc/chain-index/src/types.rs | 4 ++++ crates/rpc/types/src/receipt.rs | 7 +++++++ 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/rpc/chain-index/src/index.rs b/crates/rpc/chain-index/src/index.rs index 43b57ee..82c612b 100644 --- a/crates/rpc/chain-index/src/index.rs +++ b/crates/rpc/chain-index/src/index.rs @@ -209,6 +209,7 @@ impl PersistentChainIndex { contract_address BLOB, status INTEGER NOT NULL, tx_type INTEGER NOT NULL, + revert_reason TEXT, FOREIGN KEY (block_number) REFERENCES blocks(number) ); CREATE INDEX IF NOT EXISTS idx_receipts_block ON receipts(block_number); @@ -342,6 +343,7 @@ impl PersistentChainIndex { let status: i64 = row.get(9)?; let tx_type: i64 = row.get(10)?; let effective_gas_price_bytes: Vec = row.get(11)?; + let revert_reason: Option = row.get(12)?; let to = to_bytes .as_deref() @@ -366,6 +368,7 @@ impl PersistentChainIndex { logs: vec![], // logs are stored separately status: status as u8, tx_type: tx_type as u8, + revert_reason, }) } } @@ -504,7 +507,7 @@ impl ChainIndex for PersistentChainIndex { receipts.block_number, receipts.from_addr, receipts.to_addr, receipts.cumulative_gas_used, receipts.gas_used, receipts.contract_address, receipts.status, receipts.tx_type, - transactions.gas_price + transactions.gas_price, receipts.revert_reason FROM receipts INNER JOIN transactions ON transactions.hash = receipts.transaction_hash WHERE receipts.transaction_hash = ?", @@ -672,8 +675,8 @@ fn insert_receipt( tx.execute( "INSERT OR REPLACE INTO receipts (transaction_hash, transaction_index, block_hash, block_number, from_addr, to_addr, - cumulative_gas_used, gas_used, contract_address, status, tx_type) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + cumulative_gas_used, gas_used, contract_address, status, tx_type, revert_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", params![ receipt.transaction_hash.as_slice(), array_index, @@ -686,6 +689,7 @@ fn insert_receipt( receipt.contract_address.as_ref().map(|a| a.as_slice()), receipt.status as i64, receipt.tx_type as i64, + receipt.revert_reason.as_deref(), ], )?; Ok(()) @@ -864,6 +868,7 @@ mod tests { logs: vec![], status: if success { 1 } else { 0 }, tx_type: 0, + revert_reason: None, } } diff --git a/crates/rpc/chain-index/src/integration.rs b/crates/rpc/chain-index/src/integration.rs index 31515fc..fe6bd96 100644 --- a/crates/rpc/chain-index/src/integration.rs +++ b/crates/rpc/chain-index/src/integration.rs @@ -329,6 +329,10 @@ fn build_stored_receipt( let to = resolve_recipient_address(tx); let logs: Vec = tx_result.events.iter().map(event_to_stored_log).collect(); let status = if tx_result.response.is_ok() { 1 } else { 0 }; + let revert_reason = match &tx_result.response { + Err(err) => Some(format!("ErrorCode(id=0x{:04x}, arg={})", err.id, err.arg)), + Ok(_) => None, + }; StoredReceipt { transaction_hash: tx_hash, @@ -347,6 +351,7 @@ fn build_stored_receipt( logs, status, tx_type: eth_fields.as_ref().map(|f| f.tx_type).unwrap_or(0), + revert_reason, } } diff --git a/crates/rpc/chain-index/src/provider.rs b/crates/rpc/chain-index/src/provider.rs index 9ecb5cf..34a09fa 100644 --- a/crates/rpc/chain-index/src/provider.rs +++ b/crates/rpc/chain-index/src/provider.rs @@ -878,6 +878,7 @@ mod tests { logs: vec![], status: 1, tx_type: 0, + revert_reason: None, } } diff --git a/crates/rpc/chain-index/src/types.rs b/crates/rpc/chain-index/src/types.rs index be03da6..f418bfd 100644 --- a/crates/rpc/chain-index/src/types.rs +++ b/crates/rpc/chain-index/src/types.rs @@ -169,6 +169,9 @@ pub struct StoredReceipt { pub status: u8, /// Transaction type. pub tx_type: u8, + /// Revert reason for failed transactions. + #[serde(skip_serializing_if = "Option::is_none")] + pub revert_reason: Option, } impl StoredReceipt { @@ -204,6 +207,7 @@ impl StoredReceipt { logs_bloom: Bytes::new(), tx_type: U64::from(self.tx_type as u64), status: U64::from(self.status as u64), + revert_reason: self.revert_reason.clone(), } } } diff --git a/crates/rpc/types/src/receipt.rs b/crates/rpc/types/src/receipt.rs index 993142c..370e6e3 100644 --- a/crates/rpc/types/src/receipt.rs +++ b/crates/rpc/types/src/receipt.rs @@ -42,6 +42,9 @@ pub struct RpcReceipt { pub tx_type: U64, /// Status (1 = success, 0 = failure) pub status: U64, + /// Revert reason for failed transactions (Evolve error code and message). + #[serde(skip_serializing_if = "Option::is_none")] + pub revert_reason: Option, } impl RpcReceipt { @@ -78,6 +81,7 @@ impl RpcReceipt { logs_bloom: Bytes::new(), tx_type: U64::ZERO, status: U64::from(Self::STATUS_SUCCESS), + revert_reason: None, } } @@ -92,6 +96,7 @@ impl RpcReceipt { to: Option
, gas_used: u64, cumulative_gas_used: u64, + revert_reason: Option, ) -> Self { Self { transaction_hash: tx_hash, @@ -108,6 +113,7 @@ impl RpcReceipt { logs_bloom: Bytes::new(), tx_type: U64::ZERO, status: U64::from(Self::STATUS_FAILURE), + revert_reason, } } @@ -153,6 +159,7 @@ mod tests { None, 50000, 50000, + None, ); assert!(!receipt.is_success()); assert_eq!(receipt.status, U64::from(0u64)); From 41a3d9a4f3041dab510b92534bd1658882355ddd Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Thu, 12 Mar 2026 10:04:44 +0100 Subject: [PATCH 2/4] add more tests and clearer startup paths --- bin/testapp/examples/print_token_address.rs | 55 ++ crates/app/node/src/lib.rs | 69 +- crates/app/stf/src/invoker.rs | 26 +- crates/app/stf/src/lib.rs | 94 +- crates/rpc/chain-index/src/lib.rs | 2 +- crates/rpc/chain-index/src/provider.rs | 125 ++- crates/rpc/chain-index/src/querier.rs | 946 +++++++++++++++++++- crates/rpc/eth-jsonrpc/src/error.rs | 2 +- crates/rpc/eth-jsonrpc/src/server.rs | 14 +- crates/rpc/evnode/src/runner.rs | 26 +- docs/bun.lock | 30 +- docs/package.json | 7 +- docs/scripts/viem-transfer-e2e.ts | 573 ++++++++++++ docs/vocs.config.ts | 1 - 14 files changed, 1886 insertions(+), 84 deletions(-) create mode 100644 bin/testapp/examples/print_token_address.rs create mode 100644 docs/scripts/viem-transfer-e2e.ts diff --git a/bin/testapp/examples/print_token_address.rs b/bin/testapp/examples/print_token_address.rs new file mode 100644 index 0000000..833d538 --- /dev/null +++ b/bin/testapp/examples/print_token_address.rs @@ -0,0 +1,55 @@ +use std::path::PathBuf; +use std::sync::mpsc; + +use clap::Parser; +use commonware_runtime::tokio::{Config as TokioConfig, Runner}; +use commonware_runtime::Runner as RunnerTrait; +use evolve_node::HasTokenAccountId; +use evolve_server::load_chain_state; +use evolve_storage::{QmdbStorage, StorageConfig}; +use evolve_testapp::GenesisAccounts; +use evolve_tx_eth::derive_runtime_contract_address; + +#[derive(Debug, Parser)] +#[command(name = "print_token_address")] +#[command(about = "Print the testapp token contract address for an initialized data dir")] +struct Cli { + /// Path to the initialized node data directory + #[arg(long)] + data_dir: PathBuf, +} + +fn main() { + let cli = Cli::parse(); + let data_dir = cli.data_dir; + let (tx, rx) = mpsc::channel(); + + let runtime_config = TokioConfig::default() + .with_storage_directory(&data_dir) + .with_worker_threads(2); + + Runner::new(runtime_config).start(move |context| { + let data_dir = data_dir.clone(); + async move { + let storage = QmdbStorage::new( + context, + StorageConfig { + path: data_dir, + ..Default::default() + }, + ) + .await + .expect("open qmdb storage"); + + let state = load_chain_state::(&storage) + .expect("load initialized chain state"); + let token_address = + derive_runtime_contract_address(state.genesis_result.token_account_id()); + + tx.send(token_address).expect("send token address"); + } + }); + + let token_address = rx.recv().expect("receive token address"); + println!("{token_address:#x}"); +} diff --git a/crates/app/node/src/lib.rs b/crates/app/node/src/lib.rs index 945ad99..cc4f445 100644 --- a/crates/app/node/src/lib.rs +++ b/crates/app/node/src/lib.rs @@ -51,6 +51,16 @@ pub const DEFAULT_DATA_DIR: &str = "./data"; /// Default RPC server address. pub const DEFAULT_RPC_ADDR: &str = "127.0.0.1:8545"; +fn ensure_rpc_startup_compatibility(provider: &ChainStateProvider, runner_name: &str) +where + I: evolve_chain_index::ChainIndex, + A: AccountsCodeStorage + Send + Sync, +{ + if let Err(err) = provider.ensure_rpc_compatibility() { + panic!("{runner_name} cannot start RPC: {err}"); + } +} + fn parse_env_u64(var: &str, default: u64) -> u64 { std::env::var(var) .ok() @@ -96,7 +106,8 @@ where Tx: Transaction + MempoolTx + Encodable + Send + Sync + 'static, S: ReadonlyKV + Storage + Clone + Send + Sync + 'static, Codes: AccountsCodeStorage + Send + Sync + 'static, - Stf: StfExecutor + Send + Sync + 'static, + Stf: + StfExecutor + evolve_chain_index::RpcExecutionContext + Send + Sync + 'static, { let mempool: SharedMempool> = new_shared_mempool(); let dev = Arc::new(DevConsensus::with_mempool( @@ -289,6 +300,9 @@ struct EthRunnerConfig { } /// Run the dev node with default settings (RPC enabled). +/// Run the dev node with RPC disabled by default. +/// +/// Use `run_dev_node_with_rpc_and_mempool_eth` when you need compatible ETH JSON-RPC. pub fn run_dev_node< Stf, Codes, @@ -312,7 +326,8 @@ pub fn run_dev_node< Tx: Transaction + MempoolTx + Encodable + Send + Sync + 'static, Codes: AccountsCodeStorage + Send + Sync + 'static, S: ReadonlyKV + Storage + Clone + Send + Sync + 'static, - Stf: StfExecutor + Send + Sync + 'static, + Stf: + StfExecutor + evolve_chain_index::RpcExecutionContext + Send + Sync + 'static, G: BorshSerialize + BorshDeserialize + Clone @@ -339,11 +354,14 @@ pub fn run_dev_node< build_codes, run_genesis, build_storage, - RpcConfig::default(), + RpcConfig::disabled(), ) } /// Run the dev node with custom RPC configuration. +/// +/// Startup fails if RPC is enabled for a generic transaction runner that +/// cannot provide mempool-backed ETH ingress verification. pub fn run_dev_node_with_rpc< Stf, Codes, @@ -368,7 +386,8 @@ pub fn run_dev_node_with_rpc< Tx: Transaction + MempoolTx + Encodable + Send + Sync + 'static, Codes: AccountsCodeStorage + Send + Sync + 'static, S: ReadonlyKV + Storage + Clone + Send + Sync + 'static, - Stf: StfExecutor + Send + Sync + 'static, + Stf: + StfExecutor + evolve_chain_index::RpcExecutionContext + Send + Sync + 'static, G: BorshSerialize + BorshDeserialize + Clone @@ -490,9 +509,12 @@ pub fn run_dev_node_with_rpc< gas_price: U256::ZERO, sync_status: SyncStatus::NotSyncing(false), }; + let query_executor = Arc::new((build_stf)(&genesis_result)); let state_querier: Arc = Arc::new(StorageStateQuerier::new( storage.clone(), genesis_result.token_account_id(), + Arc::clone(&codes_for_rpc), + query_executor, )); let state_provider = ChainStateProvider::with_account_codes( Arc::clone(&chain_index), @@ -500,6 +522,7 @@ pub fn run_dev_node_with_rpc< Arc::clone(&codes_for_rpc), ) .with_state_querier(Arc::clone(&state_querier)); + ensure_rpc_startup_compatibility(&state_provider, "run_dev_node_with_rpc"); // Start JSON-RPC server let server_config = RpcServerConfig { @@ -523,6 +546,7 @@ pub fn run_dev_node_with_rpc< codes_for_rpc, ) .with_state_querier(state_querier); + ensure_rpc_startup_compatibility(&grpc_state_provider, "run_dev_node_with_rpc"); let grpc_config = grpc_server_config(&rpc_config, grpc_addr); tracing::info!("Starting gRPC server on {}", grpc_addr); let grpc_server = GrpcServer::with_subscription_manager( @@ -768,12 +792,10 @@ pub fn run_dev_node_with_rpc_and_mempool< let mempool: SharedMempool> = new_shared_mempool(); // Note: RPC with custom Tx types is not fully supported. - // The RPC layer requires TxContext for eth_sendRawTransaction. - // For custom Tx types, use run_dev_node_with_rpc_and_mempool_eth instead. if rpc_config.enabled { - tracing::warn!( - "RPC enabled with generic Tx type. eth_sendRawTransaction will not work. \ - Use run_dev_node_with_rpc_and_mempool_eth for ETH transactions with full RPC support." + panic!( + "run_dev_node_with_rpc_and_mempool cannot start RPC for generic transaction \ + types. Use run_dev_node_with_rpc_and_mempool_eth for ETH-compatible RPC." ); } @@ -844,7 +866,11 @@ pub fn run_dev_node_with_rpc_and_mempool_eth< ) where Codes: AccountsCodeStorage + Send + Sync + 'static, S: ReadonlyKV + Storage + Clone + Send + Sync + 'static, - Stf: StfExecutor + Send + Sync + 'static, + Stf: StfExecutor + + evolve_chain_index::RpcExecutionContext + + Send + + Sync + + 'static, G: BorshSerialize + BorshDeserialize + Clone @@ -900,7 +926,11 @@ fn run_dev_node_with_rpc_and_mempool_eth_impl< ) where Codes: AccountsCodeStorage + Send + Sync + 'static, S: ReadonlyKV + Storage + Clone + Send + Sync + 'static, - Stf: StfExecutor + Send + Sync + 'static, + Stf: StfExecutor + + evolve_chain_index::RpcExecutionContext + + Send + + Sync + + 'static, G: BorshSerialize + BorshDeserialize + Clone @@ -1030,10 +1060,13 @@ fn run_dev_node_with_rpc_and_mempool_eth_impl< }; // Create state querier for balance/nonce reads + let query_executor = Arc::new((build_stf)(&genesis_result)); let state_querier: Arc = Arc::new( StorageStateQuerier::new( storage.clone(), genesis_result.token_account_id(), + Arc::clone(&codes_for_rpc), + query_executor, ), ); @@ -1044,6 +1077,10 @@ fn run_dev_node_with_rpc_and_mempool_eth_impl< mempool.clone(), ) .with_state_querier(Arc::clone(&state_querier)); + ensure_rpc_startup_compatibility( + &state_provider, + "run_dev_node_with_rpc_and_mempool_eth", + ); let server_config = RpcServerConfig { http_addr: rpc_config.http_addr, @@ -1068,6 +1105,10 @@ fn run_dev_node_with_rpc_and_mempool_eth_impl< mempool.clone(), ) .with_state_querier(state_querier); + ensure_rpc_startup_compatibility( + &grpc_state_provider, + "run_dev_node_with_rpc_and_mempool_eth", + ); let grpc_config = grpc_server_config(&rpc_config, grpc_addr); tracing::info!("Starting gRPC server on {}", grpc_addr); let grpc_server = GrpcServer::with_subscription_manager( @@ -1213,7 +1254,11 @@ pub fn run_dev_node_with_rpc_and_mempool_mock_storage< rpc_config: RpcConfig, ) where Codes: AccountsCodeStorage + Send + Sync + 'static, - Stf: StfExecutor + Send + Sync + 'static, + Stf: StfExecutor + + evolve_chain_index::RpcExecutionContext + + Send + + Sync + + 'static, G: BorshSerialize + BorshDeserialize + Clone diff --git a/crates/app/stf/src/invoker.rs b/crates/app/stf/src/invoker.rs index 3d564ac..2c66c8b 100644 --- a/crates/app/stf/src/invoker.rs +++ b/crates/app/stf/src/invoker.rs @@ -179,17 +179,37 @@ impl<'s, 'a, S: ReadonlyKV, A: AccountsCodeStorage> Invoker<'s, 'a, S, A> { account_codes: &'a A, gas_counter: &'a mut GasCounter, recipient: AccountId, + ) -> Self { + Self::new_for_query_with_context( + storage, + account_codes, + gas_counter, + recipient, + RUNTIME_ACCOUNT_ID, + vec![], + BlockContext::default(), + ) + } + + pub fn new_for_query_with_context( + storage: &'a mut ExecutionState<'s, S>, + account_codes: &'a A, + gas_counter: &'a mut GasCounter, + recipient: AccountId, + sender: AccountId, + funds: Vec, + block: BlockContext, ) -> Self { Self { whoami: recipient, - sender: RUNTIME_ACCOUNT_ID, - funds: vec![], + sender, + funds, account_codes, storage, gas_counter, scope: ExecutionScope::Query, call_depth: 0, - block: BlockContext::default(), + block, } } diff --git a/crates/app/stf/src/lib.rs b/crates/app/stf/src/lib.rs index 70bab39..b71b126 100644 --- a/crates/app/stf/src/lib.rs +++ b/crates/app/stf/src/lib.rs @@ -41,8 +41,8 @@ use crate::metrics::{BlockExecutionMetrics, TxExecutionMetrics}; use crate::results::{BlockResult, TxResult}; use evolve_core::events_api::Event; use evolve_core::{ - AccountCode, AccountId, BlockContext, Environment, EnvironmentQuery, InvokableMessage, - InvokeRequest, InvokeResponse, ReadonlyKV, SdkResult, + AccountCode, AccountId, BlockContext, Environment, EnvironmentQuery, FungibleAsset, + InvokableMessage, InvokeRequest, InvokeResponse, ReadonlyKV, SdkResult, }; use evolve_stf_traits::{ AccountsCodeStorage, BeginBlocker as BeginBlockerTrait, Block as BlockTrait, @@ -90,6 +90,30 @@ pub struct Stf { _phantoms: PhantomData<(Tx, Block)>, } +/// Execution context for STF-backed read-only queries. +#[derive(Clone, Debug)] +pub struct QueryContext { + /// Optional gas limit for the query. `None` means unbounded gas tracking. + pub gas_limit: Option, + /// Caller identity exposed as `env.sender()`. + pub sender: AccountId, + /// Funds exposed through the read-only environment. + pub funds: Vec, + /// Block metadata exposed through `env.block()`. + pub block: BlockContext, +} + +impl Default for QueryContext { + fn default() -> Self { + Self { + gas_limit: None, + sender: evolve_core::runtime_api::RUNTIME_ACCOUNT_ID, + funds: vec![], + block: BlockContext::default(), + } + } +} + #[cfg(test)] mod model_tests { use super::*; @@ -1395,6 +1419,72 @@ where let mut ctx = Invoker::new_for_query(&mut state, account_codes, &mut gas_counter, to); ctx.do_query(to, &InvokeRequest::new(req)?) } + + /// Executes a query represented as a raw [`InvokeRequest`]. + /// + /// Unlike [`query`](Self::query), this accepts a fully constructed request and + /// allows the caller to provide sender, funds, and block metadata. This is + /// used by RPC adapters that need to mirror transaction-like context while + /// keeping the operation read-only. + pub fn query_invoke_request<'a, S: ReadonlyKV + 'a, A: AccountsCodeStorage + 'a>( + &self, + storage: &'a S, + account_codes: &'a A, + to: AccountId, + request: &InvokeRequest, + context: QueryContext, + ) -> TxResult { + let QueryContext { + gas_limit, + sender, + funds, + block, + } = context; + let mut state = ExecutionState::new(storage); + let mut gas_counter = gas_limit + .map(|limit| GasCounter::finite(limit, self.storage_gas_config.clone())) + .unwrap_or_else(GasCounter::infinite); + let mut ctx = Invoker::new_for_query_with_context( + &mut state, + account_codes, + &mut gas_counter, + to, + sender, + funds, + block, + ); + let response = ctx.do_query(to, request); + + TxResult { + events: state.pop_events(), + gas_used: gas_counter.gas_used(), + response, + } + } + + /// Executes a single transaction against the current state without committing changes. + /// + /// This is intended for RPC simulations such as `eth_call` and + /// `eth_estimateGas`. The transaction goes through the normal sender + /// resolution, bootstrap, validation, and execution pipeline, but all writes + /// remain in the temporary execution state. + pub fn simulate_transaction<'a, S: ReadonlyKV + 'a, A: AccountsCodeStorage + 'a>( + &self, + storage: &'a S, + account_codes: &'a A, + tx: &Tx, + block: BlockContext, + ) -> TxResult { + let mut state = ExecutionState::new(storage); + self.apply_tx( + &mut state, + account_codes, + tx, + self.storage_gas_config.clone(), + block, + ) + } + /// Executes a closure with read-only access to a specific account's code. /// /// This method provides a developer-friendly way to extract data from account state diff --git a/crates/rpc/chain-index/src/lib.rs b/crates/rpc/chain-index/src/lib.rs index c49effd..9e50876 100644 --- a/crates/rpc/chain-index/src/lib.rs +++ b/crates/rpc/chain-index/src/lib.rs @@ -44,5 +44,5 @@ pub use integration::{build_index_data, event_to_stored_log, index_block, BlockM pub use provider::{ ChainStateProvider, ChainStateProviderConfig, NoopAccountCodes, DEFAULT_PROTOCOL_VERSION, }; -pub use querier::{StateQuerier, StorageStateQuerier}; +pub use querier::{RpcExecutionContext, StateQuerier, StorageStateQuerier}; pub use types::*; diff --git a/crates/rpc/chain-index/src/provider.rs b/crates/rpc/chain-index/src/provider.rs index 34a09fa..bb53351 100644 --- a/crates/rpc/chain-index/src/provider.rs +++ b/crates/rpc/chain-index/src/provider.rs @@ -288,6 +288,20 @@ impl ChainStateProvider Result<(), &'static str> { + if self.mempool.is_none() { + return Err("RPC startup requires a mempool-backed provider"); + } + if self.verifier.is_none() { + return Err("RPC startup requires an ingress verifier"); + } + if self.state_querier.is_none() { + return Err("RPC startup requires a state querier"); + } + Ok(()) + } + /// Attach a state querier for balance/nonce/call queries. pub fn with_state_querier(mut self, querier: Arc) -> Self { self.state_querier = Some(querier); @@ -397,7 +411,7 @@ impl St let querier = self .state_querier .as_ref() - .ok_or_else(|| RpcError::NotImplemented("state_querier not configured".to_string()))?; + .ok_or_else(|| RpcError::NotImplemented("state_querier not configured"))?; querier.get_balance(address).await } @@ -409,28 +423,28 @@ impl St let querier = self .state_querier .as_ref() - .ok_or_else(|| RpcError::NotImplemented("state_querier not configured".to_string()))?; + .ok_or_else(|| RpcError::NotImplemented("state_querier not configured"))?; querier.get_transaction_count(address).await } - async fn call(&self, request: &CallRequest, _block: Option) -> Result { + async fn call(&self, request: &CallRequest, block: Option) -> Result { let querier = self .state_querier .as_ref() - .ok_or_else(|| RpcError::NotImplemented("state_querier not configured".to_string()))?; - querier.call(request).await + .ok_or_else(|| RpcError::NotImplemented("state_querier not configured"))?; + querier.call(request, block).await } async fn estimate_gas( &self, request: &CallRequest, - _block: Option, + block: Option, ) -> Result { let querier = self .state_querier .as_ref() - .ok_or_else(|| RpcError::NotImplemented("state_querier not configured".to_string()))?; - querier.estimate_gas(request).await + .ok_or_else(|| RpcError::NotImplemented("state_querier not configured"))?; + querier.estimate_gas(request, block).await } async fn get_logs(&self, filter: &LogFilter) -> Result, RpcError> { @@ -471,7 +485,7 @@ impl St Some(m) => m, None => { return Err(RpcError::NotImplemented( - "sendRawTransaction: no mempool configured".to_string(), + "sendRawTransaction: no mempool configured", )) } }; @@ -480,7 +494,7 @@ impl St Some(v) => v, None => { return Err(RpcError::NotImplemented( - "sendRawTransaction: no verifier configured".to_string(), + "sendRawTransaction: no verifier configured", )) } }; @@ -502,19 +516,25 @@ impl St Ok(hash) } - async fn get_code(&self, _address: Address, _block: Option) -> Result { - // TODO: Implement via Storage - Ok(Bytes::new()) + async fn get_code(&self, address: Address, block: Option) -> Result { + let querier = self + .state_querier + .as_ref() + .ok_or_else(|| RpcError::NotImplemented("state_querier not configured"))?; + querier.get_code(address, block).await } async fn get_storage_at( &self, - _address: Address, - _position: U256, - _block: Option, + address: Address, + position: U256, + block: Option, ) -> Result { - // TODO: Implement via Storage - Ok(B256::ZERO) + let querier = self + .state_querier + .as_ref() + .ok_or_else(|| RpcError::NotImplemented("state_querier not configured"))?; + querier.get_storage_at(address, position, block).await } async fn list_module_identifiers(&self) -> Result, RpcError> { @@ -691,6 +711,7 @@ mod tests { use std::sync::Mutex; use crate::types::{StoredBlock, StoredLog, StoredReceipt, StoredTransaction, TxLocation}; + use async_trait::async_trait; use borsh::{BorshDeserialize, BorshSerialize}; use evolve_core::encoding::Encodable; use evolve_core::{AccountId, ErrorCode, InvokableMessage, InvokeRequest, Message, SdkResult}; @@ -813,6 +834,52 @@ mod tests { ChainStateProvider::new(index, provider_config()) } + struct DummyStateQuerier; + + #[async_trait] + impl StateQuerier for DummyStateQuerier { + async fn get_balance(&self, _address: Address) -> Result { + Ok(U256::ZERO) + } + + async fn get_transaction_count(&self, _address: Address) -> Result { + Ok(0) + } + + async fn get_code( + &self, + _address: Address, + _block: Option, + ) -> Result { + Ok(Bytes::new()) + } + + async fn get_storage_at( + &self, + _address: Address, + _position: U256, + _block: Option, + ) -> Result { + Ok(B256::ZERO) + } + + async fn call( + &self, + _request: &CallRequest, + _block: Option, + ) -> Result { + Ok(Bytes::new()) + } + + async fn estimate_gas( + &self, + _request: &CallRequest, + _block: Option, + ) -> Result { + Ok(21_000) + } + } + fn block(number: u64, hash: B256) -> StoredBlock { StoredBlock { number, @@ -963,6 +1030,14 @@ mod tests { async fn send_raw_transaction_without_mempool_is_not_implemented() { let provider = default_provider(Arc::new(MockChainIndex::default())); + let startup_error = provider + .ensure_rpc_compatibility() + .expect_err("provider without ingress should fail startup validation"); + assert_eq!( + startup_error, + "RPC startup requires a mempool-backed provider" + ); + let error = provider .send_raw_transaction(&[0x01, 0x02, 0x03]) .await @@ -983,7 +1058,12 @@ mod tests { provider_config(), Arc::new(NoopAccountCodes), mempool.clone(), - ); + ) + .with_state_querier(Arc::new(DummyStateQuerier)); + + provider + .ensure_rpc_compatibility() + .expect("mempool-backed provider with state querier should pass startup validation"); let raw = decode_hex(LEGACY_TX_RLP); let hash = provider @@ -1012,7 +1092,12 @@ mod tests { Arc::new(NoopAccountCodes), mempool.clone(), gateway, - ); + ) + .with_state_querier(Arc::new(DummyStateQuerier)); + + provider + .ensure_rpc_compatibility() + .expect("custom gateway provider should pass startup validation"); let raw = custom_wire_tx_bytes(sender_types::CUSTOM, b"ok".to_vec()); let hash = provider diff --git a/crates/rpc/chain-index/src/querier.rs b/crates/rpc/chain-index/src/querier.rs index 06afcdb..f8cd0d2 100644 --- a/crates/rpc/chain-index/src/querier.rs +++ b/crates/rpc/chain-index/src/querier.rs @@ -1,22 +1,33 @@ -//! State querier for reading account balances and nonces from storage. +//! State querier for reading account balances, nonces, and call simulations. //! -//! This module provides direct storage reads for RPC state queries -//! (eth_getBalance, eth_getTransactionCount) without going through -//! the full STF execution pipeline. +//! This module provides direct storage reads for simple RPC state queries and +//! STF-backed simulations for `eth_call` / `eth_estimateGas`. -use alloy_primitives::{Address, Bytes, U256}; -use async_trait::async_trait; +use std::sync::Arc; +use alloy_primitives::{keccak256, Address, Bytes, B256, U256}; +use async_trait::async_trait; use evolve_core::encoding::Encodable; -use evolve_core::{AccountId, Message, ReadonlyKV}; +use evolve_core::{ + runtime_api::ACCOUNT_IDENTIFIER_PREFIX, AccountId, BlockContext, FungibleAsset, InvokeRequest, + InvokeResponse, Message, ReadonlyKV, ERR_UNKNOWN_FUNCTION, +}; use evolve_eth_jsonrpc::error::RpcError; use evolve_rpc_types::CallRequest; -use evolve_tx_eth::{lookup_account_id_in_storage, lookup_contract_account_id_in_storage}; +use evolve_stf::results::TxResult; +use evolve_stf::{QueryContext, Stf, ERR_OUT_OF_GAS}; +use evolve_stf_traits::{ + AccountsCodeStorage, BeginBlocker as BeginBlockerTrait, Block as BlockTrait, + EndBlocker as EndBlockerTrait, PostTxExecution, TxValidator, +}; +use evolve_tx_eth::{ + derive_eth_eoa_account_id, lookup_account_id_in_storage, lookup_contract_account_id_in_storage, + sender_types, TxContext, TxContextMeta, TxPayload, ETH_EOA_CODE_ID, +}; -/// Trait for querying on-chain state (balance, nonce). -/// -/// Implementors hold a reference to storage and know how to -/// map Ethereum addresses to Evolve account state. +const DEFAULT_RPC_GAS_LIMIT: u64 = 30_000_000; + +/// Trait for querying on-chain state (balance, nonce, simulated calls). #[async_trait] pub trait StateQuerier: Send + Sync { /// Get the token balance for an Ethereum address. @@ -25,30 +36,108 @@ pub trait StateQuerier: Send + Sync { /// Get the transaction count (nonce) for an Ethereum address. async fn get_transaction_count(&self, address: Address) -> Result; + /// Get code bytes for an Ethereum address. + async fn get_code(&self, address: Address, block: Option) -> Result; + + /// Get a storage word for an Ethereum address. + async fn get_storage_at( + &self, + address: Address, + position: U256, + block: Option, + ) -> Result; + /// Execute a read-only call. - async fn call(&self, request: &CallRequest) -> Result; + async fn call(&self, request: &CallRequest, block: Option) -> Result; /// Estimate gas for a transaction. - async fn estimate_gas(&self, request: &CallRequest) -> Result; + async fn estimate_gas( + &self, + request: &CallRequest, + block: Option, + ) -> Result; } -/// State querier that reads directly from storage. +/// STF execution hooks required by the RPC querier. +pub trait RpcExecutionContext: Send + Sync { + fn simulate_call_tx( + &self, + storage: &S, + account_codes: &A, + tx: &TxContext, + block: BlockContext, + ) -> TxResult; + + fn execute_query( + &self, + storage: &S, + account_codes: &A, + to: AccountId, + request: &InvokeRequest, + context: QueryContext, + ) -> TxResult; +} + +impl RpcExecutionContext + for Stf +where + B: BlockTrait + Send + Sync, + Begin: BeginBlockerTrait + Send + Sync, + ValidatorT: TxValidator + Send + Sync, + End: EndBlockerTrait + Send + Sync, + Post: PostTxExecution + Send + Sync, +{ + fn simulate_call_tx( + &self, + storage: &S, + account_codes: &A, + tx: &TxContext, + block: BlockContext, + ) -> TxResult { + self.simulate_transaction(storage, account_codes, tx, block) + } + + fn execute_query( + &self, + storage: &S, + account_codes: &A, + to: AccountId, + request: &InvokeRequest, + context: QueryContext, + ) -> TxResult { + self.query_invoke_request(storage, account_codes, to, request, context) + } +} + +/// State querier that reads directly from storage and uses STF dry-runs for RPC calls. /// -/// Uses the known storage key layout to read token balances and nonces -/// without invoking the STF. This is the same key format used by the -/// `#[account_impl]` macro: +/// Uses the known storage key layout to read token balances and nonces without +/// invoking the full STF for simple state reads: /// - Nonce: `account_id_bytes ++ [0]` (EthEoaAccount storage prefix 0) /// - Balance: `token_id_bytes ++ [1] ++ encode(account_id)` (Token storage prefix 1) -pub struct StorageStateQuerier { +pub struct StorageStateQuerier { storage: S, token_account_id: AccountId, + account_codes: Arc, + executor: Arc, + default_gas_limit: u64, } -impl StorageStateQuerier { - pub fn new(storage: S, token_account_id: AccountId) -> Self { +impl + StorageStateQuerier +{ + pub fn new( + storage: S, + token_account_id: AccountId, + account_codes: Arc, + executor: Arc, + ) -> Self { Self { storage, token_account_id, + account_codes, + executor, + default_gas_limit: DEFAULT_RPC_GAS_LIMIT, } } @@ -87,6 +176,42 @@ impl StorageStateQuerier { } } + fn read_raw_storage(&self, key: &[u8]) -> Result>, RpcError> { + self.storage + .get(key) + .map_err(|e| RpcError::InternalError(format!("storage read: {:?}", e))) + } + + fn read_account_storage( + &self, + account_id: AccountId, + key: &[u8], + ) -> Result>, RpcError> { + let mut full_key = account_id.as_bytes().to_vec(); + full_key.extend_from_slice(key); + self.read_raw_storage(&full_key) + } + + fn read_account_code_identifier( + &self, + account_id: AccountId, + ) -> Result, RpcError> { + let mut key = vec![ACCOUNT_IDENTIFIER_PREFIX]; + key.extend_from_slice(&account_id.as_bytes()); + match self.read_raw_storage(&key)? { + Some(value) => Message::from_bytes(value) + .get::() + .map(Some) + .map_err(|e| RpcError::InternalError(format!("decode account code id: {:?}", e))), + None => Ok(None), + } + } + + fn resolve_contract_account_id(&self, address: Address) -> Result, RpcError> { + lookup_contract_account_id_in_storage(&self.storage, address) + .map_err(|e| RpcError::InternalError(format!("lookup contract account id: {:?}", e))) + } + fn resolve_account_id(&self, address: Address) -> Result, RpcError> { if let Some(account_id) = lookup_account_id_in_storage(&self.storage, address) .map_err(|e| RpcError::InternalError(format!("lookup account id: {:?}", e)))? @@ -94,13 +219,215 @@ impl StorageStateQuerier { return Ok(Some(account_id)); } - lookup_contract_account_id_in_storage(&self.storage, address) - .map_err(|e| RpcError::InternalError(format!("lookup contract account id: {:?}", e))) + self.resolve_contract_account_id(address) + } + + fn resolve_sender_account_id(&self, address: Address) -> Result { + Ok(self + .resolve_account_id(address)? + .unwrap_or_else(|| derive_eth_eoa_account_id(address))) + } + + fn sender_nonce(&self, address: Address) -> Result { + let Some(account_id) = self.resolve_account_id(address)? else { + return Ok(0); + }; + self.read_nonce(account_id) + } + + fn call_request_gas_limit(&self, request: &CallRequest) -> u64 { + request + .gas + .map(|value| value.to::()) + .unwrap_or(self.default_gas_limit) + } + + fn call_request_effective_gas_price(&self, request: &CallRequest) -> u128 { + let effective = request + .max_fee_per_gas + .or(request.gas_price) + .unwrap_or(U256::ZERO); + u128::try_from(effective).unwrap_or(u128::MAX) + } + + fn call_request_funds(&self, request: &CallRequest) -> Result, RpcError> { + let Some(value) = request.value else { + return Ok(vec![]); + }; + if value.is_zero() { + return Ok(vec![]); + } + + let amount = u128::try_from(value).map_err(|_| { + RpcError::InvalidParams("call value exceeds supported native token range".to_string()) + })?; + + Ok(vec![FungibleAsset { + asset_id: self.token_account_id, + amount, + }]) + } + + fn block_context(block: Option) -> BlockContext { + BlockContext::new(block.unwrap_or_default(), 0) + } + + fn call_request_to_invoke_request(request: &CallRequest) -> InvokeRequest { + let input = request.input_data().cloned().unwrap_or_else(Bytes::new); + let bytes = input.as_ref(); + let (function_id, args) = if bytes.len() >= 4 { + ( + u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as u64, + &bytes[4..], + ) + } else { + (0u64, bytes) + }; + + InvokeRequest::new_from_message( + "eth_dispatch", + function_id, + Message::from_bytes(args.to_vec()), + ) + } + + fn synthetic_tx_hash( + &self, + request: &CallRequest, + block: Option, + ) -> Result { + let payload = serde_json::to_vec(&(request, block)) + .map_err(|e| RpcError::InternalError(format!("encode synthetic tx: {e}")))?; + Ok(B256::from(keccak256(payload))) + } + + fn build_synthetic_tx( + &self, + request: &CallRequest, + block: Option, + ) -> Result { + let sender = request.from.unwrap_or(Address::ZERO); + let invoke_request = Self::call_request_to_invoke_request(request); + let funds = self.call_request_funds(request)?; + let sender_account = self.resolve_sender_account_id(sender)?; + let authentication_payload = Message::new(&sender.into_array()).map_err(|e| { + RpcError::InternalError(format!("encode synthetic auth payload: {:?}", e)) + })?; + + TxContext::from_payload( + TxPayload::Custom( + request + .input_data() + .cloned() + .unwrap_or_else(Bytes::new) + .to_vec(), + ), + sender_types::EOA_SECP256K1, + TxContextMeta { + tx_hash: self.synthetic_tx_hash(request, block)?, + gas_limit: self.call_request_gas_limit(request), + nonce: self.sender_nonce(sender)?, + chain_id: None, + effective_gas_price: self.call_request_effective_gas_price(request), + invoke_request, + funds, + sender_account, + recipient_account: self.resolve_account_id(request.to)?, + sender_key: sender.as_slice().to_vec(), + authentication_payload, + sender_eth_address: Some(sender.into()), + recipient_eth_address: Some(request.to.into()), + }, + ) + .ok_or_else(|| RpcError::InternalError("failed to build synthetic tx context".to_string())) + } + + fn run_query( + &self, + request: &CallRequest, + block: Option, + ) -> Result, RpcError> { + let Some(target_account) = self.resolve_account_id(request.to)? else { + return Ok(None); + }; + let sender = self.resolve_sender_account_id(request.from.unwrap_or(Address::ZERO))?; + let funds = self.call_request_funds(request)?; + let invoke_request = Self::call_request_to_invoke_request(request); + + Ok(Some(self.executor.execute_query( + &self.storage, + self.account_codes.as_ref(), + target_account, + &invoke_request, + QueryContext { + gas_limit: Some(self.call_request_gas_limit(request)), + sender, + funds, + block: Self::block_context(block), + }, + ))) + } + + fn run_exec(&self, request: &CallRequest, block: Option) -> Result { + let synthetic_tx = self.build_synthetic_tx(request, block)?; + Ok(self.executor.simulate_call_tx( + &self.storage, + self.account_codes.as_ref(), + &synthetic_tx, + Self::block_context(block), + )) + } + + fn response_bytes(response: InvokeResponse) -> Result { + let bytes = response + .into_inner() + .into_bytes() + .map_err(|e| RpcError::InternalError(format!("encode response bytes: {:?}", e)))?; + Ok(Bytes::from(bytes)) + } + + fn storage_slot_candidates(position: U256) -> Vec> { + let mut candidates = vec![position.to_be_bytes::<32>().to_vec()]; + if position <= U256::from(u8::MAX) { + candidates.push(vec![position.to::()]); + } + candidates + } + + fn storage_word(value: &[u8]) -> B256 { + let mut word = [0u8; 32]; + let word_len = word.len(); + if value.len() >= word_len { + word.copy_from_slice(&value[value.len() - word_len..]); + } else { + let start = word_len - value.len(); + word[start..].copy_from_slice(value); + } + B256::from(word) + } + + fn map_call_error(err: evolve_core::ErrorCode) -> RpcError { + if err == ERR_OUT_OF_GAS { + return RpcError::ExecutionReverted("out of gas".to_string()); + } + RpcError::ExecutionReverted(format!("{:?}", err)) + } + + fn map_estimate_error(err: evolve_core::ErrorCode) -> RpcError { + if err == ERR_OUT_OF_GAS { + return RpcError::GasEstimationFailed("out of gas".to_string()); + } + RpcError::GasEstimationFailed(format!("{:?}", err)) } } #[async_trait] -impl StateQuerier for StorageStateQuerier { +impl< + S: ReadonlyKV + Send + Sync, + A: AccountsCodeStorage + Send + Sync + 'static, + E: RpcExecutionContext + 'static, + > StateQuerier for StorageStateQuerier +{ async fn get_balance(&self, address: Address) -> Result { let Some(account_id) = self.resolve_account_id(address)? else { return Ok(U256::ZERO); @@ -116,13 +443,572 @@ impl StateQuerier for StorageStateQuerier { self.read_nonce(account_id) } - async fn call(&self, _request: &CallRequest) -> Result { - // TODO: Implement via STF::query() - Ok(Bytes::new()) + async fn get_code(&self, address: Address, _block: Option) -> Result { + let Some(account_id) = self.resolve_contract_account_id(address)? else { + return Ok(Bytes::new()); + }; + + let code_id = self + .read_account_code_identifier(account_id)? + .ok_or_else(|| { + RpcError::InternalError("missing account code identifier".to_string()) + })?; + + if code_id == ETH_EOA_CODE_ID { + return Ok(Bytes::new()); + } + + Ok(Bytes::from(code_id.into_bytes())) + } + + async fn get_storage_at( + &self, + address: Address, + position: U256, + _block: Option, + ) -> Result { + let Some(account_id) = self.resolve_account_id(address)? else { + return Ok(B256::ZERO); + }; + + for key in Self::storage_slot_candidates(position) { + if let Some(value) = self.read_account_storage(account_id, &key)? { + return Ok(Self::storage_word(&value)); + } + } + + Ok(B256::ZERO) + } + + async fn call(&self, request: &CallRequest, block: Option) -> Result { + if self.resolve_account_id(request.to)?.is_none() { + return Ok(Bytes::new()); + } + + let exec_result = self.run_exec(request, block)?; + match exec_result.response { + Ok(response) => return Self::response_bytes(response), + Err(err) if err == ERR_UNKNOWN_FUNCTION => {} + Err(err) => return Err(Self::map_call_error(err)), + } + + let Some(query_result) = self.run_query(request, block)? else { + return Ok(Bytes::new()); + }; + match query_result.response { + Ok(response) => Self::response_bytes(response), + Err(err) => Err(Self::map_call_error(err)), + } + } + + async fn estimate_gas( + &self, + request: &CallRequest, + block: Option, + ) -> Result { + let exec_result = self.run_exec(request, block)?; + match exec_result.response { + Ok(_) => return Ok(exec_result.gas_used), + Err(err) if err == ERR_UNKNOWN_FUNCTION => {} + Err(err) => return Err(Self::map_estimate_error(err)), + } + + let Some(query_result) = self.run_query(request, block)? else { + return Err(RpcError::GasEstimationFailed( + "target account not found".to_string(), + )); + }; + match query_result.response { + Ok(_) => Ok(query_result.gas_used), + Err(err) => Err(Self::map_estimate_error(err)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::collections::BTreeMap; + + use borsh::{BorshDeserialize, BorshSerialize}; + use evolve_core::encoding::Decodable; + use evolve_core::low_level::{exec_account, query_account}; + use evolve_core::runtime_api::{ACCOUNT_IDENTIFIER_PREFIX, RUNTIME_ACCOUNT_ID}; + use evolve_core::schema::AccountSchema; + use evolve_core::storage_api::{ + StorageGetRequest, StorageGetResponse, StorageSetRequest, StorageSetResponse, + STORAGE_ACCOUNT_ID, + }; + use evolve_core::{AccountCode, Environment, EnvironmentQuery, SdkResult}; + use evolve_stf_traits::Block as BlockTrait; + + const QUERY_SELECTOR: u32 = 0x0102_0304; + const EXEC_SELECTOR: u32 = 0x0506_0708; + const QUERY_FUNCTION_ID: u64 = QUERY_SELECTOR as u64; + const EXEC_FUNCTION_ID: u64 = EXEC_SELECTOR as u64; + const EOA_ADDR_TO_ID_PREFIX: &[u8] = b"registry/eoa/eth/a2i/"; + const EOA_ID_TO_ADDR_PREFIX: &[u8] = b"registry/eoa/eth/i2a/"; + const CONTRACT_ADDR_TO_ID_PREFIX: &[u8] = b"registry/contract/runtime/a2i/"; + const CONTRACT_ID_TO_ADDR_PREFIX: &[u8] = b"registry/contract/runtime/i2a/"; + + #[derive(Clone, Default)] + struct TestStorage { + state: BTreeMap, Vec>, + } + + impl ReadonlyKV for TestStorage { + fn get(&self, key: &[u8]) -> Result>, evolve_core::ErrorCode> { + Ok(self.state.get(key).cloned()) + } + } + + #[derive(Default)] + struct TestCodeStorage { + codes: BTreeMap>, + } + + impl TestCodeStorage { + fn add(&mut self, code: impl AccountCode + 'static) { + self.codes.insert(code.identifier(), Box::new(code)); + } + } + + impl AccountsCodeStorage for TestCodeStorage { + fn with_code(&self, identifier: &str, f: F) -> Result + where + F: FnOnce(Option<&dyn AccountCode>) -> R, + { + Ok(f(self.codes.get(identifier).map(|code| code.as_ref()))) + } + + fn list_identifiers(&self) -> Vec { + self.codes.keys().cloned().collect() + } + } + + #[derive(Clone, Default)] + struct TestBlock; + + impl BlockTrait for TestBlock { + fn context(&self) -> BlockContext { + BlockContext::new(0, 0) + } + + fn txs(&self) -> &[TxContext] { + &[] + } + } + + #[derive(Default)] + struct NoopBegin; + + impl BeginBlockerTrait for NoopBegin { + fn begin_block(&self, _block: &TestBlock, _env: &mut dyn Environment) {} + } + + #[derive(Default)] + struct NoopEnd; + + impl EndBlockerTrait for NoopEnd { + fn end_block(&self, _env: &mut dyn Environment) {} + } + + #[derive(Default)] + struct NoopValidator; + + impl TxValidator for NoopValidator { + fn validate_tx(&self, _tx: &TxContext, _env: &mut dyn Environment) -> SdkResult<()> { + Ok(()) + } + } + + #[derive(Default)] + struct NoopPostTx; + + impl PostTxExecution for NoopPostTx { + fn after_tx_executed( + _tx: &TxContext, + _gas_consumed: u64, + _tx_result: &SdkResult, + _env: &mut dyn Environment, + ) -> SdkResult<()> { + Ok(()) + } + } + + type TestStf = Stf; + + #[derive(Clone, BorshSerialize, BorshDeserialize)] + struct ReadValue { + key: u8, } - async fn estimate_gas(&self, _request: &CallRequest) -> Result { - // TODO: Implement via STF with gas tracking - Ok(21000) + #[derive(Clone, BorshSerialize, BorshDeserialize)] + struct WriteValue { + key: u8, + value: u64, + } + + struct DummyEoaAccount; + + impl AccountCode for DummyEoaAccount { + fn identifier(&self) -> String { + "EthEoaAccount".to_string() + } + + fn schema(&self) -> AccountSchema { + AccountSchema::new("EthEoaAccount", "EthEoaAccount") + } + + fn init( + &self, + _env: &mut dyn Environment, + _request: &InvokeRequest, + ) -> SdkResult { + InvokeResponse::new(&()) + } + + fn execute( + &self, + _env: &mut dyn Environment, + _request: &InvokeRequest, + ) -> SdkResult { + Err(ERR_UNKNOWN_FUNCTION) + } + + fn query( + &self, + _env: &mut dyn EnvironmentQuery, + _request: &InvokeRequest, + ) -> SdkResult { + Err(ERR_UNKNOWN_FUNCTION) + } + } + + struct TestContract; + + impl AccountCode for TestContract { + fn identifier(&self) -> String { + "TestContract".to_string() + } + + fn schema(&self) -> AccountSchema { + AccountSchema::new("TestContract", "TestContract") + } + + fn init( + &self, + _env: &mut dyn Environment, + _request: &InvokeRequest, + ) -> SdkResult { + InvokeResponse::new(&()) + } + + fn execute( + &self, + env: &mut dyn Environment, + request: &InvokeRequest, + ) -> SdkResult { + match request.function() { + EXEC_FUNCTION_ID => { + let payload: WriteValue = request.get()?; + let _: StorageSetResponse = exec_account( + STORAGE_ACCOUNT_ID, + &StorageSetRequest { + key: vec![payload.key], + value: Message::new(&payload.value)?, + }, + vec![], + env, + )?; + InvokeResponse::new(&payload.value) + } + _ => Err(ERR_UNKNOWN_FUNCTION), + } + } + + fn query( + &self, + env: &mut dyn EnvironmentQuery, + request: &InvokeRequest, + ) -> SdkResult { + match request.function() { + QUERY_FUNCTION_ID => { + let payload: ReadValue = request.get()?; + let response: StorageGetResponse = query_account( + STORAGE_ACCOUNT_ID, + &StorageGetRequest { + account_id: env.whoami(), + key: vec![payload.key], + }, + env, + )?; + let value = response + .value + .map(|message| message.get::()) + .transpose()? + .unwrap_or_default(); + InvokeResponse::new(&value) + } + _ => Err(ERR_UNKNOWN_FUNCTION), + } + } + } + + fn selector_bytes(selector: u32) -> [u8; 4] { + selector.to_be_bytes() + } + + fn calldata(selector: u32, payload: &T) -> Bytes { + let mut data = selector_bytes(selector).to_vec(); + data.extend(payload.encode().expect("payload should encode")); + Bytes::from(data) + } + + fn code_id_key(account_id: AccountId) -> Vec { + let mut key = vec![ACCOUNT_IDENTIFIER_PREFIX]; + key.extend_from_slice(&account_id.as_bytes()); + key + } + + fn eoa_forward_key(address: Address) -> Vec { + let mut key = RUNTIME_ACCOUNT_ID.as_bytes().to_vec(); + key.extend_from_slice(EOA_ADDR_TO_ID_PREFIX); + key.extend_from_slice(address.as_slice()); + key + } + + fn eoa_reverse_key(account_id: AccountId) -> Vec { + let mut key = RUNTIME_ACCOUNT_ID.as_bytes().to_vec(); + key.extend_from_slice(EOA_ID_TO_ADDR_PREFIX); + key.extend_from_slice(&account_id.as_bytes()); + key + } + + fn contract_forward_key(address: Address) -> Vec { + let mut key = RUNTIME_ACCOUNT_ID.as_bytes().to_vec(); + key.extend_from_slice(CONTRACT_ADDR_TO_ID_PREFIX); + key.extend_from_slice(address.as_slice()); + key + } + + fn contract_reverse_key(account_id: AccountId) -> Vec { + let mut key = RUNTIME_ACCOUNT_ID.as_bytes().to_vec(); + key.extend_from_slice(CONTRACT_ID_TO_ADDR_PREFIX); + key.extend_from_slice(&account_id.as_bytes()); + key + } + + fn account_slot_key(account_id: AccountId, slot: u8) -> Vec { + let mut key = account_id.as_bytes().to_vec(); + key.push(slot); + key + } + + fn setup_querier() -> ( + StorageStateQuerier, + Address, + Address, + ) { + let token_account_id = AccountId::from_u64(50); + let contract_account_id = AccountId::from_u64(100); + let sender_account_id = AccountId::from_u64(200); + let contract_address = Address::repeat_byte(0x11); + let sender_address = Address::repeat_byte(0x22); + + let mut storage = TestStorage::default(); + storage.state.insert( + code_id_key(contract_account_id), + Message::new(&"TestContract".to_string()) + .expect("contract code id should encode") + .into_bytes() + .expect("contract code id bytes"), + ); + storage.state.insert( + code_id_key(sender_account_id), + Message::new(&"EthEoaAccount".to_string()) + .expect("sender code id should encode") + .into_bytes() + .expect("sender code id bytes"), + ); + storage.state.insert( + contract_forward_key(contract_address), + Message::new(&contract_account_id) + .expect("contract account id should encode") + .into_bytes() + .expect("contract account id bytes"), + ); + storage.state.insert( + contract_reverse_key(contract_account_id), + Message::new(&contract_address.into_array()) + .expect("contract address should encode") + .into_bytes() + .expect("contract address bytes"), + ); + storage.state.insert( + eoa_forward_key(sender_address), + Message::new(&sender_account_id) + .expect("sender account id should encode") + .into_bytes() + .expect("sender account id bytes"), + ); + storage.state.insert( + eoa_reverse_key(sender_account_id), + Message::new(&sender_address.into_array()) + .expect("sender address should encode") + .into_bytes() + .expect("sender address bytes"), + ); + storage.state.insert( + account_slot_key(sender_account_id, 0), + Message::new(&0u64) + .expect("nonce should encode") + .into_bytes() + .expect("nonce bytes"), + ); + storage.state.insert( + account_slot_key(contract_account_id, 7), + Message::new(&77u64) + .expect("query value should encode") + .into_bytes() + .expect("query value bytes"), + ); + + let mut codes = TestCodeStorage::default(); + codes.add(DummyEoaAccount); + codes.add(TestContract); + let codes = Arc::new(codes); + let executor = Arc::new(TestStf::new( + NoopBegin, + NoopEnd, + NoopValidator, + NoopPostTx, + evolve_stf::StorageGasConfig::default(), + )); + + ( + StorageStateQuerier::new(storage, token_account_id, codes, executor), + contract_address, + sender_address, + ) + } + + #[tokio::test] + async fn eth_call_falls_back_to_query_dispatch() { + let (querier, contract_address, sender_address) = setup_querier(); + let request = CallRequest { + from: Some(sender_address), + to: contract_address, + data: Some(calldata(QUERY_SELECTOR, &ReadValue { key: 7 })), + ..Default::default() + }; + + let result = querier + .call(&request, Some(12)) + .await + .expect("query call should succeed"); + + let decoded = u64::decode(result.as_ref()).expect("result should decode"); + assert_eq!(decoded, 77); + } + + #[tokio::test] + async fn eth_call_executes_state_changing_selector_in_dry_run_mode() { + let (querier, contract_address, sender_address) = setup_querier(); + let request = CallRequest { + from: Some(sender_address), + to: contract_address, + data: Some(calldata(EXEC_SELECTOR, &WriteValue { key: 9, value: 55 })), + ..Default::default() + }; + + let result = querier + .call(&request, Some(15)) + .await + .expect("exec call should succeed"); + + let decoded = u64::decode(result.as_ref()).expect("result should decode"); + assert_eq!(decoded, 55); + } + + #[tokio::test] + async fn eth_estimate_gas_uses_execution_pipeline_when_available() { + let (querier, contract_address, sender_address) = setup_querier(); + let request = CallRequest { + from: Some(sender_address), + to: contract_address, + data: Some(calldata(EXEC_SELECTOR, &WriteValue { key: 3, value: 99 })), + ..Default::default() + }; + + let gas = querier + .estimate_gas(&request, Some(20)) + .await + .expect("gas estimate should succeed"); + + assert!(gas > 0); + } + + #[tokio::test] + async fn eth_call_returns_empty_bytes_for_unknown_target() { + let (querier, _contract_address, sender_address) = setup_querier(); + let request = CallRequest { + from: Some(sender_address), + to: Address::repeat_byte(0xAA), + data: Some(calldata(QUERY_SELECTOR, &ReadValue { key: 1 })), + ..Default::default() + }; + + let result = querier + .call(&request, Some(1)) + .await + .expect("unknown target should not error"); + + assert!(result.is_empty()); + } + + #[tokio::test] + async fn eth_get_code_returns_runtime_code_identifier_bytes() { + let (querier, contract_address, _sender_address) = setup_querier(); + + let code = querier + .get_code(contract_address, Some(3)) + .await + .expect("code lookup should succeed"); + + assert_eq!(code, Bytes::from("TestContract".as_bytes().to_vec())); + } + + #[tokio::test] + async fn eth_get_code_returns_empty_for_eoa_addresses() { + let (querier, _contract_address, sender_address) = setup_querier(); + + let code = querier + .get_code(sender_address, Some(3)) + .await + .expect("code lookup should succeed"); + + assert!(code.is_empty()); + } + + #[tokio::test] + async fn eth_get_storage_at_reads_runtime_storage_slots() { + let (querier, contract_address, _sender_address) = setup_querier(); + + let value = querier + .get_storage_at(contract_address, U256::from(7u64), Some(3)) + .await + .expect("storage lookup should succeed"); + + let raw = Message::new(&77u64) + .expect("value should encode") + .into_bytes() + .expect("value bytes"); + let mut expected = [0u8; 32]; + let start = expected.len() - raw.len(); + expected[start..].copy_from_slice(&raw); + + assert_eq!(value, B256::from(expected)); } } diff --git a/crates/rpc/eth-jsonrpc/src/error.rs b/crates/rpc/eth-jsonrpc/src/error.rs index 68f083c..b847fe3 100644 --- a/crates/rpc/eth-jsonrpc/src/error.rs +++ b/crates/rpc/eth-jsonrpc/src/error.rs @@ -61,7 +61,7 @@ pub enum RpcError { GasEstimationFailed(String), #[error("Method not implemented: {0}")] - NotImplemented(String), + NotImplemented(&'static str), #[error("Invalid block number or tag")] InvalidBlockNumberOrTag, diff --git a/crates/rpc/eth-jsonrpc/src/server.rs b/crates/rpc/eth-jsonrpc/src/server.rs index 64bc909..9733e2e 100644 --- a/crates/rpc/eth-jsonrpc/src/server.rs +++ b/crates/rpc/eth-jsonrpc/src/server.rs @@ -181,7 +181,7 @@ impl StateProvider for NoopStateProvider { } async fn call(&self, _request: &CallRequest, _block: Option) -> Result { - Ok(Bytes::new()) + Err(RpcError::NotImplemented("eth_call")) } async fn estimate_gas( @@ -189,7 +189,7 @@ impl StateProvider for NoopStateProvider { _request: &CallRequest, _block: Option, ) -> Result { - Ok(21000) // Default gas for simple transfer + Err(RpcError::NotImplemented("eth_estimateGas")) } async fn get_logs(&self, _filter: &LogFilter) -> Result, RpcError> { @@ -198,7 +198,7 @@ impl StateProvider for NoopStateProvider { async fn send_raw_transaction(&self, _data: &[u8]) -> Result { // No-op: return a dummy hash - Err(RpcError::NotImplemented("sendRawTransaction".to_string())) + Err(RpcError::NotImplemented("sendRawTransaction")) } async fn get_code(&self, _address: Address, _block: Option) -> Result { @@ -930,7 +930,7 @@ mod tests { RpcError::GasEstimationFailed(msg) => { RpcError::GasEstimationFailed(msg.clone()) } - RpcError::NotImplemented(method) => RpcError::NotImplemented(method.clone()), + RpcError::NotImplemented(method) => RpcError::NotImplemented(*method), RpcError::InvalidBlockNumberOrTag => RpcError::InvalidBlockNumberOrTag, RpcError::InvalidAddress(msg) => RpcError::InvalidAddress(msg.clone()), RpcError::InvalidTransaction(msg) => RpcError::InvalidTransaction(msg.clone()), @@ -996,7 +996,7 @@ mod tests { Ok(vec![]) } async fn send_raw_transaction(&self, _: &[u8]) -> Result { - Err(RpcError::NotImplemented("sendRawTransaction".to_string())) + Err(RpcError::NotImplemented("sendRawTransaction")) } async fn get_code(&self, _: Address, _: Option) -> Result { Ok(Bytes::new()) @@ -1143,7 +1143,7 @@ mod tests { "reverted", ), ( - RpcError::NotImplemented("eth_foo".to_string()), + RpcError::NotImplemented("eth_foo"), crate::error::codes::METHOD_NOT_SUPPORTED, "Method not implemented: eth_foo", ), @@ -1433,7 +1433,7 @@ mod tests { Ok(vec![]) } async fn send_raw_transaction(&self, _: &[u8]) -> Result { - Err(RpcError::NotImplemented("sendRawTransaction".to_string())) + Err(RpcError::NotImplemented("sendRawTransaction")) } async fn get_code(&self, _: Address, _: Option) -> Result { Ok(Bytes::new()) diff --git a/crates/rpc/evnode/src/runner.rs b/crates/rpc/evnode/src/runner.rs index 370c6ca..f36c70e 100644 --- a/crates/rpc/evnode/src/runner.rs +++ b/crates/rpc/evnode/src/runner.rs @@ -18,7 +18,7 @@ use commonware_runtime::tokio::{Config as TokioConfig, Context as TokioContext, use commonware_runtime::{Runner as RunnerTrait, Spawner}; use evolve_chain_index::{ build_index_data, BlockMetadata, ChainIndex, ChainStateProvider, ChainStateProviderConfig, - PersistentChainIndex, StateQuerier, StorageStateQuerier, + PersistentChainIndex, RpcExecutionContext, StateQuerier, StorageStateQuerier, }; use evolve_core::{AccountId, ReadonlyKV}; use evolve_eth_jsonrpc::{start_server_with_subscriptions, RpcServerConfig, SubscriptionManager}; @@ -334,17 +334,19 @@ async fn run_server_with_shutdown( } } -async fn start_external_consensus_rpc_server( +async fn start_external_consensus_rpc_server( config: &NodeConfig, storage: S, mempool: SharedMempool>, chain_index: &Option, token_account_id: AccountId, + executor: Arc, build_codes: &BuildCodes, ) -> Option where S: ReadonlyKV + Clone + Send + Sync + 'static, Codes: AccountsCodeStorage + Send + Sync + 'static, + Exec: RpcExecutionContext + Send + Sync + 'static, BuildCodes: Fn() -> Codes + Clone + Send + Sync + 'static, { if !config.rpc.enabled { @@ -360,8 +362,12 @@ where gas_price: U256::ZERO, sync_status: SyncStatus::NotSyncing(false), }; - let state_querier: Arc = - Arc::new(StorageStateQuerier::new(storage, token_account_id)); + let state_querier: Arc = Arc::new(StorageStateQuerier::new( + storage, + token_account_id, + Arc::clone(&codes_for_rpc), + executor, + )); let state_provider = ChainStateProvider::with_mempool( Arc::clone(&chain_index), state_provider_config, @@ -369,6 +375,9 @@ where mempool, ) .with_state_querier(state_querier); + state_provider + .ensure_rpc_compatibility() + .expect("external consensus RPC requires mempool, verifier, and state querier"); let rpc_addr = config.parsed_rpc_addr(); let server_config = RpcServerConfig { @@ -413,7 +422,12 @@ pub fn run_external_consensus_node_eth< ) where Codes: AccountsCodeStorage + Send + Sync + 'static, S: ReadonlyKV + Storage + Clone + Send + Sync + 'static, - Stf: StfExecutor + EvnodeStfExecutor + Send + Sync + 'static, + Stf: StfExecutor + + EvnodeStfExecutor + + RpcExecutionContext + + Send + + Sync + + 'static, G: BorshSerialize + BorshDeserialize + Clone @@ -509,12 +523,14 @@ pub fn run_external_consensus_node_eth< Path::new(&config.storage.path), config.rpc.enabled || config.rpc.enable_block_indexing, ); + let query_executor = Arc::new((build_stf)(&genesis_result)); let rpc_handle = start_external_consensus_rpc_server( &config, storage.clone(), mempool.clone(), &chain_index, genesis_result.token_account_id(), + query_executor, build_codes.as_ref(), ) .await; diff --git a/docs/bun.lock b/docs/bun.lock index c739fae..be4163b 100644 --- a/docs/bun.lock +++ b/docs/bun.lock @@ -7,15 +7,19 @@ "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", + "viem": "^2.39.0", "vocs": "^1.0.0", }, "devDependencies": { + "@types/node": "^24.0.0", "@types/react": "^19.0.0", "typescript": "^5.5.0", }, }, }, "packages": { + "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], + "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], "@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], @@ -166,6 +170,10 @@ "@mermaid-js/parser": ["@mermaid-js/parser@0.6.3", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA=="], + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "@radix-ui/colors": ["@radix-ui/colors@3.0.0", "", {}, "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg=="], @@ -346,6 +354,12 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="], + "@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="], + + "@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], + + "@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], + "@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="], "@shikijs/engine-javascript": ["@shikijs/engine-javascript@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "oniguruma-to-es": "^2.2.0" } }, "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A=="], @@ -484,7 +498,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], + "@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], "@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], @@ -512,6 +526,8 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], + "abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -754,6 +770,8 @@ "eval": ["eval@0.1.8", "", { "dependencies": { "@types/node": "*", "require-like": ">= 0.1.1" } }, "sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw=="], + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], @@ -856,6 +874,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], + "javascript-stringify": ["javascript-stringify@2.1.0", "", {}, "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -1076,6 +1096,8 @@ "ora": ["ora@7.0.1", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^4.0.0", "cli-spinners": "^2.9.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^1.3.0", "log-symbols": "^5.1.0", "stdin-discarder": "^0.1.0", "string-width": "^6.1.0", "strip-ansi": "^7.1.0" } }, "sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw=="], + "ox": ["ox@0.14.0", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-WLOB7IKnmI3Ol6RAqY7CJdZKl8QaI44LN91OGF1061YIeN6bL5IsFcdp7+oQShRyamE/8fW/CBRWhJAOzI35Dw=="], + "p-limit": ["p-limit@5.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], @@ -1320,6 +1342,8 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "viem": ["viem@2.47.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.14.0", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-frlK109+X5z2vlZeIGKa6Rxev6CcIpumV/VVhaIPc/QFotiB6t/CgUwkMlYfr4F2YNBZZ2l6jguWz2sY1XrQHw=="], + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], @@ -1342,6 +1366,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], @@ -1394,6 +1420,8 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "eval/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], + "hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], diff --git a/docs/package.json b/docs/package.json index 95b325b..314e1cf 100644 --- a/docs/package.json +++ b/docs/package.json @@ -6,14 +6,19 @@ "scripts": { "dev": "vocs dev", "build": "vocs build", - "preview": "vocs preview" + "preview": "vocs preview", + "typecheck": "tsc --noEmit", + "demo:viem-transfer": "bun run scripts/viem-transfer-e2e.ts", + "test:viem-transfer": "bun run scripts/viem-transfer-e2e.ts --quiet" }, "dependencies": { + "viem": "^2.39.0", "vocs": "^1.0.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { + "@types/node": "^24.0.0", "@types/react": "^19.0.0", "typescript": "^5.5.0" } diff --git a/docs/scripts/viem-transfer-e2e.ts b/docs/scripts/viem-transfer-e2e.ts new file mode 100644 index 0000000..c448d8a --- /dev/null +++ b/docs/scripts/viem-transfer-e2e.ts @@ -0,0 +1,573 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { inspect } from "node:util"; + +import { + bytesToHex, + type Address, + type Hex, + concatHex, + createPublicClient, + createWalletClient, + defineChain, + getAddress, + hexToBytes, + http, + keccak256, + parseGwei, + stringToHex, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(SCRIPT_DIR, "..", ".."); +const TESTAPP_BINARY = resolve(REPO_ROOT, "target", "debug", "testapp"); +const TOKEN_ADDRESS_BINARY = resolve( + REPO_ROOT, + "target", + "debug", + "examples", + "print_token_address" +); + +const QUIET = process.argv.includes("--quiet"); +const KEEP_TEMP = process.argv.includes("--keep-temp"); + +const CHAIN_ID = 1; +const RPC_READY_TIMEOUT_MS = 120_000; +const RPC_POLL_INTERVAL_MS = 1_000; +const NODE_SHUTDOWN_TIMEOUT_MS = 10_000; +const RPC_PORT_CANDIDATES = [18545, 28545, 38545, 48545]; + +const SENDER_PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as const; +const RECIPIENT_PRIVATE_KEY = + "0x59c6995e998f97a5a0044966f0945382d7f0f8cb9b8b9c5d91b3ef1c0d8f4a7c" as const; + +const INITIAL_SENDER_BALANCE = 1_000n; +const INITIAL_RECIPIENT_BALANCE = 1n; +const TRANSFER_AMOUNT = 100n; +const GAS_LIMIT = 150_000n; + +const DOMAIN_EOA_ETH_V1 = "eoa:eth:v1"; +const DOMAIN_CONTRACT_ADDR_RUNTIME_V1 = "contract:addr:runtime:v1"; +const TRANSFER_SELECTOR = hexToBytes(keccak256(stringToHex("transfer"))).subarray(0, 4); + +type CargoResult = { + stdout: string; + stderr: string; + combined: string; +}; + +type CommandResult = CargoResult; + +type NodeHandle = { + process: ChildProcess; + getLogs: () => string; +}; + +function log(message: string): void { + if (!QUIET) { + console.log(message); + } +} + +function runCargo(args: string[], label: string): CargoResult { + return runCommand("cargo", args, label); +} + +function runCommand(command: string, args: string[], label: string): CommandResult { + const result = spawnSync(command, args, { + cwd: REPO_ROOT, + encoding: "utf8", + }); + + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + const combined = `${stdout}${stderr}`; + + if (result.status !== 0) { + throw new Error( + `${label} failed with exit code ${result.status ?? "unknown"}\n` + + `${combined.trim() || "(no output)"}` + ); + } + + return { stdout, stderr, combined }; +} + +function buildGenesisFile(path: string, sender: Address, recipient: Address): void { + const contents = { + token: { + name: "Evolve", + symbol: "EV", + decimals: 6, + icon_url: "https://example.com/token.png", + description: "Viem demo token", + }, + minter_id: 100_002, + accounts: [ + { + eth_address: sender, + balance: Number(INITIAL_SENDER_BALANCE), + }, + { + eth_address: recipient, + balance: Number(INITIAL_RECIPIENT_BALANCE), + }, + ], + }; + + writeFileSync(path, JSON.stringify(contents, null, 2)); +} + +function createChain(rpcUrl: string) { + return defineChain({ + id: CHAIN_ID, + name: "Evolve Local Demo", + nativeCurrency: { + name: "Evolve", + symbol: "EV", + decimals: 6, + }, + rpcUrls: { + default: { + http: [rpcUrl], + }, + }, + }); +} + +function concatBytes(...parts: Uint8Array[]): Uint8Array { + const totalLength = parts.reduce((sum, part) => sum + part.length, 0); + const result = new Uint8Array(totalLength); + + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + + return result; +} + +function encodeU128Le(value: bigint): Uint8Array { + assert(value >= 0n, "u128 cannot be negative"); + assert(value < 1n << 128n, "u128 overflow"); + + const result = new Uint8Array(16); + let current = value; + + for (let index = 0; index < result.length; index += 1) { + result[index] = Number(current & 0xffn); + current >>= 8n; + } + + return result; +} + +function deriveEthEoaAccountId(address: Address): Uint8Array { + const input = concatHex([stringToHex(DOMAIN_EOA_ETH_V1), address]); + return hexToBytes(keccak256(input)); +} + +function encodeTransferCalldata(recipientAddress: Address, amount: bigint): Hex { + const recipientAccountId = deriveEthEoaAccountId(recipientAddress); + const calldata = concatBytes(TRANSFER_SELECTOR, recipientAccountId, encodeU128Le(amount)); + return bytesToHex(calldata); +} + +function deriveRuntimeContractAddress(accountId: Uint8Array): Address { + const input = concatHex([ + stringToHex(DOMAIN_CONTRACT_ADDR_RUNTIME_V1), + bytesToHex(accountId), + ]); + const digest = hexToBytes(keccak256(input)); + return getAddress(bytesToHex(digest.subarray(12))); +} + +function parseTokenAddressFromInitOutput(output: string): Address { + const match = output.match(/atom:\s*AccountId\(\[([^\]]+)\]\)/m); + if (!match) { + throw new Error(`failed to parse token account id from init output\n${output.trim()}`); + } + + const bytes = match[1] + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .map((value) => Number.parseInt(value, 10)); + + assert.equal(bytes.length, 32, "token account id must have 32 bytes"); + assert( + bytes.every((value) => Number.isInteger(value) && value >= 0 && value <= 255), + "token account id bytes must be valid u8 values" + ); + + return deriveRuntimeContractAddress(Uint8Array.from(bytes)); +} + +function startNode(args: string[]): NodeHandle { + const child = spawn(TESTAPP_BINARY, args, { + cwd: REPO_ROOT, + stdio: ["ignore", "pipe", "pipe"], + }); + + let logs = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + logs += chunk; + }); + child.stderr.on("data", (chunk: string) => { + logs += chunk; + }); + + return { + process: child, + getLogs: () => logs, + }; +} + +async function waitForRpcReady( + publicClient: ReturnType, + node: NodeHandle +): Promise { + const deadline = Date.now() + RPC_READY_TIMEOUT_MS; + + while (Date.now() < deadline) { + if (node.process.exitCode !== null) { + throw new Error( + `testapp exited before RPC became ready (exit ${node.process.exitCode})\n${node + .getLogs() + .trim()}` + ); + } + + try { + const chainId = await publicClient.getChainId(); + if (chainId === CHAIN_ID) { + return; + } + } catch { + // RPC is not ready yet. + } + + await sleep(RPC_POLL_INTERVAL_MS); + } + + throw new Error(`timed out waiting for JSON-RPC readiness\n${node.getLogs().trim()}`); +} + +async function waitForFirstBlock( + publicClient: ReturnType, + node: NodeHandle +): Promise { + const deadline = Date.now() + RPC_READY_TIMEOUT_MS; + + while (Date.now() < deadline) { + if (node.process.exitCode !== null) { + throw new Error( + `testapp exited before producing a block (exit ${node.process.exitCode})\n${node + .getLogs() + .trim()}` + ); + } + + try { + const blockNumber = await publicClient.getBlockNumber(); + if (blockNumber >= 1n) { + return blockNumber; + } + } catch { + // Chain index is not ready yet. + } + + await sleep(RPC_POLL_INTERVAL_MS); + } + + throw new Error(`timed out waiting for first block\n${node.getLogs().trim()}`); +} + +async function stopNode(node: NodeHandle): Promise { + if (node.process.exitCode !== null) { + return; + } + + node.process.kill("SIGINT"); + const stopped = await waitForExit(node.process, NODE_SHUTDOWN_TIMEOUT_MS); + if (!stopped && node.process.exitCode === null) { + node.process.kill("SIGKILL"); + await waitForExit(node.process, NODE_SHUTDOWN_TIMEOUT_MS); + } +} + +function waitForExit( + child: ChildProcess, + timeoutMs: number +): Promise { + return new Promise((resolvePromise) => { + if (child.exitCode !== null) { + resolvePromise(true); + return; + } + + const timeout = setTimeout(() => { + cleanup(); + resolvePromise(false); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timeout); + child.removeListener("exit", onExit); + child.removeListener("error", onError); + }; + + const onExit = () => { + cleanup(); + resolvePromise(true); + }; + + const onError = () => { + cleanup(); + resolvePromise(false); + }; + + child.once("exit", onExit); + child.once("error", onError); + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolvePromise) => { + setTimeout(resolvePromise, ms); + }); +} + +function findFreePort(): Promise { + return new Promise((resolvePromise, reject) => { + const tryNext = async (index: number) => { + if (index >= RPC_PORT_CANDIDATES.length) { + reject(new Error("failed to find an available RPC port")); + return; + } + + const port = RPC_PORT_CANDIDATES[index]; + const available = await isPortAvailable(port); + if (available) { + resolvePromise(port); + return; + } + + void tryNext(index + 1); + }; + + void tryNext(0); + }); +} + +function isPortAvailable(port: number): Promise { + return new Promise((resolvePromise) => { + const socket = new Socket(); + + const cleanup = () => { + socket.removeAllListeners(); + socket.destroy(); + }; + + socket.once("connect", () => { + cleanup(); + resolvePromise(false); + }); + + socket.once("error", (error: NodeJS.ErrnoException) => { + cleanup(); + resolvePromise(error.code === "ECONNREFUSED"); + }); + + socket.setTimeout(1_000, () => { + cleanup(); + resolvePromise(false); + }); + + socket.connect(port, "127.0.0.1"); + }); +} + +async function main(): Promise { + const sender = privateKeyToAccount(SENDER_PRIVATE_KEY); + const recipient = privateKeyToAccount(RECIPIENT_PRIVATE_KEY); + + const sandboxDir = mkdtempSync(join(tmpdir(), "evolve-viem-demo-")); + const configPath = join(sandboxDir, "config.yaml"); + const dataDir = join(sandboxDir, "data"); + const genesisPath = join(sandboxDir, "genesis.json"); + const rpcPort = await findFreePort(); + const rpcUrl = `http://127.0.0.1:${rpcPort}`; + + buildGenesisFile(genesisPath, sender.address, recipient.address); + + log(`Working directory: ${sandboxDir}`); + if (!existsSync(TESTAPP_BINARY) || !existsSync(TOKEN_ADDRESS_BINARY)) { + log("Building testapp binaries..."); + runCargo( + ["build", "-p", "evolve_testapp", "--example", "print_token_address"], + "build testapp binaries" + ); + } + log("Initializing local testapp state..."); + const initOutput = runCommand( + TESTAPP_BINARY, + [ + "init", + "--config", + configPath, + "--log-level", + "info", + "--data-dir", + dataDir, + "--genesis-file", + genesisPath, + ], + "testapp init" + ); + + const tokenAddressOutput = runCommand( + TOKEN_ADDRESS_BINARY, + ["--data-dir", dataDir], + "print token address" + ); + const tokenAddress = getAddress( + tokenAddressOutput.stdout.trim() || parseTokenAddressFromInitOutput(initOutput.combined) + ); + const chain = createChain(rpcUrl); + + const node = startNode([ + "run", + "--config", + configPath, + "--log-level", + "info", + "--data-dir", + dataDir, + "--genesis-file", + genesisPath, + "--rpc-addr", + `127.0.0.1:${rpcPort}`, + ]); + + const publicClient = createPublicClient({ + chain, + transport: http(rpcUrl), + }); + + try { + log(`Starting JSON-RPC node at ${rpcUrl}...`); + await waitForRpcReady(publicClient, node); + const firstBlock = await waitForFirstBlock(publicClient, node); + log(`First block available at height ${firstBlock}`); + + const walletClient = createWalletClient({ + account: sender, + chain, + transport: http(rpcUrl), + }); + + const senderBalanceBefore = await publicClient.getBalance({ + address: sender.address, + }); + const recipientBalanceBefore = await publicClient.getBalance({ + address: recipient.address, + }); + const senderNonceBefore = await publicClient.getTransactionCount({ + address: sender.address, + }); + + assert.equal(senderBalanceBefore, INITIAL_SENDER_BALANCE); + assert.equal(recipientBalanceBefore, INITIAL_RECIPIENT_BALANCE); + + const serializedTransaction = await walletClient.signTransaction({ + account: sender, + chain, + nonce: senderNonceBefore, + to: tokenAddress, + data: encodeTransferCalldata(recipient.address, TRANSFER_AMOUNT), + gas: GAS_LIMIT, + maxFeePerGas: parseGwei("20"), + maxPriorityFeePerGas: parseGwei("1"), + value: 0n, + type: "eip1559", + }); + + const hash = await publicClient.sendRawTransaction({ + serializedTransaction, + }); + + const receipt = await publicClient.waitForTransactionReceipt({ + hash, + pollingInterval: RPC_POLL_INTERVAL_MS, + timeout: RPC_READY_TIMEOUT_MS, + }); + + assert.equal(receipt.status, "success"); + assert(receipt.blockNumber >= 1n, "receipt must include a confirmed block number"); + + const senderBalanceAfter = await publicClient.getBalance({ + address: sender.address, + }); + const recipientBalanceAfter = await publicClient.getBalance({ + address: recipient.address, + }); + const senderNonceAfter = await publicClient.getTransactionCount({ + address: sender.address, + }); + + assert.equal(senderBalanceAfter, INITIAL_SENDER_BALANCE - TRANSFER_AMOUNT); + assert.equal(recipientBalanceAfter, INITIAL_RECIPIENT_BALANCE + TRANSFER_AMOUNT); + assert.equal(senderNonceAfter, senderNonceBefore + 1); + + const result = { + rpcUrl, + tokenAddress, + sender: sender.address, + recipient: recipient.address, + txHash: hash, + blockNumber: receipt.blockNumber.toString(), + senderBalanceBefore: senderBalanceBefore.toString(), + senderBalanceAfter: senderBalanceAfter.toString(), + recipientBalanceBefore: recipientBalanceBefore.toString(), + recipientBalanceAfter: recipientBalanceAfter.toString(), + senderNonceBefore: senderNonceBefore.toString(), + senderNonceAfter: senderNonceAfter.toString(), + }; + + if (QUIET) { + console.log(JSON.stringify(result)); + } else { + console.log("viem transfer demo passed"); + console.log(JSON.stringify(result, null, 2)); + } + } finally { + await stopNode(node); + + if (KEEP_TEMP) { + log(`Keeping temp directory: ${sandboxDir}`); + } else { + rmSync(sandboxDir, { recursive: true, force: true }); + } + } +} + +main().catch((error: unknown) => { + const message = + error instanceof Error + ? `${error.stack ?? error.message}\n${inspect(error, { depth: 8 })}` + : inspect(error, { depth: 8 }); + console.error(message); + process.exitCode = 1; +}); diff --git a/docs/vocs.config.ts b/docs/vocs.config.ts index 142d53f..224b4bb 100644 --- a/docs/vocs.config.ts +++ b/docs/vocs.config.ts @@ -6,7 +6,6 @@ export default defineConfig({ description: 'Modular Rust SDK for building blockchain applications with Ethereum compatibility', logoUrl: '/logo.svg', iconUrl: '/favicon.ico', - outDir: './dist', socials: [ { From ceeace0f8f591b8b6f8f80dbd4b781afce1de770 Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Fri, 13 Mar 2026 12:39:15 +0100 Subject: [PATCH 3/4] tests and stability --- bin/testapp/examples/print_token_address.rs | 37 ++- crates/app/node/src/lib.rs | 231 +++------------- crates/app/stf/src/lib.rs | 11 - crates/rpc/chain-index/src/provider.rs | 179 ++++++++++-- crates/rpc/chain-index/src/querier.rs | 286 ++++++++++++-------- crates/rpc/evnode/src/runner.rs | 26 +- docs/scripts/viem-transfer-e2e.ts | 176 +++++++----- 7 files changed, 498 insertions(+), 448 deletions(-) diff --git a/bin/testapp/examples/print_token_address.rs b/bin/testapp/examples/print_token_address.rs index 833d538..b42f528 100644 --- a/bin/testapp/examples/print_token_address.rs +++ b/bin/testapp/examples/print_token_address.rs @@ -28,26 +28,23 @@ fn main() { .with_storage_directory(&data_dir) .with_worker_threads(2); - Runner::new(runtime_config).start(move |context| { - let data_dir = data_dir.clone(); - async move { - let storage = QmdbStorage::new( - context, - StorageConfig { - path: data_dir, - ..Default::default() - }, - ) - .await - .expect("open qmdb storage"); - - let state = load_chain_state::(&storage) - .expect("load initialized chain state"); - let token_address = - derive_runtime_contract_address(state.genesis_result.token_account_id()); - - tx.send(token_address).expect("send token address"); - } + Runner::new(runtime_config).start(move |context| async move { + let storage = QmdbStorage::new( + context, + StorageConfig { + path: data_dir, + ..Default::default() + }, + ) + .await + .expect("open qmdb storage"); + + let state = + load_chain_state::(&storage).expect("load initialized chain state"); + let token_address = + derive_runtime_contract_address(state.genesis_result.token_account_id()); + + tx.send(token_address).expect("send token address"); }); let token_address = rx.recv().expect("receive token address"); diff --git a/crates/app/node/src/lib.rs b/crates/app/node/src/lib.rs index cc4f445..56fa081 100644 --- a/crates/app/node/src/lib.rs +++ b/crates/app/node/src/lib.rs @@ -106,8 +106,7 @@ where Tx: Transaction + MempoolTx + Encodable + Send + Sync + 'static, S: ReadonlyKV + Storage + Clone + Send + Sync + 'static, Codes: AccountsCodeStorage + Send + Sync + 'static, - Stf: - StfExecutor + evolve_chain_index::RpcExecutionContext + Send + Sync + 'static, + Stf: StfExecutor + Send + Sync + 'static, { let mempool: SharedMempool> = new_shared_mempool(); let dev = Arc::new(DevConsensus::with_mempool( @@ -299,7 +298,6 @@ struct EthRunnerConfig { runner_mode: EthRunnerMode, } -/// Run the dev node with default settings (RPC enabled). /// Run the dev node with RPC disabled by default. /// /// Use `run_dev_node_with_rpc_and_mempool_eth` when you need compatible ETH JSON-RPC. @@ -326,8 +324,7 @@ pub fn run_dev_node< Tx: Transaction + MempoolTx + Encodable + Send + Sync + 'static, Codes: AccountsCodeStorage + Send + Sync + 'static, S: ReadonlyKV + Storage + Clone + Send + Sync + 'static, - Stf: - StfExecutor + evolve_chain_index::RpcExecutionContext + Send + Sync + 'static, + Stf: StfExecutor + Send + Sync + 'static, G: BorshSerialize + BorshDeserialize + Clone @@ -360,8 +357,9 @@ pub fn run_dev_node< /// Run the dev node with custom RPC configuration. /// -/// Startup fails if RPC is enabled for a generic transaction runner that -/// cannot provide mempool-backed ETH ingress verification. +/// Generic transaction runners cannot provide compatible ETH JSON-RPC ingress. +/// Startup fails when `rpc_config.enabled` is `true`; use +/// `run_dev_node_with_rpc_and_mempool_eth` instead. pub fn run_dev_node_with_rpc< Stf, Codes, @@ -386,8 +384,7 @@ pub fn run_dev_node_with_rpc< Tx: Transaction + MempoolTx + Encodable + Send + Sync + 'static, Codes: AccountsCodeStorage + Send + Sync + 'static, S: ReadonlyKV + Storage + Clone + Send + Sync + 'static, - Stf: - StfExecutor + evolve_chain_index::RpcExecutionContext + Send + Sync + 'static, + Stf: StfExecutor + Send + Sync + 'static, G: BorshSerialize + BorshDeserialize + Clone @@ -407,6 +404,13 @@ pub fn run_dev_node_with_rpc< BuildStorageFut: Future>> + Send + 'static, { + if rpc_config.enabled { + panic!( + "run_dev_node_with_rpc cannot start RPC for generic transaction types. Use \ + run_dev_node_with_rpc_and_mempool_eth for ETH-compatible RPC." + ); + } + tracing::info!("=== Evolve Dev Node ==="); let data_dir = data_dir.as_ref(); std::fs::create_dir_all(data_dir).expect("failed to create data directory"); @@ -415,11 +419,10 @@ pub fn run_dev_node_with_rpc< path: data_dir.to_path_buf(), ..Default::default() }; - let chain_index_db_path = data_dir.join("chain-index.sqlite"); let runtime_config = TokioConfig::default() .with_storage_directory(data_dir) - .with_worker_threads(4); // More threads for RPC handling + .with_worker_threads(4); let runner = Runner::new(runtime_config); @@ -436,7 +439,6 @@ pub fn run_dev_node_with_rpc< let run_genesis = Arc::clone(&run_genesis); let build_storage = Arc::clone(&build_storage); let rpc_config = rpc_config.clone(); - let chain_index_db_path = chain_index_db_path.clone(); async move { // Clone context early since build_storage takes ownership @@ -485,191 +487,42 @@ pub fn run_dev_node_with_rpc< // Build block archive callback (always on) let archive_cb = build_block_archive(context_for_archive).await; - // Set up RPC infrastructure if enabled - let rpc_handle = if rpc_config.enabled { - // Create chain index backed by SQLite - let chain_index = Arc::new( - PersistentChainIndex::new(&chain_index_db_path) - .expect("failed to open chain index database"), - ); - - // Initialize from existing data - if let Err(e) = chain_index.initialize() { - tracing::warn!("Failed to initialize chain index: {:?}", e); - } - - // Create subscription manager for real-time events - let subscriptions = Arc::new(SubscriptionManager::new()); - - // Create state provider for RPC - let codes_for_rpc = Arc::new(build_codes()); - let state_provider_config = ChainStateProviderConfig { - chain_id: rpc_config.chain_id, - protocol_version: DEFAULT_PROTOCOL_VERSION.to_string(), - gas_price: U256::ZERO, - sync_status: SyncStatus::NotSyncing(false), - }; - let query_executor = Arc::new((build_stf)(&genesis_result)); - let state_querier: Arc = Arc::new(StorageStateQuerier::new( - storage.clone(), - genesis_result.token_account_id(), - Arc::clone(&codes_for_rpc), - query_executor, - )); - let state_provider = ChainStateProvider::with_account_codes( - Arc::clone(&chain_index), - state_provider_config.clone(), - Arc::clone(&codes_for_rpc), - ) - .with_state_querier(Arc::clone(&state_querier)); - ensure_rpc_startup_compatibility(&state_provider, "run_dev_node_with_rpc"); - - // Start JSON-RPC server - let server_config = RpcServerConfig { - http_addr: rpc_config.http_addr, - chain_id: rpc_config.chain_id, - }; - - tracing::info!("Starting JSON-RPC server on {}", rpc_config.http_addr); - let handle = start_server_with_subscriptions( - server_config, - state_provider, - Arc::clone(&subscriptions), - ) - .await - .expect("failed to start RPC server"); - - let grpc_handle = if let Some(grpc_addr) = rpc_config.grpc_addr { - let grpc_state_provider = ChainStateProvider::with_account_codes( - Arc::clone(&chain_index), - state_provider_config, - codes_for_rpc, - ) - .with_state_querier(state_querier); - ensure_rpc_startup_compatibility(&grpc_state_provider, "run_dev_node_with_rpc"); - let grpc_config = grpc_server_config(&rpc_config, grpc_addr); - tracing::info!("Starting gRPC server on {}", grpc_addr); - let grpc_server = GrpcServer::with_subscription_manager( - grpc_config, - grpc_state_provider, - Arc::clone(&subscriptions), - ); - Some(tokio::spawn(async move { - if let Err(e) = grpc_server.serve().await { - tracing::error!("gRPC server error: {}", e); - } - })) - } else { - None - }; - - // Create DevConsensus with RPC support - let consensus = DevConsensus::with_rpc( - stf, - storage, - codes, - dev_config, - chain_index, - subscriptions, - ) - .with_indexing_enabled(rpc_config.enable_block_indexing) - .with_block_archive(archive_cb); - let dev: Arc> = - Arc::new(consensus); - - tracing::info!( - "Block interval: {:?}, starting at height {}", - block_interval, - initial_height - ); - - tracing::info!("Starting block production... (Ctrl+C to stop)"); + let consensus = + DevConsensus::new(stf, storage, codes, dev_config).with_block_archive(archive_cb); + let dev: Arc> = + Arc::new(consensus); - // Run block production and Ctrl+C handling concurrently using Spawner pattern. - // When Ctrl+C is received, stop() triggers shutdown signal via context.stopped(). - tokio::select! { - _ = dev.run_block_production(context_for_shutdown.clone()) => { - // Block production exited - } - _ = tokio::signal::ctrl_c() => { - tracing::info!("Received Ctrl+C, initiating graceful shutdown..."); - context_for_shutdown - .stop(0, Some(shutdown_timeout(&rpc_config))) - .await - .expect("shutdown failed"); - } - } + tracing::info!( + "Block interval: {:?}, starting at height {}", + block_interval, + initial_height + ); - // Save chain state - let final_height = dev.height(); - tracing::info!("Stopped at height: {}", final_height); + tracing::info!("Starting block production... (Ctrl+C to stop)"); - let chain_state = ChainState { - height: final_height, - genesis_result, - }; - if let Err(e) = save_chain_state(dev.storage(), &chain_state).await { - tracing::error!("Failed to save chain state: {}", e); - } else { - tracing::info!("Saved chain state at height {}", final_height); + tokio::select! { + _ = dev.run_block_production(context_for_shutdown.clone()) => { } - - Some((handle, grpc_handle)) - } else { - // No RPC - use simple DevConsensus - let consensus = DevConsensus::new(stf, storage, codes, dev_config) - .with_block_archive(archive_cb); - let dev: Arc> = - Arc::new(consensus); - - tracing::info!( - "Block interval: {:?}, starting at height {}", - block_interval, - initial_height - ); - - tracing::info!("Starting block production... (Ctrl+C to stop)"); - - // Run block production and Ctrl+C handling concurrently using Spawner pattern - tokio::select! { - _ = dev.run_block_production(context_for_shutdown.clone()) => { - // Block production exited - } - _ = tokio::signal::ctrl_c() => { - tracing::info!("Received Ctrl+C, initiating graceful shutdown..."); - context_for_shutdown - .stop(0, Some(shutdown_timeout(&rpc_config))) - .await - .expect("shutdown failed"); - } + _ = tokio::signal::ctrl_c() => { + tracing::info!("Received Ctrl+C, initiating graceful shutdown..."); + context_for_shutdown + .stop(0, Some(shutdown_timeout(&rpc_config))) + .await + .expect("shutdown failed"); } + } - let final_height = dev.height(); - tracing::info!("Stopped at height: {}", final_height); - - let chain_state = ChainState { - height: final_height, - genesis_result, - }; - if let Err(e) = save_chain_state(dev.storage(), &chain_state).await { - tracing::error!("Failed to save chain state: {}", e); - } else { - tracing::info!("Saved chain state at height {}", final_height); - } + let final_height = dev.height(); + tracing::info!("Stopped at height: {}", final_height); - None + let chain_state = ChainState { + height: final_height, + genesis_result, }; - - // Stop RPC server if running - if let Some((handle, grpc_handle)) = rpc_handle { - tracing::info!("Stopping RPC server..."); - handle.stop().expect("failed to stop RPC server"); - tracing::info!("RPC server stopped"); - if let Some(grpc_handle) = grpc_handle { - tracing::info!("Stopping gRPC server..."); - grpc_handle.abort(); - tracing::info!("gRPC server stopped"); - } + if let Err(e) = save_chain_state(dev.storage(), &chain_state).await { + tracing::error!("Failed to save chain state: {}", e); + } else { + tracing::info!("Saved chain state at height {}", final_height); } } }); diff --git a/crates/app/stf/src/lib.rs b/crates/app/stf/src/lib.rs index b71b126..7b8f6de 100644 --- a/crates/app/stf/src/lib.rs +++ b/crates/app/stf/src/lib.rs @@ -103,17 +103,6 @@ pub struct QueryContext { pub block: BlockContext, } -impl Default for QueryContext { - fn default() -> Self { - Self { - gas_limit: None, - sender: evolve_core::runtime_api::RUNTIME_ACCOUNT_ID, - funds: vec![], - block: BlockContext::default(), - } - } -} - #[cfg(test)] mod model_tests { use super::*; diff --git a/crates/rpc/chain-index/src/provider.rs b/crates/rpc/chain-index/src/provider.rs index bb53351..4521675 100644 --- a/crates/rpc/chain-index/src/provider.rs +++ b/crates/rpc/chain-index/src/provider.rs @@ -20,7 +20,7 @@ use crate::error::ChainIndexError; use crate::index::ChainIndex; use crate::querier::StateQuerier; use evolve_core::schema::AccountSchema; -use evolve_core::AccountCode; +use evolve_core::{AccountCode, BlockContext}; use evolve_eth_jsonrpc::error::RpcError; use evolve_eth_jsonrpc::StateProvider; use evolve_mempool::{Mempool, MempoolTx, SharedMempool}; @@ -307,6 +307,39 @@ impl ChainStateProvider Result<&dyn StateQuerier, RpcError> { + self.state_querier + .as_deref() + .ok_or(RpcError::NotImplemented("state_querier not configured")) + } + + fn resolve_state_query_block(&self, block: Option) -> Result { + let latest = self + .index + .latest_block_number() + .map_err(RpcError::from)? + .ok_or(RpcError::BlockNotFound)?; + + match block { + None => Ok(latest), + Some(number) if number == latest => Ok(number), + Some(number) => Err(RpcError::InvalidParams(format!( + "historical state queries are not supported: requested block {number}, latest block is {latest}" + ))), + } + } + + fn resolve_block_context(&self, block: Option) -> Result { + let number = self.resolve_state_query_block(block)?; + let timestamp = self + .index + .get_block(number) + .map_err(RpcError::from)? + .map(|b| b.timestamp) + .unwrap_or(0); + Ok(BlockContext::new(number, timestamp)) + } } impl From for RpcError { @@ -407,32 +440,26 @@ impl St } } - async fn get_balance(&self, address: Address, _block: Option) -> Result { - let querier = self - .state_querier - .as_ref() - .ok_or_else(|| RpcError::NotImplemented("state_querier not configured"))?; + async fn get_balance(&self, address: Address, block: Option) -> Result { + let _latest = self.resolve_state_query_block(block)?; + let querier = self.require_querier()?; querier.get_balance(address).await } async fn get_transaction_count( &self, address: Address, - _block: Option, + block: Option, ) -> Result { - let querier = self - .state_querier - .as_ref() - .ok_or_else(|| RpcError::NotImplemented("state_querier not configured"))?; + let _latest = self.resolve_state_query_block(block)?; + let querier = self.require_querier()?; querier.get_transaction_count(address).await } async fn call(&self, request: &CallRequest, block: Option) -> Result { - let querier = self - .state_querier - .as_ref() - .ok_or_else(|| RpcError::NotImplemented("state_querier not configured"))?; - querier.call(request, block).await + let block_ctx = self.resolve_block_context(block)?; + let querier = self.require_querier()?; + querier.call(request, block_ctx).await } async fn estimate_gas( @@ -440,11 +467,9 @@ impl St request: &CallRequest, block: Option, ) -> Result { - let querier = self - .state_querier - .as_ref() - .ok_or_else(|| RpcError::NotImplemented("state_querier not configured"))?; - querier.estimate_gas(request, block).await + let block_ctx = self.resolve_block_context(block)?; + let querier = self.require_querier()?; + querier.estimate_gas(request, block_ctx).await } async fn get_logs(&self, filter: &LogFilter) -> Result, RpcError> { @@ -517,10 +542,8 @@ impl St } async fn get_code(&self, address: Address, block: Option) -> Result { - let querier = self - .state_querier - .as_ref() - .ok_or_else(|| RpcError::NotImplemented("state_querier not configured"))?; + let block = Some(self.resolve_state_query_block(block)?); + let querier = self.require_querier()?; querier.get_code(address, block).await } @@ -530,10 +553,8 @@ impl St position: U256, block: Option, ) -> Result { - let querier = self - .state_querier - .as_ref() - .ok_or_else(|| RpcError::NotImplemented("state_querier not configured"))?; + let block = Some(self.resolve_state_query_block(block)?); + let querier = self.require_querier()?; querier.get_storage_at(address, position, block).await } @@ -866,7 +887,7 @@ mod tests { async fn call( &self, _request: &CallRequest, - _block: Option, + _block: BlockContext, ) -> Result { Ok(Bytes::new()) } @@ -874,7 +895,60 @@ mod tests { async fn estimate_gas( &self, _request: &CallRequest, + _block: BlockContext, + ) -> Result { + Ok(21_000) + } + } + + #[derive(Default)] + struct RecordingStateQuerier { + call_blocks: Mutex>, + } + + #[async_trait] + impl StateQuerier for RecordingStateQuerier { + async fn get_balance(&self, _address: Address) -> Result { + Ok(U256::ZERO) + } + + async fn get_transaction_count(&self, _address: Address) -> Result { + Ok(0) + } + + async fn get_code( + &self, + _address: Address, + _block: Option, + ) -> Result { + Ok(Bytes::new()) + } + + async fn get_storage_at( + &self, + _address: Address, + _position: U256, _block: Option, + ) -> Result { + Ok(B256::ZERO) + } + + async fn call( + &self, + _request: &CallRequest, + block: BlockContext, + ) -> Result { + self.call_blocks + .lock() + .expect("call block lock should not be poisoned") + .push(block); + Ok(Bytes::new()) + } + + async fn estimate_gas( + &self, + _request: &CallRequest, + _block: BlockContext, ) -> Result { Ok(21_000) } @@ -1312,4 +1386,49 @@ mod tests { RpcError::InternalError(message) if message.contains("sqlite error: db down") )); } + + #[tokio::test] + async fn state_queries_reject_historical_blocks() { + let provider = default_provider(Arc::new(MockChainIndex { + latest: Some(7), + ..Default::default() + })) + .with_state_querier(Arc::new(DummyStateQuerier)); + + let error = provider + .get_balance(Address::repeat_byte(0x55), Some(6)) + .await + .expect_err("historical state query should fail"); + + assert!(matches!( + error, + RpcError::InvalidParams(message) + if message.contains("historical state queries are not supported") + && message.contains("requested block 6") + && message.contains("latest block is 7") + )); + } + + #[tokio::test] + async fn state_queries_default_to_latest_block() { + let querier = Arc::new(RecordingStateQuerier::default()); + let provider = default_provider(Arc::new(MockChainIndex { + latest: Some(9), + ..Default::default() + })) + .with_state_querier(querier.clone()); + + provider + .call(&CallRequest::default(), None) + .await + .expect("latest state query should succeed"); + + let observed = querier + .call_blocks + .lock() + .expect("call block lock should not be poisoned") + .clone(); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0].height, 9); + } } diff --git a/crates/rpc/chain-index/src/querier.rs b/crates/rpc/chain-index/src/querier.rs index f8cd0d2..7bc0ab7 100644 --- a/crates/rpc/chain-index/src/querier.rs +++ b/crates/rpc/chain-index/src/querier.rs @@ -48,13 +48,13 @@ pub trait StateQuerier: Send + Sync { ) -> Result; /// Execute a read-only call. - async fn call(&self, request: &CallRequest, block: Option) -> Result; + async fn call(&self, request: &CallRequest, block: BlockContext) -> Result; /// Estimate gas for a transaction. async fn estimate_gas( &self, request: &CallRequest, - block: Option, + block: BlockContext, ) -> Result; } @@ -268,20 +268,19 @@ impl) -> BlockContext { - BlockContext::new(block.unwrap_or_default(), 0) + + fn input_bytes(request: &CallRequest) -> Vec { + request.input_data().map(|b| b.to_vec()).unwrap_or_default() } - fn call_request_to_invoke_request(request: &CallRequest) -> InvokeRequest { - let input = request.input_data().cloned().unwrap_or_else(Bytes::new); - let bytes = input.as_ref(); - let (function_id, args) = if bytes.len() >= 4 { + fn call_request_to_invoke_request(input: &[u8]) -> InvokeRequest { + let (function_id, args) = if input.len() >= 4 { ( - u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as u64, - &bytes[4..], + u32::from_be_bytes([input[0], input[1], input[2], input[3]]) as u64, + &input[4..], ) } else { - (0u64, bytes) + (0u64, input) }; InvokeRequest::new_from_message( @@ -294,90 +293,126 @@ impl, + block_height: u64, ) -> Result { - let payload = serde_json::to_vec(&(request, block)) + let payload = serde_json::to_vec(&(request, block_height)) .map_err(|e| RpcError::InternalError(format!("encode synthetic tx: {e}")))?; Ok(B256::from(keccak256(payload))) } - fn build_synthetic_tx( + /// Pre-resolves addresses and parses the `CallRequest` once for reuse + /// across both exec and query paths. + fn resolve_call_context( &self, request: &CallRequest, - block: Option, - ) -> Result { + block: BlockContext, + ) -> Result { + let input = Self::input_bytes(request); let sender = request.from.unwrap_or(Address::ZERO); - let invoke_request = Self::call_request_to_invoke_request(request); - let funds = self.call_request_funds(request)?; let sender_account = self.resolve_sender_account_id(sender)?; - let authentication_payload = Message::new(&sender.into_array()).map_err(|e| { + let recipient_account = self.resolve_account_id(request.to)?; + let funds = self.call_request_funds(request)?; + let gas_limit = self.call_request_gas_limit(request); + let invoke_request = Self::call_request_to_invoke_request(&input); + + Ok(ResolvedCallContext { + input, + sender, + sender_account, + recipient_account, + funds, + gas_limit, + invoke_request, + block_ctx: block, + effective_gas_price: self.call_request_effective_gas_price(request), + nonce: self.sender_nonce(sender)?, + tx_hash: self.synthetic_tx_hash(request, block.height)?, + to_address: request.to, + }) + } + + fn build_synthetic_tx(&self, ctx: &ResolvedCallContext) -> Result { + let authentication_payload = Message::new(&ctx.sender.into_array()).map_err(|e| { RpcError::InternalError(format!("encode synthetic auth payload: {:?}", e)) })?; TxContext::from_payload( - TxPayload::Custom( - request - .input_data() - .cloned() - .unwrap_or_else(Bytes::new) - .to_vec(), - ), + TxPayload::Custom(ctx.input.clone()), sender_types::EOA_SECP256K1, TxContextMeta { - tx_hash: self.synthetic_tx_hash(request, block)?, - gas_limit: self.call_request_gas_limit(request), - nonce: self.sender_nonce(sender)?, + tx_hash: ctx.tx_hash, + gas_limit: ctx.gas_limit, + nonce: ctx.nonce, chain_id: None, - effective_gas_price: self.call_request_effective_gas_price(request), - invoke_request, - funds, - sender_account, - recipient_account: self.resolve_account_id(request.to)?, - sender_key: sender.as_slice().to_vec(), + effective_gas_price: ctx.effective_gas_price, + invoke_request: ctx.invoke_request.clone(), + funds: ctx.funds.clone(), + sender_account: ctx.sender_account, + recipient_account: ctx.recipient_account, + sender_key: ctx.sender.as_slice().to_vec(), authentication_payload, - sender_eth_address: Some(sender.into()), - recipient_eth_address: Some(request.to.into()), + sender_eth_address: Some(ctx.sender.into()), + recipient_eth_address: Some(ctx.to_address.into()), }, ) .ok_or_else(|| RpcError::InternalError("failed to build synthetic tx context".to_string())) } - fn run_query( - &self, - request: &CallRequest, - block: Option, - ) -> Result, RpcError> { - let Some(target_account) = self.resolve_account_id(request.to)? else { - return Ok(None); - }; - let sender = self.resolve_sender_account_id(request.from.unwrap_or(Address::ZERO))?; - let funds = self.call_request_funds(request)?; - let invoke_request = Self::call_request_to_invoke_request(request); - - Ok(Some(self.executor.execute_query( + fn run_query(&self, ctx: &ResolvedCallContext) -> Option { + let target_account = ctx.recipient_account?; + Some(self.executor.execute_query( &self.storage, self.account_codes.as_ref(), target_account, - &invoke_request, + &ctx.invoke_request, QueryContext { - gas_limit: Some(self.call_request_gas_limit(request)), - sender, - funds, - block: Self::block_context(block), + gas_limit: Some(ctx.gas_limit), + sender: ctx.sender_account, + funds: ctx.funds.clone(), + block: ctx.block_ctx, }, - ))) + )) } - fn run_exec(&self, request: &CallRequest, block: Option) -> Result { - let synthetic_tx = self.build_synthetic_tx(request, block)?; + fn run_exec(&self, ctx: &ResolvedCallContext) -> Result { + let synthetic_tx = self.build_synthetic_tx(ctx)?; Ok(self.executor.simulate_call_tx( &self.storage, self.account_codes.as_ref(), &synthetic_tx, - Self::block_context(block), + ctx.block_ctx, )) } + /// Run exec first; if it returns `ERR_UNKNOWN_FUNCTION`, fall back to query. + fn exec_with_query_fallback( + &self, + request: &CallRequest, + block: BlockContext, + map_err: fn(evolve_core::ErrorCode) -> RpcError, + ) -> Result { + let ctx = self.resolve_call_context(request, block)?; + + if ctx.recipient_account.is_none() { + return Ok(CallExecutionOutcome::UnknownTarget); + } + + let exec_result = self.run_exec(&ctx)?; + match &exec_result.response { + Ok(_) => return Ok(CallExecutionOutcome::Success(exec_result)), + Err(err) if *err == ERR_UNKNOWN_FUNCTION => {} + Err(err) => return Err(map_err(*err)), + } + + match self.run_query(&ctx) { + Some(query_result) => match &query_result.response { + Ok(_) => Ok(CallExecutionOutcome::Success(query_result)), + Err(err) => Err(map_err(*err)), + }, + None => Ok(CallExecutionOutcome::UnknownTarget), + } + } + fn response_bytes(response: InvokeResponse) -> Result { let bytes = response .into_inner() @@ -386,14 +421,6 @@ impl Vec> { - let mut candidates = vec![position.to_be_bytes::<32>().to_vec()]; - if position <= U256::from(u8::MAX) { - candidates.push(vec![position.to::()]); - } - candidates - } - fn storage_word(value: &[u8]) -> B256 { let mut word = [0u8; 32]; let word_len = word.len(); @@ -406,19 +433,32 @@ impl RpcError { + fn map_stf_error(err: evolve_core::ErrorCode, wrap: fn(String) -> RpcError) -> RpcError { if err == ERR_OUT_OF_GAS { - return RpcError::ExecutionReverted("out of gas".to_string()); + return wrap("out of gas".to_string()); } - RpcError::ExecutionReverted(format!("{:?}", err)) + wrap(format!("{:?}", err)) } +} - fn map_estimate_error(err: evolve_core::ErrorCode) -> RpcError { - if err == ERR_OUT_OF_GAS { - return RpcError::GasEstimationFailed("out of gas".to_string()); - } - RpcError::GasEstimationFailed(format!("{:?}", err)) - } +struct ResolvedCallContext { + input: Vec, + sender: Address, + sender_account: AccountId, + recipient_account: Option, + funds: Vec, + gas_limit: u64, + invoke_request: InvokeRequest, + block_ctx: BlockContext, + effective_gas_price: u128, + nonce: u64, + tx_hash: B256, + to_address: Address, +} + +enum CallExecutionOutcome { + Success(TxResult), + UnknownTarget, } #[async_trait] @@ -471,8 +511,14 @@ impl< return Ok(B256::ZERO); }; - for key in Self::storage_slot_candidates(position) { - if let Some(value) = self.read_account_storage(account_id, &key)? { + // Try 32-byte big-endian key first, then single-byte for small positions. + let be_key = position.to_be_bytes::<32>(); + if let Some(value) = self.read_account_storage(account_id, &be_key)? { + return Ok(Self::storage_word(&value)); + } + if position <= U256::from(u8::MAX) { + let short_key = [position.to::()]; + if let Some(value) = self.read_account_storage(account_id, &short_key)? { return Ok(Self::storage_word(&value)); } } @@ -480,48 +526,32 @@ impl< Ok(B256::ZERO) } - async fn call(&self, request: &CallRequest, block: Option) -> Result { - if self.resolve_account_id(request.to)?.is_none() { - return Ok(Bytes::new()); - } - - let exec_result = self.run_exec(request, block)?; - match exec_result.response { - Ok(response) => return Self::response_bytes(response), - Err(err) if err == ERR_UNKNOWN_FUNCTION => {} - Err(err) => return Err(Self::map_call_error(err)), - } - - let Some(query_result) = self.run_query(request, block)? else { - return Ok(Bytes::new()); + async fn call(&self, request: &CallRequest, block: BlockContext) -> Result { + let result = match self.exec_with_query_fallback(request, block, |e| { + Self::map_stf_error(e, RpcError::ExecutionReverted) + })? { + CallExecutionOutcome::Success(result) => result, + CallExecutionOutcome::UnknownTarget => return Ok(Bytes::new()), }; - match query_result.response { - Ok(response) => Self::response_bytes(response), - Err(err) => Err(Self::map_call_error(err)), - } + Self::response_bytes(result.response.expect("checked by fallback helper")) } async fn estimate_gas( &self, request: &CallRequest, - block: Option, + block: BlockContext, ) -> Result { - let exec_result = self.run_exec(request, block)?; - match exec_result.response { - Ok(_) => return Ok(exec_result.gas_used), - Err(err) if err == ERR_UNKNOWN_FUNCTION => {} - Err(err) => return Err(Self::map_estimate_error(err)), - } - - let Some(query_result) = self.run_query(request, block)? else { - return Err(RpcError::GasEstimationFailed( - "target account not found".to_string(), - )); + let result = match self.exec_with_query_fallback(request, block, |e| { + Self::map_stf_error(e, RpcError::GasEstimationFailed) + })? { + CallExecutionOutcome::Success(result) => result, + CallExecutionOutcome::UnknownTarget => { + return Err(RpcError::GasEstimationFailed( + "target account not found".to_string(), + )) + } }; - match query_result.response { - Ok(_) => Ok(query_result.gas_used), - Err(err) => Err(Self::map_estimate_error(err)), - } + Ok(result.gas_used) } } @@ -545,8 +575,10 @@ mod tests { const QUERY_SELECTOR: u32 = 0x0102_0304; const EXEC_SELECTOR: u32 = 0x0506_0708; + const FAIL_SELECTOR: u32 = 0x090A_0B0C; const QUERY_FUNCTION_ID: u64 = QUERY_SELECTOR as u64; const EXEC_FUNCTION_ID: u64 = EXEC_SELECTOR as u64; + const FAIL_FUNCTION_ID: u64 = FAIL_SELECTOR as u64; const EOA_ADDR_TO_ID_PREFIX: &[u8] = b"registry/eoa/eth/a2i/"; const EOA_ID_TO_ADDR_PREFIX: &[u8] = b"registry/eoa/eth/i2a/"; const CONTRACT_ADDR_TO_ID_PREFIX: &[u8] = b"registry/contract/runtime/a2i/"; @@ -724,6 +756,7 @@ mod tests { )?; InvokeResponse::new(&payload.value) } + FAIL_FUNCTION_ID => Err(ERR_OUT_OF_GAS), _ => Err(ERR_UNKNOWN_FUNCTION), } } @@ -905,7 +938,7 @@ mod tests { }; let result = querier - .call(&request, Some(12)) + .call(&request, BlockContext::new(12, 1000)) .await .expect("query call should succeed"); @@ -924,7 +957,7 @@ mod tests { }; let result = querier - .call(&request, Some(15)) + .call(&request, BlockContext::new(15, 1500)) .await .expect("exec call should succeed"); @@ -932,6 +965,27 @@ mod tests { assert_eq!(decoded, 55); } + #[tokio::test] + async fn eth_call_preserves_execution_reverts() { + let (querier, contract_address, sender_address) = setup_querier(); + let request = CallRequest { + from: Some(sender_address), + to: contract_address, + data: Some(Bytes::from(selector_bytes(FAIL_SELECTOR).to_vec())), + ..Default::default() + }; + + let error = querier + .call(&request, BlockContext::new(15, 1500)) + .await + .expect_err("reverting call should error"); + + assert!(matches!( + error, + RpcError::ExecutionReverted(message) if message == "out of gas" + )); + } + #[tokio::test] async fn eth_estimate_gas_uses_execution_pipeline_when_available() { let (querier, contract_address, sender_address) = setup_querier(); @@ -943,7 +997,7 @@ mod tests { }; let gas = querier - .estimate_gas(&request, Some(20)) + .estimate_gas(&request, BlockContext::new(20, 2000)) .await .expect("gas estimate should succeed"); @@ -961,7 +1015,7 @@ mod tests { }; let result = querier - .call(&request, Some(1)) + .call(&request, BlockContext::new(1, 100)) .await .expect("unknown target should not error"); diff --git a/crates/rpc/evnode/src/runner.rs b/crates/rpc/evnode/src/runner.rs index f36c70e..fa9096f 100644 --- a/crates/rpc/evnode/src/runner.rs +++ b/crates/rpc/evnode/src/runner.rs @@ -523,17 +523,21 @@ pub fn run_external_consensus_node_eth< Path::new(&config.storage.path), config.rpc.enabled || config.rpc.enable_block_indexing, ); - let query_executor = Arc::new((build_stf)(&genesis_result)); - let rpc_handle = start_external_consensus_rpc_server( - &config, - storage.clone(), - mempool.clone(), - &chain_index, - genesis_result.token_account_id(), - query_executor, - build_codes.as_ref(), - ) - .await; + let rpc_handle = if config.rpc.enabled { + let query_executor = Arc::new((build_stf)(&genesis_result)); + start_external_consensus_rpc_server( + &config, + storage.clone(), + mempool.clone(), + &chain_index, + genesis_result.token_account_id(), + query_executor, + build_codes.as_ref(), + ) + .await + } else { + None + }; let executor_config = ExecutorServiceConfig::default(); let commit_sink = ExternalConsensusCommitSink::spawn( diff --git a/docs/scripts/viem-transfer-e2e.ts b/docs/scripts/viem-transfer-e2e.ts index c448d8a..0264b29 100644 --- a/docs/scripts/viem-transfer-e2e.ts +++ b/docs/scripts/viem-transfer-e2e.ts @@ -69,6 +69,7 @@ type CommandResult = CargoResult; type NodeHandle = { process: ChildProcess; getLogs: () => string; + getSpawnError: () => Error | undefined; }; function log(message: string): void { @@ -91,6 +92,12 @@ function runCommand(command: string, args: string[], label: string): CommandResu const stderr = result.stderr ?? ""; const combined = `${stdout}${stderr}`; + if (result.error) { + throw new Error( + `${label} failed to start\n${result.error.message}\n${combined.trim() || "(no output)"}` + ); + } + if (result.status !== 0) { throw new Error( `${label} failed with exit code ${result.status ?? "unknown"}\n` + @@ -219,21 +226,43 @@ function startNode(args: string[]): NodeHandle { }); let logs = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { + let spawnError: Error | undefined; + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { logs += chunk; }); - child.stderr.on("data", (chunk: string) => { + child.stderr?.on("data", (chunk: string) => { logs += chunk; }); + child.once("error", (error) => { + spawnError = error; + logs += `spawn error: ${error.message}\n`; + }); return { process: child, getLogs: () => logs, + getSpawnError: () => spawnError, }; } +function assertNodeRunning(node: NodeHandle, phase: string): void { + const spawnError = node.getSpawnError(); + if (spawnError) { + throw new Error( + `testapp failed to start while ${phase}\n${spawnError.message}\n${node.getLogs().trim()}` + ); + } + + if (node.process.exitCode !== null) { + throw new Error( + `testapp exited while ${phase} (exit ${node.process.exitCode})\n${node.getLogs().trim()}` + ); + } +} + async function waitForRpcReady( publicClient: ReturnType, node: NodeHandle @@ -241,13 +270,7 @@ async function waitForRpcReady( const deadline = Date.now() + RPC_READY_TIMEOUT_MS; while (Date.now() < deadline) { - if (node.process.exitCode !== null) { - throw new Error( - `testapp exited before RPC became ready (exit ${node.process.exitCode})\n${node - .getLogs() - .trim()}` - ); - } + assertNodeRunning(node, "waiting for JSON-RPC readiness"); try { const chainId = await publicClient.getChainId(); @@ -271,13 +294,7 @@ async function waitForFirstBlock( const deadline = Date.now() + RPC_READY_TIMEOUT_MS; while (Date.now() < deadline) { - if (node.process.exitCode !== null) { - throw new Error( - `testapp exited before producing a block (exit ${node.process.exitCode})\n${node - .getLogs() - .trim()}` - ); - } + assertNodeRunning(node, "waiting for the first block"); try { const blockNumber = await publicClient.getBlockNumber(); @@ -295,6 +312,10 @@ async function waitForFirstBlock( } async function stopNode(node: NodeHandle): Promise { + if (node.getSpawnError()) { + return; + } + if (node.process.exitCode !== null) { return; } @@ -404,27 +425,63 @@ async function main(): Promise { const recipient = privateKeyToAccount(RECIPIENT_PRIVATE_KEY); const sandboxDir = mkdtempSync(join(tmpdir(), "evolve-viem-demo-")); - const configPath = join(sandboxDir, "config.yaml"); - const dataDir = join(sandboxDir, "data"); - const genesisPath = join(sandboxDir, "genesis.json"); - const rpcPort = await findFreePort(); - const rpcUrl = `http://127.0.0.1:${rpcPort}`; - - buildGenesisFile(genesisPath, sender.address, recipient.address); - - log(`Working directory: ${sandboxDir}`); - if (!existsSync(TESTAPP_BINARY) || !existsSync(TOKEN_ADDRESS_BINARY)) { - log("Building testapp binaries..."); - runCargo( - ["build", "-p", "evolve_testapp", "--example", "print_token_address"], - "build testapp binaries" + let node: NodeHandle | undefined; + + try { + const configPath = join(sandboxDir, "config.yaml"); + const dataDir = join(sandboxDir, "data"); + const genesisPath = join(sandboxDir, "genesis.json"); + const rpcPort = await findFreePort(); + const rpcUrl = `http://127.0.0.1:${rpcPort}`; + + buildGenesisFile(genesisPath, sender.address, recipient.address); + + log(`Working directory: ${sandboxDir}`); + if (!existsSync(TESTAPP_BINARY) || !existsSync(TOKEN_ADDRESS_BINARY)) { + log("Building testapp binaries..."); + runCargo( + [ + "build", + "-p", + "evolve_testapp", + "--bin", + "testapp", + "--example", + "print_token_address", + ], + "build testapp binaries" + ); + } + + log("Initializing local testapp state..."); + const initOutput = runCommand( + TESTAPP_BINARY, + [ + "init", + "--config", + configPath, + "--log-level", + "info", + "--data-dir", + dataDir, + "--genesis-file", + genesisPath, + ], + "testapp init" ); - } - log("Initializing local testapp state..."); - const initOutput = runCommand( - TESTAPP_BINARY, - [ - "init", + + const tokenAddressOutput = runCommand( + TOKEN_ADDRESS_BINARY, + ["--data-dir", dataDir], + "print token address" + ); + const tokenAddress = getAddress( + tokenAddressOutput.stdout.trim() || parseTokenAddressFromInitOutput(initOutput.combined) + ); + const chain = createChain(rpcUrl); + + node = startNode([ + "run", "--config", configPath, "--log-level", @@ -433,40 +490,15 @@ async function main(): Promise { dataDir, "--genesis-file", genesisPath, - ], - "testapp init" - ); - - const tokenAddressOutput = runCommand( - TOKEN_ADDRESS_BINARY, - ["--data-dir", dataDir], - "print token address" - ); - const tokenAddress = getAddress( - tokenAddressOutput.stdout.trim() || parseTokenAddressFromInitOutput(initOutput.combined) - ); - const chain = createChain(rpcUrl); - - const node = startNode([ - "run", - "--config", - configPath, - "--log-level", - "info", - "--data-dir", - dataDir, - "--genesis-file", - genesisPath, - "--rpc-addr", - `127.0.0.1:${rpcPort}`, - ]); + "--rpc-addr", + `127.0.0.1:${rpcPort}`, + ]); - const publicClient = createPublicClient({ - chain, - transport: http(rpcUrl), - }); + const publicClient = createPublicClient({ + chain, + transport: http(rpcUrl), + }); - try { log(`Starting JSON-RPC node at ${rpcUrl}...`); await waitForRpcReady(publicClient, node); const firstBlock = await waitForFirstBlock(publicClient, node); @@ -553,7 +585,9 @@ async function main(): Promise { console.log(JSON.stringify(result, null, 2)); } } finally { - await stopNode(node); + if (node) { + await stopNode(node); + } if (KEEP_TEMP) { log(`Keeping temp directory: ${sandboxDir}`); From 13267822079b94ff1d27387f0fd04b28fb864b1f Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Mon, 16 Mar 2026 12:04:57 +0100 Subject: [PATCH 4/4] comments and linting --- crates/rpc/chain-index/src/provider.rs | 8 +++++++- crates/rpc/chain-index/src/querier.rs | 1 - crates/rpc/eth-jsonrpc/src/server.rs | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/rpc/chain-index/src/provider.rs b/crates/rpc/chain-index/src/provider.rs index 4521675..6c47dca 100644 --- a/crates/rpc/chain-index/src/provider.rs +++ b/crates/rpc/chain-index/src/provider.rs @@ -337,7 +337,13 @@ impl ChainStateProvider Vec { request.input_data().map(|b| b.to_vec()).unwrap_or_default() } diff --git a/crates/rpc/eth-jsonrpc/src/server.rs b/crates/rpc/eth-jsonrpc/src/server.rs index 9733e2e..c06d083 100644 --- a/crates/rpc/eth-jsonrpc/src/server.rs +++ b/crates/rpc/eth-jsonrpc/src/server.rs @@ -930,7 +930,7 @@ mod tests { RpcError::GasEstimationFailed(msg) => { RpcError::GasEstimationFailed(msg.clone()) } - RpcError::NotImplemented(method) => RpcError::NotImplemented(*method), + RpcError::NotImplemented(method) => RpcError::NotImplemented(method), RpcError::InvalidBlockNumberOrTag => RpcError::InvalidBlockNumberOrTag, RpcError::InvalidAddress(msg) => RpcError::InvalidAddress(msg.clone()), RpcError::InvalidTransaction(msg) => RpcError::InvalidTransaction(msg.clone()),