DEV Community

Franco Victorio
Franco Victorio

Posted on

Debugging transactions in Ethereum. Part 1: The Long and Winding Road

Do you know that happy feeling when a transaction fails and you are the one that gets to debug it? Of course you don't.

Debugging transactions in Ethereum is still a major pain point. While there's been a lot of progress (and I hope to cover much of it in this series), it remains an unpleasant activity. So the more tools you have at your disposal, the better.

We'll start by exploring the data we get from etherscan. All of the transactions I'll use as examples were executed in Rinkeby, but everything should be valid for Kovan or Mainnet. I think. I hope.

(I'll also use eth-cli for performing some simple tasks. You can install it by doing npm install -g eth-cli, but you don't need to.)

Function selectors

Let's start with a very simple contract:

contract Box {
    uint256 public value;

    function inc() external {
        value++;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now let's say we are interacting with this contract somehow (maybe we are building a dapp for it) and our transaction fails. Check for example this transaction:

A failing transaction

There isn't a lot of data here, but we can manage to get some useful information out of it.

If you click on "Click to see More", you'll see more data about this transaction. The part we care about now is the Input Data, a bunch of bytes that make up our transaction.

More data about our tx

Input data always starts with a function selector: 8 characters (4 bytes) that identify the method being called. So you can start by checking that you are calling the proper method. In this case, since there's only one function, you can use eth method:hash 'inc()' to find out its selector. Turns out it's 371303c0, but in the failing transaction the 4 bytes are 33da8f9c. So we found the problem!

In this case I artificially made the situation harder by using a weird name (it's incrementPlease(), in case you care). But in other cases it's even easier.

In this transaction, etherscan correctly decodes the method being called.

Automatically decoded function selector

I don't know exactly when it does this and when it doesn't (maybe it leverages the 4byte directory?), but in any case if the method being called is common enough, then it will probably be automatically decoded here, as in this case where we are calling increment() instead of the correct one, inc().

Revert reasons

Let's add another method to our contract:

contract Box {
    // ...

    function dec() external {
        require(value > 0, "Value must always be positive");
        value--;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we have a require with a revert reason. In this transaction we get an error, but we can easily guess what happened thanks to the message in the "Status" row.

Alt Text

This helps a lot, so the take-away is simple: always add reason strings to your reverts!

Of course, if you don't include a reason string, like here, then you'll need to try other approaches.

Digression: how revert reasons work

Curious about how to recover the error manually? (Hopefully you will never have to do this.) First, we make an eth_call with the same data, and at the same block number (0x5c471e is 6047518, the block number where the failed transaction was mined)

$ curl -H "Content-Type: application/json" -X POST --data \
        '{"id":1, \
        "jsonrpc":"2.0", \
        "method":"eth_call", \
        "params":[ \
          {"from": "0x9A2015Ed446E7A7450b9175413DEb04Fe4e555c2", "to": "0x23c1fd51DD362D87A9E20F7370B7E9A0CbC40D4f", "value": "0x0", "data": "0xb3bcfa82"}, \
          "0x5c471e" \
        ]}' https://rinkeby.infura.io/

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": "0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001d56616c7565206d75737420616c7761797320626520706f736974697665000000"
}
Enter fullscreen mode Exit fullscreen mode

And then we interpret the result as the data of a call to the function Error(string):

$ eth method:decode 'Error(string)' '0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001d56616c7565206d75737420616c7761797320626520706f736974697665000000'

[
  "Value must always be positive"
]
Enter fullscreen mode Exit fullscreen mode

Invalid opcodes

Now let's add a third method to our contract:

contract Box {
    // ...

    function divideBy(uint256 x) external {
        value = value / x;
    }
}
Enter fullscreen mode Exit fullscreen mode

Again, this is a very simple code, so you can probably guess what's coming, but let's do it anyway.

This transaction fails and we don't get an error message. Instead, there's an invalid opcode 0xfe.

Alt Text

If you check the list of op codes you'll see that 0xFE means "INVALID". If we decode the data that was sent:

$ eth method:decode 'divideBy(uint256)' 0x1fcc4afb0000000000000000000000000000000000000000000000000000000000000000
["0"]
Enter fullscreen mode Exit fullscreen mode

Then the error is clear: there was a division by zero that triggered this. Another example of an invalid opcode being thrown happens when you try to access an array using and index that is out of bounds.

We had to manually decode the data because the contract is not verified. If it were, then etherscan would've decoded it for us.


Of course, in most real life scenarios your code won't be so easy to read as it was in these examples. In the next post, we'll continue to explore what can be done in those cases.

Top comments (0)