-
Notifications
You must be signed in to change notification settings - Fork 2
Description
Problem Description
When running npx hardhat test --network local, 22 out of 89 test cases fail. After raising the estimated gas, the result became:
Testing Approach:
- Multiply
estimateGas * 10 - Multiply
estimateGas * 1000
Results:
- Initial: 22 failures
estimateGas * 10: 17 failuresestimateGas * 1000: Still 17 failures
Conclusion: While increasing gas limits reduced some failures (from 22 to 17), it didn't solve all problems. This indicates not a simple out-of-gas error, but a deeper structural issue.
Env
local revive node: paritytech/polkadot-sdk#10166
test repo: https://github.com/papermoonio/v2-periphery-polkadot/tree/revm
The commit version of polkadot-sdk that used for the local network is: 947a492c685b9d5cf506d87cf70be1fabad744dd
How to reproduce
git clone https://github.com/papermoonio/v2-periphery-polkadot
cd v2-periphery-polkadot/
git checkout revm
npm install
# run test
npx hardhat test
# run test on local network
npx hardhat test --network localAnalysis
Identifying Failure Patterns
Common Characteristics of Failing Test Cases
** Failed Test Categories**:
-
Remove Liquidity ETH
removeLiquidityETH(Router01 & Router02)removeLiquidityWithPermit(Router01 & Router02)removeLiquidityETHWithPermit(Router01 & Router02)
-
Swap Tokens ↔ ETH
swapTokensForExactETH- happy path & amounts (Router01 & Router02)swapExactTokensForETH- happy path & amounts (Router01 & Router02)swapETHForExactTokens- happy path & amounts (Router01 & Router02)
-
Fee-on-Transfer with ETH
removeLiquidityETHSupportingFeeOnTransferTokens(Router02)removeLiquidityETHWithPermitSupportingFeeOnTransferTokens(Router02)swapExactETHForTokensSupportingFeeOnTransferTokens(Router02)swapExactTokensForETHSupportingFeeOnTransferTokens(Router02)
Through systematic analysis of failing test cases, discovered they all share these characteristics:
- Involve ETH outbound transfers - Transferring ETH from contracts
- Use WETH.withdraw() - Unwrapping WETH to native ETH
- Involve contract receive/fallback functions - ETH recipient is a contract address
Key Finding: Unidirectional Failures
✅ Successful operations:
- ETH → WETH (deposit)
- EOA → Contract ETH transfers
- ETH transfers using .call{value:}()
❌ Failed operations:
- WETH → ETH (withdraw)
Key Pattern: All failed transfers are outbound from contracts, and all use .transfer() method.
Contract Call Chain
Failing test cases involve this call chain:
User calls Router.removeLiquidityETH()
↓
Router calls WETH.withdraw(amountETH)
↓
WETH uses payable(router).transfer(amountETH) ← 2300 gas stipend
↓
Router.receive() executes: assert(msg.sender == WETH)
↓
Router calls TransferHelper.safeTransferETH(user, amountETH)
Failure Point: WETH.withdraw() → Router.receive() step
Hypothesis - Substrate fee charging system
Suspected that:
- when ETH transfer operations enter Substrate execution logic, they generate significant weight (Substrate's computational cost unit), and this weight is not accurately calculated in EVM's
estimateGas. receivein receiver contract that would execute some logic, while due to substrate has extra fee-charging items, it may exceed the gas limit.
Verify: Systematic Testing - eth-transfer-test Project
test repop: https://github.com/papermoonio/eth-transfer-test
how to build
git clone https://github.com/papermoonio/eth-transfer-test
cd eth-transfer-test
npm install
# run test
npx hardhat test
npx hardhat test --network localTest Design
Created three types of receiver contracts:
- DoNothingReceiver - Empty receive(), does nothing
- SimpleReceiver - receive() emits an event
- ComplexReceiver - receive() performs complex operations (state changes + events)
Testing three transfer methods:
.transfer()- Fixed 2300 gas stipend.send()- Fixed 2300 gas stipend, returns bool.call{value:}()- Forwards all remaining gas
Hardhat Network Test Results
npx hardhat testResult: ✅ 12/12 tests passing (100%)
❯ npx hardhat test
ETH Transfer Test Suite
1. transfer() Method Tests
✔ 1.1 transfer() → EOA
✔ 1.2 transfer() → DoNothingReceiver
✔ 1.3 transfer() → SimpleReceiver
✔ 1.4 transfer() → ComplexReceiver
2. send() Method Tests
✔ 2.1 send() → EOA
✔ 2.2 send() → DoNothingReceiver
✔ 2.3 send() → SimpleReceiver
✔ 2.4 send() → ComplexReceiver
3. call() Method Tests
✔ 3.1 call() → EOA
✔ 3.2 call() → DoNothingReceiver
✔ 3.3 call() → SimpleReceiver
✔ 3.4 call() → ComplexReceiver
12 passing (822ms)
Key Findings:
On Hardhat network, when transfer/send recipients are contracts with simple receive logic, they succeed; complex logic receivers may fail due to gas limits.
4.3 Local Network (Revive) Test Results
npx hardhat test --network localResult: ❌ 8 passing, 4 failing
ETH Transfer Test Suite
1. transfer() Method Tests
✔ 1.1 transfer() → EOA
1) 1.2 transfer() → DoNothingReceiver
2) 1.3 transfer() → SimpleReceiver
✔ 1.4 transfer() → ComplexReceiver
2. send() Method Tests
✔ 2.1 send() → EOA
3) 2.2 send() → DoNothingReceiver
4) 2.3 send() → SimpleReceiver
✔ 2.4 send() → ComplexReceiver
3. call() Method Tests
✔ 3.1 call() → EOA
✔ 3.2 call() → DoNothingReceiver
✔ 3.3 call() → SimpleReceiver
✔ 3.4 call() → ComplexReceiver
8 passing (501ms)
4 failing
1) ETH Transfer Test Suite
1. transfer() Method Tests
1.2 transfer() → DoNothingReceiver:
Error: cannot estimate gas; transaction may fail or may require manual gas limit [ See: https://links.ethers.org/v5-errors-UNPREDICTABLE_GAS_LIMIT ] (reason="execution reverted: ", method="estimateGas", transaction={"from":"0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac","to":"0x1377Ce7BadadB01a2D06Dc41f31e8B57d9882888","value":{"type":"BigNumber","hex":"0x016345785d8a0000"},"data":"0x636e082b000000000000000000000000ab7785d56697e65c2683c8121aac93d3a028ba95","accessList":null}, error={"name":"ProviderError","_stack":"ProviderError: execution reverted: \n at HttpProvider.request (/Users/suvi/Documents/paritytech/papermoon/code/eth-transfer-test/node_modules/hardhat/src/internal/core/providers/http.ts:107:21)\n at processTicksAndRejections (node:internal/process/task_queues:105:5)\n at EthersProviderWrapper.send (/Users/suvi/Documents/paritytech/papermoon/code/eth-transfer-test/node_modules/@nomiclabs/hardhat-ethers/src/internal/ethers-provider-wrapper.ts:13:20)","code":3,"_isProviderError":true,"data":"0x"}, code=UNPREDICTABLE_GAS_LIMIT, version=providers/5.8.0)
at Logger.makeError (node_modules/@ethersproject/logger/src.ts/index.ts:269:28)
at Logger.throwError (node_modules/@ethersproject/logger/src.ts/index.ts:281:20)
at checkError (node_modules/@ethersproject/providers/src.ts/json-rpc-provider.ts:78:20)
at EthersProviderWrapper.<anonymous> (node_modules/@ethersproject/providers/src.ts/json-rpc-provider.ts:642:20)
at step (node_modules/@ethersproject/providers/lib/json-rpc-provider.js:48:23)
at Object.throw (node_modules/@ethersproject/providers/lib/json-rpc-provider.js:29:53)
at rejected (node_modules/@ethersproject/providers/lib/json-rpc-provider.js:21:65)
at processTicksAndRejections (node:internal/process/task_queues:105:5)
2) ETH Transfer Test Suite
1. transfer() Method Tests
1.3 transfer() → SimpleReceiver:
Error: cannot estimate gas; transaction may fail or may require manual gas limit [ See: https://links.ethers.org/v5-errors-UNPREDICTABLE_GAS_LIMIT ] (reason="execution reverted: ", method="estimateGas", transaction={"from":"0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac","to":"0x1377Ce7BadadB01a2D06Dc41f31e8B57d9882888","value":{"type":"BigNumber","hex":"0x016345785d8a0000"},"data":"0x636e082b000000000000000000000000b5f73112516ebeb89c7ef67a507513b441ac28fa","accessList":null}, error={"name":"ProviderError","_stack":"ProviderError: execution reverted: \n at HttpProvider.request (/Users/suvi/Documents/paritytech/papermoon/code/eth-transfer-test/node_modules/hardhat/src/internal/core/providers/http.ts:107:21)\n at processTicksAndRejections (node:internal/process/task_queues:105:5)\n at EthersProviderWrapper.send (/Users/suvi/Documents/paritytech/papermoon/code/eth-transfer-test/node_modules/@nomiclabs/hardhat-ethers/src/internal/ethers-provider-wrapper.ts:13:20)","code":3,"_isProviderError":true,"data":"0x"}, code=UNPREDICTABLE_GAS_LIMIT, version=providers/5.8.0)
at Logger.makeError (node_modules/@ethersproject/logger/src.ts/index.ts:269:28)
at Logger.throwError (node_modules/@ethersproject/logger/src.ts/index.ts:281:20)
at checkError (node_modules/@ethersproject/providers/src.ts/json-rpc-provider.ts:78:20)
at EthersProviderWrapper.<anonymous> (node_modules/@ethersproject/providers/src.ts/json-rpc-provider.ts:642:20)
at step (node_modules/@ethersproject/providers/lib/json-rpc-provider.js:48:23)
at Object.throw (node_modules/@ethersproject/providers/lib/json-rpc-provider.js:29:53)
at rejected (node_modules/@ethersproject/providers/lib/json-rpc-provider.js:21:65)
at processTicksAndRejections (node:internal/process/task_queues:105:5)
3) ETH Transfer Test Suite
2. send() Method Tests
2.2 send() → DoNothingReceiver:
AssertionError: Expected event "TransferSuccess" to be emitted, but it wasn't
4) ETH Transfer Test Suite
2. send() Method Tests
2.3 send() → SimpleReceiver:
AssertionError: Expected event "TransferSuccess" to be emitted, but it wasn't
Key Finding: Clear Failure Pattern
| Transfer Method | EOA | DoNothingReceiver | SimpleReceiver | ComplexReceiver |
|---|---|---|---|---|
| transfer() | ✅ | ❌ | ❌ | ✔️ (expected to fail) |
| send() | ✅ | ✔️ (expected to fail) | ||
| call() | ✅ | ✅ | ✅ | ✅ |
Core Conclusions:
1 transfer and send has 2300 gas limit cap
2. .transfer() and .send() fail when transferring to contracts - Due to insufficient 2300 gas
3. .call() succeeds when transferring to contracts - Because it forwards all remaining gas
Root Cause : Bytecode Analysis - Evidence of 2300 Gas Limit
5.1 EVM Opcode Analysis
Solidity's .transfer() and .send() compile to use EVM's TRANSFER opcode.
Finding compiled bytecode:
cat artifacts/contracts/TransferTest.sol/Sender.json | jq -r '.deployedBytecode'Key bytecode sequence:
6108fc # PUSH2 0x08fc (2300 in decimal)
2300 Gas Stipend in EVM Specification
According to Ethereum Yellow Paper and Solidity documentation:
TRANSFER opcode (0x08fc):
- Built into Solidity's
.transfer()and.send()methods - Hard-coded 2300 gas stipend
- Purpose: Sufficient for simple logging, but prevents complex operations and reentrancy attacks
Bytecode Evidence:
// Solidity source:
to.transfer(msg.value);
// Compiled bytecode:
PUSH2 0x08fc // Push 2300 (0x08fc in decimal)
SWAP1
DUP2
ISZERO
...
CALL // Call with 2300 gas limitKey Finding: The 6108fc byte sequence appears in the implementation of .transfer() and .send(), proving that the 2300 gas limit is hard-coded in bytecode, not a configuration parameter.