From 1115e1959bbdd2bceb3cbe50cccc4f70526742b0 Mon Sep 17 00:00:00 2001 From: Shashank Date: Tue, 25 Aug 2026 16:01:01 +0530 Subject: [PATCH 1/4] Added devnet skip sender test --- .github/workflows/forest.yml | 1 + mise.toml | 1 + scripts/devnet/README.md | 14 +- scripts/devnet/test_harness.sh | 2 +- src/dev/subcommands/devnet_cmd.rs | 3 + .../devnet_cmd/contracts/compile.sh | 2 +- .../contracts/contract_a/contract_a.hex | 1 + .../contracts/contract_a/contract_a.sol | 42 + .../contracts/contract_b/contract_b.hex | 1 + .../contracts/contract_b/contract_b.sol | 19 + .../devnet_cmd/contracts/errors/errors.hex | 1 + .../devnet_cmd/contracts/errors/errors.sol | 31 + .../contracts/simple_coin/simple_coin.hex | 1 + .../contracts/simple_coin/simple_coin.sol | 31 + .../subcommands/devnet_cmd/eth_skip_sender.rs | 1187 +++++++++++++++++ 15 files changed, 1329 insertions(+), 8 deletions(-) create mode 100644 src/dev/subcommands/devnet_cmd/contracts/contract_a/contract_a.hex create mode 100644 src/dev/subcommands/devnet_cmd/contracts/contract_a/contract_a.sol create mode 100644 src/dev/subcommands/devnet_cmd/contracts/contract_b/contract_b.hex create mode 100644 src/dev/subcommands/devnet_cmd/contracts/contract_b/contract_b.sol create mode 100644 src/dev/subcommands/devnet_cmd/contracts/errors/errors.hex create mode 100644 src/dev/subcommands/devnet_cmd/contracts/errors/errors.sol create mode 100644 src/dev/subcommands/devnet_cmd/contracts/simple_coin/simple_coin.hex create mode 100644 src/dev/subcommands/devnet_cmd/contracts/simple_coin/simple_coin.sol create mode 100644 src/dev/subcommands/devnet_cmd/eth_skip_sender.rs diff --git a/.github/workflows/forest.yml b/.github/workflows/forest.yml index 3e1c01bcf92..0aad894713e 100644 --- a/.github/workflows/forest.yml +++ b/.github/workflows/forest.yml @@ -499,6 +499,7 @@ jobs: forest-dev tests mpool forest-dev tests wallet forest-dev devnet eth-gas + forest-dev devnet eth-skip-sender timeout-minutes: ${{ fromJSON(env.SCRIPT_TIMEOUT_MINUTES) }} - name: Dump docker logs if: always() diff --git a/mise.toml b/mise.toml index de95628c3be..4a59d66b968 100644 --- a/mise.toml +++ b/mise.toml @@ -249,6 +249,7 @@ devnet_test_env_init forest-dev tests mpool forest-dev tests wallet forest-dev devnet eth-gas +forest-dev devnet eth-skip-sender ''' [tasks."codecov:nextest"] diff --git a/scripts/devnet/README.md b/scripts/devnet/README.md index f3d2ecd863f..f36cd6f97f9 100644 --- a/scripts/devnet/README.md +++ b/scripts/devnet/README.md @@ -59,8 +59,9 @@ forest-cli net peers ## Running the integration tests The wallet/mpool suites that run against calibnet, plus the `eth_estimateGas` -parity suite, can be run against the local devnet. This brings up the devnet, -waits for it to sync, wires up the host environment, and runs the tests: +parity suite and the skip-sender integration tests, can be run against the local +devnet. This brings up the devnet, waits for it to sync, wires up the host +environment, and runs the tests: ```shell mise run test:devnet @@ -69,12 +70,13 @@ mise run test:devnet Under the hood this sources `test_harness.sh`, which reads the admin token and the funded genesis key from the running `forest` container, exports `FULLNODE_API_INFO` (Forest RPC on port 3456) and `FOREST_TEST_PRELOADED_ADDRESS`, -then runs `forest-dev tests mpool`, `forest-dev tests wallet` and -`forest-dev devnet eth-gas`. +then runs `forest-dev tests mpool`, `forest-dev tests wallet`, +`forest-dev devnet eth-gas` and `forest-dev devnet eth-skip-sender`. The `devnet` suites (unlike `tests`) need both nodes reachable: `eth-gas` -compares Forest against Lotus, so it runs pre-flight checks and fails early if -the devnet or harness environment is missing. +compares Forest against Lotus, and `eth-skip-sender` deploys contracts and +submits transactions, so they run pre-flight checks and fail early if the +devnet or harness environment is missing. ## Local devnet development diff --git a/scripts/devnet/test_harness.sh b/scripts/devnet/test_harness.sh index 3ffa5c41ce1..f2712795bf4 100644 --- a/scripts/devnet/test_harness.sh +++ b/scripts/devnet/test_harness.sh @@ -1,6 +1,6 @@ #!/bin/bash # Sourced (not executed) helpers for the devnet test suites (wallet, mpool, -# eth-gas) on the docker devnet. Run after the devnet is up (`setup.sh`) and synced (`check.sh`). +# eth-gas, eth-skip-sender) on the docker devnet. Run after the devnet is up (`setup.sh`) and synced (`check.sh`). # # The genesis key is the Lotus miner's default wallet, so using it as the test # sender causes nonce contention. We fund a dedicated wallet instead. diff --git a/src/dev/subcommands/devnet_cmd.rs b/src/dev/subcommands/devnet_cmd.rs index ded31996e97..e369691a3e0 100644 --- a/src/dev/subcommands/devnet_cmd.rs +++ b/src/dev/subcommands/devnet_cmd.rs @@ -7,6 +7,7 @@ //! that environment is missing, rather than letting a suite surface it as an opaque mid-run error. mod eth_gas; +mod eth_skip_sender; use crate::dev::subcommands::tests_cmd::helpers::{docker, forest_client, lotus_client}; use crate::rpc::prelude::*; @@ -16,6 +17,7 @@ use anyhow::{Context as _, ensure}; #[derive(Debug, clap::Subcommand)] pub enum DevnetCommand { EthGas(eth_gas::EthGasTestCommand), + EthSkipSender(eth_skip_sender::EthSkipSenderTestCommand), } impl DevnetCommand { @@ -23,6 +25,7 @@ impl DevnetCommand { preflight().await.context("devnet pre-flight failed")?; match self { Self::EthGas(cmd) => cmd.run().await, + Self::EthSkipSender(cmd) => cmd.run().await, } } } diff --git a/src/dev/subcommands/devnet_cmd/contracts/compile.sh b/src/dev/subcommands/devnet_cmd/contracts/compile.sh index 2c133cd1c29..83803da3556 100755 --- a/src/dev/subcommands/devnet_cmd/contracts/compile.sh +++ b/src/dev/subcommands/devnet_cmd/contracts/compile.sh @@ -15,5 +15,5 @@ for sol in "${DIR}"/*/*.sol; do echo "compiling ${name} with solc ${SOLC_VERSION}" docker run --rm --volume "${contract_dir}:/src:ro" "ethereum/solc:${SOLC_VERSION}" \ --bin "/src/${name}.sol" | - awk '/^Binary:/ { getline; print }' > "${contract_dir}/${name}.hex" + awk '/^Binary:/ { getline; if ($0 != "") print }' > "${contract_dir}/${name}.hex" done diff --git a/src/dev/subcommands/devnet_cmd/contracts/contract_a/contract_a.hex b/src/dev/subcommands/devnet_cmd/contracts/contract_a/contract_a.hex new file mode 100644 index 00000000000..3a3bcbc0b15 --- /dev/null +++ b/src/dev/subcommands/devnet_cmd/contracts/contract_a/contract_a.hex @@ -0,0 +1 @@ +6080604052348015600e575f5ffd5b50602a5f81905550610506806100235f395ff3fe608060405234801561000f575f5ffd5b5060043610610086575f3560e01c8063893885081161005957806389388508146100fe5780639ff8a3681461011c578063a70c52381461013a578063d1accab81461015857610086565b8063209652551461008a578063224bc07f146100a857806355241077146100c45780636d619daa146100e0575b5f5ffd5b610092610176565b60405161009f9190610372565b60405180910390f35b6100c260048036038101906100bd91906103e9565b61017e565b005b6100de60048036038101906100d9919061043e565b6101c1565b005b6100e86101ca565b6040516100f59190610372565b60405180910390f35b6101066101cf565b6040516101139190610372565b60405180910390f35b61012461026e565b6040516101319190610478565b60405180910390f35b610142610293565b60405161014f9190610372565b60405180910390f35b610160610332565b60405161016d9190610478565b60405180910390f35b5f5f54905090565b8060015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555050565b805f8190555050565b5f5481565b5f60015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1663f2e2b693306040518263ffffffff1660e01b815260040161022a9190610478565b602060405180830381865afa158015610245573d5f5f3e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061026991906104a5565b905090565b60015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b5f60015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff166317dbce79306040518263ffffffff1660e01b81526004016102ee9190610478565b602060405180830381865afa158015610309573d5f5f3e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061032d91906104a5565b905090565b5f60015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b5f819050919050565b61036c8161035a565b82525050565b5f6020820190506103855f830184610363565b92915050565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6103b88261038f565b9050919050565b6103c8816103ae565b81146103d2575f5ffd5b50565b5f813590506103e3816103bf565b92915050565b5f602082840312156103fe576103fd61038b565b5b5f61040b848285016103d5565b91505092915050565b61041d8161035a565b8114610427575f5ffd5b50565b5f8135905061043881610414565b92915050565b5f602082840312156104535761045261038b565b5b5f6104608482850161042a565b91505092915050565b610472816103ae565b82525050565b5f60208201905061048b5f830184610469565b92915050565b5f8151905061049f81610414565b92915050565b5f602082840312156104ba576104b961038b565b5b5f6104c784828501610491565b9150509291505056fea2646970667358221220ef73361e3224e41ad642fd9aabefa2d060c222b31891f2d1ab94b16d02104e2c64736f6c634300081e0033 diff --git a/src/dev/subcommands/devnet_cmd/contracts/contract_a/contract_a.sol b/src/dev/subcommands/devnet_cmd/contracts/contract_a/contract_a.sol new file mode 100644 index 00000000000..3e83b7a24d3 --- /dev/null +++ b/src/dev/subcommands/devnet_cmd/contracts/contract_a/contract_a.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.30; + +/// Lotus `itests/contracts/ContractA.sol`, pinned to the compiler this +/// directory's `compile.sh` uses. Calls into ContractB, which calls back here. +interface IContractB { + function callBackAndRead(address origin) external view returns (uint256); + function callBackAndDouble(address origin) external view returns (uint256); +} + +contract ContractA { + uint256 public storedValue; + address public contractB; + + constructor() { + storedValue = 42; + } + + function setContractB(address _contractB) external { + contractB = _contractB; + } + + function getValue() external view returns (uint256) { + return storedValue; + } + + function setValue(uint256 _value) external { + storedValue = _value; + } + + function callBAndReadBack() external view returns (uint256) { + return IContractB(contractB).callBackAndRead(address(this)); + } + + function callBAndDouble() external view returns (uint256) { + return IContractB(contractB).callBackAndDouble(address(this)); + } + + function getContractB() external view returns (address) { + return contractB; + } +} diff --git a/src/dev/subcommands/devnet_cmd/contracts/contract_b/contract_b.hex b/src/dev/subcommands/devnet_cmd/contracts/contract_b/contract_b.hex new file mode 100644 index 00000000000..52460e528a2 --- /dev/null +++ b/src/dev/subcommands/devnet_cmd/contracts/contract_b/contract_b.hex @@ -0,0 +1 @@ +6080604052348015600e575f5ffd5b506103468061001c5f395ff3fe608060405234801561000f575f5ffd5b5060043610610034575f3560e01c806317dbce7914610038578063f2e2b69314610068575b5f5ffd5b610052600480360381019061004d91906101f1565b610098565b60405161005f9190610234565b60405180910390f35b610082600480360381019061007d91906101f1565b61010d565b60405161008f9190610234565b60405180910390f35b5f8173ffffffffffffffffffffffffffffffffffffffff1663209652556040518163ffffffff1660e01b8152600401602060405180830381865afa1580156100e2573d5f5f3e3d5ffd5b505050506040513d601f19601f820116820180604052508101906101069190610277565b9050919050565b5f5f8273ffffffffffffffffffffffffffffffffffffffff1663209652556040518163ffffffff1660e01b8152600401602060405180830381865afa158015610158573d5f5f3e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061017c9190610277565b905060028161018b91906102cf565b915050919050565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6101c082610197565b9050919050565b6101d0816101b6565b81146101da575f5ffd5b50565b5f813590506101eb816101c7565b92915050565b5f6020828403121561020657610205610193565b5b5f610213848285016101dd565b91505092915050565b5f819050919050565b61022e8161021c565b82525050565b5f6020820190506102475f830184610225565b92915050565b6102568161021c565b8114610260575f5ffd5b50565b5f815190506102718161024d565b92915050565b5f6020828403121561028c5761028b610193565b5b5f61029984828501610263565b91505092915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f6102d98261021c565b91506102e48361021c565b92508282026102f28161021c565b91508282048414831517610309576103086102a2565b5b509291505056fea264697066735822122082a9ef86dd8a7e9cd8c81b48d42e6afa5631353a17a8fcf68071ebd8f3052a7264736f6c634300081e0033 diff --git a/src/dev/subcommands/devnet_cmd/contracts/contract_b/contract_b.sol b/src/dev/subcommands/devnet_cmd/contracts/contract_b/contract_b.sol new file mode 100644 index 00000000000..650baf91a5f --- /dev/null +++ b/src/dev/subcommands/devnet_cmd/contracts/contract_b/contract_b.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.30; + +/// Lotus `itests/contracts/ContractB.sol`, pinned to the compiler this +/// directory's `compile.sh` uses. Callback target for ContractA skip-sender tests. +interface IContractA { + function getValue() external view returns (uint256); +} + +contract ContractB { + function callBackAndRead(address origin) external view returns (uint256) { + return IContractA(origin).getValue(); + } + + function callBackAndDouble(address origin) external view returns (uint256) { + uint256 val = IContractA(origin).getValue(); + return val * 2; + } +} diff --git a/src/dev/subcommands/devnet_cmd/contracts/errors/errors.hex b/src/dev/subcommands/devnet_cmd/contracts/errors/errors.hex new file mode 100644 index 00000000000..6d12303d170 --- /dev/null +++ b/src/dev/subcommands/devnet_cmd/contracts/errors/errors.hex @@ -0,0 +1 @@ +6080604052348015600e575f5ffd5b506102cc8061001c5f395ff3fe608060405234801561000f575f5ffd5b5060043610610055575f3560e01c80630abe88b61461005957806358d4cbce1461006357806359be8c551461006d5780638791bd3314610077578063c6dbcf2e14610081575b5f5ffd5b61006161008b565b005b61006b61009b565b005b61007561009f565b005b61007f6100da565b005b61008961010c565b005b5f61009957610098610126565b5b565b5f5ffd5b6040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016100d1906101ad565b60405180910390fd5b6040517f09caebf300000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5f600190505f5f90508082610121919061022e565b505050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52600160045260245ffd5b5f82825260208201905092915050565b7f6d7920726561736f6e00000000000000000000000000000000000000000000005f82015250565b5f610197600983610153565b91506101a282610163565b602082019050919050565b5f6020820190508181035f8301526101c48161018b565b9050919050565b5f819050919050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601260045260245ffd5b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f610238826101cb565b9150610243836101cb565b925082610253576102526101d4565b5b60015f0383147f80000000000000000000000000000000000000000000000000000000000000008314161561028b5761028a610201565b5b82820590509291505056fea2646970667358221220544d83bfa77064090f1818996be89f2496e4c52cb66b84772263812b623ec21064736f6c634300081e0033 diff --git a/src/dev/subcommands/devnet_cmd/contracts/errors/errors.sol b/src/dev/subcommands/devnet_cmd/contracts/errors/errors.sol new file mode 100644 index 00000000000..a17cc219feb --- /dev/null +++ b/src/dev/subcommands/devnet_cmd/contracts/errors/errors.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.30; + +/// Lotus `itests/contracts/Errors.sol`, pinned to the compiler this +/// directory's `compile.sh` uses. Skip-sender revert tests call these methods +/// to pin JSON-RPC code, decoded reason, and revert data. +contract Errors { + error CustomError(); + + function failRevertEmpty() public { + revert(); + } + + function failRevertReason() public { + revert("my reason"); + } + + function failAssert() public { + assert(false); + } + + function failDivZero() public { + int a = 1; + int b = 0; + a / b; + } + + function failCustom() public { + revert CustomError(); + } +} diff --git a/src/dev/subcommands/devnet_cmd/contracts/simple_coin/simple_coin.hex b/src/dev/subcommands/devnet_cmd/contracts/simple_coin/simple_coin.hex new file mode 100644 index 00000000000..9dc48cc31e7 --- /dev/null +++ b/src/dev/subcommands/devnet_cmd/contracts/simple_coin/simple_coin.hex @@ -0,0 +1 @@ +6080604052348015600e575f5ffd5b506127105f5f3273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f20819055506104f58061005f5f395ff3fe608060405234801561000f575f5ffd5b506004361061003f575f3560e01c80637bd703e81461004357806390b98a1114610073578063f8b2cb4f146100a3575b5f5ffd5b61005d600480360381019061005891906102f4565b6100d3565b60405161006a9190610337565b60405180910390f35b61008d6004803603810190610088919061037a565b6100f0565b60405161009a91906103d2565b60405180910390f35b6100bd60048036038101906100b891906102f4565b610251565b6040516100ca9190610337565b60405180910390f35b5f60026100df83610251565b6100e99190610418565b9050919050565b5f815f5f3373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f2054101561013d575f905061024b565b815f5f3373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f8282546101889190610459565b92505081905550815f5f8573ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f205f8282546101da919061048c565b925050819055508273ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef8460405161023e9190610337565b60405180910390a3600190505b92915050565b5f5f5f8373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020015f20549050919050565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6102c38261029a565b9050919050565b6102d3816102b9565b81146102dd575f5ffd5b50565b5f813590506102ee816102ca565b92915050565b5f6020828403121561030957610308610296565b5b5f610316848285016102e0565b91505092915050565b5f819050919050565b6103318161031f565b82525050565b5f60208201905061034a5f830184610328565b92915050565b6103598161031f565b8114610363575f5ffd5b50565b5f8135905061037481610350565b92915050565b5f5f604083850312156103905761038f610296565b5b5f61039d858286016102e0565b92505060206103ae85828601610366565b9150509250929050565b5f8115159050919050565b6103cc816103b8565b82525050565b5f6020820190506103e55f8301846103c3565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f6104228261031f565b915061042d8361031f565b925082820261043b8161031f565b91508282048414831517610452576104516103eb565b5b5092915050565b5f6104638261031f565b915061046e8361031f565b9250828203905081811115610486576104856103eb565b5b92915050565b5f6104968261031f565b91506104a18361031f565b92508282019050808211156104b9576104b86103eb565b5b9291505056fea2646970667358221220e0cfde66801b2ecaad214997e30ea2fcb10e4b212161c6554ca5e157310214d264736f6c634300081e0033 diff --git a/src/dev/subcommands/devnet_cmd/contracts/simple_coin/simple_coin.sol b/src/dev/subcommands/devnet_cmd/contracts/simple_coin/simple_coin.sol new file mode 100644 index 00000000000..b724cbb78ee --- /dev/null +++ b/src/dev/subcommands/devnet_cmd/contracts/simple_coin/simple_coin.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.30; + +/// Lotus `itests/contracts/SimpleCoin.sol`, pinned to the compiler this +/// directory's `compile.sh` uses. `sendCoin` returns whether `balances[msg.sender]` +/// covered the amount, so a skip-sender `eth_call` can report the requested `from`. +contract SimpleCoin { + mapping(address => uint256) balances; + + event Transfer(address indexed _from, address indexed _to, uint256 _value); + + constructor() { + balances[tx.origin] = 10000; + } + + function sendCoin(address receiver, uint256 amount) public returns (bool sufficient) { + if (balances[msg.sender] < amount) return false; + balances[msg.sender] -= amount; + balances[receiver] += amount; + emit Transfer(msg.sender, receiver, amount); + return true; + } + + function getBalanceInEth(address addr) public view returns (uint256) { + return getBalance(addr) * 2; + } + + function getBalance(address addr) public view returns (uint256) { + return balances[addr]; + } +} diff --git a/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs b/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs new file mode 100644 index 00000000000..c8d1b2de220 --- /dev/null +++ b/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs @@ -0,0 +1,1187 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +//! Skip-sender `eth_call` and `eth_estimateGas` tests on the docker devnet +//! (`scripts/devnet`). These cases need a private chain: deploy a contract, fund +//! an address, submit a transaction, or assert Forest state after a skip-call. +//! +//! Tests deploy `SimpleCoin`, `ContractA` / `ContractB`, `NestedGas`, or `Errors` +//! as needed. They cover estimate-then-submit from an unfunded `from` (including +//! nested `recurse`), estimate parity with a funded placeholder, `msg.sender` +//! identity via `sendCoin`, skip-call state isolation, a historical `eth_call`, +//! cross-contract callbacks, and the skip-sender success/error matrix (CREATE, +//! `gasPrice`, `FromNil`, `FromEOA`, value, revert data). + +use crate::dev::subcommands::tests_cmd::helpers::*; +use crate::rpc::Client; +use crate::rpc::eth::errors::EXECUTION_REVERTED_CODE; +use crate::rpc::eth::{ + BlockNumberOrHash, EthBigInt, Predefined, + types::{EthAddress, EthBytes, EthCallMessage}, +}; +use crate::rpc::prelude::*; +use crate::rpc::types::ApiTipsetKey; +use crate::shim::address::Address; +use crate::shim::econ::TokenAmount; +use crate::shim::state_tree::ActorState; +use crate::utils::encoding::{hex, keccak_256}; +use anyhow::{Context as _, ensure}; +use cid::Cid; +use jsonrpsee::core::ClientError; +use libtest_mimic::{Arguments, Failed, Trial}; +use std::io::Write as _; +use std::str::FromStr as _; +use tempfile::NamedTempFile; +use tokio::sync::OnceCell; + +const SIMPLE_COIN_HEX: &str = include_str!("contracts/simple_coin/simple_coin.hex"); +const CONTRACT_A_HEX: &str = include_str!("contracts/contract_a/contract_a.hex"); +const CONTRACT_B_HEX: &str = include_str!("contracts/contract_b/contract_b.hex"); +const NESTED_GAS_HEX: &str = include_str!("contracts/nested_gas/nested_gas.hex"); +const ERRORS_HEX: &str = include_str!("contracts/errors/errors.hex"); + +const SEND_COIN_SIGNATURE: &str = "sendCoin(address,uint256)"; +const SET_CONTRACT_B_SIGNATURE: &str = "setContractB(address)"; +const GET_BALANCE_SIGNATURE: &str = "getBalance(address)"; +const CALL_B_AND_READ_BACK: &str = "callBAndReadBack()"; +const CALL_B_AND_DOUBLE: &str = "callBAndDouble()"; +const RECURSE_SIGNATURE: &str = "recurse(uint256)"; +const FAIL_DIV_ZERO: &str = "failDivZero()"; +const FAIL_ASSERT: &str = "failAssert()"; +const FAIL_REVERT_REASON: &str = "failRevertReason()"; +const FAIL_REVERT_EMPTY: &str = "failRevertEmpty()"; +const FAIL_CUSTOM: &str = "failCustom()"; + +const NESTED_DEPTH: u64 = 100; +const DEPLOYER_FUND_AMT: &str = "10 FIL"; +const ROUND_TRIP_FUND_AMT: &str = "1 FIL"; +const RECURSIVE_FUND_AMT: &str = "10 FIL"; +const EOA_FUND_AMT: &str = "10 FIL"; +const PLACEHOLDER_FUND_AMT: &str = "2 FIL"; +const ESTIMATE_PARITY: f64 = 0.10; +const GAS_PRICE: u64 = 1_000_000_000; +const MIN_ESTIMATE_GAS: u64 = 21_000; +const MAX_ESTIMATE_GAS: u64 = 10_000_000_000; +/// ABI `Panic(uint256)` payload for Solidity assert (`0x01`) and division by zero (`0x12`). +const PANIC_ASSERT: &str = + "4e487b710000000000000000000000000000000000000000000000000000000000000001"; +const PANIC_DIV_ZERO: &str = + "4e487b710000000000000000000000000000000000000000000000000000000000000012"; + +/// Skip-sender integration tests that need a private chain with a miner +#[derive(Debug, clap::Args)] +pub struct EthSkipSenderTestCommand {} + +impl EthSkipSenderTestCommand { + pub async fn run(self) -> anyhow::Result<()> { + let args = Arguments { + test_threads: Some(1), + ..Default::default() + }; + libtest_mimic::run(&args, tests()).exit(); + } +} + +fn tests() -> Vec { + fn trial(name: &'static str, body: fn() -> anyhow::Result<()>) -> Trial { + Trial::test(name, move || { + body().map_err(|e| Failed::from(format!("{e:?}"))) + }) + } + + vec![ + trial("round_trip_from_unfunded", || { + block_on(round_trip_from_unfunded()) + }), + trial("parity_with_existing_sender", || { + block_on(parity_with_existing_sender()) + }), + trial("round_trip_recursive", || block_on(round_trip_recursive())), + trial("call_sender_identity", || block_on(call_sender_identity())), + trial("skip_sender_state_isolation", || { + block_on(skip_sender_state_isolation()) + }), + trial("skip_sender_historical_call", || { + block_on(skip_sender_historical_call()) + }), + trial("cross_contract_from_contract", || { + block_on(cross_contract_from_contract()) + }), + trial("cross_contract_from_missing", || { + block_on(cross_contract_from_missing()) + }), + trial("cross_contract_from_eoa", || { + block_on(cross_contract_from_eoa()) + }), + trial("cross_contract_double_callback", || { + block_on(cross_contract_double_callback()) + }), + trial("call_skip_sender", || block_on(call_skip_sender())), + trial("estimate_gas_skip_sender", || { + block_on(estimate_gas_skip_sender()) + }), + trial("funded_placeholder_sender", || { + block_on(funded_placeholder_sender()) + }), + ] +} + +fn selector(signature: &str) -> Vec { + keccak_256(signature.as_bytes()) + .get(..4) + .expect("keccak256 is 32 bytes") + .to_vec() +} + +fn abi_address_word(addr: EthAddress) -> Vec { + let mut word = vec![0u8; 12]; + word.extend_from_slice(addr.0.as_bytes()); + word +} + +fn send_coin_calldata(to: EthAddress, amount: u64) -> Vec { + let mut out = selector(SEND_COIN_SIGNATURE); + out.extend(abi_address_word(to)); + out.extend_from_slice(ðereum_types::U256::from(amount).to_big_endian()); + out +} + +fn set_contract_b_calldata(addr: EthAddress) -> Vec { + let mut out = selector(SET_CONTRACT_B_SIGNATURE); + out.extend(abi_address_word(addr)); + out +} + +fn recurse_calldata(depth: u64) -> Vec { + let mut out = selector(RECURSE_SIGNATURE); + out.extend_from_slice(ðereum_types::U256::from(depth).to_big_endian()); + out +} + +fn get_balance_calldata(addr: EthAddress) -> Vec { + let mut out = selector(GET_BALANCE_SIGNATURE); + out.extend(abi_address_word(addr)); + out +} + +fn simple_coin_initcode() -> anyhow::Result { + Ok(EthBytes( + hex::decode(SIMPLE_COIN_HEX.trim()).context("decoding SimpleCoin initcode")?, + )) +} + +/// Missing eth address: `0xdeadbeef` then zeros, last byte `seed`. +fn non_existent(seed: u8) -> anyhow::Result { + EthAddress::from_str(&format!("0xdeadbeef{:030}{seed:02x}", 0)) + .context("parsing missing eth address") +} + +fn latest() -> BlockNumberOrHash { + BlockNumberOrHash::PredefinedBlock(Predefined::Latest) +} + +/// Deployed EVM actor: `eth` for JSON-RPC, `f4` for `lotus send` / `StateGetActor`. +#[derive(Clone, Copy)] +struct Deployed { + eth: EthAddress, + f4: Address, +} + +/// Dedicated delegated wallet used to deploy and to credit `SimpleCoin`. +/// Not the genesis/miner key: that wallet races with the miner on nonce. +async fn deployer() -> anyhow::Result<&'static Address> { + static DEPLOYER: OnceCell
= OnceCell::const_new(); + DEPLOYER + .get_or_try_init(|| async { + let addr = lotus_exec(&["wallet", "new", "delegated"])?; + let msg = send_from( + &FOREST_TEST_PRELOADED_ADDRESS, + &addr, + DEPLOYER_FUND_AMT, + Backend::Local, + )?; + eprintln!("funding deployer {addr} with {DEPLOYER_FUND_AMT}, msg: {msg}"); + let balance = poll_until_funded(&addr, Backend::Local).await?; + eprintln!("deployer {addr} funded balance: {balance}"); + Address::from_str(&addr).context("parsing the deployer address") + }) + .await +} + +async fn deploy_hex(label: &str, bytecode: &str, container_path: &str) -> anyhow::Result { + let deployer = deployer().await?; + let mut hex_file = + NamedTempFile::new_in(std::env::temp_dir()).context("staging the contract bytecode")?; + hex_file.write_all(bytecode.trim().as_bytes())?; + hex_file.flush()?; + docker(&[ + "cp", + &hex_file.path().to_string_lossy(), + &format!("lotus:{container_path}"), + ])?; + + let from = deployer.to_string(); + let deploy = + lotus_exec_retrying_mpool(&["evm", "deploy", "--from", &from, "--hex", container_path]) + .await?; + let f4 = deploy + .lines() + .find_map(|l| l.trim().strip_prefix("f4 Address: ")) + .with_context(|| format!("no `f4 Address:` in {label} deploy output:\n{deploy}"))?; + let f4 = Address::from_str(f4.trim()).context("parsing the deployed f4 address")?; + eprintln!("deployed {label} at {f4}"); + poll_until_actor(f4).await?; + Ok(Deployed { + eth: EthAddress::from_filecoin_address(&f4)?, + f4, + }) +} + +async fn simple_coin() -> anyhow::Result<&'static Deployed> { + static CONTRACT: OnceCell = OnceCell::const_new(); + CONTRACT + .get_or_try_init(|| deploy_hex("SimpleCoin", SIMPLE_COIN_HEX, "/tmp/simple_coin.hex")) + .await +} + +async fn contract_b() -> anyhow::Result<&'static Deployed> { + static CONTRACT: OnceCell = OnceCell::const_new(); + CONTRACT + .get_or_try_init(|| deploy_hex("ContractB", CONTRACT_B_HEX, "/tmp/contract_b.hex")) + .await +} + +async fn nested_gas() -> anyhow::Result<&'static Deployed> { + static CONTRACT: OnceCell = OnceCell::const_new(); + CONTRACT + .get_or_try_init(|| deploy_hex("NestedGas", NESTED_GAS_HEX, "/tmp/nested_gas_skip.hex")) + .await +} + +async fn errors_contract() -> anyhow::Result<&'static Deployed> { + static CONTRACT: OnceCell = OnceCell::const_new(); + CONTRACT + .get_or_try_init(|| deploy_hex("Errors", ERRORS_HEX, "/tmp/errors.hex")) + .await +} + +/// Shared senders and contracts for the skip-sender call/estimate tables. +struct TableEnv { + coin: EthAddress, + errors: EthAddress, + eoa: EthAddress, + eoa2: EthAddress, +} + +async fn table_env() -> anyhow::Result<&'static TableEnv> { + static ENV: OnceCell = OnceCell::const_new(); + ENV.get_or_try_init(|| async { + let coin = simple_coin().await?; + let errors = errors_contract().await?; + let (_eoa_f4, eoa) = new_funded(EOA_FUND_AMT).await?; + let (_eoa2_f4, eoa2) = new_unfunded().await?; + Ok(TableEnv { + coin: coin.eth, + errors: errors.eth, + eoa, + eoa2, + }) + }) + .await +} + +/// `ContractA` with `setContractB` already mined, so callbacks see `storedValue`. +async fn linked_contracts() -> anyhow::Result<&'static (Deployed, Deployed)> { + static LINKED: OnceCell<(Deployed, Deployed)> = OnceCell::const_new(); + LINKED + .get_or_try_init(|| async { + let b = contract_b().await?; + let a = deploy_hex("ContractA", CONTRACT_A_HEX, "/tmp/contract_a.hex").await?; + invoke(&a.f4, &set_contract_b_calldata(b.eth)).await?; + Ok(( + a, + Deployed { + eth: b.eth, + f4: b.f4, + }, + )) + }) + .await +} + +async fn get_actor(client: &Client, addr: Address) -> anyhow::Result> { + client + .call(StateGetActor::request((addr, ApiTipsetKey(None)))?) + .await + .map_err(|e| anyhow::anyhow!("{e:#}")) +} + +async fn poll_until_actor(addr: Address) -> anyhow::Result { + let client = forest_client()?; + let started = tokio::time::Instant::now(); + let mut attempt = 0u32; + loop { + attempt += 1; + eprintln!("Polling StateGetActor {addr} attempt {attempt}"); + if let Some(actor) = get_actor(&client, addr).await? { + return Ok(actor); + } + if started.elapsed() >= POLL_TIMEOUT { + anyhow::bail!("Timed out waiting for actor {addr} after {POLL_TIMEOUT:?}"); + } + let remaining = POLL_TIMEOUT.saturating_sub(started.elapsed()); + tokio::time::sleep(POLL_WAIT_TIME.min(remaining)).await; + } +} + +async fn wait_for_cid(forest: &Client, cid: Cid) -> anyhow::Result<()> { + let lookup = forest + .call(StateWaitMsg::request((cid, 0, 800, true))?.with_timeout(POLL_TIMEOUT)) + .await + .map_err(|e| anyhow::anyhow!("{e:#}"))?; + let exit = lookup.receipt.exit_code(); + ensure!( + exit.is_success(), + "message {cid} failed on chain with exit code {exit}" + ); + Ok(()) +} + +async fn invoke(to: &Address, calldata: &[u8]) -> anyhow::Result { + let forest = forest_client()?; + let from = deployer().await?.to_string(); + let to_s = to.to_string(); + let params = hex::encode(calldata); + let out = + lotus_exec_retrying_mpool(&["send", "--from", &from, "--params-hex", ¶ms, &to_s, "0"]) + .await?; + let cid = Cid::from_str( + out.lines() + .last() + .context("no cid from `lotus send`")? + .trim(), + )?; + wait_for_cid(&forest, cid).await?; + Ok(cid) +} + +async fn submit_at_gas_limit( + from: &Address, + to: &Address, + calldata: &[u8], + gas_limit: u64, +) -> anyhow::Result<()> { + let forest = forest_client()?; + let from_s = from.to_string(); + let to_s = to.to_string(); + let params = hex::encode(calldata); + let gas = gas_limit.to_string(); + let out = lotus_exec_retrying_mpool(&[ + "send", + "--from", + &from_s, + "--params-hex", + ¶ms, + "--gas-limit", + &gas, + &to_s, + "0", + ]) + .await?; + let cid = Cid::from_str( + out.lines() + .last() + .context("no cid from `lotus send`")? + .trim(), + )?; + eprintln!("submitted at estimate {gas_limit}: {cid}"); + wait_for_cid(&forest, cid).await?; + let hash = forest + .call(EthGetTransactionHashByCid::request((cid,))?) + .await + .map_err(|e| anyhow::anyhow!("{e:#}"))? + .context("no Ethereum transaction hash for submitted CID")?; + let receipt = forest + .call(EthGetTransactionReceipt::request((hash,))?) + .await + .map_err(|e| anyhow::anyhow!("{e:#}"))? + .context("no Ethereum receipt for submitted transaction")?; + // `EthTxReceipt` fields are module-private; read `status` through serde. + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct ReceiptStatus { + status: crate::rpc::eth::EthUint64, + } + let status = serde_json::from_value::(serde_json::to_value(&receipt)?)? + .status + .0; + ensure!( + status == 1, + "estimated gas limit {gas_limit} must be sufficient; receipt status is {status}" + ); + Ok(()) +} + +async fn eth_call_msg( + client: &Client, + msg: EthCallMessage, + block: BlockNumberOrHash, +) -> anyhow::Result { + Ok(client.call(EthCall::request((msg, block))?).await?) +} + +async fn estimate_msg(client: &Client, msg: EthCallMessage) -> anyhow::Result { + Ok(client + .call(EthEstimateGas::request((msg, Some(latest())))?) + .await? + .0) +} + +fn rpc_call_err(err: &anyhow::Error) -> Option<&jsonrpsee::types::ErrorObjectOwned> { + match err.downcast_ref::() { + Some(ClientError::Call(obj)) => Some(obj), + _ => None, + } +} + +fn rpc_data(obj: &jsonrpsee::types::ErrorObjectOwned) -> Option { + let raw = obj.data()?; + serde_json::from_str::(raw.get()) + .ok() + .or_else(|| Some(raw.get().trim_matches('"').to_string())) +} + +enum Expect { + Success, + SuccessGas, + ErrContains(&'static str), + Reverted { + msg: &'static str, + data_contains: Option, + data_eq: Option<&'static str>, + }, +} + +struct SkipSenderCase { + name: &'static str, + msg: EthCallMessage, + call: Option, + estimate: Option, +} + +fn fil(whole: u64) -> EthBigInt { + EthBigInt::from(TokenAmount::from_whole(whole)) +} + +fn skip_sender_cases(env: &TableEnv) -> anyhow::Result> { + let initcode = simple_coin_initcode()?; + let missing = non_existent(0x01)?; + let gas_price = Some(EthBigInt::from(GAS_PRICE)); + let custom = hex::encode(selector("CustomError()")); + + let transfer = |from: Option, to: Option| EthCallMessage { + from, + to, + ..Default::default() + }; + + Ok(vec![ + SkipSenderCase { + name: "CreateFromContract", + msg: EthCallMessage { + from: Some(env.coin), + to: None, + data: Some(initcode.clone()), + ..Default::default() + }, + call: Some(Expect::ErrContains("disallowed caller")), + estimate: Some(Expect::ErrContains("disallowed caller")), + }, + SkipSenderCase { + name: "CreateFromNonExistent", + msg: EthCallMessage { + from: Some(missing), + to: None, + data: Some(initcode), + ..Default::default() + }, + call: Some(Expect::Success), + estimate: Some(Expect::SuccessGas), + }, + SkipSenderCase { + name: "FromContract", + msg: transfer(Some(env.coin), Some(env.eoa)), + call: Some(Expect::Success), + estimate: Some(Expect::SuccessGas), + }, + SkipSenderCase { + name: "FromContractWithGasPrice", + msg: EthCallMessage { + from: Some(env.coin), + to: Some(env.eoa), + gas_price, + ..Default::default() + }, + call: Some(Expect::Success), + estimate: Some(Expect::SuccessGas), + }, + SkipSenderCase { + name: "FromContractToSelf", + msg: EthCallMessage { + from: Some(env.coin), + to: Some(env.coin), + data: Some(EthBytes(get_balance_calldata(env.coin))), + ..Default::default() + }, + call: Some(Expect::Success), + estimate: Some(Expect::SuccessGas), + }, + SkipSenderCase { + name: "FromContractWithValue", + msg: EthCallMessage { + from: Some(env.coin), + to: Some(env.eoa), + value: Some(fil(1)), + ..Default::default() + }, + call: Some(Expect::ErrContains("insufficient")), + estimate: Some(Expect::ErrContains("insufficient")), + }, + SkipSenderCase { + name: "FromNonExistent", + msg: transfer(Some(missing), Some(env.eoa)), + call: Some(Expect::Success), + estimate: Some(Expect::SuccessGas), + }, + SkipSenderCase { + name: "FromNonExistentWithGasPrice", + msg: EthCallMessage { + from: Some(missing), + to: Some(env.eoa), + gas_price, + ..Default::default() + }, + call: Some(Expect::Success), + estimate: Some(Expect::SuccessGas), + }, + SkipSenderCase { + name: "FromNonExistentToContractWithData", + msg: EthCallMessage { + from: Some(missing), + to: Some(env.errors), + data: Some(EthBytes(selector(FAIL_REVERT_EMPTY))), + ..Default::default() + }, + call: Some(Expect::Reverted { + msg: "none", + data_contains: None, + data_eq: Some("0x"), + }), + estimate: Some(Expect::Reverted { + msg: "none", + data_contains: None, + data_eq: Some("0x"), + }), + }, + SkipSenderCase { + name: "FromNonExistentWithValue", + msg: EthCallMessage { + from: Some(missing), + to: Some(env.eoa), + value: Some(fil(1)), + ..Default::default() + }, + call: Some(Expect::ErrContains("insufficient")), + estimate: None, + }, + SkipSenderCase { + name: "FromEOA", + msg: transfer(Some(env.eoa), Some(env.eoa2)), + call: Some(Expect::Success), + estimate: Some(Expect::SuccessGas), + }, + SkipSenderCase { + name: "FromNil", + msg: transfer(None, Some(env.eoa)), + call: Some(Expect::Success), + estimate: None, + }, + SkipSenderCase { + name: "ValueOverBalance", + msg: EthCallMessage { + from: Some(env.eoa), + to: Some(missing), + value: Some(fil(11)), + ..Default::default() + }, + call: Some(Expect::ErrContains("insufficient")), + estimate: None, + }, + SkipSenderCase { + name: "RevertDivideByZero", + msg: EthCallMessage { + from: Some(env.eoa), + to: Some(env.errors), + data: Some(EthBytes(selector(FAIL_DIV_ZERO))), + ..Default::default() + }, + call: Some(Expect::Reverted { + msg: "DivideByZero", + data_contains: Some(PANIC_DIV_ZERO.to_string()), + data_eq: None, + }), + estimate: Some(Expect::Reverted { + msg: "DivideByZero", + data_contains: Some(PANIC_DIV_ZERO.to_string()), + data_eq: None, + }), + }, + SkipSenderCase { + name: "RevertAssert", + msg: EthCallMessage { + from: Some(env.eoa), + to: Some(env.errors), + data: Some(EthBytes(selector(FAIL_ASSERT))), + ..Default::default() + }, + call: Some(Expect::Reverted { + msg: "Assert", + data_contains: Some(PANIC_ASSERT.to_string()), + data_eq: None, + }), + estimate: Some(Expect::Reverted { + msg: "Assert", + data_contains: Some(PANIC_ASSERT.to_string()), + data_eq: None, + }), + }, + SkipSenderCase { + name: "RevertWithReason", + msg: EthCallMessage { + from: Some(env.eoa), + to: Some(env.errors), + data: Some(EthBytes(selector(FAIL_REVERT_REASON))), + ..Default::default() + }, + call: Some(Expect::Reverted { + msg: "my reason", + data_contains: None, + data_eq: None, + }), + estimate: Some(Expect::Reverted { + msg: "my reason", + data_contains: None, + data_eq: None, + }), + }, + SkipSenderCase { + name: "RevertEmpty", + msg: EthCallMessage { + from: Some(env.eoa), + to: Some(env.errors), + data: Some(EthBytes(selector(FAIL_REVERT_EMPTY))), + ..Default::default() + }, + call: Some(Expect::Reverted { + msg: "none", + data_contains: None, + data_eq: Some("0x"), + }), + estimate: Some(Expect::Reverted { + msg: "none", + data_contains: None, + data_eq: Some("0x"), + }), + }, + SkipSenderCase { + name: "RevertCustomError", + msg: EthCallMessage { + from: Some(env.eoa), + to: Some(env.errors), + data: Some(EthBytes(selector(FAIL_CUSTOM))), + ..Default::default() + }, + call: Some(Expect::Reverted { + msg: "", + data_contains: Some(custom.clone()), + data_eq: None, + }), + estimate: Some(Expect::Reverted { + msg: "", + data_contains: Some(custom), + data_eq: None, + }), + }, + ]) +} + +fn assert_expect( + label: &str, + result: Result, + expect: &Expect, +) -> anyhow::Result<()> { + match expect { + Expect::Success => { + result.with_context(|| format!("{label}: expected success"))?; + Ok(()) + } + Expect::SuccessGas => { + let gas = result.with_context(|| format!("{label}: expected a gas estimate"))?; + ensure!( + gas >= MIN_ESTIMATE_GAS, + "{label}: estimate {gas} is below the 21_000 transfer floor" + ); + ensure!( + gas < MAX_ESTIMATE_GAS, + "{label}: estimate {gas} looks like an overflow" + ); + Ok(()) + } + Expect::ErrContains(needle) => { + let err = result.err().with_context(|| { + format!("{label}: expected an error containing `{needle}`, but the call succeeded") + })?; + let text = match rpc_call_err(&err) { + Some(obj) => { + let mut s = obj.message().to_string(); + if let Some(data) = rpc_data(obj) { + s.push(' '); + s.push_str(&data); + } + s + } + None => err.to_string(), + }; + ensure!( + text.to_ascii_lowercase() + .contains(&needle.to_ascii_lowercase()), + "{label}: error `{text}` does not contain `{needle}`" + ); + Ok(()) + } + Expect::Reverted { + msg, + data_contains, + data_eq, + } => { + let err = result.err().with_context(|| { + format!("{label}: expected execution reverted, but the call succeeded") + })?; + let obj = rpc_call_err(&err) + .with_context(|| format!("{label}: expected a JSON-RPC error, got {err:#}"))?; + ensure!( + obj.code() == EXECUTION_REVERTED_CODE, + "{label}: expected execution-reverted code {EXECUTION_REVERTED_CODE}, got {}: {}", + obj.code(), + obj.message() + ); + if !msg.is_empty() { + ensure!( + obj.message().contains(msg), + "{label}: revert message `{}` does not contain `{msg}`", + obj.message() + ); + } + let data = rpc_data(obj).unwrap_or_default(); + if let Some(want) = data_eq { + ensure!(data == *want, "{label}: revert data `{data}` != `{want}`"); + } + if let Some(want) = data_contains { + ensure!( + data.contains(want), + "{label}: revert data `{data}` does not contain `{want}`" + ); + } + Ok(()) + } + } +} + +async fn call_skip_sender() -> anyhow::Result<()> { + let forest = forest_client()?; + let env = table_env().await?; + for case in skip_sender_cases(env)? { + let Some(expect) = case.call else { + continue; + }; + let label = format!("eth_call {}", case.name); + let result = eth_call_msg(&forest, case.msg, latest()) + .await + .map(|_| 0u64); + assert_expect(&label, result, &expect)?; + } + Ok(()) +} + +async fn estimate_gas_skip_sender() -> anyhow::Result<()> { + let forest = forest_client()?; + let env = table_env().await?; + for case in skip_sender_cases(env)? { + let Some(expect) = case.estimate else { + continue; + }; + let label = format!("eth_estimateGas {}", case.name); + assert_expect(&label, estimate_msg(&forest, case.msg).await, &expect)?; + } + Ok(()) +} + +async fn funded_placeholder_sender() -> anyhow::Result<()> { + let forest = forest_client()?; + let (_from_f4, from) = new_funded(PLACEHOLDER_FUND_AMT).await?; + let (_to_f4, to) = new_funded(ROUND_TRIP_FUND_AMT).await?; + eth_call_msg( + &forest, + EthCallMessage { + from: Some(from), + to: Some(to), + value: Some(fil(1)), + ..Default::default() + }, + latest(), + ) + .await + .context("value-bearing eth_call from a funded placeholder")?; + Ok(()) +} + +async fn estimate_gas( + client: &Client, + from: EthAddress, + to: EthAddress, + data: Vec, +) -> anyhow::Result { + estimate_msg( + client, + EthCallMessage { + from: Some(from), + to: Some(to), + data: Some(EthBytes(data)), + ..Default::default() + }, + ) + .await +} + +async fn eth_call( + client: &Client, + from: EthAddress, + to: EthAddress, + data: Vec, + block: BlockNumberOrHash, +) -> anyhow::Result { + let msg = EthCallMessage { + from: Some(from), + to: Some(to), + data: (!data.is_empty()).then_some(EthBytes(data)), + ..Default::default() + }; + client + .call(EthCall::request((msg, block))?) + .await + .map_err(|e| anyhow::anyhow!("{e:#}")) +} + +fn within_parity(skip: u64, funded: u64) -> bool { + let denom = funded.max(1) as f64; + (skip as f64 - funded as f64).abs() / denom <= ESTIMATE_PARITY +} + +async fn new_unfunded() -> anyhow::Result<(Address, EthAddress)> { + let addr = lotus_exec(&["wallet", "new", "delegated"])?; + let f4 = Address::from_str(&addr).context("parsing unfunded delegated address")?; + let eth = EthAddress::from_filecoin_address(&f4)?; + Ok((f4, eth)) +} + +async fn new_funded(amount: &str) -> anyhow::Result<(Address, EthAddress)> { + let (f4, eth) = new_unfunded().await?; + let msg = send_from( + &FOREST_TEST_PRELOADED_ADDRESS, + &f4.to_string(), + amount, + Backend::Local, + )?; + eprintln!("funding placeholder {f4} with {amount}, msg: {msg}"); + poll_until_funded(&f4.to_string(), Backend::Local).await?; + Ok((f4, eth)) +} + +async fn round_trip_from_unfunded() -> anyhow::Result<()> { + let forest = forest_client()?; + let coin = simple_coin().await?; + let recipient = non_existent(0x01)?; + let calldata = send_coin_calldata(recipient, 0); + + let (from_f4, from_eth) = new_unfunded().await?; + let gas = estimate_gas(&forest, from_eth, coin.eth, calldata.clone()) + .await + .context("eth_estimateGas from unfunded sender")?; + eprintln!("skip-sender estimate {gas} from {from_f4}"); + + ensure!( + get_actor(&forest, from_f4).await?.is_none(), + "ephemeral placeholder for {from_f4} leaked onto chain during estimate" + ); + + let fund_msg = send_from( + &FOREST_TEST_PRELOADED_ADDRESS, + &from_f4.to_string(), + ROUND_TRIP_FUND_AMT, + Backend::Local, + )?; + eprintln!("funding round-trip sender {from_f4}, msg: {fund_msg}"); + poll_until_funded(&from_f4.to_string(), Backend::Local).await?; + let actor = poll_until_actor(from_f4).await?; + ensure!( + actor.sequence == 0, + "pre-submit nonce of {from_f4} is {}, expected 0 (placeholder must not have incremented it)", + actor.sequence + ); + + submit_at_gas_limit(&from_f4, &coin.f4, &calldata, gas).await?; + let after = get_actor(&forest, from_f4) + .await? + .with_context(|| format!("actor {from_f4} missing after successful submit"))?; + ensure!( + after.sequence == 1, + "successful tx must use nonce 0; on-chain nonce is now {}", + after.sequence + ); + Ok(()) +} + +async fn parity_with_existing_sender() -> anyhow::Result<()> { + let forest = forest_client()?; + let coin = simple_coin().await?; + let calldata = send_coin_calldata(non_existent(0x01)?, 0); + + let skip = estimate_gas(&forest, non_existent(0x42)?, coin.eth, calldata.clone()) + .await + .context("eth_estimateGas from missing from")?; + let (_placeholder, placeholder_eth) = new_funded(ROUND_TRIP_FUND_AMT).await?; + let funded = estimate_gas(&forest, placeholder_eth, coin.eth, calldata) + .await + .context("eth_estimateGas from funded placeholder")?; + eprintln!("parity skip={skip} funded={funded}"); + ensure!( + within_parity(skip, funded), + "skip-sender estimate {skip} vs funded-placeholder {funded} exceeds 10%" + ); + Ok(()) +} + +async fn round_trip_recursive() -> anyhow::Result<()> { + let forest = forest_client()?; + let nested = nested_gas().await?; + let calldata = recurse_calldata(NESTED_DEPTH); + + let (from_f4, from_eth) = new_unfunded().await?; + let gas = estimate_gas(&forest, from_eth, nested.eth, calldata.clone()) + .await + .context("skip-sender eth_estimateGas recurse(100)")?; + + let (_placeholder, placeholder_eth) = new_funded(RECURSIVE_FUND_AMT).await?; + let funded = estimate_gas(&forest, placeholder_eth, nested.eth, calldata.clone()) + .await + .context("funded-placeholder eth_estimateGas recurse(100)")?; + eprintln!("recursive skip={gas} funded={funded}"); + ensure!( + within_parity(gas, funded), + "recursive skip-sender estimate {gas} vs funded-placeholder {funded} exceeds 10%" + ); + + let fund_msg = send_from( + &FOREST_TEST_PRELOADED_ADDRESS, + &from_f4.to_string(), + RECURSIVE_FUND_AMT, + Backend::Local, + )?; + eprintln!("funding recursive sender {from_f4}, msg: {fund_msg}"); + poll_until_funded(&from_f4.to_string(), Backend::Local).await?; + submit_at_gas_limit(&from_f4, &nested.f4, &calldata, gas).await +} + +async fn call_sender_identity() -> anyhow::Result<()> { + let forest = forest_client()?; + let coin = simple_coin().await?; + let sender_contract = contract_b().await?; + let with_coins = non_existent(0x21)?; + let without_coins = non_existent(0x22)?; + let recipient = non_existent(0x01)?; + + for to in [sender_contract.eth, with_coins] { + invoke(&coin.f4, &send_coin_calldata(to, 100)).await?; + } + + let spend = send_coin_calldata(recipient, 10); + for (label, from, want) in [ + ("contract from", sender_contract.eth, 1u8), + ("credited missing from", with_coins, 1), + ("uncounted missing from", without_coins, 0), + ] { + let ret = eth_call(&forest, from, coin.eth, spend.clone(), latest()) + .await + .with_context(|| format!("eth_call sendCoin from {label}"))?; + ensure!( + ret.0.len() == 32, + "{label}: sendCoin return must be a 32-byte ABI bool, got {} bytes", + ret.0.len() + ); + ensure!( + ret.0.last() == Some(&want), + "{label}: callee must observe the requested from as msg.sender (want {want}, got {ret:?})" + ); + } + Ok(()) +} + +async fn isolation_call( + forest: &Client, + from: EthAddress, + to: EthAddress, + block: BlockNumberOrHash, +) -> anyhow::Result { + eth_call(forest, from, to, Vec::new(), block).await +} + +async fn skip_sender_state_isolation() -> anyhow::Result<()> { + let forest = forest_client()?; + let to = EthAddress::from_filecoin_address(deployer().await?)?; + let from = non_existent(0x03)?; + + let first = isolation_call(&forest, from, to, latest()).await?; + let second = isolation_call(&forest, from, to, latest()).await?; + ensure!( + first == second, + "repeated skip-sender eth_call results must match" + ); + + let fil = from.to_filecoin_address()?; + ensure!( + get_actor(&forest, fil).await?.is_none(), + "skip-sender eth_call must not persist an actor for {fil}" + ); + + let mut futs = Vec::with_capacity(8); + for _ in 0..8 { + futs.push(async move { + let client = forest_client()?; + isolation_call(&client, from, to, latest()).await + }); + } + let concurrent = futures::future::try_join_all(futs).await?; + for (i, got) in concurrent.iter().enumerate() { + ensure!( + *got == first, + "concurrent skip-sender eth_call {i} diverged from the first result" + ); + } + Ok(()) +} + +async fn skip_sender_historical_call() -> anyhow::Result<()> { + let forest = forest_client()?; + let to = EthAddress::from_filecoin_address(deployer().await?)?; + let from = non_existent(0x03)?; + let head = forest + .call(EthBlockNumber::request(())?) + .await + .map_err(|e| anyhow::anyhow!("{e:#}"))?; + ensure!( + head.0 > 2, + "devnet head {} is too low for a head-2 historical eth_call", + head.0 + ); + let hist = BlockNumberOrHash::from_block_number((head.0 - 2) as i64); + isolation_call(&forest, from, to, hist) + .await + .context("historical skip-sender eth_call at head-2")?; + Ok(()) +} + +async fn cross_contract_from_contract() -> anyhow::Result<()> { + let forest = forest_client()?; + let (a, b) = linked_contracts().await?; + assert_abi_u256( + eth_call( + &forest, + b.eth, + a.eth, + selector(CALL_B_AND_READ_BACK), + latest(), + ) + .await + .context("callBAndReadBack from contract from")?, + 42, + "cross-contract callback from contract from", + ) +} + +async fn cross_contract_from_missing() -> anyhow::Result<()> { + let forest = forest_client()?; + let (a, _b) = linked_contracts().await?; + assert_abi_u256( + eth_call( + &forest, + non_existent(0x10)?, + a.eth, + selector(CALL_B_AND_READ_BACK), + latest(), + ) + .await + .context("callBAndReadBack from missing from")?, + 42, + "cross-contract callback from missing from", + ) +} + +async fn cross_contract_from_eoa() -> anyhow::Result<()> { + let forest = forest_client()?; + let (a, _b) = linked_contracts().await?; + let (_from_f4, from) = new_funded(ROUND_TRIP_FUND_AMT).await?; + assert_abi_u256( + eth_call( + &forest, + from, + a.eth, + selector(CALL_B_AND_READ_BACK), + latest(), + ) + .await + .context("callBAndReadBack from EOA from")?, + 42, + "cross-contract callback from EOA from", + ) +} + +async fn cross_contract_double_callback() -> anyhow::Result<()> { + let forest = forest_client()?; + let (a, _b) = linked_contracts().await?; + assert_abi_u256( + eth_call( + &forest, + non_existent(0x11)?, + a.eth, + selector(CALL_B_AND_DOUBLE), + latest(), + ) + .await + .context("callBAndDouble from missing from")?, + 84, + "cross-contract double callback from missing from", + ) +} + +fn assert_abi_u256(ret: EthBytes, expected: u8, label: &str) -> anyhow::Result<()> { + ensure!( + ret.0.len() == 32, + "{label}: expected 32-byte ABI uint256, got {} bytes", + ret.0.len() + ); + ensure!( + ret.0.last() == Some(&expected), + "{label}: expected {expected}, got {ret:?}" + ); + Ok(()) +} From 02bf4e5f81abacaf802d74c7b904ada49f500a1c Mon Sep 17 00:00:00 2001 From: Shashank Date: Wed, 26 Aug 2026 06:22:48 +0530 Subject: [PATCH 2/4] fix test --- .../subcommands/devnet_cmd/eth_skip_sender.rs | 707 ++++++++---------- src/dev/subcommands/tests_cmd/helpers.rs | 20 +- 2 files changed, 328 insertions(+), 399 deletions(-) diff --git a/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs b/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs index c8d1b2de220..61197174e6a 100644 --- a/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs +++ b/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs @@ -139,29 +139,31 @@ fn abi_address_word(addr: EthAddress) -> Vec { word } -fn send_coin_calldata(to: EthAddress, amount: u64) -> Vec { - let mut out = selector(SEND_COIN_SIGNATURE); - out.extend(abi_address_word(to)); - out.extend_from_slice(ðereum_types::U256::from(amount).to_big_endian()); +fn calldata(sig: &str, extra: &[u8]) -> Vec { + let mut out = selector(sig); + out.extend_from_slice(extra); out } +fn send_coin_calldata(to: EthAddress, amount: u64) -> Vec { + let mut extra = abi_address_word(to); + extra.extend_from_slice(ðereum_types::U256::from(amount).to_big_endian()); + calldata(SEND_COIN_SIGNATURE, &extra) +} + fn set_contract_b_calldata(addr: EthAddress) -> Vec { - let mut out = selector(SET_CONTRACT_B_SIGNATURE); - out.extend(abi_address_word(addr)); - out + calldata(SET_CONTRACT_B_SIGNATURE, &abi_address_word(addr)) } fn recurse_calldata(depth: u64) -> Vec { - let mut out = selector(RECURSE_SIGNATURE); - out.extend_from_slice(ðereum_types::U256::from(depth).to_big_endian()); - out + calldata( + RECURSE_SIGNATURE, + ðereum_types::U256::from(depth).to_big_endian(), + ) } fn get_balance_calldata(addr: EthAddress) -> Vec { - let mut out = selector(GET_BALANCE_SIGNATURE); - out.extend(abi_address_word(addr)); - out + calldata(GET_BALANCE_SIGNATURE, &abi_address_word(addr)) } fn simple_coin_initcode() -> anyhow::Result { @@ -187,6 +189,13 @@ struct Deployed { f4: Address, } +/// Lotus `wallet new` string (`t4…`) plus parsed Filecoin and ETH forms. +struct Wallet { + cli: String, + f4: Address, + eth: EthAddress, +} + /// Dedicated delegated wallet used to deploy and to credit `SimpleCoin`. /// Not the genesis/miner key: that wallet races with the miner on nonce. async fn deployer() -> anyhow::Result<&'static Address> { @@ -194,16 +203,7 @@ async fn deployer() -> anyhow::Result<&'static Address> { DEPLOYER .get_or_try_init(|| async { let addr = lotus_exec(&["wallet", "new", "delegated"])?; - let msg = send_from( - &FOREST_TEST_PRELOADED_ADDRESS, - &addr, - DEPLOYER_FUND_AMT, - Backend::Local, - )?; - eprintln!("funding deployer {addr} with {DEPLOYER_FUND_AMT}, msg: {msg}"); - let balance = poll_until_funded(&addr, Backend::Local).await?; - eprintln!("deployer {addr} funded balance: {balance}"); - Address::from_str(&addr).context("parsing the deployer address") + fund_on_chain(&addr, DEPLOYER_FUND_AMT).await }) .await } @@ -278,13 +278,13 @@ async fn table_env() -> anyhow::Result<&'static TableEnv> { ENV.get_or_try_init(|| async { let coin = simple_coin().await?; let errors = errors_contract().await?; - let (_eoa_f4, eoa) = new_funded(EOA_FUND_AMT).await?; - let (_eoa2_f4, eoa2) = new_unfunded().await?; + let eoa = new_funded(EOA_FUND_AMT).await?; + let eoa2 = new_unfunded().await?; Ok(TableEnv { coin: coin.eth, errors: errors.eth, - eoa, - eoa2, + eoa: eoa.eth, + eoa2: eoa2.eth, }) }) .await @@ -298,40 +298,66 @@ async fn linked_contracts() -> anyhow::Result<&'static (Deployed, Deployed)> { let b = contract_b().await?; let a = deploy_hex("ContractA", CONTRACT_A_HEX, "/tmp/contract_a.hex").await?; invoke(&a.f4, &set_contract_b_calldata(b.eth)).await?; - Ok(( - a, - Deployed { - eth: b.eth, - f4: b.f4, - }, - )) + Ok((a, *b)) }) .await } async fn get_actor(client: &Client, addr: Address) -> anyhow::Result> { - client + match client .call(StateGetActor::request((addr, ApiTipsetKey(None)))?) .await - .map_err(|e| anyhow::anyhow!("{e:#}")) + { + Ok(actor) => Ok(actor), + Err(e) + if ["actor not found", "resolution lookup failed"] + .iter() + .any(|s| format!("{e:#}").contains(s)) => + { + Ok(None) + } + Err(e) => Err(anyhow::anyhow!("{e:#}")), + } } async fn poll_until_actor(addr: Address) -> anyhow::Result { - let client = forest_client()?; - let started = tokio::time::Instant::now(); - let mut attempt = 0u32; - loop { - attempt += 1; - eprintln!("Polling StateGetActor {addr} attempt {attempt}"); - if let Some(actor) = get_actor(&client, addr).await? { - return Ok(actor); - } - if started.elapsed() >= POLL_TIMEOUT { - anyhow::bail!("Timed out waiting for actor {addr} after {POLL_TIMEOUT:?}"); - } - let remaining = POLL_TIMEOUT.saturating_sub(started.elapsed()); - tokio::time::sleep(POLL_WAIT_TIME.min(remaining)).await; - } + poll_until_actor_on("forest", addr, forest_client).await +} + +/// The miner only talks to Lotus, so `lotus send` / `lotus evm deploy --from` +/// fail with `actor not found` until Lotus's state has the Forest-funded sender. +async fn poll_until_lotus_actor(addr: Address) -> anyhow::Result { + poll_until_actor_on("lotus", addr, lotus_client).await +} + +async fn poll_until_actor_on( + node: &str, + addr: Address, + make_client: fn() -> anyhow::Result, +) -> anyhow::Result { + poll(&format!("{node} StateGetActor {addr}"), || async { + get_actor(&make_client()?, addr).await + }) + .await +} + +/// Fund `cli_addr` (Lotus `t4…` form) from the harness wallet, then wait until +/// both Forest and Lotus see the actor. Lotus visibility is required before any +/// `lotus --from`. Pass the Lotus string into `forest-wallet`; it rejects Forest +/// `Address::to_string()` (`f4…`) while `CurrentNetwork` stays Mainnet. +async fn fund_on_chain(cli_addr: &str, amount: &str) -> anyhow::Result
{ + let addr = Address::from_str(cli_addr).context("parsing funded delegated address")?; + let msg = send_from( + &FOREST_TEST_PRELOADED_ADDRESS, + cli_addr, + amount, + Backend::Local, + )?; + eprintln!("funding {cli_addr} with {amount}, msg: {msg}"); + let balance = poll_until_funded(cli_addr, Backend::Local).await?; + eprintln!("{cli_addr} funded on forest, balance: {balance}"); + poll_until_lotus_actor(addr).await?; + Ok(addr) } async fn wait_for_cid(forest: &Client, cid: Cid) -> anyhow::Result<()> { @@ -347,78 +373,57 @@ async fn wait_for_cid(forest: &Client, cid: Cid) -> anyhow::Result<()> { Ok(()) } -async fn invoke(to: &Address, calldata: &[u8]) -> anyhow::Result { +async fn lotus_send( + from: &Address, + to: &Address, + calldata: &[u8], + gas_limit: Option, +) -> anyhow::Result { let forest = forest_client()?; - let from = deployer().await?.to_string(); + let from_s = from.to_string(); let to_s = to.to_string(); let params = hex::encode(calldata); - let out = - lotus_exec_retrying_mpool(&["send", "--from", &from, "--params-hex", ¶ms, &to_s, "0"]) - .await?; + let gas = gas_limit.map(|g| g.to_string()); + let mut args = vec![ + "send", + "--from", + from_s.as_str(), + "--params-hex", + params.as_str(), + ]; + if let Some(gas) = gas.as_deref() { + args.extend(["--gas-limit", gas]); + } + args.extend([to_s.as_str(), "0"]); + let out = lotus_exec_retrying_mpool(&args).await?; let cid = Cid::from_str( out.lines() .last() .context("no cid from `lotus send`")? .trim(), )?; - wait_for_cid(&forest, cid).await?; + if let Some(limit) = gas_limit { + eprintln!("submitted at estimate {limit}: {cid}"); + wait_for_cid(&forest, cid) + .await + .with_context(|| format!("transaction submitted at eth_estimateGas {limit} failed"))?; + } else { + wait_for_cid(&forest, cid).await?; + } Ok(cid) } +async fn invoke(to: &Address, calldata: &[u8]) -> anyhow::Result { + lotus_send(deployer().await?, to, calldata, None).await +} + async fn submit_at_gas_limit( from: &Address, to: &Address, calldata: &[u8], gas_limit: u64, ) -> anyhow::Result<()> { - let forest = forest_client()?; - let from_s = from.to_string(); - let to_s = to.to_string(); - let params = hex::encode(calldata); - let gas = gas_limit.to_string(); - let out = lotus_exec_retrying_mpool(&[ - "send", - "--from", - &from_s, - "--params-hex", - ¶ms, - "--gas-limit", - &gas, - &to_s, - "0", - ]) - .await?; - let cid = Cid::from_str( - out.lines() - .last() - .context("no cid from `lotus send`")? - .trim(), - )?; - eprintln!("submitted at estimate {gas_limit}: {cid}"); - wait_for_cid(&forest, cid).await?; - let hash = forest - .call(EthGetTransactionHashByCid::request((cid,))?) - .await - .map_err(|e| anyhow::anyhow!("{e:#}"))? - .context("no Ethereum transaction hash for submitted CID")?; - let receipt = forest - .call(EthGetTransactionReceipt::request((hash,))?) - .await - .map_err(|e| anyhow::anyhow!("{e:#}"))? - .context("no Ethereum receipt for submitted transaction")?; - // `EthTxReceipt` fields are module-private; read `status` through serde. - #[derive(serde::Deserialize)] - #[serde(rename_all = "camelCase")] - struct ReceiptStatus { - status: crate::rpc::eth::EthUint64, - } - let status = serde_json::from_value::(serde_json::to_value(&receipt)?)? - .status - .0; - ensure!( - status == 1, - "estimated gas limit {gas_limit} must be sufficient; receipt status is {status}" - ); + lotus_send(from, to, calldata, Some(gas_limit)).await?; Ok(()) } @@ -451,6 +456,7 @@ fn rpc_data(obj: &jsonrpsee::types::ErrorObjectOwned) -> Option { .or_else(|| Some(raw.get().trim_matches('"').to_string())) } +#[derive(Clone)] enum Expect { Success, SuccessGas, @@ -469,6 +475,44 @@ struct SkipSenderCase { estimate: Option, } +impl SkipSenderCase { + fn success(name: &'static str, msg: EthCallMessage) -> Self { + Self { + name, + msg, + call: Some(Expect::Success), + estimate: Some(Expect::SuccessGas), + } + } + + fn err_both(name: &'static str, msg: EthCallMessage, needle: &'static str) -> Self { + Self { + name, + msg, + call: Some(Expect::ErrContains(needle)), + estimate: Some(Expect::ErrContains(needle)), + } + } + + fn revert_both(name: &'static str, msg: EthCallMessage, expect: Expect) -> Self { + Self { + name, + msg, + call: Some(expect.clone()), + estimate: Some(expect), + } + } + + fn call_only(name: &'static str, msg: EthCallMessage, expect: Expect) -> Self { + Self { + name, + msg, + call: Some(expect), + estimate: None, + } + } +} + fn fil(whole: u64) -> EthBigInt { EthBigInt::from(TokenAmount::from_whole(whole)) } @@ -478,240 +522,156 @@ fn skip_sender_cases(env: &TableEnv) -> anyhow::Result> { let missing = non_existent(0x01)?; let gas_price = Some(EthBigInt::from(GAS_PRICE)); let custom = hex::encode(selector("CustomError()")); + let revert_empty = Expect::Reverted { + msg: "none", + data_contains: None, + data_eq: Some("0x"), + }; let transfer = |from: Option, to: Option| EthCallMessage { from, to, ..Default::default() }; + let errors_from_eoa = |sig: &'static str| EthCallMessage { + from: Some(env.eoa), + to: Some(env.errors), + data: Some(EthBytes(selector(sig))), + ..Default::default() + }; Ok(vec![ - SkipSenderCase { - name: "CreateFromContract", - msg: EthCallMessage { + SkipSenderCase::err_both( + "CreateFromContract", + EthCallMessage { from: Some(env.coin), to: None, data: Some(initcode.clone()), ..Default::default() }, - call: Some(Expect::ErrContains("disallowed caller")), - estimate: Some(Expect::ErrContains("disallowed caller")), - }, - SkipSenderCase { - name: "CreateFromNonExistent", - msg: EthCallMessage { + "disallowed caller", + ), + SkipSenderCase::success( + "CreateFromNonExistent", + EthCallMessage { from: Some(missing), to: None, data: Some(initcode), ..Default::default() }, - call: Some(Expect::Success), - estimate: Some(Expect::SuccessGas), - }, - SkipSenderCase { - name: "FromContract", - msg: transfer(Some(env.coin), Some(env.eoa)), - call: Some(Expect::Success), - estimate: Some(Expect::SuccessGas), - }, - SkipSenderCase { - name: "FromContractWithGasPrice", - msg: EthCallMessage { + ), + SkipSenderCase::success("FromContract", transfer(Some(env.coin), Some(env.eoa))), + SkipSenderCase::success( + "FromContractWithGasPrice", + EthCallMessage { from: Some(env.coin), to: Some(env.eoa), gas_price, ..Default::default() }, - call: Some(Expect::Success), - estimate: Some(Expect::SuccessGas), - }, - SkipSenderCase { - name: "FromContractToSelf", - msg: EthCallMessage { + ), + SkipSenderCase::success( + "FromContractToSelf", + EthCallMessage { from: Some(env.coin), to: Some(env.coin), data: Some(EthBytes(get_balance_calldata(env.coin))), ..Default::default() }, - call: Some(Expect::Success), - estimate: Some(Expect::SuccessGas), - }, - SkipSenderCase { - name: "FromContractWithValue", - msg: EthCallMessage { + ), + SkipSenderCase::err_both( + "FromContractWithValue", + EthCallMessage { from: Some(env.coin), to: Some(env.eoa), value: Some(fil(1)), ..Default::default() }, - call: Some(Expect::ErrContains("insufficient")), - estimate: Some(Expect::ErrContains("insufficient")), - }, - SkipSenderCase { - name: "FromNonExistent", - msg: transfer(Some(missing), Some(env.eoa)), - call: Some(Expect::Success), - estimate: Some(Expect::SuccessGas), - }, - SkipSenderCase { - name: "FromNonExistentWithGasPrice", - msg: EthCallMessage { + "insufficient", + ), + SkipSenderCase::success("FromNonExistent", transfer(Some(missing), Some(env.eoa))), + SkipSenderCase::success( + "FromNonExistentWithGasPrice", + EthCallMessage { from: Some(missing), to: Some(env.eoa), gas_price, ..Default::default() }, - call: Some(Expect::Success), - estimate: Some(Expect::SuccessGas), - }, - SkipSenderCase { - name: "FromNonExistentToContractWithData", - msg: EthCallMessage { + ), + SkipSenderCase::revert_both( + "FromNonExistentToContractWithData", + EthCallMessage { from: Some(missing), to: Some(env.errors), data: Some(EthBytes(selector(FAIL_REVERT_EMPTY))), ..Default::default() }, - call: Some(Expect::Reverted { - msg: "none", - data_contains: None, - data_eq: Some("0x"), - }), - estimate: Some(Expect::Reverted { - msg: "none", - data_contains: None, - data_eq: Some("0x"), - }), - }, - SkipSenderCase { - name: "FromNonExistentWithValue", - msg: EthCallMessage { + revert_empty.clone(), + ), + SkipSenderCase::call_only( + "FromNonExistentWithValue", + EthCallMessage { from: Some(missing), to: Some(env.eoa), value: Some(fil(1)), ..Default::default() }, - call: Some(Expect::ErrContains("insufficient")), - estimate: None, - }, - SkipSenderCase { - name: "FromEOA", - msg: transfer(Some(env.eoa), Some(env.eoa2)), - call: Some(Expect::Success), - estimate: Some(Expect::SuccessGas), - }, - SkipSenderCase { - name: "FromNil", - msg: transfer(None, Some(env.eoa)), - call: Some(Expect::Success), - estimate: None, - }, - SkipSenderCase { - name: "ValueOverBalance", - msg: EthCallMessage { + Expect::ErrContains("insufficient"), + ), + SkipSenderCase::success("FromEOA", transfer(Some(env.eoa), Some(env.eoa2))), + SkipSenderCase::call_only("FromNil", transfer(None, Some(env.eoa)), Expect::Success), + SkipSenderCase::call_only( + "ValueOverBalance", + EthCallMessage { from: Some(env.eoa), to: Some(missing), value: Some(fil(11)), ..Default::default() }, - call: Some(Expect::ErrContains("insufficient")), - estimate: None, - }, - SkipSenderCase { - name: "RevertDivideByZero", - msg: EthCallMessage { - from: Some(env.eoa), - to: Some(env.errors), - data: Some(EthBytes(selector(FAIL_DIV_ZERO))), - ..Default::default() - }, - call: Some(Expect::Reverted { - msg: "DivideByZero", - data_contains: Some(PANIC_DIV_ZERO.to_string()), - data_eq: None, - }), - estimate: Some(Expect::Reverted { + Expect::ErrContains("insufficient"), + ), + SkipSenderCase::revert_both( + "RevertDivideByZero", + errors_from_eoa(FAIL_DIV_ZERO), + Expect::Reverted { msg: "DivideByZero", data_contains: Some(PANIC_DIV_ZERO.to_string()), data_eq: None, - }), - }, - SkipSenderCase { - name: "RevertAssert", - msg: EthCallMessage { - from: Some(env.eoa), - to: Some(env.errors), - data: Some(EthBytes(selector(FAIL_ASSERT))), - ..Default::default() }, - call: Some(Expect::Reverted { + ), + SkipSenderCase::revert_both( + "RevertAssert", + errors_from_eoa(FAIL_ASSERT), + Expect::Reverted { msg: "Assert", data_contains: Some(PANIC_ASSERT.to_string()), data_eq: None, - }), - estimate: Some(Expect::Reverted { - msg: "Assert", - data_contains: Some(PANIC_ASSERT.to_string()), - data_eq: None, - }), - }, - SkipSenderCase { - name: "RevertWithReason", - msg: EthCallMessage { - from: Some(env.eoa), - to: Some(env.errors), - data: Some(EthBytes(selector(FAIL_REVERT_REASON))), - ..Default::default() }, - call: Some(Expect::Reverted { + ), + SkipSenderCase::revert_both( + "RevertWithReason", + errors_from_eoa(FAIL_REVERT_REASON), + Expect::Reverted { msg: "my reason", data_contains: None, data_eq: None, - }), - estimate: Some(Expect::Reverted { - msg: "my reason", - data_contains: None, - data_eq: None, - }), - }, - SkipSenderCase { - name: "RevertEmpty", - msg: EthCallMessage { - from: Some(env.eoa), - to: Some(env.errors), - data: Some(EthBytes(selector(FAIL_REVERT_EMPTY))), - ..Default::default() }, - call: Some(Expect::Reverted { - msg: "none", - data_contains: None, - data_eq: Some("0x"), - }), - estimate: Some(Expect::Reverted { - msg: "none", - data_contains: None, - data_eq: Some("0x"), - }), - }, - SkipSenderCase { - name: "RevertCustomError", - msg: EthCallMessage { - from: Some(env.eoa), - to: Some(env.errors), - data: Some(EthBytes(selector(FAIL_CUSTOM))), - ..Default::default() - }, - call: Some(Expect::Reverted { - msg: "", - data_contains: Some(custom.clone()), - data_eq: None, - }), - estimate: Some(Expect::Reverted { + ), + SkipSenderCase::revert_both( + "RevertEmpty", + errors_from_eoa(FAIL_REVERT_EMPTY), + revert_empty, + ), + SkipSenderCase::revert_both( + "RevertCustomError", + errors_from_eoa(FAIL_CUSTOM), + Expect::Reverted { msg: "", data_contains: Some(custom), data_eq: None, - }), - }, + }, + ), ]) } @@ -828,13 +788,13 @@ async fn estimate_gas_skip_sender() -> anyhow::Result<()> { async fn funded_placeholder_sender() -> anyhow::Result<()> { let forest = forest_client()?; - let (_from_f4, from) = new_funded(PLACEHOLDER_FUND_AMT).await?; - let (_to_f4, to) = new_funded(ROUND_TRIP_FUND_AMT).await?; + let from = new_funded(PLACEHOLDER_FUND_AMT).await?; + let to = new_funded(ROUND_TRIP_FUND_AMT).await?; eth_call_msg( &forest, EthCallMessage { - from: Some(from), - to: Some(to), + from: Some(from.eth), + to: Some(to.eth), value: Some(fil(1)), ..Default::default() }, @@ -870,16 +830,17 @@ async fn eth_call( data: Vec, block: BlockNumberOrHash, ) -> anyhow::Result { - let msg = EthCallMessage { - from: Some(from), - to: Some(to), - data: (!data.is_empty()).then_some(EthBytes(data)), - ..Default::default() - }; - client - .call(EthCall::request((msg, block))?) - .await - .map_err(|e| anyhow::anyhow!("{e:#}")) + eth_call_msg( + client, + EthCallMessage { + from: Some(from), + to: Some(to), + data: (!data.is_empty()).then_some(EthBytes(data)), + ..Default::default() + }, + block, + ) + .await } fn within_parity(skip: u64, funded: u64) -> bool { @@ -887,24 +848,17 @@ fn within_parity(skip: u64, funded: u64) -> bool { (skip as f64 - funded as f64).abs() / denom <= ESTIMATE_PARITY } -async fn new_unfunded() -> anyhow::Result<(Address, EthAddress)> { - let addr = lotus_exec(&["wallet", "new", "delegated"])?; - let f4 = Address::from_str(&addr).context("parsing unfunded delegated address")?; +async fn new_unfunded() -> anyhow::Result { + let cli = lotus_exec(&["wallet", "new", "delegated"])?; + let f4 = Address::from_str(&cli).context("parsing unfunded delegated address")?; let eth = EthAddress::from_filecoin_address(&f4)?; - Ok((f4, eth)) + Ok(Wallet { cli, f4, eth }) } -async fn new_funded(amount: &str) -> anyhow::Result<(Address, EthAddress)> { - let (f4, eth) = new_unfunded().await?; - let msg = send_from( - &FOREST_TEST_PRELOADED_ADDRESS, - &f4.to_string(), - amount, - Backend::Local, - )?; - eprintln!("funding placeholder {f4} with {amount}, msg: {msg}"); - poll_until_funded(&f4.to_string(), Backend::Local).await?; - Ok((f4, eth)) +async fn new_funded(amount: &str) -> anyhow::Result { + let wallet = new_unfunded().await?; + fund_on_chain(&wallet.cli, amount).await?; + Ok(wallet) } async fn round_trip_from_unfunded() -> anyhow::Result<()> { @@ -913,36 +867,31 @@ async fn round_trip_from_unfunded() -> anyhow::Result<()> { let recipient = non_existent(0x01)?; let calldata = send_coin_calldata(recipient, 0); - let (from_f4, from_eth) = new_unfunded().await?; - let gas = estimate_gas(&forest, from_eth, coin.eth, calldata.clone()) + let from = new_unfunded().await?; + let gas = estimate_gas(&forest, from.eth, coin.eth, calldata.clone()) .await .context("eth_estimateGas from unfunded sender")?; - eprintln!("skip-sender estimate {gas} from {from_f4}"); + eprintln!("skip-sender estimate {gas} from {}", from.cli); ensure!( - get_actor(&forest, from_f4).await?.is_none(), - "ephemeral placeholder for {from_f4} leaked onto chain during estimate" + get_actor(&forest, from.f4).await?.is_none(), + "ephemeral placeholder for {} leaked onto chain during estimate", + from.f4 ); - let fund_msg = send_from( - &FOREST_TEST_PRELOADED_ADDRESS, - &from_f4.to_string(), - ROUND_TRIP_FUND_AMT, - Backend::Local, - )?; - eprintln!("funding round-trip sender {from_f4}, msg: {fund_msg}"); - poll_until_funded(&from_f4.to_string(), Backend::Local).await?; - let actor = poll_until_actor(from_f4).await?; + fund_on_chain(&from.cli, ROUND_TRIP_FUND_AMT).await?; + let actor = poll_until_actor(from.f4).await?; ensure!( actor.sequence == 0, - "pre-submit nonce of {from_f4} is {}, expected 0 (placeholder must not have incremented it)", + "pre-submit nonce of {} is {}, expected 0 (placeholder must not have incremented it)", + from.f4, actor.sequence ); - submit_at_gas_limit(&from_f4, &coin.f4, &calldata, gas).await?; - let after = get_actor(&forest, from_f4) + submit_at_gas_limit(&from.f4, &coin.f4, &calldata, gas).await?; + let after = get_actor(&forest, from.f4) .await? - .with_context(|| format!("actor {from_f4} missing after successful submit"))?; + .with_context(|| format!("actor {} missing after successful submit", from.f4))?; ensure!( after.sequence == 1, "successful tx must use nonce 0; on-chain nonce is now {}", @@ -959,8 +908,8 @@ async fn parity_with_existing_sender() -> anyhow::Result<()> { let skip = estimate_gas(&forest, non_existent(0x42)?, coin.eth, calldata.clone()) .await .context("eth_estimateGas from missing from")?; - let (_placeholder, placeholder_eth) = new_funded(ROUND_TRIP_FUND_AMT).await?; - let funded = estimate_gas(&forest, placeholder_eth, coin.eth, calldata) + let placeholder = new_funded(ROUND_TRIP_FUND_AMT).await?; + let funded = estimate_gas(&forest, placeholder.eth, coin.eth, calldata) .await .context("eth_estimateGas from funded placeholder")?; eprintln!("parity skip={skip} funded={funded}"); @@ -976,13 +925,13 @@ async fn round_trip_recursive() -> anyhow::Result<()> { let nested = nested_gas().await?; let calldata = recurse_calldata(NESTED_DEPTH); - let (from_f4, from_eth) = new_unfunded().await?; - let gas = estimate_gas(&forest, from_eth, nested.eth, calldata.clone()) + let from = new_unfunded().await?; + let gas = estimate_gas(&forest, from.eth, nested.eth, calldata.clone()) .await .context("skip-sender eth_estimateGas recurse(100)")?; - let (_placeholder, placeholder_eth) = new_funded(RECURSIVE_FUND_AMT).await?; - let funded = estimate_gas(&forest, placeholder_eth, nested.eth, calldata.clone()) + let placeholder = new_funded(RECURSIVE_FUND_AMT).await?; + let funded = estimate_gas(&forest, placeholder.eth, nested.eth, calldata.clone()) .await .context("funded-placeholder eth_estimateGas recurse(100)")?; eprintln!("recursive skip={gas} funded={funded}"); @@ -991,15 +940,8 @@ async fn round_trip_recursive() -> anyhow::Result<()> { "recursive skip-sender estimate {gas} vs funded-placeholder {funded} exceeds 10%" ); - let fund_msg = send_from( - &FOREST_TEST_PRELOADED_ADDRESS, - &from_f4.to_string(), - RECURSIVE_FUND_AMT, - Backend::Local, - )?; - eprintln!("funding recursive sender {from_f4}, msg: {fund_msg}"); - poll_until_funded(&from_f4.to_string(), Backend::Local).await?; - submit_at_gas_limit(&from_f4, &nested.f4, &calldata, gas).await + fund_on_chain(&from.cli, RECURSIVE_FUND_AMT).await?; + submit_at_gas_limit(&from.f4, &nested.f4, &calldata, gas).await } async fn call_sender_identity() -> anyhow::Result<()> { @@ -1036,22 +978,13 @@ async fn call_sender_identity() -> anyhow::Result<()> { Ok(()) } -async fn isolation_call( - forest: &Client, - from: EthAddress, - to: EthAddress, - block: BlockNumberOrHash, -) -> anyhow::Result { - eth_call(forest, from, to, Vec::new(), block).await -} - async fn skip_sender_state_isolation() -> anyhow::Result<()> { let forest = forest_client()?; let to = EthAddress::from_filecoin_address(deployer().await?)?; let from = non_existent(0x03)?; - let first = isolation_call(&forest, from, to, latest()).await?; - let second = isolation_call(&forest, from, to, latest()).await?; + let first = eth_call(&forest, from, to, Vec::new(), latest()).await?; + let second = eth_call(&forest, from, to, Vec::new(), latest()).await?; ensure!( first == second, "repeated skip-sender eth_call results must match" @@ -1067,7 +1000,7 @@ async fn skip_sender_state_isolation() -> anyhow::Result<()> { for _ in 0..8 { futs.push(async move { let client = forest_client()?; - isolation_call(&client, from, to, latest()).await + eth_call(&client, from, to, Vec::new(), latest()).await }); } let concurrent = futures::future::try_join_all(futs).await?; @@ -1094,83 +1027,69 @@ async fn skip_sender_historical_call() -> anyhow::Result<()> { head.0 ); let hist = BlockNumberOrHash::from_block_number((head.0 - 2) as i64); - isolation_call(&forest, from, to, hist) + eth_call(&forest, from, to, Vec::new(), hist) .await .context("historical skip-sender eth_call at head-2")?; Ok(()) } -async fn cross_contract_from_contract() -> anyhow::Result<()> { +async fn assert_call_b( + from: EthAddress, + sig: &str, + expected: u8, + label: &str, +) -> anyhow::Result<()> { let forest = forest_client()?; - let (a, b) = linked_contracts().await?; + let (a, _) = linked_contracts().await?; assert_abi_u256( - eth_call( - &forest, - b.eth, - a.eth, - selector(CALL_B_AND_READ_BACK), - latest(), - ) - .await - .context("callBAndReadBack from contract from")?, + eth_call(&forest, from, a.eth, selector(sig), latest()) + .await + .with_context(|| label.to_string())?, + expected, + label, + ) +} + +async fn cross_contract_from_contract() -> anyhow::Result<()> { + let (_, b) = linked_contracts().await?; + assert_call_b( + b.eth, + CALL_B_AND_READ_BACK, 42, "cross-contract callback from contract from", ) + .await } async fn cross_contract_from_missing() -> anyhow::Result<()> { - let forest = forest_client()?; - let (a, _b) = linked_contracts().await?; - assert_abi_u256( - eth_call( - &forest, - non_existent(0x10)?, - a.eth, - selector(CALL_B_AND_READ_BACK), - latest(), - ) - .await - .context("callBAndReadBack from missing from")?, + assert_call_b( + non_existent(0x10)?, + CALL_B_AND_READ_BACK, 42, "cross-contract callback from missing from", ) + .await } async fn cross_contract_from_eoa() -> anyhow::Result<()> { - let forest = forest_client()?; - let (a, _b) = linked_contracts().await?; - let (_from_f4, from) = new_funded(ROUND_TRIP_FUND_AMT).await?; - assert_abi_u256( - eth_call( - &forest, - from, - a.eth, - selector(CALL_B_AND_READ_BACK), - latest(), - ) - .await - .context("callBAndReadBack from EOA from")?, + let from = new_funded(ROUND_TRIP_FUND_AMT).await?; + assert_call_b( + from.eth, + CALL_B_AND_READ_BACK, 42, "cross-contract callback from EOA from", ) + .await } async fn cross_contract_double_callback() -> anyhow::Result<()> { - let forest = forest_client()?; - let (a, _b) = linked_contracts().await?; - assert_abi_u256( - eth_call( - &forest, - non_existent(0x11)?, - a.eth, - selector(CALL_B_AND_DOUBLE), - latest(), - ) - .await - .context("callBAndDouble from missing from")?, + assert_call_b( + non_existent(0x11)?, + CALL_B_AND_DOUBLE, 84, "cross-contract double callback from missing from", ) + .await } fn assert_abi_u256(ret: EthBytes, expected: u8, label: &str) -> anyhow::Result<()> { diff --git a/src/dev/subcommands/tests_cmd/helpers.rs b/src/dev/subcommands/tests_cmd/helpers.rs index dfcd97ea6e9..8caded475e5 100644 --- a/src/dev/subcommands/tests_cmd/helpers.rs +++ b/src/dev/subcommands/tests_cmd/helpers.rs @@ -145,7 +145,7 @@ const RPC_RETRY_DELAY: Duration = Duration::from_secs(15); /// Poll until `try_check` returns `Some` or [`POLL_TIMEOUT`] elapses, sleeping /// [`POLL_WAIT_TIME`] between attempts. -async fn poll(label: &str, mut try_check: F) -> anyhow::Result +pub async fn poll(label: &str, mut try_check: F) -> anyhow::Result where F: FnMut() -> Fut, Fut: Future>>, @@ -186,14 +186,24 @@ pub async fn poll_until_funded(address: &str, backend: Backend) -> anyhow::Resul poll_until_changed(address, FIL_ZERO, backend).await } -/// Run a `lotus` command, retrying while it fails with the transient mpool `check has failed` -/// (the mpool briefly lags the chain head just after the sender is funded, so a submit can be -/// rejected until it catches up). Any other failure propagates immediately. +/// Run a `lotus` command, retrying while it fails because Lotus's mpool has not yet +/// caught up to a sender that was just funded (the miner talks to Lotus, not Forest). +/// Any other failure propagates immediately. pub async fn lotus_exec_retrying_mpool(args: &[&str]) -> anyhow::Result { poll(&format!("lotus {}", args.join(" ")), || async { match lotus_exec(args) { Ok(out) => Ok(Some(out)), - Err(e) if format!("{e:#}").contains("check has failed") => Ok(None), + Err(e) + if [ + "check has failed", + "failed to get nonce from mempool", + "resolution lookup failed", + ] + .iter() + .any(|s| format!("{e:#}").contains(s)) => + { + Ok(None) + } Err(e) => Err(e), } }) From 400d83a42f7a0f8eacef389ffe6540e20da3df9e Mon Sep 17 00:00:00 2001 From: Shashank Date: Thu, 27 Aug 2026 10:32:40 +0530 Subject: [PATCH 3/4] port contract comments --- .../devnet_cmd/contracts/contract_a/contract_a.hex | 2 +- .../devnet_cmd/contracts/contract_a/contract_a.sol | 10 ++++++++++ .../devnet_cmd/contracts/contract_b/contract_b.hex | 2 +- .../devnet_cmd/contracts/contract_b/contract_b.sol | 5 +++++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/dev/subcommands/devnet_cmd/contracts/contract_a/contract_a.hex b/src/dev/subcommands/devnet_cmd/contracts/contract_a/contract_a.hex index 3a3bcbc0b15..abf20f20dc5 100644 --- a/src/dev/subcommands/devnet_cmd/contracts/contract_a/contract_a.hex +++ b/src/dev/subcommands/devnet_cmd/contracts/contract_a/contract_a.hex @@ -1 +1 @@ -6080604052348015600e575f5ffd5b50602a5f81905550610506806100235f395ff3fe608060405234801561000f575f5ffd5b5060043610610086575f3560e01c8063893885081161005957806389388508146100fe5780639ff8a3681461011c578063a70c52381461013a578063d1accab81461015857610086565b8063209652551461008a578063224bc07f146100a857806355241077146100c45780636d619daa146100e0575b5f5ffd5b610092610176565b60405161009f9190610372565b60405180910390f35b6100c260048036038101906100bd91906103e9565b61017e565b005b6100de60048036038101906100d9919061043e565b6101c1565b005b6100e86101ca565b6040516100f59190610372565b60405180910390f35b6101066101cf565b6040516101139190610372565b60405180910390f35b61012461026e565b6040516101319190610478565b60405180910390f35b610142610293565b60405161014f9190610372565b60405180910390f35b610160610332565b60405161016d9190610478565b60405180910390f35b5f5f54905090565b8060015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555050565b805f8190555050565b5f5481565b5f60015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1663f2e2b693306040518263ffffffff1660e01b815260040161022a9190610478565b602060405180830381865afa158015610245573d5f5f3e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061026991906104a5565b905090565b60015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b5f60015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff166317dbce79306040518263ffffffff1660e01b81526004016102ee9190610478565b602060405180830381865afa158015610309573d5f5f3e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061032d91906104a5565b905090565b5f60015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b5f819050919050565b61036c8161035a565b82525050565b5f6020820190506103855f830184610363565b92915050565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6103b88261038f565b9050919050565b6103c8816103ae565b81146103d2575f5ffd5b50565b5f813590506103e3816103bf565b92915050565b5f602082840312156103fe576103fd61038b565b5b5f61040b848285016103d5565b91505092915050565b61041d8161035a565b8114610427575f5ffd5b50565b5f8135905061043881610414565b92915050565b5f602082840312156104535761045261038b565b5b5f6104608482850161042a565b91505092915050565b610472816103ae565b82525050565b5f60208201905061048b5f830184610469565b92915050565b5f8151905061049f81610414565b92915050565b5f602082840312156104ba576104b961038b565b5b5f6104c784828501610491565b9150509291505056fea2646970667358221220ef73361e3224e41ad642fd9aabefa2d060c222b31891f2d1ab94b16d02104e2c64736f6c634300081e0033 +6080604052348015600e575f5ffd5b50602a5f81905550610506806100235f395ff3fe608060405234801561000f575f5ffd5b5060043610610086575f3560e01c8063893885081161005957806389388508146100fe5780639ff8a3681461011c578063a70c52381461013a578063d1accab81461015857610086565b8063209652551461008a578063224bc07f146100a857806355241077146100c45780636d619daa146100e0575b5f5ffd5b610092610176565b60405161009f9190610372565b60405180910390f35b6100c260048036038101906100bd91906103e9565b61017e565b005b6100de60048036038101906100d9919061043e565b6101c1565b005b6100e86101ca565b6040516100f59190610372565b60405180910390f35b6101066101cf565b6040516101139190610372565b60405180910390f35b61012461026e565b6040516101319190610478565b60405180910390f35b610142610293565b60405161014f9190610372565b60405180910390f35b610160610332565b60405161016d9190610478565b60405180910390f35b5f5f54905090565b8060015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555050565b805f8190555050565b5f5481565b5f60015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1663f2e2b693306040518263ffffffff1660e01b815260040161022a9190610478565b602060405180830381865afa158015610245573d5f5f3e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061026991906104a5565b905090565b60015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b5f60015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff166317dbce79306040518263ffffffff1660e01b81526004016102ee9190610478565b602060405180830381865afa158015610309573d5f5f3e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061032d91906104a5565b905090565b5f60015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b5f819050919050565b61036c8161035a565b82525050565b5f6020820190506103855f830184610363565b92915050565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6103b88261038f565b9050919050565b6103c8816103ae565b81146103d2575f5ffd5b50565b5f813590506103e3816103bf565b92915050565b5f602082840312156103fe576103fd61038b565b5b5f61040b848285016103d5565b91505092915050565b61041d8161035a565b8114610427575f5ffd5b50565b5f8135905061043881610414565b92915050565b5f602082840312156104535761045261038b565b5b5f6104608482850161042a565b91505092915050565b610472816103ae565b82525050565b5f60208201905061048b5f830184610469565b92915050565b5f8151905061049f81610414565b92915050565b5f602082840312156104ba576104b961038b565b5b5f6104c784828501610491565b9150509291505056fea264697066735822122072a88324061d97eecf334e91d04262f4de9c4bea40c9ae7f7b0bc5a6fbacf60364736f6c634300081e0033 diff --git a/src/dev/subcommands/devnet_cmd/contracts/contract_a/contract_a.sol b/src/dev/subcommands/devnet_cmd/contracts/contract_a/contract_a.sol index 3e83b7a24d3..860f800dc5a 100644 --- a/src/dev/subcommands/devnet_cmd/contracts/contract_a/contract_a.sol +++ b/src/dev/subcommands/devnet_cmd/contracts/contract_a/contract_a.sol @@ -3,11 +3,14 @@ pragma solidity =0.8.30; /// Lotus `itests/contracts/ContractA.sol`, pinned to the compiler this /// directory's `compile.sh` uses. Calls into ContractB, which calls back here. + +// Interface for ContractB interface IContractB { function callBackAndRead(address origin) external view returns (uint256); function callBackAndDouble(address origin) external view returns (uint256); } +// ContractA: Has state and can call ContractB which calls back contract ContractA { uint256 public storedValue; address public contractB; @@ -16,26 +19,33 @@ contract ContractA { storedValue = 42; } + // Sets the ContractB address (called after deployment) function setContractB(address _contractB) external { contractB = _contractB; } + // Simple view function that ContractB will call back function getValue() external view returns (uint256) { return storedValue; } + // Sets a new value function setValue(uint256 _value) external { storedValue = _value; } + // Calls ContractB, which calls back to this contract's getValue() + // This tests: A calls B, B calls A.getValue() (view) function callBAndReadBack() external view returns (uint256) { return IContractB(contractB).callBackAndRead(address(this)); } + // Calls ContractB, which reads and doubles our value function callBAndDouble() external view returns (uint256) { return IContractB(contractB).callBackAndDouble(address(this)); } + // Returns the ContractB address for verification function getContractB() external view returns (address) { return contractB; } diff --git a/src/dev/subcommands/devnet_cmd/contracts/contract_b/contract_b.hex b/src/dev/subcommands/devnet_cmd/contracts/contract_b/contract_b.hex index 52460e528a2..52deb745da1 100644 --- a/src/dev/subcommands/devnet_cmd/contracts/contract_b/contract_b.hex +++ b/src/dev/subcommands/devnet_cmd/contracts/contract_b/contract_b.hex @@ -1 +1 @@ -6080604052348015600e575f5ffd5b506103468061001c5f395ff3fe608060405234801561000f575f5ffd5b5060043610610034575f3560e01c806317dbce7914610038578063f2e2b69314610068575b5f5ffd5b610052600480360381019061004d91906101f1565b610098565b60405161005f9190610234565b60405180910390f35b610082600480360381019061007d91906101f1565b61010d565b60405161008f9190610234565b60405180910390f35b5f8173ffffffffffffffffffffffffffffffffffffffff1663209652556040518163ffffffff1660e01b8152600401602060405180830381865afa1580156100e2573d5f5f3e3d5ffd5b505050506040513d601f19601f820116820180604052508101906101069190610277565b9050919050565b5f5f8273ffffffffffffffffffffffffffffffffffffffff1663209652556040518163ffffffff1660e01b8152600401602060405180830381865afa158015610158573d5f5f3e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061017c9190610277565b905060028161018b91906102cf565b915050919050565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6101c082610197565b9050919050565b6101d0816101b6565b81146101da575f5ffd5b50565b5f813590506101eb816101c7565b92915050565b5f6020828403121561020657610205610193565b5b5f610213848285016101dd565b91505092915050565b5f819050919050565b61022e8161021c565b82525050565b5f6020820190506102475f830184610225565b92915050565b6102568161021c565b8114610260575f5ffd5b50565b5f815190506102718161024d565b92915050565b5f6020828403121561028c5761028b610193565b5b5f61029984828501610263565b91505092915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f6102d98261021c565b91506102e48361021c565b92508282026102f28161021c565b91508282048414831517610309576103086102a2565b5b509291505056fea264697066735822122082a9ef86dd8a7e9cd8c81b48d42e6afa5631353a17a8fcf68071ebd8f3052a7264736f6c634300081e0033 +6080604052348015600e575f5ffd5b506103468061001c5f395ff3fe608060405234801561000f575f5ffd5b5060043610610034575f3560e01c806317dbce7914610038578063f2e2b69314610068575b5f5ffd5b610052600480360381019061004d91906101f1565b610098565b60405161005f9190610234565b60405180910390f35b610082600480360381019061007d91906101f1565b61010d565b60405161008f9190610234565b60405180910390f35b5f8173ffffffffffffffffffffffffffffffffffffffff1663209652556040518163ffffffff1660e01b8152600401602060405180830381865afa1580156100e2573d5f5f3e3d5ffd5b505050506040513d601f19601f820116820180604052508101906101069190610277565b9050919050565b5f5f8273ffffffffffffffffffffffffffffffffffffffff1663209652556040518163ffffffff1660e01b8152600401602060405180830381865afa158015610158573d5f5f3e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061017c9190610277565b905060028161018b91906102cf565b915050919050565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6101c082610197565b9050919050565b6101d0816101b6565b81146101da575f5ffd5b50565b5f813590506101eb816101c7565b92915050565b5f6020828403121561020657610205610193565b5b5f610213848285016101dd565b91505092915050565b5f819050919050565b61022e8161021c565b82525050565b5f6020820190506102475f830184610225565b92915050565b6102568161021c565b8114610260575f5ffd5b50565b5f815190506102718161024d565b92915050565b5f6020828403121561028c5761028b610193565b5b5f61029984828501610263565b91505092915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f6102d98261021c565b91506102e48361021c565b92508282026102f28161021c565b91508282048414831517610309576103086102a2565b5b509291505056fea26469706673582212209f90fdacf5ee509e35cfe0923fc7e3e613a96f57ab595450e04777c0fe202c4064736f6c634300081e0033 diff --git a/src/dev/subcommands/devnet_cmd/contracts/contract_b/contract_b.sol b/src/dev/subcommands/devnet_cmd/contracts/contract_b/contract_b.sol index 650baf91a5f..436deb888d6 100644 --- a/src/dev/subcommands/devnet_cmd/contracts/contract_b/contract_b.sol +++ b/src/dev/subcommands/devnet_cmd/contracts/contract_b/contract_b.sol @@ -3,15 +3,20 @@ pragma solidity =0.8.30; /// Lotus `itests/contracts/ContractB.sol`, pinned to the compiler this /// directory's `compile.sh` uses. Callback target for ContractA skip-sender tests. + +// Interface for ContractA (used by ContractB to call back) interface IContractA { function getValue() external view returns (uint256); } +// ContractB: Receives a call and calls back to the origin contract's view function contract ContractB { + // Calls back to the provided contract address and reads its getValue() function callBackAndRead(address origin) external view returns (uint256) { return IContractA(origin).getValue(); } + // Calls back to origin, reads value, and returns double function callBackAndDouble(address origin) external view returns (uint256) { uint256 val = IContractA(origin).getValue(); return val * 2; From d322cd8211a840bbd13f7bad0855e0ad8c10ba6c Mon Sep 17 00:00:00 2001 From: Shashank Date: Thu, 27 Aug 2026 12:22:15 +0530 Subject: [PATCH 4/4] add out-of-gas test --- .../subcommands/devnet_cmd/eth_skip_sender.rs | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs b/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs index 61197174e6a..963907c2cd5 100644 --- a/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs +++ b/src/dev/subcommands/devnet_cmd/eth_skip_sender.rs @@ -10,11 +10,11 @@ //! nested `recurse`), estimate parity with a funded placeholder, `msg.sender` //! identity via `sendCoin`, skip-call state isolation, a historical `eth_call`, //! cross-contract callbacks, and the skip-sender success/error matrix (CREATE, -//! `gasPrice`, `FromNil`, `FromEOA`, value, revert data). +//! `gasPrice`, `FromNil`, `FromEOA`, value, revert data, out-of-gas). use crate::dev::subcommands::tests_cmd::helpers::*; use crate::rpc::Client; -use crate::rpc::eth::errors::EXECUTION_REVERTED_CODE; +use crate::rpc::eth::errors::{EXECUTION_REVERTED_CODE, OUT_OF_GAS_CODE}; use crate::rpc::eth::{ BlockNumberOrHash, EthBigInt, Predefined, types::{EthAddress, EthBytes, EthCallMessage}, @@ -67,6 +67,8 @@ const PANIC_ASSERT: &str = "4e487b710000000000000000000000000000000000000000000000000000000000000001"; const PANIC_DIV_ZERO: &str = "4e487b710000000000000000000000000000000000000000000000000000000000000012"; +/// `JUMPDEST PUSH1 0x00 JUMP` CREATE payload; constructor loops until `BLOCK_GAS_LIMIT`. +const OOG_INITCODE: &str = "5b600056"; /// Skip-sender integration tests that need a private chain with a miner #[derive(Debug, clap::Args)] @@ -172,6 +174,17 @@ fn simple_coin_initcode() -> anyhow::Result { )) } +fn oog_create(from: Option) -> anyhow::Result { + Ok(EthCallMessage { + from, + to: None, + data: Some(EthBytes( + hex::decode(OOG_INITCODE).context("decoding OOG initcode")?, + )), + ..Default::default() + }) +} + /// Missing eth address: `0xdeadbeef` then zeros, last byte `seed`. fn non_existent(seed: u8) -> anyhow::Result { EthAddress::from_str(&format!("0xdeadbeef{:030}{seed:02x}", 0)) @@ -466,6 +479,10 @@ enum Expect { data_contains: Option, data_eq: Option<&'static str>, }, + ErrCode { + code: i32, + contains: &'static str, + }, } struct SkipSenderCase { @@ -527,6 +544,10 @@ fn skip_sender_cases(env: &TableEnv) -> anyhow::Result> { data_contains: None, data_eq: Some("0x"), }; + let oog_err = Expect::ErrCode { + code: EXECUTION_REVERTED_CODE, + contains: "SysErrOutOfGas", + }; let transfer = |from: Option, to: Option| EthCallMessage { from, @@ -560,6 +581,20 @@ fn skip_sender_cases(env: &TableEnv) -> anyhow::Result> { ..Default::default() }, ), + SkipSenderCase::revert_both( + "OutOfGasFromNonExistent", + oog_create(Some(missing))?, + oog_err.clone(), + ), + SkipSenderCase { + name: "OutOfGasFromEoa", + msg: oog_create(Some(env.eoa))?, + call: Some(oog_err), + estimate: Some(Expect::ErrCode { + code: OUT_OF_GAS_CODE, + contains: "call ran out of gas", + }), + }, SkipSenderCase::success("FromContract", transfer(Some(env.coin), Some(env.eoa))), SkipSenderCase::success( "FromContractWithGasPrice", @@ -754,6 +789,25 @@ fn assert_expect( } Ok(()) } + Expect::ErrCode { code, contains } => { + let err = result.err().with_context(|| { + format!("{label}: expected error code {code}, but the call succeeded") + })?; + let obj = rpc_call_err(&err) + .with_context(|| format!("{label}: expected a JSON-RPC error, got {err:#}"))?; + ensure!( + obj.code() == *code, + "{label}: expected error code {code}, got {}: {}", + obj.code(), + obj.message() + ); + ensure!( + obj.message().contains(contains), + "{label}: error `{}` does not contain `{contains}`", + obj.message() + ); + Ok(()) + } } }