Based on this article at Solidity blog about Custom Errors, I've understood that this recent Solidity feature Custom Errors would behavior in the same way as a failed condition in require statement, since it says require(condition, "error message") should be translated to if (!condition) revert CustomError().
However, I've faced something different.
I've written the following contract in a Hardhat project to test this case:
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
contract HellowWorld {
string private greeting;
uint256 public immutable imutavel;
error UnevitactableError();
constructor(string memory _greeting) {
greeting = _greeting;
imutavel = 10;
}
function hello() public view returns (string memory) {
return greeting;
}
function setHello(string memory _greeting) public {
greeting = _greeting;
}
function testFailRequire() external {
for (uint256 i = 0; i < 100; i++) {
greeting = "consome gas";
}
require(false);
}
function testFailRevert() external {
for (uint256 i = 0; i < 100; i++) {
greeting = "consome gas";
}
revert UnevitactableError();
}
function testFailAssert() external {
for (uint256 i = 0; i < 100; i++) {
greeting = "consome gas";
}
assert(false);
}
}
HelloWorld.sol
I've also written some tests to assert the behavior:
import { Signer } from "ethers";
import { ethers } from "hardhat";
import { HellowWorld } from "./../../typechain-types";
/**
- @description Sets a initial state desired to every test
*/
export async function deployHelloWorldFixture(): Promise<IHellowWorldFixture> {
// Contracts are deployed using the first signer/account by default
const [
owner,
manager,
teamMemberA,
teamMemberB,
accountA,
accountB,
accountC,
accountD,
accountE,
] = await ethers.getSigners();
const HelloWorldFactory = await ethers.getContractFactory("HellowWorld");
const contract = await HelloWorldFactory.deploy("Hello, world!");
return {
contract,
owner,
manager,
teamMemberA,
teamMemberB,
accountA,
accountB,
accountC,
accountD,
accountE,
};
}
export type IHellowWorldFixture = {
contract: HellowWorld;
owner: Signer;
manager: Signer;
teamMemberA: Signer;
teamMemberB: Signer;
accountA: Signer;
accountB: Signer;
accountC: Signer;
accountD: Signer;
accountE: Signer;
};
fixtureHellowWorld.ts
import {
loadFixture,
setNextBlockBaseFeePerGas,
} from "@nomicfoundation/hardhat-network-helpers";
import { expect } from "chai";
import {
deployHelloWorldFixture,
IHellowWorldFixture,
} from "./fixtures/fixtureHelloWorld";
describe.only("HelloWorld", function () {
let fixture: IHellowWorldFixture;
beforeEach(async function () {
// We use loadFixture to run this setup once, snapshot that state,
// and reset Hardhat Network to that snapshopt in every test.
fixture = await loadFixture(deployHelloWorldFixture);
});
it("Should revert returning the remaining gas when failed with 'require' (0xfd) - the final ETH balance equals the previous balance", async function () {
const balanceBefore = await fixture.accountE.getBalance();
console.log("balanceBefore", balanceBefore.toString());
// seta o baseFeePerGas para poder passar um gasPrice = 1
await setNextBlockBaseFeePerGas(1);
await expect(
fixture.contract
.connect(fixture.accountE)
.testFailRequire({ gasLimit: 1000000, gasPrice: 1 })
).to.be.revertedWithoutReason;
const balanceAfter = await fixture.accountE.getBalance();
console.log(
"balanceAfter",
balanceAfter.toString(),
`-${balanceBefore.sub(balanceAfter).toNumber()}`
);
expect(balanceAfter).to.be.equal(balanceBefore);
});
it("Should revert returning the remaining gas when failed with 'revert CustomError()' - the final ETH balance equals the previous balance", async function () {
const balanceBefore = await fixture.accountE.getBalance();
console.log("balanceBefore", balanceBefore.toString());
// seta o baseFeePerGas para poder passar um gasPrice = 1
await setNextBlockBaseFeePerGas(1);
await expect(
fixture.contract
.connect(fixture.accountE)
.testFailRevert({ gasLimit: 1000000, gasPrice: 1 })
).to.be.revertedWithCustomError(fixture.contract, "UnevitactableError");
const balanceAfter = await fixture.accountE.getBalance();
console.log(
"balanceAfter",
balanceAfter.toString(),
`-${balanceBefore.sub(balanceAfter).toNumber()}`
);
//expect(balanceAfter).to.be.equal(balanceBefore);
});
it("Should revert NOT returning remaining gas when failed with 'assert' (0xfe) - the final ETH balance is lesser than previous balance", async function () {
const balanceBefore = await fixture.accountE.getBalance();
console.log("balanceBefore", balanceBefore.toString());
// seta o baseFeePerGas para poder passar um gasPrice = 1
await setNextBlockBaseFeePerGas(1);
await expect(
fixture.contract
.connect(fixture.accountE)
.testFailAssert({ gasLimit: 1000000, gasPrice: 1 })
).to.be.revertedWithPanic("0x01");
const balanceAfter = await fixture.accountE.getBalance();
console.log(
"balanceAfter",
balanceAfter.toString(),
`-${balanceBefore.sub(balanceAfter).toNumber()}`
);
expect(balanceAfter).to.be.lessThan(balanceBefore);
});
});
test.ts
My expectation was:
testFailRequiretest didn't affect the account caller's balance;testFailReverttest didn't affect the account caller's balance;testFailAsserttest end up with a lesser balance.
The expectation 1 and 3 were reached. However, the expectation 2 not. Why?
I'd appreciate if one could clarify this for us.
UPDATE
I've published this contract on Goerli Network and, as we can see in the transaction list, the transactions to the method testFailRevert are more expensive than the testFailRequire and testFailAssert. What does not match the statement that custom errors and require are equivalent.
https://goerli.etherscan.io/address/0xA69fF680173D7317A2BB482B2d00CA99323e01d5