When interacting with Solidity smart contracts, a function call may not proceed as expected. Instead of completing successfully, the transaction halts, and all changes made during execution are rolled back. This is known as a REVERT
— a mechanism designed to protect the integrity of runtime operations.
Think of it as the blockchain’s saying, “Something went wrong; let’s undo everything to ensure state consistency.”
Reverts ensure no partial or unintended actions are allowed. They are triggered when defined conditions within a smart contract function are unmet due to invalid inputs, insufficient funds, or unauthorized access attempts.
Understanding why a smart contract reverts enables developers to effectively debug failed contract conditions, validate inputs before executing critical functions, and prevent unexpected behavior. This reduces the risk of errors and ensures the security of smart contracts.
Before we get into the details, let’s examine function calls in Solidity and how they impact reverts.
Calls are how smart contracts interact with and execute logic on the Ethereum Virtual Machine (EVM). These interactions can be made through high-level calls, using a contract’s interface to invoke functions with type safety, or low-level calls, which execute functions directly.
Regardless of the method, a smart contract's runtime behavior is determined by explicitly or implicitly invoked functions. The EVM evaluates each call to ensure the operation's validity.
If a runtime error occurs—such as a failed condition in a require
statement—the EVM triggers a state-reverting exception. This undoes all changes the transaction made, restoring the contract to its previous state. However, the gas spent during this process is irrecoverable.
For example, in the contract below, if callMultiply(x,y)
is called with values > 10, or lowLevelCallMultiply(12, 5)
is called, the require
statement in the multiply function triggers a revert because the defined conditions are not met (12 is greater than 10).
/ SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Callee {
function multiply(uint256 x, uint256 y) public pure returns (uint256) {
require(x < 10 && y < 10, "Inputs must be less than 10");
return x * y;
}
}
contract Caller {
Callee callee;
constructor(address calleeAddress) {
callee = Callee(calleeAddress); // Instantiate the Callee contract
}
function callMultiply(uint256 x, uint256 y) public view returns (uint256) {
// High-level call
return callee.multiply(x, y);
}
function lowLevelCallMultiply(uint256 x, uint256 y) public returns (uint256) {
// Low-level call
(bool success, bytes memory result) = address(callee).call(
abi.encodeWithSignature("multiply(uint256,uint256)", x, y)
);
require(success, "Low-level call failed"); // Check if the call was successful
return abi.decode(result, (uint256)); // Decode the returned value
}
}
Note that a low-level call (e.g., lowLevelCallMultiply
) does not automatically revert on failure. Instead, it returns a boolean (true
or false
), which is pushed onto the EVM stack like any other data. The EVM does not take any action based on this value. Therefore, the contract must explicitly check and handle the result to determine whether the call was successful.
(bool success, bytes memory result) = address(target).call(data);
require(success, "Low-level call failed");
While the behavior described above applies in most cases when a revert is triggered, the EVM's response varies depending on the type of error and the context in which it occurs. The following sections explore how the EVM handles reverts across different scenarios.
require
, revert
, and assert
To ensure transaction conditions are met, Solidity provides the error handling statements require
, revert
, and assert
, allowing developers to define conditions under which operations should fail or revert. Let’s explore each.
The revert
function aborts transaction execution, undoes any state changes, and may or may not include string arguments to add context for the revert. When called without a string argument, revert
triggers a state revert without providing information to the caller.
function withdraw(uint256 amount) public {
if (amount > balance) {
revert(); // No message returned
}
balance -= amount;
}
Revert
statement with no error message.
However, if a string argument is provided, the transaction is reverted, and the error message is displayed.
revert("Error: Insufficient balance");
// Here, "Error: Insufficient balance" is returned to the caller.
The string "Insufficient balance" is encoded to the ABI specification, in the same way that other data types are. Ensuring the message can be interpreted by any client (web3.js, ethers.js).
Note: The typical use of revert
is within an if
condition. Yet, revert
statements can be invoked unconditionally in cases where the error or exception is explicit and doesn't depend on evaluating a condition at runtime.
function deprecatedFunction() public pure {
revert("This function is no longer supported");
}
Reverts with no condition checks.
A require
statement is a conditional check used to validate inputs, ensure that necessary state conditions are met during execution, and provide an optional error message. If the specified condition returns false
, execution is halted, and all state changes revert.
require(msg.sender == player, "description");
Unlike revert
, require
embeds the conditional check directly within the statement. For instance, require(condition, "description")
; is equivalent to explicitly writing if (!condition) { revert("description");}
.
function withdraw() public {
require(msg.sender == player, "Only the player can withdraw funds");
payable(owner).transfer(address(this).balance);
}
Require statement with an error message.
For example, if the condition in a require
statement of a withdraw
function is false, the error message “Only the player can withdraw funds” is returned, and changes are reverted.
The assert
function enforces internal invariants and detects errors in smart contracts to ensure invariant conditions hold during execution.
A failed assert
statement indicates a critical program error. Unlike require
, assert
is for internal consistency and correctness, not user input or external validation.
When an assert
fails, the EVM triggers a Panic(uint256)
error, and state changes are rolled back. This error is associated with specific internal error categories represented by unique error codes.
The error return value of Panic(uint256) is composed of:
Function selector identifies the error, ensuring the correct error-handling routine is invoked. The selector is the first 4 bytes of the keccak256 hash of the string.
Error code: A uint256
value specifying the type of issue (e.g., overflow, division by zero).
In the following example contract, testOverFlow
reverts with a Panic(uint256)
error due to an arithmetic overflow. The return value includes the selector 0x4e487b71
and the code 0x011
.
function testOverflow() public pure {
uint256 maxUint = type(uint256).max;
uint256 result = maxUint + 1; // This causes an overflow
assert(result > maxUint); // Fails and triggers Panic(0x11)
}
Similarly, division by zero, popping an empty array, and array-out-of-bounds also trigger panic errors. This behavior differs from revert errors, which can include custom error messages. Instead, panic errors encode the reason for failure in a standardized format for internal exceptions.
An out-of-gas operation in Solidity occurs when a smart contract execution consumes more gas than the transaction's gas limit. This can happen due to inefficient operations, infinite loops, or computationally expensive tasks.
When this happens, the operation halts, and the EVM reverts all state changes. However, unlike require()
or revert()
, an out-of-gas situation stops execution without passing any reason back to the caller.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract ExpensiveEtherTransfer {
function sendEtherToMultiple(address payable[] memory recipients) public payable {
uint256 amountPerRecipient = msg.value/recipients.length;
for (uint256 i = 0; i < recipients.length; i++) {
require(recipients[i] != address(0), "Invalid address");
// Transfer Ether to each recipient
recipients[i].transfer(amountPerRecipient);
}/
}
}
For high-level function calls (e.g., contract.method()
), the EVM reverts execution immediately when the gas runs out, and no error message is returned because there isn’t enough gas to encode it. In low-level function calls (e.g., call, delegatecall
), an out-of-gas error causes the success
flag to return false
, and the bytes
return data remains empty (0x
), providing no additional information. This equally applies to assembly calls.
Note: Out-of-gas errors are not part of Solidity's error or Panic(uint256)
mechanisms. Instead, they are intrinsic EVM-level failures caused by insufficient gas.
Try/catch
is a control structure for handling exceptions during external function calls or contract interactions. The block handles runtime errors locally without impacting state changes made before the error. If an error occurs within a called contract, only state changes within that specific contract are reverted.
In these situations, any error within the calling contract (e.g., inside the try or catch block itself) will not be automatically caught by try/catch
, and the transaction will revert unless handled separately. This means handling errors occurring within the try-or-catch block itself with explicit error handling (like revert
, require
, or custom error handling) methods.
contract TryCatchExample {
event Success(string message);
event ErrorHandled(string reason);
event LowLevelError(string description);
ExternalContract public externalContract;
// Constructor initializes the external contract
constructor(address externalContractAddress) {
externalContract = ExternalContract(externalContractAddress);
}
// Function to call the riskyOperation of the external contract
function execute(uint256 value) public {
try externalContract.riskyOperation(value) returns (string memory result) {
// Catch successful execution
emit Success(result);
} catch Error(string memory reason) {
// Catch revert errors with a reason string
emit ErrorHandled(reason);
} catch (bytes memory lowLevelData) {
// Catch low-level errors (e.g., out-of-gas or invalid opcodes)
emit LowLevelError("Low-level error encountered");
}
}
}
From the code snippet above, the catch Error(string memory reason)
block catches Error(string)
-based errors. It extracts the reason string and allows the developer to handle it programmatically. If the revert
or require
statement includes a reason string, it will match the Error(string)
format and can be caught in this block.
This means that revert("Some reason")
or require(false, "Some reason")
will be intercepted by the catch Error(string memory reason)
block.
However, custom errors (e.g., error CustomError(uint256
value)
) have no direct support in try/catch
syntax to define a specific catch
block like catch CustomError(){}
.
The catch (bytes memory lowLevelData)
or catch() {}
in the try/catch
block serves to handle low-level errors and unspecified errors that are not explicitly captured by the earlier catch
blocks.
Understanding how reverts are triggered helps Solidity developers debug failed conditions, validate inputs, and prevent unexpected behavior. This understanding is crucial for reducing gas costs and ensuring the contract’s security.
Furthermore, handling errors in external calls using try/catch blocks allows developers to manage exceptions locally without affecting the overall contract state. This makes it easier to debug and test smart contracts effectively.