From 6deb8ea49407d25c3d636ed3ed6587a2a5395b9b Mon Sep 17 00:00:00 2001 From: yihuang Date: Fri, 22 Aug 2025 11:00:29 +0800 Subject: [PATCH 1/8] feat: support storage slot detection inspired by https://github.com/halo3mic/token-bss --- eth_contract/slots.py | 284 ++++++++++++++++++++++++++++++++++++++++++ eth_contract/utils.py | 1 - pyproject.toml | 3 + tests/conftest.py | 3 +- tests/test_erc20.py | 69 +++++----- tests/test_slots.py | 82 ++++++++++++ tests/trace.py | 33 +++++ uv.lock | 31 +---- 8 files changed, 437 insertions(+), 69 deletions(-) create mode 100644 eth_contract/slots.py create mode 100644 tests/test_slots.py create mode 100644 tests/trace.py diff --git a/eth_contract/slots.py b/eth_contract/slots.py new file mode 100644 index 0000000..d273ad2 --- /dev/null +++ b/eth_contract/slots.py @@ -0,0 +1,284 @@ +""" +Utilities to parse erc20 slots from trace result. + +To override erc20 balance or allowance value for arbitrary account in eth_call, we need +to know the storage slot of the mappings, erc20 don't standadize these things, normally +we have to find it in the solc compiler output, but we can't do that for arbitrary +tokens without their source code. + +The solution here is to parse the trace result of `balanceOf` or `allowance` calls to +find out the storage slots. + +This implementation here is inspired by the `token-bss` project[1]. + +References: + +[1]. https://github.com/halo3mic/token-bss +[2]. https://hackmd.io/@oS7_rZFHQnCFw_lsRei3nw/HJN1rQWmA +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + +from eth_utils import keccak +from hexbytes import HexBytes + + +def get_op_name(log: dict) -> str: + return log.get("opName") or log["op"] + + +def get_memory(log: dict) -> bytes: + mem = log["memory"] + if isinstance(mem, str): + return HexBytes(mem) + return b"".join([bytes.fromhex(i) for i in mem]) + + +@dataclass +class MappingSlot: + slot: bytes + is_solidity: bool = True # otherwise vyper + + def __init__(self, slot: bytes | int, is_solidity: bool = True) -> None: + if isinstance(slot, int): + slot = slot.to_bytes(32, "big") + if len(slot) != 32: + raise ValueError("slot must be 32 bytes") + self.slot = slot + self.is_solidity = is_solidity + + def value(self, key: bytes) -> MappingSlot: + "compute the value storage slot for the given key" + v0, v1 = key.rjust(32, b"\x00"), self.slot + if self.is_solidity: + slot = keccak(v0 + v1) + else: + slot = keccak(v1 + v0) + return MappingSlot(slot, self.is_solidity) + + +def parse_mapping_reads( + top_contract: bytes, traces: Iterable[dict] +) -> Iterable[tuple[bytes, bytes, bytes, bytes]]: + """ + parse the mapping reads from the traces. + + for example: `balances[user]` is compiled to: + + ``` + slot = KECCAK256(v0 | v1) + SLOAD(slot) + ``` + + we parse the opcodes and return `[(contract, v0, v1, slot)]` + + in solidity, v0 is the map key, v1 is the index of the mapping field, + in vyper, v1 is the map key, v0 is the index of the mapping field. + """ + # stack to track current calling contract, `depth -> contract address` + contracts: dict[int, bytes] = {1: top_contract} + # record pre-image of hash operation + hashed: dict[bytes, tuple[bytes, bytes]] = {} + # temporarily record the pre-image, will be paired with the hash result in next step + tmp_pre_image: tuple[bytes, bytes] | None = None + for step in traces: + if "stack" not in step: + continue + + stack = step["stack"] + + if tmp_pre_image is not None: + # the hash result is at the top of the stack of next op + hashed[HexBytes(stack[-1])] = tmp_pre_image + tmp_pre_image = None + + op = get_op_name(step) + if op == "KECCAK256": + # compute the storage slot for the mapping key + size, offset = int(stack[-2], 16), int(stack[-1], 16) + if size != 64: + continue + mem = get_memory(step)[offset : offset + 64] + tmp_pre_image = mem[:32], mem[32:] + elif op == "SLOAD": + slot = HexBytes(stack[-1]) + try: + v0, v1 = hashed[slot] + except KeyError: + continue + + # we are reading from a slot which is result of hashing two values + # likely a read from a map + contract = contracts[step["depth"]] + yield (contract, v0, v1, slot) + elif op in ("CALL", "STATICCALL"): + contracts[step["depth"] + 1] = HexBytes(stack[-2])[-20:] + elif op == "DELEGATECALL": + depth = step["depth"] + contracts[depth + 1] = contracts[depth] + + +def parse_nested_mapping_reads( + top_contract: bytes, traces: Iterable[dict] +) -> Iterable[tuple[bytes, bytes, bytes, bytes, bytes]]: + """ + parse the nested mapping reads from the traces. + + for example: `allowances[user][spender]` is compiled to: + + ``` + tmp = KECCAK256(v0 | v1) + slot = KECCAK256(v2 | tmp) # or KECCAK256(tmp | v2) + SLOAD(slot) + ``` + + we'll parse the opcodes and return `[(contract, v0, v1, v2, slot)]` + + in solidity, v0 is the user, v1 is the index of the mapping field, + in vyper, v1 is the user, v0 is the index of the mapping field. + v2 should always be the spender. + """ + + # stack to track current calling contract + contracts: dict[int, bytes] = {1: top_contract} + # record pre-image of hash operation + hashed: dict[bytes, tuple[bytes, bytes]] = {} + # temporarily record the pre-image, will be paired with the hash result in next step + tmp_pre_image: tuple[bytes, bytes] | None = None + for step in traces: + if "stack" not in step: + continue + + stack = step["stack"] + + if tmp_pre_image is not None: + # the hash result is at the top of the stack of next op + hashed[HexBytes(stack[-1])] = tmp_pre_image + tmp_pre_image = None + + op = get_op_name(step) + if op == "KECCAK256": + # compute the storage slot for the mapping key + size, offset = int(stack[-2], 16), int(stack[-1], 16) + if size != 64: + continue + mem = get_memory(step)[offset : offset + 64] + tmp_pre_image = mem[:32], mem[32:] + elif op == "SLOAD": + slot = HexBytes(stack[-1]) + try: + n0, n1 = hashed[slot] + except KeyError: + continue + + # check nested mapping read + try: + v0, v1 = hashed[n0] + v2 = n1 + except KeyError: + try: + v0, v1 = hashed[n1] + v2 = n0 + except KeyError: + continue + + # we are reading from a slot which is result of hashing two values + # likely a read from a map + contract = contracts[step["depth"]] + yield (contract, v0, v1, v2, slot) + elif op in ("CALL", "STATICCALL"): + contracts[step["depth"] + 1] = HexBytes(stack[-2])[-20:] + elif op == "DELEGATECALL": + depth = step["depth"] + contracts[depth + 1] = contracts[depth] + + +def parse_balance_slot( + token: bytes, user: bytes, traces: Iterable[dict] +) -> MappingSlot | None: + """ + detect the balance slot of token contract with a balanceOf trace result + """ + user = user.rjust(32, b"\x00") + for contract, v0, v1, slot in parse_mapping_reads(token, traces): + if contract != token: + continue + + if v0 == user: + return MappingSlot(v1, True) + elif v1 == user: + return MappingSlot(v0, False) + return None + + +def parse_allowance_slot( + token: bytes, user: bytes, spender: bytes, traces: Iterable[dict] +) -> MappingSlot | None: + """ + detect the balance slot of token contract with a balanceOf trace result + """ + user = user.rjust(32, b"\x00") + spender = spender.rjust(32, b"\x00") + for contract, v0, v1, v2, slot in parse_nested_mapping_reads(token, traces): + if contract != token: + continue + + if v2 != spender: + continue + + if v0 == user: + return MappingSlot(v1, True) + elif v1 == user: + return MappingSlot(v0, False) + return None + + +def parse_batch_allowance_slot( + tokens: set[bytes], user: bytes, spender: bytes, traces: Iterable[dict] +) -> dict[bytes, MappingSlot]: + """ + the trace is generated with a multicall of `allowance(user, spender)` + """ + top_contract = b"\x00" * 20 # placeholder for top contract + user = user.rjust(32, b"\x00") + spender = spender.rjust(32, b"\x00") + + result = {} + for contract, v0, v1, v2, slot in parse_nested_mapping_reads(top_contract, traces): + if contract not in tokens: + continue + + if v2 != spender: + continue + + if v0 == user: + result[contract] = MappingSlot(v1, True) + elif v1 == user: + result[contract] = MappingSlot(v0, False) + + return result + + +def parse_batch_balance_slot( + tokens: set[bytes], user: bytes, traces: Iterable[dict] +) -> dict[bytes, MappingSlot]: + """ + the trace is generated with a multicall of `balanceOf(user)` + """ + top_contract = b"\x00" * 20 # placeholder for top contract + user = user.rjust(32, b"\x00") + + result = {} + for contract, v0, v1, slot in parse_mapping_reads(top_contract, traces): + if contract not in tokens: + continue + + if v0 == user: + result[contract] = MappingSlot(v1, True) + elif v1 == user: + result[contract] = MappingSlot(v0, False) + + return result diff --git a/eth_contract/utils.py b/eth_contract/utils.py index 019dfa7..6d683ad 100644 --- a/eth_contract/utils.py +++ b/eth_contract/utils.py @@ -21,7 +21,6 @@ from web3.types import Nonce, TxParams, TxReceipt, Wei ZERO_ADDRESS = to_checksum_address("0x0000000000000000000000000000000000000000") -ETH_MAINNET_FORK = "https://eth-mainnet.public.blastapi.io" async def fill_transaction_defaults(w3: AsyncWeb3, **tx: Unpack[TxParams]) -> TxParams: diff --git a/pyproject.toml b/pyproject.toml index 3d6f18e..3265eec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,3 +28,6 @@ line-length = 88 [tool.isort] profile = "black" + +[tool.uv.sources] +pyrevm = { git = "https://github.com/yihuang/pyrevm.git" } diff --git a/tests/conftest.py b/tests/conftest.py index 6ab0843..0195299 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,7 +25,7 @@ ENTRYPOINT08_SALT, ) from eth_contract.multicall3 import MULTICALL3_ADDRESS -from eth_contract.utils import ETH_MAINNET_FORK, get_initcode +from eth_contract.utils import get_initcode from .contracts import ( MULTICALL3ROUTER_ARTIFACT, @@ -34,6 +34,7 @@ WETH_SALT, ) +ETH_MAINNET_FORK = "https://eth-mainnet.public.blastapi.io" Account.enable_unaudited_hdwallet_features() TEST_MNEMONIC = ( "body bag bird mix language evidence what liar reunion wire lesson evolve" diff --git a/tests/test_erc20.py b/tests/test_erc20.py index 616ef22..bcd399e 100644 --- a/tests/test_erc20.py +++ b/tests/test_erc20.py @@ -1,13 +1,14 @@ import asyncio -import io -import json -from contextlib import redirect_stdout import pyrevm import pytest from eth_utils import keccak, to_checksum_address, to_hex from eth_contract.contract import Contract +from eth_contract.deploy_utils import ( + ensure_deployed_by_create2, + ensure_deployed_by_create3, +) from eth_contract.erc20 import ERC20 from eth_contract.multicall3 import ( MULTICALL3, @@ -16,20 +17,16 @@ multicall, ) from eth_contract.utils import ( - ETH_MAINNET_FORK, ZERO_ADDRESS, balance_of, get_initcode, send_transaction, ) -from eth_contract.deploy_utils import ( - ensure_deployed_by_create2, - ensure_deployed_by_create3, -) from eth_contract.weth import WETH -from .conftest import MULTICALL3ROUTER +from .conftest import ETH_MAINNET_FORK, MULTICALL3ROUTER from .contracts import MULTICALL3ROUTER_ARTIFACT, WETH_ADDRESS, MockERC20_ARTIFACT +from .trace import trace_call @pytest.mark.asyncio @@ -269,43 +266,37 @@ async def test_7702(w3, test_accounts): def test_pyrevm_trace(): - vm = pyrevm.EVM(fork_url=ETH_MAINNET_FORK, tracing=True) + vm = pyrevm.EVM(fork_url=ETH_MAINNET_FORK, tracing=True, with_memory=True) addr = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" # USDC whale = "0x37305B1cD40574E4C5Ce33f8e8306Be057fD7341" - with redirect_stdout(io.StringIO()) as out: - vm.message_call(caller=whale, to=addr, calldata=ERC20.fns.balanceOf(whale).data) - out.seek(0) - for line in out.readlines(): - item = json.loads(line) - if item.get("opName") == "SLOAD": - print(item) + + for trace in trace_call(vm, data=ERC20.fns.balanceOf(whale).data, to=addr): + if trace.get("opName") == "SLOAD": + print(trace) def test_pyrevm_trace_log(): - vm = pyrevm.EVM(fork_url=ETH_MAINNET_FORK, tracing=True) WETH_ADDRESS = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" whale = "0x44663D61BD6Ad13848D1E90b1F5940eB6836D2F5" deposit_amount = 2589000000000000 - vm = pyrevm.EVM(fork_url=ETH_MAINNET_FORK, tracing=True) deposit_fn = "Deposit(address,uint256)" deposit_hash = f"0x{keccak(deposit_fn.encode()).hex()}" - with redirect_stdout(io.StringIO()) as out: - vm.message_call( - caller=whale, - to=WETH_ADDRESS, - value=deposit_amount, - calldata=WETH.fns.deposit().data, - ) - out.seek(0) - trace_lines = out.readlines() - for line in trace_lines: - item = json.loads(line) - if "opName" in item: - op = item["opName"] - if op.startswith("LOG"): - # LOG2 -> 2, LOG0 -> 0 etc - num_topics = int(op[3]) - stack = item["stack"] - topics = stack[-(2 + num_topics) : -2][::-1] - assert topics[0] == deposit_hash, "deposit event hash mismatch" - assert to_checksum_address(topics[1]) == whale, "whale address mismatch" + + vm = pyrevm.EVM(fork_url=ETH_MAINNET_FORK, tracing=True, with_memory=True) + for trace in trace_call( + vm, + **{ + "from": whale, + "to": WETH_ADDRESS, + "data": WETH.fns.deposit().data, + "value": deposit_amount, + }, + ): + op = trace.get("opName", "") + if op.startswith("LOG"): + # LOG2 -> 2, LOG0 -> 0 etc + num_topics = int(op[3]) + stack = trace["stack"] + topics = stack[-(2 + num_topics) : -2][::-1] + assert topics[0] == deposit_hash, "deposit event hash mismatch" + assert to_checksum_address(topics[1]) == whale, "whale address mismatch" diff --git a/tests/test_slots.py b/tests/test_slots.py new file mode 100644 index 0000000..d39f6e9 --- /dev/null +++ b/tests/test_slots.py @@ -0,0 +1,82 @@ +""" +Test for slots module using pyrevm with memory tracing. +""" + +import os + +import pyrevm +import pytest +from eth_utils import to_hex +from hexbytes import HexBytes +from web3 import AsyncHTTPProvider, AsyncWeb3 + +from eth_contract.erc20 import ERC20 +from eth_contract.slots import parse_allowance_slot, parse_balance_slot + +from .conftest import ETH_MAINNET_FORK +from .trace import trace_call + + +@pytest.mark.asyncio +async def test_pyrevm_balance_slot_tracing(): + """Test balance slot detection with pyrevm tracing""" + # USDC contract + token = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + user = b"\x01".rjust(20) + fn = ERC20.fns.balanceOf(user) + + # Capture and parse traces + vm = pyrevm.EVM(fork_url=ETH_MAINNET_FORK, tracing=True, with_memory=True) + traces = trace_call(vm, {"to": token, "data": fn.data}) + + # Parse balance slot from traces + slot = parse_balance_slot(HexBytes(token), user, traces) + assert slot is not None + + # verify the slot with state overrides + bz = os.urandom(32) + w3 = AsyncWeb3(AsyncHTTPProvider(ETH_MAINNET_FORK)) + assert int.from_bytes(bz, "big") == await fn.call( + w3, + to=token, + state_override={ + token: { + "stateDiff": {to_hex(slot.value(user).slot): to_hex(bz)}, + } + }, + ) + + +@pytest.mark.asyncio +async def test_pyrevm_allowance_slot_tracing(): + """Test allowance slot detection with pyrevm tracing and memory support.""" + # USDC contract + token = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + owner = b"\x01".rjust(20) + spender = b"\x02".rjust(20) + fn = ERC20.fns.allowance(owner, spender) + + # Capture and parse traces + traces = trace_call( + pyrevm.EVM(fork_url=ETH_MAINNET_FORK, tracing=True, with_memory=True), + {"to": token, "data": fn.data}, + ) + + # Parse allowance slot from traces + slot = parse_allowance_slot(HexBytes(token), owner, HexBytes(spender), traces) + assert slot is not None + + # verify the slot with state overrides + bz = os.urandom(32) + w3 = AsyncWeb3(AsyncHTTPProvider(ETH_MAINNET_FORK)) + assert int.from_bytes(bz, "big") == await fn.call( + w3, + to=token, + state_override={ + token: { + "stateDiff": { + to_hex(slot.value(owner).value(spender).slot): to_hex(bz), + }, + } + }, + ) diff --git a/tests/trace.py b/tests/trace.py new file mode 100644 index 0000000..4559c9e --- /dev/null +++ b/tests/trace.py @@ -0,0 +1,33 @@ +import io +import json +from contextlib import redirect_stdout +from typing import Unpack + +import pyrevm +from web3.types import TxParams + +from eth_contract.utils import ZERO_ADDRESS + + +def trace_call(vm: pyrevm.EVM, **tx: Unpack[TxParams]) -> list[dict]: + """ + Capture and parse traces from a pyrevm message call. + """ + with redirect_stdout(io.StringIO()) as out: + vm.message_call( + caller=tx.get("from", ZERO_ADDRESS), # type: ignore + to=tx.get("to", ""), # type: ignore + calldata=tx.get("data"), # type: ignore + value=tx.get("value", 0), + ) + + out.seek(0) + traces = [] + for line in out.readlines(): + try: + trace_item = json.loads(line) + traces.append(trace_item) + except json.JSONDecodeError: + continue + + return traces diff --git a/uv.lock b/uv.lock index 19cff19..7a2d24f 100644 --- a/uv.lock +++ b/uv.lock @@ -610,7 +610,7 @@ dev = [ { name = "flake8", specifier = ">=7.2.0" }, { name = "isort", specifier = ">=6.0.1" }, { name = "mypy", specifier = ">=1.16.0" }, - { name = "pyrevm", specifier = ">=0.3.3" }, + { name = "pyrevm", git = "https://github.com/yihuang/pyrevm.git" }, { name = "pytest", specifier = ">=8.4.0" }, { name = "pytest-asyncio", specifier = ">=0.23.0" }, ] @@ -1395,33 +1395,8 @@ wheels = [ [[package]] name = "pyrevm" -version = "0.3.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/97/36/4065d382d27152d4250cd4ec3871ca8271f1c181c686e01c80e0a2495046/pyrevm-0.3.3.tar.gz", hash = "sha256:cd2686a08c51872c0f1f615b9cbedb92d5fe28a7709226b40ced54dfff81cb3f", size = 55636, upload-time = "2024-05-03T15:08:02.973Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/34/0f1d877ddea671d9abe193a6f24eabe680c7da08c38142a9d0aef9f7b714/pyrevm-0.3.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b9520ca226830f4d3100c58251e0af2ab12853a88e8d64dfd2d9fcbbee5963d8", size = 4122037, upload-time = "2024-05-03T15:07:03.516Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f3/881cd32a9008fb5b2f1530d732be69479133ce00e0775dc1310860dbbb58/pyrevm-0.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:72396324a7fff824339d175fa16113fe670626eece50c7a665d8152d0c81e408", size = 4017884, upload-time = "2024-05-03T15:07:05.346Z" }, - { url = "https://files.pythonhosted.org/packages/78/10/4d8318d598ca43cf95ce1903fabbe84430cc63f957b5b422691231f5d316/pyrevm-0.3.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e55ecec995c5831bddbd46add07f1478eb2ea6211fed5bdbed1e7ad80ed4a8c7", size = 6122265, upload-time = "2024-05-03T15:07:07.067Z" }, - { url = "https://files.pythonhosted.org/packages/05/e3/745d52384969a0c59adb85476db044456d5b0142e93e5b6ca67c41d12314/pyrevm-0.3.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df35ac89ec7cbf1b7203a30776d51a76a2a4f5390b24f8558da400b75037daad", size = 5877051, upload-time = "2024-05-03T15:07:08.735Z" }, - { url = "https://files.pythonhosted.org/packages/ec/20/bd004d93837b165d6e2854b12a1d19edb25dd659c359ee17784039f704c3/pyrevm-0.3.3-cp310-none-win_amd64.whl", hash = "sha256:07d40e08928a287f19abcd19f86e48ef7994e844449fd4a27dbe444690ddb4b7", size = 3921647, upload-time = "2024-05-03T15:07:10.766Z" }, - { url = "https://files.pythonhosted.org/packages/d6/6e/c9730301f27df3755a9bc7054feab6f26f71d24e3703f3cd9e08712d1b66/pyrevm-0.3.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c6f48cd9c9728d236eaf963746b38218cb346e5049c04a9d1fda59f17018e28f", size = 4122103, upload-time = "2024-05-03T15:07:12.297Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3a/1eca64ffaed19040e797da12acf3eac51ca87f3f46b6a0c2c029b4150126/pyrevm-0.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c97ddbfc3c5a408ff24df7014f4cd87dcc7eb2e1d697cefe53aa6be4ba86067", size = 4017930, upload-time = "2024-05-03T15:07:14.321Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f3/cd339a81c404ccfb10c79ddd2a5b76f780ddaa5d7b1e44dda9b8643fc570/pyrevm-0.3.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:950a07c64b3428f5b761c273589d12ea1a22419d41729e14a1059afff317105a", size = 6122165, upload-time = "2024-05-03T15:07:15.799Z" }, - { url = "https://files.pythonhosted.org/packages/2f/24/18629eaa751e6d113e26de5be3c8d87d1f0362df189870446a8328b82771/pyrevm-0.3.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0d18c286f579b1b60ee9111d887a832de213313424751c94ea61d70c18bc03a", size = 5877021, upload-time = "2024-05-03T15:07:17.735Z" }, - { url = "https://files.pythonhosted.org/packages/de/14/6407e1a3c525f69a0edfdc4d33eca25185db0586a0152434ef36e194581e/pyrevm-0.3.3-cp311-none-win_amd64.whl", hash = "sha256:a0565c1cbd3b9ed55664217e9d1af981e1ec6a96fb5fe3f1c80c7da35038d2a3", size = 3921648, upload-time = "2024-05-03T15:07:19.559Z" }, - { url = "https://files.pythonhosted.org/packages/3b/93/f57edb9a4df280e0e269a8599b479c45c9244a9ed876baa1a172f2b5ee54/pyrevm-0.3.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ec688b37dfc39a85a0a6f7709ee434b72e4e9c9f0bc7a525cd583dd5b7b97529", size = 4119675, upload-time = "2024-05-03T15:07:21.09Z" }, - { url = "https://files.pythonhosted.org/packages/40/cc/b9c7eaadeaf3cd53430e016285419036a3149e59ccc9d4ed2b2e64ee2b5a/pyrevm-0.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ac341771699dc5652ae7d5ffb151fcdc80181c03197eed7b0282422a922f73e2", size = 4019208, upload-time = "2024-05-03T15:07:23.764Z" }, - { url = "https://files.pythonhosted.org/packages/f4/74/a5894433e7b59bce65b3a0f344a29c017ddf8310cefa54122f6015eb68c3/pyrevm-0.3.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a738adcc0d567b5cc4e6e4a8378b4d894125240c84516cf7bfd779cad597ed5c", size = 6122290, upload-time = "2024-05-03T15:07:25.48Z" }, - { url = "https://files.pythonhosted.org/packages/eb/41/d887ddc8721ca58aa873fdad654a7a7813fdd665cdf41023eb83ca7f53b5/pyrevm-0.3.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40f982b736d44c2ba0ecbf60d31895062db7a51fd4f8f7a16fc24062c4980b3a", size = 5876308, upload-time = "2024-05-03T15:07:27.182Z" }, - { url = "https://files.pythonhosted.org/packages/95/5f/b8e948af81fe4024377959cf10726c848044b5a9c2b99b549d3ce6b2b166/pyrevm-0.3.3-cp312-none-win_amd64.whl", hash = "sha256:405d3c63872b59cb82b699000ad2fdff185bc25b91c4f8a6c06bc6a35afcd0c9", size = 3928748, upload-time = "2024-05-03T15:07:28.699Z" }, - { url = "https://files.pythonhosted.org/packages/79/03/9612317122ce69fd267e8288ddc852b98cf5845619097e6656d92daa5e2e/pyrevm-0.3.3-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f5cf30a97002bbf3d9abe41bf247f6dd0414ae64a735771995cfcedbf027fdc", size = 6122604, upload-time = "2024-05-03T15:07:41.532Z" }, - { url = "https://files.pythonhosted.org/packages/c4/82/a013d8ed68e7c5509a18a9ce178146b794f2495b1c9c96eec940cd5467ce/pyrevm-0.3.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08013d4c29575195e0cebbfa33d88b783df034376fea9ceb71d2cfffe964a5b6", size = 5876089, upload-time = "2024-05-03T15:07:43.386Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/0a969ef5739aea0057f00c7683689469564f91f0803b7193dd1481f5b67a/pyrevm-0.3.3-cp39-none-win_amd64.whl", hash = "sha256:a86918316cad702fdbacbde02cc1e929dbd577aff02d1f125af7056b00c965df", size = 3921959, upload-time = "2024-05-03T15:07:45.512Z" }, - { url = "https://files.pythonhosted.org/packages/65/83/9ac5ab9efc3243d256bf72e191539fd12ea228ea5ef73864a9bdbbded024/pyrevm-0.3.3-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43bfe5954f8af2e3e53615c06930aba76e128581bcb53ce99421e8255d3d7886", size = 6122651, upload-time = "2024-05-03T15:07:47.201Z" }, - { url = "https://files.pythonhosted.org/packages/5c/5b/a32d57f1629528c786b6586008ae1799bbbdb9d313de2fe5e0a74e8a2406/pyrevm-0.3.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9661740a03b0cb6ad57f5dce3272e940181c431686650cedc7c957f8e908923f", size = 5876132, upload-time = "2024-05-03T15:07:49.24Z" }, - { url = "https://files.pythonhosted.org/packages/ae/24/ef77afeab806368665c4b06ac4946b0892f73ef26e53219dd01fed261b5a/pyrevm-0.3.3-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9dcb27a4aa69cca3cfe7334785f6477b4dde0286827db880ab48a147838ad12c", size = 6121625, upload-time = "2024-05-03T15:07:59.79Z" }, - { url = "https://files.pythonhosted.org/packages/a6/0e/adab22bf54d429f03461b4a71bbf07d42ee1dc28b863db42d006bcaee3f4/pyrevm-0.3.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:108ad8dc1f41302a64bc870faeadd3405056c93b09953584cdde6eba51b918a3", size = 5875264, upload-time = "2024-05-03T15:08:01.37Z" }, -] +version = "0.3.6" +source = { git = "https://github.com/yihuang/pyrevm.git#ef0903ad7ca3d01e675105b63302d07cd03ea4b1" } [[package]] name = "pytest" From cf7b00ee3b1f9c1a37f2d9d72f9899f7eb0cf5df Mon Sep 17 00:00:00 2001 From: yihuang Date: Fri, 22 Aug 2025 14:23:43 +0800 Subject: [PATCH 2/8] Apply suggestions from code review --- eth_contract/slots.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/eth_contract/slots.py b/eth_contract/slots.py index d273ad2..7a7b930 100644 --- a/eth_contract/slots.py +++ b/eth_contract/slots.py @@ -2,7 +2,7 @@ Utilities to parse erc20 slots from trace result. To override erc20 balance or allowance value for arbitrary account in eth_call, we need -to know the storage slot of the mappings, erc20 don't standadize these things, normally +to know the storage slot of the mappings, erc20 don't standardize these things, normally we have to find it in the solc compiler output, but we can't do that for arbitrary tokens without their source code. @@ -200,7 +200,7 @@ def parse_balance_slot( token: bytes, user: bytes, traces: Iterable[dict] ) -> MappingSlot | None: """ - detect the balance slot of token contract with a balanceOf trace result + detect the balance slot of token contract with a `balanceOf(user)` trace result """ user = user.rjust(32, b"\x00") for contract, v0, v1, slot in parse_mapping_reads(token, traces): @@ -218,7 +218,7 @@ def parse_allowance_slot( token: bytes, user: bytes, spender: bytes, traces: Iterable[dict] ) -> MappingSlot | None: """ - detect the balance slot of token contract with a balanceOf trace result + detect the balance slot of token contract with a `allowance[user][spender]` trace result """ user = user.rjust(32, b"\x00") spender = spender.rjust(32, b"\x00") From 3a6bcb1f4bda74f2192f58440074ca2ba0647d69 Mon Sep 17 00:00:00 2001 From: yihuang Date: Fri, 22 Aug 2025 14:25:52 +0800 Subject: [PATCH 3/8] fix --- tests/test_slots.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_slots.py b/tests/test_slots.py index d39f6e9..f321b9e 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -27,7 +27,7 @@ async def test_pyrevm_balance_slot_tracing(): # Capture and parse traces vm = pyrevm.EVM(fork_url=ETH_MAINNET_FORK, tracing=True, with_memory=True) - traces = trace_call(vm, {"to": token, "data": fn.data}) + traces = trace_call(vm, to=token, data=fn.data) # Parse balance slot from traces slot = parse_balance_slot(HexBytes(token), user, traces) @@ -59,7 +59,8 @@ async def test_pyrevm_allowance_slot_tracing(): # Capture and parse traces traces = trace_call( pyrevm.EVM(fork_url=ETH_MAINNET_FORK, tracing=True, with_memory=True), - {"to": token, "data": fn.data}, + to=token, + data=fn.data, ) # Parse allowance slot from traces From b66b1989f440cd2f1d1e1bada31b218ad417971b Mon Sep 17 00:00:00 2001 From: yihuang Date: Fri, 22 Aug 2025 14:35:58 +0800 Subject: [PATCH 4/8] fix lint --- eth_contract/slots.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/eth_contract/slots.py b/eth_contract/slots.py index 7a7b930..ba53b0e 100644 --- a/eth_contract/slots.py +++ b/eth_contract/slots.py @@ -218,7 +218,8 @@ def parse_allowance_slot( token: bytes, user: bytes, spender: bytes, traces: Iterable[dict] ) -> MappingSlot | None: """ - detect the balance slot of token contract with a `allowance[user][spender]` trace result + detect the balance slot of token contract with a `allowance[user][spender]` + trace result """ user = user.rjust(32, b"\x00") spender = spender.rjust(32, b"\x00") From b60a706429aac6742fda22078cb2ad8d445a2399 Mon Sep 17 00:00:00 2001 From: mmsqe Date: Fri, 22 Aug 2025 14:38:22 +0800 Subject: [PATCH 5/8] add right padded --- tests/test_slots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_slots.py b/tests/test_slots.py index f321b9e..0da550b 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -41,7 +41,7 @@ async def test_pyrevm_balance_slot_tracing(): to=token, state_override={ token: { - "stateDiff": {to_hex(slot.value(user).slot): to_hex(bz)}, + "stateDiff": {to_hex(slot.value(user.rjust(32, b"\x00")).slot): to_hex(bz)}, } }, ) From a96c8294650fc509faee085213e485c9bfb0001e Mon Sep 17 00:00:00 2001 From: mmsqe Date: Fri, 22 Aug 2025 15:00:55 +0800 Subject: [PATCH 6/8] lint --- tests/test_slots.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_slots.py b/tests/test_slots.py index 0da550b..022db62 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -41,7 +41,9 @@ async def test_pyrevm_balance_slot_tracing(): to=token, state_override={ token: { - "stateDiff": {to_hex(slot.value(user.rjust(32, b"\x00")).slot): to_hex(bz)}, + "stateDiff": { + to_hex(slot.value(user.rjust(32, b"\x00")).slot): to_hex(bz) + }, } }, ) From 70a4bbd5abba0f5a1826c6bc9d4008c41e572800 Mon Sep 17 00:00:00 2001 From: yihuang Date: Fri, 22 Aug 2025 16:41:11 +0800 Subject: [PATCH 7/8] cleanup --- tests/test_slots.py | 30 +++++++++--------------------- tests/trace.py | 13 +++---------- 2 files changed, 12 insertions(+), 31 deletions(-) diff --git a/tests/test_slots.py b/tests/test_slots.py index 022db62..eca617a 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -22,7 +22,7 @@ async def test_pyrevm_balance_slot_tracing(): """Test balance slot detection with pyrevm tracing""" # USDC contract token = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" - user = b"\x01".rjust(20) + user = b"\x01".rjust(20, b"\x00") fn = ERC20.fns.balanceOf(user) # Capture and parse traces @@ -36,16 +36,9 @@ async def test_pyrevm_balance_slot_tracing(): # verify the slot with state overrides bz = os.urandom(32) w3 = AsyncWeb3(AsyncHTTPProvider(ETH_MAINNET_FORK)) + state = {to_hex(slot.value(user).slot): to_hex(bz)} assert int.from_bytes(bz, "big") == await fn.call( - w3, - to=token, - state_override={ - token: { - "stateDiff": { - to_hex(slot.value(user.rjust(32, b"\x00")).slot): to_hex(bz) - }, - } - }, + w3, to=token, state_override={token: {"stateDiff": state}} ) @@ -54,8 +47,8 @@ async def test_pyrevm_allowance_slot_tracing(): """Test allowance slot detection with pyrevm tracing and memory support.""" # USDC contract token = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" - owner = b"\x01".rjust(20) - spender = b"\x02".rjust(20) + owner = b"\x01".rjust(20, b"\x00") + spender = b"\x02".rjust(20, b"\x00") fn = ERC20.fns.allowance(owner, spender) # Capture and parse traces @@ -72,14 +65,9 @@ async def test_pyrevm_allowance_slot_tracing(): # verify the slot with state overrides bz = os.urandom(32) w3 = AsyncWeb3(AsyncHTTPProvider(ETH_MAINNET_FORK)) + state = { + to_hex(slot.value(owner).value(spender).slot): to_hex(bz), + } assert int.from_bytes(bz, "big") == await fn.call( - w3, - to=token, - state_override={ - token: { - "stateDiff": { - to_hex(slot.value(owner).value(spender).slot): to_hex(bz), - }, - } - }, + w3, to=token, state_override={token: {"stateDiff": state}} ) diff --git a/tests/trace.py b/tests/trace.py index 4559c9e..dcf205c 100644 --- a/tests/trace.py +++ b/tests/trace.py @@ -1,7 +1,7 @@ import io import json from contextlib import redirect_stdout -from typing import Unpack +from typing import Iterable, Unpack import pyrevm from web3.types import TxParams @@ -9,7 +9,7 @@ from eth_contract.utils import ZERO_ADDRESS -def trace_call(vm: pyrevm.EVM, **tx: Unpack[TxParams]) -> list[dict]: +def trace_call(vm: pyrevm.EVM, **tx: Unpack[TxParams]) -> Iterable[dict]: """ Capture and parse traces from a pyrevm message call. """ @@ -22,12 +22,5 @@ def trace_call(vm: pyrevm.EVM, **tx: Unpack[TxParams]) -> list[dict]: ) out.seek(0) - traces = [] for line in out.readlines(): - try: - trace_item = json.loads(line) - traces.append(trace_item) - except json.JSONDecodeError: - continue - - return traces + yield json.loads(line) From d1063c8f97dcb1ebb3533d221f202485f430c54c Mon Sep 17 00:00:00 2001 From: yihuang Date: Fri, 22 Aug 2025 17:54:46 +0800 Subject: [PATCH 8/8] fix flaky test --- tests/test_slots.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_slots.py b/tests/test_slots.py index eca617a..e6d2955 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -34,7 +34,7 @@ async def test_pyrevm_balance_slot_tracing(): assert slot is not None # verify the slot with state overrides - bz = os.urandom(32) + bz = os.urandom(16).rjust(32, b"\x00") w3 = AsyncWeb3(AsyncHTTPProvider(ETH_MAINNET_FORK)) state = {to_hex(slot.value(user).slot): to_hex(bz)} assert int.from_bytes(bz, "big") == await fn.call( @@ -63,7 +63,7 @@ async def test_pyrevm_allowance_slot_tracing(): assert slot is not None # verify the slot with state overrides - bz = os.urandom(32) + bz = os.urandom(16).rjust(32, b"\x00") w3 = AsyncWeb3(AsyncHTTPProvider(ETH_MAINNET_FORK)) state = { to_hex(slot.value(owner).value(spender).slot): to_hex(bz),