callStatic: an ethersjs tool to examine why transactions reverts

callStatic: an ethersjs tool to examine why transactions reverts

Overview

At UBET, we are developing our infrastructure for supporting decentralized betting for sports events. One of the most critical parts of our software implementation are the smart contracts that run on the EVM of the Polygon network. These contracts are responsible for managing the liquidity pools where the users place their bets. As they control the funds and rewards they receive, we are taken as many security measures as possible in order to minimize the vulnerabilities they could possibly have.

One important part of security analysis is to have the ability to understand why code may fail. Smart contract languages, in our case Solidity, provide tools that allow analyzing errors during transaction mining and code execution. However, using the tools, in combination with the fast development of backend/frontend tools like ethersjs, sometimes make the analysis of custom error fields unclear. Due to the low amount of literature covering custom error fields, we decided to write about one possible ethersjs tool called callStatic, which is implemented on the Contract objects of that library.

As we know, both successful and failed transactions are included in the blocks. In both cases, the only information we have from them is the transaction receipt which includes information about transaction hash, ethers value sent, gas information, etc. For failed transactions, the transaction receipt does not include information about the revert reason. And this is a problem if we want to post-mortem analyze transactions. Fortunately, the callStatic tool from ethersjs allows us to recreate what happened with old and possible future transactions before submitting it.

Smart Contract Example

We are going to use this smart contract deployed on the Polygon Mumbai testnet to do experiments and interact through the ethersjs library.

// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.15;

error InvalidCall();
error InvalidNumber(uint256);

contract Errors {

    uint256 public number;

    function normalError() public {
        number = 666;
        revert("InvalidCall()");
    }

    function customError() public {
        number = 666;
        revert InvalidCall();
    }

    function customErrorWithNumber(uint256 _number) public {
        if (number < 666) revert InvalidNumber(number);
        number = _number;
    }

    function setNumber(uint256 _number) public {
        number = _number;
    }
}

we have included functions that revert in the old classical Solidity way by passing an error string and functions that revert by passing the new Solidity custom-errors. The latest is the one that is generally more difficult to track and currently most of the block explorer do not properly support. We will show that with callStatic we can know exactly the revert reason for both of these kind of errors.

Reverts with strings errors (old way)

Let's start by showing that Polyscan supports the classical string error of Solidity by submitting a transaction calling to normalError(). In this Polyscan link we can clearly see that the correct error message InvalidCall() is present in the error message:

image.png

That was expected and still is good news since we want to know the failure reason of past transactions.

Reverts with custom-errors (new way)

But what happens if we submit a transaction executing the customError() function that reverts with the new solidity custom-error? On Polyscan we can see that, until today (22.10.2022), the error is not shown:

image.png

Note that there is no information provided about the revert reason as with the string error above. This motivates us to search for other alternatives that allow us to interpret the error messages we get independently off the block explorers.

Implement callStatic script

Let's create a script called callStatic.js, and configure our ethersjs environment in the following way:

const ABI = [...];
const CONTRACT_ADDRESS = "0x6826E8a44c1aeceB84de44Ec13FfEEc9e2e67B67";

const provider = new ethers.providers.JsonRpcProvider(
  "https://polygon-mumbai.g.alchemy.com/v2/<ALCHEMY_API_KEY>"
);
const contract = new ethers.Contract(CONTRACT_ADDRESS, ABI, provider);

Remember to provide a valid Alchemy API key or other Polygon Mumbai provider URL. Note that we aren't setting any signer since we are not going to submit any signed transaction to the blockchain. We are going to kind of simulate the transaction without paying gas for it.

Using callStatic is really simple once the Contract object is properly initialized. The way is works is almost the same as when we submit a real transaction. Let's look at the following function to call the normalError() function in the contract.

async function normalError() {
  try {
    const tx = await contract.callStatic.normalError();
  } catch (error) {
    console.log("caught error:\n", error);
  }
}

normalError();

After executing the script with node we can see that the error reason is logged:

  reason: 'InvalidCall()',
  code: 'CALL_EXCEPTION',
  method: 'normalError()',
  data: '0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000d496e76616c696443616c6c282900000000000000000000000000000000000000',
  errorArgs: [ 'InvalidCall()' ],
  errorName: 'Error',
  errorSignature: 'Error(string)',
  address: '0x6826E8a44c1aeceB84de44Ec13FfEEc9e2e67B67',
  args: [],
  transaction: {
    data: '0xb8ff3a5d',
    to: '0x6826E8a44c1aeceB84de44Ec13FfEEc9e2e67B67'
  }

We can see clearly the InvalidCall() message which is the same reported by Polyscan.

As we mentioned, our main motivation was to see the reason when the new Solidity custom-errors are used. We have seen that Polyscan currently does not provide support for them. Now let's try to execute customError() with the callStatic tool:

async function customError() {
  try {
    const tx = await contract.callStatic.customError();
  } catch (error) {
    console.log("caught error:\n", error);
  }
}

customError();

In that case we get:

  reason: null,
  code: 'CALL_EXCEPTION',
  method: 'customError()',
  data: '0xae962d4e',
  errorArgs: [],
  errorName: 'InvalidCall',
  errorSignature: 'InvalidCall()',
  address: '0xf948063ca55e43098ebd84dc15793c2c3cbaf2c5',
  args: [],
  transaction: {
    data: '0xdda3a7bd',
    to: '0xF948063cA55E43098EBD84dc15793c2C3CBAF2C5'
  }

Note that even thought some fields are empty or marked as null that aren't for the normal error string above, we can see clearly the custom-error reason on the errorSignature field: InvalidCall()!

With this result we have done a smart step forward and see the revert reason of a custom-error. Now we are going to do an even further step forward and analyse a situation were the EVM has changed and we want to analyze the condition on a past state.

Examing pass state situations

As we know the EVM state changes with every block as new successful transactions are included into the new blocks and execute EVM code. In our past examples we have used callStatic for the last state of the blockchain. That means that if the state changed and now we want to analyse a past transaction the new callStatic could not reproduce exactly the same execution of the past transactions (for instance, the transactions could be successful this time).

The way to solve this problem is indicating to callStatic the block number were we want to simulate the transaction. In that case the Polygon node will run our call for the EVM state at that block number. This allows us to analyze exactly why past transactions failed.

We prepared a second experiment which depends on the EVM state by defining a state variable call number which is an uint256 and a function called customErrorWithNumber(unit256 _number) which reverts if number is less than 666. If number is bigger or equal to 666, the number changes to the function argument _number, changing the EVM state. We have additionally defined a function called setNumber(uint256 _number) that allows to change the number state variable value, so we can make customErrorWithNumber(unit256 _number) revert or not depending on the number state variable.

Let's make the first call to customErrorWithNumber(unit256 _number) to revert. We do so by setting the number to a value less than 666. After number is properly set to a value less than 666 we can call customErrorWithNumber(unit256 _number):

image.png

See on the smart contract that this transaction should revert with a InvalidNumber(number) error.

Now lets create our callStatic calling function to see what we can obtain:

async function customErrorWithNumber() {
  try {
    const tx = await contract.callStatic.customErrorWithNumber(50);
  } catch (error) {
    console.log("caught error:\n", error);
  }
}

customErrorWithNumber();

After running the script we obtain:

  reason: null,
  code: 'CALL_EXCEPTION',
  method: 'customErrorWithNumber(uint256)',
  data: '0xc5d83cde0000000000000000000000000000000000000000000000000000000000000000',
  errorArgs: [ BigNumber { _hex: '0x00', _isBigNumber: true } ],
  errorName: 'InvalidNumber',
  errorSignature: 'InvalidNumber(uint256)',
  address: '0x6826E8a44c1aeceB84de44Ec13FfEEc9e2e67B67',
  args: [ 1666 ],
  transaction: {
    data: '0xe129f95c0000000000000000000000000000000000000000000000000000000000000682',
    to: '0x6826E8a44c1aeceB84de44Ec13FfEEc9e2e67B67'
  }

Note that we can see also the value of the state variable number in the field errorArgs it is 0x00 in hex which is 0 in decimal and the value it had when we did the call.

Now we can change the value of the number to something bigger than 666 to make the customErrorWithNumber() function execute successfully without reverting. We can do that directly on Polyscan as:

image.png

Now if we execute again to our script calling the function we would see that no error was thrown. This is what we expected since now the transaction would execute successfully. We can also test that on Polyscan:

image.png

image.png

The question is how could we debug the previous transaction that failed when we called the customErrorWithNumber and number was less than 666. The state is now so that the transaction would not behave in the same way as before.

As we said the solution is to specify and provide callStatic the block number where we want to do the EVM call. We can do that by providing the block number under the blockTag keyword. On our script that is (we did the transaction that failed on block 28768838 where number=0):

async function customErrorWithNumber() {
  try {
    const tx = await contract.callStatic.customErrorWithNumber(666, {
      blockTag: 28768838,
    });
  } catch (error) {
    console.log("caught error:\n", error);
  }
}

customErrorWithNumber();

The result of executing this script is:

  reason: null,
  code: 'CALL_EXCEPTION',
  method: 'customErrorWithNumber(uint256)',
  data: '0xc5d83cde0000000000000000000000000000000000000000000000000000000000000000',
  errorArgs: [ BigNumber { _hex: '0x00', _isBigNumber: true } ],
  errorName: 'InvalidNumber',
  errorSignature: 'InvalidNumber(uint256)',
  address: '0x6826E8a44c1aeceB84de44Ec13FfEEc9e2e67B67',
  args: [ 666 ],
  transaction: {
    data: '0xe129f95c000000000000000000000000000000000000000000000000000000000000029a',
    to: '0x6826E8a44c1aeceB84de44Ec13FfEEc9e2e67B67'
  }

We can see the revert reason again, even though now the state has changed. If we set the number back to something less than 666, let's say 10 and execute the callStatic for the last block again, we get:

  reason: null,
  code: 'CALL_EXCEPTION',
  method: 'customErrorWithNumber(uint256)',
  data: '0xc5d83cde000000000000000000000000000000000000000000000000000000000000000a',
  errorArgs: [ BigNumber { _hex: '0x0a', _isBigNumber: true } ],
  errorName: 'InvalidNumber',
  errorSignature: 'InvalidNumber(uint256)',
  address: '0x6826E8a44c1aeceB84de44Ec13FfEEc9e2e67B67',
  args: [ 666 ],
  transaction: {
    data: '0xe129f95c000000000000000000000000000000000000000000000000000000000000029a',
    to: '0x6826E8a44c1aeceB84de44Ec13FfEEc9e2e67B67'
  }

As expected, we can see that the value provided in the errorArgs field is set to 0x0a which is 10 in hexadecimal.

Conclusions

  • We found a way to analyze revert reasons of failed transactions using the callStatic tools from ethersjs. We could analyse why past transactions failed even when the EVM state changed by providing the corresponding arguments to the functions and the block number from when they failed using the blockTag keyword.
  • We have a way to estimate if a transaction will fail or not before broadcasting it to the blockchain network. This allows us to save the gas cost associated with failed transactions. Our current callStatic process might fail if the success of the transactions depends on things like the block timestamp since this is non-deterministic and adjustable by the block miners.