Published on

Hyperledger Besu Web3j where is my Revert Reason

Authors

When working with Solidity it is common to ensure data is valid and rules are enforced by using requires.

Require also allows an optional error message to be supplied for example:

require(msg.sender != address(0), "MyContract: Sender cannot be the null address");

This message bubbles up as what is known as the revert reason.

One thing that frustrated me when working with a private Besu node was that I kept getting generic errors - i.e. my revert reason's were not bubbling up.

Configuring a Private Network Besu Node to Return Revert Reasons

After a bit of digging, I came across this in the official docs. This means that we have to explicitly turn it on using the flag revert-reason-enabled.

  • As mentioned in the docs this is very memory intensive and should not be done when connecting to the public chain

Another important thing to note is this snippet from the where is the revert reason included page:

Not being included in the transactions receipts root hash means the revert reason is only available to nodes that execute the transaction when importing the block. That is, the revert reason is not available if using fast sync.

Ok cool, we need to make sure fast sync is turned off.

After looking through the fast-sync-min-peers page it looks like this is by default 5 (i.e. if you do not specify this flag explicitly it will turn on at 5+ nodes). So to ensure you have revert reasons on a private chain you need to explicitly specify this flag with a high number and the revert reason flag. If you have a config.toml it will need to include options that look as below:

fast-sync-min-peers=1000000
revert-reason-enabled=true

Decoding Revert Reasons

Now that we have our config in order we need to also write some code to fetch and decode this revert reason for us since as per the docs:

Client libraries (eg, web3j) do not support extracting the revert reason from the transaction receipt. To extract the revert reason your Dapp must interact directly with Besu using a custom JSON -> Object converter.

When running this it will give you a JSON response similar to the below when hitting the eth_gettransactionreceipt.

For example if I curled the hash of a failing transaction:

curl -X POST --data '{"jsonrpc":"2.0","method":"eth_getTransactionReceipt","params":["0x504ce587a65bdbdb6414a0c6c16d86a04dd79bfcc4f2950eec9634b30ce5370f"],"id":53}' http://127.0.0.1:8545

I would then get something like the following:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "blockHash": "0xe7212a92cfb9b06addc80dec2a0dfae9ea94fd344efeb157c41e12994fcad60a",
    "blockNumber": "0x50",
    "contractAddress": null,
    "cumulativeGasUsed": "0x5208",
    "from": "0x627306090abab3a6e1400e9345bc60c78a8bef57",
    "gasUsed": "0x5208",
    "logs": [],
    "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
    "status": "0x1",
    "to": "0xf17f52151ebef6c7334fad080c5704d77216b732",
    "transactionHash": "0xc00e97af59c6f88de163306935f7682af1a34c67245e414537d02e422815efc3",
    "transactionIndex": "0x0",
    "revertReason": "0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001a4e6f7420656e6f7567682045746865722070726f76696465642e000000000000"
  }
}

The revertReason field can be decoded based on the reason formatting described in the docs:

0x08c379a0                                                         // Function selector for Error(string)
0x0000000000000000000000000000000000000000000000000000000000000020 // Data offset
0x000000000000000000000000000000000000000000000000000000000000001a // String length
0x4e6f7420656e6f7567682045746865722070726f76696465642e000000000000 // String data

I wrote the following snippet of code which decodes this in Kotlin:

    fun decodeRevertReason(encodedRevertReason: String): String {
        val errorMethodId = "0x08c379a0"
        val revertReasonTypes = listOf<TypeReference<Type<*>>>(TypeReference.create<Type<*>>(AbiTypes.getType("string") as Class<Type<*>>))
        val encodedWithoutPrefix = encodedRevertReason.substring(errorMethodId.length)

        val decoded = FunctionReturnDecoder.decode(encodedWithoutPrefix, revertReasonTypes)

        return (decoded[0] as Utf8String).value
    }

The above example's revert reason decodes to: Not enough Ether provided.

Overriding web3j Generated Contract Methods

Unfortunately in web3j as of the writing of this post, the transaction hash of failed (i.e. reverted transactions) is not logged out. As a workaround, I extended my generated contract to override the executeTransaction method so that the exception thrown at least has the transaction hash in.

For example:

package foo.bar.contracts

import org.web3j.abi.FunctionEncoder
import org.web3j.abi.datatypes.Function
import org.web3j.crypto.Credentials
import org.web3j.protocol.Web3j
import org.web3j.protocol.core.methods.response.TransactionReceipt
import org.web3j.protocol.exceptions.TransactionException
import org.web3j.tx.gas.ContractGasProvider
import foo.bar.generated.FizzBuzzContract
import java.math.BigInteger

class FizzBuzzContractExtended(
        contractAddress: String?,
        web3j: Web3j?,
        credentials: Credentials?,
        contractGasProvider: ContractGasProvider?
) : FizzBuzzContract (
        contractAddress,
        web3j,
        credentials,
        contractGasProvider
) {


    override fun executeTransaction(function: Function?): TransactionReceipt {
        val receipt = send(
                contractAddress,
                FunctionEncoder.encode(function),
                BigInteger.ZERO,
                gasProvider.getGasPrice(function!!.name),
                gasProvider.getGasLimit(function!!.name),
                false
        )

        if (!receipt.isStatusOK) {
            throw TransactionException("Transaction has failed with status: ${receipt.status}. Gas used: ${receipt.gasUsed}. Transaction hash: '${receipt.transactionHash}'")
        }

        return receipt
    }


    companion object {
        fun load(contractAddress: String?, web3j: Web3j?, credentials: Credentials?, contractGasProvider: ContractGasProvider?): FizzBuzzContract {
            return MyContractExtended(contractAddress, web3j, credentials, contractGasProvider)
        }
    }
}

Using a Transaction Hash to Determine the Revert Reason

With the above modification in place we can now try get the revert reason for a transaction. The Besu client exposes the revert reason in eth_getTransactionReceipt as long as it is exposed as described above.

Using Spring it is trivial to retrieve this reason using the rest template. Example code that does this in Kotlin is below:

    fun getRevertReason(transactionHash: String): String {
        val request = JSONRPCRequest(
                "2.0",
                "eth_getTransactionReceipt",
                arrayOf(transactionHash),
                53
        )

        val response = restTemplate.postForEntity(besuEndPointURLAndPort, request, Map::class.java)
        val responseResult = response.body["result"] as Map<String, String>
        return responseResult["revertReason"] ?: ""
    }

And the JSONRPCRequest class from above is defined as:

package com.example

data class JSONRPCRequest(val jsonrpc: String, val method: String, val params: Array<String>, val id: Int)

While using this approach results in an additional network call, so are other approaches to retrieving the revert reason.