This article belongs to a series of posts intended to share some nerdy from our latest spotcheck on the implementation of the imposing ERC-4337 for Account Abstraction.

This is the third article of the series. In case you haven't, read the first two below:

Pointers in Solidity?
Learning about memory pointers in Solidity using assembly.
First article. Memory pointers in Solidity.
Catch me if you can!
Learning about edge cases of Solidity’s try/catch while I explored Account Abstraction.
Second article. Error handling in Solidity with try/catch.

It's funny how all these articles start in the same way. Just me, going over the core contract of the system (a.k.a. the EntryPoint) until I find a piece of code that puts me to the test.

In today's case, I'll cover a getter function that uses a revert statement, instead of the usual return, to return some data.

Revert and return data

Before going to the EntryPoint contract itself, let's start at the lowest possible level, with the EVM's REVERT opcode.

Every time a revert happens, the EVM allows attaching some information to it. Like "sending a message", if you will.

In practice, the opcode pops an offset and a size from the stack, which uses to read a chunk of memory in the current context.

According to

The return data is the way a smart contract can return a value after a call. It can be set by contract calls through the RETURN and REVERT instructions, and can be read by the calling contract with RETURNDATASIZE and RETURNDATACOPY.

A suspicious revert

With that in mind, let's look at the following function from the EntryPoint contract.

function getSenderAddress(bytes calldata initCode) public {
	address sender = senderCreator.createSender(initCode);
	revert SenderAddressResult(sender);

Two questions quickly came to mind:
1) Why do this? Is it an optimization?
2) If so, is it possible to use reverts instead of returns to save gas?

Without paying too much attention to the first one (because #yolo), I embarked on a journey to answer the second.

Different types of reverts

Forget about the EntryPoint, Account Abstraction, and all that. Just imagine this simple scenario: a user has to pay ETH for something, and the code needs to handle the case where not enough funds were sent.

I'll share a few examples on how to accomplish this, using reverts. Then I'm going to check the amount of gas consumed during execution. And afterwards, I'll compare the alternatives, and see if I reach some sort of conclusion. Sounds like a plan!

First case

A direct call to Solidity's revert, with a string:

contract RevertWithString {
    function buy(uint256 amount) public payable {
        if (msg.value < amount)
            revert("Not enough Ether provided.");

Cost: 296 gas.

Second case

A revert using Solidity's require, with the same string.

contract RevertWithRequire {
    function buy(uint256 amount) public payable {
        require(msg.value >= amount, "Not enough Ether provided.");

Cost: 295 gas.

Unsurprisingly, practically same gas costs. According to Solidity's docs:

if (!condition) revert(...); and require(condition, ...); are equivalent as long as the arguments to revert and require do not have side-effects, for example if they are just strings.

Third case

Using custom errors.

contract RevertCustomError {
    error NotEnoughEtherProvided();
    function buy(uint256 amount) public payable {
        if (msg.value < amount)
            revert NotEnoughEtherProvided();

Cost: 241 gas.

Custom errors with no parameters are cheaper! BUT, how can you use them to return dynamic values? Unless you have enough custom errors to cover an entire spectrum data you need to return, you need to add parameters as well.

Fourth case

Custom error with the same string:

contract RevertCustomErrorWithData {
    error MyCustomError(string);
    function buy(uint amount) public payable {
        if (msg.value < amount)
            revert MyCustomError("Not enough Ether provided.");

Cost: 295 gas.

Now we are talking! It actually makes no difference in terms of gas costs. Although I personally prefer using custom errors.

Why not include Solidity's assert as well?
Because assert should only be used to test for internal errors, and to check invariants. Properly functioning code should never create a Panic, not even on invalid external input.

It's time to compare these against a return.

The return case

Here's a getter that returns the same string that was used before.

contract JustAGetter {
    function buy(uint amount) public payable returns (string memory) {
        if (msg.value < amount)
            return "Not enough ether provided.";
        return "Enough ether provided.";

Cost: 552 gas!

Reverts are cheaper! So why aren't all the gas optimizooors using this technique?

Well, working with reverts as you would do with a getter means implementing error handling. Because in order to use the returned data you'd need to somehow try/catch it, and that would only work with external function calls. If you go that way, your code would start getting way too complex, making it less maintainable and readable. On top of it, the additional logic would add up for more gas expenses.

Let's verify that.

Try-catching the revert

Getting a string via revert with custom errors.

contract RevertGetter {
    error MyCustomError(string);
    RevertCustomErrorWithData custom; // an instance of contract from a previous example
    constructor() {
        custom = new RevertCustomErrorWithData(); 

    function getString(uint256 amount) public payable returns (string memory) {
        try {
            return "This should never run";
        } catch (bytes memory lowLevelData) {
            return string(lowLevelData);

Cost: 552 gas.

The same gas cost than a simple return - surprised?

You can find all the previous code in this public gist.

What about the EntryPoint contract?

Yeah, right! Back at it.

The question I was asking was: why use reverts to return data in the EntryPoint contract? Couldn't they do something like this instead?

function getSenderAddressWithReturn(bytes calldata initCode) public returns (address) {
    address sender = senderCreator.createSender(initCode);
    return sender;

Actually, no. Reading the comments of the code will give you a hint. In reality, this was never about gas optimizations.

The real reason is that the developers wanted to include on-chain logic to support off-chain clients, and didn't want to risk any case where that logic could have side-effects involving actual state changes.

Believe or not, there are situations in which you want to run state-changing functions, without committing the actual changes to state. For example?

Simulations! I mean, any kind of situation where you'd like to check out the real behavior of some logic, without actually changing anything just yet.

In the case of this function, the developers didn't want to create the sender account, they just wanted its address. To get it, it was easier to just run the code as if the account was created (without really creating it), and get the address back.

And since this is only supposed to be handled off-chain by a bundler, there are no additional gas costs associated with it. See below how the getSenderAddress function could be called off-chain:

async getCounterFactualAddress (): Promise<string> {
    const initCode = this.getAccountInitCode()
    try {
        await this.entryPointView.callStatic.getSenderAddress(initCode)
    } catch (e: any) {
        return e.errorArgs.sender
        throw new Error('must handle revert')


What did we learn?

First of all, either returns or reverts can be used to return data to callers. It's then up to the developers to handle the differences between them.

And then, that my initial assumptions, even on the most simple functions, can change throughout my research! I started my exploration thinking of gas optimizations, and ended up realizing that the real reason for a specific line of code was completely different.

Until our next adventure!