ERC-777: The Token Standard That Tried to Fix ERC-20

ERC-777 was proposed in 2017 to fix a specific, well-known problem: ERC-20 tokens had no way to notify a contract when tokens arrived. This caused tokens to get stuck in contracts silently, and it forced developers to use the awkward approve + transferFrom two-step flow. ERC-777 introduced hooks, operators, and a richer send interface to address these problems.

This post covers how ERC-777 works, what it adds over ERC-20, where it struggles, and why most projects still avoid it.

The Problem ERC-777 Solves

With ERC-20, if you send tokens directly to a contract using transfer, the contract has no idea the tokens arrived. There is no callback, no notification, no hook. The tokens sit in the contract with no way to trigger any logic.

The workaround is the approve + transferFrom pattern. You first call approve to allow the contract to pull tokens, then the contract calls transferFrom to actually take them. Two transactions. Two signatures. Higher gas costs.

ERC-777 replaces both of these patterns with a hook system. When tokens arrive at a contract, the standard automatically calls a function on that contract to notify it. This makes token receipt an event the contract can respond to, not something it has to poll or pull.

Hooks: The Core Innovation

The key addition in ERC-777 is two hooks: tokensToSend and tokensReceived.

tokensToSend is optional. It fires before tokens leave the sender's address. A sender contract can implement this to intercept or reject outgoing transfers.

tokensReceived is mandatory for contract recipients. If a contract receives ERC-777 tokens but does not implement tokensReceived, the transfer reverts. This prevents tokens from being sent to contracts that cannot handle them.

function tokensReceived(
    address operator,
    address from,
    address to,
    uint256 amount,
    bytes calldata userData,
    bytes calldata operatorData
) external;

The userData and operatorData parameters are also new. They let the sender attach arbitrary bytes to a transfer, similar to a memo on a bank transfer. A receiving contract can parse these bytes to decide what to do with the incoming tokens.

Operators

ERC-777 introduces the concept of operators: addresses that are authorized to send tokens on behalf of another address. This is different from ERC-20's approve mechanism.

With ERC-20 approve, you authorize a specific spender to spend up to a specific amount. With ERC-777 operators, you authorize an address to move any amount of your tokens until you revoke that authorization.

// Authorize an operator
function authorizeOperator(address operator) external;

// Revoke an operator
function revokeOperator(address operator) external;

// Check authorization
function isOperatorFor(address operator, address tokenHolder) external view returns (bool);

There is also a concept of default operators. These are addresses the token creator designates as operators for all token holders at deployment time. They can be used for things like automatic fee collection or protocol-level transfers. Token holders can revoke default operators individually, but they are active by default unless revoked.

The Send Interface

ERC-777 replaces transfer with send:

function send(address recipient, uint256 amount, bytes calldata data) external;

The data parameter is the memo. Any contract or wallet can pass arbitrary bytes here, and the recipient contract receives them in tokensReceived. This enables use cases like routing logic inside a vault, attaching order IDs to payments, or passing configuration flags alongside a transfer.

How a Transfer Actually Flows

When you call send, the execution follows this sequence:

  1. Check the sender has enough balance.
  2. Look up if the sender is a contract. If it is, call tokensToSend on it if the sender has registered an implementation.
  3. Deduct the balance from the sender and add it to the recipient.
  4. Look up if the recipient is a contract. If it is, call tokensReceived on it. This call is required. If the recipient is a contract and does not implement tokensReceived, the whole transaction reverts.
  5. Transfer completes.

The lookup in steps 2 and 4 is done using the ERC-1820 registry, described below.

ERC-1820: The Interface Registry

ERC-777 depends on ERC-1820, a global registry contract deployed at the same address on every EVM-compatible chain:

0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24

ERC-1820 answers the question: "does this address implement interface X?" Every ERC-777 token registers itself with ERC-1820. Every contract that wants to receive ERC-777 tokens registers its tokensReceived implementation.

bytes32 constant TOKENS_RECIPIENT_INTERFACE_HASH =
    keccak256("ERC777TokensRecipient");

address implementer = erc1820Registry.getInterfaceImplementer(
    account,
    TOKENS_RECIPIENT_INTERFACE_HASH
);

If implementer is the zero address, the recipient has not registered a tokensReceived hook. For a contract recipient, this causes the transfer to revert.

Backward Compatibility with ERC-20

ERC-777 tokens implement all ERC-20 functions. Old interfaces, wallets, and contracts that only know about ERC-20 will still work with an ERC-777 token.

// These still work on an ERC-777 token
function transfer(address recipient, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);

The catch: when you use ERC-20 functions on an ERC-777 token, the hooks do not fire. The tokensReceived callback does not execute. This matters if a contract depends on the hook to update state when tokens arrive. If someone sends tokens via the ERC-20 transfer path, the hook is skipped and the contract state may become inconsistent.

A Working Receiver Contract

Here is a minimal vault contract that correctly receives ERC-777 tokens:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC777/IERC777.sol";
import "@openzeppelin/contracts/token/ERC777/IERC777Recipient.sol";
import "@openzeppelin/contracts/utils/introspection/IERC1820Registry.sol";

contract TokenVault is IERC777Recipient {
    IERC777 public token;

    IERC1820Registry private constant _ERC1820_REGISTRY =
        IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);

    bytes32 private constant _TOKENS_RECIPIENT_INTERFACE_HASH =
        keccak256("ERC777TokensRecipient");

    constructor(address tokenAddress) {
        token = IERC777(tokenAddress);

        // Register this contract as a tokensReceived implementer
        _ERC1820_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), "Unknown token");
        // Tokens are already credited to this contract at this point.
        // userData can be parsed here to trigger specific vault logic.
    }
}

Notice that the constructor registers the contract with ERC-1820. Without this registration, any ERC-777 transfer to this address would revert.

Security: The Reentrancy Problem

The hook mechanism introduces a serious reentrancy risk. When send is called, it executes the recipient's tokensReceived function before the transfer fully settles in some implementations. A malicious or buggy recipient can re-enter the calling contract during this callback.

// Vulnerable pattern
function withdraw(uint256 amount) external {
    token.send(msg.sender, amount, ""); // Calls tokensReceived on msg.sender
    userBalance[msg.sender] -= amount;  // State updated after external call
}

On line 3, send triggers tokensReceived on msg.sender. If msg.sender is a malicious contract, it can call withdraw again inside tokensReceived before line 4 executes. The balance check passes each time because userBalance has not been decremented yet.

This is not hypothetical. In 2020, several DeFi protocols that used ERC-777 tokens suffered reentrancy attacks exploiting exactly this pattern. The hooks make the attack surface larger than with plain ERC-20.

If you work with ERC-777 tokens, always follow the checks-effects-interactions pattern. Update state before making any external call, including send.

The hook mechanism also means gas costs are higher and harder to predict. You are executing unknown code in the recipient contract. The gas usage for a transfer depends on what the recipient's hook does.

Why ERC-777 Never Replaced ERC-20

The problems ERC-777 solved were real. The hooks are genuinely useful. But the standard came with enough new risks that most developers chose not to adopt it.

The reentrancy attacks in 2020 made the tradeoffs concrete. It is not that ERC-777 is insecure by design, but that it makes writing secure contracts harder. Developers need to be aware of the hook execution order, register with ERC-1820, and handle the ERC-20 compatibility gap carefully.

Major protocols explicitly rejected ERC-777 over these concerns. Uniswap only supports ERC-20. When a protocol as widely used as Uniswap does not support a standard, that standard will not become the default.

ERC-20 also has momentum. Tooling, auditing patterns, wallet support, and developer intuition are all built around ERC-20. Switching to ERC-777 means relearning assumptions that were already settled.

Newer standards like ERC-4626 for vault tokens and ERC-1155 for multi-token contracts solve specific problems without inheriting ERC-777's complexity. Projects that need richer token behavior tend to go to one of those instead.

When ERC-777 Is Worth Considering

ERC-777 is not useless. It fits specific situations where the hook mechanism provides real value and the team is prepared to handle the security implications.

Protocol-level tokens where operators are needed for automated logic are a reasonable fit. Tokens used inside closed systems where every recipient is a known, audited contract are another case where the risks are more contained.

If you are building a simple fungible token for general use, stick with ERC-20. If you need vault or yield-bearing token behavior, look at ERC-4626. ERC-777 occupies a narrow space where its specific features are the right tool for the job.

Summary

ERC-777 introduced three meaningful improvements over ERC-20: hooks for contract notification, operators for delegated token management, and a richer send interface with attached data. It maintained backward compatibility with ERC-20 to avoid a hard migration.

In practice, the reentrancy risks from hooks, the dependency on ERC-1820, the gas unpredictability, and the ERC-20 compatibility gap made adoption slow. Most of the ecosystem stayed on ERC-20, and the specific problems ERC-777 solved were addressed through better patterns and auditing rather than a new standard.

The concepts ERC-777 introduced are still worth understanding. Hooks, operator patterns, and attached data are ideas that appear in other standards and protocols. Knowing how ERC-777 implemented them, and where the implementation went wrong, is useful context for working with any token system.