3

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:

  1. testFailRequire test didn't affect the account caller's balance;
  2. testFailRevert test didn't affect the account caller's balance;
  3. testFailAssert test 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

4
  • Does this answer your question? Difference between require and assert and the difference between revert and throw
    – matank001
    Oct 15, 2022 at 22:08
  • Unfortunately no. Nothing is said about revert and Custom Error implemented in Solidity 0.8.4
    – Fabiano
    Oct 16, 2022 at 10:28
  • Custom errors are require are not equivalent. But I'm not exactly sure what your question is then. Is your question "Are custom errors and require equivalent"? Then the answer to that is no. Or is your question, "How are custom errors and require different?" Could you please clarify? Additionally in the future, if you could minimize your codebase to the minimal example of the question you're trying to ask it would be easier to triage. Dec 1, 2022 at 15:05
  • Why do you have that for loop in your functions? That's what's throwing you off. Usually reverts and requires refund gas, but you're using it all up before it can even refund gas. However much gas you sent will be how much it uses. Because of that loop, you're seeing gas discrepancies between functions because you're sending different amounts of gas. But yes, please rephrase the question to exactly the question you're trying to get to the heart of. Instead of "why aren't my assumptions being met" have it be "what is the difference between revert and assert" or whatever you are looking for. Dec 1, 2022 at 15:14

2 Answers 2

4
+50

Each transaction will consume gas when it is executed.

Both require and revert execute the revert opcode and return all remaining gas. Assert execute the invalid opcode and consumes all the remaining gas. I suspect that testFailRequire does not consume any gas since it is not executed. This can happen if your development environment makes an eth_estimateGas call before executing the transaction and aborts the call if the gas estimation fails.

In your example, the testFailRequire method is cheaper because you do not return an error message. When you use an error message, your custom error consumes less gas.

For the following simplified example, the gas cost behave like this:

testFailRequire < testFailRevert < testFailRequireWithInfo

//SPDX license identifier: Unlicense
pragma solidity ^0.8.0;

Contract HelloWorld {
    Error UnevitactableError();

    string internal constant CHEAP_REVERT = '1';

    constructor() {
    }
    function testFailRequire() external {
        require(false);
    }

    function testFailRevert() external {
        Revert UnevitactableError();
    }
    function testFailRequireWithInfo() external {
        require(false, CHEAP_REVERT);
    }

}

To understand why the gas cost is not exactly the same, you need to understand how custom errors are compiled into assembly code, as explained in this solidity blog.

Note that in a production environment, the gas cost of each function call also depends on other factors (e.g., the order in which you define your functions).

0

I think you are still making a state change to greeting before reverting

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.