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:
- Detect visible functions
- Match visible functions with expected signatures
- Find custom modifiers
- Detect definition of events
- Detect emission of events
- Verify public getters
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:
- Parse the contract
- Get its functions
- Keep functions with
public
orexternal
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!.