diff --git a/scripts/tests/api_compare/gen_trace_call_refs.sh b/scripts/tests/api_compare/gen_trace_call_refs.sh new file mode 100644 index 000000000000..8e41b2505731 --- /dev/null +++ b/scripts/tests/api_compare/gen_trace_call_refs.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash + +# Load .env +source .env || { echo "Failed to load .env"; exit 1; } + +# Validate script arguments +[[ -z "$ADDRESS" || -z "$TRACER" || -z "$SEPOLIA_RPC_URL" ]] && { + echo "ERROR: Set ADDRESS, TRACER, SEPOLIA_RPC_URL in .env" + exit 1 +} + +echo "Generating trace_call test suite..." +echo "Tracer: $TRACER" +echo "Caller: $ADDRESS" + +BALANCE=$(cast balance "$ADDRESS" --rpc-url "$SEPOLIA_RPC_URL") +echo "Caller balance: $BALANCE wei" +echo + +# The array of test cases +declare -a TESTS=( + # id:function_name:args:value_hex + "1:setX(uint256):999:" + "2:deposit():" + "3:transfer(address,uint256):0x1111111111111111111111111111111111111111 500:" + "4:callSelf(uint256):999:" + "5:delegateSelf(uint256):777:" + "6:staticRead():" + "7:createChild():" + "8:destroyAndSend():" + "9:keccakIt(bytes32):0x000000000000000000000000000000000000000000000000000000000000abcd:" + "10:doRevert():" +) + +# 0x13880 is 80,000 + +# Remember: trace_call is not a real transaction +# +# It’s a simulation! +# RPC nodes limit gas to prevent: +# - Infinite loops +# - DoS attacks +# - Memory exhaustion + +# We generated reference results using Alchemy provider, so you will likely see params.gas != action.gas +# in the first trace + +# Generate each test reference +for TEST in "${TESTS[@]}"; do + IFS=':' read -r ID FUNC ARGS VALUE_HEX <<< "$TEST" + + echo "test$ID: $FUNC" + + # Encode calldata + if [[ -z "$ARGS" ]]; then + CALLDATA=$(cast calldata "$FUNC") + else + CALLDATA=$(cast calldata "$FUNC" $ARGS) + fi + + # Build payload + if [[ -n "$VALUE_HEX" ]]; then + PAYLOAD=$(jq -n \ + --arg from "$ADDRESS" \ + --arg to "$TRACER" \ + --arg data "$CALLDATA" \ + --arghex value "$VALUE_HEX" \ + '{ + jsonrpc: "2.0", + id: ($id | tonumber), + method: "trace_call", + params: [ + { from: $from, to: $to, data: $data, value: $value, gas: "0x13880" }, + ["trace"], + "latest" + ] + }' --arg id "$ID") + else + PAYLOAD=$(jq -n \ + --arg from "$ADDRESS" \ + --arg to "$TRACER" \ + --arg data "$CALLDATA" \ + '{ + jsonrpc: "2.0", + id: ($id | tonumber), + method: "trace_call", + params: [ + { from: $from, to: $to, data: $data, gas: "0x13880" }, + ["trace"], + "latest" + ] + }' --arg id "$ID") + fi + + # Send request + RESPONSE=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + --data "$PAYLOAD" \ + "$SEPOLIA_RPC_URL") + + # Combine request + response + JSON_TEST=$(jq -n \ + --argjson request "$(echo "$PAYLOAD" | jq '.')" \ + --argjson response "$(echo "$RESPONSE" | jq '.')" \ + '{ request: $request, response: $response }') + + # Save reference file + FILENAME="./refs/test${ID}.json" + echo "$JSON_TEST" | jq . > "$FILENAME" + echo "Saved to $FILENAME" + + echo +done + +echo "All test references have been generated." diff --git a/scripts/tests/trace_call_integration_test.sh b/scripts/tests/trace_call_integration_test.sh new file mode 100755 index 000000000000..63a177415e7f --- /dev/null +++ b/scripts/tests/trace_call_integration_test.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# Trace Call Comparison Test - Compares Forest's trace_call with Anvil's debug_traceCall +# Usage: ./trace_call_integration_test.sh [--deploy] [--verbose] +set -e + +# --- Parse Flags --- +DEPLOY_CONTRACT=false +VERBOSE=false +while [[ $# -gt 0 ]]; do + case $1 in + --deploy) DEPLOY_CONTRACT=true; shift ;; + --verbose) VERBOSE=true; shift ;; + *) echo "Usage: $0 [--deploy] [--verbose]"; exit 1 ;; + esac +done + +# --- Configuration --- +FOREST_RPC_URL="${FOREST_RPC_URL:-http://localhost:2345/rpc/v1}" +ANVIL_RPC_URL="${ANVIL_RPC_URL:-http://localhost:8545}" +FOREST_ACCOUNT="${FOREST_ACCOUNT:- "0xb7aa1e9c847cda5f60f1ae6f65c3eae44848d41f"}" +FOREST_CONTRACT="${FOREST_CONTRACT:- "0x8724d2eb7f86ebaef34e050b02fac6c268e56775"}" +ANVIL_ACCOUNT="${ANVIL_ACCOUNT:-"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"}" +ANVIL_CONTRACT="${ANVIL_CONTRACT:-"0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9"}" +ANVIL_PRIVATE_KEY="${ANVIL_PRIVATE_KEY:- ""}" + +GREEN='\033[0;32m' RED='\033[0;31m' BLUE='\033[0;34m' YELLOW='\033[0;33m' NC='\033[0m' +PASS_COUNT=0 FAIL_COUNT=0 + +# --- Dependency Check --- +command -v jq &>/dev/null || { echo "Error: jq is required"; exit 1; } +command -v curl &>/dev/null || { echo "Error: curl is required"; exit 1; } + +# --- Unified RPC Dispatcher --- +# Single entry point for all RPC calls - removes JSON-RPC boilerplate from test logic +call_rpc() { + local url="$1" method="$2" params="$3" + curl -s -X POST "$url" \ + -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$method\",\"params\":$params}" +} + +# --- RPC Health Check --- +check_rpc() { + local name="$1" url="$2" + local resp=$(call_rpc "$url" "eth_chainId" "[]") + if [ -z "$resp" ] || echo "$resp" | jq -e '.error' &>/dev/null; then + echo -e "${RED}Error: Cannot connect to $name at $url${NC}" + return 1 + fi + return 0 +} + +check_rpc "Forest" "$FOREST_RPC_URL" || exit 1 +check_rpc "Anvil" "$ANVIL_RPC_URL" || exit 1 + +# --- Deploy Contract (if requested) --- +if [ "$DEPLOY_CONTRACT" = true ]; then + command -v forge &>/dev/null || { echo "Error: forge is required for --deploy"; exit 1; } + echo -e "${YELLOW}Deploying Tracer contract on Anvil...${NC}" + CONTRACT_PATH="src/tool/subcommands/api_cmd/contracts/tracer/Tracer.sol" + ANVIL_CONTRACT=$(forge create "$CONTRACT_PATH:Tracer" \ + --rpc-url "$ANVIL_RPC_URL" \ + --private-key "$ANVIL_PRIVATE_KEY" \ + --broadcast --json 2>/dev/null | jq -r '.deployedTo') + echo -e "Deployed to: ${GREEN}$ANVIL_CONTRACT${NC}" +fi + +# --- Normalization Helpers --- +# Convert different node outputs into a standard format for comparison + +# Normalize empty values: null, "", "0x" -> "0x" +normalize_empty() { + local val="$1" + [[ "$val" == "null" || -z "$val" ]] && echo "0x" || echo "$val" +} + +# Get balance change type from Forest's Parity Delta format +# Returns: "unchanged", "changed", "added", or "removed" +get_balance_type() { + local val="$1" + # Handle unchanged cases + if [[ "$val" == "=" || "$val" == "\"=\"" || "$val" == "null" || -z "$val" ]]; then + echo "unchanged" + return + fi + # Check for Delta types + if echo "$val" | jq -e 'has("*")' &>/dev/null; then + echo "changed" + elif echo "$val" | jq -e 'has("+")' &>/dev/null; then + echo "added" + elif echo "$val" | jq -e 'has("-")' &>/dev/null; then + echo "removed" + else + echo "unchanged" + fi +} + +assert_eq() { + local label="$1" f_val="$2" a_val="$3" + + # Normalize: lowercase and treat null/0x/empty as equivalent + local f_norm=$(echo "$f_val" | tr '[:upper:]' '[:lower:]') + local a_norm=$(echo "$a_val" | tr '[:upper:]' '[:lower:]') + [[ "$f_norm" == "null" || -z "$f_norm" ]] && f_norm="0x" + [[ "$a_norm" == "null" || -z "$a_norm" ]] && a_norm="0x" + + if [ "$f_norm" = "$a_norm" ]; then + echo -e " ${GREEN}[PASS]${NC} $label: $f_val" + PASS_COUNT=$((PASS_COUNT + 1)) + else + echo -e " ${RED}[FAIL]${NC} $label: (Forest: $f_val | Anvil: $a_val)" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +assert_both_have_error() { + local f_err="$1" a_err="$2" + if [[ -n "$f_err" && "$f_err" != "null" ]] && [[ -n "$a_err" && "$a_err" != "null" ]]; then + echo -e " ${GREEN}[PASS]${NC} Error: both have error" + PASS_COUNT=$((PASS_COUNT + 1)) + else + echo -e " ${RED}[FAIL]${NC} Error: (Forest: '$f_err' | Anvil: '$a_err')" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +# Compares Forest's trace_call [trace] with Anvil's debug_traceCall (callTracer) +# Types: "standard" (default), "revert", "deep" +test_trace() { + local name="$1" data="$2" type="${3:-standard}" + echo -e "${BLUE}--- $name ---${NC}" + + # Forest: trace_call with trace + local f_params="[{\"from\":\"$FOREST_ACCOUNT\",\"to\":\"$FOREST_CONTRACT\",\"data\":\"$data\"},[\"trace\"],\"latest\"]" + local f_resp=$(call_rpc "$FOREST_RPC_URL" "trace_call" "$f_params") + + # Anvil: debug_traceCall with callTracer + local a_params="[{\"from\":\"$ANVIL_ACCOUNT\",\"to\":\"$ANVIL_CONTRACT\",\"data\":\"$data\"},\"latest\",{\"tracer\":\"callTracer\"}]" + local a_resp=$(call_rpc "$ANVIL_RPC_URL" "debug_traceCall" "$a_params") + + [[ "$VERBOSE" = true ]] && echo -e "${YELLOW}Forest:${NC} $f_resp\n${YELLOW}Anvil:${NC} $a_resp" + + # Extract & compare input (common to all types) + local f_input=$(echo "$f_resp" | jq -r '.result.trace[0].action.input') + local a_input=$(echo "$a_resp" | jq -r '.result.input') + assert_eq "Input" "$f_input" "$a_input" + + # Type-specific comparisons + case $type in + revert) + local f_err=$(echo "$f_resp" | jq -r '.result.trace[0].error // empty') + local a_err=$(echo "$a_resp" | jq -r '.result.error // empty') + assert_both_have_error "$f_err" "$a_err" + ;; + deep) + local f_count=$(echo "$f_resp" | jq -r '.result.trace | length') + local a_count=$(echo "$a_resp" | jq '[.. | objects | select(has("type"))] | length') + assert_eq "TraceCount" "$f_count" "$a_count" + ;; + *) + local f_out=$(normalize_empty "$(echo "$f_resp" | jq -r '.result.trace[0].result.output // .result.output')") + local a_out=$(normalize_empty "$(echo "$a_resp" | jq -r '.result.output')") + local f_sub=$(echo "$f_resp" | jq -r '.result.trace[0].subtraces // 0') + local a_sub=$(echo "$a_resp" | jq -r '.result.calls // [] | length') + assert_eq "Output" "$f_out" "$a_out" + assert_eq "Subcalls" "$f_sub" "$a_sub" + ;; + esac + echo "" +} + +# Compares Forest's trace_call [stateDiff] with Anvil's prestateTracer (diffMode) +test_state_diff() { + local name="$1" data="$2" value="${3:-0x0}" expect="${4:-unchanged}" + echo -e "${BLUE}--- $name (stateDiff) ---${NC}" + + # Forest: trace_call with stateDiff + local f_params="[{\"from\":\"$FOREST_ACCOUNT\",\"to\":\"$FOREST_CONTRACT\",\"data\":\"$data\",\"value\":\"$value\"},[\"stateDiff\"],\"latest\"]" + local f_resp=$(call_rpc "$FOREST_RPC_URL" "trace_call" "$f_params") + + # Anvil: prestateTracer with diffMode + local a_params="[{\"from\":\"$ANVIL_ACCOUNT\",\"to\":\"$ANVIL_CONTRACT\",\"data\":\"$data\",\"value\":\"$value\"},\"latest\",{\"tracer\":\"prestateTracer\",\"tracerConfig\":{\"diffMode\":true}}]" + local a_resp=$(call_rpc "$ANVIL_RPC_URL" "debug_traceCall" "$a_params") + + [[ "$VERBOSE" = true ]] && echo -e "${YELLOW}Forest:${NC} $f_resp\n${YELLOW}Anvil:${NC} $a_resp" + + # Extract contract addresses (lowercase for jq lookup) + local f_contract_lower=$(echo "$FOREST_CONTRACT" | tr '[:upper:]' '[:lower:]') + local a_contract_lower=$(echo "$ANVIL_CONTRACT" | tr '[:upper:]' '[:lower:]') + + # Extract Forest stateDiff balance + local f_diff=$(echo "$f_resp" | jq '.result.stateDiff // {}') + local f_bal=$(echo "$f_diff" | jq -r --arg a "$f_contract_lower" '.[$a].balance // "="') + local f_type=$(get_balance_type "$f_bal") + + # Extract Anvil pre/post balance and determine change type + local a_pre_bal=$(echo "$a_resp" | jq -r --arg a "$a_contract_lower" '.result.pre[$a].balance // "0x0"') + local a_post_bal=$(echo "$a_resp" | jq -r --arg a "$a_contract_lower" '.result.post[$a].balance // "0x0"') + local a_type="unchanged" + [[ "$a_pre_bal" != "$a_post_bal" ]] && a_type="changed" + + # Semantic assertions - compare intent, not raw values + assert_eq "Forest matches Expected" "$f_type" "$expect" + assert_eq "Forest matches Anvil" "$f_type" "$a_type" + echo "" +} + +echo "==============================================" +echo "Trace Call Comparison: Forest vs Anvil" +echo "==============================================" +echo "Forest: $FOREST_RPC_URL | Contract: $FOREST_CONTRACT" +echo "Anvil: $ANVIL_RPC_URL | Contract: $ANVIL_CONTRACT" +echo "" + +# --- Trace Tests --- +echo -e "${BLUE}=== Trace Tests ===${NC}" +echo "" + +test_trace "setX(123)" \ + "0x4018d9aa000000000000000000000000000000000000000000000000000000000000007b" + +test_trace "doRevert()" \ + "0xafc874d2" \ + "revert" + +test_trace "callSelf(999)" \ + "0xa1a8859500000000000000000000000000000000000000000000000000000000000003e7" + +test_trace "complexTrace()" \ + "0x6659ab96" + +test_trace "deepTrace(3)" \ + "0x0f3a17b80000000000000000000000000000000000000000000000000000000000000003" \ + "deep" + +# --- StateDiff Tests --- +echo -e "${BLUE}=== StateDiff Tests ===${NC}" +echo "" + +test_state_diff "deposit() with 1 ETH" \ + "0xd0e30db0" \ + "0xde0b6b3a7640000" \ + "changed" + +test_state_diff "setX(42) no value" \ + "0x4018d9aa000000000000000000000000000000000000000000000000000000000000002a" \ + "0x0" \ + "unchanged" + +# --- Results --- +echo "==============================================" +echo -e "Results: ${GREEN}Passed: $PASS_COUNT${NC} | ${RED}Failed: $FAIL_COUNT${NC}" +[[ $FAIL_COUNT -gt 0 ]] && exit 1 || exit 0 diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index a1cc3120d055..01d19c2499c8 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -79,9 +79,10 @@ use std::num::NonZeroUsize; use std::ops::RangeInclusive; use std::str::FromStr; use std::sync::{Arc, LazyLock}; -use tracing::log; use utils::{decode_payload, lookup_eth_address}; +use nunny::Vec as NonEmpty; + static FOREST_TRACE_FILTER_MAX_RESULT: LazyLock = LazyLock::new(|| env_or_default("FOREST_TRACE_FILTER_MAX_RESULT", 500)); @@ -469,6 +470,35 @@ impl ExtBlockNumberOrHash { } } +#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum EthTraceType { + /// Requests a structured call graph, showing the hierarchy of calls (e.g., `call`, `create`, `reward`) + /// with details like `from`, `to`, `gas`, `input`, `output`, and `subtraces`. + Trace, + /// Requests a state difference object, detailing changes to account states (e.g., `balance`, `nonce`, `storage`, `code`) + /// caused by the simulated transaction. + /// + /// It shows `"from"` and `"to"` values for modified fields, using `"+"`, `"-"`, or `"="` for code changes. + StateDiff, +} + +lotus_json_with_self!(EthTraceType); + +#[derive(PartialEq, Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct EthTraceResults { + /// Output bytes from the transaction execution + pub output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// State diff showing all account changes (only when StateDiff trace type requested) + pub state_diff: Option, + /// Call trace hierarchy (only when Trace trace type requested) + pub trace: Vec, +} + +lotus_json_with_self!(EthTraceResults); + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, GetSize)] #[serde(untagged)] // try a Vec, then a Vec pub enum Transactions { @@ -2105,9 +2135,7 @@ where Err(anyhow::anyhow!("failed to estimate gas: {err}").into()) } Ok(gassed_msg) => { - log::info!("correct gassed_msg: do eth_gas_search {gassed_msg:?}"); let expected_gas = eth_gas_search(ctx, gassed_msg, &tipset.key().into()).await?; - log::info!("trying eth_gas search: {expected_gas}"); Ok(expected_gas.into()) } } @@ -2121,7 +2149,7 @@ async fn apply_message( where DB: Blockstore + Send + Sync + 'static, { - let invoc_res = ctx + let (invoc_res, _) = ctx .state_manager .apply_on_state_with_gas(tipset, msg, StateLookupPolicy::Enabled) .await @@ -2212,7 +2240,7 @@ where DB: Blockstore + Send + Sync + 'static, { msg.gas_limit = limit; - let (_invoc_res, apply_ret, _) = data + let (_invoc_res, apply_ret, _, _) = data .state_manager .call_with_gas( &mut msg.into(), @@ -3872,6 +3900,127 @@ where Ok(all_traces) } +pub enum EthTraceCall {} +impl RpcMethod<3> for EthTraceCall { + const NAME: &'static str = "Forest.EthTraceCall"; + const NAME_ALIAS: Option<&'static str> = Some("trace_call"); + const N_REQUIRED_PARAMS: usize = 1; + const PARAM_NAMES: [&'static str; 3] = ["tx", "traceTypes", "blockParam"]; + const API_PATHS: BitFlags = ApiPaths::all(); + const PERMISSION: Permission = Permission::Read; + const DESCRIPTION: Option<&'static str> = Some("Returns traces created by the transaction."); + + type Params = (EthCallMessage, NonEmpty, BlockNumberOrHash); + type Ok = EthTraceResults; + async fn handle( + ctx: Ctx, + (tx, trace_types, block_param): Self::Params, + ) -> Result { + let msg = Message::try_from(tx)?; + let ts = tipset_by_block_number_or_hash( + ctx.chain_store(), + block_param, + ResolveNullTipset::TakeOlder, + )?; + + let (pre_state_root, _) = ctx + .state_manager + .tipset_state(&ts, StateLookupPolicy::Enabled) + .await + .map_err(|e| anyhow::anyhow!("failed to get tipset state: {e}"))?; + let pre_state = StateTree::new_from_root(ctx.store_owned(), &pre_state_root)?; + + let (invoke_result, post_state_root) = ctx + .state_manager + .apply_on_state_with_gas(Some(ts.clone()), msg.clone(), StateLookupPolicy::Enabled) + .await + .map_err(|e| anyhow::anyhow!("failed to apply message: {e}"))?; + let post_state = StateTree::new_from_root(ctx.store_owned(), &post_state_root)?; + + let mut trace_results = EthTraceResults::default(); + + trace_results.output = get_trace_output(&msg, &invoke_result); + + // Extract touched addresses for state diff (do this before consuming exec_trace) + let touched_addresses = invoke_result + .execution_trace + .as_ref() + .map(extract_touched_eth_addresses) + .unwrap_or_default(); + + // Build call traces if requested + if trace_types.contains(&EthTraceType::Trace) { + if let Some(exec_trace) = invoke_result.execution_trace { + let mut env = trace::base_environment(&post_state, &msg.from()) + .map_err(|e| anyhow::anyhow!("failed to create trace environment: {e}"))?; + trace::build_traces(&mut env, &[], exec_trace)?; + trace_results.trace = env.traces; + } + } + + // Build state diff if requested + if trace_types.contains(&EthTraceType::StateDiff) { + // Add the caller address to touched addresses + let mut all_touched = touched_addresses; + if let Ok(caller_eth) = EthAddress::from_filecoin_address(&msg.from()) { + all_touched.insert(caller_eth); + } + if let Ok(to_eth) = EthAddress::from_filecoin_address(&msg.to()) { + all_touched.insert(to_eth); + } + + let state_diff = + trace::build_state_diff(ctx.store(), &pre_state, &post_state, &all_touched)?; + trace_results.state_diff = Some(state_diff); + } + + Ok(trace_results) + } +} + +/// Get output bytes from trace execution result. +fn get_trace_output(msg: &Message, invoke_result: &ApiInvocResult) -> Option { + if msg.to() == FilecoinAddress::ETHEREUM_ACCOUNT_MANAGER_ACTOR { + return Some(EthBytes::default()); + } + + let msg_rct = invoke_result.msg_rct.as_ref()?; + let return_data = msg_rct.return_data(); + + if return_data.is_empty() { + return Some(EthBytes::default()); + } + + decode_payload(&return_data, CBOR).ok() +} + +/// Maximum number of addresses to track in state diff (safety limit) +static MAX_STATE_DIFF_ADDRESSES: LazyLock = + LazyLock::new(|| env_or_default("FOREST_TRACE_STATE_DIFF_MAX_ADDRESSES", 1000)); + +/// Extract all unique Ethereum addresses touched during execution from the trace. +fn extract_touched_eth_addresses(trace: &crate::rpc::state::ExecutionTrace) -> HashSet { + let mut addresses = HashSet::default(); + extract_addresses_recursive(trace, &mut addresses); + addresses +} + +fn extract_addresses_recursive( + trace: &crate::rpc::state::ExecutionTrace, + addresses: &mut HashSet, +) { + if let Ok(eth_addr) = EthAddress::from_filecoin_address(&trace.msg.from) { + addresses.insert(eth_addr); + } + if let Ok(eth_addr) = EthAddress::from_filecoin_address(&trace.msg.to) { + addresses.insert(eth_addr); + } + + for subcall in &trace.subcalls { + extract_addresses_recursive(subcall, addresses); + } +} + pub enum EthTraceTransaction {} impl RpcMethod<1> for EthTraceTransaction { const NAME: &'static str = "Filecoin.EthTraceTransaction"; diff --git a/src/rpc/methods/eth/trace.rs b/src/rpc/methods/eth/trace.rs index 032f6c09cf54..dd563ee2c587 100644 --- a/src/rpc/methods/eth/trace.rs +++ b/src/rpc/methods/eth/trace.rs @@ -13,12 +13,17 @@ use crate::rpc::methods::state::ExecutionTrace; use crate::rpc::state::ActorTrace; use crate::shim::fvm_shared_latest::METHOD_CONSTRUCTOR; use crate::shim::{actors::is_evm_actor, address::Address, error::ExitCode, state_tree::StateTree}; +use ahash::HashSet; use fil_actor_eam_state::v12 as eam12; use fil_actor_evm_state::v15 as evm12; use fil_actor_init_state::v12::ExecReturn; use fil_actor_init_state::v15::Method as InitMethod; use fvm_ipld_blockstore::Blockstore; +use crate::rpc::eth::types::{AccountDiff, Delta, StateDiff}; +use crate::rpc::eth::{EthBigInt, EthUint64, MAX_STATE_DIFF_ADDRESSES}; +use crate::shim::actors::{EVMActorStateLoad, evm}; +use crate::shim::state_tree::ActorState; use anyhow::{Context, bail}; use num::FromPrimitive; use tracing::debug; @@ -619,3 +624,679 @@ fn trace_evm_private( } } } + +/// Build state diff by comparing pre and post-execution states for touched addresses. +pub(crate) fn build_state_diff( + store: &S, + pre_state: &StateTree, + post_state: &StateTree, + touched_addresses: &HashSet, +) -> anyhow::Result { + let mut state_diff = StateDiff::new(); + + // Limit the number of addresses for safety + let addresses: Vec<_> = touched_addresses + .iter() + .take(*MAX_STATE_DIFF_ADDRESSES) + .collect(); + + for eth_addr in addresses { + let fil_addr = eth_addr.to_filecoin_address()?; + + // Get actor state before and after + let pre_actor = pre_state + .get_actor(&fil_addr) + .map_err(|e| anyhow::anyhow!("failed to get actor state: {e}"))?; + + let post_actor = post_state + .get_actor(&fil_addr) + .map_err(|e| anyhow::anyhow!("failed to get actor state: {e}"))?; + + let account_diff = build_account_diff(store, pre_actor.as_ref(), post_actor.as_ref())?; + + // Only include it if there were actual changes + state_diff.insert_if_changed(*eth_addr, account_diff); + } + + Ok(state_diff) +} + +/// Build account diff by comparing pre and post actor states. +fn build_account_diff( + store: &DB, + pre_actor: Option<&ActorState>, + post_actor: Option<&ActorState>, +) -> anyhow::Result { + let mut diff = AccountDiff::default(); + + // Compare balance + let pre_balance = pre_actor.map(|a| EthBigInt(a.balance.atto().clone())); + let post_balance = post_actor.map(|a| EthBigInt(a.balance.atto().clone())); + diff.balance = Delta::from_comparison(pre_balance, post_balance); + + // Helper to get nonce from actor (uses EVM nonce for EVM actors) + let get_nonce = |actor: &ActorState| -> EthUint64 { + if is_evm_actor(&actor.code) { + EthUint64::from( + evm::State::load(store, actor.code, actor.state) + .map(|s| s.nonce()) + .unwrap_or(actor.sequence), + ) + } else { + EthUint64::from(actor.sequence) + } + }; + + // Helper to get bytecode from EVM actor + let get_bytecode = |actor: &ActorState| -> Option { + if !is_evm_actor(&actor.code) { + return None; + } + + // Load EVM state and get bytecode CID + let evm_state = evm::State::load(store, actor.code, actor.state).ok()?; + // Load actual bytecode from blockstore + store + .get(&evm_state.bytecode()) + .ok() + .flatten() + .map(EthBytes) + }; + + // Compare nonce + let pre_nonce = pre_actor.map(get_nonce); + let post_nonce = post_actor.map(get_nonce); + diff.nonce = Delta::from_comparison(pre_nonce, post_nonce); + + // Compare code (bytecode for EVM actors) + let pre_code = pre_actor.and_then(get_bytecode); + let post_code = post_actor.and_then(get_bytecode); + diff.code = Delta::from_comparison(pre_code, post_code); + + // TODO: implement EVM storage slot comparison + + Ok(diff) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::MemoryDB; + use crate::networks::ACTOR_BUNDLES_METADATA; + use crate::rpc::eth::types::ChangedType; + use crate::shim::address::Address as FilecoinAddress; + use crate::shim::econ::TokenAmount; + use crate::shim::machine::BuiltinActor; + use crate::shim::state_tree::{StateTree, StateTreeVersion}; + use crate::utils::db::CborStoreExt as _; + use ahash::HashSetExt as _; + use cid::Cid; + use num::BigInt; + use std::sync::Arc; + + fn create_test_actor(balance_atto: u64, sequence: u64) -> ActorState { + ActorState::new( + Cid::default(), // Non-EVM actor code CID + Cid::default(), // State CID (not used for non-EVM) + TokenAmount::from_atto(balance_atto), + sequence, + None, // No delegated address + ) + } + + fn get_evm_actor_code_cid() -> Option { + for bundle in ACTOR_BUNDLES_METADATA.values() { + if bundle.actor_major_version().ok() == Some(17) { + if let Ok(cid) = bundle.manifest.get(BuiltinActor::EVM) { + return Some(cid); + } + } + } + None + } + + fn create_evm_actor_with_bytecode( + store: &MemoryDB, + balance_atto: u64, + actor_sequence: u64, + evm_nonce: u64, + bytecode: Option<&[u8]>, + ) -> Option { + use fvm_ipld_blockstore::Blockstore as _; + + let evm_code_cid = get_evm_actor_code_cid()?; + + // Store bytecode as raw bytes (not CBOR-encoded) + let bytecode_cid = if let Some(code) = bytecode { + use multihash_codetable::MultihashDigest; + let mh = multihash_codetable::Code::Blake2b256.digest(code); + let cid = Cid::new_v1(fvm_ipld_encoding::IPLD_RAW, mh); + store.put_keyed(&cid, code).ok()?; + cid + } else { + Cid::default() + }; + + let bytecode_hash = if let Some(code) = bytecode { + use keccak_hash::keccak; + let hash = keccak(code); + fil_actor_evm_state::v17::BytecodeHash::from(hash.0) + } else { + fil_actor_evm_state::v17::BytecodeHash::EMPTY + }; + + let evm_state = fil_actor_evm_state::v17::State { + bytecode: bytecode_cid, + bytecode_hash, + contract_state: Cid::default(), + transient_data: None, + nonce: evm_nonce, + tombstone: None, + }; + + let state_cid = store.put_cbor_default(&evm_state).ok()?; + + Some(ActorState::new( + evm_code_cid, + state_cid, + TokenAmount::from_atto(balance_atto), + actor_sequence, + None, + )) + } + + fn create_masked_id_eth_address(actor_id: u64) -> EthAddress { + EthAddress::from_actor_id(actor_id) + } + + struct TestStateTrees { + store: Arc, + pre_state: StateTree, + post_state: StateTree, + } + + impl TestStateTrees { + fn new() -> anyhow::Result { + let store = Arc::new(MemoryDB::default()); + // Use V4 which creates FvmV2 state trees that allow direct set_actor + let pre_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; + let post_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; + Ok(Self { + store, + pre_state, + post_state, + }) + } + + /// Create state trees with different actors in pre and post. + fn with_changed_actor( + actor_id: u64, + pre_actor: ActorState, + post_actor: ActorState, + ) -> anyhow::Result { + let store = Arc::new(MemoryDB::default()); + let mut pre_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; + let mut post_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; + let addr = FilecoinAddress::new_id(actor_id); + pre_state.set_actor(&addr, pre_actor)?; + post_state.set_actor(&addr, post_actor)?; + Ok(Self { + store, + pre_state, + post_state, + }) + } + + /// Create state trees with actor only in post (creation scenario). + fn with_created_actor(actor_id: u64, post_actor: ActorState) -> anyhow::Result { + let store = Arc::new(MemoryDB::default()); + let pre_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; + let mut post_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; + let addr = FilecoinAddress::new_id(actor_id); + post_state.set_actor(&addr, post_actor)?; + Ok(Self { + store, + pre_state, + post_state, + }) + } + + /// Create state trees with actor only in pre (deletion scenario). + fn with_deleted_actor(actor_id: u64, pre_actor: ActorState) -> anyhow::Result { + let store = Arc::new(MemoryDB::default()); + let mut pre_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; + let post_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; + let addr = FilecoinAddress::new_id(actor_id); + pre_state.set_actor(&addr, pre_actor)?; + Ok(Self { + store, + pre_state, + post_state, + }) + } + + /// Build state diff for given touched addresses. + fn build_diff(&self, touched_addresses: &HashSet) -> anyhow::Result { + build_state_diff( + self.store.as_ref(), + &self.pre_state, + &self.post_state, + touched_addresses, + ) + } + } + + #[test] + fn test_build_state_diff_empty_touched_addresses() { + let trees = TestStateTrees::new().unwrap(); + let touched_addresses = HashSet::new(); + + let state_diff = trees.build_diff(&touched_addresses).unwrap(); + + // No addresses touched = empty state diff + assert!(state_diff.0.is_empty()); + } + + #[test] + fn test_build_state_diff_nonexistent_address() { + let trees = TestStateTrees::new().unwrap(); + let mut touched_addresses = HashSet::new(); + touched_addresses.insert(create_masked_id_eth_address(9999)); + + let state_diff = trees.build_diff(&touched_addresses).unwrap(); + + // Address doesn't exist in either state, so no diff (both None = unchanged) + assert!(state_diff.0.is_empty()); + } + + #[test] + fn test_build_state_diff_balance_increase() { + let actor_id = 1001u64; + let pre_actor = create_test_actor(1000, 5); + let post_actor = create_test_actor(2000, 5); + let trees = TestStateTrees::with_changed_actor(actor_id, pre_actor, post_actor).unwrap(); + + let mut touched_addresses = HashSet::new(); + touched_addresses.insert(create_masked_id_eth_address(actor_id)); + + let state_diff = trees.build_diff(&touched_addresses).unwrap(); + + assert_eq!(state_diff.0.len(), 1); + let eth_addr = create_masked_id_eth_address(actor_id); + let diff = state_diff.0.get(ð_addr).unwrap(); + match &diff.balance { + Delta::Changed(change) => { + assert_eq!(change.from.0, BigInt::from(1000)); + assert_eq!(change.to.0, BigInt::from(2000)); + } + _ => panic!("Expected Delta::Changed for balance"), + } + assert!(diff.nonce.is_unchanged()); + } + + #[test] + fn test_build_state_diff_balance_decrease() { + let actor_id = 1002u64; + let pre_actor = create_test_actor(5000, 10); + let post_actor = create_test_actor(3000, 10); + let trees = TestStateTrees::with_changed_actor(actor_id, pre_actor, post_actor).unwrap(); + + let mut touched_addresses = HashSet::new(); + touched_addresses.insert(create_masked_id_eth_address(actor_id)); + + let state_diff = trees.build_diff(&touched_addresses).unwrap(); + + let eth_addr = create_masked_id_eth_address(actor_id); + let diff = state_diff.0.get(ð_addr).unwrap(); + match &diff.balance { + Delta::Changed(change) => { + assert_eq!(change.from.0, BigInt::from(5000)); + assert_eq!(change.to.0, BigInt::from(3000)); + } + _ => panic!("Expected Delta::Changed for balance"), + } + assert!(diff.nonce.is_unchanged()); + } + + #[test] + fn test_build_state_diff_nonce_increment() { + let actor_id = 1003u64; + let pre_actor = create_test_actor(1000, 5); + let post_actor = create_test_actor(1000, 6); + let trees = TestStateTrees::with_changed_actor(actor_id, pre_actor, post_actor).unwrap(); + + let mut touched_addresses = HashSet::new(); + touched_addresses.insert(create_masked_id_eth_address(actor_id)); + + let state_diff = trees.build_diff(&touched_addresses).unwrap(); + + let eth_addr = create_masked_id_eth_address(actor_id); + let diff = state_diff.0.get(ð_addr).unwrap(); + assert!(diff.balance.is_unchanged()); + match &diff.nonce { + Delta::Changed(change) => { + assert_eq!(change.from.0, 5); + assert_eq!(change.to.0, 6); + } + _ => panic!("Expected Delta::Changed for nonce"), + } + } + + #[test] + fn test_build_state_diff_both_balance_and_nonce_change() { + let actor_id = 1004u64; + let pre_actor = create_test_actor(10000, 100); + let post_actor = create_test_actor(9000, 101); + let trees = TestStateTrees::with_changed_actor(actor_id, pre_actor, post_actor).unwrap(); + + let mut touched_addresses = HashSet::new(); + touched_addresses.insert(create_masked_id_eth_address(actor_id)); + + let state_diff = trees.build_diff(&touched_addresses).unwrap(); + + let eth_addr = create_masked_id_eth_address(actor_id); + let diff = state_diff.0.get(ð_addr).unwrap(); + match &diff.balance { + Delta::Changed(change) => { + assert_eq!(change.from.0, BigInt::from(10000)); + assert_eq!(change.to.0, BigInt::from(9000)); + } + _ => panic!("Expected Delta::Changed for balance"), + } + match &diff.nonce { + Delta::Changed(change) => { + assert_eq!(change.from.0, 100); + assert_eq!(change.to.0, 101); + } + _ => panic!("Expected Delta::Changed for nonce"), + } + } + + #[test] + fn test_build_state_diff_account_creation() { + let actor_id = 1005u64; + let post_actor = create_test_actor(5000, 0); + let trees = TestStateTrees::with_created_actor(actor_id, post_actor).unwrap(); + + let mut touched_addresses = HashSet::new(); + touched_addresses.insert(create_masked_id_eth_address(actor_id)); + + let state_diff = trees.build_diff(&touched_addresses).unwrap(); + + let eth_addr = create_masked_id_eth_address(actor_id); + let diff = state_diff.0.get(ð_addr).unwrap(); + match &diff.balance { + Delta::Added(balance) => { + assert_eq!(balance.0, BigInt::from(5000)); + } + _ => panic!("Expected Delta::Added for balance"), + } + match &diff.nonce { + Delta::Added(nonce) => { + assert_eq!(nonce.0, 0); + } + _ => panic!("Expected Delta::Added for nonce"), + } + } + + #[test] + fn test_build_state_diff_account_deletion() { + let actor_id = 1006u64; + let pre_actor = create_test_actor(3000, 10); + let trees = TestStateTrees::with_deleted_actor(actor_id, pre_actor).unwrap(); + + let mut touched_addresses = HashSet::new(); + touched_addresses.insert(create_masked_id_eth_address(actor_id)); + + let state_diff = trees.build_diff(&touched_addresses).unwrap(); + + let eth_addr = create_masked_id_eth_address(actor_id); + let diff = state_diff.0.get(ð_addr).unwrap(); + match &diff.balance { + Delta::Removed(balance) => { + assert_eq!(balance.0, BigInt::from(3000)); + } + _ => panic!("Expected Delta::Removed for balance"), + } + match &diff.nonce { + Delta::Removed(nonce) => { + assert_eq!(nonce.0, 10); + } + _ => panic!("Expected Delta::Removed for nonce"), + } + } + + #[test] + fn test_build_state_diff_multiple_addresses() { + let store = Arc::new(MemoryDB::default()); + let mut pre_state = StateTree::new(store.clone(), StateTreeVersion::V5).unwrap(); + let mut post_state = StateTree::new(store.clone(), StateTreeVersion::V5).unwrap(); + + // Actor 1: balance increase + let addr1 = FilecoinAddress::new_id(2001); + pre_state + .set_actor(&addr1, create_test_actor(1000, 0)) + .unwrap(); + post_state + .set_actor(&addr1, create_test_actor(2000, 0)) + .unwrap(); + + // Actor 2: nonce increase + let addr2 = FilecoinAddress::new_id(2002); + pre_state + .set_actor(&addr2, create_test_actor(500, 5)) + .unwrap(); + post_state + .set_actor(&addr2, create_test_actor(500, 6)) + .unwrap(); + + // Actor 3: no change (should not appear in diff) + let addr3 = FilecoinAddress::new_id(2003); + pre_state + .set_actor(&addr3, create_test_actor(100, 1)) + .unwrap(); + post_state + .set_actor(&addr3, create_test_actor(100, 1)) + .unwrap(); + + let mut touched_addresses = HashSet::new(); + touched_addresses.insert(create_masked_id_eth_address(2001)); + touched_addresses.insert(create_masked_id_eth_address(2002)); + touched_addresses.insert(create_masked_id_eth_address(2003)); + + let state_diff = + build_state_diff(store.as_ref(), &pre_state, &post_state, &touched_addresses).unwrap(); + + assert_eq!(state_diff.0.len(), 2); + assert!( + state_diff + .0 + .contains_key(&create_masked_id_eth_address(2001)) + ); + assert!( + state_diff + .0 + .contains_key(&create_masked_id_eth_address(2002)) + ); + assert!( + !state_diff + .0 + .contains_key(&create_masked_id_eth_address(2003)) + ); + } + + #[test] + fn test_build_state_diff_evm_actor_scenarios() { + struct TestCase { + name: &'static str, + pre: Option<(u64, u64, Option<&'static [u8]>)>, // balance, nonce, bytecode + post: Option<(u64, u64, Option<&'static [u8]>)>, + expected_balance: Delta, + expected_nonce: Delta, + expected_code: Delta, + } + + let bytecode1: &[u8] = &[0x60, 0x80, 0x60, 0x40, 0x52]; + let bytecode2: &[u8] = &[0x60, 0x80, 0x60, 0x40, 0x52, 0x00]; + + let cases = vec![ + TestCase { + name: "No change", + pre: Some((1000, 5, Some(bytecode1))), + post: Some((1000, 5, Some(bytecode1))), + expected_balance: Delta::Unchanged, + expected_nonce: Delta::Unchanged, + expected_code: Delta::Unchanged, + }, + TestCase { + name: "Balance increase", + pre: Some((1000, 5, Some(bytecode1))), + post: Some((2000, 5, Some(bytecode1))), + expected_balance: Delta::Changed(ChangedType { + from: EthBigInt(BigInt::from(1000)), + to: EthBigInt(BigInt::from(2000)), + }), + expected_nonce: Delta::Unchanged, + expected_code: Delta::Unchanged, + }, + TestCase { + name: "Nonce increment", + pre: Some((1000, 5, Some(bytecode1))), + post: Some((1000, 6, Some(bytecode1))), + expected_balance: Delta::Unchanged, + expected_nonce: Delta::Changed(ChangedType { + from: EthUint64(5), + to: EthUint64(6), + }), + expected_code: Delta::Unchanged, + }, + TestCase { + name: "Bytecode change", + pre: Some((1000, 5, Some(bytecode1))), + post: Some((1000, 5, Some(bytecode2))), + expected_balance: Delta::Unchanged, + expected_nonce: Delta::Unchanged, + expected_code: Delta::Changed(ChangedType { + from: EthBytes(bytecode1.to_vec()), + to: EthBytes(bytecode2.to_vec()), + }), + }, + TestCase { + name: "Balance and Nonce change", + pre: Some((1000, 5, Some(bytecode1))), + post: Some((2000, 6, Some(bytecode1))), + expected_balance: Delta::Changed(ChangedType { + from: EthBigInt(BigInt::from(1000)), + to: EthBigInt(BigInt::from(2000)), + }), + expected_nonce: Delta::Changed(ChangedType { + from: EthUint64(5), + to: EthUint64(6), + }), + expected_code: Delta::Unchanged, + }, + TestCase { + name: "Creation", + pre: None, + post: Some((5000, 0, Some(bytecode1))), + expected_balance: Delta::Added(EthBigInt(BigInt::from(5000))), + expected_nonce: Delta::Added(EthUint64(0)), + expected_code: Delta::Added(EthBytes(bytecode1.to_vec())), + }, + TestCase { + name: "Deletion", + pre: Some((3000, 10, Some(bytecode1))), + post: None, + expected_balance: Delta::Removed(EthBigInt(BigInt::from(3000))), + expected_nonce: Delta::Removed(EthUint64(10)), + expected_code: Delta::Removed(EthBytes(bytecode1.to_vec())), + }, + ]; + + for case in cases { + let store = Arc::new(MemoryDB::default()); + let actor_id = 10000u64; // arbitrary ID + + let pre_actor = case.pre.and_then(|(bal, nonce, code)| { + create_evm_actor_with_bytecode(&store, bal, 0, nonce, code) + }); + let post_actor = case.post.and_then(|(bal, nonce, code)| { + create_evm_actor_with_bytecode(&store, bal, 0, nonce, code) + }); + + let mut pre_state = StateTree::new(store.clone(), StateTreeVersion::V5).unwrap(); + let mut post_state = StateTree::new(store.clone(), StateTreeVersion::V5).unwrap(); + let addr = FilecoinAddress::new_id(actor_id); + + if let Some(actor) = pre_actor { + pre_state.set_actor(&addr, actor).unwrap(); + } + if let Some(actor) = post_actor { + post_state.set_actor(&addr, actor).unwrap(); + } + + let mut touched_addresses = HashSet::new(); + touched_addresses.insert(create_masked_id_eth_address(actor_id)); + + let state_diff = + build_state_diff(store.as_ref(), &pre_state, &post_state, &touched_addresses) + .unwrap(); + + if case.expected_balance == Delta::Unchanged + && case.expected_nonce == Delta::Unchanged + && case.expected_code == Delta::Unchanged + { + assert!( + state_diff.0.is_empty(), + "Test case '{}' failed: expected empty diff", + case.name + ); + } else { + let eth_addr = create_masked_id_eth_address(actor_id); + let diff = state_diff.0.get(ð_addr).unwrap_or_else(|| { + panic!("Test case '{}' failed: missing diff entry", case.name) + }); + + assert_eq!( + diff.balance, case.expected_balance, + "Test case '{}' failed: balance mismatch", + case.name + ); + assert_eq!( + diff.nonce, case.expected_nonce, + "Test case '{}' failed: nonce mismatch", + case.name + ); + assert_eq!( + diff.code, case.expected_code, + "Test case '{}' failed: code mismatch", + case.name + ); + } + } + } + + #[test] + fn test_build_state_diff_non_evm_actor_no_code() { + // Non-EVM actors should have no code in their diff + let actor_id = 4005u64; + let pre_actor = create_test_actor(1000, 5); + let post_actor = create_test_actor(2000, 6); + let trees = TestStateTrees::with_changed_actor(actor_id, pre_actor, post_actor).unwrap(); + + let mut touched_addresses = HashSet::new(); + touched_addresses.insert(create_masked_id_eth_address(actor_id)); + + let state_diff = trees.build_diff(&touched_addresses).unwrap(); + + let eth_addr = create_masked_id_eth_address(actor_id); + let diff = state_diff.0.get(ð_addr).unwrap(); + + // Balance and nonce should change + assert!(!diff.balance.is_unchanged()); + assert!(!diff.nonce.is_unchanged()); + + // Code should be unchanged (None -> None for non-EVM actors) + assert!(diff.code.is_unchanged()); + } +} diff --git a/src/rpc/methods/eth/types.rs b/src/rpc/methods/eth/types.rs index 4db4e44fc967..015b2afae1af 100644 --- a/src/rpc/methods/eth/types.rs +++ b/src/rpc/methods/eth/types.rs @@ -11,6 +11,7 @@ use jsonrpsee::types::SubscriptionId; use libsecp256k1::util::FULL_PUBLIC_KEY_SIZE; use rand::Rng; use serde::de::{IntoDeserializer, value::StringDeserializer}; +use std::collections::BTreeMap; use std::{hash::Hash, ops::Deref}; pub const METHOD_GET_BYTE_CODE: u64 = 3; @@ -103,11 +104,14 @@ impl GetStorageAtParams { Eq, Hash, PartialEq, + PartialOrd, + Ord, Debug, Deserialize, Serialize, Default, Clone, + Copy, JsonSchema, derive_more::From, derive_more::Into, @@ -379,12 +383,15 @@ impl TryFrom for Message { #[derive( PartialEq, Eq, + PartialOrd, + Ord, Hash, Debug, Deserialize, Serialize, Default, Clone, + Copy, JsonSchema, derive_more::Display, derive_more::From, @@ -748,6 +755,107 @@ impl EthTrace { } } +/// Represents a changed value with before and after states. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ChangedType { + /// Value before the change + pub from: T, + /// Value after the change + pub to: T, +} + +/// Represents how a value changed during transaction execution. +// Taken from https://github.com/alloy-rs/alloy/blob/v1.5.2/crates/rpc-types-trace/src/parity.rs#L84 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum Delta { + /// Existing value didn't change. + #[serde(rename = "=")] + Unchanged, + /// A new value was added (account/storage created). + #[serde(rename = "+")] + Added(T), + /// The existing value was removed (account/storage deleted). + #[serde(rename = "-")] + Removed(T), + /// The existing value changed from one value to another. + #[serde(rename = "*")] + Changed(ChangedType), +} + +impl Default for Delta { + fn default() -> Self { + Delta::Unchanged + } +} + +impl Delta { + pub fn from_comparison(old: Option, new: Option) -> Self { + match (old, new) { + (None, None) => Delta::Unchanged, + (None, Some(new_val)) => Delta::Added(new_val), + (Some(old_val), None) => Delta::Removed(old_val), + (Some(old_val), Some(new_val)) => { + if old_val == new_val { + Delta::Unchanged + } else { + Delta::Changed(ChangedType { + from: old_val, + to: new_val, + }) + } + } + } + } + + pub fn is_unchanged(&self) -> bool { + matches!(self, Delta::Unchanged) + } +} + +/// Account state diff after transaction execution. +/// Tracks changes to balance, nonce, code, and storage. +// Taken from https://github.com/alloy-rs/alloy/blob/v1.5.2/crates/rpc-types-trace/src/parity.rs#L156 +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct AccountDiff { + pub balance: Delta, + pub code: Delta, + pub nonce: Delta, + /// All touched/changed storage values (key -> delta) + pub storage: BTreeMap>, +} + +impl AccountDiff { + pub fn is_unchanged(&self) -> bool { + self.balance.is_unchanged() + && self.code.is_unchanged() + && self.nonce.is_unchanged() + && self.storage.is_empty() + } +} + +/// State diff containing all account changes from a transaction. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct StateDiff(pub BTreeMap); + +impl StateDiff { + pub fn new() -> Self { + Self(BTreeMap::new()) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn insert_if_changed(&mut self, addr: EthAddress, diff: AccountDiff) { + if !diff.is_unchanged() { + self.0.insert(addr, diff); + } + } +} + +lotus_json_with_self!(StateDiff); + #[cfg(test)] mod tests { use super::*; diff --git a/src/rpc/methods/gas.rs b/src/rpc/methods/gas.rs index f0a06fd40a93..2edd8ea71b5d 100644 --- a/src/rpc/methods/gas.rs +++ b/src/rpc/methods/gas.rs @@ -254,7 +254,7 @@ impl GasEstimateGasLimit { _ => ChainMessage::Unsigned(msg), }; - let (invoc_res, apply_ret, _) = data + let (invoc_res, apply_ret, _, _) = data .state_manager .call_with_gas( &mut chain_msg, diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index e7d55df8e16b..a325a11f96a1 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -152,6 +152,7 @@ macro_rules! for_each_rpc_method { $callback!($crate::rpc::eth::EthSyncing); $callback!($crate::rpc::eth::EthTraceBlock); $callback!($crate::rpc::eth::EthTraceBlockV2); + $callback!($crate::rpc::eth::EthTraceCall); $callback!($crate::rpc::eth::EthTraceFilter); $callback!($crate::rpc::eth::EthTraceTransaction); $callback!($crate::rpc::eth::EthTraceReplayBlockTransactions); diff --git a/src/shim/actors/builtin/evm/mod.rs b/src/shim/actors/builtin/evm/mod.rs index a8cca3e8309a..68b0f50de27a 100644 --- a/src/shim/actors/builtin/evm/mod.rs +++ b/src/shim/actors/builtin/evm/mod.rs @@ -52,6 +52,14 @@ impl State { pub fn is_alive(&self) -> bool { delegate_state!(self.tombstone.is_none()) } + + pub fn bytecode(&self) -> Cid { + delegate_state!(self.bytecode) + } + + pub fn bytecode_hash(&self) -> [u8; 32] { + delegate_state!(self.bytecode_hash.into()) + } } #[delegated_enum(impl_conversions)] diff --git a/src/state_manager/mod.rs b/src/state_manager/mod.rs index 7256d5b04de4..2cf27e0b6f52 100644 --- a/src/state_manager/mod.rs +++ b/src/state_manager/mod.rs @@ -20,8 +20,7 @@ use crate::chain::{ index::{ChainIndex, ResolveNullTipset}, }; use crate::interpreter::{ - ApplyResult, BlockMessages, CalledAt, ExecutionContext, IMPLICIT_MESSAGE_GAS_LIMIT, VM, - resolve_to_key_addr, + BlockMessages, CalledAt, ExecutionContext, IMPLICIT_MESSAGE_GAS_LIMIT, VM, resolve_to_key_addr, }; use crate::interpreter::{MessageCallbackCtx, VMTrace}; use crate::lotus_json::{LotusJson, lotus_json_with_self}; @@ -684,7 +683,7 @@ where tipset: Option, msg: Message, state_lookup: StateLookupPolicy, - ) -> anyhow::Result { + ) -> anyhow::Result<(ApiInvocResult, Cid)> { let ts = tipset.unwrap_or_else(|| self.heaviest_tipset()); let from_a = self.resolve_to_key_addr(&msg.from, &ts).await?; @@ -706,18 +705,23 @@ where _ => ChainMessage::Unsigned(msg.clone()), }; - let (_invoc_res, apply_ret, duration) = self + let (_invoc_res, apply_ret, duration, state_root) = self .call_with_gas(&mut chain_msg, &[], Some(ts), VMTrace::Traced, state_lookup) .await?; - Ok(ApiInvocResult { - msg_cid: msg.cid(), - msg, - msg_rct: Some(apply_ret.msg_receipt()), - error: apply_ret.failure_info().unwrap_or_default(), - duration: duration.as_nanos().clamp(0, u64::MAX as u128) as u64, - gas_cost: MessageGasCost::default(), - execution_trace: structured::parse_events(apply_ret.exec_trace()).unwrap_or_default(), - }) + + Ok(( + ApiInvocResult { + msg_cid: msg.cid(), + msg, + msg_rct: Some(apply_ret.msg_receipt()), + error: apply_ret.failure_info().unwrap_or_default(), + duration: duration.as_nanos().clamp(0, u64::MAX as u128) as u64, + gas_cost: MessageGasCost::default(), + execution_trace: structured::parse_events(apply_ret.exec_trace()) + .unwrap_or_default(), + }, + state_root, + )) } /// Computes message on the given [Tipset] state, after applying other @@ -729,7 +733,7 @@ where tipset: Option, trace_config: VMTrace, state_lookup: StateLookupPolicy, - ) -> Result<(InvocResult, ApplyRet, Duration), Error> { + ) -> Result<(InvocResult, ApplyRet, Duration, Cid), Error> { let ts = tipset.unwrap_or_else(|| self.heaviest_tipset()); let (st, _) = self .tipset_state(&ts, state_lookup) @@ -743,7 +747,7 @@ where let genesis_info = GenesisInfo::from_chain_config(self.chain_config().clone()); // FVM requires a stack size of 64MiB. The alternative is to use `ThreadedExecutor` from // FVM, but that introduces some constraints, and possible deadlocks. - let (ret, duration) = stacker::grow(64 << 20, || -> ApplyResult { + let (ret, duration, state_cid) = stacker::grow(64 << 20, || -> anyhow::Result<_> { let mut vm = VM::new( ExecutionContext { heaviest_tipset: ts.clone(), @@ -771,14 +775,18 @@ where .get_actor(&message.from()) .map_err(|e| Error::Other(format!("Could not get actor from state: {e}")))? .ok_or_else(|| Error::Other("cant find actor in state tree".to_string()))?; + message.set_sequence(from_actor.sequence); - vm.apply_message(message) + let (ret, duration) = vm.apply_message(message)?; + let state_root = vm.flush()?; + Ok((ret, duration, state_root)) })?; Ok(( InvocResult::new(message.message().clone(), &ret), ret, duration, + state_cid, )) } diff --git a/src/tool/subcommands/api_cmd/api_compare_tests.rs b/src/tool/subcommands/api_cmd/api_compare_tests.rs index 690c9df2ff02..856cddb7b918 100644 --- a/src/tool/subcommands/api_cmd/api_compare_tests.rs +++ b/src/tool/subcommands/api_cmd/api_compare_tests.rs @@ -12,8 +12,8 @@ use crate::rpc::FilterList; use crate::rpc::auth::AuthNewParams; use crate::rpc::beacon::BeaconGetEntry; use crate::rpc::eth::{ - BlockNumberOrHash, EthInt64, ExtBlockNumberOrHash, ExtPredefined, Predefined, - new_eth_tx_from_signed_message, types::*, + BlockNumberOrHash, EthInt64, EthTraceType, EthUint64, ExtBlockNumberOrHash, ExtPredefined, + Predefined, new_eth_tx_from_signed_message, types::*, }; use crate::rpc::gas::{GasEstimateGasLimit, GasEstimateMessageGas}; use crate::rpc::miner::BlockTemplate; @@ -1529,6 +1529,42 @@ fn eth_tests() -> Vec { FilecoinAddressToEthAddress::request((*KNOWN_CALIBNET_F4_ADDRESS, None)).unwrap(), )); } + + let cases = [( + EthBytes::from_str( + "0x4018d9aa00000000000000000000000000000000000000000000000000000000000003e7", + ) + .unwrap(), + false, + )]; + + for (input, state_diff) in cases { + tests.push(RpcTest::identity( + EthTraceCall::request(( + EthCallMessage { + from: Some( + EthAddress::from_str("0x1111111111111111111111111111111111111111").unwrap(), + ), + to: Some( + EthAddress::from_str("0x4A38E58A3602D057c8aC2c4D76f0C45CFF3b5f56").unwrap(), + ), + data: Some(input), + gas: Some( + EthUint64(0x13880), // 80,000 + ), + ..Default::default() + }, + if state_diff { + nunny::vec![EthTraceType::Trace, EthTraceType::StateDiff] + } else { + nunny::vec![EthTraceType::Trace] + }, + BlockNumberOrHash::PredefinedBlock(Predefined::Latest), + )) + .unwrap(), + )); + } + tests } @@ -1585,6 +1621,28 @@ fn eth_tests_with_tipset(store: &Arc, shared_tipset: &Tipset let block_hash: EthHash = block_cid.into(); let mut tests = vec![ + RpcTest::identity( + EthTraceCall::request(( + EthCallMessage { + from: Some( + EthAddress::from_str("0x1111111111111111111111111111111111111111").unwrap(), + ), + to: Some( + EthAddress::from_str("0x4A38E58A3602D057c8aC2c4D76f0C45CFF3b5f56").unwrap(), + ), + data: Some( + EthBytes::from_str("0x4018d9aa00000000000000000000000000000000000000000000000000000000000003e7").unwrap() + ), + gas: Some( + EthUint64(0x13880) // 80,000 + ), + ..Default::default() + }, + nunny::vec![EthTraceType::Trace], + BlockNumberOrHash::PredefinedBlock(Predefined::Latest), + )) + .unwrap(), + ), RpcTest::identity( EthGetBalance::request(( EthAddress::from_str("0xff38c072f286e3b20b3954ca9f99c05fbecc64aa").unwrap(), diff --git a/src/tool/subcommands/api_cmd/contracts/tracer/Tracer.sol b/src/tool/subcommands/api_cmd/contracts/tracer/Tracer.sol new file mode 100644 index 000000000000..e9aef4dc0c24 --- /dev/null +++ b/src/tool/subcommands/api_cmd/contracts/tracer/Tracer.sol @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +contract Tracer { + uint256 public x; + mapping(address => uint256) public balances; + event Transfer(address indexed from, address indexed to, uint256 value); + + constructor() payable { + x = 42; + } + + // Allow contract to receive ETH + receive() external payable {} + + // 1. Simple storage write + function setX(uint256 _x) external { + x = _x; + } + + // 2. Balance update (SSTORE) - Contract receives ETH + function deposit() external payable { + balances[msg.sender] = msg.value; + } + + // 2b. Send ETH to address - Tests balance decrease/increase + function sendEth(address payable to) external payable { + to.transfer(msg.value); + } + + // 2c. Withdraw ETH - Contract sends ETH to caller + function withdraw(uint256 amount) external { + require( + address(this).balance >= amount, + "insufficient contract balance" + ); + payable(msg.sender).transfer(amount); + } + + // 3. Transfer between two accounts (SSTORE x2) + function transfer(address to, uint256 amount) external { + require(balances[msg.sender] >= amount, "insufficient balance"); + balances[msg.sender] -= amount; + balances[to] += amount; + emit Transfer(msg.sender, to, amount); + } + + // 4. CALL (external call to self – creates CALL opcode) + function callSelf(uint256 _x) external { + (bool ok, ) = address(this).call( + abi.encodeWithSelector(this.setX.selector, _x) + ); + require(ok, "call failed"); + } + + // 5. DELEGATECALL (to self – shows delegatecall trace) + function delegateSelf(uint256 _x) external { + (bool ok, ) = address(this).delegatecall( + abi.encodeWithSelector(this.setX.selector, _x) + ); + require(ok, "delegatecall failed"); + } + + // 6. STATICCALL (read-only) + function staticRead() external view returns (uint256) { + return x; + } + + // 7. CREATE (deploy a tiny contract) + function createChild() external returns (address child) { + bytes + memory code = hex"6080604052348015600f57600080fd5b5060019050601c806100226000396000f3fe6080604052"; + assembly { + child := create(0, add(code, 0x20), 0x1c) + } + } + + // 8. SELFDESTRUCT (send ETH to caller) + // Deprecated (EIP-6780): selfdestruct only sends ETH (code & storage stay) + function destroyAndSend() external { + selfdestruct(payable(msg.sender)); + } + + // 9. Precompile use – keccak256 + function keccakIt(bytes32 input) external pure returns (bytes32) { + return keccak256(abi.encodePacked(input)); + } + + // 10. Revert + function doRevert() external pure { + revert("from some fiasco"); + } + + // ========== DEEP TRACE FUNCTIONS ========== + + // 11. Deep recursive CALL trace + // Creates trace depth of `depth` levels + function deepTrace(uint256 depth) external returns (uint256) { + if (depth == 0) { + x = x + 1; // Storage write at deepest level + return x; + } + (bool ok, bytes memory result) = address(this).call( + abi.encodeWithSelector(this.deepTrace.selector, depth - 1) + ); + require(ok, "deep call failed"); + return abi.decode(result, (uint256)); + } + + // 12. Mixed call types trace + // Alternates between CALL, DELEGATECALL, and STATICCALL + function mixedTrace(uint256 depth) external returns (uint256) { + if (depth == 0) { + return x; + } + + uint256 callType = depth % 3; + + if (callType == 0) { + // Regular CALL + (bool ok, bytes memory result) = address(this).call( + abi.encodeWithSelector(this.mixedTrace.selector, depth - 1) + ); + require(ok, "call failed"); + return abi.decode(result, (uint256)); + } else if (callType == 1) { + // DELEGATECALL + (bool ok, bytes memory result) = address(this).delegatecall( + abi.encodeWithSelector(this.mixedTrace.selector, depth - 1) + ); + require(ok, "delegatecall failed"); + return abi.decode(result, (uint256)); + } else { + // STATICCALL (read-only) + (bool ok, bytes memory result) = address(this).staticcall( + abi.encodeWithSelector(this.mixedTrace.selector, depth - 1) + ); + require(ok, "staticcall failed"); + return abi.decode(result, (uint256)); + } + } + + // 13. Wide trace - multiple sibling calls at same level + // Creates `width` parallel calls, each going `depth` levels deep + // Example: wideTrace(3, 2) creates 3 siblings, each 2 levels deep + function wideTrace( + uint256 width, + uint256 depth + ) external returns (uint256 sum) { + if (depth == 0) { + return 1; + } + + for (uint256 i = 0; i < width; i++) { + (bool ok, bytes memory result) = address(this).call( + abi.encodeWithSelector( + this.wideTrace.selector, + width, + depth - 1 + ) + ); + require(ok, "wide call failed"); + sum += abi.decode(result, (uint256)); + } + return sum; + } + + // 14. Complex trace - combines everything + // Level 0: CALL to setX + // Level 1: DELEGATECALL to inner + // Level 2: Multiple CALLs + // Level 3: STATICCALL + function complexTrace() external returns (uint256) { + // First: regular call to setX + (bool ok1, ) = address(this).call( + abi.encodeWithSelector(this.setX.selector, 100) + ); + require(ok1, "setX failed"); + + // Second: delegatecall that does more calls + (bool ok2, bytes memory result) = address(this).delegatecall( + abi.encodeWithSelector(this.innerComplex.selector) + ); + require(ok2, "innerComplex failed"); + + return abi.decode(result, (uint256)); + } + + // Helper for complexTrace + function innerComplex() external returns (uint256) { + // Multiple sibling calls + (bool ok1, ) = address(this).call( + abi.encodeWithSelector(this.setX.selector, 200) + ); + require(ok1, "inner call 1 failed"); + + (bool ok2, ) = address(this).call( + abi.encodeWithSelector(this.setX.selector, 300) + ); + require(ok2, "inner call 2 failed"); + + // Staticcall to read + (bool ok3, bytes memory result) = address(this).staticcall( + abi.encodeWithSelector(this.staticRead.selector) + ); + require(ok3, "staticcall failed"); + + return abi.decode(result, (uint256)); + } + + // 15. Failing nested trace - revert at depth + // Useful for testing partial trace on failure + function failAtDepth( + uint256 depth, + uint256 failAt + ) external returns (uint256) { + if (depth == failAt) { + revert("intentional failure at depth"); + } + if (depth == 0) { + return x; + } + (bool ok, bytes memory result) = address(this).call( + abi.encodeWithSelector(this.failAtDepth.selector, depth - 1, failAt) + ); + require(ok, "nested call failed"); + return abi.decode(result, (uint256)); + } +}