This workshop was recorded on the 15th July 2021 and led by Martin Abbatemarco, Security Researcher at OpenZeppelin. It is the inaugural workshop of a series that covers multiple topics around secure development of smart contracts.
In this workshop, we covered the following:
- Intro to composability in Ethereum
- Initial triggers and intuitions to code review a simple token integration
- 20+ edge cases when interacting with tokens using real examples
- Must-have and nice-to-have measures for secure integrations
You can watch the video, view the slides, and learn about numerous edge cases that you should be aware of when integrating tokens into a project. Beyond learning specific cases with code examples and real threats in Ethereum’s mainnet, throughout the workshop you will learn more about our approach and mindset for code reviews, which you can translate to your own projects.
Risks of composability
The interoperable nature of Ethereum means applications heavily rely on one another. Composability refers to the communication between DEXs, lending pools, aggregators, oracles, and other building blocks, continuously introducing novel and valuable dynamics. In practice, composability often translates to actual code integrations like, calling other people’s contracts from your own. Yet the more DeFi layers we integrate and compose, the more careful we need to be. As the stack grows, so does systemic risk. Thus it becomes fundamental to understand how the dynamics, behaviors, peculiarities and subtleties of other systems can affect our own operations.
About this workshop
One key aspect of composability is token integration. Even the most popular tokens can have deceiving behaviors, often overlooked until it is too late. This workshop aims at raising developer awareness on this front, highlighting numerous considerations to bear in mind when interacting with tokens in mainnet. It should be noted that this workshop is not exhaustive and there may be numerous behaviors not considered here.
A simple token integration: triggers and intuitions
As an ice breaker, let’s cover this simple example of a Solidity contract that allows for the depositing of a token. Read it line by line. How many issues, problems, code smells and questions can you come up with after a thorough pass?
After an initial scan, these might be some of the initial questions and triggers:
- Does this snippet even compile?
- What Solidity version should be used? Depending on the Solidity version, are there risks of overflows?
- There are no docstrings, what is this code supposed to do in the first place?
- Why doesn’t it have functions to withdraw tokens?
- No visibility on state variables? How is the contract’s state expected to be inspected?
- Why doesn’t the contract emit events?
- Is the deposit feature assuming previous approval of tokens?
- Why is the token transfer not checking the usual boolean return value?
- Is there risk of reentrancy?
- Most importantly, is this contract expected to work with any token ?
Regardless of specific issues or security vulnerabilities spotted, the point of this exercise is the importance of asking questions, and being suspicious about every single line of code you review. Besides this healthy skepticism, the focus of an auditor inspecting the piece of code shown will probably be around the kinds of tokens the contract accepts. This notion leads us to the core of the security session: real examples of unexpected behaviors in well-known tokens.
Unexpected token behaviors
In the workshop we covered more than 20 different behaviors across a wide variety of tokens (USDT, USDC, TUSD, DAI, LINK, WBTC, cTokens, aTokens, OpenZeppelin’s ERC20 implementation, and others). For each behavior we showed code snippets, problems and solutions that developers can use as safeguards against unexpected behaviors. We discussed real issues introduced by the nature of some of these tokens, along with prevention use case measures applied by some of the biggest players in the DeFi ecosystem. Specifically, we covered the following scenarios.
Tokens that block addresses
Privileged accounts can control families of tokens. One of the many arbitrary powers these accounts may have is blocking addresses. The USDC and USDT properties are common examples of this behavior. In practice, this means that, upon being blocked, an account will not be allowed to transfer tokens.
As seen in the snippets below for USDC, the token contract inherits from a
Blacklistable contract. Therefore it can use the
notBlacklisted modifier placed on the transfer function to ensure that both the sender and receiver are allowed to handle USDC tokens.
It’s possible to know whether an account is blocked by querying the token contract itself, using its
isBlacklisted function, as seen below.
Tokens that can be paused
On a similar note, some tokens that can be paused. USDC, USDT and BNT are examples of this among many others. Although the specifics depend on each implementation, the general idea is that once a token is paused, transfers and approvals are stopped. Normal operation can be resumed by unpausing the token.
Below we include some related code snippets for USDT, where we can see:
- The pause function to be called by the contract’s owner
- The transfer function marked with the
Tokens that execute arbitrary code on senders/receivers using hooks
Multiple token standards such as ERC777, ERC721 and ERC1363 implement hooks on either the sender account, the receiver, or both. For example, during a transfer, a common ERC777-compliant token might first call the sender account, then modify token balances, and finally call the receiver account. While such behavior can be useful and valuable for some applications, it might become problematic for others. Examples of the latter can be observed in security incidents and known weaknesses in early versions of popular protocols.
Tokens that prevent certain transfer operations
Tokens can even differ in how they handle one of their most fundamental operations – transfers. Depending on which token you’re looking at, you can see instances of transfers not allowed to the token contract itself, the zero address, or the caller’s address. Some tokens even refuse transfers of value zero. As a simple example below, we show a code snippet of BNB, a token that does not allow transfers to the zero address nor transfers of zero value.
Tokens that take fees on transfers
A family of tokens may not transfer the entire amount of tokens specified by the caller. As an example, if you transferred 100 tokens, the receiver could only receive 90. Should contracts integrating with this kind of token not consider such behavior, their internal accounting may be corrupted. The following snippet is an example of that scenario, where if a token that takes fees on transfers is used, the value stored in the
deposits and accumulated in
totalDeposits may differ from the actual amount transferred to the contract.
Examples of tokens that have the potential to take fees on transfers are USDT, or deflationary tokens such as STA (involved in a security incident in Balancer pools).
To prevent internal accounting inconsistencies in these scenarios, projects take different approaches. The snippet below shows how the CErc20 contract of the Compound protocol achieves this in roughly 4 steps:
- Checking the pre-transfer balance
- Executing the actual transfer
- Checking the post-transfer balance
- Only accounting for the difference between the two balances
Tokens that have different ways of signaling success and failure
There are multiple ways of handling success or failure during transfer and approvals. While some tokens revert upon failure, others consistently return boolean flags or have mixed behaviors. Below we include snippets of the transfer functions of ZRX and USDT. In the case of ZRX, the transfer function only returns a boolean flag.
In the case of USDT, we can already see from its declaration that the transfer function does not return any value.
To handle most of these inconsistent behaviors across multiple tokens, you can use the SafeERC20 library available in OpenZeppelin Contracts.
Tokens that can be flash-loaned, flash-minted or have large total supply
Do not assume that an account cannot have a significant amount of tokens in balance. Flash loans and flash mints are here to stay, and they can allow anybody to own a huge amount of tokens, even if just for a single transaction. You should be aware of these dynamics and test for extreme values in your contracts’ functions when necessary. For more details, you can check out OpenZeppelin Contract’s ERC20FlashMint contract.
Tokens that modify balances outside transfer operations
There is a family of tokens known for being able to change token balances outside transfer operations. Some examples of this are UBI, AMPL, or aTokens from AAVE. Regardless of the reasons for this particular behavior, in practice, this means that the amount returned by the tokens’
balanceOf function can change across multiple calls, even if the account never sent nor received tokens. You can usually notice this behavior by inspecting the corresponding
balanceOf functions, where you’ll find non-standard logic. In the case of UBI, we see the following:
In the case of aTokens:
If you are integrating with similar tokens, and keeping an internal accounting of token balances in your own contracts – make sure that you include specific logic to maintain the accounting up-to-date.
Tokens that have an “infinite” approval mechanism
Some tokens allow users to approve an infinite amount of tokens to others. In practice, this means that whenever a
transferFrom operation occurs, the amount of tokens approved is not decreased. Although not consistently implemented, this behavior has become far more common in recent years since it favors the token’s UX. You can find it implemented in many popular tokens such as DAI, as seen in the snippet below.
Tokens that do not expose decimals, name and symbol functions
If you have ever read the ERC20 standard, you may have noticed that the functions
symbol are not mandatory for a token to comply with the interface. In practice, these functions are usually just assumed to be implemented. Most if not all well-known tokens include them. Still, out of an abundance of caution to handle unexpected failures, some projects that may integrate with arbitrary tokens were seen to use the try/catch structure to query these functions.
Tokens that do not have 18 decimals
While tokens with 18 decimals are the norm, do not blindly assume that all this applies to all tokens. USDC has 6 decimals, cTokens from Compound and WBTC have 8. Your internal calculations may be inaccurate if you are handling tokens with different numbers of decimals without considering these cases.
The specifics on how to handle tokens with different numbers of decimals may vary depending on each implementation. Yet, if you dive deep into the codebases of most well-known DeFi projects, you are likely going to find examples on how to do it correctly.
Tokens that do not guard against the ERC20 allowance front-running attack, or do so in a non-standard way
The “allowance front-running attack” in ERC20 tokens is already a well-known weakness of the standard. Some tokens, such as OpenZeppelin Contract’s ERC20 implementation, expose additional functionality in their interface that can help mitigate associated risks. Others take a different approach. In the case of USDT, it does not allow approving tokens when the approval amount is already positive. While this behavior is not fundamentally flawed, it does introduce a deviation that can cause interoperability problems if not considered.
Tokens that do not emit expected events
Off-chain services may rely on logged events to track token operations. However, not all tokens emit the same events. For example, as shown below, MKR does not emit the usual Transfer events when burning and minting tokens.
If these subtleties are not taken into account, your off-chain accounting might be inaccurate and introduce unexpected errors in your systems.
Tokens that can be upgradeable
On top of everything covered in the workshop, it must be noted that some tokens are upgradable. This means that their behavior could change at any point in time. Therefore, when integrating upgradeable tokens, never assume immutability. Instead, you should consistently monitor their upgrade processes and be ready to react quickly to a suspicious or non-compatible upgrade. Examples of upgradeable tokens shown in the workshop are USDC, TUSD and UBI. In USDC, you will find the following upgradeTo function that allows a privileged account to trigger an upgrade.
As upgradeability becomes more popular among Ethereum applications, it is sensible to expect most tokens will include some sort of upgradeability mechanism.
While all general guidelines, specific issues, and security recommendations discussed during the workshop are valuable and relevant in today’s ecosystem, a fundamental aspect of integrating tokens not covered in the workshop is assessing economic risks. There have been multiple cases of protocols failing just due to problems in the underlying assets being used. Risk analysis is of the utmost importance, and responsible teams should aim to have dedicated members studying this aspect of token integration. Even if Solidity smart contracts are implemented flawlessly, price volatility and misalignment of economic incentives often become dangerously problematic and lead to failures.
- Zero trust mindset. Tokens are arbitrary code.
- Have a healthy distrust for standards.
- Consider having a list of allowed tokens. Verify behavior. Analyze risks.
- See how experienced players do it. Learn from their code.
- Document your trust assumptions.
Resources from the Ethereum security community
The Ethereum security community is quite active and collects many of these behaviors in different checklists and repositories. These are some of them:
- Weird ERC20 Tokens (Github)
- Awesome Buggy ERC20 Tokens
- Token integration checklist (Github)
- Token Interaction Checklist (Consensys Diligence)
To see the slides from the webinar click here.
See the documentation: