ERC-223 Receive notification callback token standard (Token with transaction handling model)
Final · Tokens · created 2017-05-03
A fungible token standard that notifies a receiving contract of a deposit by calling its tokenReceived function when tokens are sent to it. It makes tokens behave like ether.
Why it is needed
ERC-20 tokens give the receiver no notification. When a user sends tokens to a contract with transfer, the token contract's balance decreases, but the receiving contract never learns that a deposit happened. The tokens stay at the receiving contract's address with no way to withdraw them. The original text reports that $201 million was lost this way across 50 ERC-20 tokens surveyed as of May 9, 2023. In addition, ERC-20 provides two transfer methods, transfer and approve+transferFrom, and leaves the choice of which to use to the user. Choosing wrongly loses the tokens. ERC-223 calls tokenReceived to notify the receiver when it is a contract, and reverts transfers to contracts that lack this function. The token decides the transfer method itself, so the user has no choice to make.
What to implement
function totalSupply() view returns (uint256): Returns the total token supply. Same as ERC-20.function name() view returns (string memory): Returns the token name. Optional.function symbol() view returns (string memory): Returns the token symbol. Optional.function decimals() view returns (uint8): Returns the number of token decimals. Optional.function balanceOf(address _owner) view returns (uint256): Returns the token balance of the _owner address.function transfer(address _to, uint _value) returns (bool): Sends tokens to _to. If _to is a contract, it calls tokenReceived with empty data, and reverts if the function is missing.function transfer(address _to, uint _value, bytes calldata _data) returns (bool): Sends tokens to _to and passes _data along. If _to is a contract, it calls tokenReceived.event Transfer(address indexed _from, address indexed _to, uint256 _value, bytes _data): Emitted when tokens move. Compatible with the ERC-20 Transfer event, with _data added.function tokenReceived(address _from, uint _value, bytes calldata _data) returns (bytes4): A function the receiving contract implements. The token contract calls it, and it returns 0x8943ec02 after processing.
In depth
If the receiver is a contract, the tokenReceived call is mandatory
A contract that wants to receive this token must implement tokenReceived. When _to is a contract, transfer calls tokenReceived(address, uint256, bytes calldata), and if the receiving contract lacks this function, the transaction fails and the token movement is reverted. When _to is an externally owned account (EOA), transfer does not call tokenReceived and only moves the tokens. Whether _to is a contract is determined by whether its code size is 0. The original text describes this forced call as a key security mechanism. Because the receiver can accept tokens only if it explicitly implements a token handling function, funds cannot get locked in a contract that is not ready to handle tokens.
transfer has two forms, and _data is optional
There are two forms: transfer(address _to, uint _value) and transfer(address _to, uint _value, bytes calldata _data). Both move tokens and call tokenReceived when the receiver is a contract. _data may be left empty, but attaching it costs more gas. In the reference implementation, transfer without _data passes hex"00000000" as empty data. Like msg.data in an ether transaction, _data can be used to encode a second function call in the receiving contract, or to publicly record a financial transaction on chain. Of these, transfer(address, uint256, bytes calldata), which takes bytes, is not backward compatible with the ERC-20 interface.
tokenReceived must return 0x8943ec02
tokenReceived(address _from, uint _value, bytes calldata _data) is called by the token contract. Inside this function, msg.sender is the token contract address and _from is the sender of the tokens. So the function can check msg.sender to filter which token arrived. After processing, it must return 0x8943ec02. The function name must be tokenReceived and the parameters must be address, uint256, bytes in that order, which gives the function signature 0x8943ec02. In some cases the receiving contract's fallback function handles this call, and then it may not return 0x8943ec02. This function can also be called directly by an EOA.
To prevent re-entrancy, tokenReceived is called last
The token contract must call tokenReceived on _to only after finishing all other work. This ordering prevents re-entrancy attacks, where the receiving contract calls back into the token contract inside the callback. If transfer is payable and ether came with it, that ether must be forwarded to _to along with the tokens, in this order: forward the ether, update token balances, call tokenReceived. The reference implementation also updates the sender's and receiver's balances first, then runs the isContract check, calls tokenReceived, and emits the Transfer event.
name, symbol, and decimals are optional
totalSupply, balanceOf, name, symbol, and decimals work the same as in ERC-20. Of these, name, symbol, and decimals are OPTIONAL, so they may be provided for usability, but interfaces and other contracts must not expect these values to exist. The Transfer event is similar to and compatible with the ERC-20 Transfer event, with bytes _data added. The reference implementation also includes a standard() function that returns "223".
Contract deposits finish in one transaction
ERC-223 is a push model, where the sender initiates the transfer and the receiver handles it. So depositing into a contract takes a single transfer. ERC-20 requires two calls, approve and transferFrom. The gas comparison in the original text is about 46 gas for approve and about 75K gas for transferFrom for an ERC-20 deposit, and about 54K gas for an ERC-223 deposit, counting transfer and the receiver's handling together. Also, because the receiver handles every transfer, it can reject wrong transfers and correct user mistakes. Both contracts and EOAs are sent tokens with a single transfer, so the user does not need to choose a transfer method.
Differences from ERC-20 and compatibility
ERC-20 provides two transfer methods, transfer and approve+transferFrom, and leaves the choice of which to use to the user. If tokens are sent to a contract with ERC-20 transfer, the receiving contract does not recognize the deposit at all, and the tokens are locked with no way to recover them. In the original example, ERC20Receiver takes transferFrom through a deposit function, so it cannot block deposits of tokens other than tokenA, and tokenA that arrives through transfer is not recorded in deposits. ERC223Receiver, by contrast, checks msg.sender == tokenA with require in tokenReceived, so it rejects other tokens and increases deposits[_from]. ERC-223 tokens must be sent with the same transfer whether the receiver is an EOA or a contract.
The reference implementation's contract detection is not complete
The reference implementation's Address.isContract checks code size with extcodesize. While a contract's constructor is running, its code is not yet stored and the check returns 0, so a comment notes that a false result from this function must not be taken to mean the address is an EOA. The reference implementation's token sets name, symbol, and decimals once in the constructor, manages balances with a balances mapping, and defines the tokenReceived signature with the IERC223Recipient abstract contract.
Things to watch out for
- Replay issues must be considered: The original text says that because this token uses an operating model similar to plain ether, replay issues, where the same request is executed again, must be considered in the design.