How to build and use ERC-777 tokens

An intro to the new upgraded standard for ERC-20 tokens

The new upgraded standard for ERC-20 tokens is becoming more and more popular. It's fully backwards compatible, you can easily create one using the Openzeppelin contracts and there are many interesting new features not available in ERC-20.

Should you upgrade from ERC-20? Well let's look into what ERC-777 is.

xkcd upgrades

The Features of ERC-777

Let's explore all features with direct code examples that you can simply follow via Remix. Let's create an ERC-777 contract:

// SPDX-License-Identifier: MIT
pragma solidity 0.7.4;

import "http://github.com/OpenZeppelin/openzeppelin-contracts/blob/v3.2.1-solc-0.7/contracts/token/ERC777/ERC777.sol";
import "http://github.com/OpenZeppelin/openzeppelin-contracts/blob/v3.2.1-solc-0.7/contracts/token/ERC777/IERC777Sender.sol";
import "http://github.com/OpenZeppelin/openzeppelin-contracts/blob/v3.2.1-solc-0.7/contracts/token/ERC777/IERC777Recipient.sol";
import "http://github.com/OpenZeppelin/openzeppelin-contracts/blob/v3.2.1-solc-0.7/contracts/introspection/ERC1820Implementer.sol";
import "http://github.com/OpenZeppelin/openzeppelin-contracts/blob/v3.2.1-solc-0.7/contracts/introspection/IERC1820Registry.sol";

contract TestERC777 is ERC777 {
    constructor(
        uint256 initialSupply,
        address[] memory defaultOperators
    ) ERC777("Gold", "GLD", defaultOperators) {
        _mint(msg.sender, initialSupply, "", "");
    }
}

Don't worry about the imports and defaultOperators yet, we will need them later on.

1. Similar to sending ETH + Data

Now that we have a deployed token, we can use it similarly to ERC-20. One difference though is a new transfer function. While in ERC-20 we used to do token.transfer(receiver, amount), now with ERC-777 we do token.send(receiver, amount, "").

Why this change?

First of all it's now more similar to the way of sending ETH via the send function. And then we also have a new bytes data field which enables one to send arbitrary data along with the transfer. This can be used freely and thus adds extra functionality in the token transfer call. In the regular transfer case you would just leave it empty.

2. Registry (EIP-1820)

This is technically its own standard as the 777 standard was getting too large. So a new standard EIP-1820 was created. One reason the standard was needed was to enable the hooks functionality (see below). But let's look at the 1820 standard now.

We have previously looked at the EIP-165 standard here. As a quick recap EIP-165 allows smart contracts to register as implementing a specific interface. This can ensure no invalid smart contract addresses are used, for example preventing to send tokens to a contract that doesn't have functions to retrieve those tokens. In EIP-165 a contract must return true for the supportsInterface(interfaceId) function if it implements the given interface.

So what's different in EIP-1820?

Single Registry Contract

In contrast to EIP-165 we don't have contracts themselves implementing a supportsInterface function themselves. Instead there is a single registry contract. This registry is always deployed on the same address for every Ethereum network: 0x1820a4b7618bde71dce8cdc73aab6c95905fad24.

How is it ensured that the registry is always available at the same address for each network?

The solution for this is quite interesting. The so-called 'Nick method' was first described here. The cryptographic signature for signed transactions in Ethereum consists of three values, v, r and s. Usually these have to be generated by a private key and the ecrecover function will retrieve the signer public key, i.e., the Ethereum address. But as it turns out you can choose these values at random and in 50% of the times will still get a valid signature.

This is being used by the EIP-1820 standard to define fixed values for v, r and s which produce a valid signature:

{
    v: 27,
    r: '0x1820182018201820182018201820182018201820182018201820182018201820'
    s: '0x1820182018201820182018201820182018201820182018201820182018201820'
}

Now looking at these values, you can see that those are not random. So you also now know that nobody actually owns the private key that could have created this transaction signature. Since nobody owns the key and this signature is only valid to deploy the regular registry contract at the 1820... address, we have a guarantee that in every Ethereum network under the 0x1820a4b7618bde71dce8cdc73aab6c95905fad24 address:

  • we can find the correct registry contract
  • or no contract at all (not yet deployed)

Take a look at  https://etherscan.io/address/0x1820a4b7618bde71dce8cdc73aab6c95905fad24#code to see the registry in action for the mainnet.

Registering an Interface

Given the single registry contract, anyone can call

function setInterfaceImplementer(address _addr, bytes32 _interfaceHash, address _implementer)

This can be another smart contract, but even EOA (human controlled addresses) can register an interface for their own address. This was not possible with EIP-165.

Fully EIP-165 backwards compatible + Caching

And EIP-1820 comes fully backwards compatible. When you call the function:

function getInterfaceImplementer(address _addr, bytes32 _interfaceHash) returns (address)

with an EIP-165 interface hash (ending with 28 zeroes), the call is just forwarded => _addr.supportsInterface(_interfaceHash). This also allows for some extra caching to save gas and store the EIP-165 call result for future usage.

3. Key feature: Hooks

Now that we understand the registry, we can look at the key feature of ERC-777 which are the new hooks. They allow someone to register a smart contract function to be executed every time tokens are sent from this address and/or received to this address.

Let's look at how one would implement both of these cases:

Send Hooks

First a send hook. This will be our hook that executes every time tokens are about to be sent from the given address. If someone wants to use this hook for their own address, they would call

erc1820.setInterfaceImplementer(
    myAddress,
    TOKENS_SENDER_INTERFACE_HASH,
    usingERC777SenderHook
)
usingERC777SenderHook.registerHookForAccount(
    myAddress
)

Now every time tokens are sent from myAddress, the tokensToSend function will be executed. If you're curious how this is implemented in the ERC-777 token contract, take a look here:

address implementer = registry.getInterfaceImplementer(
    from,
    TOKENS_SENDER_INTERFACE_HASH
);
if (implementer != address(0)) {
    IERC777Sender(implementer).tokensToSend(
        operator,
        from,
        to,
        amount,
        userData,
        operatorData
    );
}
contract UsingERC777SenderHook is IERC777Sender, ERC1820Implementer {
    // keccak256("ERC777TokensSender")
    bytes32 constant private TOKENS_SENDER_INTERFACE_HASH =
        0x29ddb589b1fb5fc7cf394961c1adf5f8c6454761adf795e67fe149f658abe895;

    function registerHookForAccount(address account) public {
        _registerInterfaceForAddress(
            TOKENS_SENDER_INTERFACE_HASH,
            account
        );
    }

    function tokensToSend(
        address operator,
        address from,
        address to,
        uint256 amount,
        bytes calldata userData,
        bytes calldata operatorData
    ) external override {
        // this will be run for every registered
        // 'from' token transfers
    }
}

Receive Hook

Likewise we can create a hook that is run after the registered address has received tokens. In our example on the right we use this to register the contract itself as receiver and implementer.

Now every time tokens are received to our contract, the tokensReceived function will be executed. If you're curious how this is implemented in the ERC-777 token contract, take a look here:

address implementer = registry.getInterfaceImplementer(
    to,
    TOKENS_RECIPIENT_INTERFACE_HASH
);

if (implementer != address(0)) {
    IERC777Recipient(implementer).tokensReceived(
        operator,
        from,
        to,
        amount,
        userData,
        operatorData
    );
} else {
    require(!to.isContract());
}

As you can see, for contracts we actually revert in case no implementer is registered. This is a good thing! Now only contracts that are registered to receive tokens as our example on the right actually can receive tokens.

contract UsingERC777ReceiverHook is IERC777Recipient {
    ERC777 public token;
    IERC1820Registry public registry
        = IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);
    
    // keccak256('ERC777TokensRecipient')
    bytes32 constant private TOKENS_RECIPIENT_INTERFACE_HASH
        = 0xb281fc8c12954d22544db45de3159a39272895b169a852b314f9cc762e44c53b;
        
    mapping(address => uint256) private _balances;

    constructor() {
        token = new TestERC777(100 ether, new address[](0));
        token.transfer(msg.sender, 100 ether);
        
        registry.setInterfaceImplementer(
            address(this),
            TOKENS_RECIPIENT_INTERFACE_HASH,
            address(this)
        );
    }

    function tokensReceived(
        address /*operator*/,
        address from,
        address /*to*/,
        uint256 amount,
        bytes calldata /*userData*/,
        bytes calldata /*operatorData*/
    ) external override {
        require(msg.sender == address(token), "Invalid token");

        // like approve + transferFrom, but only one tx
        _balances[from] += amount;
    }
}

Approve + TransferFrom in one Transaction

Just like the ERC20-Permit standard, this also allows us to do approve + transferFrom in one transaction. What previously was

  1. Approve 10 tokens to contract (token.approve(contractAddress, 10e18))
  2. Call function contractAddress.execute (internally using token.transferFrom(msg.sender, address(this), 10e18)).

Now becomes:

  1. Send 10 tokens to contract (token.send(contractAddress, 10e18, "")). That's it, our registered tokensReceived function can internally call execute.

4. Operators Functionality

With ERC-777 you also get functionality to set operators. This can be a set of default operators defined in the constructor which will be able to transfer tokens on behalf of any address. Or they can be defined by the token holders themselves as being allowed to transfer on their behalfs.

Obviously you have to fully trust the operators. That's why for the most part you won't set a regular address as operator. Rather those operators are intended to be verified smart contracts such as an exchange, a cheque processor or an automatic charging system. Ensuring they cannot steal any money and behave in an expected manner.

5. Backwards Compatible

One of the good things about 777 is that it's fully backwards compatible with ERC-20. This means all the same functions must exist including the identical events. Meaning you can actually just treat it as an ERC-20. But be aware of hooks.

Yo Dawg Hooks

If you treat it as ERC-20 or not, any registered send or receive hooks will still be triggered regardless. People can abuse this for reentrancy attacks. This has happened earlier this year for 300k USD lost on Uniswap v1. Simple solution: use reentrancy guards.

So should I use ERC-777 instead of ERC-20?

I wouldn't say this is a clear decision yet. Out in the wild the amount of popular ERC-777 tokens is still pretty small. There are additional risks involved with 777 as mentioned above with reentrancy. Also added complexity and people not being very familiar with it yet are reasons to not use it.

Ask yourself if any of the ERC-777 features would be of particular value to you. If not, stick to ERC-20. Otherwise you might want to give ERC-777 a try.


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.