diff --git a/bin/evd/src/main.rs b/bin/evd/src/main.rs index d60fcc9..a2d5b75 100644 --- a/bin/evd/src/main.rs +++ b/bin/evd/src/main.rs @@ -72,7 +72,7 @@ use commonware_runtime::tokio::{Config as TokioConfig, Runner}; use commonware_runtime::{Runner as RunnerTrait, Spawner}; use evolve_chain_index::{ build_index_data, BlockMetadata, ChainIndex, ChainStateProvider, ChainStateProviderConfig, - PersistentChainIndex, + PersistentChainIndex, StateQuerier, StorageStateQuerier, }; use evolve_core::{AccountId, ReadonlyKV}; use evolve_eth_jsonrpc::{start_server_with_subscriptions, RpcServerConfig, SubscriptionManager}; @@ -83,6 +83,7 @@ use evolve_node::{ GenesisOutput, InitArgs, NodeConfig, RunArgs, }; use evolve_rpc_types::SyncStatus; +use evolve_scheduler::scheduler_account::SchedulerRef; use evolve_server::{ load_chain_state, save_chain_state, BlockBuilder, ChainState, CHAIN_STATE_KEY, }; @@ -90,12 +91,13 @@ use evolve_stf_traits::{AccountsCodeStorage, StateChange}; use evolve_storage::{Operation, QmdbStorage, Storage, StorageConfig}; use evolve_testapp::genesis_config::{load_genesis_config, EvdGenesisConfig, EvdGenesisResult}; use evolve_testapp::{ - build_mempool_stf, default_gas_config, do_genesis_inner, initialize_custom_genesis_resources, - install_account_codes, PLACEHOLDER_ACCOUNT, + build_mempool_stf, default_gas_config, do_eth_genesis_inner, install_account_codes, + PLACEHOLDER_ACCOUNT, }; use evolve_testing::server_mocks::AccountStorageMock; +use evolve_token::account::TokenRef; use evolve_tx_eth::TxContext; - +use evolve_tx_eth::{register_runtime_contract_account, resolve_or_create_eoa_account}; #[derive(Parser)] #[command(name = "evd")] #[command(about = "Evolve node daemon with gRPC execution layer")] @@ -161,252 +163,374 @@ fn main() { } } -fn run_node(config: NodeConfig, genesis_config: Option) { - tracing::info!("=== Evolve Node Daemon (evd) ==="); +type TokioContext = commonware_runtime::tokio::Context; +type NodeStorage = QmdbStorage; +type SharedChainIndex = Arc; +type RpcMempool = SharedMempool>; - std::fs::create_dir_all(&config.storage.path).expect("failed to create data directory"); +struct RpcRuntimeHandle { + stop_fn: Option>, +} - let storage_config = StorageConfig { - path: config.storage.path.clone().into(), - ..Default::default() - }; +impl RpcRuntimeHandle { + fn new(stop_fn: impl FnOnce() + Send + 'static) -> Self { + Self { + stop_fn: Some(Box::new(stop_fn)), + } + } - let runtime_config = TokioConfig::default() - .with_storage_directory(&config.storage.path) - .with_worker_threads(4); + fn stop(mut self) { + if let Some(stop_fn) = self.stop_fn.take() { + stop_fn(); + } + } +} - let runner = Runner::new(runtime_config); +async fn init_storage_and_genesis( + context: TokioContext, + storage_config: StorageConfig, + genesis_config: Option, +) -> (NodeStorage, EvdGenesisResult, u64) { + let storage = QmdbStorage::new(context, storage_config) + .await + .expect("failed to create storage"); - runner.start(move |context| { - async move { - let context_for_shutdown = context.clone(); + let codes = build_codes(); + tracing::info!("Installed account codes: {:?}", codes.list_identifiers()); - // Initialize QMDB storage - let storage = QmdbStorage::new(context, storage_config) + match load_chain_state::(&storage) { + Some(state) => { + tracing::info!("Resuming from existing state at height {}", state.height); + (storage, state.genesis_result, state.height) + } + None => { + tracing::info!("No existing state found, running genesis..."); + let output = run_genesis(&storage, &codes, genesis_config.as_ref()); + commit_genesis(&storage, output.changes, &output.genesis_result) .await - .expect("failed to create storage"); - - // Set up account codes - let codes = build_codes(); - tracing::info!("Installed account codes: {:?}", codes.list_identifiers()); - - // Load or run genesis - let (genesis_result, initial_height) = - match load_chain_state::(&storage) { - Some(state) => { - tracing::info!("Resuming from existing state at height {}", state.height); - (state.genesis_result, state.height) - } - None => { - tracing::info!("No existing state found, running genesis..."); - let output = run_genesis(&storage, &codes, genesis_config.as_ref()); - commit_genesis(&storage, output.changes, &output.genesis_result) - .await - .expect("genesis commit failed"); - tracing::info!("Genesis complete: {:?}", output.genesis_result); - (output.genesis_result, 1) - } - }; - - // Build STF with scheduler from genesis - let gas_config = default_gas_config(); - let stf = build_mempool_stf(gas_config, genesis_result.scheduler); - - // Create shared mempool - let mempool: SharedMempool> = new_shared_mempool(); - // Create chain index backed by SQLite (only when needed) - let chain_index = if config.rpc.enabled || config.rpc.enable_block_indexing { - let chain_index_db_path = - std::path::PathBuf::from(&config.storage.path).join("chain-index.sqlite"); - let index = Arc::new( - PersistentChainIndex::new(&chain_index_db_path) - .expect("failed to open chain index database"), - ); - if let Err(e) = index.initialize() { - tracing::warn!("Failed to initialize chain index: {:?}", e); - } - Some(index) - } else { - None - }; + .expect("genesis commit failed"); + tracing::info!("Genesis complete: {:?}", output.genesis_result); + (storage, output.genesis_result, 1) + } + } +} - // Set up JSON-RPC server if enabled - let rpc_handle = if config.rpc.enabled { - let subscriptions = Arc::new(SubscriptionManager::new()); - let codes_for_rpc = Arc::new(build_codes()); - - let state_provider_config = ChainStateProviderConfig { - chain_id: config.chain.chain_id, - protocol_version: "0x1".to_string(), - gas_price: U256::ZERO, - sync_status: SyncStatus::NotSyncing(false), - }; - - let state_provider = ChainStateProvider::with_mempool( - Arc::clone(chain_index.as_ref().expect("chain index required for RPC")), - state_provider_config, - codes_for_rpc, - mempool.clone(), - ); - - let rpc_addr = config.parsed_rpc_addr(); - let server_config = RpcServerConfig { - http_addr: rpc_addr, - chain_id: config.chain.chain_id, - }; - - tracing::info!("Starting JSON-RPC server on {}", rpc_addr); - let handle = start_server_with_subscriptions( - server_config, - state_provider, - Arc::clone(&subscriptions), - ) - .await - .expect("failed to start RPC server"); +fn init_chain_index(config: &NodeConfig) -> Option { + if !config.rpc.enabled && !config.rpc.enable_block_indexing { + return None; + } - Some(handle) - } else { - None - }; + let chain_index_db_path = + std::path::PathBuf::from(&config.storage.path).join("chain-index.sqlite"); + let index = Arc::new( + PersistentChainIndex::new(&chain_index_db_path) + .expect("failed to open chain index database"), + ); + if let Err(err) = index.initialize() { + tracing::warn!("Failed to initialize chain index: {:?}", err); + } + Some(index) +} - // Shared state for the block callback - let parent_hash = Arc::new(std::sync::RwLock::new(B256::ZERO)); - let current_height = Arc::new(AtomicU64::new(initial_height)); - - // Build the OnBlockExecuted callback: commits state to storage + indexes blocks - let storage_for_callback = storage.clone(); - let chain_index_for_callback = chain_index.clone(); - let parent_hash_for_callback = Arc::clone(&parent_hash); - let current_height_for_callback = Arc::clone(¤t_height); - let callback_chain_id = config.chain.chain_id; - let executor_config = ExecutorServiceConfig::default(); - let callback_max_gas = executor_config.max_gas; - let callback_indexing_enabled = config.rpc.enable_block_indexing; - - let on_block_executed: OnBlockExecuted = Arc::new(move |info| { - // 1. Commit state changes to QmdbStorage - let operations = state_changes_to_operations(info.state_changes); - - let commit_hash = futures::executor::block_on(async { - storage_for_callback - .batch(operations) - .await - .expect("storage batch failed"); - storage_for_callback - .commit() - .await - .expect("storage commit failed") - }); - let state_root = B256::from_slice(commit_hash.as_bytes()); - - // 2. Compute block hash and build metadata - let prev_parent = *parent_hash_for_callback.read().unwrap(); - let block_hash = compute_block_hash(info.height, info.timestamp, prev_parent); - - let metadata = BlockMetadata::new( - block_hash, - prev_parent, - state_root, - info.timestamp, - callback_max_gas, - Address::ZERO, - callback_chain_id, - ); - - // 3. Reconstruct block and index it - let block = BlockBuilder::::new() - .number(info.height) - .timestamp(info.timestamp) - .transactions(info.transactions) - .build(); - - let (stored_block, stored_txs, stored_receipts) = - build_index_data(&block, &info.block_result, &metadata); - - if let Some(ref chain_index) = chain_index_for_callback { - if callback_indexing_enabled { - if let Err(e) = - chain_index.store_block(stored_block, stored_txs, stored_receipts) - { - tracing::warn!("Failed to index block {}: {:?}", info.height, e); - } else { - tracing::debug!( - "Indexed block {} (hash={}, state_root={})", - info.height, - block_hash, - state_root - ); - } - } - } +async fn start_rpc_server( + config: &NodeConfig, + storage: NodeStorage, + mempool: RpcMempool, + chain_index: &Option, + token_account_id: AccountId, +) -> Option { + if !config.rpc.enabled { + return None; + } - // 4. Update parent hash and height for next block - *parent_hash_for_callback.write().unwrap() = block_hash; - current_height_for_callback.store(info.height, Ordering::SeqCst); - }); - - // Configure gRPC server - let grpc_config = EvnodeServerConfig { - addr: config.parsed_grpc_addr(), - enable_gzip: config.grpc.enable_gzip, - max_message_size: config.grpc_max_message_size_usize(), - executor_config, - }; + let subscriptions = Arc::new(SubscriptionManager::new()); + let codes_for_rpc = Arc::new(build_codes()); + let state_provider_config = ChainStateProviderConfig { + chain_id: config.chain.chain_id, + protocol_version: "0x1".to_string(), + gas_price: U256::ZERO, + sync_status: SyncStatus::NotSyncing(false), + }; - let grpc_addr = config.parsed_grpc_addr(); - tracing::info!("Starting gRPC server on {}", grpc_addr); - tracing::info!("Configuration:"); - tracing::info!(" - Chain ID: {}", config.chain.chain_id); - tracing::info!(" - gRPC compression: {}", config.grpc.enable_gzip); - tracing::info!(" - JSON-RPC: {}", config.rpc.enabled); - tracing::info!(" - Block indexing: {}", config.rpc.enable_block_indexing); - tracing::info!(" - Initial height: {}", initial_height); - - // Create gRPC server with mempool and block callback - let server = EvnodeServer::with_mempool( - grpc_config, - stf, - storage.clone(), - build_codes(), - mempool, - ) - .with_on_block_executed(on_block_executed); - - tracing::info!("Server ready. Press Ctrl+C to stop."); - - // Run gRPC server with shutdown handling - tokio::select! { - result = server.serve() => { - if let Err(e) = result { - tracing::error!("gRPC server error: {}", e); - } - } - _ = tokio::signal::ctrl_c() => { - tracing::info!("Received Ctrl+C, shutting down..."); - context_for_shutdown - .stop(0, Some(Duration::from_secs(config.operations.shutdown_timeout_secs))) - .await - .expect("shutdown failed"); + let state_querier: Arc = + Arc::new(StorageStateQuerier::new(storage, token_account_id)); + let state_provider = ChainStateProvider::with_mempool( + Arc::clone(chain_index.as_ref().expect("chain index required for RPC")), + state_provider_config, + codes_for_rpc, + mempool, + ) + .with_state_querier(state_querier); + + let rpc_addr = config.parsed_rpc_addr(); + let server_config = RpcServerConfig { + http_addr: rpc_addr, + chain_id: config.chain.chain_id, + }; + + tracing::info!("Starting JSON-RPC server on {}", rpc_addr); + let handle = + start_server_with_subscriptions(server_config, state_provider, Arc::clone(&subscriptions)) + .await + .expect("failed to start RPC server"); + + Some(RpcRuntimeHandle::new(move || { + handle.stop().expect("failed to stop RPC server"); + })) +} + +fn build_on_block_executed( + storage: NodeStorage, + chain_index: Option, + initial_height: u64, + callback_chain_id: u64, + callback_max_gas: u64, + callback_indexing_enabled: bool, +) -> (OnBlockExecuted, Arc) { + let initial_parent_hash = resolve_initial_parent_hash(chain_index.as_ref(), initial_height); + let parent_hash = Arc::new(std::sync::RwLock::new(initial_parent_hash)); + let current_height = Arc::new(AtomicU64::new(initial_height)); + let parent_hash_for_callback = Arc::clone(&parent_hash); + let current_height_for_callback = Arc::clone(¤t_height); + + let on_block_executed: OnBlockExecuted = Arc::new(move |info| { + let operations = state_changes_to_operations(info.state_changes); + let commit_hash = futures::executor::block_on(async { + storage + .batch(operations) + .await + .expect("storage batch failed"); + storage.commit().await.expect("storage commit failed") + }); + let state_root = B256::from_slice(commit_hash.as_bytes()); + + let prev_parent = *parent_hash_for_callback.read().unwrap(); + let block_hash = compute_block_hash(info.height, info.timestamp, prev_parent); + let metadata = BlockMetadata::new( + block_hash, + prev_parent, + state_root, + info.timestamp, + callback_max_gas, + Address::ZERO, + callback_chain_id, + ); + + let block = BlockBuilder::::new() + .number(info.height) + .timestamp(info.timestamp) + .transactions(info.transactions) + .build(); + let (stored_block, stored_txs, stored_receipts) = + build_index_data(&block, &info.block_result, &metadata); + + if callback_indexing_enabled { + if let Some(ref index) = chain_index { + if let Err(err) = index.store_block(stored_block, stored_txs, stored_receipts) { + tracing::warn!("Failed to index block {}: {:?}", info.height, err); + } else { + tracing::debug!( + "Indexed block {} (hash={}, state_root={})", + info.height, + block_hash, + state_root + ); } } + } - // Save chain state with actual committed height - let chain_state = ChainState { - height: current_height.load(Ordering::SeqCst), - genesis_result, - }; - if let Err(e) = save_chain_state(&storage, &chain_state).await { - tracing::error!("Failed to save chain state: {}", e); - } + *parent_hash_for_callback.write().unwrap() = block_hash; + current_height_for_callback.store(info.height, Ordering::SeqCst); + }); - // Stop RPC server - if let Some(handle) = rpc_handle { - tracing::info!("Stopping JSON-RPC server..."); - handle.stop().expect("failed to stop RPC server"); - } + (on_block_executed, current_height) +} + +fn resolve_initial_parent_hash( + chain_index: Option<&SharedChainIndex>, + initial_height: u64, +) -> B256 { + let Some(index) = chain_index else { + return B256::ZERO; + }; + + match index.get_block(initial_height) { + Ok(Some(block)) => { + tracing::info!("Seeding parent hash from indexed block {}", initial_height); + return block.hash; + } + Ok(None) => {} + Err(err) => { + tracing::warn!( + "Failed to read indexed block {} while seeding parent hash: {:?}", + initial_height, + err + ); + } + } + + let latest_indexed = match index.latest_block_number() { + Ok(latest) => latest, + Err(err) => { + tracing::warn!( + "Failed to read latest indexed block while seeding parent hash: {:?}", + err + ); + return B256::ZERO; + } + }; + + let Some(latest_height) = latest_indexed else { + return B256::ZERO; + }; + + if latest_height != initial_height { + tracing::warn!( + "Chain state height {} does not match indexed head {}; seeding parent hash from indexed head", + initial_height, + latest_height + ); + } + + match index.get_block(latest_height) { + Ok(Some(block)) => block.hash, + Ok(None) => { + tracing::warn!( + "Indexed head {} missing block payload while seeding parent hash", + latest_height + ); + B256::ZERO + } + Err(err) => { + tracing::warn!( + "Failed to read indexed head block {} while seeding parent hash: {:?}", + latest_height, + err + ); + B256::ZERO + } + } +} + +fn log_server_configuration(config: &NodeConfig, initial_height: u64) { + let grpc_addr = config.parsed_grpc_addr(); + tracing::info!("Starting gRPC server on {}", grpc_addr); + tracing::info!("Configuration:"); + tracing::info!(" - Chain ID: {}", config.chain.chain_id); + tracing::info!(" - gRPC compression: {}", config.grpc.enable_gzip); + tracing::info!(" - JSON-RPC: {}", config.rpc.enabled); + tracing::info!(" - Block indexing: {}", config.rpc.enable_block_indexing); + tracing::info!(" - Initial height: {}", initial_height); +} - tracing::info!("Shutdown complete."); +async fn run_server_with_shutdown( + serve_future: F, + context_for_shutdown: TokioContext, + shutdown_timeout_secs: u64, +) where + F: std::future::Future>, + E: std::fmt::Display, +{ + tokio::pin!(serve_future); + tokio::select! { + result = &mut serve_future => { + if let Err(err) = result { + tracing::error!("gRPC server error: {}", err); + } } + _ = tokio::signal::ctrl_c() => { + tracing::info!("Received Ctrl+C, shutting down..."); + context_for_shutdown + .stop(0, Some(Duration::from_secs(shutdown_timeout_secs))) + .await + .expect("shutdown failed"); + } + } +} + +async fn persist_chain_state( + storage: &NodeStorage, + current_height: &Arc, + genesis_result: EvdGenesisResult, +) { + let chain_state = ChainState { + height: current_height.load(Ordering::SeqCst), + genesis_result, + }; + if let Err(err) = save_chain_state(storage, &chain_state).await { + tracing::error!("Failed to save chain state: {}", err); + } +} + +fn stop_rpc_server(rpc_handle: Option) { + if let Some(handle) = rpc_handle { + tracing::info!("Stopping JSON-RPC server..."); + handle.stop(); + } +} + +fn run_node(config: NodeConfig, genesis_config: Option) { + tracing::info!("=== Evolve Node Daemon (evd) ==="); + std::fs::create_dir_all(&config.storage.path).expect("failed to create data directory"); + + let storage_config = StorageConfig { + path: config.storage.path.clone().into(), + ..Default::default() + }; + let runtime_config = TokioConfig::default() + .with_storage_directory(&config.storage.path) + .with_worker_threads(4); + let runner = Runner::new(runtime_config); + + runner.start(move |context| async move { + let context_for_shutdown = context.clone(); + let (storage, genesis_result, initial_height) = + init_storage_and_genesis(context, storage_config, genesis_config).await; + + let stf = build_mempool_stf(default_gas_config(), genesis_result.scheduler); + let mempool: RpcMempool = new_shared_mempool(); + let chain_index = init_chain_index(&config); + let rpc_handle = start_rpc_server( + &config, + storage.clone(), + mempool.clone(), + &chain_index, + genesis_result.token, + ) + .await; + + let executor_config = ExecutorServiceConfig::default(); + let (on_block_executed, current_height) = build_on_block_executed( + storage.clone(), + chain_index, + initial_height, + config.chain.chain_id, + executor_config.max_gas, + config.rpc.enable_block_indexing, + ); + log_server_configuration(&config, initial_height); + + let grpc_config = EvnodeServerConfig { + addr: config.parsed_grpc_addr(), + enable_gzip: config.grpc.enable_gzip, + max_message_size: config.grpc_max_message_size_usize(), + executor_config, + }; + let server = + EvnodeServer::with_mempool(grpc_config, stf, storage.clone(), build_codes(), mempool) + .with_on_block_executed(on_block_executed); + + tracing::info!("Server ready. Press Ctrl+C to stop."); + run_server_with_shutdown( + server.serve(), + context_for_shutdown, + config.operations.shutdown_timeout_secs, + ) + .await; + + persist_chain_state(&storage, ¤t_height, genesis_result).await; + stop_rpc_server(rpc_handle); + tracing::info!("Shutdown complete."); }); } @@ -467,27 +591,40 @@ fn run_genesis( } } -/// Default genesis using testapp's `do_genesis_inner` (sequential account IDs). +/// Default genesis using ETH-address-derived AccountIds for EOA balances. fn run_default_genesis( storage: &S, codes: &AccountStorageMock, ) -> GenesisOutput { use evolve_core::BlockContext; + use std::str::FromStr; - tracing::info!("Running default testapp genesis..."); + tracing::info!("Running default ETH-mapped genesis..."); let gas_config = default_gas_config(); let stf = build_mempool_stf(gas_config, PLACEHOLDER_ACCOUNT); let genesis_block = BlockContext::new(0, 0); + let alice_eth_address = std::env::var("GENESIS_ALICE_ETH_ADDRESS") + .ok() + .and_then(|s| Address::from_str(s.trim()).ok()) + .map(Into::into) + .unwrap_or([0xAA; 20]); + let bob_eth_address = std::env::var("GENESIS_BOB_ETH_ADDRESS") + .ok() + .and_then(|s| Address::from_str(s.trim()).ok()) + .map(Into::into) + .unwrap_or([0xBB; 20]); let (accounts, state) = stf - .system_exec(storage, codes, genesis_block, |env| do_genesis_inner(env)) + .system_exec(storage, codes, genesis_block, |env| { + do_eth_genesis_inner(alice_eth_address, bob_eth_address, env) + }) .expect("genesis failed"); let changes = state.into_changes().expect("failed to get state changes"); let genesis_result = EvdGenesisResult { - token: accounts.atom, + token: accounts.evolve, scheduler: accounts.scheduler, }; @@ -499,8 +636,7 @@ fn run_default_genesis( /// Custom genesis with ETH EOA accounts from a genesis JSON file. /// -/// Registers funded EOA accounts via `EthEoaAccountRef::initialize` inside -/// `system_exec`, then initializes the token with their balances. +/// Funds balances at ETH-address-derived AccountIds. fn run_custom_genesis( storage: &S, codes: &AccountStorageMock, @@ -508,10 +644,17 @@ fn run_custom_genesis( ) -> GenesisOutput { use evolve_core::BlockContext; - let funded_accounts = genesis_config - .funded_accounts() - .expect("invalid address in genesis config"); - + let funded_accounts: Vec<([u8; 20], u128)> = genesis_config + .accounts + .iter() + .filter(|acc| acc.balance > 0) + .map(|acc| { + let addr = acc + .parse_address() + .expect("invalid address in genesis config"); + (addr.into_array(), acc.balance) + }) + .collect(); let minter = AccountId::new(genesis_config.minter_id); let metadata = genesis_config.token.to_metadata(); @@ -521,16 +664,26 @@ fn run_custom_genesis( let (genesis_result, state) = stf .system_exec(storage, codes, genesis_block, |env| { - let resources = initialize_custom_genesis_resources( - &funded_accounts, - metadata.clone(), - minter, - env, - )?; + let balances: Vec<(AccountId, u128)> = funded_accounts + .iter() + .map( + |(eth_addr, balance)| -> evolve_core::SdkResult<(AccountId, u128)> { + let addr = Address::from(*eth_addr); + Ok((resolve_or_create_eoa_account(addr, env)?, *balance)) + }, + ) + .collect::>>()?; + + let token = TokenRef::initialize(metadata.clone(), balances, Some(minter), env)?.0; + let _token_eth_addr = register_runtime_contract_account(token.0, env)?; + + let scheduler_acc = SchedulerRef::initialize(vec![], vec![], env)?.0; + let _scheduler_eth_addr = register_runtime_contract_account(scheduler_acc.0, env)?; + scheduler_acc.update_begin_blockers(vec![], env)?; Ok(EvdGenesisResult { - token: resources.token, - scheduler: resources.scheduler, + token: token.0, + scheduler: scheduler_acc.0, }) }) .expect("genesis failed"); @@ -589,3 +742,126 @@ fn state_changes_to_operations(changes: Vec) -> Vec { }) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + use evolve_core::encoding::Encodable; + use evolve_core::runtime_api::ACCOUNT_IDENTIFIER_PREFIX; + use evolve_core::Message; + use evolve_storage::MockStorage; + use std::collections::BTreeMap; + use std::sync::{Mutex, MutexGuard}; + + static ENV_VAR_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvVarGuard { + entries: Vec<(&'static str, Option)>, + _guard: MutexGuard<'static, ()>, + } + + impl EnvVarGuard { + fn acquire() -> Self { + let guard = ENV_VAR_LOCK.lock().expect("env var lock poisoned"); + Self { + entries: Vec::new(), + _guard: guard, + } + } + + fn set(&mut self, key: &'static str, value: &str) { + let old = std::env::var(key).ok(); + std::env::set_var(key, value); + self.entries.push((key, old)); + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + for (key, old) in self.entries.iter().rev() { + if let Some(value) = old { + std::env::set_var(key, value); + } else { + std::env::remove_var(key); + } + } + } + } + + fn apply_changes_to_map(changes: Vec) -> BTreeMap, Vec> { + let mut out = BTreeMap::new(); + for change in changes { + match change { + StateChange::Set { key, value } => { + out.insert(key, value); + } + StateChange::Remove { key } => { + out.remove(&key); + } + } + } + out + } + + fn read_token_balance( + state: &BTreeMap, Vec>, + token_account_id: AccountId, + account_id: AccountId, + ) -> u128 { + let mut key = token_account_id.as_bytes().to_vec(); + key.push(1u8); // Token::balances storage prefix + key.extend(account_id.encode().expect("encode account id")); + + match state.get(&key) { + Some(value) => Message::from_bytes(value.clone()) + .get::() + .expect("decode balance"), + None => 0, + } + } + + fn eoa_account_ids(state: &BTreeMap, Vec>) -> Vec { + state + .iter() + .filter_map(|(key, value)| { + if key.len() != 33 || key[0] != ACCOUNT_IDENTIFIER_PREFIX { + return None; + } + let code_id = Message::from_bytes(value.clone()).get::().ok()?; + if code_id != "EthEoaAccount" { + return None; + } + let account_bytes: [u8; 32] = key[1..33].try_into().ok()?; + Some(AccountId::from_bytes(account_bytes)) + }) + .collect() + } + + #[test] + fn default_genesis_funds_eth_mapped_sender_account() { + let mut env = EnvVarGuard::acquire(); + env.set( + "GENESIS_ALICE_ETH_ADDRESS", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + ); + env.set( + "GENESIS_BOB_ETH_ADDRESS", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + ); + env.set("GENESIS_ALICE_TOKEN_BALANCE", "1234"); + env.set("GENESIS_BOB_TOKEN_BALANCE", "5678"); + + let storage = MockStorage::new(); + let codes = build_codes(); + let output = run_default_genesis(&storage, &codes); + let state = apply_changes_to_map(output.changes); + + let eoa_ids = eoa_account_ids(&state); + assert_eq!(eoa_ids.len(), 2); + assert!(eoa_ids.iter().any(|id| read_token_balance( + &state, + output.genesis_result.token, + *id + ) == 1234)); + } +} diff --git a/bin/testapp/src/eth_eoa.rs b/bin/testapp/src/eth_eoa.rs index b0ab9d7..d1263d3 100644 --- a/bin/testapp/src/eth_eoa.rs +++ b/bin/testapp/src/eth_eoa.rs @@ -22,7 +22,6 @@ pub mod eth_eoa_account { use evolve_collections::item::Item; use evolve_core::{AccountId, Environment, Message, SdkResult}; use evolve_macros::{exec, init, query}; - use evolve_tx_eth::address_to_account_id; use evolve_tx_eth::TxContext; /// An Ethereum-compatible externally owned account. @@ -93,18 +92,20 @@ pub mod eth_eoa_account { /// - Just increments nonce (test mode, no signature verification) #[exec] fn authenticate(&self, tx: Message, env: &mut dyn Environment) -> SdkResult<()> { + let expected_address = self.eth_address.may_get(env)?.unwrap_or([0u8; 20]); + + if let Ok(sender_address) = tx.get::<[u8; 20]>() { + if sender_address != expected_address { + return Err(evolve_core::ErrorCode::new(0x51)); // Sender mismatch + } // Fast path: validator passes sender AccountId directly. - if let Ok(sender_id) = tx.get::() { - let expected_address = self.eth_address.may_get(env)?.unwrap_or([0u8; 20]); - let expected_id = - address_to_account_id(alloy_primitives::Address::from(expected_address)); - if sender_id != expected_id { + } else if let Ok(sender_id) = tx.get::() { + if sender_id != env.whoami() { return Err(evolve_core::ErrorCode::new(0x51)); // Sender mismatch } // Backward-compatible fallback for older validator payloads. } else if let Ok(mempool_tx) = tx.get::() { let sender_bytes: [u8; 20] = mempool_tx.sender_address().into(); - let expected_address = self.eth_address.may_get(env)?.unwrap_or([0u8; 20]); if sender_bytes != expected_address { return Err(evolve_core::ErrorCode::new(0x51)); // Sender mismatch } diff --git a/bin/testapp/src/genesis_config.rs b/bin/testapp/src/genesis_config.rs index d0a1e75..eedd964 100644 --- a/bin/testapp/src/genesis_config.rs +++ b/bin/testapp/src/genesis_config.rs @@ -2,6 +2,7 @@ use alloy_primitives::Address; use borsh::{BorshDeserialize, BorshSerialize}; use evolve_core::AccountId; use evolve_fungible_asset::FungibleAssetMetadata; +use evolve_node::HasTokenAccountId; use serde::Deserialize; use std::collections::BTreeSet; @@ -37,6 +38,12 @@ pub struct EvdGenesisResult { pub scheduler: AccountId, } +impl HasTokenAccountId for EvdGenesisResult { + fn token_account_id(&self) -> AccountId { + self.token + } +} + impl EvdGenesisConfig { /// Load genesis config from a JSON file. pub fn load(path: &str) -> Result { diff --git a/bin/testapp/src/lib.rs b/bin/testapp/src/lib.rs index 8af70ac..23a5179 100644 --- a/bin/testapp/src/lib.rs +++ b/bin/testapp/src/lib.rs @@ -6,6 +6,7 @@ use crate::eth_eoa::eth_eoa_account::{EthEoaAccount, EthEoaAccountRef}; use evolve_authentication::AuthenticationTxValidator; use evolve_core::{AccountId, BlockContext, Environment, InvokeResponse, ReadonlyKV, SdkResult}; use evolve_fungible_asset::FungibleAssetMetadata; +use evolve_node::HasTokenAccountId; use evolve_scheduler::scheduler_account::{Scheduler, SchedulerRef}; use evolve_scheduler::server::{SchedulerBeginBlocker, SchedulerEndBlocker}; use evolve_server::Block; @@ -13,8 +14,8 @@ use evolve_stf::execution_state::ExecutionState; use evolve_stf::{Stf, StorageGasConfig}; use evolve_stf_traits::{AccountsCodeStorage, PostTxExecution, WritableAccountsCodeStorage}; use evolve_token::account::{Token, TokenRef}; -use evolve_tx_eth::address_to_account_id; use evolve_tx_eth::TxContext; +use evolve_tx_eth::{register_runtime_contract_account, resolve_or_create_eoa_account}; pub const MINTER: AccountId = AccountId::new(100_002); @@ -78,55 +79,11 @@ pub struct GenesisAccounts { pub scheduler: AccountId, } -/// Shared custom-genesis resources used by evd and testapp binaries. -#[derive(Clone, Copy, Debug)] -pub struct CustomGenesisResources { - pub alice: Option, - pub bob: Option, - pub token: AccountId, - pub scheduler: AccountId, -} - -/// Initialize funded EOAs, token, and scheduler for custom genesis. -pub fn initialize_custom_genesis_resources( - funded_accounts: &[([u8; 20], u128)], - metadata: FungibleAssetMetadata, - minter: AccountId, - env: &mut dyn Environment, -) -> SdkResult { - for (eth_addr, _) in funded_accounts { - EthEoaAccountRef::initialize(*eth_addr, env)?; +impl HasTokenAccountId for GenesisAccounts { + fn token_account_id(&self) -> AccountId { + self.atom } - - let balances: Vec<(AccountId, u128)> = funded_accounts - .iter() - .map(|(eth_addr, balance)| { - let addr = alloy_primitives::Address::from(*eth_addr); - (address_to_account_id(addr), *balance) - }) - .collect(); - - let token = TokenRef::initialize(metadata, balances, Some(minter), env)?.0; - - let scheduler_acc = SchedulerRef::initialize(vec![], vec![], env)?.0; - scheduler_acc.update_begin_blockers(vec![], env)?; - - let alice = funded_accounts - .first() - .map(|(eth_addr, _)| address_to_account_id(alloy_primitives::Address::from(*eth_addr))); - let bob = funded_accounts - .get(1) - .map(|(eth_addr, _)| address_to_account_id(alloy_primitives::Address::from(*eth_addr))) - .or(alice); - - Ok(CustomGenesisResources { - alice, - bob, - token: token.0, - scheduler: scheduler_acc.0, - }) } - fn parse_genesis_address_env(var: &str) -> Option<[u8; 20]> { use alloy_primitives::Address; use std::str::FromStr; @@ -166,9 +123,11 @@ pub fn do_genesis_with_addresses( env, )? .0; + let _atom_eth_addr = register_runtime_contract_account(atom.0, env)?; // Create scheduler (no begin blockers needed for block info anymore) let scheduler_acc = SchedulerRef::initialize(vec![], vec![], env)?.0; + let _scheduler_eth_addr = register_runtime_contract_account(scheduler_acc.0, env)?; // Update scheduler's account's list. scheduler_acc.update_begin_blockers(vec![], env)?; @@ -226,10 +185,9 @@ pub fn do_eth_genesis_inner( use alloy_primitives::Address; use std::str::FromStr; - // Convert Ethereum addresses to AccountIds - // (accounts should already be registered in storage) - let alice_id = address_to_account_id(Address::from(alice_eth_address)); - let bob_id = address_to_account_id(Address::from(bob_eth_address)); + // Resolve/create canonical EOA accounts from full 20-byte ETH addresses. + let alice_id = resolve_or_create_eoa_account(Address::from(alice_eth_address), env)?; + let bob_id = resolve_or_create_eoa_account(Address::from(bob_eth_address), env)?; let alice_balance = std::env::var("GENESIS_ALICE_TOKEN_BALANCE") .ok() .and_then(|v| u128::from_str(v.trim()).ok()) @@ -253,9 +211,11 @@ pub fn do_eth_genesis_inner( env, )? .0; + let _evolve_eth_addr = register_runtime_contract_account(evolve.0, env)?; // Create scheduler let scheduler_acc = SchedulerRef::initialize(vec![], vec![], env)?.0; + let _scheduler_eth_addr = register_runtime_contract_account(scheduler_acc.0, env)?; scheduler_acc.update_begin_blockers(vec![], env)?; Ok(EthGenesisAccounts { diff --git a/bin/testapp/src/main.rs b/bin/testapp/src/main.rs index fb64f69..0bec09d 100644 --- a/bin/testapp/src/main.rs +++ b/bin/testapp/src/main.rs @@ -8,11 +8,10 @@ use evolve_node::{ run_dev_node_with_rpc_and_mempool_mock_storage, GenesisOutput, InitArgs, RunArgs, }; use evolve_storage::{QmdbStorage, Storage, StorageConfig}; -use evolve_testapp::genesis_config::{load_genesis_config, EvdGenesisConfig}; +use evolve_testapp::genesis_config::EvdGenesisConfig; use evolve_testapp::{ - build_mempool_stf, default_gas_config, do_eth_genesis_inner, - initialize_custom_genesis_resources, install_account_codes, GenesisAccounts, MempoolStf, - PLACEHOLDER_ACCOUNT, + build_mempool_stf, default_gas_config, do_eth_genesis_inner, install_account_codes, + GenesisAccounts, MempoolStf, PLACEHOLDER_ACCOUNT, }; use evolve_testing::server_mocks::AccountStorageMock; @@ -70,8 +69,8 @@ fn main() { } }; - let rpc_config = config.to_rpc_config(); - + let mut rpc_config = config.to_rpc_config(); + rpc_config.grpc_addr = Some(config.parsed_grpc_addr()); if args.custom.mock_storage { run_dev_node_with_rpc_and_mempool_mock_storage( &config.storage.path, @@ -108,7 +107,6 @@ fn main() { std::process::exit(2); } }; - init_dev_node( &config.storage.path, build_genesis_stf, @@ -122,6 +120,14 @@ fn main() { } } +fn load_genesis_config(path: Option<&str>) -> Result, String> { + path.map(|p| { + tracing::info!("Loading genesis config from: {}", p); + EvdGenesisConfig::load(p) + }) + .transpose() +} + fn build_codes() -> AccountStorageMock { let mut codes = AccountStorageMock::default(); install_account_codes(&mut codes); @@ -196,8 +202,21 @@ fn run_custom_genesis( config: &EvdGenesisConfig, ) -> Result, Box> { use evolve_core::{AccountId, BlockContext}; - - let funded_accounts = config.funded_accounts()?; + use evolve_scheduler::scheduler_account::SchedulerRef; + use evolve_token::account::TokenRef; + use evolve_tx_eth::{register_runtime_contract_account, resolve_or_create_eoa_account}; + + let configured_accounts: Vec<([u8; 20], u128)> = config + .accounts + .iter() + .map(|acc| { + acc.parse_address() + .map(|addr| (addr.into_array(), acc.balance)) + }) + .collect::, _>>()?; + if configured_accounts.is_empty() { + return Err("custom genesis requires at least one account".into()); + } let minter = AccountId::new(config.minter_id); let metadata = config.token.to_metadata(); @@ -206,20 +225,41 @@ fn run_custom_genesis( let (accounts, state) = stf .system_exec(storage, codes, genesis_block, |env| { - let resources = initialize_custom_genesis_resources( - &funded_accounts, - metadata.clone(), - minter, - env, - )?; - let alice = resources.alice.unwrap_or(resources.token); - let bob = resources.bob.unwrap_or(alice); + let resolved_accounts: Vec<(AccountId, u128)> = configured_accounts + .iter() + .map( + |(eth_addr, balance)| -> evolve_core::SdkResult<(AccountId, u128)> { + let addr = alloy_primitives::Address::from(*eth_addr); + Ok((resolve_or_create_eoa_account(addr, env)?, *balance)) + }, + ) + .collect::>>()?; + let balances: Vec<(AccountId, u128)> = resolved_accounts + .iter() + .filter(|(_, balance)| *balance > 0) + .map(|(account_id, balance)| (*account_id, *balance)) + .collect(); + let alice_acc = resolved_accounts + .first() + .map(|(account_id, _)| *account_id) + .expect("configured_accounts validated as non-empty"); + let bob_acc = resolved_accounts + .get(1) + .map(|(account_id, _)| *account_id) + .unwrap_or(alice_acc); + + let token = TokenRef::initialize(metadata.clone(), balances, Some(minter), env)?.0; + let _token_eth_addr = register_runtime_contract_account(token.0, env)?; + + let scheduler_acc = SchedulerRef::initialize(vec![], vec![], env)?.0; + let _scheduler_eth_addr = register_runtime_contract_account(scheduler_acc.0, env)?; + scheduler_acc.update_begin_blockers(vec![], env)?; Ok(GenesisAccounts { - alice, - bob, - atom: resources.token, - scheduler: resources.scheduler, + alice: alice_acc, + bob: bob_acc, + atom: token.0, + scheduler: scheduler_acc.0, }) }) .map_err(|e| format!("{:?}", e))?; @@ -231,7 +271,6 @@ fn run_custom_genesis( changes, }) } - async fn build_storage( context: commonware_runtime::tokio::Context, config: StorageConfig, @@ -241,3 +280,117 @@ async fn build_storage( .await .map_err(|e| Box::new(e) as _) } + +#[cfg(test)] +mod tests { + use super::*; + use evolve_core::encoding::Encodable; + use evolve_core::runtime_api::ACCOUNT_IDENTIFIER_PREFIX; + use evolve_core::AccountId; + use evolve_core::Message; + use evolve_storage::MockStorage; + use evolve_testapp::genesis_config::{AccountConfig, TokenConfig}; + use std::collections::BTreeMap; + + fn apply_changes_to_map( + changes: Vec, + ) -> BTreeMap, Vec> { + let mut out = BTreeMap::new(); + for change in changes { + match change { + evolve_stf_traits::StateChange::Set { key, value } => { + out.insert(key, value); + } + evolve_stf_traits::StateChange::Remove { key } => { + out.remove(&key); + } + } + } + out + } + + fn read_token_balance( + state: &BTreeMap, Vec>, + token_account_id: AccountId, + account_id: AccountId, + ) -> u128 { + let mut key = token_account_id.as_bytes().to_vec(); + key.push(1u8); // Token::balances storage prefix + key.extend(account_id.encode().expect("encode account id")); + + match state.get(&key) { + Some(value) => Message::from_bytes(value.clone()) + .get::() + .expect("decode balance"), + None => 0, + } + } + + fn count_registered_code_id( + state: &BTreeMap, Vec>, + expected_code_id: &str, + ) -> usize { + state + .iter() + .filter(|(key, value)| { + if key.len() != 33 || key[0] != ACCOUNT_IDENTIFIER_PREFIX { + return false; + } + Message::from_bytes((*value).clone()) + .get::() + .map(|code_id| code_id == expected_code_id) + .unwrap_or(false) + }) + .count() + } + + #[test] + fn custom_genesis_funds_registry_resolved_eoa_account() { + let stf = build_genesis_stf(); + let codes = build_codes(); + let storage = MockStorage::new(); + + let config = EvdGenesisConfig { + token: TokenConfig { + name: "evolve".to_string(), + symbol: "ev".to_string(), + decimals: 6, + icon_url: "https://example.com/icon.png".to_string(), + description: "token".to_string(), + }, + minter_id: 100_002, + accounts: vec![AccountConfig { + eth_address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266".to_string(), + balance: 777, + }], + }; + + let output = run_custom_genesis(&stf, &codes, &storage, &config).expect("custom genesis"); + let state = apply_changes_to_map(output.changes); + + let mapped_id = state + .iter() + .find_map(|(key, value)| { + if key.len() != 33 || key[0] != ACCOUNT_IDENTIFIER_PREFIX { + return None; + } + let code_id = Message::from_bytes((*value).clone()).get::().ok()?; + if code_id != "EthEoaAccount" { + return None; + } + let id_bytes: [u8; 32] = key[1..33].try_into().ok()?; + Some(AccountId::from_bytes(id_bytes)) + }) + .expect("eoa account id"); + assert_eq!( + read_token_balance(&state, output.genesis_result.atom, mapped_id), + 777 + ); + assert_eq!(output.genesis_result.alice, mapped_id); + assert_eq!(output.genesis_result.bob, mapped_id); + assert_ne!(output.genesis_result.alice, output.genesis_result.atom); + assert_ne!(output.genesis_result.bob, output.genesis_result.atom); + + assert_eq!(count_registered_code_id(&state, "EthEoaAccount"), 1); + } +} diff --git a/bin/testapp/src/sim_testing.rs b/bin/testapp/src/sim_testing.rs index 930ffcf..f33d4fe 100644 --- a/bin/testapp/src/sim_testing.rs +++ b/bin/testapp/src/sim_testing.rs @@ -21,7 +21,9 @@ use evolve_stf::results::BlockResult; use evolve_stf_traits::{Block as BlockTrait, Transaction}; use evolve_testing::server_mocks::AccountStorageMock; use evolve_token::account::TokenRef; -use evolve_tx_eth::{account_id_to_address, address_to_account_id}; +use evolve_tx_eth::{ + derive_eth_eoa_account_id, derive_runtime_contract_address, register_runtime_contract_account, +}; use evolve_tx_eth::{EthGateway, TxContext}; use k256::ecdsa::{signature::hazmat::PrehashSigner, SigningKey, VerifyingKey}; use std::collections::BTreeMap; @@ -331,8 +333,8 @@ impl SimTestApp { let alice_address = get_address(&alice_key); let bob_address = get_address(&bob_key); - let alice_id = address_to_account_id(alice_address); - let bob_id = address_to_account_id(bob_address); + let alice_id = derive_eth_eoa_account_id(alice_address); + let bob_id = derive_eth_eoa_account_id(bob_address); register_account_code_identifier(&mut sim, alice_id, "EthEoaAccount") .expect("register alice code"); @@ -355,10 +357,12 @@ impl SimTestApp { env, )? .0; + let _atom_eth_addr = register_runtime_contract_account(atom.0, env)?; let scheduler = evolve_scheduler::scheduler_account::SchedulerRef::initialize(vec![], vec![], env)? .0; + let _scheduler_eth_addr = register_runtime_contract_account(scheduler.0, env)?; scheduler.update_begin_blockers(vec![], env)?; Ok(GenesisAccounts { @@ -414,7 +418,7 @@ impl SimTestApp { calldata.extend_from_slice(&selector); calldata.extend_from_slice(&args); - let to = account_id_to_address(token_account); + let to = derive_runtime_contract_address(token_account); Some(create_signed_tx( signing_key, self.chain_id, @@ -724,7 +728,7 @@ impl SimTestApp { /// Create an EOA with a specific Ethereum address. pub fn create_eoa_with_address(&mut self, eth_address: [u8; 20]) -> AccountId { - let account_id = address_to_account_id(alloy_primitives::Address::from(eth_address)); + let account_id = derive_eth_eoa_account_id(alloy_primitives::Address::from(eth_address)); register_account_code_identifier(&mut self.sim, account_id, "EthEoaAccount") .expect("register eoa code"); init_eth_eoa_storage(&mut self.sim, account_id, eth_address).expect("init eoa storage"); @@ -736,7 +740,7 @@ impl SimTestApp { let signing_key = generate_signing_key(&mut self.sim, MAX_SIGNING_KEY_ATTEMPTS) .expect("failed to generate signing key"); let address = get_address(&signing_key); - let account_id = address_to_account_id(address); + let account_id = derive_eth_eoa_account_id(address); self.signers.insert(account_id, signing_key); self.nonces.entry(account_id).or_insert(0); account_id @@ -747,7 +751,7 @@ impl SimTestApp { let signing_key = generate_signing_key(&mut self.sim, MAX_SIGNING_KEY_ATTEMPTS) .expect("failed to generate signing key"); let address = get_address(&signing_key); - let account_id = address_to_account_id(address); + let account_id = derive_eth_eoa_account_id(address); register_account_code_identifier(&mut self.sim, account_id, code_id) .expect("register account code"); self.signers.insert(account_id, signing_key); diff --git a/bin/testapp/tests/mempool_e2e.rs b/bin/testapp/tests/mempool_e2e.rs index 8653f68..22fb06f 100644 --- a/bin/testapp/tests/mempool_e2e.rs +++ b/bin/testapp/tests/mempool_e2e.rs @@ -8,23 +8,27 @@ //! 5. Token balances are updated correctly use alloy_consensus::{SignableTransaction, TxEip1559}; -use alloy_primitives::{Bytes, PrimitiveSignature, TxKind, U256}; +use alloy_primitives::{Address, Bytes, PrimitiveSignature, TxKind, B256, U256}; use async_trait::async_trait; use evolve_core::{AccountId, ErrorCode, ReadonlyKV}; -use evolve_node::build_dev_node_with_mempool; +use evolve_node::{build_dev_node_with_mempool, DevNodeMempoolHandles}; use evolve_server::DevConfig; +use evolve_simulator::{generate_signing_key, SimConfig, Simulator}; use evolve_storage::{CommitHash, Operation}; use evolve_testapp::{ build_mempool_stf, default_gas_config, do_eth_genesis, install_account_codes, + EthGenesisAccounts, MempoolStf, }; use evolve_testing::server_mocks::AccountStorageMock; -use evolve_tx_eth::{account_id_to_address, EthGateway}; +use evolve_tx_eth::{derive_runtime_contract_address, EthGateway, TxContext}; use k256::ecdsa::{signature::hazmat::PrehashSigner, SigningKey, VerifyingKey}; -use rand::rngs::OsRng; use std::collections::BTreeMap; use std::sync::RwLock; use tiny_keccak::{Hasher, Keccak}; +type TestNodeHandles = + DevNodeMempoolHandles; + // ============================================================================ // Test Infrastructure // ============================================================================ @@ -249,48 +253,25 @@ fn read_token_balance( // E2E Test // ============================================================================ -/// End-to-end test of token transfer via Ethereum transaction. -/// -/// This test verifies: -/// 1. Alice signs a tx calling token.transfer(bob, 100) -/// 2. Transaction is submitted to mempool -/// 3. DevConsensus produces a block -/// 4. Transaction is authenticated (signature verified, nonce incremented) -/// 5. Token transfer executes (Alice balance decreases, Bob balance increases) -#[tokio::test] -async fn test_token_transfer_e2e() { - let chain_id = 1337u64; - - // Create signing keys for Alice and Bob - let alice_key = SigningKey::random(&mut OsRng); - let bob_key = SigningKey::random(&mut OsRng); - let alice_address = get_address(&alice_key); - let bob_address = get_address(&bob_key); +fn deterministic_signing_keys() -> (SigningKey, SigningKey) { + let mut simulator = Simulator::new(0xD15E_A5E5, SimConfig::default()); + let alice_key = generate_signing_key(&mut simulator, 64).expect("alice signing key"); + let bob_key = generate_signing_key(&mut simulator, 64).expect("bob signing key"); + (alice_key, bob_key) +} - // Set up account codes +fn setup_genesis( + chain_id: u64, + alice_address: Address, + bob_address: Address, +) -> (TestNodeHandles, EthGenesisAccounts, AccountId, AccountId) { let mut codes = AccountStorageMock::new(); install_account_codes(&mut codes); - // Derive account IDs from Ethereum addresses - let alice_account_id = evolve_tx_eth::address_to_account_id(alice_address); - let bob_account_id = evolve_tx_eth::address_to_account_id(bob_address); - - // Create initial storage and pre-populate ETH EOA account data let init_storage = AsyncMockStorage::new(); - - // Register account code identifiers (global storage) - init_storage.register_account_code(alice_account_id, "EthEoaAccount"); - init_storage.register_account_code(bob_account_id, "EthEoaAccount"); - - // Initialize account storage (nonce, eth_address) - init_storage.init_eth_eoa_storage(alice_account_id, alice_address.into()); - init_storage.init_eth_eoa_storage(bob_account_id, bob_address.into()); - - // Build STF for TxContext let gas_config = default_gas_config(); let stf = build_mempool_stf(gas_config.clone(), AccountId::new(0)); - // Run genesis to create token and scheduler let (genesis_state, genesis_accounts) = do_eth_genesis( &stf, &codes, @@ -300,99 +281,74 @@ async fn test_token_transfer_e2e() { ) .expect("genesis should succeed"); - // Apply genesis state changes to existing storage let genesis_changes = genesis_state.into_changes().expect("get changes"); init_storage.apply_changes(genesis_changes); - let storage = init_storage; - // Rebuild STF with correct scheduler ID let stf = build_mempool_stf(gas_config, genesis_accounts.scheduler); - let config = DevConfig { - block_interval: None, // Manual block production + block_interval: None, gas_limit: 30_000_000, initial_height: 1, chain_id, }; - let handles = build_dev_node_with_mempool(stf, storage, codes, config); - let dev = handles.dev; - let mempool = handles.mempool; - - // Read initial state - let alice_nonce_before = read_nonce(dev.storage(), alice_account_id); - let alice_balance_before = - read_token_balance(dev.storage(), genesis_accounts.evolve, alice_account_id); - let bob_balance_before = - read_token_balance(dev.storage(), genesis_accounts.evolve, bob_account_id); - - println!("Initial state:"); - println!(" Alice nonce: {}", alice_nonce_before); - println!(" Alice token balance: {}", alice_balance_before); - println!(" Bob token balance: {}", bob_balance_before); - - assert_eq!(alice_nonce_before, 0); - assert_eq!(alice_balance_before, 1000); // From genesis - assert_eq!(bob_balance_before, 2000); // From genesis + let handles = build_dev_node_with_mempool(stf, init_storage, codes, config); + + let alice_account_id = + evolve_tx_eth::lookup_account_id_in_storage(handles.dev.storage(), alice_address) + .expect("lookup alice id") + .expect("alice id exists"); + let bob_account_id = + evolve_tx_eth::lookup_account_id_in_storage(handles.dev.storage(), bob_address) + .expect("lookup bob id") + .expect("bob id exists"); + + (handles, genesis_accounts, alice_account_id, bob_account_id) +} - // Build the transfer call - // Function: transfer(to: AccountId, amount: u128) - // Selector: keccak256("transfer")[0..4] - let transfer_amount = 100u128; +fn build_transfer_tx( + alice_key: &SigningKey, + chain_id: u64, + token_address: Address, + bob_account_id: AccountId, + transfer_amount: u128, +) -> Vec { let selector = compute_selector("transfer"); - - // Borsh-encode the arguments: (AccountId, u128) - // AccountId is u128 (16 bytes), amount is u128 (16 bytes) let args = borsh::to_vec(&(bob_account_id, transfer_amount)).expect("encode args"); - - // Calldata = selector + args let mut calldata = Vec::with_capacity(4 + args.len()); calldata.extend_from_slice(&selector); calldata.extend_from_slice(&args); - // Get token's Ethereum address - let token_address = account_id_to_address(genesis_accounts.evolve); - - println!("\nTransaction details:"); - println!(" Token account: {:?}", genesis_accounts.evolve); - println!(" Token address: {:?}", token_address); - println!(" Selector: 0x{}", hex::encode(selector)); - println!(" Transfer amount: {}", transfer_amount); - - // Create and sign transaction from Alice to Token - let raw_tx = create_signed_tx( - &alice_key, + create_signed_tx( + alice_key, chain_id, - 0, // nonce + 0, token_address, U256::ZERO, Bytes::from(calldata), - ); + ) +} - // Submit transaction to mempool +async fn submit_and_produce_block(handles: &TestNodeHandles, chain_id: u64, raw_tx: &[u8]) -> B256 { let tx_hash = { let gateway = EthGateway::new(chain_id); - let tx_context = gateway.decode_and_verify(&raw_tx).expect("decode tx"); - let mut pool = mempool.write().await; + let tx_context = gateway.decode_and_verify(raw_tx).expect("decode tx"); + let mut pool = handles.mempool.write().await; let tx_id = pool.add(tx_context).expect("add tx to mempool"); - alloy_primitives::B256::from(tx_id) + B256::from(tx_id) }; - println!("\nSubmitted transaction: {:?}", tx_hash); - // Verify transaction is in mempool - assert_eq!(mempool.read().await.len(), 1, "mempool should have 1 tx"); + assert_eq!( + handles.mempool.read().await.len(), + 1, + "mempool should have 1 tx" + ); - // Produce a block from mempool - let block_result = dev + let block_result = handles + .dev .produce_block_from_mempool(10) .await .expect("produce block"); - println!("\nBlock produced at height {}", block_result.height); - println!(" Transactions: {}", block_result.tx_count); - println!(" Successful: {}", block_result.successful_txs); - println!(" Failed: {}", block_result.failed_txs); - - // Verify block was produced assert_eq!(block_result.height, 1, "should be block 1"); assert_eq!(block_result.tx_count, 1, "should have 1 tx"); assert_eq!( @@ -403,32 +359,39 @@ async fn test_token_transfer_e2e() { block_result.failed_txs, 0, "no transactions should have failed" ); - - // Mempool should be empty after block production assert!( - mempool.read().await.is_empty(), + handles.mempool.read().await.is_empty(), "mempool should be empty after block" ); - // Verify state changes - let alice_nonce_after = read_nonce(dev.storage(), alice_account_id); - let alice_balance_after = - read_token_balance(dev.storage(), genesis_accounts.evolve, alice_account_id); - let bob_balance_after = - read_token_balance(dev.storage(), genesis_accounts.evolve, bob_account_id); + tx_hash +} - println!("\nFinal state:"); - println!(" Alice nonce: {}", alice_nonce_after); - println!(" Alice token balance: {}", alice_balance_after); - println!(" Bob token balance: {}", bob_balance_after); +fn assert_post_block_state( + handles: &TestNodeHandles, + genesis_accounts: &EthGenesisAccounts, + alice_account_id: AccountId, + bob_account_id: AccountId, + transfer_amount: u128, + alice_balance_before: u128, + bob_balance_before: u128, +) { + let alice_nonce_after = read_nonce(handles.dev.storage(), alice_account_id); + let alice_balance_after = read_token_balance( + handles.dev.storage(), + genesis_accounts.evolve, + alice_account_id, + ); + let bob_balance_after = read_token_balance( + handles.dev.storage(), + genesis_accounts.evolve, + bob_account_id, + ); - // Verify nonce was incremented assert_eq!( alice_nonce_after, 1, "alice nonce should increment after tx" ); - - // Verify token balances changed correctly assert_eq!( alice_balance_after, alice_balance_before - transfer_amount, @@ -439,13 +402,62 @@ async fn test_token_transfer_e2e() { bob_balance_before + transfer_amount, "bob balance should increase by transfer amount" ); +} + +/// End-to-end test of token transfer via Ethereum transaction. +/// +/// This test verifies: +/// 1. Alice signs a tx calling token.transfer(bob, 100) +/// 2. Transaction is submitted to mempool +/// 3. DevConsensus produces a block +/// 4. Transaction is authenticated (signature verified, nonce incremented) +/// 5. Token transfer executes (Alice balance decreases, Bob balance increases) +#[tokio::test] +async fn test_token_transfer_e2e() { + let chain_id = 1337u64; + let transfer_amount = 100u128; + + let (alice_key, bob_key) = deterministic_signing_keys(); + let alice_address = get_address(&alice_key); + let bob_address = get_address(&bob_key); + + let (handles, genesis_accounts, alice_account_id, bob_account_id) = + setup_genesis(chain_id, alice_address, bob_address); + + // Read initial state + let alice_nonce_before = read_nonce(handles.dev.storage(), alice_account_id); + let alice_balance_before = read_token_balance( + handles.dev.storage(), + genesis_accounts.evolve, + alice_account_id, + ); + let bob_balance_before = read_token_balance( + handles.dev.storage(), + genesis_accounts.evolve, + bob_account_id, + ); + + assert_eq!(alice_nonce_before, 0); + assert_eq!(alice_balance_before, 1000); + assert_eq!(bob_balance_before, 2000); + + let token_address = derive_runtime_contract_address(genesis_accounts.evolve); + let raw_tx = build_transfer_tx( + &alice_key, + chain_id, + token_address, + bob_account_id, + transfer_amount, + ); - println!("\n✓ Token transfer e2e test passed!"); - println!(" - Transaction submitted to mempool"); - println!(" - DevConsensus produced block from mempool"); - println!(" - Authentication succeeded (nonce incremented)"); - println!( - " - Token transfer executed ({} tokens from Alice to Bob)", - transfer_amount + let _tx_hash = submit_and_produce_block(&handles, chain_id, &raw_tx).await; + assert_post_block_state( + &handles, + &genesis_accounts, + alice_account_id, + bob_account_id, + transfer_amount, + alice_balance_before, + bob_balance_before, ); } diff --git a/bin/txload/src/main.rs b/bin/txload/src/main.rs index 3fc18ad..3ecaec6 100644 --- a/bin/txload/src/main.rs +++ b/bin/txload/src/main.rs @@ -8,7 +8,7 @@ use alloy_consensus::{SignableTransaction, TxEip1559}; use alloy_primitives::{keccak256, Address, Bytes, PrimitiveSignature, TxKind, B256, U256}; use clap::Parser; use evolve_core::AccountId; -use evolve_tx_eth::address_to_account_id; +use evolve_tx_eth::derive_eth_eoa_account_id; use k256::ecdsa::{signature::hazmat::PrehashSigner, SigningKey, VerifyingKey}; use rand::RngCore; use serde_json::{json, Value}; @@ -378,7 +378,7 @@ async fn run_loadtest(config: LoadtestConfig) -> Result<(), String> { wallets.push(WorkerWallet { signing_key: key, address, - account_id: address_to_account_id(address), + account_id: derive_eth_eoa_account_id(address), next_nonce: 0, }); } diff --git a/crates/app/node/src/config.rs b/crates/app/node/src/config.rs index 8a68f45..8361fa2 100644 --- a/crates/app/node/src/config.rs +++ b/crates/app/node/src/config.rs @@ -73,7 +73,7 @@ impl NodeConfig { chain_id: self.chain.chain_id, enabled: self.rpc.enabled, enable_block_indexing: self.rpc.enable_block_indexing, - grpc_addr: Some(self.parsed_grpc_addr()), + grpc_addr: None, } } } diff --git a/crates/app/node/src/lib.rs b/crates/app/node/src/lib.rs index 9130d9e..bd7d211 100644 --- a/crates/app/node/src/lib.rs +++ b/crates/app/node/src/lib.rs @@ -19,9 +19,12 @@ use alloy_primitives::U256; use borsh::{BorshDeserialize, BorshSerialize}; use commonware_runtime::tokio::{Config as TokioConfig, Context as TokioContext, Runner}; use commonware_runtime::{Runner as RunnerTrait, Spawner}; -use evolve_chain_index::{ChainStateProvider, ChainStateProviderConfig, PersistentChainIndex}; +use evolve_chain_index::{ + ChainStateProvider, ChainStateProviderConfig, PersistentChainIndex, StateQuerier, + StorageStateQuerier, +}; use evolve_core::encoding::Encodable; -use evolve_core::ReadonlyKV; +use evolve_core::{AccountId, ReadonlyKV}; use evolve_eth_jsonrpc::{start_server_with_subscriptions, RpcServerConfig, SubscriptionManager}; use evolve_grpc::{GrpcServer, GrpcServerConfig}; use evolve_mempool::{new_shared_mempool, Mempool, MempoolTx, SharedMempool}; @@ -176,6 +179,14 @@ pub struct GenesisOutput { pub changes: Vec, } +/// Trait for extracting the token account ID from a genesis result. +/// +/// Implementing this trait on your genesis result type enables +/// `eth_getBalance` queries via the RPC server. +pub trait HasTokenAccountId { + fn token_account_id(&self) -> AccountId; +} + type RuntimeContext = TokioContext; /// Build the block archive callback. @@ -244,7 +255,14 @@ pub fn run_dev_node< Codes: AccountsCodeStorage + Send + Sync + 'static, S: ReadonlyKV + Storage + Clone + Send + Sync + 'static, Stf: StfExecutor + Send + Sync + 'static, - G: BorshSerialize + BorshDeserialize + Clone + Debug + Send + Sync + 'static, + G: BorshSerialize + + BorshDeserialize + + Clone + + Debug + + HasTokenAccountId + + Send + + Sync + + 'static, BuildGenesisStf: Fn() -> Stf + Send + Sync + 'static, BuildStf: Fn(&G) -> Stf + Send + Sync + 'static, BuildCodes: Fn() -> Codes + Clone + Send + Sync + 'static, @@ -293,7 +311,14 @@ pub fn run_dev_node_with_rpc< Codes: AccountsCodeStorage + Send + Sync + 'static, S: ReadonlyKV + Storage + Clone + Send + Sync + 'static, Stf: StfExecutor + Send + Sync + 'static, - G: BorshSerialize + BorshDeserialize + Clone + Debug + Send + Sync + 'static, + G: BorshSerialize + + BorshDeserialize + + Clone + + Debug + + HasTokenAccountId + + Send + + Sync + + 'static, BuildGenesisStf: Fn() -> Stf + Send + Sync + 'static, BuildStf: Fn(&G) -> Stf + Send + Sync + 'static, BuildCodes: Fn() -> Codes + Clone + Send + Sync + 'static, @@ -407,11 +432,16 @@ pub fn run_dev_node_with_rpc< 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 { @@ -433,7 +463,8 @@ pub fn run_dev_node_with_rpc< Arc::clone(&chain_index), state_provider_config, codes_for_rpc, - ); + ) + .with_state_querier(state_querier); let grpc_config = GrpcServerConfig { addr: grpc_addr, chain_id: rpc_config.chain_id, @@ -760,7 +791,14 @@ pub fn run_dev_node_with_rpc_and_mempool_eth< Codes: AccountsCodeStorage + Send + Sync + 'static, S: ReadonlyKV + Storage + Clone + Send + Sync + 'static, Stf: StfExecutor + Send + Sync + 'static, - G: BorshSerialize + BorshDeserialize + Clone + Debug + Send + Sync + 'static, + G: BorshSerialize + + BorshDeserialize + + Clone + + Debug + + HasTokenAccountId + + Send + + Sync + + 'static, BuildGenesisStf: Fn() -> Stf + Send + Sync + 'static, BuildStf: Fn(&G) -> Stf + Send + Sync + 'static, BuildCodes: Fn() -> Codes + Clone + Send + Sync + 'static, @@ -868,12 +906,22 @@ pub fn run_dev_node_with_rpc_and_mempool_eth< gas_price: U256::ZERO, sync_status: SyncStatus::NotSyncing(false), }; + + // Create state querier for balance/nonce reads + let state_querier: Arc = Arc::new( + StorageStateQuerier::new( + storage.clone(), + genesis_result.token_account_id(), + ), + ); + let state_provider = ChainStateProvider::with_mempool( Arc::clone(&chain_index), state_provider_config.clone(), Arc::clone(&codes_for_rpc), mempool.clone(), - ); + ) + .with_state_querier(Arc::clone(&state_querier)); let server_config = RpcServerConfig { http_addr: rpc_config.http_addr, @@ -896,7 +944,8 @@ pub fn run_dev_node_with_rpc_and_mempool_eth< state_provider_config, codes_for_rpc, mempool.clone(), - ); + ) + .with_state_querier(state_querier); let grpc_config = GrpcServerConfig { addr: grpc_addr, chain_id: rpc_config.chain_id, @@ -916,7 +965,6 @@ pub fn run_dev_node_with_rpc_and_mempool_eth< } else { None }; - let consensus = DevConsensus::with_rpc_and_mempool( stf, storage, @@ -1042,7 +1090,14 @@ pub fn run_dev_node_with_rpc_and_mempool_mock_storage< ) where Codes: AccountsCodeStorage + Send + Sync + 'static, Stf: StfExecutor + Send + Sync + 'static, - G: BorshSerialize + BorshDeserialize + Clone + Debug + Send + Sync + 'static, + G: BorshSerialize + + BorshDeserialize + + Clone + + Debug + + HasTokenAccountId + + Send + + Sync + + 'static, BuildGenesisStf: Fn() -> Stf + Send + Sync + 'static, BuildStf: Fn(&G) -> Stf + Send + Sync + 'static, BuildCodes: Fn() -> Codes + Clone + Send + Sync + 'static, diff --git a/crates/app/sdk/core/src/lib.rs b/crates/app/sdk/core/src/lib.rs index 4722a71..0314223 100644 --- a/crates/app/sdk/core/src/lib.rs +++ b/crates/app/sdk/core/src/lib.rs @@ -38,36 +38,83 @@ pub type SdkResult = Result; #[cfg(not(feature = "error-decode"))] pub type SdkResult = Result; +/// Canonical 32-byte account identity. +/// +/// Byte representation is the only canonical form. No numeric interpretation is +/// required for correctness. #[derive( Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, BorshSerialize, BorshDeserialize, )] -pub struct AccountId(u128); +pub struct AccountId([u8; 32]); impl AccountId { - pub fn invalid() -> AccountId { - AccountId(u128::MAX) + /// Reserved invalid sentinel (all 0xFF). + pub const fn invalid() -> AccountId { + AccountId([0xFF; 32]) } -} -impl AccountId { - pub fn increase(&self) -> Self { - Self(self.0 + 1) + /// Construct from raw canonical bytes. + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) } -} -impl AccountId { + /// Return raw canonical bytes. + pub const fn to_bytes(self) -> [u8; 32] { + self.0 + } + + /// Borrow raw canonical bytes. + pub const fn as_bytes(&self) -> [u8; 32] { + self.0 + } + + /// Backward-compatible constructor from `u128`. + /// + /// Encodes into the lower 16 bytes in big-endian order. + // TODO(account-id-cleanup): remove numeric AccountId compatibility (`new`/`inner`) + // after migrating genesis/tooling/docs to canonical 32-byte account IDs end-to-end. pub const fn new(u: u128) -> Self { - Self(u) + let mut out = [0u8; 32]; + let bytes = u.to_be_bytes(); + out[16] = bytes[0]; + out[17] = bytes[1]; + out[18] = bytes[2]; + out[19] = bytes[3]; + out[20] = bytes[4]; + out[21] = bytes[5]; + out[22] = bytes[6]; + out[23] = bytes[7]; + out[24] = bytes[8]; + out[25] = bytes[9]; + out[26] = bytes[10]; + out[27] = bytes[11]; + out[28] = bytes[12]; + out[29] = bytes[13]; + out[30] = bytes[14]; + out[31] = bytes[15]; + Self(out) } + /// Legacy extractor for compatibility where a numeric ID is still required. pub const fn inner(&self) -> u128 { - self.0 + u128::from_be_bytes([ + self.0[16], self.0[17], self.0[18], self.0[19], self.0[20], self.0[21], self.0[22], + self.0[23], self.0[24], self.0[25], self.0[26], self.0[27], self.0[28], self.0[29], + self.0[30], self.0[31], + ]) } -} -impl AccountId { - pub fn as_bytes(&self) -> Vec { - self.0.to_be_bytes().into() + /// Increment account ID bytes in big-endian order (wraps on overflow). + pub fn increase(&self) -> Self { + let mut out = self.0; + for i in (0..32).rev() { + let (next, carry) = out[i].overflowing_add(1); + out[i] = next; + if !carry { + break; + } + } + Self(out) } } @@ -145,33 +192,6 @@ pub trait InvokableMessage: Encodable + Clone { const FUNCTION_IDENTIFIER_NAME: &'static str; } -/// A macro that ensures a condition holds true. If not, returns an error. -/// -/// # Usage -/// -/// ```rust -/// # use std::error::Error; -/// # -/// # // Suppose we have an enum for our errors: -/// #[derive(Debug, )] -/// enum MyError { -/// SomeError, -/// AnotherError, -/// } -/// -/// // Return type must be Result -/// fn example_function(value: i32) -> Result<(), MyError> { -/// use evolve_core::ensure; -/// -/// ensure!(value > 10, MyError::SomeError); -/// // Proceed if the condition is satisfied -/// Ok(()) -/// } -/// # -/// # fn main() { -/// # example_function(11).unwrap(); -/// # } -/// ``` #[macro_export] macro_rules! ensure { ($cond:expr, $err:expr) => { @@ -225,85 +245,116 @@ mod tests { } } - #[derive(Debug, Clone, PartialEq, BorshSerialize, BorshDeserialize)] - struct TestPayload { - value: u32, - name: String, - } + impl Environment for TestEnv { + fn do_exec( + &mut self, + _to: AccountId, + _data: &InvokeRequest, + _funds: Vec, + ) -> SdkResult { + Err(ERR_UNKNOWN_FUNCTION) + } - #[test] - fn test_invoke_request_encode_decode() { - let function_id: u64 = 42; - let human_name = "test_function"; - let payload = TestPayload { - value: 123, - name: "test".to_string(), - }; + fn emit_event(&mut self, _name: &str, _data: &[u8]) -> SdkResult<()> { + Ok(()) + } - // Create InvokeRequest using the public API - let message = Message::new(&payload).expect("message creation should succeed"); - let request = InvokeRequest::new_from_message(human_name, function_id, message); + fn unique_id(&mut self) -> SdkResult<[u8; 32]> { + Ok([0u8; 32]) + } + } - // Verify accessors work - assert_eq!(request.function(), function_id); - assert_eq!(request.human_name(), human_name); + #[derive(Clone, BorshSerialize, BorshDeserialize)] + struct DummyMessage { + value: u64, + } - // Verify payload can be retrieved - let retrieved: TestPayload = request.get().expect("get should succeed"); - assert_eq!(retrieved, payload); + impl InvokableMessage for DummyMessage { + const FUNCTION_IDENTIFIER: u64 = 42; + const FUNCTION_IDENTIFIER_NAME: &'static str = "dummy"; + } + + #[test] + fn test_message_roundtrip() { + let msg = DummyMessage { value: 123 }; + let encoded = msg.encode().unwrap(); + let decoded = borsh::from_slice::(&encoded).unwrap(); + assert_eq!(decoded.value, 123); } #[test] fn test_one_coin_success() { - let mut funds = BTreeMap::new(); - funds.insert( - 0, - FungibleAsset { - asset_id: AccountId::new(1), - amount: 100, - }, - ); let env = TestEnv { - whoami: AccountId::new(10), - sender: AccountId::new(20), - funds: funds.into_values().collect(), + whoami: AccountId::new(1), + sender: AccountId::new(2), + funds: vec![FungibleAsset { + asset_id: AccountId::new(10), + amount: 100, + }], }; - let coin = one_coin(&env).expect("one_coin should succeed with exactly one fund"); - assert_eq!(coin.asset_id, AccountId::new(1)); + let coin = one_coin(&env).unwrap(); + assert_eq!(coin.asset_id, AccountId::new(10)); assert_eq!(coin.amount, 100); } #[test] - fn test_one_coin_fails_with_zero_funds() { + fn test_one_coin_error() { let env = TestEnv { - whoami: AccountId::new(10), - sender: AccountId::new(20), + whoami: AccountId::new(1), + sender: AccountId::new(2), funds: vec![], }; - let err = one_coin(&env).expect_err("one_coin should fail with zero funds"); + let err = one_coin(&env).unwrap_err(); assert_eq!(err, ERR_ONE_COIN); } #[test] - fn test_one_coin_fails_with_multiple_funds() { - let env = TestEnv { - whoami: AccountId::new(10), - sender: AccountId::new(20), - funds: vec![ - FungibleAsset { - asset_id: AccountId::new(1), - amount: 10, - }, - FungibleAsset { - asset_id: AccountId::new(2), - amount: 20, - }, - ], - }; + fn test_storage_key_size_constant() { + assert_eq!(MAX_STORAGE_KEY_SIZE, 254); + } - let err = one_coin(&env).expect_err("one_coin should fail with multiple funds"); - assert_eq!(err, ERR_ONE_COIN); + #[test] + fn test_account_id_u128_compat() { + let id = AccountId::new(42u128); + assert_eq!(id.inner(), 42u128); + + let mut expected = [0u8; 32]; + expected[31] = 42; + assert_eq!(id.as_bytes(), expected); + } + + #[test] + fn test_account_id_increase() { + let a = AccountId::from_bytes([0u8; 32]); + let b = a.increase(); + let mut expected = [0u8; 32]; + expected[31] = 1; + assert_eq!(b.as_bytes(), expected); + } + + #[test] + fn test_ensure_macro() { + fn test_fn(val: i32) -> SdkResult<()> { + ensure!(val > 10, ERR_UNAUTHORIZED); + Ok(()) + } + + assert!(test_fn(11).is_ok()); + assert_eq!(test_fn(5).unwrap_err(), ERR_UNAUTHORIZED); + } + + #[test] + fn test_message_btreemap_roundtrip() { + let mut map = BTreeMap::new(); + map.insert("a".to_string(), 1u64); + map.insert("b".to_string(), 2u64); + + let msg = Message::new(&map).unwrap(); + let decoded: BTreeMap = msg.get().unwrap(); + + assert_eq!(decoded.get("a"), Some(&1u64)); + assert_eq!(decoded.get("b"), Some(&2u64)); } } diff --git a/crates/app/sdk/core/src/low_level.rs b/crates/app/sdk/core/src/low_level.rs index 0cfb50b..545043c 100644 --- a/crates/app/sdk/core/src/low_level.rs +++ b/crates/app/sdk/core/src/low_level.rs @@ -1,6 +1,7 @@ use crate::encoding::{Decodable, Encodable}; use crate::runtime_api::{ - CreateAccountRequest, CreateAccountResponse, MigrateRequest, RUNTIME_ACCOUNT_ID, + CreateAccountRequest, CreateAccountResponse, MigrateRequest, RegisterAccountAtIdRequest, + RegisterAccountAtIdResponse, RUNTIME_ACCOUNT_ID, }; use crate::{ AccountId, Environment, EnvironmentQuery, FungibleAsset, InvokableMessage, InvokeRequest, @@ -64,3 +65,22 @@ pub fn migrate_account( env.do_exec(RUNTIME_ACCOUNT_ID, &invoke_request, funds)? .get() } + +pub fn register_account_at_id( + account_id: AccountId, + code_id: String, + init_msg: &Req, + env: &mut dyn Environment, +) -> SdkResult<()> { + let _: RegisterAccountAtIdResponse = exec_account( + RUNTIME_ACCOUNT_ID, + &RegisterAccountAtIdRequest { + account_id, + code_id, + init_message: Message::new(init_msg)?, + }, + vec![], + env, + )?; + Ok(()) +} diff --git a/crates/app/sdk/core/src/runtime_api.rs b/crates/app/sdk/core/src/runtime_api.rs index 2495104..d1edf4b 100644 --- a/crates/app/sdk/core/src/runtime_api.rs +++ b/crates/app/sdk/core/src/runtime_api.rs @@ -3,7 +3,7 @@ use crate::{AccountId, InvokableMessage, InvokeRequest, InvokeResponse}; use borsh::{BorshDeserialize, BorshSerialize}; pub const ACCOUNT_IDENTIFIER_PREFIX: u8 = 0; pub const ACCOUNT_IDENTIFIER_SINGLETON_PREFIX: u8 = 1; -pub const RUNTIME_ACCOUNT_ID: AccountId = AccountId(0); +pub const RUNTIME_ACCOUNT_ID: AccountId = AccountId::from_bytes([0u8; 32]); /// Storage key for consensus parameters. /// This is a well-known key that nodes read to validate blocks during sync. @@ -26,6 +26,21 @@ pub struct CreateAccountResponse { pub init_response: Message, } +#[derive(BorshDeserialize, BorshSerialize, Clone)] +pub struct RegisterAccountAtIdRequest { + pub account_id: AccountId, + pub code_id: String, + pub init_message: Message, +} + +impl InvokableMessage for RegisterAccountAtIdRequest { + const FUNCTION_IDENTIFIER: u64 = 2; + const FUNCTION_IDENTIFIER_NAME: &'static str = "register_account_at_id"; +} + +#[derive(BorshDeserialize, BorshSerialize, Clone)] +pub struct RegisterAccountAtIdResponse {} + #[derive(BorshDeserialize, BorshSerialize, Clone)] pub struct MigrateRequest { pub account_id: AccountId, @@ -34,7 +49,7 @@ pub struct MigrateRequest { } impl InvokableMessage for MigrateRequest { - const FUNCTION_IDENTIFIER: u64 = 2; + const FUNCTION_IDENTIFIER: u64 = 3; const FUNCTION_IDENTIFIER_NAME: &'static str = "migrate"; } diff --git a/crates/app/sdk/core/src/storage_api.rs b/crates/app/sdk/core/src/storage_api.rs index e0aa2fd..31f7a6c 100644 --- a/crates/app/sdk/core/src/storage_api.rs +++ b/crates/app/sdk/core/src/storage_api.rs @@ -1,7 +1,7 @@ use crate::{AccountId, InvokableMessage, Message}; use borsh::{BorshDeserialize, BorshSerialize}; -pub const STORAGE_ACCOUNT_ID: AccountId = AccountId(1); +pub const STORAGE_ACCOUNT_ID: AccountId = AccountId::new(1); #[derive(BorshDeserialize, BorshSerialize, Clone)] pub struct StorageGetRequest { diff --git a/crates/app/sdk/standards/evolve_authentication/src/lib.rs b/crates/app/sdk/standards/evolve_authentication/src/lib.rs index 5b77135..2058114 100644 --- a/crates/app/sdk/standards/evolve_authentication/src/lib.rs +++ b/crates/app/sdk/standards/evolve_authentication/src/lib.rs @@ -42,8 +42,9 @@ impl TxV for AuthenticationTxValidator { fn validate_tx(&self, tx: &T, env: &mut dyn Environment) -> SdkResult<()> { + let sender_account = tx.resolve_sender_account(env)?; // trigger authentication - auth_interface::AuthenticationInterfaceRef::new(tx.sender()) + auth_interface::AuthenticationInterfaceRef::new(sender_account) .authenticate(tx.authentication_payload()?, env) .map_err(|e| { if e == ERR_UNKNOWN_FUNCTION { diff --git a/crates/app/sdk/stf_traits/src/lib.rs b/crates/app/sdk/stf_traits/src/lib.rs index 3bd12d3..71abfd9 100644 --- a/crates/app/sdk/stf_traits/src/lib.rs +++ b/crates/app/sdk/stf_traits/src/lib.rs @@ -24,10 +24,35 @@ pub trait Transaction { fn funds(&self) -> &[FungibleAsset]; fn compute_identifier(&self) -> [u8; 32]; + /// Resolve the canonical sender account ID in the current state context. + /// + /// Default behavior is the static sender embedded in the transaction. + /// ETH transactions can override this to use address->account registry lookup. + fn resolve_sender_account(&self, _env: &mut dyn Environment) -> SdkResult { + Ok(self.sender()) + } + + /// Resolve the canonical recipient account ID in the current state context. + /// + /// Default behavior is the static recipient embedded in the transaction. + fn resolve_recipient_account(&self, _env: &mut dyn Environment) -> SdkResult { + Ok(self.recipient()) + } + /// Optional sender bootstrap primitive for STF account auto-registration. fn sender_bootstrap(&self) -> Option { None } + + /// Optional original 20-byte sender address (for ETH-compatible indexing). + fn sender_eth_address(&self) -> Option<[u8; 20]> { + None + } + + /// Optional original 20-byte recipient address (for ETH-compatible indexing). + fn recipient_eth_address(&self) -> Option<[u8; 20]> { + None + } } /// Provides the message payload used for account-level authentication. diff --git a/crates/app/sdk/testing/src/lib.rs b/crates/app/sdk/testing/src/lib.rs index fcdcadb..2989aca 100644 --- a/crates/app/sdk/testing/src/lib.rs +++ b/crates/app/sdk/testing/src/lib.rs @@ -111,7 +111,7 @@ impl MockEnv { StorageSetRequest::FUNCTION_IDENTIFIER => { let storage_set: StorageSetRequest = request.get()?; - let mut key = self.whoami.as_bytes(); + let mut key = self.whoami.as_bytes().to_vec(); key.extend(storage_set.key); self.state.insert(key, storage_set.value.as_vec()?); @@ -120,7 +120,7 @@ impl MockEnv { } StorageRemoveRequest::FUNCTION_IDENTIFIER => { let storage_remove: StorageRemoveRequest = request.get()?; - let mut key = self.whoami.as_bytes(); + let mut key = self.whoami.as_bytes().to_vec(); key.extend(storage_remove.key); self.state.remove(&key); Ok(InvokeResponse::new(&StorageRemoveResponse {})?) @@ -134,7 +134,7 @@ impl MockEnv { StorageGetRequest::FUNCTION_IDENTIFIER => { let storage_get: StorageGetRequest = request.get()?; - let mut key = storage_get.account_id.as_bytes(); + let mut key = storage_get.account_id.as_bytes().to_vec(); key.extend(storage_get.key); let value = self.state.get(&key).cloned(); diff --git a/crates/app/stf/src/handlers.rs b/crates/app/stf/src/handlers.rs index d161fc6..7a3a735 100644 --- a/crates/app/stf/src/handlers.rs +++ b/crates/app/stf/src/handlers.rs @@ -11,7 +11,8 @@ use crate::errors::{ERR_ACCOUNT_DOES_NOT_EXIST, ERR_CODE_NOT_FOUND, ERR_SAME_COD use crate::invoker::Invoker; use crate::runtime_api_impl; use evolve_core::runtime_api::{ - CreateAccountRequest, CreateAccountResponse, MigrateRequest, RUNTIME_ACCOUNT_ID, + CreateAccountRequest, CreateAccountResponse, MigrateRequest, RegisterAccountAtIdRequest, + RegisterAccountAtIdResponse, RUNTIME_ACCOUNT_ID, }; use evolve_core::storage_api::{ StorageGetRequest, StorageGetResponse, StorageRemoveRequest, StorageRemoveResponse, @@ -56,6 +57,11 @@ pub fn handle_system_exec( }; Ok(InvokeResponse::new(&resp)?) } + RegisterAccountAtIdRequest::FUNCTION_IDENTIFIER => { + let req: RegisterAccountAtIdRequest = request.get()?; + invoker.register_account_at_id(req.account_id, &req.code_id, req.init_message)?; + Ok(InvokeResponse::new(&RegisterAccountAtIdResponse {})?) + } MigrateRequest::FUNCTION_IDENTIFIER => { // exec on behalf of runtime the migration request, runtime has the money // so runtime needs to send the money to the account, so here we simulate that @@ -114,7 +120,7 @@ pub fn handle_storage_exec( StorageSetRequest::FUNCTION_IDENTIFIER => { let storage_set: StorageSetRequest = request.get()?; - let mut key = invoker.whoami.as_bytes(); + let mut key = invoker.whoami.as_bytes().to_vec(); key.extend(storage_set.key); // increase gas costs @@ -127,7 +133,7 @@ pub fn handle_storage_exec( } StorageRemoveRequest::FUNCTION_IDENTIFIER => { let storage_remove: StorageRemoveRequest = request.get()?; - let mut key = invoker.whoami.as_bytes(); + let mut key = invoker.whoami.as_bytes().to_vec(); key.extend(storage_remove.key); invoker.gas_counter.consume_remove_gas(&key)?; invoker.storage.remove(&key)?; @@ -145,7 +151,7 @@ pub fn handle_storage_query( StorageGetRequest::FUNCTION_IDENTIFIER => { let storage_get: StorageGetRequest = request.get()?; - let mut key = storage_get.account_id.as_bytes(); + let mut key = storage_get.account_id.as_bytes().to_vec(); key.extend(storage_get.key); let value = invoker.storage.get(&key)?; diff --git a/crates/app/stf/src/lib.rs b/crates/app/stf/src/lib.rs index f7dec10..fbc3a7d 100644 --- a/crates/app/stf/src/lib.rs +++ b/crates/app/stf/src/lib.rs @@ -348,7 +348,7 @@ mod model_tests { } fn account_storage_key(account: AccountId, key: &[u8]) -> Vec { - let mut out = account.as_bytes(); + let mut out = account.as_bytes().to_vec(); out.extend_from_slice(key); out } @@ -1250,6 +1250,78 @@ where state.pop_events() } + fn tx_error_result( + state: &mut ExecutionState<'_, S>, + gas_counter: &GasCounter, + err: evolve_core::ErrorCode, + ) -> TxResult { + TxResult { + events: state.pop_events(), + gas_used: gas_counter.gas_used(), + response: Err(err), + } + } + + fn resolve_sender_phase<'a, S: ReadonlyKV + 'a, A: AccountsCodeStorage + 'a>( + &self, + state: &mut ExecutionState<'a, S>, + codes: &'a A, + gas_counter: &mut GasCounter, + tx: &Tx, + block: BlockContext, + ) -> SdkResult { + let mut resolve_ctx = Invoker::new_for_begin_block(state, codes, gas_counter, block); + tx.resolve_sender_account(&mut resolve_ctx) + } + + fn bootstrap_sender_phase<'a, S: ReadonlyKV + 'a, A: AccountsCodeStorage + 'a>( + &self, + state: &mut ExecutionState<'a, S>, + codes: &'a A, + gas_counter: &mut GasCounter, + tx: &Tx, + resolved_sender: AccountId, + block: BlockContext, + ) -> SdkResult<()> { + let Some(bootstrap) = tx.sender_bootstrap() else { + return Ok(()); + }; + let mut reg_ctx = Invoker::new_for_begin_block(state, codes, gas_counter, block); + reg_ctx.register_account_at_id( + resolved_sender, + bootstrap.account_code_id, + bootstrap.init_message, + )?; + drop(reg_ctx); + state.pop_events(); + Ok(()) + } + + fn validate_tx_phase<'s, 'b, S: ReadonlyKV + 's, A: AccountsCodeStorage + 'b>( + &self, + tx: &Tx, + ctx: &mut Invoker<'s, 'b, S, A>, + ) -> SdkResult<()> { + self.tx_validator.validate_tx(tx, ctx) + } + + fn execute_tx_phase<'s, 'b, S: ReadonlyKV + 's, A: AccountsCodeStorage + 'b>( + &self, + tx: &Tx, + ctx: Invoker<'s, 'b, S, A>, + resolved_sender: AccountId, + ) -> SdkResult { + let mut exec_ctx = ctx.into_new_exec(resolved_sender); + let recipient = tx.resolve_recipient_account(&mut exec_ctx)?; + let response = exec_ctx.do_exec(recipient, tx.request(), tx.funds().to_vec()); + let post_tx_result = + PostTx::after_tx_executed(tx, exec_ctx.gas_used(), &response, &mut exec_ctx); + match post_tx_result { + Ok(()) => response, + Err(post_tx_err) => Err(post_tx_err), + } + } + fn apply_tx<'a, S: ReadonlyKV + 'a, A: AccountsCodeStorage + 'a>( &self, state: &mut ExecutionState<'a, S>, @@ -1258,84 +1330,37 @@ where gas_config: StorageGasConfig, block: BlockContext, ) -> TxResult { - // create a finite gas counter for the full tx lifecycle, - // including optional sender bootstrap registration. let mut gas_counter = GasCounter::Finite { gas_limit: tx.gas_limit(), gas_used: 0, storage_gas_config: gas_config, }; - // Auto-register sender when transaction provides a bootstrap primitive. - if let Some(bootstrap) = tx.sender_bootstrap() { - let mut reg_ctx = Invoker::new_for_begin_block(state, codes, &mut gas_counter, block); - if let Err(err) = reg_ctx.register_account_at_id( - tx.sender(), - bootstrap.account_code_id, - bootstrap.init_message, - ) { - drop(reg_ctx); - let gas_used = gas_counter.gas_used(); - let events = state.pop_events(); - return TxResult { - events, - gas_used, - response: Err(err), - }; - } - drop(reg_ctx); - state.pop_events(); - } + let resolved_sender = + match self.resolve_sender_phase(state, codes, &mut gas_counter, tx, block) { + Ok(sender) => sender, + Err(err) => return Self::tx_error_result(state, &gas_counter, err), + }; - // NOTE: Transaction validation and execution are atomic - they share the same - // ExecutionState throughout the process. The state cannot change between - // validation and execution because: - // 1. The same ExecutionState instance is used for both phases - // 2. into_new_exec() preserves the storage, maintaining consistency - // 3. Transactions are processed sequentially, not concurrently - - // create validation context - let mut ctx = Invoker::new_for_validate_tx(state, codes, &mut gas_counter, tx, block); - // do tx validation; we do not swap invoker - match self.tx_validator.validate_tx(tx, &mut ctx) { - Ok(()) => (), - Err(err) => { - drop(ctx); - let gas_used = gas_counter.gas_used(); - let events = state.pop_events(); - return TxResult { - events, - gas_used, - response: Err(err), - }; - } + if let Err(err) = + self.bootstrap_sender_phase(state, codes, &mut gas_counter, tx, resolved_sender, block) + { + return Self::tx_error_result(state, &gas_counter, err); } - // exec tx - transforms validation context to execution context - // while preserving the same underlying state - let mut ctx = ctx.into_new_exec(tx.sender()); - - let response = ctx.do_exec(tx.recipient(), tx.request(), tx.funds().to_vec()); - - // Run post-tx handler (e.g., for fee collection, logging, etc.) - // The handler can observe the result and make additional state changes - let post_tx_result = PostTx::after_tx_executed(tx, ctx.gas_used(), &response, &mut ctx); - - drop(ctx); - let gas_used = gas_counter.gas_used(); - let events = state.pop_events(); + let mut validation_ctx = + Invoker::new_for_validate_tx(state, codes, &mut gas_counter, tx, block); + if let Err(err) = self.validate_tx_phase(tx, &mut validation_ctx) { + drop(validation_ctx); + return Self::tx_error_result(state, &gas_counter, err); + } - // If post-tx handler fails, the tx response becomes the error - // This allows the handler to reject transactions after execution - let final_response = match post_tx_result { - Ok(()) => response, - Err(post_tx_err) => Err(post_tx_err), - }; + let response = self.execute_tx_phase(tx, validation_ctx, resolved_sender); TxResult { - events, - gas_used, - response: final_response, + events: state.pop_events(), + gas_used: gas_counter.gas_used(), + response, } } diff --git a/crates/app/tx/eth/src/eoa_registry.rs b/crates/app/tx/eth/src/eoa_registry.rs new file mode 100644 index 0000000..0115cad --- /dev/null +++ b/crates/app/tx/eth/src/eoa_registry.rs @@ -0,0 +1,250 @@ +use crate::error::ERR_ADDRESS_ACCOUNT_CONFLICT; +use crate::traits::{derive_eth_eoa_account_id, derive_runtime_contract_address}; +use alloy_primitives::Address; +use evolve_core::low_level::{exec_account, query_account, register_account_at_id}; +use evolve_core::runtime_api::RUNTIME_ACCOUNT_ID; +use evolve_core::storage_api::{ + StorageGetRequest, StorageGetResponse, StorageSetRequest, StorageSetResponse, + STORAGE_ACCOUNT_ID, +}; +use evolve_core::{AccountId, Environment, EnvironmentQuery, Message, ReadonlyKV, SdkResult}; + +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/"; +const ETH_EOA_CODE_ID: &str = "EthEoaAccount"; + +fn addr_to_id_key(address: Address) -> Vec { + let mut key = Vec::with_capacity(EOA_ADDR_TO_ID_PREFIX.len() + 20); + key.extend_from_slice(EOA_ADDR_TO_ID_PREFIX); + key.extend_from_slice(address.as_slice()); + key +} + +fn id_to_addr_key(account_id: AccountId) -> Vec { + let account_bytes = account_id.as_bytes(); + let mut key = Vec::with_capacity(EOA_ID_TO_ADDR_PREFIX.len() + account_bytes.len()); + key.extend_from_slice(EOA_ID_TO_ADDR_PREFIX); + key.extend_from_slice(&account_bytes); + key +} + +fn contract_addr_to_id_key(address: Address) -> Vec { + let mut key = Vec::with_capacity(CONTRACT_ADDR_TO_ID_PREFIX.len() + 20); + key.extend_from_slice(CONTRACT_ADDR_TO_ID_PREFIX); + key.extend_from_slice(address.as_slice()); + key +} + +fn contract_id_to_addr_key(account_id: AccountId) -> Vec { + let account_bytes = account_id.as_bytes(); + let mut key = Vec::with_capacity(CONTRACT_ID_TO_ADDR_PREFIX.len() + account_bytes.len()); + key.extend_from_slice(CONTRACT_ID_TO_ADDR_PREFIX); + key.extend_from_slice(&account_bytes); + key +} + +fn decode_account_id(message: Message) -> SdkResult { + message.get::() +} + +pub fn lookup_account_id_in_env( + address: Address, + env: &mut dyn EnvironmentQuery, +) -> SdkResult> { + let response: StorageGetResponse = query_account( + STORAGE_ACCOUNT_ID, + &StorageGetRequest { + account_id: RUNTIME_ACCOUNT_ID, + key: addr_to_id_key(address), + }, + env, + )?; + match response.value { + Some(raw) => Ok(Some(decode_account_id(raw)?)), + None => Ok(None), + } +} + +pub fn lookup_address_in_env( + account_id: AccountId, + env: &mut dyn EnvironmentQuery, +) -> SdkResult> { + let response: StorageGetResponse = query_account( + STORAGE_ACCOUNT_ID, + &StorageGetRequest { + account_id: RUNTIME_ACCOUNT_ID, + key: id_to_addr_key(account_id), + }, + env, + )?; + match response.value { + Some(raw) => { + let bytes = raw.get::<[u8; 20]>()?; + Ok(Some(Address::from(bytes))) + } + None => Ok(None), + } +} + +pub fn lookup_account_id_in_storage( + storage: &S, + address: Address, +) -> SdkResult> { + let mut full_key = RUNTIME_ACCOUNT_ID.as_bytes().to_vec(); + full_key.extend_from_slice(&addr_to_id_key(address)); + match storage.get(&full_key)? { + Some(raw) => Ok(Some(decode_account_id(Message::from_bytes(raw))?)), + None => Ok(None), + } +} + +pub fn lookup_contract_account_id_in_env( + address: Address, + env: &mut dyn EnvironmentQuery, +) -> SdkResult> { + let response: StorageGetResponse = query_account( + STORAGE_ACCOUNT_ID, + &StorageGetRequest { + account_id: RUNTIME_ACCOUNT_ID, + key: contract_addr_to_id_key(address), + }, + env, + )?; + match response.value { + Some(raw) => Ok(Some(decode_account_id(raw)?)), + None => Ok(None), + } +} + +pub fn lookup_contract_account_id_in_storage( + storage: &S, + address: Address, +) -> SdkResult> { + let mut full_key = RUNTIME_ACCOUNT_ID.as_bytes().to_vec(); + full_key.extend_from_slice(&contract_addr_to_id_key(address)); + match storage.get(&full_key)? { + Some(raw) => Ok(Some(decode_account_id(Message::from_bytes(raw))?)), + None => Ok(None), + } +} + +pub fn lookup_address_in_storage( + storage: &S, + account_id: AccountId, +) -> SdkResult> { + let mut full_key = RUNTIME_ACCOUNT_ID.as_bytes().to_vec(); + full_key.extend_from_slice(&id_to_addr_key(account_id)); + match storage.get(&full_key)? { + Some(raw) => { + let bytes = Message::from_bytes(raw).get::<[u8; 20]>()?; + Ok(Some(Address::from(bytes))) + } + None => Ok(None), + } +} + +fn set_mapping( + address: Address, + account_id: AccountId, + env: &mut dyn Environment, +) -> SdkResult<()> { + if let Some(existing) = lookup_account_id_in_env(address, env)? { + if existing != account_id { + return Err(ERR_ADDRESS_ACCOUNT_CONFLICT); + } + } + if let Some(existing_addr) = lookup_address_in_env(account_id, env)? { + if existing_addr != address { + return Err(ERR_ADDRESS_ACCOUNT_CONFLICT); + } + } + + let _resp: StorageSetResponse = exec_account( + STORAGE_ACCOUNT_ID, + &StorageSetRequest { + key: addr_to_id_key(address), + value: Message::new(&account_id)?, + }, + vec![], + env, + )?; + let _resp: StorageSetResponse = exec_account( + STORAGE_ACCOUNT_ID, + &StorageSetRequest { + key: id_to_addr_key(account_id), + value: Message::new(&address.into_array())?, + }, + vec![], + env, + )?; + Ok(()) +} + +pub fn resolve_or_create_eoa_account( + address: Address, + env: &mut dyn Environment, +) -> SdkResult { + if let Some(account_id) = lookup_account_id_in_env(address, env)? { + return Ok(account_id); + } + + let account_id = derive_eth_eoa_account_id(address); + register_account_at_id( + account_id, + ETH_EOA_CODE_ID.to_string(), + &address.into_array(), + env, + )?; + + set_mapping(address, account_id, env)?; + Ok(account_id) +} + +pub fn register_runtime_contract_account( + account_id: AccountId, + env: &mut dyn Environment, +) -> SdkResult
{ + let address = derive_runtime_contract_address(account_id); + if let Some(existing) = lookup_contract_account_id_in_env(address, env)? { + if existing != account_id { + return Err(ERR_ADDRESS_ACCOUNT_CONFLICT); + } + } + + let response: StorageGetResponse = query_account( + STORAGE_ACCOUNT_ID, + &StorageGetRequest { + account_id: RUNTIME_ACCOUNT_ID, + key: contract_id_to_addr_key(account_id), + }, + env, + )?; + if let Some(raw) = response.value { + let existing = raw.get::<[u8; 20]>()?; + if existing != address.into_array() { + return Err(ERR_ADDRESS_ACCOUNT_CONFLICT); + } + } + + let _resp: StorageSetResponse = exec_account( + STORAGE_ACCOUNT_ID, + &StorageSetRequest { + key: contract_addr_to_id_key(address), + value: Message::new(&account_id)?, + }, + vec![], + env, + )?; + let _resp: StorageSetResponse = exec_account( + STORAGE_ACCOUNT_ID, + &StorageSetRequest { + key: contract_id_to_addr_key(account_id), + value: Message::new(&address.into_array())?, + }, + vec![], + env, + )?; + Ok(address) +} diff --git a/crates/app/tx/eth/src/error.rs b/crates/app/tx/eth/src/error.rs index 5259154..0ceb3c4 100644 --- a/crates/app/tx/eth/src/error.rs +++ b/crates/app/tx/eth/src/error.rs @@ -30,3 +30,15 @@ define_error!( 0x1D, "gas limit exceeds block limit" ); + +// System/runtime lookup errors (0x50-0x5F range) +define_error!( + ERR_RECIPIENT_REQUIRED, + 0x50, + "recipient required (contract creation not supported yet)" +); +define_error!( + ERR_ADDRESS_ACCOUNT_CONFLICT, + 0x5A, + "address/account registry conflict" +); diff --git a/crates/app/tx/eth/src/lib.rs b/crates/app/tx/eth/src/lib.rs index 09d4411..af88ccc 100644 --- a/crates/app/tx/eth/src/lib.rs +++ b/crates/app/tx/eth/src/lib.rs @@ -35,6 +35,7 @@ pub mod decoder; pub mod envelope; +pub mod eoa_registry; pub mod error; pub mod ethereum; pub mod gateway; @@ -45,9 +46,18 @@ pub mod verifier; // Re-export main types pub use decoder::TypedTxDecoder; pub use envelope::{tx_type, TxEnvelope}; +pub use eoa_registry::{ + lookup_account_id_in_env, lookup_account_id_in_storage, lookup_address_in_env, + lookup_address_in_storage, lookup_contract_account_id_in_env, + lookup_contract_account_id_in_storage, register_runtime_contract_account, + resolve_or_create_eoa_account, +}; pub use error::*; pub use ethereum::{SignedEip1559Tx, SignedLegacyTx}; pub use gateway::{EthGateway, GatewayError}; pub use mempool::TxContext; -pub use traits::{account_id_to_address, address_to_account_id, TypedTransaction}; +pub use traits::{ + derive_eth_eoa_account_id, derive_runtime_contract_account_id, derive_runtime_contract_address, + derive_system_account_id, TypedTransaction, +}; pub use verifier::{EcdsaVerifier, SignatureVerifierRegistry}; diff --git a/crates/app/tx/eth/src/mempool.rs b/crates/app/tx/eth/src/mempool.rs index f24760e..d602fbc 100644 --- a/crates/app/tx/eth/src/mempool.rs +++ b/crates/app/tx/eth/src/mempool.rs @@ -13,12 +13,16 @@ use alloy_primitives::{Address, B256}; use evolve_core::encoding::{Decodable, Encodable}; -use evolve_core::{AccountId, FungibleAsset, InvokeRequest, Message, SdkResult}; +use evolve_core::{AccountId, Environment, FungibleAsset, InvokeRequest, Message, SdkResult}; use evolve_mempool::{GasPriceOrdering, MempoolTx, SenderKey}; -use evolve_stf_traits::{AuthenticationPayload, SenderBootstrap, Transaction}; +use evolve_stf_traits::{AuthenticationPayload, Transaction}; use crate::envelope::TxEnvelope; -use crate::traits::{address_to_account_id, TypedTransaction}; +use crate::eoa_registry::{ + lookup_account_id_in_env, lookup_contract_account_id_in_env, resolve_or_create_eoa_account, +}; +use crate::error::ERR_RECIPIENT_REQUIRED; +use crate::traits::TypedTransaction; /// A verified transaction ready for mempool storage. /// @@ -28,10 +32,6 @@ use crate::traits::{address_to_account_id, TypedTransaction}; pub struct TxContext { /// The original Ethereum transaction envelope. envelope: TxEnvelope, - /// Sender account ID (derived from address). - sender_id: AccountId, - /// Recipient account ID (derived from address). - recipient_id: AccountId, /// The invoke request to execute (derived by evolve_tx). invoke_request: InvokeRequest, /// Gas price for ordering (effective gas price). @@ -43,9 +43,10 @@ impl TxContext { /// /// Returns `None` if the transaction has no recipient (contract creation). pub fn new(envelope: TxEnvelope, base_fee: u128) -> Option { - let sender_id = address_to_account_id(envelope.sender()); - let recipient = envelope.to()?; - let recipient_id = address_to_account_id(recipient); + // TODO(vm): when EVM contract creation is supported, allow `to == None` + // and route create-transactions through deployment execution instead of + // rejecting them at mempool decode time. + envelope.to()?; let invoke_request = envelope.to_invoke_requests().into_iter().next()?; @@ -54,8 +55,6 @@ impl TxContext { Some(Self { envelope, - sender_id, - recipient_id, invoke_request, effective_gas_price, }) @@ -114,11 +113,29 @@ impl MempoolTx for TxContext { impl Transaction for TxContext { fn sender(&self) -> AccountId { - self.sender_id + AccountId::invalid() + } + + fn resolve_sender_account(&self, env: &mut dyn Environment) -> SdkResult { + resolve_or_create_eoa_account(self.sender_address(), env) } fn recipient(&self) -> AccountId { - self.recipient_id + AccountId::invalid() + } + + fn resolve_recipient_account(&self, env: &mut dyn Environment) -> SdkResult { + // TODO(vm): contract creation currently has no recipient and is rejected. + // Once VM deployment is supported, this branch should route to creation + // logic instead of returning recipient-required. + let to = self.envelope.to().ok_or(ERR_RECIPIENT_REQUIRED)?; + if let Some(account_id) = lookup_account_id_in_env(to, env)? { + return Ok(account_id); + } + if let Some(account_id) = lookup_contract_account_id_in_env(to, env)? { + return Ok(account_id); + } + resolve_or_create_eoa_account(to, env) } fn request(&self) -> &InvokeRequest { @@ -138,18 +155,19 @@ impl Transaction for TxContext { self.envelope.tx_hash().0 } - fn sender_bootstrap(&self) -> Option { - let eth_address: [u8; 20] = self.sender_address().into(); - Some(SenderBootstrap { - account_code_id: "EthEoaAccount", - init_message: Message::new(ð_address).ok()?, - }) + fn sender_eth_address(&self) -> Option<[u8; 20]> { + Some(self.sender_address().into()) + } + + fn recipient_eth_address(&self) -> Option<[u8; 20]> { + self.envelope.to().map(Into::into) } } impl AuthenticationPayload for TxContext { fn authentication_payload(&self) -> SdkResult { - Message::new(&self.sender_id) + let sender: [u8; 20] = self.sender_address().into(); + Message::new(&sender) } } @@ -164,9 +182,7 @@ impl Decodable for TxContext { let envelope = TxEnvelope::decode(bytes)?; // Use base_fee of 0 for decoding - the effective gas price will be // recalculated if needed when the transaction is added to a mempool - TxContext::new(envelope, 0).ok_or_else(|| { - evolve_core::ErrorCode::new(0x50) // Contract creation not supported - }) + TxContext::new(envelope, 0).ok_or(ERR_RECIPIENT_REQUIRED) } } @@ -190,4 +206,177 @@ fn calculate_effective_gas_price(envelope: &TxEnvelope, base_fee: u128) -> u128 } #[cfg(test)] -mod tests {} +mod tests { + use super::*; + use crate::eoa_registry::{lookup_account_id_in_env, register_runtime_contract_account}; + use crate::traits::{derive_eth_eoa_account_id, derive_runtime_contract_account_id}; + use alloy_consensus::{SignableTransaction, TxLegacy}; + use alloy_primitives::{Bytes, PrimitiveSignature, TxKind, U256}; + use evolve_core::runtime_api::{ + RegisterAccountAtIdRequest, RegisterAccountAtIdResponse, ACCOUNT_IDENTIFIER_PREFIX, + RUNTIME_ACCOUNT_ID, + }; + use evolve_core::storage_api::{ + StorageGetRequest, StorageGetResponse, StorageSetRequest, StorageSetResponse, + STORAGE_ACCOUNT_ID, + }; + use evolve_core::{ + BlockContext, EnvironmentQuery, FungibleAsset, InvokableMessage, InvokeResponse, + ERR_UNKNOWN_FUNCTION, + }; + use k256::ecdsa::{signature::hazmat::PrehashSigner, SigningKey}; + use rand::rngs::OsRng; + use std::collections::BTreeMap; + + #[derive(Default)] + struct MockEnv { + funds: Vec, + storage: BTreeMap, Vec>, + } + + impl MockEnv { + fn account_scoped_key(account_id: AccountId, key: &[u8]) -> Vec { + let mut full = account_id.as_bytes().to_vec(); + full.extend_from_slice(key); + full + } + } + + impl EnvironmentQuery for MockEnv { + fn whoami(&self) -> AccountId { + RUNTIME_ACCOUNT_ID + } + + fn sender(&self) -> AccountId { + AccountId::invalid() + } + + fn funds(&self) -> &[FungibleAsset] { + &self.funds + } + + fn block(&self) -> BlockContext { + BlockContext::default() + } + + fn do_query( + &mut self, + to: AccountId, + data: &InvokeRequest, + ) -> evolve_core::SdkResult { + if to != STORAGE_ACCOUNT_ID { + return Err(ERR_UNKNOWN_FUNCTION); + } + match data.function() { + StorageGetRequest::FUNCTION_IDENTIFIER => { + let req: StorageGetRequest = data.get()?; + let key = Self::account_scoped_key(req.account_id, &req.key); + let value = self + .storage + .get(&key) + .cloned() + .map(evolve_core::Message::from_bytes); + InvokeResponse::new(&StorageGetResponse { value }) + } + _ => Err(ERR_UNKNOWN_FUNCTION), + } + } + } + + impl Environment for MockEnv { + fn do_exec( + &mut self, + to: AccountId, + data: &InvokeRequest, + _funds: Vec, + ) -> evolve_core::SdkResult { + if to == STORAGE_ACCOUNT_ID && data.function() == StorageSetRequest::FUNCTION_IDENTIFIER + { + let req: StorageSetRequest = data.get()?; + let key = Self::account_scoped_key(RUNTIME_ACCOUNT_ID, &req.key); + self.storage.insert(key, req.value.into_bytes()?); + return InvokeResponse::new(&StorageSetResponse {}); + } + + if to == RUNTIME_ACCOUNT_ID + && data.function() == RegisterAccountAtIdRequest::FUNCTION_IDENTIFIER + { + let req: RegisterAccountAtIdRequest = data.get()?; + let mut key = vec![ACCOUNT_IDENTIFIER_PREFIX]; + key.extend_from_slice(&req.account_id.as_bytes()); + self.storage + .insert(key, evolve_core::Message::new(&req.code_id)?.into_bytes()?); + return InvokeResponse::new(&RegisterAccountAtIdResponse {}); + } + + Err(ERR_UNKNOWN_FUNCTION) + } + + fn emit_event(&mut self, _name: &str, _data: &[u8]) -> evolve_core::SdkResult<()> { + Ok(()) + } + + fn unique_id(&mut self) -> evolve_core::SdkResult<[u8; 32]> { + Ok([0u8; 32]) + } + } + + fn sign_hash(signing_key: &SigningKey, hash: B256) -> PrimitiveSignature { + let (sig, recovery_id) = signing_key.sign_prehash(hash.as_ref()).unwrap(); + let r = U256::from_be_slice(&sig.r().to_bytes()); + let s = U256::from_be_slice(&sig.s().to_bytes()); + let v = recovery_id.is_y_odd(); + PrimitiveSignature::new(r, s, v) + } + + fn build_tx_context(to: Address) -> TxContext { + let signing_key = SigningKey::random(&mut OsRng); + let tx = TxLegacy { + chain_id: Some(1), + nonce: 0, + gas_price: 1_000_000_000, + gas_limit: 21_000, + to: TxKind::Call(to), + value: U256::ZERO, + input: Bytes::new(), + }; + let signature = sign_hash(&signing_key, tx.signature_hash()); + let signed = tx.into_signed(signature); + let mut encoded = Vec::new(); + signed.rlp_encode(&mut encoded); + + let envelope = TxEnvelope::decode(&encoded).expect("decode signed tx"); + TxContext::new(envelope, 0).expect("construct tx context") + } + + #[test] + fn resolve_recipient_account_creates_mapping_for_unseen_eoa() { + let recipient = Address::repeat_byte(0xAB); + let tx = build_tx_context(recipient); + let mut env = MockEnv::default(); + + let resolved = tx + .resolve_recipient_account(&mut env) + .expect("resolve recipient"); + assert_eq!(resolved, derive_eth_eoa_account_id(recipient)); + + let mapped = lookup_account_id_in_env(recipient, &mut env) + .expect("lookup recipient") + .expect("recipient mapping exists"); + assert_eq!(mapped, resolved); + } + + #[test] + fn resolve_recipient_account_prefers_existing_contract_mapping() { + let mut env = MockEnv::default(); + let contract_id = derive_runtime_contract_account_id(b"mempool-test-contract"); + let contract_address = + register_runtime_contract_account(contract_id, &mut env).expect("register contract"); + + let tx = build_tx_context(contract_address); + let resolved = tx + .resolve_recipient_account(&mut env) + .expect("resolve recipient"); + assert_eq!(resolved, contract_id); + } +} diff --git a/crates/app/tx/eth/src/traits.rs b/crates/app/tx/eth/src/traits.rs index cc4fdf7..93bcb53 100644 --- a/crates/app/tx/eth/src/traits.rs +++ b/crates/app/tx/eth/src/traits.rs @@ -1,145 +1,100 @@ -//! Core traits for typed transactions. +//! Core traits and canonical identity derivation for typed transactions. -use alloy_primitives::{Address, B256}; +use alloy_primitives::{keccak256, Address, B256}; use evolve_core::{AccountId, InvokeRequest}; +const DOMAIN_EOA_ETH_V1: &[u8] = b"eoa:eth:v1"; +const DOMAIN_CONTRACT_RUNTIME_V1: &[u8] = b"contract:runtime:v1"; +const DOMAIN_SYSTEM_V1: &[u8] = b"system:v1"; +const DOMAIN_CONTRACT_ADDR_RUNTIME_V1: &[u8] = b"contract:addr:runtime:v1"; + +/// Derive a canonical ETH EOA account ID from a 20-byte address. +pub fn derive_eth_eoa_account_id(addr: Address) -> AccountId { + derive_account_id(DOMAIN_EOA_ETH_V1, addr.as_slice()) +} + +/// Derive a canonical runtime contract account ID from creation entropy. +pub fn derive_runtime_contract_account_id(creation_entropy: &[u8]) -> AccountId { + derive_account_id(DOMAIN_CONTRACT_RUNTIME_V1, creation_entropy) +} + +/// Derive a canonical system/module account ID from a module name. +pub fn derive_system_account_id(module_name: &str) -> AccountId { + derive_account_id(DOMAIN_SYSTEM_V1, module_name.as_bytes()) +} + +/// Deterministic runtime contract address used for explicit registry mappings. +pub fn derive_runtime_contract_address(account_id: AccountId) -> Address { + let mut input = Vec::with_capacity(DOMAIN_CONTRACT_ADDR_RUNTIME_V1.len() + 32); + input.extend_from_slice(DOMAIN_CONTRACT_ADDR_RUNTIME_V1); + input.extend_from_slice(&account_id.as_bytes()); + let digest = keccak256(&input); + Address::from_slice(&digest.as_slice()[12..32]) +} + +fn derive_account_id(domain_tag: &[u8], payload: &[u8]) -> AccountId { + let mut input = Vec::with_capacity(domain_tag.len() + payload.len()); + input.extend_from_slice(domain_tag); + input.extend_from_slice(payload); + AccountId::from_bytes(keccak256(&input).0) +} + /// Core trait that all transaction types must implement. -/// -/// This trait defines the common interface for all transaction types, -/// whether they are standard Ethereum transactions or custom Evolve types. pub trait TypedTransaction: Send + Sync { - /// Returns the EIP-2718 transaction type byte. - /// - /// - `0x00-0x7f`: Reserved for Ethereum standard types - /// - `0x80-0xff`: Available for custom/L2 types fn tx_type(&self) -> u8; - - /// Returns the sender address. - /// - /// For signed transactions, this is recovered from the signature. fn sender(&self) -> Address; - - /// Returns the transaction hash. fn tx_hash(&self) -> B256; - - /// Returns the gas limit for this transaction. fn gas_limit(&self) -> u64; - - /// Returns the chain ID for replay protection (EIP-155). - /// - /// Returns `None` for legacy transactions without EIP-155. fn chain_id(&self) -> Option; - - /// Returns the nonce for this transaction. fn nonce(&self) -> u64; - - /// Returns the recipient address, if any. - /// - /// Returns `None` for contract creation transactions. fn to(&self) -> Option
; - - /// Returns the value (in wei) being transferred. fn value(&self) -> alloy_primitives::U256; - - /// Returns the input data for this transaction. fn input(&self) -> &[u8]; - - /// Converts the transaction to internal InvokeRequest(s). - /// - /// Standard Ethereum transactions produce a single InvokeRequest. - /// Batch transactions may produce multiple. fn to_invoke_requests(&self) -> Vec; - /// Converts the sender Address to an AccountId. - /// - /// Uses the lower 128 bits of the address for compatibility. fn sender_account_id(&self) -> AccountId { - address_to_account_id(self.sender()) + derive_eth_eoa_account_id(self.sender()) } - /// Converts the recipient Address to an AccountId, if present. fn recipient_account_id(&self) -> Option { - self.to().map(address_to_account_id) + self.to().map(derive_eth_eoa_account_id) } } -/// Converts an Ethereum Address (20 bytes) to an AccountId (u128). -/// -/// Takes the last 16 bytes of the address. This mapping is reversible: -/// `address_to_account_id(account_id_to_address(id)) == id` -/// -/// This allows contract accounts to be addressed via Ethereum transactions. -/// The first 4 bytes of the address are discarded, so addresses that only -/// differ in those bytes will map to the same AccountId. -pub fn address_to_account_id(addr: Address) -> AccountId { - let bytes = addr.as_slice(); - let mut id_bytes = [0u8; 16]; - id_bytes.copy_from_slice(&bytes[4..]); - AccountId::new(u128::from_be_bytes(id_bytes)) -} - -/// Converts an AccountId to an Ethereum Address. -/// -/// Pads with zeros in the first 4 bytes. This is the inverse of `address_to_account_id`: -/// `address_to_account_id(account_id_to_address(id)) == id` -/// -/// For EOA addresses derived from public keys (which have random first 4 bytes), -/// this won't recover the original address. But for contract addresses that were -/// created from AccountIds, this is a perfect round-trip. -pub fn account_id_to_address(id: AccountId) -> Address { - let id_bytes = id.as_bytes(); - let mut addr_bytes = [0u8; 20]; - // Copy 16 bytes of AccountId to last 16 bytes of address - addr_bytes[4..20].copy_from_slice(&id_bytes[..16]); - Address::from_slice(&addr_bytes) -} - #[cfg(test)] mod tests { use super::*; #[test] - fn test_account_id_to_address_encodes_id_bytes() { - let id = AccountId::new(0x112233445566778899aabbccddeeff00); - let addr = account_id_to_address(id); - assert_eq!(&addr.as_slice()[4..], &id.as_bytes()[..16]); + fn test_eth_eoa_derivation_is_deterministic() { + let addr = Address::from([0x11; 20]); + assert_eq!( + derive_eth_eoa_account_id(addr), + derive_eth_eoa_account_id(addr) + ); } #[test] - fn test_account_id_address_round_trip() { - // For any AccountId, converting to address and back should give the same ID - let id = AccountId::new(0x112233445566778899aabbccddeeff00); - let addr = account_id_to_address(id); - let id_back = address_to_account_id(addr); - assert_eq!(id, id_back); - - // Test with various values - for i in [0u128, 1, u128::MAX, 0xdeadbeef, 12345678901234567890] { - let id = AccountId::new(i); - let addr = account_id_to_address(id); - let id_back = address_to_account_id(addr); - assert_eq!(id, id_back, "round-trip failed for id={}", i); - } + fn test_domains_are_separated() { + let payload = [0x22; 20]; + let eoa = derive_account_id(DOMAIN_EOA_ETH_V1, &payload); + let contract = derive_account_id(DOMAIN_CONTRACT_RUNTIME_V1, &payload); + assert_ne!(eoa, contract); } #[test] - fn test_address_to_account_id_first_4_bytes_ignored() { - // Addresses that only differ in the first 4 bytes map to the same AccountId. - // This is the trade-off for reversibility with account_id_to_address. - let mut bytes1 = [0u8; 20]; - let mut bytes2 = [0u8; 20]; - bytes1[0] = 0x01; - bytes2[0] = 0x02; - let addr1 = Address::from_slice(&bytes1); - let addr2 = Address::from_slice(&bytes2); - - // These collide - this is expected and documented behavior - assert_eq!(address_to_account_id(addr1), address_to_account_id(addr2)); + fn test_system_derivation() { + let a = derive_system_account_id("runtime"); + let b = derive_system_account_id("storage"); + assert_ne!(a, b); + } - // But addresses differing in the last 16 bytes do NOT collide - let mut bytes3 = [0u8; 20]; - bytes3[19] = 0x01; - let addr3 = Address::from_slice(&bytes3); - assert_ne!(address_to_account_id(addr1), address_to_account_id(addr3)); + #[test] + fn test_runtime_contract_address_is_deterministic() { + let id = AccountId::new(0x112233u128); + let a = derive_runtime_contract_address(id); + let b = derive_runtime_contract_address(id); + assert_eq!(a, b); + assert_ne!(derive_eth_eoa_account_id(a), id); } } diff --git a/crates/app/tx/eth/tests/integration_tests.rs b/crates/app/tx/eth/tests/integration_tests.rs index 0c9111e..c390e61 100644 --- a/crates/app/tx/eth/tests/integration_tests.rs +++ b/crates/app/tx/eth/tests/integration_tests.rs @@ -364,15 +364,15 @@ fn test_registry_verifies_correct_chain() { #[test] fn test_address_to_account_id_preserves_uniqueness() { - use evolve_tx_eth::address_to_account_id; + use evolve_tx_eth::derive_eth_eoa_account_id; let addr1 = Address::repeat_byte(0x11); let addr2 = Address::repeat_byte(0x22); let addr3 = Address::repeat_byte(0x33); - let id1 = address_to_account_id(addr1); - let id2 = address_to_account_id(addr2); - let id3 = address_to_account_id(addr3); + let id1 = derive_eth_eoa_account_id(addr1); + let id2 = derive_eth_eoa_account_id(addr2); + let id3 = derive_eth_eoa_account_id(addr3); // All should be unique assert_ne!(id1, id2); @@ -382,11 +382,11 @@ fn test_address_to_account_id_preserves_uniqueness() { #[test] fn test_sender_account_id_matches_address_conversion() { - use evolve_tx_eth::address_to_account_id; + use evolve_tx_eth::derive_eth_eoa_account_id; let signing_key = SigningKey::random(&mut OsRng); let sender = get_address(&signing_key); - let expected_id = address_to_account_id(sender); + let expected_id = derive_eth_eoa_account_id(sender); let tx = TxLegacy { chain_id: Some(1), diff --git a/crates/rpc/chain-index/src/integration.rs b/crates/rpc/chain-index/src/integration.rs index e880d13..35bab4c 100644 --- a/crates/rpc/chain-index/src/integration.rs +++ b/crates/rpc/chain-index/src/integration.rs @@ -192,16 +192,8 @@ fn build_stored_transaction( transaction_index: u32, chain_id: u64, ) -> StoredTransaction { - let from = account_id_to_address(tx.sender()); - let to = { - let recipient = tx.recipient(); - // Check if recipient is the invalid/zero account - if recipient == AccountId::invalid() { - None - } else { - Some(account_id_to_address(recipient)) - } - }; + let from = resolve_sender_address(tx); + let to = resolve_recipient_address(tx); // Extract value from funds (sum of all fungible assets as a simple approach) let value = tx @@ -242,15 +234,8 @@ fn build_stored_receipt( transaction_index: u32, cumulative_gas_used: u64, ) -> StoredReceipt { - let from = account_id_to_address(tx.sender()); - let to = { - let recipient = tx.recipient(); - if recipient == AccountId::invalid() { - None - } else { - Some(account_id_to_address(recipient)) - } - }; + let from = resolve_sender_address(tx); + let to = resolve_recipient_address(tx); // Convert events to logs let logs: Vec = tx_result.events.iter().map(event_to_stored_log).collect(); @@ -274,6 +259,25 @@ fn build_stored_receipt( } } +fn resolve_sender_address(tx: &Tx) -> Address { + tx.sender_eth_address() + .map(Address::from) + .unwrap_or_else(|| account_id_to_address(tx.sender())) +} + +fn resolve_recipient_address(tx: &Tx) -> Option
{ + if let Some(recipient) = tx.recipient_eth_address() { + Some(Address::from(recipient)) + } else { + let recipient = tx.recipient(); + if recipient == AccountId::invalid() { + None + } else { + Some(account_id_to_address(recipient)) + } + } +} + /// Compute a simple transactions root (hash of all tx hashes). fn compute_tx_root(txs: &[StoredTransaction]) -> B256 { if txs.is_empty() { diff --git a/crates/rpc/chain-index/src/lib.rs b/crates/rpc/chain-index/src/lib.rs index 947553e..789ba89 100644 --- a/crates/rpc/chain-index/src/lib.rs +++ b/crates/rpc/chain-index/src/lib.rs @@ -34,6 +34,7 @@ pub mod error; pub mod index; pub mod integration; pub mod provider; +pub mod querier; pub mod types; pub use cache::ChainCache; @@ -41,4 +42,5 @@ pub use error::{ChainIndexError, ChainIndexResult}; pub use index::{ChainIndex, PersistentChainIndex}; pub use integration::{build_index_data, event_to_stored_log, index_block, BlockMetadata}; pub use provider::{ChainStateProvider, ChainStateProviderConfig, NoopAccountCodes}; +pub use querier::{StateQuerier, StorageStateQuerier}; pub use types::*; diff --git a/crates/rpc/chain-index/src/provider.rs b/crates/rpc/chain-index/src/provider.rs index 06acef4..2bde5c5 100644 --- a/crates/rpc/chain-index/src/provider.rs +++ b/crates/rpc/chain-index/src/provider.rs @@ -18,6 +18,7 @@ use tokio::time::timeout; use crate::error::ChainIndexError; use crate::index::ChainIndex; +use crate::querier::StateQuerier; use evolve_core::schema::AccountSchema; use evolve_core::AccountCode; use evolve_eth_jsonrpc::error::RpcError; @@ -131,6 +132,8 @@ pub struct ChainStateProvider< mempool: Option>>, /// Optional ingress verifier for transaction decode/verify. verifier: Option>, + /// Optional state querier for balance/nonce/call queries. + state_querier: Option>, /// Cached module identifiers for schema introspection endpoints. module_ids_cache: parking_lot::RwLock>>, /// Cached per-module schema lookups. @@ -165,6 +168,7 @@ impl ChainStateProvider { account_codes: Arc::new(NoopAccountCodes), mempool: None, verifier: None, + state_querier: None, module_ids_cache: parking_lot::RwLock::new(None), module_schema_cache: parking_lot::RwLock::new(BTreeMap::new()), all_schemas_cache: parking_lot::RwLock::new(None), @@ -185,6 +189,7 @@ impl ChainStateProvider ChainStateProvider ChainStateProvider Option<&SharedMempool>> { self.mempool.as_ref() } + + /// 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 + } } impl From for RpcError { @@ -363,36 +375,44 @@ impl St } } - async fn get_balance(&self, _address: Address, _block: Option) -> Result { - // TODO: Implement state queries via Storage + STF - // For now, return zero - Ok(U256::ZERO) + 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()))?; + querier.get_balance(address).await } async fn get_transaction_count( &self, - _address: Address, + address: Address, _block: Option, ) -> Result { - // TODO: Implement nonce queries via Storage + STF - // For now, return zero - Ok(0) + let querier = self + .state_querier + .as_ref() + .ok_or_else(|| RpcError::NotImplemented("state_querier not configured".to_string()))?; + querier.get_transaction_count(address).await } - async fn call(&self, _request: &CallRequest, _block: Option) -> Result { - // TODO: Implement via STF::query() - // For now, return empty - Ok(Bytes::new()) + 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 estimate_gas( &self, - _request: &CallRequest, + request: &CallRequest, _block: Option, ) -> Result { - // TODO: Implement via STF with gas tracking - // For now, return default gas - Ok(21000) + let querier = self + .state_querier + .as_ref() + .ok_or_else(|| RpcError::NotImplemented("state_querier not configured".to_string()))?; + querier.estimate_gas(request).await } async fn get_logs(&self, filter: &LogFilter) -> Result, RpcError> { diff --git a/crates/rpc/chain-index/src/querier.rs b/crates/rpc/chain-index/src/querier.rs new file mode 100644 index 0000000..06afcdb --- /dev/null +++ b/crates/rpc/chain-index/src/querier.rs @@ -0,0 +1,128 @@ +//! State querier for reading account balances and nonces from storage. +//! +//! This module provides direct storage reads for RPC state queries +//! (eth_getBalance, eth_getTransactionCount) without going through +//! the full STF execution pipeline. + +use alloy_primitives::{Address, Bytes, U256}; +use async_trait::async_trait; + +use evolve_core::encoding::Encodable; +use evolve_core::{AccountId, Message, ReadonlyKV}; +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}; + +/// 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. +#[async_trait] +pub trait StateQuerier: Send + Sync { + /// Get the token balance for an Ethereum address. + async fn get_balance(&self, address: Address) -> Result; + + /// Get the transaction count (nonce) for an Ethereum address. + async fn get_transaction_count(&self, address: Address) -> Result; + + /// Execute a read-only call. + async fn call(&self, request: &CallRequest) -> Result; + + /// Estimate gas for a transaction. + async fn estimate_gas(&self, request: &CallRequest) -> Result; +} + +/// State querier that reads directly from storage. +/// +/// 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: +/// - Nonce: `account_id_bytes ++ [0]` (EthEoaAccount storage prefix 0) +/// - Balance: `token_id_bytes ++ [1] ++ encode(account_id)` (Token storage prefix 1) +pub struct StorageStateQuerier { + storage: S, + token_account_id: AccountId, +} + +impl StorageStateQuerier { + pub fn new(storage: S, token_account_id: AccountId) -> Self { + Self { + storage, + token_account_id, + } + } + + fn read_nonce(&self, account_id: AccountId) -> Result { + let mut key = account_id.as_bytes().to_vec(); + key.push(0u8); // EthEoaAccount::nonce storage prefix + match self + .storage + .get(&key) + .map_err(|e| RpcError::InternalError(format!("storage read: {:?}", e)))? + { + Some(value) => Message::from_bytes(value) + .get::() + .map_err(|e| RpcError::InternalError(format!("decode nonce: {:?}", e))), + None => Ok(0), + } + } + + fn read_balance(&self, account_id: AccountId) -> Result { + let mut key = self.token_account_id.as_bytes().to_vec(); + key.push(1u8); // Token::balances storage prefix + key.extend( + account_id + .encode() + .map_err(|e| RpcError::InternalError(format!("encode account id: {:?}", e)))?, + ); + match self + .storage + .get(&key) + .map_err(|e| RpcError::InternalError(format!("storage read: {:?}", e)))? + { + Some(value) => Message::from_bytes(value) + .get::() + .map_err(|e| RpcError::InternalError(format!("decode balance: {:?}", e))), + None => Ok(0), + } + } + + 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)))? + { + 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))) + } +} + +#[async_trait] +impl 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); + }; + let balance = self.read_balance(account_id)?; + Ok(U256::from(balance)) + } + + async fn get_transaction_count(&self, address: Address) -> Result { + let Some(account_id) = self.resolve_account_id(address)? else { + return Ok(0); + }; + self.read_nonce(account_id) + } + + async fn call(&self, _request: &CallRequest) -> Result { + // TODO: Implement via STF::query() + Ok(Bytes::new()) + } + + async fn estimate_gas(&self, _request: &CallRequest) -> Result { + // TODO: Implement via STF with gas tracking + Ok(21000) + } +} diff --git a/crates/rpc/types/Cargo.toml b/crates/rpc/types/Cargo.toml index 56c7ebb..cb3c421 100644 --- a/crates/rpc/types/Cargo.toml +++ b/crates/rpc/types/Cargo.toml @@ -9,6 +9,7 @@ rust-version.workspace = true [dependencies] evolve_core.workspace = true +evolve_tx_eth.workspace = true alloy-primitives = { version = "0.8", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } diff --git a/crates/rpc/types/src/lib.rs b/crates/rpc/types/src/lib.rs index 5d0fa00..d974ab7 100644 --- a/crates/rpc/types/src/lib.rs +++ b/crates/rpc/types/src/lib.rs @@ -22,27 +22,16 @@ pub struct ChainConfig { pub chain_id: u64, } -/// Convert an evolve AccountId to an Ethereum address. +/// Convert an internal AccountId to a deterministic compatibility address. /// -/// AccountId is u128, Address is 20 bytes. We take the lower 20 bytes. +/// This is one-way and is intended for indexing/display fallbacks only. pub fn account_id_to_address(account_id: evolve_core::AccountId) -> Address { - let bytes = account_id.as_bytes(); - // AccountId::as_bytes() returns big-endian u128 (16 bytes) - // Pad to 20 bytes by prepending 4 zero bytes - let mut addr_bytes = [0u8; 20]; - addr_bytes[4..].copy_from_slice(&bytes); - Address::from(addr_bytes) + evolve_tx_eth::derive_runtime_contract_address(account_id) } -/// Convert an Ethereum address to an evolve AccountId. -/// -/// Takes the lower 16 bytes of the address as a u128. +/// Derive canonical ETH-EOA AccountId from an Ethereum address. pub fn address_to_account_id(address: Address) -> evolve_core::AccountId { - let bytes = address.as_slice(); - // Take last 16 bytes (address is 20 bytes) - let mut id_bytes = [0u8; 16]; - id_bytes.copy_from_slice(&bytes[4..]); - evolve_core::AccountId::new(u128::from_be_bytes(id_bytes)) + evolve_tx_eth::derive_eth_eoa_account_id(address) } /// Sync status for eth_syncing response. @@ -211,7 +200,7 @@ mod proptests { use proptest::prelude::*; fn arb_account_id() -> impl Strategy { - any::().prop_map(evolve_core::AccountId::new) + any::<[u8; 32]>().prop_map(evolve_core::AccountId::from_bytes) } fn arb_address() -> impl Strategy { @@ -295,14 +284,14 @@ mod proptests { } proptest! { - // ==================== AccountId <-> Address conversion ==================== - // Tests our custom conversion logic between Evolve AccountId and Ethereum Address + #[test] + fn prop_account_id_to_address_is_deterministic(id in arb_account_id()) { + prop_assert_eq!(account_id_to_address(id), account_id_to_address(id)); + } #[test] - fn prop_account_id_to_address_roundtrip(id in arb_account_id()) { - let address = account_id_to_address(id); - let recovered = address_to_account_id(address); - prop_assert_eq!(id, recovered); + fn prop_address_to_account_id_is_deterministic(address in arb_address()) { + prop_assert_eq!(address_to_account_id(address), address_to_account_id(address)); } // ==================== Custom serde implementations ==================== diff --git a/crates/testing/debugger/src/breakpoints.rs b/crates/testing/debugger/src/breakpoints.rs index 536c920..a737a17 100644 --- a/crates/testing/debugger/src/breakpoints.rs +++ b/crates/testing/debugger/src/breakpoints.rs @@ -8,11 +8,8 @@ use evolve_core::AccountId; use serde::{Deserialize, Serialize}; /// Convert AccountId to serializable bytes. -fn account_to_bytes(id: AccountId) -> [u8; 16] { - let bytes = id.as_bytes(); - let mut arr = [0u8; 16]; - arr.copy_from_slice(&bytes[..16]); - arr +fn account_to_bytes(id: AccountId) -> [u8; 32] { + id.as_bytes() } /// A breakpoint condition. @@ -25,7 +22,7 @@ pub enum Breakpoint { OnTx([u8; 32]), /// Break on any transaction involving this account (stored as bytes). - OnAccount([u8; 16]), + OnAccount([u8; 32]), /// Break when a specific storage key is modified. OnStorageKey(Vec), diff --git a/crates/testing/debugger/src/inspector.rs b/crates/testing/debugger/src/inspector.rs index 4829d86..53131f7 100644 --- a/crates/testing/debugger/src/inspector.rs +++ b/crates/testing/debugger/src/inspector.rs @@ -10,11 +10,8 @@ use evolve_core::AccountId; use std::collections::HashMap; /// Convert AccountId to serializable bytes. -fn account_to_bytes(id: AccountId) -> [u8; 16] { - let bytes = id.as_bytes(); - let mut arr = [0u8; 16]; - arr.copy_from_slice(&bytes[..16]); - arr +fn account_to_bytes(id: AccountId) -> [u8; 32] { + id.as_bytes() } /// Inspector for examining trace and state. diff --git a/crates/testing/debugger/src/trace.rs b/crates/testing/debugger/src/trace.rs index 1759ff9..9743caa 100644 --- a/crates/testing/debugger/src/trace.rs +++ b/crates/testing/debugger/src/trace.rs @@ -11,18 +11,14 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Convert AccountId to serializable bytes. -fn account_to_bytes(id: AccountId) -> [u8; 16] { - let bytes = id.as_bytes(); - let mut arr = [0u8; 16]; - arr.copy_from_slice(&bytes[..16]); - arr +fn account_to_bytes(id: AccountId) -> [u8; 32] { + id.as_bytes() } /// Convert bytes back to AccountId. #[allow(dead_code)] -fn bytes_to_account(bytes: [u8; 16]) -> AccountId { - let value = u128::from_be_bytes(bytes); - AccountId::new(value) +fn bytes_to_account(bytes: [u8; 32]) -> AccountId { + AccountId::from_bytes(bytes) } /// A complete execution trace capturing all state transitions. @@ -226,10 +222,10 @@ pub enum TraceEvent { /// Transaction execution started. TxStart { tx_id: [u8; 32], - /// Sender account ID as bytes (u128 big-endian). - sender: [u8; 16], - /// Recipient account ID as bytes (u128 big-endian). - recipient: [u8; 16], + /// Sender account ID as canonical 32 bytes. + sender: [u8; 32], + /// Recipient account ID as canonical 32 bytes. + recipient: [u8; 32], event_index: usize, }, @@ -243,10 +239,10 @@ pub enum TraceEvent { /// A call was made between accounts. Call { - /// Caller account ID as bytes (u128 big-endian). - from: [u8; 16], - /// Callee account ID as bytes (u128 big-endian). - to: [u8; 16], + /// Caller account ID as canonical 32 bytes. + from: [u8; 32], + /// Callee account ID as canonical 32 bytes. + to: [u8; 32], function_id: u64, data_hash: [u8; 32], event_index: usize, diff --git a/crates/testing/simulator/src/eth_eoa.rs b/crates/testing/simulator/src/eth_eoa.rs index 3dddc61..aa661e5 100644 --- a/crates/testing/simulator/src/eth_eoa.rs +++ b/crates/testing/simulator/src/eth_eoa.rs @@ -33,14 +33,16 @@ pub fn init_eth_eoa_storage( account_id: AccountId, eth_address: [u8; 20], ) -> Result<(), ErrorCode> { - let mut nonce_key = account_id.as_bytes(); + let mut nonce_key = Vec::with_capacity(account_id.as_bytes().len() + 1); + nonce_key.extend_from_slice(&account_id.as_bytes()); nonce_key.push(0u8); let nonce_value = Message::new(&0u64) .expect("encode nonce") .into_bytes() .expect("nonce bytes"); - let mut addr_key = account_id.as_bytes(); + let mut addr_key = Vec::with_capacity(account_id.as_bytes().len() + 1); + addr_key.extend_from_slice(&account_id.as_bytes()); addr_key.push(1u8); let addr_value = Message::new(ð_address) .expect("encode eth address") diff --git a/docker/testapp/Dockerfile b/docker/testapp/Dockerfile new file mode 100644 index 0000000..575e83f --- /dev/null +++ b/docker/testapp/Dockerfile @@ -0,0 +1,49 @@ +FROM rust:bookworm AS chef + +WORKDIR /app + +RUN cargo install cargo-chef + +FROM chef AS planner + +COPY . . + +RUN cargo chef prepare --recipe-path recipe.json + +FROM chef AS builder + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends protobuf-compiler libprotobuf-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=planner /app/recipe.json recipe.json + +RUN cargo chef cook --recipe-path recipe.json --release + +COPY . . + +RUN cargo build -p evolve_testapp --release + +FROM debian:bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system evolve \ + && useradd --system --gid evolve --create-home --home-dir /home/evolve evolve \ + && mkdir -p /var/lib/evolve/data \ + && chown -R evolve:evolve /var/lib/evolve /home/evolve + +WORKDIR /app + +COPY --from=builder /app/target/release/testapp /usr/local/bin/testapp + +RUN chown evolve:evolve /usr/local/bin/testapp /app + +USER evolve:evolve + +EXPOSE 8545 50051 + +ENTRYPOINT ["/usr/local/bin/testapp"]