Alert Source Discuss
⚠️ Review Standards Track: ERC

ERC-6120: Universal Token Router

A single router contract enables tokens to be sent to application contracts in the transfer-and-call pattern instead of approve-then-call.

Authors Zergity (@Zergity), Ngo Quang Anh (@anhnq82), BerlinP (@BerlinP), Khanh Pham (@blackskin18)
Created 2022-12-12
Requires EIP-20, EIP-721, EIP-1014, EIP-1155

Abstract

ETH is designed with transfer-and-call as the default behavior in a transaction. Unfortunately, ERC-20 is not designed with that pattern in mind and newer standards cannot apply to the token contracts that have already been deployed.

Application and router contracts must use the approve-then-call pattern, which costs additional $n\times m\times l$ approve (or permit) signatures for $n$ contracts, $m$ tokens, and $l$ accounts. Not only these allowance transactions create a bad user experience, cost a lot of user fees and network storage, but they also put users at serious security risks as they often have to approve unaudited, unverified, and upgradable proxy contracts. The approve-then-call pattern is also quite error-prone, as many allowance-related bugs and exploits have been found recently.

The Universal Token Router (UTR) separates the token allowance from the application logic, allowing any token to be spent in a contract call the same way with ETH, without approving any other application contracts.

Tokens approved to the Universal Token Router can only be spent in transactions directly signed by their owner, and they have clearly visible token transfer behavior, including token types (ETH, ERC-20, ERC-721 or ERC-1155), amountIn, amountOutMin, and recipient.

The Universal Token Router contract is counter-factually deployed using EIP-1014 at a single address across all EVM-compatible networks, so new token contracts can pre-configure it as a trusted spender, and no approval transaction is necessary for their interactive usage.

Motivation

When users approve their tokens to a contract, they trust that:

  • it only spends the tokens with their permission (from msg.sender or ecrecover)
  • it does not use delegatecall (e.g. upgradable proxies)

By ensuring the same security conditions above, the Universal Token Router can be shared by all interactive applications, saving most approval transactions for old tokens and ALL approval transactions for new tokens.

Before this EIP, when users sign transactions to spend their approved tokens, they trust the front-end code entirely to construct those transactions honestly and correctly. This puts them at great risk of phishing sites.

The Universal Token Router function arguments can act as a manifest for users when signing a transaction. With the support from wallets, users can see and review their expected token behavior instead of blindly trusting the application contracts and front-end code. Phishing sites will be much easier to detect and avoid for users.

Most of the application contracts are already compatible with the Universal Token Router and can use it to have the following benefits:

  • Securely share the user token allowance with all other applications.
  • Update their peripheral contracts as often as they want.
  • Save development and security audit costs on router contracts.

The Universal Token Router promotes the security-by-result model in decentralized applications instead of security-by-process. By directly querying token balance change for output verification, user transactions can be secured even when interacting with erroneous or malicious contracts. With non-token results, application helper contracts can provide additional result-checking functions for UTR’s output verification.

Specification

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.

The main interface of the UTR contract:

interface IUniversalTokenRouter {
    function exec(
        Output[] memory outputs,
        Action[] memory actions
    ) payable;

    function pay(
        address sender,
        address recipient,
        uint eip,
        address token,
        uint id,
        uint amount
    );
}

Output Verification

Output defines the expected token balance change for verification.

struct Output {
    address recipient;
    uint eip;           // token standard: 0 for ETH or EIP number
    address token;      // token contract address
    uint id;            // token id for ERC-721 and ERC-1155
    uint amountOutMin;
}

Token balances of the recipient address are recorded at the beginning and the end of the exec function for each item in outputs. Transaction will revert with INSUFFICIENT_OUTPUT_AMOUNT if any of the balance changes are less than its amountOutMin.

A special id ERC_721_BALANCE is reserved for ERC-721, which can be used in output actions to verify the total amount of all ids owned by the recipient address.

ERC_721_BALANCE = keccak256('UniversalTokenRouter.ERC_721_BALANCE')

Action

Action defines the token inputs and the contract call.

struct Action {
    Input[] inputs;
    address code;       // contract code address
    bytes data;         // contract input data
}

Input

Input defines the input token to transfer or prepare before the action contract is executed.

struct Input {
    uint mode;
    address recipient;
    uint eip;           // token standard: 0 for ETH or EIP number
    address token;      // token contract address
    uint id;            // token id for ERC-721 and ERC-1155
    uint amountIn;
}

mode takes one of the following values:

  • PAYMENT = 0: the token can be transferred from msg.sender to the recipient by calling UTR.pay from anywhere in the same transaction.
  • TRANSFER = 1: the token is transferred directly from msg.sender to recipient.
  • CALL_VALUE = 2: the ETH amount will be passed to the action as the call value.

Payment

PAYMENT is the recommended mode for application contracts that use the transfer-in-callback pattern. E.g., flashloan contracts, Uniswap/v3-core, etc.

For each Input with PAYMENT mode, at most amountIn of the token can be transferred from msg.sender to the recipient by calling UTR.pay from anywhere in the same transaction.

UTR
 |
 | PAYMENT
 | (payments pended for UTR.pay)
 |
 |                                  Application Contracts
action.code.call ---------------------> |
                                        |
UTR.pay <----------------------- (call) |
                                        |
 | <-------------------------- (return) |
 |
 | (clear all pending payments)
 |
END

Token’s allowance and PAYMENT are essentially different as:

  • allowance: allow a specific spender to transfer the token to anyone at any time.
  • PAYMENT: allow anyone to transfer the token to a specific recipient only in that transaction.

Discard Payment

Sometimes, it’s useful to discard the payment instead of performing the transfer, for example, when the application contract wants to burn its own token from msg.sender. The following function can be used to verify the payment to the caller’s address and discard a portion of it.

IUniversalTokenRouter.discard(
    address sender,
    uint eip,
    address token,
    uint id,
    uint amount
);

Usage Examples

Uniswap V2 Router

Legacy function:

UniswapV2Router01.swapExactTokensForTokens(
    uint amountIn,
    uint amountOutMin,
    address[] calldata path,
    address to,
    uint deadline
)

UniswapV2Helper01.swapExactTokensForTokens is a modified version of it without the token transfer part.

This transaction is signed by users to execute the swap instead of the legacy function:

UniversalTokenRouter.exec([{
    recipient: to,
    eip: 20,
    token: path[path.length-1],
    id: 0,
    amountOutMin,
}], [{
    inputs: [{
        mode: TRANSFER,
        recipient: UniswapV2Library.pairFor(factory, path[0], path[1]),
        eip: 20,
        token: path[0],
        id: 0,
        amountIn: amountIn,
    }],
    code: UniswapV2Helper01.address,
    data: encodeFunctionData("swapExactTokensForTokens", [
        amountIn,
        amountOutMin,
        path,
        to,
        deadline,
    ]),
}])

Uniswap V3 Router

Legacy router contract:

contract SwapRouter {
    // this function is called by pool to pay the input tokens
    function pay(
        address token,
        address payer,
        address recipient,
        uint256 value
    ) internal {
        ...
        // pull payment
        TransferHelper.safeTransferFrom(token, payer, recipient, value);
    }
}

The helper contract to use with the UTR:

contract SwapHelper {
    // this function is called by pool to pay the input tokens
    function pay(
        address token,
        address payer,
        address recipient,
        uint256 value
    ) internal {
        ...
        // pull payment
        UTR.pay(payer, recipient, 20, token, 0, value);
    }
}

This transaction is signed by users to execute the exactInput functionality using PAYMENT mode:

UniversalTokenRouter.exec([{
    eip: 20,
    token: tokenOut,
    id: 0,
    amountOutMin: 1,
    recipient: to,
}], [{
    inputs: [{
        mode: PAYMENT,
        eip: 20,
        token: tokenIn,
        id: 0,
        amountIn: amountIn,
        recipient: pool.address,
    }],
    code: SwapHelper.address,
    data: encodeFunctionData("exactInput", [...]),
}])

Rationale

The Permit type signature is not supported since the purpose of the Universal Token Router is to eliminate all interactive approve signatures for new tokens, and most for old tokens.

Backwards Compatibility

Tokens

Old token contracts (ERC-20, ERC-721 and ERC-1155) require approval for the Universal Token Router once for each account.

New token contracts can pre-configure the Universal Token Router as a trusted spender, and no approval transaction is required for interactive usage.

Applications

The only application contracts INCOMPATIBLE with the UTR are contracts that use msg.sender as the beneficiary address in their internal storage without any function for ownership transfer.

All application contracts that accept recipient (or to) argument as the beneficiary address are compatible with the UTR out of the box.

Application contracts that transfer tokens (ERC-20, ERC-721, and ERC-1155) to msg.sender need additional adapters to add a recipient to their functions.

// sample adapter contract for WETH
contract WethAdapter {
    function deposit(address recipient) external payable {
        IWETH(WETH).deposit(){value: msg.value};
        TransferHelper.safeTransfer(WETH, recipient, msg.value);
    }
}

Additional helper and adapter contracts might be needed, but they’re mostly peripheral and non-intrusive. They don’t hold any tokens or allowances, so they can be frequently updated and have little to no security impact on the core application contracts.

Reference Implementation

contract UniversalTokenRouter is IUniversalTokenRouter {
    uint constant PAYMENT       = 0;
    uint constant TRANSFER      = 1;
    uint constant CALL_VALUE    = 2;

    uint constant EIP_ETH       = 0;

    uint constant ERC_721_BALANCE = uint(keccak256('UniversalTokenRouter.ERC_721_BALANCE'));

    // non-persistent in-transaction pending payments
    mapping(bytes32 => uint) t_payments;

    // accepting ETH for WETH.withdraw
    receive() external payable {}

    function exec(
        Output[] memory outputs,
        Action[] memory actions
    ) override external payable {
    unchecked {
        // track the expected balances before any action is executed
        for (uint i = 0; i < outputs.length; ++i) {
            Output memory output = outputs[i];
            uint balance = _balanceOf(output);
            uint expected = output.amountOutMin + balance;
            require(expected >= balance, 'UniversalTokenRouter: OUTPUT_BALANCE_OVERFLOW');
            output.amountOutMin = expected;
        }

        address sender = msg.sender;

        for (uint i = 0; i < actions.length; ++i) {
            Action memory action = actions[i];
            uint value;
            for (uint j = 0; j < action.inputs.length; ++j) {
                Input memory input = action.inputs[j];
                uint mode = input.mode;
                if (mode == PAYMENT) {
                    bytes32 key = keccak256(abi.encodePacked(sender, input.recipient, input.eip, input.token, input.id));
                    t_payments[key] = input.amountIn;
                } else if (mode == TRANSFER) {
                    _transferToken(sender, input.recipient, input.eip, input.token, input.id, input.amountIn);
                } else if (mode == CALL_VALUE) {
                    // require(input.eip == EIP_ETH && input.id == 0, "UniversalTokenRouter: ETH_ONLY");
                    value = input.amountIn;
                }
            }
            if (action.data.length > 0) {
                (bool success, bytes memory result) = action.code.call{value: value}(action.data);
                if (!success) {
                    assembly {
                        revert(add(result,32),mload(result))
                    }
                }
            }
            // clear all in-transaction storages, allowances and left-overs
            for (uint j = 0; j < action.inputs.length; ++j) {
                Input memory input = action.inputs[j];
                if (input.mode == PAYMENT) {
                    // in-transaction storages
                    bytes32 key = keccak256(abi.encodePacked(sender, input.recipient, input.eip, input.token, input.id));
                    delete t_payments[key];
                }
            }
        }

        // refund any left-over ETH
        uint leftOver = address(this).balance;
        if (leftOver > 0) {
            TransferHelper.safeTransferETH(sender, leftOver);
        }

        // verify balance changes
        for (uint i = 0; i < outputs.length; ++i) {
            Output memory output = outputs[i];
            uint balance = _balanceOf(output);
            // NOTE: output.amountOutMin is reused as `expected`
            require(balance >= output.amountOutMin, 'UniversalTokenRouter: INSUFFICIENT_OUTPUT_AMOUNT');
        }
    } }

    function _reducePayment(
        address sender,
        address recipient,
        uint eip,
        address token,
        uint id,
        uint amount
    ) internal {
    unchecked {
        bytes32 key = keccak256(abi.encodePacked(sender, recipient, eip, token, id));
        require(t_payments[key] >= amount, 'UniversalTokenRouter: INSUFFICIENT_PAYMENT');
        t_payments[key] -= amount;
    } }

    function pay(
        address sender,
        address recipient,
        uint eip,
        address token,
        uint id,
        uint amount
    ) override external {
        _reducePayment(sender, recipient, eip, token, id, amount);
        _transferToken(sender, recipient, eip, token, id, amount);
    }

    function discard(
        address sender,
        uint eip,
        address token,
        uint id,
        uint amount
    ) public override {
        _reducePayment(sender, msg.sender, eip, token, id, amount);
    }

    function _transferToken(
        address sender,
        address recipient,
        uint eip,
        address token,
        uint id,
        uint amount
    ) internal {
        if (eip == 20) {
            if (sender == address(this)) {
                TransferHelper.safeTransfer(token, recipient, amount);
            } else {
                TransferHelper.safeTransferFrom(token, sender, recipient, amount);
            }
        } else if (eip == 1155) {
            IERC1155(token).safeTransferFrom(sender, recipient, id, amount, "");
        } else if (eip == 721) {
            IERC721(token).safeTransferFrom(sender, recipient, id);
        } else if (eip == EIP_ETH) {
            require(sender == address(this), 'UniversalTokenRouter: INVALID_ETH_SENDER');
            TransferHelper.safeTransferETH(recipient, amount);
        } else {
            revert("UniversalTokenRouter: INVALID_EIP");
        }
    }

    function _balanceOf(
        Output memory output
    ) internal view returns (uint balance) {
        uint eip = output.eip;
        if (eip == 20) {
            return IERC20(output.token).balanceOf(output.recipient);
        }
        if (eip == 1155) {
            return IERC1155(output.token).balanceOf(output.recipient, output.id);
        }
        if (eip == 721) {
            if (output.id == ERC_721_BALANCE) {
                return IERC721(output.token).balanceOf(output.recipient);
            }
            try IERC721(output.token).ownerOf(output.id) returns (address currentOwner) {
                return currentOwner == output.recipient ? 1 : 0;
            } catch {
                return 0;
            }
        }
        if (eip == EIP_ETH) {
            return output.recipient.balance;
        }
        revert("UniversalTokenRouter: INVALID_EIP");
    }
}

Security Considerations

Tokens transferred to the UTR contract will be lost forever, as there is no way to transfer them out.

ETH must be transferred to the UTR contracts before the value is spent in an action call (using CALL_VALUE). This ETH value can be siphoned out of the UTR using a re-entrant call inside an action code or rogue token functions. This exploit will not be possible if users don’t transfer more ETH than they will spend in that transaction.

// transfer 100 in, but spend only 60,
// so at most 40 wei can be exploited in this transaction
UniversalTokenRouter.exec([
    ...
], [{
    inputs: [{
        mode: CALL_VALUE,
        eip: 20,
        token: 0,
        id: 0,
        amountIn: 60,   // spend 60
        recipient: AddressZero,
    }],
    ...
}], {
    value: 100,   // transfer 100 in
})

Copyright and related rights waived via CC0.

Citation

Please cite this document as:

Zergity (@Zergity), Ngo Quang Anh (@anhnq82), BerlinP (@BerlinP), Khanh Pham (@blackskin18), "ERC-6120: Universal Token Router [DRAFT]," Ethereum Improvement Proposals, no. 6120, December 2022. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-6120.