How to prevent stuck tokens in contracts
And other use cases for the popular EIP-165
Do you remember the beginning of the Dark Forest story? If not, let's look at it again:
Somebody sent tokens to a smart contract that was not intended to receive tokens. This perfectly illustrates one of the issues not only with ERC-20 tokens, but generally with smart contracts. How can we find out if a contract actually supports being the receiver or owner of some interface/token?
You can send tokens to any smart contract, but they will mostly just be locked and not usable. This is because a smart contract (in contrast to an EOA) is not able to do arbitrary calls to other smart contracts. It only supports the functionality that actually has been implemented.
This has resulted in a lot of tokens being lost. Just take a look at following token contracts. Those are all ERC-20 contracts where users sent the token directly to the contract itself, forever locking them:
- GNT, $35,000 lost (see on Etherscan)
- DGD, $62,000 lost (see on Etherscan)
- OMG, $82,000 lost (see on Etherscan)
- ZRX, $92,000 lost (see on Etherscan)
This is where EIP-165 comes in. Let's take a closer look at it.
What is EIP-165?
At its core it's actually just one function:
interface ERC165 {
function supportsInterface(bytes4 interfaceID) external view returns (bool);
}
Now a contract can implement this interface and return true for every supported interfaceID
.
What is an interfaceID?
The given interface ID is an identifier that is computed as the XOR (explicit OR) of all function selectors that are part of the interface.
How do you calculate the id?
Previously you had to do a calculation as shown on the right. This would calculate the required XOR from all ERC20 functions. The order here doesn't matter for the result, yet every function will affect every single bit in the final result.
Since Solidity 0.7.2 you can now write type(IERC20).interfaceId
.
function calcErc20InterfaceId() returns (bytes4) {
return ERC20.transfer.selector
^ ERC20.transferFrom.selector
^ ERC20.approve.selector
^ ERC20.allowance.selector
^ ERC20.totalSupply.selector
^ ERC20.balanceOf.selector;
}
Why is the interfaceId of type bytes4?
First of all a function selector is of type bytes4. Now we still could have created a way to compute larger interface ID's out of it. But bytes4 still gives 2^32-1 = 4,294,967,295 different interface IDs. With bytes4 we also can store multiple supported interfaces in very space efficient mapping(bytes4 => bool)
.
Although we also want to avoid collisions. Maybe you've heard of the birthday paradox? Let's do an interesting short excursion...
No, we don't mean this paradox.
The birthday paradox states you don't need to have a lot of people in the same room for two of them to have the same birthday, despite there being 365 days in a year. In fact, just 23 people in one room will have a 50% chance to have at least one match.
Transferred to our scenario, you can use a calculator: https://instacalc.com/28845 to compute collisions for our interface IDs.
Given 10,000 different interfaces, there is a 98.84% chance of no collisions, while 100,000 will yield only 31%. However this is likely still good enough, because to really be a problem not only needs there be a collision, but somebody actually has to use exactly the two colliding interfaces by accident interchangeably. Think of it as it's okay to have people with the same birthdays in the world, just not in the same room.
Base EIP-165 Implementation
contract ERC165Implementation is ERC165 {
mapping(bytes4 => bool) private supportedInterfaces;
constructor() {
supportedInterfaces[this.supportsInterface.selector] = true;
}
function supportsInterface(bytes4 interfaceID) external view returns (bool) {
return supportedInterfaces[interfaceID];
}
}
Like this the contract is not yet any useful. Let's see how you could use it today with the latest tools using:
- Solidity 0.7.2+
- v3.2 Openzeppelin contracts for Solidity 0.7
- Truffle, Buidler or Remix
Example Usage for ERC-20 tokens
1a. Adding EIP-165 to your ERC-20
Let's see how we can use EIP-165 with ERC-20 tokens. Since Solidity v0.7.2 there is built-in EIP-165 support.
- Start a project with Truffle, Buidler or Remix, you can follow the instructions here if you need to.
- Install the openzeppelin contracts. Since we require Solidity 0.7, we need the newest pre-release.
- When using Truffle/Buidler, install via
npm using npm install @openzeppelin/contracts@solc-0.7
. - When using Remix simply import the contracts via Github urls. You can see below how to import all required contracts in Remix.
- Create a
TestERC20
token contract as shown on the right.
// SPDX-License-Identifier: MIT
pragma solidity 0.7.4;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/introspection/ERC165.sol";
contract TestERC20 is ERC20, ERC165 {
constructor() ERC20("","") {
_registerInterface(type(IERC20).interfaceId);
_registerInterface(ERC20.name.selector);
_registerInterface(ERC20.symbol.selector);
_registerInterface(ERC20.decimals.selector);
}
}
As you can see, we can
// import in Remix
import "http://github.com/OpenZeppelin/openzeppelin-contracts/blob/v3.2.1-solc-0.7/contracts/token/ERC20/ERC20.sol";
import "http://github.com/OpenZeppelin/openzeppelin-contracts/blob/v3.2.1-solc-0.7/contracts/introspection/ERC165.sol";
import "http://github.com/OpenZeppelin/openzeppelin-contracts/blob/v3.2.1-solc-0.7/contracts/introspection/ERC165Checker.sol";
// SPDX-License-Identifier: MIT
pragma solidity 0.7.4;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/introspection/ERC165Checker.sol";
contract UsingTestERC20 {
using ERC165Checker for address;
function addToken(address test) external {
require(
test.supportsInterface(type(IERC20).interfaceId),
'Address is not supported'
);
// store token now
}
}
1b. Checking for implemented ERC-20 interfaces
This now allows us to see if an address is actually an ERC-20 token. Inside the addToken function, we will only accept actual ERC-20 addresses ensuring to only add expected ERC-20 addresses. This means we can later use those and call ERC-20 functions without failures.
Note: Of course this only works for ERC-20 tokens that have implemented EIP-165, so you will get false negatives, but you will never get any false positives.
2a. Adding EIP-165 to a token storage contract
Now remember back the issue with money lost for tokens being sent to a non-supporting contract. Let's see how we can solve this problem.
Let's use a storage contract that contains a withdrawToOwner
function. Given this withdraw function, we can send any ERC-20 tokens to this smart contract without the funds getting lost.
Once again, to use the EIP-165 we just register the interface in the constructor. And we implement a simple withdrawToOwner
function.
// SPDX-License-Identifier: MIT
pragma solidity 0.7.4;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/introspection/ERC165.sol";
interface ArbitraryTokenStorage {
function withdrawToOwner(IERC20 token) external;
}
contract ERC20Storage is ERC165, ArbitraryTokenStorage {
address public owner;
constructor() {
owner = msg.sender;
_registerInterface(type(ArbitraryTokenStorage).interfaceId);
}
function withdrawToOwner(IERC20 token) external override {
uint256 balance = token.balanceOf(address(this));
require(balance > 0, "Contract has no balance");
require(token.transfer(owner, balance), "Transfer failed");
}
}
// SPDX-License-Identifier: MIT
pragma solidity 0.7.4;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/introspection/ERC165Checker.sol";
contract UsingTestERC20 {
using ERC165Checker for address;
function secureSendToken(IERC20 token, address to, uint256 amount) external {
require(
to.supportsInterface(type(ArbitraryTokenStorage).interfaceId),
'Address is not supported'
);
require(token.transfer(to, amount), "Transfer failed");
}
}
2b. Checking for implemented token storage interfaces
Now we implement a second contract with a secureSendToken
function. We send tokens from our contract to some other contract, but only if it is in fact an ArbitraryTokenStorage
contract.
If it's any other contract or not a contract, the function will revert. Ensuring we don't sent tokens to an invalid (not supporting) address.
What's next?
Now that you know EIP-165, consider using it in your contracts. It's not always necessary in my opinion. But in fact EIP-165 is already used in quite a few other standards including the known ERC-721 and ERC-777.
Actually ERC-777 is using the newer EIP-1820 which has backwards compatibility to EIP-165, but adds additional functionality for non-contract addresses to register an interface. We will look at EIP-1820 and ERC-777 in more details in the future.
What's your take on EIP-165? Have you already used it?
Solidity Developer