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/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index a59703f6bcac..9b07057708af 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -33,7 +33,7 @@ use crate::rpc::eth::filter::{ }; use crate::rpc::eth::types::{EthBlockTrace, EthTrace}; use crate::rpc::eth::utils::decode_revert_reason; -use crate::rpc::state::ApiInvocResult; +use crate::rpc::state::{Action, ApiInvocResult, ExecutionTrace, ResultData, TraceEntry}; use crate::rpc::types::{ApiTipsetKey, EventEntry, MessageLookup}; use crate::rpc::{ApiPaths, Ctx, Permission, RpcMethod}; use crate::rpc::{EthEventHandler, LOOKBACK_NO_LIMIT}; @@ -75,6 +75,8 @@ 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)); @@ -449,6 +451,43 @@ 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, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct EthVmTrace { + code: EthBytes, + //ops: Vec, +} + +lotus_json_with_self!(EthVmTrace); + +#[derive(PartialEq, Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct EthTraceResults { + output: Option, + state_diff: Option, + trace: Vec, + // This should always be empty since we don't support `vmTrace` atm (this + // would likely need changes in the FEVM) + vm_trace: Option, +} + +lotus_json_with_self!(EthTraceResults); + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] // try a Vec, then a Vec pub enum Transactions { @@ -3314,6 +3353,111 @@ async fn trace_block( Ok(all_traces) } +fn get_output(msg: &Message, invoke_result: ApiInvocResult) -> Result { + if msg.to() == FilecoinAddress::ETHEREUM_ACCOUNT_MANAGER_ACTOR { + Ok(EthBytes::default()) + } else { + let msg_rct = invoke_result.msg_rct.context("no message receipt")?; + let return_data = msg_rct.return_data(); + if return_data.is_empty() { + Ok(Default::default()) + } else { + let bytes = decode_payload(&return_data, CBOR)?; + Ok(bytes) + } + } +} + +fn get_entries(trace: &ExecutionTrace, parent_trace_address: &[usize]) -> Result> { + let mut entries = Vec::new(); + + // Build entry for current trace + let entry = TraceEntry { + action: Action { + call_type: "call".to_string(), // (e.g., "create" for contract creation) + from: EthAddress::from_filecoin_address(&trace.msg.from)?, + to: EthAddress::from_filecoin_address(&trace.msg.to)?, + gas: trace.msg.gas_limit.unwrap_or_default().into(), + // input needs proper decoding + input: trace.msg.params.clone().into(), + value: trace.msg.value.clone().into(), + }, + result: if trace.msg_rct.exit_code.is_success() { + let gas_used = trace.sum_gas().total_gas.into(); + Some(ResultData { + gas_used, + output: trace.msg_rct.r#return.clone().into(), + }) + } else { + // Revert case + None + }, + subtraces: trace.subcalls.len(), + trace_address: parent_trace_address.to_vec(), + type_: "call".to_string(), + }; + entries.push(entry); + + // Recursively build subcall traces + for (i, subcall) in trace.subcalls.iter().enumerate() { + let mut sub_trace_address = parent_trace_address.to_vec(); + sub_trace_address.push(i); + entries.extend(get_entries(subcall, &sub_trace_address)?); + } + + Ok(entries) +} + +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; + type Params = (EthCallMessage, NonEmpty, BlockNumberOrHash); + type Ok = EthTraceResults; + async fn handle( + ctx: Ctx, + (tx, trace_types, block_param): Self::Params, + ) -> Result { + // Note: tx.to should not be null, it should always be set to something + // (contract address or EOA) + + // Note: Should we support nonce? + + // dbg!(&tx); + // dbg!(&trace_types); + // dbg!(&block_param); + + let msg = Message::try_from(tx)?; + let ts = tipset_by_block_number_or_hash( + ctx.chain_store(), + block_param, + ResolveNullTipset::TakeOlder, + )?; + let invoke_result = apply_message(&ctx, Some(ts), msg.clone()).await?; + dbg!(&invoke_result); + + let mut trace_results: EthTraceResults = Default::default(); + let output = get_output(&msg, invoke_result.clone())?; + // output is always present, should we remove option? + trace_results.output = Some(output); + if trace_types.contains(&EthTraceType::Trace) { + // Built trace objects + let entries = if let Some(exec_trace) = invoke_result.execution_trace { + get_entries(&exec_trace, &[])? + } else { + Default::default() + }; + trace_results.trace = entries; + } + + Ok(trace_results) + } +} + pub enum EthTraceTransaction {} impl RpcMethod<1> for EthTraceTransaction { const NAME: &'static str = "Filecoin.EthTraceTransaction"; diff --git a/src/rpc/methods/state/types.rs b/src/rpc/methods/state/types.rs index 36cd381a1273..193427cf17b4 100644 --- a/src/rpc/methods/state/types.rs +++ b/src/rpc/methods/state/types.rs @@ -4,6 +4,8 @@ use crate::blocks::TipsetKey; use crate::lotus_json::{LotusJson, lotus_json_with_self}; use crate::message::Message as _; +use crate::rpc::eth::types::{EthAddress, EthBytes}; +use crate::rpc::eth::{EthBigInt, EthUint64}; use crate::shim::executor::ApplyRet; use crate::shim::{ address::Address, @@ -237,6 +239,42 @@ impl PartialEq for GasTrace { } } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct Action { + pub call_type: String, // E.g., "call", "delegatecall", "create" + pub from: EthAddress, + pub to: EthAddress, + pub gas: EthUint64, + pub input: EthBytes, + pub value: EthBigInt, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ResultData { + pub gas_used: EthUint64, + pub output: EthBytes, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TraceEntry { + /// Call parameters + pub action: Action, + /// Call result or `None` for reverts + pub result: Option, + /// How many subtraces this trace has. + pub subtraces: usize, + /// The identifier of this transaction trace in the set. + /// + /// This gives the exact location in the call trace. + pub trace_address: Vec, + /// Call type, e.g., "call", "delegatecall", "create" + #[serde(rename = "type")] + pub type_: String, +} + #[derive(PartialEq, Serialize, Deserialize, Clone, JsonSchema)] #[serde(rename_all = "PascalCase")] pub struct InvocResult { diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 5a4330895c64..743c630a39fc 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -132,6 +132,7 @@ macro_rules! for_each_rpc_method { $callback!($crate::rpc::eth::EthSubscribe); $callback!($crate::rpc::eth::EthSyncing); $callback!($crate::rpc::eth::EthTraceBlock); + $callback!($crate::rpc::eth::EthTraceCall); $callback!($crate::rpc::eth::EthTraceFilter); $callback!($crate::rpc::eth::EthTraceTransaction); $callback!($crate::rpc::eth::EthTraceReplayBlockTransactions); diff --git a/src/tool/subcommands/api_cmd/api_compare_tests.rs b/src/tool/subcommands/api_cmd/api_compare_tests.rs index 96e999044b77..01fbb86a7031 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; use crate::rpc::miner::BlockTemplate; @@ -1366,6 +1366,42 @@ fn eth_tests() -> Vec { .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 } @@ -1412,6 +1448,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..2dc6b066532a --- /dev/null +++ b/src/tool/subcommands/api_cmd/contracts/tracer/Tracer.sol @@ -0,0 +1,76 @@ +// 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; + } + + // 1. Simple storage write + function setX(uint256 _x) external { + x = _x; + } + + // 2. Balance update (SSTORE) + function deposit() external payable { + balances[msg.sender] = msg.value; + } + + // 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"); + } +}