From 455fa80865c5a917282f88805faa47c11fdc5d05 Mon Sep 17 00:00:00 2001 From: Daanvdplas Date: Mon, 16 Feb 2026 19:03:24 +0100 Subject: [PATCH 1/2] docs: mark asset hub fork context doc as wip --- ASSET_HUB_FORK_REMAINING_ISSUES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ASSET_HUB_FORK_REMAINING_ISSUES.md b/ASSET_HUB_FORK_REMAINING_ISSUES.md index 501ee0888..404f1d4bf 100644 --- a/ASSET_HUB_FORK_REMAINING_ISSUES.md +++ b/ASSET_HUB_FORK_REMAINING_ISSUES.md @@ -4,6 +4,8 @@ Date: 2026-02-16 Branch: `fix/contract-prompts-stacked-pr948-v2` Last implementation commit: `af1e4851` +> Status: WIP. This document is the authoritative context for remaining work. + ## Goal Make this flow work end-to-end on a **local fork of Asset Hub**: From 17dfbc8a9cac773b615076afa20e6a4a781ac0cb Mon Sep 17 00:00:00 2001 From: Daanvdplas Date: Mon, 16 Feb 2026 19:06:35 +0100 Subject: [PATCH 2/2] feat(fork): improve asset hub eth-rpc support on dev forks --- crates/pop-cli/src/commands/fork.rs | 166 ++++++++++- crates/pop-cli/src/commands/up/network.rs | 111 +++++++- crates/pop-fork/src/blockchain.rs | 54 +++- crates/pop-fork/src/dev.rs | 24 ++ crates/pop-fork/src/rpc_server/methods/mod.rs | 8 + .../pop-fork/src/rpc_server/methods/state.rs | 260 +++++++++++++++++- crates/pop-fork/src/strings/rpc_server.rs | 18 ++ 7 files changed, 620 insertions(+), 21 deletions(-) diff --git a/crates/pop-cli/src/commands/fork.rs b/crates/pop-cli/src/commands/fork.rs index b691a5d96..d737e2d93 100644 --- a/crates/pop-cli/src/commands/fork.rs +++ b/crates/pop-cli/src/commands/fork.rs @@ -8,6 +8,10 @@ use anyhow::Result; use clap::{ArgGroup, Args}; use console::style; use pop_chains::SupportedChains; +#[cfg(feature = "contract")] +use pop_common::{resolve_port, set_executable_permission}; +#[cfg(feature = "contract")] +use pop_contracts::{eth_rpc_generator, run_eth_rpc_node}; use pop_fork::{ BlockForkPoint, Blockchain, ExecutorConfig, SignatureMockMode, TxPool, rpc_server::{ForkRpcServer, RpcServerConfig}, @@ -27,6 +31,8 @@ use url::Url; const DETACH_READY_TIMEOUT_SECS: u64 = 120; /// Poll interval when checking for fork server readiness. const DETACH_READY_POLL_MS: u64 = 200; +/// Default Ethereum RPC port when forking Asset Hub chains. +const DEFAULT_ETH_RPC_PORT: u16 = 8545; /// UI messages used across interactive and headless paths. mod messages { @@ -53,6 +59,11 @@ mod messages { pub fn forked(chain_name: &str, block_number: u32, ws_url: &str) -> String { format!("Forked {chain_name} at block #{block_number} -> {ws_url}") } + + /// Format Ethereum RPC endpoint message. + pub fn eth_rpc_endpoint(url: &str) -> String { + format!("Ethereum RPC endpoint -> {url}") + } } /// Arguments for the fork command. @@ -76,6 +87,11 @@ pub(crate) struct ForkArgs { #[arg(short, long)] pub port: Option, + /// Preferred port for the Ethereum RPC proxy when forking Asset Hub chains. + /// If this port is not available, the next available local port is used. + #[arg(long = "eth-rpc-port")] + pub eth_rpc_port: Option, + /// Accept all signatures as valid (default: only magic signatures 0xdeadbeef). /// Use this for maximum flexibility when testing. #[arg(long = "mock-all-signatures")] @@ -107,6 +123,11 @@ pub(crate) struct ForkArgs { pub(crate) struct Command; +struct EthRpcProcess { + process: Child, + ws_url: String, +} + impl Command { pub(crate) async fn execute( args: &mut ForkArgs, @@ -315,6 +336,12 @@ impl Command { let txpool = Arc::new(TxPool::new()); let server_config = RpcServerConfig { port: args.port, ..Default::default() }; let server = ForkRpcServer::start(blockchain.clone(), txpool, server_config).await?; + let mut eth_rpc_process = Self::start_asset_hub_eth_rpc_if_needed( + &blockchain, + &server.ws_url(), + args.eth_rpc_port, + ) + .await?; let forked_msg = messages::forked( blockchain.chain_name(), @@ -322,10 +349,16 @@ impl Command { &server.ws_url(), ); log::info!("{forked_msg}"); + let mut ready_lines = vec![forked_msg]; + if let Some(eth_rpc) = eth_rpc_process.as_ref() { + let message = messages::eth_rpc_endpoint(ð_rpc.ws_url); + log::info!("{message}"); + ready_lines.push(message); + } // Signal readiness to the parent process (detach mode). if let Some(ready_path) = &args.ready_file { - std::fs::write(ready_path, &forked_msg)?; + std::fs::write(ready_path, ready_lines.join("\n"))?; } log::info!("Server running. Waiting for termination signal..."); @@ -334,6 +367,10 @@ impl Command { tokio::signal::ctrl_c().await?; log::info!("{}", messages::SHUTTING_DOWN); + if let Some(mut eth_rpc) = eth_rpc_process.take() { + let _ = eth_rpc.process.kill(); + let _ = eth_rpc.process.wait(); + } server.stop().await; let _ = blockchain.clear_local_storage().await; @@ -374,16 +411,29 @@ impl Command { let txpool = Arc::new(TxPool::new()); let server_config = RpcServerConfig { port: args.port, ..Default::default() }; let server = ForkRpcServer::start(blockchain.clone(), txpool, server_config).await?; + let mut eth_rpc_process = Self::start_asset_hub_eth_rpc_if_needed( + &blockchain, + &server.ws_url(), + args.eth_rpc_port, + ) + .await?; let ws = server.ws_url(); + let eth_rpc_hint = eth_rpc_process + .as_ref() + .map(|eth_rpc| { + format!("\n{}", style(format!(" eth rpc: {}", eth_rpc.ws_url)).dim()) + }) + .unwrap_or_default(); cli.success(format!( - "{}\n{}\n{}", + "{}\n{}\n{}{}", messages::forked(blockchain.chain_name(), blockchain.fork_point_number(), &ws), style(format!(" polkadot.js: https://polkadot.js.org/apps/?rpc={ws}#/explorer")).dim(), style(format!( " papi: https://dev.papi.how/explorer#networkId=custom&endpoint={ws}" )) .dim(), + eth_rpc_hint, ))?; cli.info(messages::PRESS_CTRL_C)?; @@ -391,6 +441,10 @@ impl Command { tokio::signal::ctrl_c().await?; cli.info(messages::SHUTTING_DOWN)?; + if let Some(mut eth_rpc) = eth_rpc_process.take() { + let _ = eth_rpc.process.kill(); + let _ = eth_rpc.process.wait(); + } server.stop().await; if let Err(e) = blockchain.clear_local_storage().await { cli.warning(format!("Failed to clear local storage: {}", e))?; @@ -416,6 +470,10 @@ impl Command { cmd_args.push("--port".to_string()); cmd_args.push(port.to_string()); } + if let Some(eth_rpc_port) = args.eth_rpc_port { + cmd_args.push("--eth-rpc-port".to_string()); + cmd_args.push(eth_rpc_port.to_string()); + } if args.mock_all_signatures { cmd_args.push("--mock-all-signatures".to_string()); } @@ -429,6 +487,69 @@ impl Command { cmd_args.push("--serve".to_string()); cmd_args } + + fn is_asset_hub_chain(chain_name: &str) -> bool { + let normalized = chain_name.to_ascii_lowercase(); + normalized.contains("asset-hub") || + normalized.contains("asset hub") || + normalized.contains("assethub") || + normalized.contains("passet-hub") || + normalized.contains("statemint") || + normalized.contains("westmint") + } + + fn should_start_asset_hub_eth_rpc( + chain_name: &str, + properties: Option<&serde_json::Value>, + ) -> bool { + if !Self::is_asset_hub_chain(chain_name) { + return false; + } + + properties + .and_then(|props| props.get("isEthereum")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(true) + } + + async fn start_asset_hub_eth_rpc_if_needed( + blockchain: &Arc, + node_ws_url: &str, + preferred_eth_rpc_port: Option, + ) -> Result> { + let properties = blockchain.chain_properties().await; + if !Self::should_start_asset_hub_eth_rpc(blockchain.chain_name(), properties.as_ref()) { + return Ok(None); + } + + #[cfg(not(feature = "contract"))] + { + let _ = (node_ws_url, preferred_eth_rpc_port); + log::warn!( + "Asset Hub fork detected, but contract feature is disabled; skipping Ethereum RPC bridge startup." + ); + Ok(None) + } + + #[cfg(feature = "contract")] + { + let mut eth_rpc_binary = eth_rpc_generator(crate::cache()?, None).await?; + let stale = eth_rpc_binary.stale(); + if stale { + eth_rpc_binary.use_latest(); + } + if stale || !eth_rpc_binary.exists() { + eth_rpc_binary.source(false, &(), true).await?; + set_executable_permission(eth_rpc_binary.path())?; + } + + let resolved_port = + resolve_port(Some(preferred_eth_rpc_port.unwrap_or(DEFAULT_ETH_RPC_PORT))); + let process = + run_eth_rpc_node(ð_rpc_binary.path(), None, node_ws_url, resolved_port).await?; + Ok(Some(EthRpcProcess { process, ws_url: format!("ws://127.0.0.1:{resolved_port}") })) + } + } } #[cfg(test)] @@ -511,6 +632,7 @@ mod tests { endpoint: Some("wss://rpc.polkadot.io".to_string()), cache: Some(PathBuf::from("/tmp/cache.db")), port: Some(9000), + eth_rpc_port: Some(18545), mock_all_signatures: true, dev: true, at: Some(100), @@ -530,6 +652,8 @@ mod tests { "/tmp/cache.db", "--port", "9000", + "--eth-rpc-port", + "18545", "--mock-all-signatures", "--dev", "--at", @@ -539,6 +663,20 @@ mod tests { ); } + #[test] + fn build_serve_args_with_eth_rpc_port() { + let args = ForkArgs { + endpoint: Some("wss://rpc.polkadot.io".to_string()), + eth_rpc_port: Some(9545), + ..Default::default() + }; + let result = Command::build_serve_args(&args); + assert_eq!( + result, + vec!["fork", "-e", "wss://rpc.polkadot.io", "--eth-rpc-port", "9545", "--serve"] + ); + } + #[test] fn build_serve_args_with_at() { let args = ForkArgs { @@ -649,4 +787,28 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("Fork process exited unexpectedly")); } + + #[test] + fn should_start_asset_hub_eth_rpc_for_asset_hub_when_property_missing() { + assert!(Command::should_start_asset_hub_eth_rpc("statemint", None)); + assert!(Command::should_start_asset_hub_eth_rpc("asset-hub-paseo", None)); + assert!(Command::should_start_asset_hub_eth_rpc("passet-hub-paseo", None)); + } + + #[test] + fn should_not_start_asset_hub_eth_rpc_for_non_asset_hub_chains() { + assert!(!Command::should_start_asset_hub_eth_rpc("polkadot", None)); + assert!(!Command::should_start_asset_hub_eth_rpc("kusama", None)); + } + + #[test] + fn should_respect_is_ethereum_property_for_asset_hub() { + let ethereum = serde_json::json!({ "isEthereum": true }); + let not_ethereum = serde_json::json!({ "isEthereum": false }); + assert!(Command::should_start_asset_hub_eth_rpc("asset-hub-polkadot", Some(ðereum))); + assert!(!Command::should_start_asset_hub_eth_rpc( + "asset-hub-polkadot", + Some(¬_ethereum) + )); + } } diff --git a/crates/pop-cli/src/commands/up/network.rs b/crates/pop-cli/src/commands/up/network.rs index d2af01267..8814b6aee 100644 --- a/crates/pop-cli/src/commands/up/network.rs +++ b/crates/pop-cli/src/commands/up/network.rs @@ -19,18 +19,84 @@ use pop_chains::{ up::{NetworkConfiguration, Zombienet}, }; use pop_common::Status; +#[cfg(feature = "contract")] +use pop_common::{resolve_port, set_executable_permission}; +#[cfg(feature = "contract")] +use pop_contracts::{eth_rpc_generator, run_eth_rpc_node}; use serde::Serialize; use std::{ collections::HashMap, ffi::OsStr, + fs::File, path::{Path, PathBuf}, + process::Child, time::{Duration, Instant}, }; -use tokio::time::sleep; - use jsonrpsee::{core::client::ClientT, rpc_params, ws_client::WsClientBuilder}; +use tokio::time::sleep; use tokio::time::timeout; +const DEFAULT_ETH_RPC_PORT: u16 = 8545; + +struct EthRpcProcess { + process: Child, + ws_url: String, + log_path: PathBuf, +} + +fn is_asset_hub_chain_id(chain_id: Option<&str>) -> bool { + let Some(chain_id) = chain_id else { + return false; + }; + let normalized = chain_id.to_ascii_lowercase(); + normalized.contains("asset-hub") || normalized.contains("passet-hub") +} + +async fn start_asset_hub_eth_rpc_if_needed( + asset_hub_ws_endpoint: Option<&str>, + base_dir: &Path, +) -> anyhow::Result> { + let Some(asset_hub_ws_endpoint) = asset_hub_ws_endpoint else { + return Ok(None); + }; + + #[cfg(not(feature = "contract"))] + { + let _ = (asset_hub_ws_endpoint, base_dir); + return Ok(None); + } + + #[cfg(feature = "contract")] + { + let mut eth_rpc_binary = eth_rpc_generator(crate::cache()?, None).await?; + let stale = eth_rpc_binary.stale(); + if stale { + eth_rpc_binary.use_latest(); + } + if stale || !eth_rpc_binary.exists() { + eth_rpc_binary.source(false, &(), true).await?; + set_executable_permission(eth_rpc_binary.path())?; + } + + let eth_rpc_port = resolve_port(Some(DEFAULT_ETH_RPC_PORT)); + let log_path = base_dir.join("eth-rpc.log"); + let log_file = File::create(&log_path)?; + let process = run_eth_rpc_node( + ð_rpc_binary.path(), + Some(&log_file), + asset_hub_ws_endpoint, + eth_rpc_port, + ) + .await?; + + Ok(Some(EthRpcProcess { + process, + ws_url: format!("ws://127.0.0.1:{eth_rpc_port}"), + log_path, + })) + } +} + /// Launch a local network by specifying a network configuration file. #[derive(Args, Clone, Default, Serialize)] pub(crate) struct ConfigFileCommand { @@ -560,7 +626,9 @@ pub(crate) async fn spawn( // Add chain info let mut chains = network.parachains(); chains.sort_by_key(|p| p.para_id()); + let mut asset_hub_ws_endpoint = None; for chain in chains { + let is_asset_hub = is_asset_hub_chain_id(chain.chain_id()); result.push_str(&format!( "\n{bar} ⛓️ {}", chain.chain_id().map_or(format!("id: {}", chain.para_id()), |c| format!( @@ -571,10 +639,37 @@ pub(crate) async fn spawn( let mut collators = chain.collators(); collators.sort_by_key(|n| n.name()); for node in collators { + if is_asset_hub && asset_hub_ws_endpoint.is_none() { + asset_hub_ws_endpoint = Some(node.ws_uri().to_string()); + } result.push_str(&output(node)); } } + let mut eth_rpc_process = match start_asset_hub_eth_rpc_if_needed( + asset_hub_ws_endpoint.as_deref(), + &base_dir, + ) + .await + { + Ok(process) => process, + Err(error) => { + cli.warning(format!( + "⚠️ Failed to start Ethereum RPC bridge for Asset Hub: {error}" + ))?; + None + }, + }; + if let Some(eth_rpc_process) = eth_rpc_process.as_ref() { + result.push_str(&format!( + "\n{bar} ⚡ Ethereum RPC bridge: +{bar} endpoint: {} +{bar} logs: tail -f {}", + eth_rpc_process.ws_url, + eth_rpc_process.log_path.display() + )); + } + if let Some(command) = command { run_custom_command(&progress, command).await?; } @@ -663,6 +758,10 @@ The network is running. Check endpoints manually if needed.", } tokio::signal::ctrl_c().await?; + if let Some(mut eth_rpc_process) = eth_rpc_process.take() { + let _ = eth_rpc_process.process.kill(); + let _ = eth_rpc_process.process.wait(); + } if auto_remove { // Remove zombienet directory after network is terminated @@ -1141,4 +1240,12 @@ cumulus-client-collator = "0.14" let err_msg = result.unwrap_err().to_string(); assert!(err_msg.contains("Timeout waiting for")); } + + #[test] + fn test_is_asset_hub_chain_id() { + assert!(is_asset_hub_chain_id(Some("asset-hub-paseo-local"))); + assert!(is_asset_hub_chain_id(Some("passet-hub-paseo-local"))); + assert!(!is_asset_hub_chain_id(Some("coretime-paseo-local"))); + assert!(!is_asset_hub_chain_id(None)); + } } diff --git a/crates/pop-fork/src/blockchain.rs b/crates/pop-fork/src/blockchain.rs index ebe667181..7f796a44c 100644 --- a/crates/pop-fork/src/blockchain.rs +++ b/crates/pop-fork/src/blockchain.rs @@ -146,6 +146,26 @@ fn select_sudo_account<'a>( (selected.0, selected.1.as_slice()) } +fn dev_accounts_for_chain(is_ethereum: bool) -> Vec<(&'static str, Vec)> { + use crate::dev::{ + ETHEREUM_DEV_ACCOUNTS, SUBSTRATE_DEV_ACCOUNTS, ethereum_fallback_account_id, + }; + + let mut accounts: Vec<(&'static str, Vec)> = + SUBSTRATE_DEV_ACCOUNTS.iter().map(|(n, a)| (*n, a.to_vec())).collect(); + + if is_ethereum { + accounts.extend(ETHEREUM_DEV_ACCOUNTS.iter().map(|(n, a)| (*n, a.to_vec()))); + accounts.extend( + ETHEREUM_DEV_ACCOUNTS + .iter() + .map(|(n, a)| (*n, ethereum_fallback_account_id(a).to_vec())), + ); + } + + accounts +} + pub type BlockBody = Vec>; // Transaction validity types for decoding TaggedTransactionQueue_validate_transaction results. @@ -1870,8 +1890,8 @@ impl Blockchain { /// account-id width (20-byte or 32-byte), with first-account fallback. pub async fn initialize_dev_accounts(&self) -> Result<(), BlockchainError> { use crate::dev::{ - DEV_BALANCE, ETHEREUM_DEV_ACCOUNTS, SUBSTRATE_DEV_ACCOUNTS, account_storage_key, - build_account_info, patch_free_balance, sudo_key_storage_key, + DEV_BALANCE, account_storage_key, build_account_info, patch_free_balance, + sudo_key_storage_key, }; // Check isEthereum property before acquiring the write lock. @@ -1883,14 +1903,9 @@ impl Blockchain { let mut head = self.head.write().await; - // On Ethereum-marked chains, also fund Substrate dev accounts. - // ink-node exposes Ethereum compatibility while still using AccountId32, and - // many tests submit Substrate-style Alice/Bob transactions. - let mut accounts: Vec<(&str, Vec)> = - SUBSTRATE_DEV_ACCOUNTS.iter().map(|(n, a)| (*n, a.to_vec())).collect(); - if is_ethereum { - accounts.extend(ETHEREUM_DEV_ACCOUNTS.iter().map(|(n, a)| (*n, a.to_vec()))); - } + // On Ethereum-marked chains, include both raw H160 dev accounts and + // AccountId32 fallback-mapped dev accounts to match ink-node prefunding. + let accounts = dev_accounts_for_chain(is_ethereum); // Build all storage keys upfront. let keys: Vec> = accounts.iter().map(|(_, a)| account_storage_key(a)).collect(); @@ -2077,4 +2092,23 @@ mod tests { assert_eq!(name_unknown, "Alice"); assert_eq!(account_unknown.len(), 32); } + + #[test] + fn dev_accounts_for_substrate_chain_only_include_substrate_accounts() { + let accounts = dev_accounts_for_chain(false); + assert_eq!(accounts.len(), 6); + assert!(accounts.iter().all(|(_, account)| account.len() == 32)); + } + + #[test] + fn dev_accounts_for_ethereum_chain_include_h160_and_fallback_accounts() { + use crate::dev::{ALITH, ethereum_fallback_account_id}; + + let accounts = dev_accounts_for_chain(true); + assert_eq!(accounts.len(), 18); + assert!(accounts.iter().any(|(_, account)| account.as_slice() == ALITH.as_slice())); + assert!(accounts + .iter() + .any(|(_, account)| account.as_slice() == ethereum_fallback_account_id(&ALITH))); + } } diff --git a/crates/pop-fork/src/dev.rs b/crates/pop-fork/src/dev.rs index 88e54b72e..016efe14f 100644 --- a/crates/pop-fork/src/dev.rs +++ b/crates/pop-fork/src/dev.rs @@ -118,6 +118,14 @@ pub const ETHEREUM_DEV_ACCOUNTS: [(&str, [u8; 20]); 6] = [ // Helpers // --------------------------------------------------------------------------- +/// Convert an Ethereum address into the AccountId32 fallback format used by +/// `pallet-revive::AccountId32Mapper`: `address(20 bytes) ++ 0xEE * 12`. +pub fn ethereum_fallback_account_id(address: &[u8; 20]) -> [u8; 32] { + let mut account_id = [0xEE; 32]; + account_id[..20].copy_from_slice(address); + account_id +} + /// Compute the `System::Account` storage key for an account (Blake2_128Concat). /// /// Works with both 32-byte (Substrate) and 20-byte (Ethereum) account IDs. @@ -187,6 +195,22 @@ mod tests { assert_eq!(key.len(), 68); } + #[test] + fn ethereum_fallback_account_id_has_expected_shape() { + let fallback = ethereum_fallback_account_id(&ALITH); + assert_eq!(&fallback[..20], &ALITH); + assert!(fallback[20..].iter().all(|b| *b == 0xEE)); + } + + #[test] + fn ethereum_fallback_account_storage_key_has_correct_length() { + let fallback = ethereum_fallback_account_id(&ALITH); + let key = account_storage_key(&fallback); + // twox128("System") + twox128("Account") + blake2_128(account) + account + // = 16 + 16 + 16 + 32 = 80 + assert_eq!(key.len(), 80); + } + #[test] fn sudo_key_storage_key_has_correct_length() { let key = sudo_key_storage_key(); diff --git a/crates/pop-fork/src/rpc_server/methods/mod.rs b/crates/pop-fork/src/rpc_server/methods/mod.rs index b9b160c29..3abca41de 100644 --- a/crates/pop-fork/src/rpc_server/methods/mod.rs +++ b/crates/pop-fork/src/rpc_server/methods/mod.rs @@ -119,6 +119,14 @@ pub fn create_rpc_module( .merge(DevApiServer::into_rpc(dev_impl)) .map_err(|e| RpcServerError::Internal(e.to_string()))?; + // anvil compatibility methods expected by eth-rpc. + module + .register_method("getAutomine", |_, _, _| ResponsePayload::success(true)) + .map_err(|e| RpcServerError::Internal(e.to_string()))?; + module + .register_method("setAutomine", |_, _, _| ResponsePayload::success(true)) + .map_err(|e| RpcServerError::Internal(e.to_string()))?; + // Collect method names before registering rpc_methods let mut method_names: Vec = module.method_names().map(String::from).collect(); method_names.push("rpc_methods".to_string()); diff --git a/crates/pop-fork/src/rpc_server/methods/state.rs b/crates/pop-fork/src/rpc_server/methods/state.rs index 343bb6daf..681c8f5d1 100644 --- a/crates/pop-fork/src/rpc_server/methods/state.rs +++ b/crates/pop-fork/src/rpc_server/methods/state.rs @@ -18,11 +18,73 @@ use jsonrpsee::{ proc_macros::rpc, }; use log::{debug, warn}; -use scale::Decode; -use std::sync::Arc; +use scale::{Decode, Encode}; +use std::{sync::Arc, time::Instant}; use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; +fn should_proxy_state_call(method: &str, block_number: u32, fork_point_number: u32) -> bool { + block_number <= fork_point_number && + (method.starts_with(runtime_api::METADATA_PREFIX) || + method == runtime_api::REVIVE_BLOCK_GAS_LIMIT || + method == runtime_api::REVIVE_GAS_PRICE) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReviveAccountQuery { + Balance([u8; 20]), + Nonce([u8; 20]), +} + +fn parse_revive_account_query(method: &str, params: &[u8]) -> Option { + let address: [u8; 20] = params.try_into().ok()?; + match method { + runtime_api::REVIVE_BALANCE => Some(ReviveAccountQuery::Balance(address)), + runtime_api::REVIVE_NONCE => Some(ReviveAccountQuery::Nonce(address)), + _ => None, + } +} + +fn is_known_ethereum_dev_account(address: &[u8; 20]) -> bool { + crate::dev::ETHEREUM_DEV_ACCOUNTS.iter().any(|(_, known)| known == address) +} + +fn decode_nonce_from_account_info(account_info: &[u8]) -> Option { + const NONCE_END: usize = 4; + let bytes: [u8; 4] = account_info.get(0..NONCE_END)?.try_into().ok()?; + Some(u32::from_le_bytes(bytes)) +} + +fn decode_free_balance_from_account_info(account_info: &[u8]) -> Option { + const FREE_START: usize = 16; + const FREE_END: usize = 32; + let bytes: [u8; 16] = account_info.get(FREE_START..FREE_END)?.try_into().ok()?; + Some(u128::from_le_bytes(bytes)) +} + +fn encode_revive_account_query_result( + query: ReviveAccountQuery, + account_info: Option<&[u8]>, +) -> Option> { + match query { + ReviveAccountQuery::Balance(address) => { + let balance = match account_info { + Some(data) => decode_free_balance_from_account_info(data)?, + None if is_known_ethereum_dev_account(&address) => crate::dev::DEV_BALANCE, + None => 0, + }; + Some(sp_core::U256::from(balance).encode()) + }, + ReviveAccountQuery::Nonce(_) => { + let nonce = match account_info { + Some(data) => decode_nonce_from_account_info(data)?, + None => 0, + }; + Some(nonce.encode()) + }, + } +} + #[async_trait::async_trait] pub trait StateBlockchain: Send + Sync { async fn head_snapshot(&self) -> (u32, subxt::utils::H256); @@ -381,6 +443,7 @@ impl StateApiServer for StateApi { } async fn call(&self, method: String, data: String, at: Option) -> RpcResult { + let started_at = Instant::now(); let params = parse_hex_bytes(&data, "data")?; let (block_number, block_hash) = match at { @@ -401,15 +464,67 @@ impl StateApiServer for StateApi { None => self.blockchain.head_snapshot().await, }; + jsonrpsee::tracing::debug!( + method = %method, + block_number = block_number, + block_hash = ?block_hash, + params_len = params.len(), + "state_call: starting" + ); + + // Fast-path revive balance/nonce queries via storage reads. + // + // Asset Hub forks can block for a long time when executing these runtime + // calls locally in WASM. For these two APIs we can derive the same values + // directly from System::Account at the target block, which also preserves + // fork-local `--dev` funding. + if let Some(query) = parse_revive_account_query(&method, ¶ms) { + let address = match query { + ReviveAccountQuery::Balance(address) | ReviveAccountQuery::Nonce(address) => address, + }; + + let fallback_account = crate::dev::ethereum_fallback_account_id(&address); + let fallback_key = crate::dev::account_storage_key(&fallback_account); + let h160_key = crate::dev::account_storage_key(&address); + + let account_info = self + .blockchain + .storage_at(block_number, &fallback_key) + .await + .map_err(|e| RpcServerError::Storage(e.to_string()))? + .or( + self.blockchain + .storage_at(block_number, &h160_key) + .await + .map_err(|e| RpcServerError::Storage(e.to_string()))?, + ); + + if let Some(result) = encode_revive_account_query_result(query, account_info.as_deref()) { + jsonrpsee::tracing::debug!( + method = %method, + block_number = block_number, + elapsed_ms = started_at.elapsed().as_millis(), + "state_call: served via revive storage fast-path" + ); + return Ok(HexString::from_bytes(&result).into()); + } + } + // Proxy metadata runtime API calls to the upstream RPC for performance, // but only for blocks at or before the fork point where the runtime is // guaranteed to match the upstream. Fork-local blocks may have a different // runtime due to upgrades. - if method.starts_with(runtime_api::METADATA_PREFIX) && - block_number <= self.blockchain.fork_point_number() - { + if should_proxy_state_call(&method, block_number, self.blockchain.fork_point_number()) { match self.blockchain.proxy_state_call(&method, ¶ms, block_hash).await { - Ok(result) => return Ok(HexString::from_bytes(&result).into()), + Ok(result) => { + jsonrpsee::tracing::debug!( + method = %method, + block_number = block_number, + elapsed_ms = started_at.elapsed().as_millis(), + "state_call: proxied upstream" + ); + return Ok(HexString::from_bytes(&result).into()); + }, Err(e) => { jsonrpsee::tracing::debug!( "Upstream proxy failed for {method}, falling back to local execution: {e}" @@ -418,11 +533,29 @@ impl StateApiServer for StateApi { } } - match self.blockchain.call_at_block(block_hash, &method, ¶ms).await { + let result = match self.blockchain.call_at_block(block_hash, &method, ¶ms).await { Ok(Some(result)) => Ok(HexString::from_bytes(&result).into()), Ok(None) => Err(RpcServerError::Internal("Call returned no result".to_string()).into()), Err(e) => Err(RpcServerError::Internal(format!("Runtime call failed: {}", e)).into()), + }; + + match &result { + Ok(_) => jsonrpsee::tracing::debug!( + method = %method, + block_number = block_number, + elapsed_ms = started_at.elapsed().as_millis(), + "state_call: completed locally" + ), + Err(error) => jsonrpsee::tracing::debug!( + method = %method, + block_number = block_number, + elapsed_ms = started_at.elapsed().as_millis(), + error = ?error, + "state_call: failed locally" + ), } + + result } async fn query_storage_at( @@ -726,3 +859,116 @@ impl StateApiServer for StateApi { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::{ + ReviveAccountQuery, encode_revive_account_query_result, parse_revive_account_query, + should_proxy_state_call, + }; + use crate::dev::{ALITH, build_account_info}; + use crate::strings::rpc_server::runtime_api; + use scale::Decode; + use sp_core::U256; + + #[test] + fn should_proxy_metadata_calls_at_or_before_fork_point() { + assert!(should_proxy_state_call( + runtime_api::METADATA, + 100, + 100 + )); + assert!(should_proxy_state_call( + runtime_api::METADATA, + 99, + 100 + )); + } + + #[test] + fn should_not_proxy_metadata_calls_after_fork_point() { + assert!(!should_proxy_state_call( + runtime_api::METADATA, + 101, + 100 + )); + } + + #[test] + fn should_proxy_revive_block_gas_limit_at_or_before_fork_point() { + assert!(should_proxy_state_call( + runtime_api::REVIVE_BLOCK_GAS_LIMIT, + 10, + 10 + )); + assert!(should_proxy_state_call( + runtime_api::REVIVE_BLOCK_GAS_LIMIT, + 9, + 10 + )); + } + + #[test] + fn should_proxy_revive_gas_price_at_or_before_fork_point() { + assert!(should_proxy_state_call( + runtime_api::REVIVE_GAS_PRICE, + 10, + 10 + )); + assert!(should_proxy_state_call( + runtime_api::REVIVE_GAS_PRICE, + 9, + 10 + )); + } + + #[test] + fn should_not_proxy_non_whitelisted_runtime_calls() { + assert!(!should_proxy_state_call( + runtime_api::CORE_VERSION, + 10, + 10 + )); + } + + #[test] + fn parse_revive_account_query_recognizes_balance_and_nonce() { + assert_eq!( + parse_revive_account_query(runtime_api::REVIVE_BALANCE, &ALITH), + Some(ReviveAccountQuery::Balance(ALITH)) + ); + assert_eq!( + parse_revive_account_query(runtime_api::REVIVE_NONCE, &ALITH), + Some(ReviveAccountQuery::Nonce(ALITH)) + ); + } + + #[test] + fn encode_revive_account_query_result_uses_account_info_offsets() { + let info = build_account_info(123_456); + let balance = encode_revive_account_query_result( + ReviveAccountQuery::Balance(ALITH), + Some(&info), + ) + .expect("balance should encode"); + let nonce = + encode_revive_account_query_result(ReviveAccountQuery::Nonce(ALITH), Some(&info)) + .expect("nonce should encode"); + + let decoded_balance = + U256::decode(&mut balance.as_slice()).expect("balance should be SCALE U256"); + let decoded_nonce = u32::decode(&mut nonce.as_slice()).expect("nonce should be SCALE u32"); + assert_eq!(decoded_balance, U256::from(123_456u128)); + assert_eq!(decoded_nonce, 0); + } + + #[test] + fn encode_revive_balance_defaults_to_dev_balance_for_known_evm_dev_accounts() { + let balance = + encode_revive_account_query_result(ReviveAccountQuery::Balance(ALITH), None) + .expect("balance should encode"); + let decoded_balance = + U256::decode(&mut balance.as_slice()).expect("balance should be SCALE U256"); + assert_eq!(decoded_balance, U256::from(crate::dev::DEV_BALANCE)); + } +} diff --git a/crates/pop-fork/src/strings/rpc_server.rs b/crates/pop-fork/src/strings/rpc_server.rs index f6ac7f099..0d57ed1f8 100644 --- a/crates/pop-fork/src/strings/rpc_server.rs +++ b/crates/pop-fork/src/strings/rpc_server.rs @@ -63,6 +63,24 @@ pub mod runtime_api { /// Runtime API method for querying detailed fee breakdown. pub const QUERY_FEE_DETAILS: &str = "TransactionPaymentApi_query_fee_details"; + + /// Runtime API method for revive block gas limit. + /// + /// This value is derived from runtime constants and does not depend on + /// fork-local storage changes, so it can be safely proxied at the fork point. + pub const REVIVE_BLOCK_GAS_LIMIT: &str = "ReviveApi_block_gas_limit"; + + /// Runtime API method for revive gas price. + /// + /// This is required by `eth-rpc` during startup and can be safely proxied + /// at the fork point to avoid expensive local runtime execution. + pub const REVIVE_GAS_PRICE: &str = "ReviveApi_gas_price"; + + /// Runtime API method for revive account balance. + pub const REVIVE_BALANCE: &str = "ReviveApi_balance"; + + /// Runtime API method for revive account nonce. + pub const REVIVE_NONCE: &str = "ReviveApi_nonce"; } /// Transaction-related constants.