ERC-1363 Standard Explained

ERC-1363 enables a smart contract to detect and respond to an incoming transfer of tokens.

Cover Image for ERC-1363 Standard Explained article by RareSkills

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 transfer
  • from is the ERC-1363 account the tokens are being deducted from
  • value is the amount of tokens being transferred
  • data 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

20 Common Solidity Beginner Mistakes

20 Common Solidity Beginner Mistakes Our intent is not to be patronizing towards developers early in their journey with this article. Having reviewed code from numerous Solidity developers, we’ve seen some mistakes occur more frequently and we list those here. By no means is this an exhaustive list of mistakes a Solidity developer can make. […]

Smart Contract Foundry Upgrades with the OpenZeppelin Plugin

Smart Contract Foundry Upgrades with the OpenZeppelin Plugin Upgrading a smart contract is a multistep and error-prone process, so to minimize the chances of human error, it is desirable to use a tool that automates the procedure as much as possible. Therefore, the OpenZeppelin Upgrade Plugin streamlines deploying, upgrading and managing smart contracts built with Foundry or […]

UUPS: Universal Upgradeable Proxy Standard (ERC-1822)

UUPS: Universal Upgradeable Proxy Standard (ERC-1822) The UUPS pattern is a proxy pattern where the upgrade function resides in the implementation contract, but changes the implementation address stored in the proxy contract via a delegatecall from the proxy. The high level mechanism is shown in the animation below: Similar to the Transparent Upgradeable Proxy, the […]

Try Catch and all the ways Solidity can revert

Try Catch and all the ways Solidity can revert This article describes all the kinds of errors that can happen when a smart contract is called, and how the Solidity Try / Catch block responds (or fails to respond) to each of them. To understand how Try / Catch works in Solidity, we must understand […]