OpenZeppelin's online ERC20 verifier: behind the scenes

image

In this article we’ll cover how the Online ERC20 Verifier works behind the scenes, going one by one through its features and learning how we built it leveraging Slither, an open-source static analysis tool.


There are many situations in which you may need to understand if a given contract is compliant with the popular ERC20 interface. If you’ve ever tried to build a dApp that interacts with several of them, you already know. In my case, I was getting a bit bored of manually auditing ERC20s. So I coded some Python scripts to test for the features I was previously auditing by hand.

A few days ago, @spalladino shipped an Online ERC20 Verifier in one sitting. The app pulls the Solidity code from Etherscan given an address, and runs static analysis scripts over the token’s code. Which scripts? The ones I had been using in my audits.

By following how I coded these scripts, you’ll learn how to:

Before we get started, a quick FAQ section.

  • Which static analysis are we using ? Slither.
  • Does the ERC20 Verifier check behavior ? NO, the transfer function of the token might as well be stealing all tokens, and this tool wouldn’t even notice.
  • Does it replace an audit? NO, light-years away from it. And it doesn’t even intend to.
  • Is it 100% reliable ? NO, we always double-check all findings during an audit by at least two human brains.
  • Is it open-source and free ($) to use ? Of course. So we need you and the whole community to help us improve it.

Detecting visible functions

The first step to analyze a contract’s public interface is to filter out functions that are not part of it. We’re only interested in public or external functions, what can be called the visible functions of a contract. All we need to do is:

  1. Parse the contract
  2. Get its functions
  3. Keep functions with public or external visibility
from slither.slither import Slither

def is_visible(function):
    return function.visibility == "public" or function.visibility == "external"

# Instance Slither
slither = Slither(filename)

# Get a reference to the contract
contract = slither.get_contract_from_name(contract_name)

# Find all visible functions
visible_functions = [f for f in contract.functions if is_visible(f)]

Slither allows us to treat contracts as Python objects. For instance, we can easily get all functions of a Solidity contract by just calling contract.functions and get a function’s visibility by calling function.visibility.

Matching visible functions with expected signatures

Of all visible functions, we only want to keep those that match a signature in the ERC20 interface, which in our custom script we can represent as follows:

ERC20_FX_SIGNATURES = [
    Signature("transfer", ["address", "uint256"], ["bool"]),
    Signature("approve", ["address", "uint256"], ["bool"]),
    Signature("transferFrom", ["address", "address", "uint256"], ["bool"]),
    Signature("allowance", ["address", "address"], ["uint256"]),
    Signature("balanceOf", ["address"], ["uint256"]),
]

Note that I built a custom Signature class to make things easier in future steps of the script.

Now it’s just a matter of comparing each visible function signature with our expected signatures and see which ones match. Again, as Solidity functions are just Python objects, see below that it’s possible to call function.signature and get a tuple containing (function name, list of parameter types, list of return value types).

def find_match(elements, signature):
    """
    Check whether a signature is found in a list of elements,
    returning either the matching element or None
    """
    return next((e for e in elements if e.signature == signature), None)


def verify_signatures(elements, expected_signatures):
    """
    Compares a list of elements and expected signatures.
    Returns a list of tuples containing (Signature, matching object or None)
    """
    return [(sig, find_match(elements, sig)) for sig in expected_signatures]


erc20_fx_matches = verify_signatures(visible_functions, ERC20_FX_SIGNATURES)

Prettifying and printing the result, for a fully compliant ERC20, we get:

== ERC20 functions definition ==
[✓] transfer (address, uint256) -> (bool)
[✓] approve (address, uint256) -> (bool)
[✓] transferFrom (address, address, uint256) -> (bool)
[✓] allowance (address, address) -> (uint256)
[✓] balanceOf (address) -> (uint256)

Finding custom modifiers

Thanks to the tool’s static analysis features, we can get a list with all modifiers of a function by just accessing a Function object’s modifiers atrribute. This will return a list containing Modifier objects (which are just Function objects by the way).

For our purposes, as the expected behavior for an ERC20 would be to have no custom modifier in any function that is part of the standard interface, the script takes care of checking whether there’re custom modifiers in these functions and logging the results.

If any found:

== Custom modifiers ==
[x] transfer modified by pausable()
[x] approve modified by onlyOwner()

When none found:

== Custom modifiers ==
[✓] No custom modifiers in ERC20 functions

Detecting definition of events

Each Contract object also has an events attribute, which returns a list of all events defined in the contract. Solidity events are represented by the Event class, so it’s possible to get any event’s signature by just reading the signature attribute.

We can leverage this to understand if a given ERC20 contract defines the expected Transfer and Approval events using the required signature. Similar to how we defined function signatures before, now we should define expected event signatures, such as:

ERC20_EVENT_SIGNATURES = [
    Signature("Transfer", ["address", "address", "uint256"]),
    Signature("Approval", ["address", "address", "uint256"]),
]

The procedure to check if defined events signatures match the expected ones is exactly the same as with functions.

Detecting emission of events

Knowing that a event’s been defined is not enough. We ought to detect if the event is actually emitted. So first, let’s define which events are supposed to be emitted in each ERC20 function:

ERC20_EVENT_SIGNATURES = [
    Signature("Transfer", ["address", "address", "uint256"]),
    Signature("Approval", ["address", "address", "uint256"]),
]

ERC20_EVENT_BY_FX = {
    "transfer": ERC20_EVENT_SIGNATURES[0],
    "approve": ERC20_EVENT_SIGNATURES[1],
    "transferFrom": ERC20_EVENT_SIGNATURES[0],
    "allowance": {},
    "balanceOf": {},
}

Once we’ve done that, now let’s see how we can detect a certain event emission in a single function. Keep in mind that we should do this recursively, because a function may not emit an event, but another function called by the first one might, and so on.

First, we have to find all events emitted in a given Solidity function. Each Function object has a list of Node objects. For each Node object in the function, we can access a list of SlithIR operations that together explain, at a lower level, what’s going on in that specific node. As we’re looking for event emissions, we should find EventCall operations in these nodes. Everything taken care of by the get_events function of our script.

Once we get all event calls (a list of EventCall objects), we must validate whether any matches the one we’re looking for (comparing both name and parameter types). To achieve this, we can read each EventCall name and its arguments. If both name and argument types match, we’ve found our winner!. Otherwise, we have to keep looking and check whether our function does any internal call to other contract function, and if it does, recursively verify if this function emits the event we’re trying to find. To access a Solidity function’s internal calls, we read the internal_calls attribute of the Function object.

And that’s it. Here’s the output.

== ERC20 events ==
[✓] Transfer (address, address, uint256)
[✓] Approval (address, address, uint256)
[✓] transfer must emit Transfer (address, address, uint256)
[✓] approve must emit Approval (address, address, uint256)
[✓] transferFrom must emit Transfer (address, address, uint256)

Verifying public getters

ERC20 tokens SHOULD define public getters for symbols, name, decimals and totalSupply. Depending on the implementation, this could either be achieved by automatic getters generated by Solidity when a state variable is declared as public, or via explicitly defining getters with public functions that read private state variables. Therefore, we have to take into account both scenarios.

The expected getter functions are:

ERC20_GETTERS = [
    Signature("totalSupply", [], ["uint256"]),
    Signature("decimals", [], ["uint8"]),
    Signature("symbol", [], ["string"]),
    Signature("name", [], ["string"]),
]

We already know how to obtain a contract’s visible functions. And for the state variables, let’s use the state_variables attribute of a Contract object.

For a state variable to match one of the expected signatures, it must: i) be declared public, ii) be called exactly the same and iii) have the same type as the getter’s return value. To check such attributes, we read the visibility, name and type attributes of a StateVariableSolc object. If no public state variable matches all of these requirements, then we should go through all visible functions. Checkout the full code to do this in the verify_getters function of the script.

A sample output can be:

== ERC20 getters ==
[✓] totalSupply () -> (uint256)
[x] decimals () -> (uint8)
[x] symbol () -> (string)
[x] name () -> (string)

Beyond

We explored many features of Slither’s Python API, and there are still many more that we didn’t cover. For instance, our script can discover non-standard balance checks in the approve function of an ERC20 token.

The code gets a bit more complicated though, as we need to check a read operation of a mapping in the contract’s state, that in a require statement is compared to an argument passed to the function. Checkout how the script attempts to accomplish this for yourself.

Now that you’ve learned about the internals of the Online ERC20 Verifier, we’re eager to see how you and the whole community help us improve it. Can we build a decent test suit for it ? Are there any bugs ? Can we extend it to other standards, such as ERC721 ? Can we make it more efficient ?.

Possibilities are endless - happy hacking!.

3 Likes