How to prevent stuck tokens in contracts

And other use cases for the popular EIP-165

Dark Forest

Do you remember the beginning of the Dark Forest story? If not, let's look at it again:

"

On Wednesday afternoon, someone asked whether it was possible to recover Uniswap liquidity tokens that had been accidentally sent to the pair contract itself.

Dan Robinson

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:

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...

Birthday Raptor

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.

Bday Paradox Interfaces 10k
Bday Paradox Interfaces 100k

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

Now with this in mind, a base implementation could look as shown on the right. Any contract supporting EIP-165 must also return true for the interface of the supportsInterface function itself.


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.

  1. Start a project with Truffle, Buidler or Remix,  you can follow the instructions here if you need to.
  2. 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.
  3. 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?


Markus Waas

Solidity Developer

More great blog posts from Markus Waas

© 2024 Solidity Dev Studio. All rights reserved.

This website is powered by Scrivito, the next generation React CMS.