diff --git a/Cargo.lock b/Cargo.lock index 34dd9ffae20b..a1cefba26666 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3307,6 +3307,7 @@ dependencies = [ "fvm_actor_utils", "fvm_ipld_blockstore", "fvm_ipld_encoding", + "fvm_ipld_kamt", "fvm_shared 2.11.3", "fvm_shared 3.13.3", "fvm_shared 4.7.5", @@ -3941,6 +3942,23 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "fvm_ipld_kamt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6a7ef4cf7dab3bf09a2c988f7fc88fa0a532be2ca96136470942426ec5f99f0" +dependencies = [ + "anyhow", + "byteorder", + "cid", + "fvm_ipld_blockstore", + "fvm_ipld_encoding", + "multihash-codetable", + "once_cell", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "fvm_sdk" version = "4.7.5" diff --git a/Cargo.toml b/Cargo.toml index 9b3da51de1d7..ebdce728f070 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,6 +94,7 @@ fvm4 = { package = "fvm", version = "~4.7", default-features = false, features = fvm_actor_utils = "14" fvm_ipld_blockstore = "0.3.1" fvm_ipld_encoding = "0.5.3" +fvm_ipld_kamt = "0.4.5" fvm_shared2 = { package = "fvm_shared", version = "~2.11" } fvm_shared3 = { package = "fvm_shared", version = "~3.13", features = ["proofs"] } fvm_shared4 = { package = "fvm_shared", version = "~4.7", features = ["proofs"] } diff --git a/docs/dictionary.txt b/docs/dictionary.txt index 0ac47eb0eaf5..4a4190eb96ca 100644 --- a/docs/dictionary.txt +++ b/docs/dictionary.txt @@ -10,6 +10,7 @@ blockstore BLS BuildKit Butterflynet +bytecode Calibnet calibnet calibnet-related @@ -40,6 +41,7 @@ DigitalOcean Drand EAM enums +ETH Eth Ethereum EVM @@ -59,6 +61,8 @@ FIP FIPs FVM GC +Geth +Geth's GH GiB Github @@ -100,6 +104,7 @@ migrator migrators mise-en-place multiaddress +Namespace namespace Neo4j NetworkEvents @@ -113,12 +118,16 @@ NVMe NVXX onwards OOMs +OpenEthereum orchestrator Organisation P2P p2p performant pre-compiled +Pre-deployed +pre-deployed +Pre-funded pre-migrations preload preloaded @@ -127,6 +136,7 @@ Q4 README retag Reth +reth Reth-like RNG Roadmap diff --git a/docs/docs/developers/guides/trace_call_guide.md b/docs/docs/developers/guides/trace_call_guide.md new file mode 100644 index 000000000000..92ae832c2ee1 --- /dev/null +++ b/docs/docs/developers/guides/trace_call_guide.md @@ -0,0 +1,177 @@ +# trace_call Developer Guide + +This guide covers testing and development workflows for Forest's `trace_call` implementation. For API documentation and user-facing usage, see the [trace_call API guide](/knowledge_base/rpc/trace_call). + +## Tracer Contract + +The [`Tracer.sol`](https://github.com/ChainSafe/forest/blob/963237708137e9c7388c57eba39a2f8bf12ace74/src/tool/subcommands/api_cmd/contracts/tracer/Tracer.sol) contract provides various functions to test different tracing scenarios. + +### Storage Layout + +| Slot | Variable | Description | +| ---- | -------------- | ---------------------------- | +| 0 | `x` | Initialized to 42 | +| 1 | `balances` | Mapping base slot | +| 2 | `storageTestA` | Starts empty (for add tests) | +| 3 | `storageTestB` | Starts empty | +| 4 | `storageTestC` | Starts empty | +| 5 | `dynamicArray` | Array length slot | + +### Function Reference + +#### Basic Operations + +| Function | Selector | Description | +| ------------------- | ------------ | --------------------------- | +| `setX(uint256)` | `0x4018d9aa` | Write to slot 0 | +| `deposit()` | `0xd0e30db0` | Receive ETH, update mapping | +| `withdraw(uint256)` | `0x2e1a7d4d` | Send ETH from contract | +| `doRevert()` | `0xafc874d2` | Always reverts | + +#### Call Tracing + +| Function | Selector | Description | +| ----------------------- | ------------ | ---------------------- | +| `callSelf(uint256)` | `0xa1a88595` | Single nested CALL | +| `delegateSelf(uint256)` | `0x8f5e07b8` | `DELEGATECALL` trace | +| `complexTrace()` | `0x6659ab96` | Multiple nested calls | +| `deepTrace(uint256)` | `0x0f3a17b8` | Recursive N-level deep | + +#### Storage Diff Testing + +| Function | Selector | Description | +| ------------------------------------------ | ------------ | -------------------- | +| `storageAdd(uint256)` | `0x55cb64b4` | Add to empty slot 2 | +| `storageChange(uint256)` | `0x7c8f6e57` | Modify existing slot | +| `storageDelete()` | `0xd92846a3` | Set slot to zero | +| `storageMultiple(uint256,uint256,uint256)` | `0x310af204` | Change slots 2,3,4 | + +### Generating Function Selectors + +Use `cast` from Foundry to generate function selectors: + +```bash +# Get selector for a function +cast sig "setX(uint256)" +# Output: 0x4018d9aa + +# Encode full calldata +cast calldata "setX(uint256)" 123 +# Output: 0x4018d9aa000000000000000000000000000000000000000000000000000000000000007b +``` + +### Deployed Contracts + +Pre-deployed Tracer contracts for quick testing: + +| Network | Contract Address | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Calibnet | [`0x73a43475aa2ccb14246613708b399f4b2ba546c7`](https://calibration.filfox.info/en/address/0x73a43475aa2ccb14246613708b399f4b2ba546c7) | +| Mainnet | [`0x9BB686Ba6a50D1CF670a98f522a59555d4977fb2`](https://filecoin.blockscout.com/address/0x9BB686Ba6a50D1CF670a98f522a59555d4977fb2) | + +## Comparison Testing with Anvil + +Anvil uses **Geth style** tracing (`debug_traceCall` with `prestateTracer`), while Forest uses **Parity style** tracing (`trace_call` with `stateDiff`). This makes Anvil useful for comparison testing — verifying that Forest produces semantically equivalent results in a different format. + +### Prerequisites + +- [Foundry](https://book.getfoundry.sh/getting-started/installation) installed (`forge`, `cast` commands) +- A running [Forest node](https://docs.forest.chainsafe.io/getting_started/syncing) and Anvil instance + +### What is Anvil? + +[Anvil](https://getfoundry.sh/anvil/reference/) is a local Ethereum development node included with Foundry. It provides: + +- Instant block mining +- Pre-funded test accounts (10 accounts with 10,000 ETH each) +- Support for `debug_traceCall` with various tracers +- No real tokens required + +### Starting Anvil + +```bash +# Start Anvil with tracer to allow debug_traceCall API's +anvil --tracing +``` + +Anvil RPC endpoint: `http://localhost:8545` + +### Deploying Contract on Anvil + +```bash +forge create src/tool/subcommands/api_cmd/contracts/tracer/Tracer.sol:Tracer \ + --rpc-url http://localhost:8545 \ + --broadcast \ + --private-key + +# Output: +# Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +# Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3 +# Transaction hash: 0x... +``` + +### Comparing Forest vs Anvil Responses + +The same contract call can be tested against both nodes. The request data payloads are identical; only the method name and parameter ordering differ. + +**Forest (Parity-style `trace_call`):** + +```bash +curl -s -X POST "http://localhost:2345/rpc/v1" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "trace_call", + "params": [ + {"from": "0x...", "to": "0x...", "data": "0x4018d9aa..."}, + ["stateDiff"], + "latest" + ] + }' +``` + +**Anvil (Geth style `debug_traceCall`):** + +```bash +curl -s -X POST "http://localhost:8545" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "debug_traceCall", + "params": [ + {"from": "0x...", "to": "0x...", "data": "0x4018d9aa..."}, + "latest", + {"tracer": "prestateTracer", "tracerConfig": {"diffMode": true}} + ] + }' +``` + +## Integration Test Script + +An automated test script is available to compare Forest's `trace_call` with Anvil's `debug_traceCall`: + +```bash +# Run the test (requires Forest and Anvil running) +./scripts/tests/trace_call_integration_test.sh + +or + +# Deploy contract on Anvil first, forest and anvil node should already be running +./scripts/tests/trace_call_integration_test.sh --deploy +``` + +### Test Categories + +1. **Trace Tests**: Call hierarchy, subcalls, reverts, deep traces +2. **Balance Diff Tests**: ETH transfers, deposits +3. **Storage Diff Tests**: Single slot, multiple slots, value comparison + +## Official Resources + +- [OpenEthereum trace module](https://openethereum.github.io/JSONRPC-trace-module) +- [Geth Built-in Tracers](https://geth.ethereum.org/docs/developers/evm-tracing/built-in-tracers) +- [Alchemy: `trace_call` vs `debug_traceCall`](https://www.alchemy.com/docs/reference/trace_call-vs-debug_tracecall) +- [Reth trace Namespace](https://reth.rs/jsonrpc/trace) +- [Foundry Book - Anvil](https://book.getfoundry.sh/reference/anvil/) diff --git a/docs/docs/users/knowledge_base/RPC/trace_call.md b/docs/docs/users/knowledge_base/RPC/trace_call.md new file mode 100644 index 000000000000..5df1a153a1cf --- /dev/null +++ b/docs/docs/users/knowledge_base/RPC/trace_call.md @@ -0,0 +1,363 @@ +# trace_call API Guide + +This guide explains the `trace_call` RPC method implemented in Forest, which follows the **[Parity/OpenEthereum](https://openethereum.github.io/JSONRPC-trace-module#trace_call) and [reth](https://reth.rs/jsonrpc/trace#trace-format-specification) trace format**. + +## Overview + +`trace_call` executes an EVM call and returns detailed execution traces without creating a transaction on the blockchain. It's useful for: + +- Debugging smart contract calls +- Analyzing gas usage and call patterns +- Inspecting state changes before execution +- Understanding nested call hierarchies + +## Request Format + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "trace_call", + "params": [ + { + "from": "0x...", // Sender address + "to": "0x...", // Contract address + "data": "0x...", // Encoded function call + "value": "0x0", // ETH value (optional) + "gas": "0x...", // Gas limit (optional) + "gasPrice": "0x..." // Gas price (optional) + }, + ["trace", "stateDiff"], // Trace types to return + "latest" // Block number or tag + ] +} +``` + +### Trace Types + +| Type | Description | +| ----------- | ------------------------------------------------------------------- | +| `trace` | Call hierarchy with inputs, outputs, gas used | +| `stateDiff` | State changes (balance, nonce, code, storage) | +| `vmTrace` | Low-level EVM execution (not yet implemented, not supported by FVM) | + +## Response Format (Parity Style) + +Forest uses the **Parity/OpenEthereum trace format**, which differs from [Geth's debug API's](https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-debug#debugtracecall). + +### Trace Response + +```json +{ + "result": { + "output": "0x...", + "trace": [ + { + "type": "call", + "subtraces": 1, + "traceAddress": [], + "action": { + "callType": "call", + "from": "0x...", + "to": "0x...", + "gas": "0x...", + "value": "0x0", + "input": "0x..." + }, + "result": { + "gasUsed": "0x...", + "output": "0x..." + }, + "error": null + } + ], + "stateDiff": { ... } + } +} +``` + +### `stateDiff` Response + +State changes use **Delta notation**: + +| Symbol | Meaning | Example | +| ------ | --------- | -------------------------------------------------------- | +| `"="` | Unchanged | `"balance": "="` | +| `"+"` | Added | `"balance": { "+": "0x1000" }` | +| `"-"` | Removed | `"balance": { "-": "0x1000" }` | +| `"*"` | Changed | `"balance": { "*": { "from": "0x100", "to": "0x200" } }` | + +```json +{ + "stateDiff": { + "0xcontract...": { + "balance": "=", + "code": "=", + "nonce": "=", + "storage": { + "0x0000...0000": { + "*": { + "from": "0x000...02a", + "to": "0x000...07b" + } + } + } + }, + "0xsender...": { + "balance": "=", + "code": "=", + "nonce": { + "*": { + "from": "0x5", + "to": "0x6" + } + }, + "storage": {} + } + } +} +``` + +## Parity vs Geth Format Comparison + +| Aspect | Forest (Parity) | Geth | +| -------------------- | ------------------------------------ | --------------------------------------- | +| **API Method** | `trace_call` | `debug_traceCall` | +| **State Format** | Delta notation (`"*"`, `"+"`, `"-"`) | Separate `pre`/`post` objects | +| **Unchanged Values** | Shows `"="` | Included in `pre`, absent in `post` | +| **Storage Changes** | `{ "*": { from, to } }` | Compare `pre.storage` vs `post.storage` | +| **Code Field** | `"="` if unchanged | Full bytecode in `pre` | + +### Example: Same call, different formats + +**Forest (Parity):** + +```json +{ + "storage": { + "0x00...00": { + "*": { + "from": "0x00...2a", + "to": "0x00...7b" + } + } + } +} +``` + +**Geth (`prestateTracer` with `diffMode`):** + +```json +{ + "pre": { + "storage": { "0x00...00": "0x00...2a" } + }, + "post": { + "storage": { "0x00...00": "0x00...7b" } + } +} +``` + +## Using trace_call with Forest + +### Prerequisites + +- A running Forest node — follow the [Getting Started](https://docs.forest.chainsafe.io/getting_started/syncing) guide to start and sync. +- A deployed EVM contract to trace against (see [Deployed Contracts](#deployed-contracts) below, or deploy your own). + +Forest RPC endpoint: `http://localhost:2345/rpc/v1` + +### Deployed Contracts + +A [Tracer](https://github.com/ChainSafe/forest/blob/963237708137e9c7388c57eba39a2f8bf12ace74/src/tool/subcommands/api_cmd/contracts/tracer/Tracer.sol) contract is pre-deployed on Calibnet and Mainnet for testing `trace_call`. It provides functions for storage writes, ETH transfers, nested calls, and reverts. + +| Network | Contract Address | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Calibnet | [`0x73a43475aa2ccb14246613708b399f4b2ba546c7`](https://calibration.filfox.info/en/address/0x73a43475aa2ccb14246613708b399f4b2ba546c7) | +| Mainnet | [`0x9BB686Ba6a50D1CF670a98f522a59555d4977fb2`](https://filecoin.blockscout.com/address/0x9BB686Ba6a50D1CF670a98f522a59555d4977fb2) | + +> **Note:** Contract availability depends on network state. Verify the contract exists before testing: +> +> ```bash +> curl -s -X POST "http://localhost:2345/rpc/v1" \ +> -H "Content-Type: application/json" \ +> -d '{"jsonrpc":"2.0","id":1,"method":"eth_getCode","params":["0x73a43475aa2ccb14246613708b399f4b2ba546c7","latest"]}' \ +> | jq -r '.result | length' +> ``` +> +> If the result is `> 2`, the contract is deployed. + +### Deploying Your Own Contract + +To deploy a contract on Calibnet or Mainnet, you need: + +1. A funded wallet with FIL tokens for gas (use the [Forest faucet](https://forest-explorer.chainsafe.dev/faucet) for testnet funds) +2. [Foundry](https://book.getfoundry.sh/getting-started/installation) installed (`forge` command) + +```bash +forge create YourContract.sol:YourContract \ + --rpc-url http://localhost:2345/rpc/v1 \ + --broadcast \ + --private-key + +# Output: +# Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +# Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3 +# Transaction hash: 0x... +``` + +## Example curl Requests + +The examples below use the pre-deployed [Tracer](https://github.com/ChainSafe/forest/blob/963237708137e9c7388c57eba39a2f8bf12ace74/src/tool/subcommands/api_cmd/contracts/tracer/Tracer.sol) contract. Here are the functions used: + +| Function | Selector | Description | +| ------------------------------------------ | ------------ | ----------------------------------- | +| `setX(uint256)` | `0x4018d9aa` | Write a value to storage slot 0 | +| `deposit()` | `0xd0e30db0` | Receive ETH, update balance mapping | +| `deepTrace(uint256)` | `0x0f3a17b8` | Recursive N-level nested calls | +| `storageMultiple(uint256,uint256,uint256)` | `0x310af204` | Write to multiple storage slots | + +Before running the examples, set the following environment variables: + +```bash +export FOREST_RPC_URL="http://localhost:2345/rpc/v1" +export SENDER="0xYOUR_ACCOUNT" # your sender address +export CONTRACT="0xCONTRACT_ADDRESS" # deployed Tracer contract +``` + +### 1. Basic Trace - `setX(123)` + +```bash +curl -s -X POST "$FOREST_RPC_URL" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "trace_call", + "params": [ + { + "from": "'$SENDER'", + "to": "'$CONTRACT'", + "data": "0x4018d9aa000000000000000000000000000000000000000000000000000000000000007b" + }, + ["trace"], + "latest" + ] + }' | jq '.' +``` + +### 2. State Diff - Storage Change + +```bash +curl -s -X POST "$FOREST_RPC_URL" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "trace_call", + "params": [ + { + "from": "'$SENDER'", + "to": "'$CONTRACT'", + "data": "0x4018d9aa000000000000000000000000000000000000000000000000000000000000007b" + }, + ["stateDiff"], + "latest" + ] + }' | jq '.result.stateDiff' +``` + +### 3. Balance Change - deposit() with ETH + +```bash +curl -s -X POST "$FOREST_RPC_URL" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "trace_call", + "params": [ + { + "from": "'$SENDER'", + "to": "'$CONTRACT'", + "data": "0xd0e30db0", + "value": "0xde0b6b3a7640000" + }, + ["trace", "stateDiff"], + "latest" + ] + }' | jq '.' +``` + +### 4. Deep Trace - `deepTrace(3)` + +```bash +curl -s -X POST "$FOREST_RPC_URL" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "trace_call", + "params": [ + { + "from": "'$SENDER'", + "to": "'$CONTRACT'", + "data": "0x0f3a17b80000000000000000000000000000000000000000000000000000000000000003" + }, + ["trace"], + "latest" + ] + }' | jq '.result.trace | length' +``` + +### 5. Multiple Storage Slots - `storageMultiple(10,20,30)` + +```bash +curl -s -X POST "$FOREST_RPC_URL" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "trace_call", + "params": [ + { + "from": "'$SENDER'", + "to": "'$CONTRACT'", + "data": "0x310af204000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001e" + }, + ["stateDiff"], + "latest" + ] + }' | jq '.result.stateDiff' +``` + +## Troubleshooting + +### Common Issues + +1. **Empty storage in `stateDiff`**: Ensure the contract is an EVM actor (has bytecode) +2. **Call reverts**: Check function requirements (e.g., `storageChange` requires slot to have value first) +3. **Missing contract**: Verify contract is deployed at the specified address + +### Debug Tips + +```bash +# Check if address has code +curl -s -X POST "$FOREST_RPC_URL" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"eth_getCode","params":["'$CONTRACT'","latest"]}' \ + | jq -r '.result | length' + +# Check account balance +curl -s -X POST "$FOREST_RPC_URL" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"eth_getBalance","params":["'$SENDER'","latest"]}' \ + | jq '.result' +``` + +## Official Resources + +- [OpenEthereum trace module](https://openethereum.github.io/JSONRPC-trace-module) +- [Geth Built-in Tracers](https://geth.ethereum.org/docs/developers/evm-tracing/built-in-tracers) +- [Alchemy: `trace_call` vs `debug_traceCall`](https://www.alchemy.com/docs/reference/trace_call-vs-debug_tracecall) +- [Reth trace Namespace](https://reth.rs/jsonrpc/trace) diff --git a/scripts/tests/trace_call_integration_test.sh b/scripts/tests/trace_call_integration_test.sh new file mode 100755 index 000000000000..89272eac29c0 --- /dev/null +++ b/scripts/tests/trace_call_integration_test.sh @@ -0,0 +1,399 @@ +#!/usr/bin/env bash +# Compare Forest `trace_call` (Parity-style traces and stateDiff) against Anvil +# `debug_traceCall` (Geth-style prestateTracer) using the Tracer.sol contract. +# Verifies call traces, balance diffs, and EVM storage diffs stay in sync. + +set -euo pipefail + +DEPLOY_CONTRACT=false +while [[ $# -gt 0 ]]; do + case $1 in + --deploy) DEPLOY_CONTRACT=true; shift ;; + *) echo "Usage: $0 [--deploy]"; 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:-0x73a43475aa2ccb14246613708b399f4b2ba546c7}" +ANVIL_ACCOUNT="${ANVIL_ACCOUNT:-0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266}" +ANVIL_CONTRACT="${ANVIL_CONTRACT:-0x5FbDB2315678afecb367f032d93F642f64180aa3}" +# -- This private key is of anvil dev node -- +ANVIL_PRIVATE_KEY="${ANVIL_PRIVATE_KEY:-0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80}" + +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 + +command -v jq &>/dev/null || { echo "Error: jq is required"; exit 1; } +command -v curl &>/dev/null || { echo "Error: curl is required"; exit 1; } + +# Call JSON-RPC method using jq for body construction (type-safe, no escaping needed) +call_rpc() { + local url="$1" method="$2" params="$3" + local body=$(jq -n --arg method "$method" --argjson params "$params" \ + '{"jsonrpc": "2.0", "id": 1, "method": $method, "params": $params}') + curl -s -X POST "$url" -H "Content-Type: application/json" -d "$body" +} + +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 + +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 + +normalize_empty() { + local val="$1" + [[ "$val" == "null" || -z "$val" ]] && echo "0x" || echo "$val" +} + +get_delta_type() { + local val="$1" + if [[ "$val" == "=" || "$val" == "\"=\"" || "$val" == "null" || -z "$val" ]]; then + echo "unchanged" + elif 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" + 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} Both have error" + PASS_COUNT=$((PASS_COUNT + 1)) + else + echo -e " ${RED}[FAIL]${NC} Error mismatch (Forest: '$f_err' | Anvil: '$a_err')" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +test_trace() { + local name="$1" data="$2" type="${3:-standard}" + echo -e "${BLUE}--- $name ---${NC}" + + local f_params=$(jq -n \ + --arg from "$FOREST_ACCOUNT" \ + --arg to "$FOREST_CONTRACT" \ + --arg data "$data" \ + '[{"from": $from, "to": $to, "data": $data}, ["trace"], "latest"]') + local f_resp=$(call_rpc "$FOREST_RPC_URL" "trace_call" "$f_params") + + local a_params=$(jq -n \ + --arg from "$ANVIL_ACCOUNT" \ + --arg to "$ANVIL_CONTRACT" \ + --arg data "$data" \ + '[{"from": $from, "to": $to, "data": $data}, "latest", {"tracer": "callTracer"}]') + local a_resp=$(call_rpc "$ANVIL_RPC_URL" "debug_traceCall" "$a_params") + + 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" + + 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 "" +} + +test_balance_diff() { + local name="$1" data="$2" value="${3:-0x0}" expect="${4:-unchanged}" + echo -e "${BLUE}--- $name (balance) ---${NC}" + + local f_params=$(jq -n \ + --arg from "$FOREST_ACCOUNT" \ + --arg to "$FOREST_CONTRACT" \ + --arg data "$data" \ + --arg value "$value" \ + '[{"from": $from, "to": $to, "data": $data, "value": $value}, ["stateDiff"], "latest"]') + local f_resp=$(call_rpc "$FOREST_RPC_URL" "trace_call" "$f_params") + + local a_params=$(jq -n \ + --arg from "$ANVIL_ACCOUNT" \ + --arg to "$ANVIL_CONTRACT" \ + --arg data "$data" \ + --arg value "$value" \ + '[{"from": $from, "to": $to, "data": $data, "value": $value}, "latest", {"tracer": "prestateTracer", "tracerConfig": {"diffMode": true}}]') + local a_resp=$(call_rpc "$ANVIL_RPC_URL" "debug_traceCall" "$a_params") + + local f_contract_lower=$(echo "$FOREST_CONTRACT" | tr '[:upper:]' '[:lower:]') + local a_contract_lower=$(echo "$ANVIL_CONTRACT" | tr '[:upper:]' '[:lower:]') + + local f_bal=$(echo "$f_resp" | jq -r --arg a "$f_contract_lower" '.result.stateDiff[$a].balance // "="') + local f_type=$(get_delta_type "$f_bal") + + 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" + + assert_eq "BalanceChange" "$f_type" "$expect" + assert_eq "ForestMatchesAnvil" "$f_type" "$a_type" + + # Compare actual balance values when changed + if [ "$f_type" = "changed" ]; then + local f_bal_to=$(echo "$f_bal" | jq -r '.["*"].to // empty') + [[ -n "$f_bal_to" && -n "$a_post_bal" && "$a_post_bal" != "0x0" ]] && \ + assert_eq "BalanceTo" "$f_bal_to" "$a_post_bal" + fi + echo "" +} + +test_storage_diff() { + local name="$1" data="$2" slot="$3" expect_type="${4:-changed}" + echo -e "${BLUE}--- $name (storage) ---${NC}" + + local f_params=$(jq -n \ + --arg from "$FOREST_ACCOUNT" \ + --arg to "$FOREST_CONTRACT" \ + --arg data "$data" \ + '[{"from": $from, "to": $to, "data": $data}, ["stateDiff"], "latest"]') + local f_resp=$(call_rpc "$FOREST_RPC_URL" "trace_call" "$f_params") + + local a_params=$(jq -n \ + --arg from "$ANVIL_ACCOUNT" \ + --arg to "$ANVIL_CONTRACT" \ + --arg data "$data" \ + '[{"from": $from, "to": $to, "data": $data}, "latest", {"tracer": "prestateTracer", "tracerConfig": {"diffMode": true}}]') + local a_resp=$(call_rpc "$ANVIL_RPC_URL" "debug_traceCall" "$a_params") + + local f_contract_lower=$(echo "$FOREST_CONTRACT" | tr '[:upper:]' '[:lower:]') + local a_contract_lower=$(echo "$ANVIL_CONTRACT" | tr '[:upper:]' '[:lower:]') + + # Forest: Extract storage slot delta and values + local f_slot_data=$(echo "$f_resp" | jq -r --arg a "$f_contract_lower" --arg s "$slot" '.result.stateDiff[$a].storage[$s] // null') + local f_type=$(get_delta_type "$f_slot_data") + + # Anvil: Determine if the storage slot changed. + # Per reth/Parity behavior, all storage slot transitions on existing accounts + # are Delta::Changed ("*"), including zero→nonzero and nonzero→zero. + local a_pre_has=$(echo "$a_resp" | jq --arg a "$a_contract_lower" --arg s "$slot" '.result.pre[$a].storage[$s] != null') + local a_post_has=$(echo "$a_resp" | jq --arg a "$a_contract_lower" --arg s "$slot" '.result.post[$a].storage[$s] != null') + local a_type="unchanged" + if [[ "$a_pre_has" == "true" || "$a_post_has" == "true" ]]; then + a_type="changed" + fi + + assert_eq "StorageChangeType" "$f_type" "$expect_type" + assert_eq "ForestMatchesAnvil" "$f_type" "$a_type" + + # Compare values: Forest uses "*": { "from": ..., "to": ... } for all changes. + # Anvil may omit the slot from pre (zero→nonzero) or post (nonzero→zero). + if [ "$f_type" = "changed" ]; then + local f_from=$(echo "$f_slot_data" | jq -r '.["*"].from // empty') + local f_to=$(echo "$f_slot_data" | jq -r '.["*"].to // empty') + + # Anvil pre value: present means nonzero existed, absent means was zero + local a_from=$(echo "$a_resp" | jq -r --arg a "$a_contract_lower" --arg s "$slot" '.result.pre[$a].storage[$s] // empty') + # Anvil post value: present means nonzero now, absent means cleared to zero + local a_to=$(echo "$a_resp" | jq -r --arg a "$a_contract_lower" --arg s "$slot" '.result.post[$a].storage[$s] // empty') + + if [[ -n "$f_to" && -n "$a_to" ]]; then + assert_eq "StorageTo" "$f_to" "$a_to" + elif [[ -n "$f_from" && -n "$a_from" ]]; then + assert_eq "StorageFrom" "$f_from" "$a_from" + fi + fi + echo "" +} + +test_storage_multiple() { + local name="$1" data="$2" expect_type="$3" + shift 3 + local slots=("$@") + echo -e "${BLUE}--- $name (multi-storage) ---${NC}" + + local f_params=$(jq -n \ + --arg from "$FOREST_ACCOUNT" \ + --arg to "$FOREST_CONTRACT" \ + --arg data "$data" \ + '[{"from": $from, "to": $to, "data": $data}, ["stateDiff"], "latest"]') + local f_resp=$(call_rpc "$FOREST_RPC_URL" "trace_call" "$f_params") + + local a_params=$(jq -n \ + --arg from "$ANVIL_ACCOUNT" \ + --arg to "$ANVIL_CONTRACT" \ + --arg data "$data" \ + '[{"from": $from, "to": $to, "data": $data}, "latest", {"tracer": "prestateTracer", "tracerConfig": {"diffMode": true}}]') + local a_resp=$(call_rpc "$ANVIL_RPC_URL" "debug_traceCall" "$a_params") + + local f_contract_lower=$(echo "$FOREST_CONTRACT" | tr '[:upper:]' '[:lower:]') + local a_contract_lower=$(echo "$ANVIL_CONTRACT" | tr '[:upper:]' '[:lower:]') + + local f_slot_count=$(echo "$f_resp" | jq -r --arg a "$f_contract_lower" '.result.stateDiff[$a].storage | length') + local a_slot_count=$(echo "$a_resp" | jq -r --arg a "$a_contract_lower" '.result.post[$a].storage | length') + assert_eq "SlotCount" "$f_slot_count" "$a_slot_count" + + for slot in "${slots[@]}"; do + local f_slot_data=$(echo "$f_resp" | jq -r --arg a "$f_contract_lower" --arg s "$slot" '.result.stateDiff[$a].storage[$s] // null') + local f_type=$(get_delta_type "$f_slot_data") + local f_to_val=$(echo "$f_slot_data" | jq -r '.["*"].to // empty') + local a_post_val=$(echo "$a_resp" | jq -r --arg a "$a_contract_lower" --arg s "$slot" '.result.post[$a].storage[$s] // empty') + + local slot_short="${slot: -4}" + assert_eq "Slot${slot_short}Type" "$f_type" "$expect_type" + assert_eq "Slot${slot_short}Value" "$f_to_val" "$a_post_val" + done + echo "" +} + +# ============================================================================= +# Main Execution +# ============================================================================= +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" + +# delegateSelf(999) - selector: 0x8f5e07b8 +test_trace "delegateSelf(999)" \ + "0x8f5e07b800000000000000000000000000000000000000000000000000000000000003e7" + +# wideTrace(3, 1) - selector: 0x56d15f7c, 3 siblings each 1 deep +test_trace "wideTrace(3,1)" \ + "0x56d15f7c00000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000001" \ + "deep" + +# failAtDepth(3, 2) - revert at depth 2 inside depth-3 recursion +# selector: 0x68bcf9e2 +test_trace "failAtDepth(3,2)" \ + "0x68bcf9e200000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000002" \ + "revert" + +# --- Balance Diff Tests --- +echo -e "${BLUE}=== Balance Diff Tests ===${NC}" +echo "" + +test_balance_diff "deposit() with 1 ETH" \ + "0xd0e30db0" \ + "0xde0b6b3a7640000" \ + "changed" + +test_balance_diff "setX(42) no value" \ + "0x4018d9aa000000000000000000000000000000000000000000000000000000000000002a" \ + "0x0" \ + "unchanged" + +# --- Storage Diff Tests --- +echo -e "${BLUE}=== Storage Diff Tests ===${NC}" +echo "" + +# Note: Each trace_call is stateless — simulated against on-chain state. +# Tests do NOT affect each other. Slot 0 (x) is always 42 on-chain. + +# Slot 0: setX(123) modifies existing value (42 → 123 = changed) +test_storage_diff "setX(123) - change slot 0" \ + "0x4018d9aa000000000000000000000000000000000000000000000000000000000000007b" \ + "0x0000000000000000000000000000000000000000000000000000000000000000" \ + "changed" + +# Slot 0: setX(42) writes same value as on-chain (42 → 42 = unchanged, no storage entry) +test_storage_diff "setX(42) - no-op same value" \ + "0x4018d9aa000000000000000000000000000000000000000000000000000000000000002a" \ + "0x0000000000000000000000000000000000000000000000000000000000000000" \ + "unchanged" + +# Slot 0: setX(0) clears existing value (42 → 0 = changed per reth/Parity) +test_storage_diff "setX(0) - clear slot 0" \ + "0x4018d9aa0000000000000000000000000000000000000000000000000000000000000000" \ + "0x0000000000000000000000000000000000000000000000000000000000000000" \ + "changed" + +# Slot 2: storageTestA (starts empty on-chain, 0 → 100 = changed per reth/Parity) +test_storage_diff "storageAdd(100) - write slot 2" \ + "0x55cb64b40000000000000000000000000000000000000000000000000000000000000064" \ + "0x0000000000000000000000000000000000000000000000000000000000000002" \ + "changed" + +# Multiple slots: storageTestA(10), storageTestB(20), storageTestC(30) - all start empty +test_storage_multiple "storageMultiple(10,20,30) - slots 2,3,4" \ + "0x310af204000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001e" \ + "changed" \ + "0x0000000000000000000000000000000000000000000000000000000000000002" \ + "0x0000000000000000000000000000000000000000000000000000000000000003" \ + "0x0000000000000000000000000000000000000000000000000000000000000004" + +# --- 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 a197c400c48a..d6d5de14a6c3 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)); @@ -268,7 +269,7 @@ lotus_json_with_self!(EthInt64); impl EthHash { // Should ONLY be used for blocks and Filecoin messages. Eth transactions expect a different hashing scheme. - pub fn to_cid(&self) -> cid::Cid { + pub fn to_cid(self) -> cid::Cid { let mh = MultihashCode::Blake2b256 .wrap(self.0.as_bytes()) .expect("should not fail"); @@ -470,6 +471,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 { @@ -607,7 +637,7 @@ impl Block { &state_tree, ctx.chain_config().eth_chain_id, )?; - tx.block_hash = block_hash.clone(); + tx.block_hash = block_hash; tx.block_number = block_number; tx.transaction_index = ti; full_transactions.push(tx); @@ -1482,11 +1512,11 @@ async fn new_eth_tx_receipt( msg_receipt: &Receipt, ) -> anyhow::Result { let mut tx_receipt = EthTxReceipt { - transaction_hash: tx.hash.clone(), - from: tx.from.clone(), - to: tx.to.clone(), + transaction_hash: tx.hash, + from: tx.from, + to: tx.to, transaction_index: tx.transaction_index, - block_hash: tx.block_hash.clone(), + block_hash: tx.block_hash, block_number: tx.block_number, r#type: tx.r#type, status: (msg_receipt.exit_code().is_success() as u64).into(), @@ -1549,7 +1579,7 @@ pub async fn eth_logs_for_block_and_transaction anyhow::Result> { let spec = EthFilterSpec { - block_hash: Some(block_hash.clone()), + block_hash: Some(*block_hash), ..Default::default() }; @@ -2112,9 +2142,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()) } } @@ -2128,9 +2156,9 @@ 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) + .apply_on_state_with_gas(tipset, msg, StateLookupPolicy::Enabled, false) .await .map_err(|e| anyhow::anyhow!("failed to apply on state with gas: {e}"))?; @@ -2219,7 +2247,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(), @@ -2227,6 +2255,7 @@ where Some(ts), VMTrace::NotTraced, StateLookupPolicy::Enabled, + false, ) .await?; Ok(apply_ret.msg_receipt().exit_code().is_success()) @@ -3944,9 +3973,9 @@ where result: trace.result, error: trace.error, }, - block_hash: block_hash.clone(), + block_hash, block_number: ts.epoch(), - transaction_hash: tx_hash.clone(), + transaction_hash: tx_hash, transaction_position: msg_idx as i64, }); } @@ -3955,6 +3984,136 @@ 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 = make_bitflags!(ApiPaths::{ V1 | V2 }); + const PERMISSION: Permission = Permission::Read; + const DESCRIPTION: Option<&'static str> = Some("Returns traces created by the transaction."); + + type Params = ( + EthCallMessage, + NonEmpty, + Option, + ); + type Ok = EthTraceResults; + async fn handle( + ctx: Ctx, + (tx, trace_types, block_param): Self::Params, + ) -> Result { + let msg = Message::try_from(tx)?; + let block_param = block_param.unwrap_or(BlockNumberOrHash::from_str("latest")?); + 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, + true, + ) + .await + .map_err(|e| anyhow::anyhow!("failed to apply message: {e}"))?; + let post_state_root = + post_state_root.context("post-execution state root required for trace call")?; + let post_state = StateTree::new_from_root(ctx.store_owned(), &post_state_root)?; + + let mut trace_results = EthTraceResults { + output: get_trace_output(&msg, &invoke_result), + ..Default::default() + }; + + // 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) + && 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() +} + +/// 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"; @@ -4101,7 +4260,7 @@ where output: get_output(), state_diff: None, trace: env.traces.clone(), - transaction_hash: tx_hash.clone(), + transaction_hash: tx_hash, vm_trace: None, }); }; @@ -4227,6 +4386,8 @@ async fn trace_filter( mod test { use super::*; use crate::rpc::eth::EventEntry; + use crate::rpc::state::{ExecutionTrace, MessageTrace, ReturnTrace}; + use crate::shim::{econ::TokenAmount, error::ExitCode}; use crate::{ db::MemoryDB, test_utils::{construct_bls_messages, construct_eth_messages, construct_messages}, @@ -4749,4 +4910,151 @@ mod test { "overflow with all ones" ); } + + fn create_execution_trace(from: FilecoinAddress, to: FilecoinAddress) -> ExecutionTrace { + ExecutionTrace { + msg: MessageTrace { + from, + to, + value: TokenAmount::default(), + method: 0, + params: Default::default(), + params_codec: 0, + gas_limit: None, + read_only: None, + }, + msg_rct: ReturnTrace { + exit_code: ExitCode::from(0u32), + r#return: Default::default(), + return_codec: 0, + }, + invoked_actor: None, + gas_charges: vec![], + subcalls: vec![], + } + } + + fn create_execution_trace_with_subcalls( + from: FilecoinAddress, + to: FilecoinAddress, + subcalls: Vec, + ) -> ExecutionTrace { + let mut trace = create_execution_trace(from, to); + trace.subcalls = subcalls; + trace + } + + #[test] + fn test_extract_touched_addresses_with_id_addresses() { + // ID addresses (e.g., f0100) can be converted to EthAddress + let from = FilecoinAddress::new_id(100); + let to = FilecoinAddress::new_id(200); + let trace = create_execution_trace(from, to); + + let addresses = extract_touched_eth_addresses(&trace); + + assert_eq!(addresses.len(), 2); + assert!(addresses.contains(&EthAddress::from_filecoin_address(&from).unwrap())); + assert!(addresses.contains(&EthAddress::from_filecoin_address(&to).unwrap())); + } + + #[test] + fn test_extract_touched_addresses_same_from_and_to() { + let addr = FilecoinAddress::new_id(100); + let trace = create_execution_trace(addr, addr); + + let addresses = extract_touched_eth_addresses(&trace); + + // Should deduplicate + assert_eq!(addresses.len(), 1); + assert!(addresses.contains(&EthAddress::from_filecoin_address(&addr).unwrap())); + } + + #[test] + fn test_extract_touched_addresses_with_subcalls() { + let addr1 = FilecoinAddress::new_id(100); + let addr2 = FilecoinAddress::new_id(200); + let addr3 = FilecoinAddress::new_id(300); + let addr4 = FilecoinAddress::new_id(400); + + let subcall = create_execution_trace(addr3, addr4); + let trace = create_execution_trace_with_subcalls(addr1, addr2, vec![subcall]); + + let addresses = extract_touched_eth_addresses(&trace); + + assert_eq!(addresses.len(), 4); + assert!(addresses.contains(&EthAddress::from_filecoin_address(&addr1).unwrap())); + assert!(addresses.contains(&EthAddress::from_filecoin_address(&addr2).unwrap())); + assert!(addresses.contains(&EthAddress::from_filecoin_address(&addr3).unwrap())); + assert!(addresses.contains(&EthAddress::from_filecoin_address(&addr4).unwrap())); + } + + #[test] + fn test_extract_touched_addresses_with_nested_subcalls() { + let addr1 = FilecoinAddress::new_id(100); + let addr2 = FilecoinAddress::new_id(200); + let addr3 = FilecoinAddress::new_id(300); + let addr4 = FilecoinAddress::new_id(400); + let addr5 = FilecoinAddress::new_id(500); + let addr6 = FilecoinAddress::new_id(600); + + // Create nested structure: trace -> subcall1 -> nested_subcall + let nested_subcall = create_execution_trace(addr5, addr6); + let subcall = create_execution_trace_with_subcalls(addr3, addr4, vec![nested_subcall]); + let trace = create_execution_trace_with_subcalls(addr1, addr2, vec![subcall]); + + let addresses = extract_touched_eth_addresses(&trace); + + assert_eq!(addresses.len(), 6); + for addr in [addr1, addr2, addr3, addr4, addr5, addr6] { + assert!(addresses.contains(&EthAddress::from_filecoin_address(&addr).unwrap())); + } + } + + #[test] + fn test_extract_touched_addresses_with_multiple_subcalls() { + let addr1 = FilecoinAddress::new_id(100); + let addr2 = FilecoinAddress::new_id(200); + let addr3 = FilecoinAddress::new_id(300); + let addr4 = FilecoinAddress::new_id(400); + let addr5 = FilecoinAddress::new_id(500); + let addr6 = FilecoinAddress::new_id(600); + + let subcall1 = create_execution_trace(addr3, addr4); + let subcall2 = create_execution_trace(addr5, addr6); + let trace = create_execution_trace_with_subcalls(addr1, addr2, vec![subcall1, subcall2]); + + let addresses = extract_touched_eth_addresses(&trace); + + assert_eq!(addresses.len(), 6); + } + + #[test] + fn test_extract_touched_addresses_deduplicates_across_subcalls() { + // Same address appears in parent and subcall + let addr1 = FilecoinAddress::new_id(100); + let addr2 = FilecoinAddress::new_id(200); + + let subcall = create_execution_trace(addr1, addr2); // addr1 repeated + let trace = create_execution_trace_with_subcalls(addr1, addr2, vec![subcall]); + + let addresses = extract_touched_eth_addresses(&trace); + + // Should deduplicate + assert_eq!(addresses.len(), 2); + } + + #[test] + fn test_extract_touched_addresses_with_non_convertible_addresses() { + // BLS addresses cannot be converted to EthAddress + let bls_addr = FilecoinAddress::new_bls(&[0u8; 48]).unwrap(); + let id_addr = FilecoinAddress::new_id(100); + + let trace = create_execution_trace(bls_addr, id_addr); + let addresses = extract_touched_eth_addresses(&trace); + + // Only the ID address should be in the set + assert_eq!(addresses.len(), 1); + assert!(addresses.contains(&EthAddress::from_filecoin_address(&id_addr).unwrap())); + } } diff --git a/src/rpc/methods/eth/filter/mod.rs b/src/rpc/methods/eth/filter/mod.rs index a7a6eaac6be3..961333d0fb45 100644 --- a/src/rpc/methods/eth/filter/mod.rs +++ b/src/rpc/methods/eth/filter/mod.rs @@ -445,7 +445,7 @@ impl EthFilterSpec { if self.from_block.is_some() || self.to_block.is_some() { bail!("must not specify block hash and from/to block"); } - ParsedFilterTipsets::Hash(block_hash.clone()) + ParsedFilterTipsets::Hash(*block_hash) } else { let from_block = self.from_block.as_deref().unwrap_or(""); let to_block = self.to_block.as_deref().unwrap_or(""); @@ -1247,7 +1247,7 @@ mod tests { // Matching the given address 0 let spec0 = EthFilterSpec { - address: Some(vec![eth_addr0.clone()].into()), + address: Some(vec![eth_addr0].into()), ..Default::default() }; @@ -1257,7 +1257,7 @@ mod tests { // Matching the given address 0 or 1 let spec1 = EthFilterSpec { - address: Some(vec![eth_addr0.clone(), eth_addr1.clone()].into()), + address: Some(vec![eth_addr0, eth_addr1].into()), ..Default::default() }; @@ -1335,45 +1335,35 @@ mod tests { assert!(spec2.matches(&addr0, &entries0).unwrap()); let spec2 = EthFilterSpec { - topics: Some(EthTopicSpec(vec![EthHashList::Single(Some( - topic0.clone(), - ))])), + topics: Some(EthTopicSpec(vec![EthHashList::Single(Some(topic0))])), ..Default::default() }; assert!(spec2.matches(&addr0, &entries0).unwrap()); let spec3 = EthFilterSpec { - topics: Some(EthTopicSpec(vec![EthHashList::List(vec![topic0.clone()])])), + topics: Some(EthTopicSpec(vec![EthHashList::List(vec![topic0])])), ..Default::default() }; assert!(spec3.matches(&addr0, &entries0).unwrap()); let spec4 = EthFilterSpec { - topics: Some(EthTopicSpec(vec![EthHashList::List(vec![ - topic1.clone(), - topic0.clone(), - ])])), + topics: Some(EthTopicSpec(vec![EthHashList::List(vec![topic1, topic0])])), ..Default::default() }; assert!(spec4.matches(&addr0, &entries0).unwrap()); let spec5 = EthFilterSpec { - topics: Some(EthTopicSpec(vec![EthHashList::Single(Some( - topic1.clone(), - ))])), + topics: Some(EthTopicSpec(vec![EthHashList::Single(Some(topic1))])), ..Default::default() }; assert!(!spec5.matches(&addr0, &entries0).unwrap()); let spec6 = EthFilterSpec { - topics: Some(EthTopicSpec(vec![EthHashList::List(vec![ - topic2.clone(), - topic3.clone(), - ])])), + topics: Some(EthTopicSpec(vec![EthHashList::List(vec![topic2, topic3])])), ..Default::default() }; @@ -1381,8 +1371,8 @@ mod tests { let spec7 = EthFilterSpec { topics: Some(EthTopicSpec(vec![ - EthHashList::Single(Some(topic1.clone())), - EthHashList::Single(Some(topic1.clone())), + EthHashList::Single(Some(topic1)), + EthHashList::Single(Some(topic1)), ])), ..Default::default() }; @@ -1391,9 +1381,9 @@ mod tests { let spec8 = EthFilterSpec { topics: Some(EthTopicSpec(vec![ - EthHashList::Single(Some(topic0.clone())), - EthHashList::Single(Some(topic1.clone())), - EthHashList::Single(Some(topic3.clone())), + EthHashList::Single(Some(topic0)), + EthHashList::Single(Some(topic1)), + EthHashList::Single(Some(topic3)), ])), ..Default::default() }; diff --git a/src/rpc/methods/eth/trace.rs b/src/rpc/methods/eth/trace.rs index 032f6c09cf54..936f4fe3ee70 100644 --- a/src/rpc/methods/eth/trace.rs +++ b/src/rpc/methods/eth/trace.rs @@ -1,28 +1,67 @@ // Copyright 2019-2026 ChainSafe Systems // SPDX-License-Identifier: Apache-2.0, MIT -use super::types::{EthAddress, EthBytes, EthCallTraceAction, EthTrace, TraceAction, TraceResult}; +use super::types::{ + EthAddress, EthBytes, EthCallTraceAction, EthHash, EthTrace, TraceAction, TraceResult, +}; use super::utils::{decode_params, decode_return}; use super::{ EthCallTraceResult, EthCreateTraceAction, EthCreateTraceResult, decode_payload, encode_filecoin_params_as_abi, encode_filecoin_returns_as_abi, }; use crate::eth::{EAMMethod, EVMMethod}; +use crate::rpc::eth::types::{AccountDiff, Delta, StateDiff}; +use crate::rpc::eth::{EthBigInt, EthUint64}; use crate::rpc::methods::eth::lookup_eth_address; use crate::rpc::methods::state::ExecutionTrace; use crate::rpc::state::ActorTrace; +use crate::shim::actors::{EVMActorStateLoad, evm}; use crate::shim::fvm_shared_latest::METHOD_CONSTRUCTOR; +use crate::shim::state_tree::ActorState; use crate::shim::{actors::is_evm_actor, address::Address, error::ExitCode, state_tree::StateTree}; +use ahash::{HashMap, HashSet}; +use anyhow::{Context, bail}; use fil_actor_eam_state::v12 as eam12; +use fil_actor_evm_state::evm_shared::v17::uints::U256; 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 anyhow::{Context, bail}; +use fvm_ipld_kamt::{AsHashedKey, Config as KamtConfig, HashedKey, Kamt}; use num::FromPrimitive; +use std::borrow::Cow; +use std::collections::BTreeMap; use tracing::debug; +/// KAMT configuration matching the EVM actor in builtin-actors. +// Code is taken from: https://github.com/filecoin-project/builtin-actors/blob/v17.0.0/actors/evm/src/interpreter/system.rs#L47 +fn evm_kamt_config() -> KamtConfig { + KamtConfig { + bit_width: 5, // 32 children per node (2^5) + min_data_depth: 0, // Data can be stored at root level + max_array_width: 1, // Max 1 key-value pair per bucket + } +} + +/// Hash algorithm for EVM storage KAMT. +// Code taken from: https://github.com/filecoin-project/builtin-actors/blob/v17.0.0/actors/evm/src/interpreter/system.rs#L49. +pub struct EvmStateHashAlgorithm; + +impl AsHashedKey for EvmStateHashAlgorithm { + fn as_hashed_key(key: &U256) -> Cow<'_, HashedKey<32>> { + Cow::Owned(key.to_big_endian()) + } +} + +/// Type alias for EVM storage KAMT with configuration. +type EvmStorageKamt = Kamt; + +fn u256_to_eth_hash(value: &U256) -> EthHash { + EthHash(ethereum_types::H256(value.to_big_endian())) +} + +const ZERO_HASH: EthHash = EthHash(ethereum_types::H256([0u8; 32])); + #[derive(Default)] pub struct Environment { caller: EthAddress, @@ -263,7 +302,7 @@ fn trace_call( r#type: "call".into(), action: TraceAction::Call(EthCallTraceAction { call_type, - from: env.caller.clone(), + from: env.caller, to: Some(to), gas: trace.msg.gas_limit.unwrap_or_default().into(), value: trace.msg.value.clone().into(), @@ -395,7 +434,7 @@ fn trace_native_create( Some(EthTrace { r#type: "create".into(), action: TraceAction::Create(EthCreateTraceAction { - from: env.caller.clone(), + from: env.caller, gas: trace.msg.gas_limit.unwrap_or_default().into(), value: trace.msg.value.clone().into(), // If we get here, this isn't a native EVM create. Those always go through @@ -505,7 +544,7 @@ fn trace_eth_create( Some(EthTrace { r#type: "create".into(), action: TraceAction::Create(EthCreateTraceAction { - from: env.caller.clone(), + from: env.caller, gas: trace.msg.gas_limit.unwrap_or_default().into(), value: trace.msg.value.clone().into(), init: init_code.into(), @@ -595,8 +634,8 @@ fn trace_evm_private( r#type: "call".into(), action: TraceAction::Call(EthCallTraceAction { call_type: "delegatecall".into(), - from: env.caller.clone(), - to: env.last_byte_code.clone(), + from: env.caller, + to: env.last_byte_code, gas: trace.msg.gas_limit.unwrap_or_default().into(), value: trace.msg.value.clone().into(), input: dp.input.into(), @@ -619,3 +658,806 @@ 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(); + + for eth_addr in touched_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 an EVM actor + let get_bytecode = |actor: &ActorState| -> Option { + if !is_evm_actor(&actor.code) { + return None; + } + + let evm_state = evm::State::load(store, actor.code, actor.state).ok()?; + 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); + + // Compare storage slots for EVM actors + diff.storage = diff_evm_storage_for_actors(store, pre_actor, post_actor)?; + + Ok(diff) +} + +/// Compute storage diff between pre and post actor states. +/// +/// Uses different Delta types based on the scenario: +/// - Account created (None → EVM): storage slots are `Delta::Added` +/// - Account deleted (EVM → None): storage slots are `Delta::Removed` +/// - Account modified (EVM → EVM): storage slots are `Delta::Changed` +/// - Actor type changed (EVM ↔ non-EVM): treated as deletion + creation +fn diff_evm_storage_for_actors( + store: &DB, + pre_actor: Option<&ActorState>, + post_actor: Option<&ActorState>, +) -> anyhow::Result>> { + let pre_is_evm = pre_actor.is_some_and(|a| is_evm_actor(&a.code)); + let post_is_evm = post_actor.is_some_and(|a| is_evm_actor(&a.code)); + + // Extract storage entries from EVM actors (empty map for non-EVM or missing actors) + let pre_entries = extract_evm_storage_entries(store, pre_actor); + let post_entries = extract_evm_storage_entries(store, post_actor); + + // If both are empty, no storage diff + if pre_entries.is_empty() && post_entries.is_empty() { + return Ok(BTreeMap::new()); + } + + let mut diff = BTreeMap::new(); + + match (pre_is_evm, post_is_evm) { + (false, true) => { + for (key_bytes, value) in &post_entries { + let key_hash = EthHash(ethereum_types::H256(*key_bytes)); + diff.insert(key_hash, Delta::Added(u256_to_eth_hash(value))); + } + } + (true, false) => { + for (key_bytes, value) in &pre_entries { + let key_hash = EthHash(ethereum_types::H256(*key_bytes)); + diff.insert(key_hash, Delta::Removed(u256_to_eth_hash(value))); + } + } + (true, true) => { + for (key_bytes, pre_value) in &pre_entries { + let key_hash = EthHash(ethereum_types::H256(*key_bytes)); + let pre_hash = u256_to_eth_hash(pre_value); + + match post_entries.get(key_bytes) { + Some(post_value) if pre_value != post_value => { + // Value changed + diff.insert( + key_hash, + Delta::Changed(super::types::ChangedType { + from: pre_hash, + to: u256_to_eth_hash(post_value), + }), + ); + } + Some(_) => { + // Value unchanged, skip + } + None => { + // Slot cleared (value → zero) + diff.insert( + key_hash, + Delta::Changed(super::types::ChangedType { + from: pre_hash, + to: ZERO_HASH, + }), + ); + } + } + } + + // Check for newly written entries (zero → value) + for (key_bytes, post_value) in &post_entries { + if !pre_entries.contains_key(key_bytes) { + let key_hash = EthHash(ethereum_types::H256(*key_bytes)); + diff.insert( + key_hash, + Delta::Changed(super::types::ChangedType { + from: ZERO_HASH, + to: u256_to_eth_hash(post_value), + }), + ); + } + } + } + // Neither EVM: no storage diff + (false, false) => {} + } + + Ok(diff) +} + +/// Extract all storage entries from an EVM actor's KAMT. +/// Returns empty map if actor is None, not an EVM actor, or state cannot be loaded. +fn extract_evm_storage_entries( + store: &DB, + actor: Option<&ActorState>, +) -> HashMap<[u8; 32], U256> { + let actor = match actor { + Some(a) if is_evm_actor(&a.code) => a, + _ => return HashMap::default(), + }; + + let evm_state = match evm::State::load(store, actor.code, actor.state) { + Ok(state) => state, + Err(e) => { + debug!("failed to load EVM state for storage extraction: {e}"); + return HashMap::default(); + } + }; + + let storage_cid = evm_state.contract_state(); + let config = evm_kamt_config(); + + let kamt: EvmStorageKamt<&DB> = match Kamt::load_with_config(&storage_cid, store, config) { + Ok(k) => k, + Err(e) => { + debug!("failed to load storage KAMT: {e}"); + return HashMap::default(); + } + }; + + let mut entries = HashMap::default(); + if let Err(e) = kamt.for_each(|key, value| { + entries.insert(key.to_big_endian(), *value); + Ok(()) + }) { + debug!("failed to iterate storage KAMT: {e}"); + return HashMap::default(); + } + + entries +} + +#[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) + && 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..5359c5d57e58 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, @@ -127,7 +131,7 @@ impl GetSize for EthAddress { } impl EthAddress { - pub fn to_filecoin_address(&self) -> anyhow::Result { + pub fn to_filecoin_address(self) -> anyhow::Result { if self.is_masked_id() { const PREFIX_LEN: usize = MASKED_ID_PREFIX.len(); // This is a masked ID address. @@ -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, @@ -715,16 +722,15 @@ impl EthTrace { to_decoded_addresses: &Option, ) -> Result { let (trace_to, trace_from) = match &self.action { - TraceAction::Call(action) => (action.to.clone(), action.from.clone()), + TraceAction::Call(action) => (action.to, action.from), TraceAction::Create(action) => { let address = match &self.result { TraceResult::Create(result) => result .address - .clone() .ok_or_else(|| anyhow::anyhow!("address is nil in create trace result"))?, _ => bail!("invalid create trace result"), }; - (Some(address), action.from.clone()) + (Some(address), action.from) } }; @@ -748,6 +754,98 @@ 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, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum Delta { + /// Existing value didn't change. + #[serde(rename = "=")] + #[default] + 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 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 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::*; @@ -804,4 +902,79 @@ mod tests { let result = EthAddress::eth_address_from_pub_key(&pubkey).unwrap(); assert_eq!(result, expected_eth_address); } + + #[test] + fn test_changed_type_serialization() { + let changed = ChangedType { + from: 10u64, + to: 20u64, + }; + let json = serde_json::to_string(&changed).unwrap(); + assert_eq!(json, r#"{"from":10,"to":20}"#); + + let deserialized: ChangedType = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, changed); + } + + #[test] + fn test_delta_unchanged() { + let delta: Delta = Delta::from_comparison(Some(42), Some(42)); + assert!(delta.is_unchanged()); + assert_eq!(delta, Delta::Unchanged); + + let json = serde_json::to_string(&delta).unwrap(); + assert_eq!(json, r#""=""#); + } + + #[test] + fn test_delta_added() { + let delta: Delta = Delta::from_comparison(None, Some(100)); + assert!(!delta.is_unchanged()); + assert_eq!(delta, Delta::Added(100)); + + let json = serde_json::to_string(&delta).unwrap(); + assert_eq!(json, r#"{"+":100}"#); + } + + #[test] + fn test_delta_removed() { + let delta: Delta = Delta::from_comparison(Some(50), None); + assert!(!delta.is_unchanged()); + assert_eq!(delta, Delta::Removed(50)); + + let json = serde_json::to_string(&delta).unwrap(); + assert_eq!(json, r#"{"-":50}"#); + } + + #[test] + fn test_delta_changed() { + let delta: Delta = Delta::from_comparison(Some(10), Some(20)); + assert!(!delta.is_unchanged()); + assert_eq!(delta, Delta::Changed(ChangedType { from: 10, to: 20 })); + + let json = serde_json::to_string(&delta).unwrap(); + assert_eq!(json, r#"{"*":{"from":10,"to":20}}"#); + } + + #[test] + fn test_delta_none_none() { + let delta: Delta = Delta::from_comparison(None, None); + assert!(delta.is_unchanged()); + assert_eq!(delta, Delta::Unchanged); + } + + #[test] + fn test_delta_deserialization() { + let unchanged: Delta = serde_json::from_str(r#""=""#).unwrap(); + assert_eq!(unchanged, Delta::Unchanged); + + let added: Delta = serde_json::from_str(r#"{"+":42}"#).unwrap(); + assert_eq!(added, Delta::Added(42)); + + let removed: Delta = serde_json::from_str(r#"{"-":42}"#).unwrap(); + assert_eq!(removed, Delta::Removed(42)); + + let changed: Delta = serde_json::from_str(r#"{"*":{"from":10,"to":20}}"#).unwrap(); + assert_eq!(changed, Delta::Changed(ChangedType { from: 10, to: 20 })); + } } diff --git a/src/rpc/methods/gas.rs b/src/rpc/methods/gas.rs index 6c8957732bec..7860b4665c96 100644 --- a/src/rpc/methods/gas.rs +++ b/src/rpc/methods/gas.rs @@ -255,7 +255,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, @@ -263,6 +263,7 @@ impl GasEstimateGasLimit { Some(ts.clone()), trace_config, StateLookupPolicy::Enabled, + false, ) .await?; Ok((invoc_res, apply_ret, prior_messages, ts)) diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 75efa377d0ef..caef1e139010 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -153,6 +153,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/rpc/snapshots/forest__rpc__tests__rpc__v1.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap index 2cb6025bcf52..d195d2191e81 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap @@ -1555,6 +1555,52 @@ methods: items: $ref: "#/components/schemas/EthBlockTrace" paramStructure: by-position + - name: Forest.EthTraceCall + description: Returns traces created by the transaction. + params: + - name: tx + required: true + schema: + $ref: "#/components/schemas/EthCallMessage" + - name: traceTypes + required: true + schema: + $ref: "#/components/schemas/NonEmpty_Array_of_EthTraceType" + - name: blockParam + required: false + schema: + anyOf: + - $ref: "#/components/schemas/BlockNumberOrHash" + - type: "null" + result: + name: Forest.EthTraceCall.Result + required: true + schema: + $ref: "#/components/schemas/EthTraceResults" + paramStructure: by-position + - name: trace_call + description: Returns traces created by the transaction. + params: + - name: tx + required: true + schema: + $ref: "#/components/schemas/EthCallMessage" + - name: traceTypes + required: true + schema: + $ref: "#/components/schemas/NonEmpty_Array_of_EthTraceType" + - name: blockParam + required: false + schema: + anyOf: + - $ref: "#/components/schemas/BlockNumberOrHash" + - type: "null" + result: + name: trace_call.Result + required: true + schema: + $ref: "#/components/schemas/EthTraceResults" + paramStructure: by-position - name: Filecoin.EthTraceFilter description: Returns the traces for transactions matching the filter criteria. params: @@ -4308,6 +4354,26 @@ methods: paramStructure: by-position components: schemas: + AccountDiff: + description: "Account state diff after transaction execution.\nTracks changes to balance, nonce, code, and storage." + type: object + properties: + balance: + $ref: "#/components/schemas/Delta" + code: + $ref: "#/components/schemas/Delta2" + nonce: + $ref: "#/components/schemas/Delta3" + storage: + description: All touched/changed storage values (key -> delta) + type: object + additionalProperties: + $ref: "#/components/schemas/Delta4" + required: + - balance + - code + - nonce + - storage ActorEvent: type: object properties: @@ -5084,6 +5150,58 @@ components: - tipset_keys - skip_checksum - dry_run + ChangedType: + description: Represents a changed value with before and after states. + type: object + properties: + from: + description: Value before the change + $ref: "#/components/schemas/EthBigInt" + to: + description: Value after the change + $ref: "#/components/schemas/EthBigInt" + required: + - from + - to + ChangedType2: + description: Represents a changed value with before and after states. + type: object + properties: + from: + description: Value before the change + $ref: "#/components/schemas/EthBytes" + to: + description: Value after the change + $ref: "#/components/schemas/EthBytes" + required: + - from + - to + ChangedType3: + description: Represents a changed value with before and after states. + type: object + properties: + from: + description: Value before the change + $ref: "#/components/schemas/EthUint64" + to: + description: Value after the change + $ref: "#/components/schemas/EthUint64" + required: + - from + - to + ChangedType4: + description: Represents a changed value with before and after states. + type: object + properties: + from: + description: Value before the change + $ref: "#/components/schemas/EthHash" + to: + description: Value after the change + $ref: "#/components/schemas/EthHash" + required: + - from + - to Cid: type: object properties: @@ -5184,6 +5302,126 @@ components: required: - Min - Max + Delta: + description: Represents how a value changed during transaction execution. + oneOf: + - description: "Existing value didn't change." + type: string + const: "=" + - description: A new value was added (account/storage created). + type: object + properties: + +: + $ref: "#/components/schemas/EthBigInt" + additionalProperties: false + required: + - + + - description: The existing value was removed (account/storage deleted). + type: object + properties: + "-": + $ref: "#/components/schemas/EthBigInt" + additionalProperties: false + required: + - "-" + - description: The existing value changed from one value to another. + type: object + properties: + "*": + $ref: "#/components/schemas/ChangedType" + additionalProperties: false + required: + - "*" + Delta2: + description: Represents how a value changed during transaction execution. + oneOf: + - description: "Existing value didn't change." + type: string + const: "=" + - description: A new value was added (account/storage created). + type: object + properties: + +: + $ref: "#/components/schemas/EthBytes" + additionalProperties: false + required: + - + + - description: The existing value was removed (account/storage deleted). + type: object + properties: + "-": + $ref: "#/components/schemas/EthBytes" + additionalProperties: false + required: + - "-" + - description: The existing value changed from one value to another. + type: object + properties: + "*": + $ref: "#/components/schemas/ChangedType2" + additionalProperties: false + required: + - "*" + Delta3: + description: Represents how a value changed during transaction execution. + oneOf: + - description: "Existing value didn't change." + type: string + const: "=" + - description: A new value was added (account/storage created). + type: object + properties: + +: + $ref: "#/components/schemas/EthUint64" + additionalProperties: false + required: + - + + - description: The existing value was removed (account/storage deleted). + type: object + properties: + "-": + $ref: "#/components/schemas/EthUint64" + additionalProperties: false + required: + - "-" + - description: The existing value changed from one value to another. + type: object + properties: + "*": + $ref: "#/components/schemas/ChangedType3" + additionalProperties: false + required: + - "*" + Delta4: + description: Represents how a value changed during transaction execution. + oneOf: + - description: "Existing value didn't change." + type: string + const: "=" + - description: A new value was added (account/storage created). + type: object + properties: + +: + $ref: "#/components/schemas/EthHash" + additionalProperties: false + required: + - + + - description: The existing value was removed (account/storage deleted). + type: object + properties: + "-": + $ref: "#/components/schemas/EthHash" + additionalProperties: false + required: + - "-" + - description: The existing value changed from one value to another. + type: object + properties: + "*": + $ref: "#/components/schemas/ChangedType4" + additionalProperties: false + required: + - "*" ECTipSet: type: object properties: @@ -5606,6 +5844,34 @@ components: type: - string - "null" + EthTraceResults: + type: object + properties: + output: + description: Output bytes from the transaction execution + anyOf: + - $ref: "#/components/schemas/EthBytes" + - type: "null" + stateDiff: + description: State diff showing all account changes (only when StateDiff trace type requested) + anyOf: + - $ref: "#/components/schemas/StateDiff" + - type: "null" + trace: + description: Call trace hierarchy (only when Trace trace type requested) + type: array + items: + $ref: "#/components/schemas/EthTrace" + required: + - trace + EthTraceType: + oneOf: + - description: "Requests a structured call graph, showing the hierarchy of calls (e.g., `call`, `create`, `reward`)\nwith details like `from`, `to`, `gas`, `input`, `output`, and `subtraces`." + type: string + const: trace + - description: "Requests a state difference object, detailing changes to account states (e.g., `balance`, `nonce`, `storage`, `code`)\ncaused by the simulated transaction.\n\nIt shows `\"from\"` and `\"to\"` values for modified fields, using `\"+\"`, `\"-\"`, or `\"=\"` for code changes." + type: string + const: stateDiff EthTxReceipt: type: object properties: @@ -6779,6 +7045,11 @@ components: items: $ref: "#/components/schemas/ECTipSet" minItems: 1 + NonEmpty_Array_of_EthTraceType: + type: array + items: + $ref: "#/components/schemas/EthTraceType" + minItems: 1 Nonce: type: string Nullable_Address: @@ -7302,6 +7573,11 @@ components: - Manifest - Bundle - ActorCids + StateDiff: + description: State diff containing all account changes from a transaction. + type: object + additionalProperties: + $ref: "#/components/schemas/AccountDiff" SupplementalData: type: object properties: diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap index 2f602208d0d8..4c9122d572be 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap @@ -1112,6 +1112,52 @@ methods: items: $ref: "#/components/schemas/EthBlockTrace" paramStructure: by-position + - name: Forest.EthTraceCall + description: Returns traces created by the transaction. + params: + - name: tx + required: true + schema: + $ref: "#/components/schemas/EthCallMessage" + - name: traceTypes + required: true + schema: + $ref: "#/components/schemas/NonEmpty_Array_of_EthTraceType" + - name: blockParam + required: false + schema: + anyOf: + - $ref: "#/components/schemas/BlockNumberOrHash" + - type: "null" + result: + name: Forest.EthTraceCall.Result + required: true + schema: + $ref: "#/components/schemas/EthTraceResults" + paramStructure: by-position + - name: trace_call + description: Returns traces created by the transaction. + params: + - name: tx + required: true + schema: + $ref: "#/components/schemas/EthCallMessage" + - name: traceTypes + required: true + schema: + $ref: "#/components/schemas/NonEmpty_Array_of_EthTraceType" + - name: blockParam + required: false + schema: + anyOf: + - $ref: "#/components/schemas/BlockNumberOrHash" + - type: "null" + result: + name: trace_call.Result + required: true + schema: + $ref: "#/components/schemas/EthTraceResults" + paramStructure: by-position - name: Filecoin.EthTraceTransaction description: Returns the traces for a specific transaction. params: @@ -1330,6 +1376,26 @@ methods: paramStructure: by-position components: schemas: + AccountDiff: + description: "Account state diff after transaction execution.\nTracks changes to balance, nonce, code, and storage." + type: object + properties: + balance: + $ref: "#/components/schemas/Delta" + code: + $ref: "#/components/schemas/Delta2" + nonce: + $ref: "#/components/schemas/Delta3" + storage: + description: All touched/changed storage values (key -> delta) + type: object + additionalProperties: + $ref: "#/components/schemas/Delta4" + required: + - balance + - code + - nonce + - storage ActorState: type: object properties: @@ -1577,12 +1643,71 @@ components: $ref: "#/components/schemas/EthInt64" required: - blockNumber + BlockNumberOrHash: + anyOf: + - type: string + - $ref: "#/components/schemas/EthInt64" + - $ref: "#/components/schemas/EthHash" + - $ref: "#/components/schemas/BlockNumber" + - $ref: "#/components/schemas/BlockHash" BlockNumberOrPredefined: anyOf: - type: string - $ref: "#/components/schemas/EthInt64" Bloom: type: string + ChangedType: + description: Represents a changed value with before and after states. + type: object + properties: + from: + description: Value before the change + $ref: "#/components/schemas/EthBigInt" + to: + description: Value after the change + $ref: "#/components/schemas/EthBigInt" + required: + - from + - to + ChangedType2: + description: Represents a changed value with before and after states. + type: object + properties: + from: + description: Value before the change + $ref: "#/components/schemas/EthBytes" + to: + description: Value after the change + $ref: "#/components/schemas/EthBytes" + required: + - from + - to + ChangedType3: + description: Represents a changed value with before and after states. + type: object + properties: + from: + description: Value before the change + $ref: "#/components/schemas/EthUint64" + to: + description: Value after the change + $ref: "#/components/schemas/EthUint64" + required: + - from + - to + ChangedType4: + description: Represents a changed value with before and after states. + type: object + properties: + from: + description: Value before the change + $ref: "#/components/schemas/EthHash" + to: + description: Value after the change + $ref: "#/components/schemas/EthHash" + required: + - from + - to Cid: type: object properties: @@ -1590,6 +1715,126 @@ components: type: string required: - / + Delta: + description: Represents how a value changed during transaction execution. + oneOf: + - description: "Existing value didn't change." + type: string + const: "=" + - description: A new value was added (account/storage created). + type: object + properties: + +: + $ref: "#/components/schemas/EthBigInt" + additionalProperties: false + required: + - + + - description: The existing value was removed (account/storage deleted). + type: object + properties: + "-": + $ref: "#/components/schemas/EthBigInt" + additionalProperties: false + required: + - "-" + - description: The existing value changed from one value to another. + type: object + properties: + "*": + $ref: "#/components/schemas/ChangedType" + additionalProperties: false + required: + - "*" + Delta2: + description: Represents how a value changed during transaction execution. + oneOf: + - description: "Existing value didn't change." + type: string + const: "=" + - description: A new value was added (account/storage created). + type: object + properties: + +: + $ref: "#/components/schemas/EthBytes" + additionalProperties: false + required: + - + + - description: The existing value was removed (account/storage deleted). + type: object + properties: + "-": + $ref: "#/components/schemas/EthBytes" + additionalProperties: false + required: + - "-" + - description: The existing value changed from one value to another. + type: object + properties: + "*": + $ref: "#/components/schemas/ChangedType2" + additionalProperties: false + required: + - "*" + Delta3: + description: Represents how a value changed during transaction execution. + oneOf: + - description: "Existing value didn't change." + type: string + const: "=" + - description: A new value was added (account/storage created). + type: object + properties: + +: + $ref: "#/components/schemas/EthUint64" + additionalProperties: false + required: + - + + - description: The existing value was removed (account/storage deleted). + type: object + properties: + "-": + $ref: "#/components/schemas/EthUint64" + additionalProperties: false + required: + - "-" + - description: The existing value changed from one value to another. + type: object + properties: + "*": + $ref: "#/components/schemas/ChangedType3" + additionalProperties: false + required: + - "*" + Delta4: + description: Represents how a value changed during transaction execution. + oneOf: + - description: "Existing value didn't change." + type: string + const: "=" + - description: A new value was added (account/storage created). + type: object + properties: + +: + $ref: "#/components/schemas/EthHash" + additionalProperties: false + required: + - + + - description: The existing value was removed (account/storage deleted). + type: object + properties: + "-": + $ref: "#/components/schemas/EthHash" + additionalProperties: false + required: + - "-" + - description: The existing value changed from one value to another. + type: object + properties: + "*": + $ref: "#/components/schemas/ChangedType4" + additionalProperties: false + required: + - "*" ElectionProof: type: object properties: @@ -1932,6 +2177,34 @@ components: - traceAddress - action - result + EthTraceResults: + type: object + properties: + output: + description: Output bytes from the transaction execution + anyOf: + - $ref: "#/components/schemas/EthBytes" + - type: "null" + stateDiff: + description: State diff showing all account changes (only when StateDiff trace type requested) + anyOf: + - $ref: "#/components/schemas/StateDiff" + - type: "null" + trace: + description: Call trace hierarchy (only when Trace trace type requested) + type: array + items: + $ref: "#/components/schemas/EthTrace" + required: + - trace + EthTraceType: + oneOf: + - description: "Requests a structured call graph, showing the hierarchy of calls (e.g., `call`, `create`, `reward`)\nwith details like `from`, `to`, `gas`, `input`, `output`, and `subtraces`." + type: string + const: trace + - description: "Requests a state difference object, detailing changes to account states (e.g., `balance`, `nonce`, `storage`, `code`)\ncaused by the simulated transaction.\n\nIt shows `\"from\"` and `\"to\"` values for modified fields, using `\"+\"`, `\"-\"`, or `\"=\"` for code changes." + type: string + const: stateDiff EthTxReceipt: type: object properties: @@ -2006,6 +2279,11 @@ components: items: $ref: "#/components/schemas/Cid" minItems: 1 + NonEmpty_Array_of_EthTraceType: + type: array + items: + $ref: "#/components/schemas/EthTraceType" + minItems: 1 Nonce: type: string Nullable_Address: @@ -2070,6 +2348,11 @@ components: maximum: 255 minimum: 0 - $ref: "#/components/schemas/SignatureType" + StateDiff: + description: State diff containing all account changes from a transaction. + type: object + additionalProperties: + $ref: "#/components/schemas/AccountDiff" Ticket: type: object properties: diff --git a/src/shim/actors/builtin/evm/mod.rs b/src/shim/actors/builtin/evm/mod.rs index a8cca3e8309a..109daff1e314 100644 --- a/src/shim/actors/builtin/evm/mod.rs +++ b/src/shim/actors/builtin/evm/mod.rs @@ -52,6 +52,18 @@ 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()) + } + + pub fn contract_state(&self) -> Cid { + delegate_state!(self.contract_state) + } } #[delegated_enum(impl_conversions)] diff --git a/src/state_manager/mod.rs b/src/state_manager/mod.rs index 7256d5b04de4..38d976991a64 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,8 @@ where tipset: Option, msg: Message, state_lookup: StateLookupPolicy, - ) -> anyhow::Result { + flush: bool, + ) -> anyhow::Result<(ApiInvocResult, Option)> { let ts = tipset.unwrap_or_else(|| self.heaviest_tipset()); let from_a = self.resolve_to_key_addr(&msg.from, &ts).await?; @@ -706,18 +706,30 @@ where _ => ChainMessage::Unsigned(msg.clone()), }; - let (_invoc_res, apply_ret, duration) = self - .call_with_gas(&mut chain_msg, &[], Some(ts), VMTrace::Traced, state_lookup) + let (_invoc_res, apply_ret, duration, state_root) = self + .call_with_gas( + &mut chain_msg, + &[], + Some(ts), + VMTrace::Traced, + state_lookup, + flush, + ) .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 +741,8 @@ where tipset: Option, trace_config: VMTrace, state_lookup: StateLookupPolicy, - ) -> Result<(InvocResult, ApplyRet, Duration), Error> { + flush: bool, + ) -> Result<(InvocResult, ApplyRet, Duration, Option), Error> { let ts = tipset.unwrap_or_else(|| self.heaviest_tipset()); let (st, _) = self .tipset_state(&ts, state_lookup) @@ -743,7 +756,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 +784,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 = if flush { Some(vm.flush()?) } else { None }; + 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 23df90854168..8e87905e7cf4 100644 --- a/src/tool/subcommands/api_cmd/api_compare_tests.rs +++ b/src/tool/subcommands/api_cmd/api_compare_tests.rs @@ -1456,7 +1456,7 @@ fn eth_tests() -> Vec { for (to, data) in cases { let msg = EthCallMessage { - to: to.clone(), + to, data: data.clone(), ..EthCallMessage::default() }; @@ -1622,14 +1622,14 @@ fn eth_tests_with_tipset(store: &Arc, shared_tipset: &Tipset RpcTest::identity( EthGetBalance::request(( EthAddress::from_str("0xff000000000000000000000000000000000003ec").unwrap(), - BlockNumberOrHash::from_block_hash_object(block_hash.clone(), false), + BlockNumberOrHash::from_block_hash_object(block_hash, false), )) .unwrap(), ), RpcTest::identity( EthGetBalance::request(( EthAddress::from_str("0xff000000000000000000000000000000000003ec").unwrap(), - BlockNumberOrHash::from_block_hash_object(block_hash.clone(), true), + BlockNumberOrHash::from_block_hash_object(block_hash, true), )) .unwrap(), ), @@ -1686,14 +1686,14 @@ fn eth_tests_with_tipset(store: &Arc, shared_tipset: &Tipset RpcTest::identity( EthGetBalanceV2::request(( EthAddress::from_str("0xff000000000000000000000000000000000003ec").unwrap(), - ExtBlockNumberOrHash::from_block_hash_object(block_hash.clone(), false), + ExtBlockNumberOrHash::from_block_hash_object(block_hash, false), )) .unwrap(), ), RpcTest::identity( EthGetBalanceV2::request(( EthAddress::from_str("0xff000000000000000000000000000000000003ec").unwrap(), - ExtBlockNumberOrHash::from_block_hash_object(block_hash.clone(), true), + ExtBlockNumberOrHash::from_block_hash_object(block_hash, true), )) .unwrap(), ), @@ -1842,8 +1842,7 @@ fn eth_tests_with_tipset(store: &Arc, shared_tipset: &Tipset ), RpcTest::identity( EthGetBlockReceipts::request((BlockNumberOrHash::from_block_hash_object( - block_hash.clone(), - true, + block_hash, true, ),)) .unwrap(), ), @@ -1856,8 +1855,7 @@ fn eth_tests_with_tipset(store: &Arc, shared_tipset: &Tipset ), RpcTest::identity( EthGetBlockReceiptsV2::request((ExtBlockNumberOrHash::from_block_hash_object( - block_hash.clone(), - true, + block_hash, true, ),)) .unwrap(), ), @@ -1873,12 +1871,10 @@ fn eth_tests_with_tipset(store: &Arc, shared_tipset: &Tipset ),)) .unwrap(), ), - RpcTest::identity( - EthGetBlockTransactionCountByHash::request((block_hash.clone(),)).unwrap(), - ), + RpcTest::identity(EthGetBlockTransactionCountByHash::request((block_hash,)).unwrap()), RpcTest::identity( EthGetBlockReceiptsLimited::request(( - BlockNumberOrHash::from_block_hash_object(block_hash.clone(), true), + BlockNumberOrHash::from_block_hash_object(block_hash, true), 4, )) .unwrap(), @@ -1886,14 +1882,14 @@ fn eth_tests_with_tipset(store: &Arc, shared_tipset: &Tipset .policy_on_rejected(PolicyOnRejected::PassWithIdenticalError), RpcTest::identity( EthGetBlockReceiptsLimited::request(( - BlockNumberOrHash::from_block_hash_object(block_hash.clone(), true), + BlockNumberOrHash::from_block_hash_object(block_hash, true), -1, )) .unwrap(), ), RpcTest::identity( EthGetBlockReceiptsLimitedV2::request(( - ExtBlockNumberOrHash::from_block_hash_object(block_hash.clone(), true), + ExtBlockNumberOrHash::from_block_hash_object(block_hash, true), 4, )) .unwrap(), @@ -1901,7 +1897,7 @@ fn eth_tests_with_tipset(store: &Arc, shared_tipset: &Tipset .policy_on_rejected(PolicyOnRejected::PassWithIdenticalError), RpcTest::identity( EthGetBlockReceiptsLimitedV2::request(( - ExtBlockNumberOrHash::from_block_hash_object(block_hash.clone(), true), + ExtBlockNumberOrHash::from_block_hash_object(block_hash, true), -1, )) .unwrap(), @@ -1931,7 +1927,7 @@ fn eth_tests_with_tipset(store: &Arc, shared_tipset: &Tipset RpcTest::identity( EthGetTransactionCount::request(( EthAddress::from_str("0xff000000000000000000000000000000000003ec").unwrap(), - BlockNumberOrHash::from_block_hash_object(block_hash.clone(), true), + BlockNumberOrHash::from_block_hash_object(block_hash, true), )) .unwrap(), ), @@ -1967,7 +1963,7 @@ fn eth_tests_with_tipset(store: &Arc, shared_tipset: &Tipset RpcTest::identity( EthGetTransactionCountV2::request(( EthAddress::from_str("0xff000000000000000000000000000000000003ec").unwrap(), - ExtBlockNumberOrHash::from_block_hash_object(block_hash.clone(), true), + ExtBlockNumberOrHash::from_block_hash_object(block_hash, true), )) .unwrap(), ), @@ -2336,11 +2332,11 @@ fn eth_tests_with_tipset(store: &Arc, shared_tipset: &Tipset ) .policy_on_rejected(PolicyOnRejected::PassWithQuasiIdenticalError), RpcTest::identity( - EthGetTransactionByBlockHashAndIndex::request((block_hash.clone(), 0.into())).unwrap(), + EthGetTransactionByBlockHashAndIndex::request((block_hash, 0.into())).unwrap(), ) .policy_on_rejected(PolicyOnRejected::PassWithIdenticalError), - RpcTest::identity(EthGetBlockByHash::request((block_hash.clone(), false)).unwrap()), - RpcTest::identity(EthGetBlockByHash::request((block_hash.clone(), true)).unwrap()), + RpcTest::identity(EthGetBlockByHash::request((block_hash, false)).unwrap()), + RpcTest::identity(EthGetBlockByHash::request((block_hash, true)).unwrap()), RpcTest::identity( EthGetLogs::request((EthFilterSpec { from_block: Some(format!("0x{:x}", shared_tipset.epoch())), @@ -2513,7 +2509,7 @@ fn eth_tests_with_tipset(store: &Arc, shared_tipset: &Tipset tests.extend([RpcTest::identity( EthEstimateGas::request(( EthCallMessage { - to: Some(eth_to_addr.clone()), + to: Some(eth_to_addr), value: Some(msg.value.clone().into()), data: Some(msg.params.clone().into()), ..Default::default() @@ -2624,13 +2620,13 @@ fn eth_state_tests_with_tipset( for smsg in sample_signed_messages(bls_messages.iter(), secp_messages.iter()) { let tx = new_eth_tx_from_signed_message(&smsg, &state, eth_chain_id)?; tests.push(RpcTest::identity( - EthGetMessageCidByTransactionHash::request((tx.hash.clone(),))?, + EthGetMessageCidByTransactionHash::request((tx.hash,))?, )); - tests.push(RpcTest::identity(EthGetTransactionByHash::request((tx - .hash - .clone(),))?)); + tests.push(RpcTest::identity(EthGetTransactionByHash::request(( + tx.hash, + ))?)); tests.push(RpcTest::identity(EthGetTransactionByHashLimited::request( - (tx.hash.clone(), shared_tipset.epoch()), + (tx.hash, shared_tipset.epoch()), )?)); tests.push(RpcTest::identity(EthTraceTransaction::request((tx .hash @@ -2639,7 +2635,7 @@ fn eth_state_tests_with_tipset( && smsg.message.to.protocol() == Protocol::Delegated { tests.push( - RpcTest::identity(EthGetTransactionReceipt::request((tx.hash.clone(),))?) + RpcTest::identity(EthGetTransactionReceipt::request((tx.hash,))?) .policy_on_rejected(PolicyOnRejected::PassWithQuasiIdenticalError), ); tests.push( 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..66813cabd258 --- /dev/null +++ b/src/tool/subcommands/api_cmd/contracts/tracer/Tracer.sol @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.30; + +/// @title Tracer Contract +/// @notice Test contract for validating Forest's trace_call RPC implementation. +/// @dev This contract is used internally for: +/// - Integration tests comparing Forest's trace_call output with other implementations (e.g., Anvil) +/// - Manual testing of trace, stateDiff +/// - Generating test vectors for EVM execution tracing +/// +/// NOT intended for production use. Functions are designed to exercise specific +/// EVM behaviors (storage writes, subcalls, reverts, events) for trace validation. +/// +/// See: docs/docs/developers/guides/trace_call_guide.md +contract Tracer { + uint256 public x; // slot 0 - initialized to 42 + mapping(address => uint256) public balances; // slot 1 - mapping base + + // Storage slots for stateDiff testing (uninitialized = start empty) + uint256 public storageTestA; // slot 2 - for add/change/delete tests + uint256 public storageTestB; // slot 3 - for multiple slot tests + uint256 public storageTestC; // slot 4 - for multiple slot tests + uint256[] public dynamicArray; // slot 5 - for array storage tests + + 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)); + } + + // ========== STORAGE DIFF TEST FUNCTIONS ========== + // These functions test stateDiff storage tracking: + // - Added (+): Write non-zero to empty slot (slot was 0) + // - Changed (*): Change non-zero to different non-zero + // - Removed (-): Set non-zero slot to 0 + + // 16. Storage Add - Write to empty slot + // First call creates "Added" (+) entry in stateDiff.storage + function storageAdd(uint256 value) external { + require(value != 0, "use non-zero for add test"); + storageTestA = value; + } + + // 17. Storage Change - Modify existing slot + // Creates "Changed" (*) entry in stateDiff.storage + function storageChange(uint256 newValue) external { + require(storageTestA != 0, "slot must have value first"); + require( + newValue != 0 && newValue != storageTestA, + "use different non-zero" + ); + storageTestA = newValue; + } + + // 18. Storage Delete - Set slot to zero + // Creates "Removed" (-) entry in stateDiff.storage + function storageDelete() external { + require(storageTestA != 0, "slot must have value first"); + storageTestA = 0; + } + + // 19. Storage Multiple - Change multiple slots in one call + // Useful for testing multiple storage entries in stateDiff + function storageMultiple(uint256 a, uint256 b, uint256 c) external { + storageTestA = a; + storageTestB = b; + storageTestC = c; + } + + // 20. Storage Mixed - Add, Change, and Delete in one call + // Requires: storageTestA has value, storageTestB is empty + function storageMixed(uint256 newA, uint256 newC) external { + // Change existing (storageTestA should have value) + storageTestA = newA; + // Delete (set to 0) + storageTestB = 0; + // Add new value + storageTestC = newC; + } + + // 21. Array Push - Adds new storage slot + // Dynamic arrays use keccak256(slot) + index for element storage + function arrayPush(uint256 value) external { + dynamicArray.push(value); + } + + // 22. Array Pop - Removes storage slot (sets to 0) + function arrayPop() external { + require(dynamicArray.length > 0, "array is empty"); + dynamicArray.pop(); + } + + // 23. Reset storage test slots to initial state (all zeros) + function storageReset() external { + storageTestA = 0; + storageTestB = 0; + storageTestC = 0; + delete dynamicArray; + } + + // 24. Get storage test values (for verification) + function getStorageTestValues() + external + view + returns (uint256, uint256, uint256, uint256) + { + return (storageTestA, storageTestB, storageTestC, dynamicArray.length); + } +} diff --git a/src/tool/subcommands/api_cmd/stateful_tests.rs b/src/tool/subcommands/api_cmd/stateful_tests.rs index 0d32bed136ce..3ea5d93c59ad 100644 --- a/src/tool/subcommands/api_cmd/stateful_tests.rs +++ b/src/tool/subcommands/api_cmd/stateful_tests.rs @@ -439,7 +439,7 @@ fn eth_new_block_filter() -> RpcTestScenario { let verify_hashes = async |hashes: &[EthHash]| -> anyhow::Result<()> { for hash in hashes { let _block = client - .call(EthGetBlockByHash::request((hash.clone(), false))?) + .call(EthGetBlockByHash::request((*hash, false))?) .await?; } Ok(()) @@ -529,7 +529,7 @@ fn eth_new_pending_transaction_filter(tx: TestTransaction) -> RpcTestScenario { let mut cids = vec![]; for hash in &hashes { if let Some(cid) = client - .call(EthGetMessageCidByTransactionHash::request((hash.clone(),))?) + .call(EthGetMessageCidByTransactionHash::request((*hash,))?) .await? { cids.push(cid); diff --git a/src/tool/subcommands/api_cmd/test_snapshots_ignored.txt b/src/tool/subcommands/api_cmd/test_snapshots_ignored.txt index 763b25dce701..cf8319fcf33c 100644 --- a/src/tool/subcommands/api_cmd/test_snapshots_ignored.txt +++ b/src/tool/subcommands/api_cmd/test_snapshots_ignored.txt @@ -83,6 +83,7 @@ Forest.ChainExportDiff Forest.ChainExportStatus Forest.ChainGetMinBaseFee Forest.ChainGetTipsetByParentState +Forest.EthTraceCall Forest.NetInfo Forest.SnapshotGC Forest.StateActorInfo