-
Notifications
You must be signed in to change notification settings - Fork 33
Description
Version of Hardhat
2.22.15
What happened?
What Goes Wrong
Hardhat reports inconsistent gas usage when running in fork mode. Different runs may result in different gas usages on the same set of transactions in the same order, in the state forked from the same block.
A potentially related issue has been reported before.
My Setup
Hardhat Server Process
I use Hardhat as a local JSON_RPC. Here is my hardhat.config.js:
module.exports = {
solidity: "0.8.25",
networks:{
hardhat:{
throwOnTransactionFailures: false,
throwOnCallFailures: false,
chainId: 1,
mining: {
mempool: {
order: "fifo"
}
},
accounts: {
count: 2
}
}
}
};
Payloads
I have a paylods.json file containing a set of queries. The detailed description of the queries structure is deferred to the further details section. The payloads.json is hosted here or find attached payloads.json
Payloads Execution
The payloads are submitted via POST request using python sctipt submit_payloads.py, which does the following;
For iteration 1 to MAX_RUNS
-
forks the blockchain using
FORK_PAYLOADpayload -
Submit each payload from the
payloads.jsonfile and logs the responses. The return status is ensured to be200 -
logs the responses to the
output_{run}.jsonfile -
prints the hashes of the logs upon exit to the screen
The full code is as below. Note that in order to run, you need to replace JSON_RPC_TOKEN with a token, I use infura api:
import json
import requests
import time
import os
import hashlib
JSON_RPC_TOKEN = "YOUR REMOTE RPC TOKEN"
# Tunable parameters
HARDHAT_PORT = 1234
MAX_RUNS = 200
COOLDOWN_SECONDS = 1
PAYLOADS = "payloads.json"
LOG_DIR = "logs"
HEADERS = {
'Content-Type': 'application/json'
}
FORK_PAYLOAD = {
"jsonrpc": "2.0",
"method": "hardhat_reset",
"params": [
{
"forking": {
"jsonRpcUrl": JSON_RPC_TOKEN,
"blockNumber": 20845407
}
}
],
"id": 1
}
REQUEST_BLOCK_INFO_PAYLOAD = {
"jsonrpc": "2.0",
"method": "eth_getBlockByNumber",
"params": ["latest", False],
"id": 1
}
def submit_payload(payload):
response = requests.request("POST", f"http://127.0.0.1:{HARDHAT_PORT}", headers=HEADERS, data=json.dumps(payload))
assert response.status_code == 200
return json.loads(response.text)
def main(run):
# first fork the mainnet
submit_payload(FORK_PAYLOAD)
# then read payloads from a file
with open(PAYLOADS, 'r') as f:
payloads = json.load(f)
# submit each payload and log the response
with open(f"{LOG_DIR}/output_{str(run).zfill(3)}.json", 'w') as f:
for i, payload in enumerate(payloads):
response = submit_payload(payload)
# log the response
f.write(json.dumps(response).replace("\n", ""))
f.write("\n")
# request block info
response = submit_payload(REQUEST_BLOCK_INFO_PAYLOAD)
# return the hash of the block
return response
if __name__ == "__main__":
if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR)
for i in range(MAX_RUNS):
main(i)
time.sleep(1)
log_files = os.listdir(LOG_DIR)
log_files = [fname for fname in log_files if fname.endswith(".json")]
log_files.sort()
# display the hash of the last block
for log_file in log_files:
with open(f"{LOG_DIR}/{log_file}", 'r') as f:
file_content = f.read()
print(f"{log_file}: {hashlib.sha256(file_content.encode()).hexdigest()}")Logs Comparation
The logs show that the gas consumption may wary from run to run. Usually the first divergence happen for the line line 1360 of the logs:
{"jsonrpc": "2.0", "id": 1, "result": {"cumulativeGasUsed": "0x9264d6", ...
{"jsonrpc": "2.0", "id": 1, "result": {"cumulativeGasUsed": "0x925ebe", ...
Further Details
The bug was discovered while my colleagues and I tried to emulate the block mining process. We collected a set of real transactions (smart contracts and externaly owned accounts) and tried to get the gas usege of the block when mined.
The payload.json file contains the payload data to call the hardhat RPC. The payloads description:
- hardhat reset and fork call, done from the script
{
"jsonrpc": "2.0",
"method": "hardhat_reset",
"params": [
{
"forking": {
"jsonRpcUrl": JSON_RPC_TOKEN,
"blockNumber": 20845407
}
}
],
"id": 1
}- Turn off automine and get automine mode
[{"jsonrpc":"2.0","method":"evm_setAutomine","params":[false],"id":1},
{"jsonrpc":"2.0","method":"hardhat_getAutomine","params":[],"id":1}]- Set the block gas limit to the sum of all maxGasUsage
{"jsonrpc":"2.0","method":"evm_setBlockGasLimit","params":[205322942],"id":1}- Impersonate all accounts which are sent to the block
...
{"jsonrpc":"2.0","method":"hardhat_impersonateAccount","params":["0x894ff15a502f55943e488a6e035bff78c828b573"],"id":1},
{"jsonrpc":"2.0","method":"hardhat_impersonateAccount","params":["0xf89d7b9c864f589bbf53a82105107622b35eaa40"],"id":1},
...-
This is quite non-standard scenario, the cause of the bag could be here. For our scenario we need to make sure that each transaction got to the pool as we are interested in the situation when, say, an account
Bdoes not have enough balance to send a transactionTxB, but if there is a transactionTxAwhich tops up the balanceBmaking the block[TxA, TxB]a valid block. To bypass hardhat's automatic balance check (which prevents sending the txTxBotherwise), for each transaction we do:- get the balance of the caller
- set the balance of the caller to
$2^{256} - 1$ - submit the transaction to the pool
- set the balance to the value we get from the first step
...
{"jsonrpc":"2.0","method":"eth_getBalance","params":["0x894fF15a502f55943E488a6E035bFF78c828B573","latest"],"id":1},
{"jsonrpc":"2.0","method":"hardhat_setBalance","params":["0x894fF15a502f55943E488a6E035bFF78c828B573","0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"],"id":1},
{"jsonrpc":"2.0","method":"eth_sendTransaction","params":[{"data":"0x095ea7b300000000000000000000000080a64c6d7f12c47b7c66c5b4e20e72bc1fcd5d9effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff","from":"0x894fF15a502f55943E488a6E035bFF78c828B573","gas":57087,"nonce":683,"to":"0x65591F115286A284870e1677B1ad52DB4A762B54","value":0,"maxFeePerGas":12497504523,"maxPriorityFeePerGas":3000000000}],"id":1},
{"jsonrpc":"2.0","method":"hardhat_setBalance","params":["0x894fF15a502f55943E488a6E035bFF78c828B573","0x765f80d4ff56ca0"],"id":1},
...- mine the block
{"jsonrpc":"2.0","method":"evm_mine","params":[],"id":1}- for each transaction get the receipt and the new balance
...
{"jsonrpc":"2.0","method":"eth_getTransactionReceipt","params":["810962865bf8a0aef20a7653aa6be334860f9f83b82110a674888665d95c6886"],"id":1},
{"jsonrpc":"2.0","method":"eth_getBalance","params":["0xc5ed543899bd2cf38be5ae098c4987e5c900dc7c","0x13e1360"],"id":1},
...Minimal reproduction steps
Reproducing the Bug
I managed to reproduce the bug in a container. You can find the PoC and instructions here. The reproduction instructions are:
git clone https://github.com/polka125/hardhat-bug-artifacts.git- put your remote api key to the file
RPC_TOKEN docker build -t hardhat_bug .docker run -v ./host_logs:/app/logs -it hardhat_bugAt this point you might get feeling that the docker stuck, but checkhost_logs/hardhat.log, it accumulates the hardhat output, i.e. the process is runningfind ./host_logs -type f -print0 | sort -z | xargs -0 shasum -a 1
The last step will print hashes of the log files, the log files with different logs will be easy to locate visually as their hashes will diverge
Search terms
Gas usage, Fork mode