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:
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:
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)
:
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:
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:
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 fromethersjs
. 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 theblockTag
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.