Bubbling up errors in Solidity

Let’s say that you have a contract A which makes a low-level call or delegatecall to another contract B:

contract A {
  function foo(address target, bytes data) external {
    (bool success, bytes memory result) = target.delegatecall(data) // used to call B.bar()
  }
}

The target contract B reverts with a revert message or custom error:

contract B {
  error AccessForbidden(address sender);

  function bar() external {
    revert AccessForbidden(msg.sender);
  }
}

How can you bubble the error up in contract A?

Low-level calls do not revert

First of all, it’s important to know that a low-level call or delegatecall doesn’t revert while calling a function that reverts:

// These won't revert even if the target contract reverts!
(bool success, bytes memory result) = target.call(data);
(bool success, bytes memory result) = target.delegatecall(data);

In the above example, when contract A calls contract B with a low-level call, The success variable signals whether the call was successful (true) or unsuccessful (false).

Using this, we could revert in the calling contract like so:

contract A {
  function foo(address target, bytes data) external {
    (bool success, bytes memory result) = target.delegatecall(data);
    require(success);
  }
}

However, the above code swallows any errors returned from the target contract! We don’t know what went wrong because no errors are bubbled up even though contract B reverts.

Bubbling up errors

Let’s examine the following solution:

(bool success, bytes memory result) = address(this).delegatecall(data);
if (!success) { // If call reverts
  // If there is return data, the call reverted without a reason or a custom error.
  if (result.length == 0) revert();
  assembly {
    // We use Yul's revert() to bubble up errors from the target contract.
    revert(add(32, result), mload(result))
  }
}
  • An inline assembly block is marked by assembly { ... }, where the code inside the curly braces is code in the Yul language.
  • result is a dynamic-size byte array. If the external contract call fails, the error object is returned in result.
  • The Yul revert function takes in two inputs: 1) the pointer of where the error byte array starts, and 2) how long the byte array is.
  • For dynamic-size byte arrays, the first 32 bit of the pointer stores its size.
    • To fill in 1), we do add(32, result) to calculate the pointer where the byte array starts.
    • To fill in 2), we do mload(result) to retrieve this value.
    • Combining the above, we get revert(add(32, result), mload(result)), which reverts the error raised in contract B.
  • When there is no revert data returned by the target contract, we terminate early with an empty revert.

Now, when you call contract B from contract A you get the following revert message:

"AccessForbidden(0x123...)"

In unit tests, you can write the following (with Hardhat):

it('reverts', async function () {
  await expect(
    myContract.foo(TEST_ADDRESS, TEST_BYTES),
  ).to.be.revertedWith('AccessForbidden()');
});

Nice! By bubbling up errors we ensure that contracts do not fail silently and fail with additional context when they are available.