ERC-1363 Standard Explained
ERC-1363 enables a smart contract to detect and respond to an incoming transfer of tokens.
What problem does ERC-1363 Solve?
Suppose a user transfers an ERC-20 token to a contract. The smart contract cannot credit the user for the transfer because it has no mechanism to see who made the transfer.
Although events track this information, they are only usable by off-chain consumers. Smart contracts cannot read events without an oracle.
Traditional solution: instead of notifying the receiver, the receiver transfers the tokens to itself using transferFrom
A typical workaround for the problem described above is for the sender of the tokens to approve the receiving smart contract to transfer tokens on behalf of the sender.
contract ReceivingContract {
function deposit(uint256 amount) external {
// will revert if this contract is not approved
// or the user has an insufficient balance
ERC20(token).transferFrom(msg.sender, address.this, amount);
deposits[msg.sender] += amount;
}
}
Then the depositor invokes a function (deposit
in the example code above) on the receiving smart contract to transfer tokens from the sender and to the contract. Since the contract knows it transferred tokens from the user, it is able to credit their account correctly.
However, adding an extra transaction to approve the contract to transfer the tokens increases the gas cost.
Additionally, the user ought to set the approval for the contract to zero after approving the contract, otherwise there is a danger that if the contract is exploited, it might withdraw more ERC-20 tokens from the user.
Transfer hooks
A transfer hook is a predefined function in the receiving smart contract that will be called when it receives tokens. That is, the token contract, after receiving a transfer instruction, calls the predefined function on the recipient address.
If the function is not present, reverts, or does not return the expected success value, the transfer reverts.
Readers already familiar with onERC721Received
in the ERC-721 standard will be familiar with the transfer hook.
ERC-1363 extends the ERC-20 standard, adding transfer hooks.
To implement the standard, the ERC-20 needs additional functions (explained later) to transfer tokens to trigger the transfer hook on the receiver, and the receiver must implement the transfer hook according to the standard.
IERC1363Receiver
For a contract that wishes to be notified that they have received ERC-1363 tokens, they must implement IERC1363Receiver (see the OpenZeppelin implementation here) which has a single function onTransferReceived
:
pragma solidity ^0.8.20;
interface IERC1363Receiver {
// returns `bytes4(keccak256("onTransferReceived(address,address,uint256,bytes)"))` on success
function onTransferReceived(
address operator,
address from,
uint256 value,
bytes calldata data
) external returns (bytes4);
}
operator
is the address initiating the transferfrom
is the ERC-1363 account the tokens are being deducted fromvalue
is the amount of tokens being transferreddata
is specified by the operator to forward to the receiver
When implementing this function, always check that msg.sender
is the ERC-1363 token you wish to receive, because anyone can call onTransferReceived()
with arbitrary values.
Here is a minimum example contract that accepts ERC-1363 tokens:
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/interfaces/IERC1363Receiver.sol";
import "@openzeppelin/contracts/interfaces/IERC1363.sol";
contract TokenReceiver is IERC1363Receiver {
address internal erc1363Token;
constructor(address erc1363Token_) {
erc1363Token = erc1363Token_;
}
mapping(address user => uint256 balance) public balances;
function onTransferReceived(
address operator,
address from,
uint256 value,
bytes calldata data
) external returns (bytes4) {
require(msg.sender == erc1363Token, "not the expected token");
balances[from] += value;
return this.onTransferReceived.selector;
}
function withdraw(uint256 value) external {
require(balances[msg.sender] >= value, "balance too low");
balances[msg.sender] -= value;
IERC1363(erc1363Token).transfer(msg.sender, value);
}
}
The traditional way of a contract knowing it received ERC-20 tokens is using transferFrom
function which requires an approval first, but with ERC-1363, the contract is able to know it has received a token and also eliminate approval step because the transferAndCall
transfers the token to the contract (w/o approval) and calls the onTransferReceived
function.
Maximizing Backwards Compatibility with ERC-20
The problem with new token standards is that existing protocols won’t be able to use them unless they are perfectly compatible with prior standards.
To maximise backwards compatibility, ERC-1363 is an ERC-20 token that adds extra functions which older protocols don’t need to use.
All the existing ERC-20 functions: name
, symbol
, decimals
, totalSupply
, balanceOf
, transfer
, transferFrom
, approve
, and allowance
behave exactly as specified by the ERC-20 standard.
The ERC-1363 standard adds new functions to ERC-20 so that legacy protocols can still interact with the ERC-1363 token exactly the way it does with ERC-20 tokens. However, newer protocols can take advantage of the transfer hook on ERC-1363 if desired.
To be a compliant ERC-1363 token, the code must also implement six additional functions:
- Two versions of
transferAndCall
- Two versions of
transferFromAndCall
- Two versions of
approveAndCall
As the name implies, these functions will do the ERC-20 action, then call the recipient’s hook function.
There are two versions of each function, one with a data parameter and one without. The data parameter is so that the sender can forward data to the receiving contract (we will show an examples of this later).
Aside from functions that take the data
argument, these functions take the same arguments in the same order as their ERC-20 counterparts.
// There are two transferAndCall functions,
// one with a data argument and one without
function transferAndCall(
address to,
uint256 value
) external returns (bool);
function transferAndCall(
address to,
uint256 value,
bytes calldata data
) external returns (bool);
// There are two transferFromAndCall functions,
// one with a data argument and one without
function transferFromAndCall(
address from,
address to,
uint256 value
) external returns (bool);
function transferFromAndCall(
address from,
address to,
uint256 value,
bytes calldata data
) external returns (bool);
// There are two approveAndCall functions,// one with a data argument and one without
function approveAndCall(
address spender,
uint256 value
) external returns (bool);
function approveAndCall(
address spender,
uint256 value,
bytes calldata data
) external returns (bool);
ERC-721 inspiration: transferFrom vs safeTransferFrom
Similar to the ERC-721 standard, the difference between transferFromAndCall
and transferFrom
in ERC-1363 is the same difference between transferFrom
and safeTransferFrom
in ERC-721. However, “safe” is not an ideal function name, since the transfer hook introduces a potential re-entrancy vector, so it isn’t “safe.” 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.
Reference Implementation
An ERC-1363 implementation can be found here. We will be using a significant amount of code from that example. It’s easier to explain the codebase piece-by-piece than pasting the implementation here in one go. For those implementing an ERC-1363 token, please use the implementation linked above. The code here is for illustration purposes only.
The ERC-1363 uses the same storage variables for balances and approvals as ERC-20. It does not store additional information.
Code overview of ERC-1363
Inheriting ERC-20
As emphasized earlier, ERC-1363 is an ERC-20 token with additional functions. The first step to build an ERC-1363 is to inherit ERC-20:
//SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import "@openzeppelin/contracts@5.0.0/token/ERC20/ERC20.sol";
contract ERC1363 is ERC20 {
constructor(
string memory name,
string memory symbol
)ERC20(name, symbol) {}
}
transferFromAndCall(address to, uint256 value) external returns (bool)
transferFromAndCall succeeds if an only if the receiving address implements onTransferReceived()
and returns the four byte function selector of onTransferReceived()
.
function transferFromAndCall(
address from,
address to,
uint256 value,
bytes memory data
) public virtual returns (bool) {
// first call the ERC-20 transferFrom function in the parent
if (!transferFrom(from, to, value)) {
revert ERC1363TransferFromFailed(from, to, value);
}
// then call the receiver
_checkOnTransferReceived(from, to, value, data);
return true;
}
// this function has no data parameter and// this function has no data parameter and
// forwards empty data
function transferFromAndCall(
address from,
address to,
uint256 value
) public virtual returns (bool) {
// `data` is empty
return transferFromAndCall(from, to, value, "");
}
transferAndCall(address to, uint256 value) external returns (bool)
This is very similar to transferFromAndCall
except that from is msg.sender
.
function transferAndCall(
address to,
uint256 value,
bytes memory data
) public virtual returns (bool) {
if (!transfer(to, value)) {
revert ERC1363TransferFailed(to, value);
}
_checkOnTransferReceived(msgSender(), to, value, data);
return true;
}
function transferAndCall(
address to,
uint256 value
) public virtual returns (bool) {
return transferAndCall(to, value, "");
}
_checkOnTransferReceived()
This function checks if the receiver is a contract, and if not reverts. Then it attempts to call onTransferReceived
and reverts if it does not receive 0x88a7ca5c
, the function selector of onTransferReceived(address,address,uint256,bytes)
. If onTransferReceived
reverts, this function reverts with the error message received from onTransferReceived
.
Because this function reverts if sent to an EOA (regular wallet) transferring an ERC-1363 to an EOA should use the ERC-20 functions transfer
or transferFrom
:
function _checkOnTransferReceived(
address from,
address to,
uint256 value,
bytes memory data
) private {
if (to.code.length == 0) {
revert ERC1363EOAReceiver(to);
}
try IERC1363Receiver(to).onTransferReceived(_msgSender(), from, value, data) returns (bytes4 retval) {
if (retval != IERC1363Receiver.onTransferReceived.selector) {
revert ERC1363InvalidReceiver(to);
}
} catch (bytes memory reason) {
if (reason.length == 0) {
revert ERC1363InvalidReceiver(to);
} else {
// this code causes the ERC-1363 to revert
// with the same revert string as the
// contract it called
assembly {
revert(add(32, reason), mload(reason))
}
}
}
}
approveAndCall
In the workflows above, the smart contract being called is the recipient of the ERC-1363 tokens.
However, what if we want to another contract to be the sender of our tokens? For example, a router contract, such as the Uniswap V2 Router, does not hold custody of the tokens. It forwards them to Uniswap to trade them.
Traditionally, such architectures use the “approve then transferFrom” workflow, but with ERC-1363 we can do this in one transaction with approveAndCall
. As the name suggests, the contract that just received approval to spend another address’ tokens gets a special hook function called.
As with the transferAndCall functions, supplying additional data to the transaction is optional depending on which approveAndCall
is invoked:
function approveAndCall(
address spender,
uint256 value
) public virtual returns (bool) {
return approveAndCall(spender, value, "");
}
function approveAndCall(
address spender,
uint256 value,
bytes memory data
) public virtual returns (bool) {
if (!approve(spender, value)) {
revert ERC1363ApproveFailed(spender, value);
}
_checkOnApprovalReceived(spender, value, data);
return true;
}
IERC1363Spender
Similar to IERC1363Receiver, a function called onApprovalReceived is triggered when an approvalAndCall
is invoked.
Here is the OpenZeppelin provided interface for IERC1363Spender. The code below has the comments removed:
interface IERC1363Spender {
function onApprovalReceived(
address owner,
uint256 value,
bytes calldata data
) external returns (bytes4);
}
Only the owner of the tokens can approve another address, so there is no need for an operator
argument — during an approval the operator
and the owner
must be the same address. value
is the size of the amount of the approval.
The following contract, upon receiving onApprovalReceived
forwards the tokens to the address specified in the data
.
import "@openzeppelin/contracts/interfaces/IERC1363Spender.sol";
contract Router is IERC1363Spender {
// additional functions are needed for an approved
// wallet to add approved ERC-1363 tokens to this mapping
mapping(address => bool) isApprovedToken;
function onApprovalReceived(
address owner,
uint256 value,
bytes calldata data
) external returns (bytes4) {
require(isApprovedToken[msg.sender], "not an approved token");
// getTarget is not implemented here,
// see the next section for how to it work
address target = getTarget(data);
bool success = IERC1363(msg.sender).transferFrom(owner, target, value);
require(success, "transfer failed");
return this.onApprovalReceived.selector;
}
}
This function should check if msg.sender
is the token contract, because if anyone is allowed to call it, that could lead to unexpected behavior.
Example receiver contract using ERC-1363
The example below demonstrates a use case for the data
argument.
interface ERC1363Receiver {
function onTransferReceived(
address operator,
address from,
uint256 value,
bytes memory data
) external returns (bytes4);
}
contract ReceiverContract is ERC1363Receiver {
mapping(address => uint256) public deposits;
address immutable token;
constructor(address token_) {
token = token_;
}
event Deposit(
address indexed from,
address indexed beneficiary,
uint256 value
);
function onTransferReceived(
address, // operator
address from,
uint256 value,
bytes memory data
) external returns (bytes4) {
require(msg.sender == token, "Caller not ERC1363 token");
address beneficiary;
if (data.length == 32) {
beneficiary = abi.decode(data, (address));
} else {
beneficiary = from;
}
deposits[from] += value;
emit Deposit(from, beneficiary, value);
return this.onTransferReceived.selector;
}
}
Prior standards attempting to solve token hooks
ERC-1363 was not the first standard to add transfer hooks to ERC-20. First, ERC-223 was proposed in May of 2017 to add the transfer hook to transfer
and transferFrom
in ERC-20. But this mean smart contracts could not receive the token unless they implemented the transfer hook. This made the standard not backwards compatible with protocols that accepted ERC-20 tokens, but didn’t have a transfer hook.
ERC-777 was introduced in November 2017. In this standard, the receiver would not have a transfer hook called unless they had registered their address in the ERC-1820 registry.
However, protocols had not designed for transfer
or transferFrom
in ERC-20 to make an external call to other contracts. This made those contract vulnerable to reentrancy because they weren’t expecting an “ERC-20” token to make calls to other contract. See the Uniswap V1 reentrancy vulnerability write up for more.
Additionally, the ERC-777 standard was quite expensive from a gas perspective because it needed to make an additional call to the ERC-1820 registry contract.
ERC-1363 solves all of these issues by leaving transfer
and transferFrom
in the ERC-20 standard completely unaltered. All of the transfer hooks are called in functions that have an explicit call in the name.
When to use the ERC-1363 standard
The ERC-1363 standard can be used wherever the ERC-20 standard would be used. In the view of the author, this standard is a desirable replacement for ERC-20 since it can eliminate the approve step of ERC-20, which has lead to considerable losses of funds.
Learn more with RareSkills
See our Solidity bootcamp to learn more about smart contract development and token standards.
Originally Published April 4, 2024