diff --git a/bin/testapp/examples/print_token_address.rs b/bin/testapp/examples/print_token_address.rs new file mode 100644 index 0000000..b42f528 --- /dev/null +++ b/bin/testapp/examples/print_token_address.rs @@ -0,0 +1,52 @@ +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| 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..56fa081 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() @@ -288,7 +298,9 @@ 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. pub fn run_dev_node< Stf, Codes, @@ -339,11 +351,15 @@ pub fn run_dev_node< build_codes, run_genesis, build_storage, - RpcConfig::default(), + RpcConfig::disabled(), ) } /// Run the dev node with custom RPC configuration. +/// +/// 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, @@ -388,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"); @@ -396,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); @@ -417,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 @@ -466,186 +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 state_querier: Arc = Arc::new(StorageStateQuerier::new( - storage.clone(), - genesis_result.token_account_id(), - )); - 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)); - - // 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); - 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); } } }); @@ -768,12 +645,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 +719,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 +779,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 +913,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 +930,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 +958,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 +1107,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..7b8f6de 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,19 @@ 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, +} + #[cfg(test)] mod model_tests { use super::*; @@ -1395,6 +1408,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/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/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 9ecb5cf..6c47dca 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}; @@ -288,11 +288,64 @@ 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); self } + + fn require_querier(&self) -> 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_else(|| { + tracing::warn!( + block = number, + "block not yet indexed, using timestamp 0 for query context" + ); + 0 + }); + Ok(BlockContext::new(number, timestamp)) + } } impl From for RpcError { @@ -393,44 +446,36 @@ 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".to_string()))?; + 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".to_string()))?; + 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".to_string()))?; - querier.call(request).await + async fn call(&self, request: &CallRequest, block: Option) -> Result { + let block_ctx = self.resolve_block_context(block)?; + let querier = self.require_querier()?; + querier.call(request, block_ctx).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 + 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> { @@ -471,7 +516,7 @@ impl St Some(m) => m, None => { return Err(RpcError::NotImplemented( - "sendRawTransaction: no mempool configured".to_string(), + "sendRawTransaction: no mempool configured", )) } }; @@ -480,7 +525,7 @@ impl St Some(v) => v, None => { return Err(RpcError::NotImplemented( - "sendRawTransaction: no verifier configured".to_string(), + "sendRawTransaction: no verifier configured", )) } }; @@ -502,19 +547,21 @@ 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 block = Some(self.resolve_state_query_block(block)?); + let querier = self.require_querier()?; + 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 block = Some(self.resolve_state_query_block(block)?); + let querier = self.require_querier()?; + querier.get_storage_at(address, position, block).await } async fn list_module_identifiers(&self) -> Result, RpcError> { @@ -691,6 +738,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 +861,105 @@ 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: BlockContext, + ) -> Result { + Ok(Bytes::new()) + } + + 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) + } + } + fn block(number: u64, hash: B256) -> StoredBlock { StoredBlock { number, @@ -878,6 +1025,7 @@ mod tests { logs: vec![], status: 1, tx_type: 0, + revert_reason: None, } } @@ -962,6 +1110,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 @@ -982,7 +1138,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 @@ -1011,7 +1172,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 @@ -1226,4 +1392,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 06afcdb..e5da7c3 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: BlockContext) -> Result; /// Estimate gas for a transaction. - async fn estimate_gas(&self, request: &CallRequest) -> Result; + async fn estimate_gas( + &self, + request: &CallRequest, + block: BlockContext, + ) -> 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,254 @@ 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 input_bytes(request: &CallRequest) -> Vec { + request.input_data().map(|b| b.to_vec()).unwrap_or_default() + } + + fn call_request_to_invoke_request(input: &[u8]) -> InvokeRequest { + let (function_id, args) = if input.len() >= 4 { + ( + u32::from_be_bytes([input[0], input[1], input[2], input[3]]) as u64, + &input[4..], + ) + } else { + (0u64, input) + }; + + InvokeRequest::new_from_message( + "eth_dispatch", + function_id, + Message::from_bytes(args.to_vec()), + ) + } + + fn synthetic_tx_hash( + &self, + request: &CallRequest, + block_height: u64, + ) -> Result { + let payload = serde_json::to_vec(&(request, block_height)) + .map_err(|e| RpcError::InternalError(format!("encode synthetic tx: {e}")))?; + Ok(B256::from(keccak256(payload))) + } + + /// Pre-resolves addresses and parses the `CallRequest` once for reuse + /// across both exec and query paths. + fn resolve_call_context( + &self, + request: &CallRequest, + block: BlockContext, + ) -> Result { + let input = Self::input_bytes(request); + let sender = request.from.unwrap_or(Address::ZERO); + let sender_account = self.resolve_sender_account_id(sender)?; + 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(ctx.input.clone()), + sender_types::EOA_SECP256K1, + TxContextMeta { + tx_hash: ctx.tx_hash, + gas_limit: ctx.gas_limit, + nonce: ctx.nonce, + chain_id: None, + 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(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, ctx: &ResolvedCallContext) -> Option { + let target_account = ctx.recipient_account?; + Some(self.executor.execute_query( + &self.storage, + self.account_codes.as_ref(), + target_account, + &ctx.invoke_request, + QueryContext { + gas_limit: Some(ctx.gas_limit), + sender: ctx.sender_account, + funds: ctx.funds.clone(), + block: ctx.block_ctx, + }, + )) + } + + 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, + 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() + .into_bytes() + .map_err(|e| RpcError::InternalError(format!("encode response bytes: {:?}", e)))?; + Ok(Bytes::from(bytes)) + } + + 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_stf_error(err: evolve_core::ErrorCode, wrap: fn(String) -> RpcError) -> RpcError { + if err == ERR_OUT_OF_GAS { + return wrap("out of gas".to_string()); + } + wrap(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] -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 +482,586 @@ 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); + }; + + // 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)); + } + } + + Ok(B256::ZERO) + } + + 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()), + }; + Self::response_bytes(result.response.expect("checked by fallback helper")) + } + + async fn estimate_gas( + &self, + request: &CallRequest, + block: BlockContext, + ) -> Result { + 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(), + )) + } + }; + Ok(result.gas_used) + } +} + +#[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 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/"; + 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()) + } } - async fn estimate_gas(&self, _request: &CallRequest) -> Result { - // TODO: Implement via STF with gas tracking - Ok(21000) + #[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, + } + + #[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) + } + FAIL_FUNCTION_ID => Err(ERR_OUT_OF_GAS), + _ => 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, BlockContext::new(12, 1000)) + .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, BlockContext::new(15, 1500)) + .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_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(); + 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, BlockContext::new(20, 2000)) + .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, BlockContext::new(1, 100)) + .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/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/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..c06d083 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..fa9096f 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,15 +523,21 @@ pub fn run_external_consensus_node_eth< Path::new(&config.storage.path), config.rpc.enabled || config.rpc.enable_block_indexing, ); - let rpc_handle = start_external_consensus_rpc_server( - &config, - storage.clone(), - mempool.clone(), - &chain_index, - genesis_result.token_account_id(), - 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/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)); 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..0264b29 --- /dev/null +++ b/docs/scripts/viem-transfer-e2e.ts @@ -0,0 +1,607 @@ +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; + getSpawnError: () => Error | undefined; +}; + +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.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` + + `${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 = ""; + 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) => { + 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 +): Promise { + const deadline = Date.now() + RPC_READY_TIMEOUT_MS; + + while (Date.now() < deadline) { + assertNodeRunning(node, "waiting for JSON-RPC readiness"); + + 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) { + assertNodeRunning(node, "waiting for the first block"); + + 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.getSpawnError()) { + return; + } + + 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-")); + 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" + ); + + 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", + "info", + "--data-dir", + dataDir, + "--genesis-file", + genesisPath, + "--rpc-addr", + `127.0.0.1:${rpcPort}`, + ]); + + const publicClient = createPublicClient({ + chain, + transport: http(rpcUrl), + }); + + 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 { + if (node) { + 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: [ {