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 deployed using the EIP-1014 SingletonFactory contract at 0x8Bd6072372189A12A2889a56b6ec982fD02b0B87 across all EVM-compatible networks. This enables new token contracts to pre-configure it as a trusted spender, eliminating the need for approval transactions during 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.
Output defines the expected token balance change for verification.
structOutput{addressrecipient;uinteip;// token standard: 0 for ETH or EIP number
addresstoken;// token contract address
uintid;// token id for ERC-721 and ERC-1155
uintamountOutMin;}
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.
Action defines the token inputs and the contract call.
structAction{Input[]inputs;addresscode;// contract code address
bytesdata;// contract input data
}
The action code contract MUST implement the ERC-165 interface with the ID 0x61206120 in order to be called by the UTR. This interface check prevents direct invocation of token allowance-spending functions (e.g., transferFrom) by the UTR. Therefore, new token contracts MUST NOT implement this interface ID.
abstractcontractNotTokenisERC165{// IERC165-supportsInterface
functionsupportsInterface(bytes4interfaceId)publicviewvirtualoverridereturns(bool){returninterfaceId==0x61206120||super.supportsInterface(interfaceId);}}contractApplicationisNotToken{// this contract can be used with the UTR
}
Input
Input defines the input token to transfer or prepare before the action contract is executed.
structInput{uintmode;addressrecipient;uinteip;// token standard: 0 for ETH or EIP number
addresstoken;// token contract address
uintid;// token id for ERC-721 and ERC-1155
uintamountIn;}
mode takes one of the following values:
PAYMENT = 0: pend a payment for the token to be transferred from msg.sender to the recipient by calling UTR.pay from anywhere in the same transaction.
TRANSFER = 1: transfer the token directly from msg.sender to the recipient.
CALL_VALUE = 2: record the ETH amount to pass to the action as the call value.
Each input in the inputs argument is processed sequentially. For simplicity, duplicated PAYMENT and CALL_VALUE inputs are valid, but only the last amountIn value is used.
Payment Input
PAYMENT is the recommended mode for application contracts that use the transfer-in-callback pattern. E.g., flashloan contracts, Uniswap/v3-core, Derivable, 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.
The payment bytes can also be used by adapter UTR contracts to pass contexts and payloads for performing custom payment logic.
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 payment.payer. The following function can be used to verify the payment to the caller’s address and discard a portion of it.
Please refer to the Discard Payment section in the Security Considerations for an important security note.
Payment Lifetime
Payments are recorded in the UTR storage and intended to be spent by input.action external calls only within that transaction. All payment storages will be cleared before the UTR.exec ends.
Native Token Tranfer
The UTR SHOULD have a receive() function for user execution logic that requires transferring ETH in. The msg.value transferred into the router can be spent in multiple inputs across different actions. While the caller takes full responsibility for the movement of ETH in and out of the router, the exec function SHOULD refund any remaining ETH before the function ends.
Please refer to the Reentrancy section in the Security Considerations for information on reentrancy risks and mitigation.
contractSwapRouter{// this function is called by pool to pay the input tokens
functionpay(addresstoken,addresspayer,addressrecipient,uint256value)internal{...// pull payment
TransferHelper.safeTransferFrom(token,payer,recipient,value);}}
The helper contract to use with the UTR:
contractSwapHelper{// this function is called by pool to pay the input tokens
functionpay(addresstoken,addresspayer,addressrecipient,uint256value)internal{...// pull payment
bytesmemorypayment=abi.encode(payer,recipient,20,token,0);UTR.pay(payment,value);}}
This transaction is signed by users to execute the exactInput functionality using PAYMENT mode:
A simple non-reentrancy ERC-20 adapter for aplication and router contracts that use direct allowance.
contractAllowanceAdapterisReentrancyGuard{structInput{addresstoken;uintamountIn;}functionapproveAndCall(Input[]memoryinputs,addressspender,bytesmemorydata,addressleftOverRecipient)externalpayablenonReentrant{for(uinti=0;i<inputs.length;++i){Inputmemoryinput=inputs[i];IERC20(input.token).approve(spender,input.amountIn);}(boolsuccess,bytesmemoryresult)=spender.call{value:msg.value}(data);if(!success){assembly{revert(add(result,32),mload(result))}}for(uinti=0;i<inputs.length;++i){Inputmemoryinput=inputs[i];// clear all allowance
IERC20(input.token).approve(spender,0);uintleftOver=IERC20(input.token).balanceOf(address(this));if(leftOver>0){TransferHelper.safeTransfer(input.token,leftOverRecipient,leftOver);}}}}
This transaction is constructed to utilize the UTR to interact with Uniswap V2 Router without approving any token to it:
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.
import"@openzeppelin/contracts/token/ERC20/ERC20.sol";/**
* @dev Implementation of the {ERC20} token standard that support a trusted ERC6120 contract as an unlimited spender.
*/contractERC20WithUTRisERC20{addressimmutableUTR;/**
* @dev Sets the values for {name}, {symbol} and ERC6120's {utr} address.
*
* All three of these values are immutable: they can only be set once during
* construction.
*
* @param utr can be zero to disable trusted ERC6120 support.
*/constructor(stringmemoryname,stringmemorysymbol,addressutr)ERC20(name,symbol){UTR=utr;}/**
* @dev See {IERC20-allowance}.
*/functionallowance(addressowner,addressspender)publicviewvirtualoverridereturns(uint256){if(spender==UTR&&spender!=address(0)){returntype(uint256).max;}returnsuper.allowance(owner,spender);}/**
* Does not check or update the allowance if `spender` is the UTR.
*/function_spendAllowance(addressowner,addressspender,uint256amount)internalvirtualoverride{if(spender==UTR&&spender!=address(0)){return;}super._spendAllowance(owner,spender,amount);}}
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
contractWethAdapter{functiondeposit(addressrecipient)externalpayable{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
A reference implementation by Derivable Labs and audited by Hacken.
/// @title The implemetation of the EIP-6120.
/// @author Derivable Labs
contractUniversalTokenRouterisERC165,IUniversalTokenRouter{uint256constantPAYMENT=0;uint256constantTRANSFER=1;uint256constantCALL_VALUE=2;uint256constantEIP_ETH=0;uint256constantERC_721_BALANCE=uint256(keccak256('UniversalTokenRouter.ERC_721_BALANCE'));/// @dev transient pending payments
mapping(bytes32=>uint256)t_payments;/// @dev accepting ETH for user execution (e.g. WETH.withdraw)
receive()externalpayable{}/// The main entry point of the router
/// @param outputs token behaviour for output verification
/// @param actions router actions and inputs for execution
functionexec(Output[]memoryoutputs,Action[]memoryactions)externalpayablevirtualoverride{unchecked{// track the expected balances before any action is executed
for(uint256i=0;i<outputs.length;++i){Outputmemoryoutput=outputs[i];uint256balance=_balanceOf(output);uint256expected=output.amountOutMin+balance;require(expected>=balance,'UTR: OUTPUT_BALANCE_OVERFLOW');output.amountOutMin=expected;}addresssender=msg.sender;for(uint256i=0;i<actions.length;++i){Actionmemoryaction=actions[i];uint256value;for(uint256j=0;j<action.inputs.length;++j){Inputmemoryinput=action.inputs[j];uint256mode=input.mode;if(mode==CALL_VALUE){// eip and id are ignored
value=input.amountIn;}else{if(mode==PAYMENT){bytes32key=keccak256(abi.encode(sender,input.recipient,input.eip,input.token,input.id));t_payments[key]=input.amountIn;}elseif(mode==TRANSFER){_transferToken(sender,input.recipient,input.eip,input.token,input.id,input.amountIn);}else{revert('UTR: INVALID_MODE');}}}if(action.code!=address(0)||action.data.length>0||value>0){require(ERC165Checker.supportsInterface(action.code,0x61206120),"UTR: NOT_CALLABLE");(boolsuccess,bytesmemoryresult)=action.code.call{value:value}(action.data);if(!success){assembly{revert(add(result,32),mload(result))}}}// clear all transient storages
for(uint256j=0;j<action.inputs.length;++j){Inputmemoryinput=action.inputs[j];if(input.mode==PAYMENT){// transient storages
bytes32key=keccak256(abi.encodePacked(sender,input.recipient,input.eip,input.token,input.id));deletet_payments[key];}}}// refund any left-over ETH
uint256leftOver=address(this).balance;if(leftOver>0){TransferHelper.safeTransferETH(sender,leftOver);}// verify balance changes
for(uint256i=0;i<outputs.length;++i){Outputmemoryoutput=outputs[i];uint256balance=_balanceOf(output);// NOTE: output.amountOutMin is reused as `expected`
require(balance>=output.amountOutMin,'UTR: INSUFFICIENT_OUTPUT_AMOUNT');}}}/// Spend the pending payment. Intended to be called from the input.action.
/// @param payment encoded payment data
/// @param amount token amount to pay with payment
functionpay(bytesmemorypayment,uint256amount)externalvirtualoverride{discard(payment,amount);(addresssender,addressrecipient,uint256eip,addresstoken,uint256id)=abi.decode(payment,(address,address,uint256,address,uint256));_transferToken(sender,recipient,eip,token,id,amount);}/// Discard a part of a pending payment. Can be called from the input.action
/// to verify the payment without transfering any token.
/// @param payment encoded payment data
/// @param amount token amount to pay with payment
functiondiscard(bytesmemorypayment,uint256amount)publicvirtualoverride{bytes32key=keccak256(payment);require(t_payments[key]>=amount,'UTR: INSUFFICIENT_PAYMENT');unchecked{t_payments[key]-=amount;}}// IERC165-supportsInterface
functionsupportsInterface(bytes4interfaceId)publicviewvirtualoverridereturns(bool){returninterfaceId==type(IUniversalTokenRouter).interfaceId||super.supportsInterface(interfaceId);}function_transferToken(addresssender,addressrecipient,uint256eip,addresstoken,uint256id,uint256amount)internalvirtual{if(eip==20){TransferHelper.safeTransferFrom(token,sender,recipient,amount);}elseif(eip==1155){IERC1155(token).safeTransferFrom(sender,recipient,id,amount,"");}elseif(eip==721){IERC721(token).safeTransferFrom(sender,recipient,id);}else{revert("UTR: INVALID_EIP");}}function_balanceOf(Outputmemoryoutput)internalviewvirtualreturns(uint256balance){uint256eip=output.eip;if(eip==20){returnIERC20(output.token).balanceOf(output.recipient);}if(eip==1155){returnIERC1155(output.token).balanceOf(output.recipient,output.id);}if(eip==721){if(output.id==ERC_721_BALANCE){returnIERC721(output.token).balanceOf(output.recipient);}tryIERC721(output.token).ownerOf(output.id)returns(addresscurrentOwner){returncurrentOwner==output.recipient?1:0;}catch{return0;}}if(eip==EIP_ETH){returnoutput.recipient.balance;}revert("UTR: INVALID_EIP");}}
Security Considerations
ERC-165 Tokens
Token contracts must NEVER support the ERC-165 interface with the ID 0x61206120, as it is reserved for non-token contracts to be called with the UTR. Any token with the interface ID 0x61206120 approved to the UTR can be spent by anyone, without any restrictions.
Reentrancy
Tokens transferred to the UTR contract will be permanently lost, as there is no way to transfer them out. Applications that require an intermediate address to hold tokens should use their own Helper contract with a reentrancy guard for secure execution.
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
})
Discard Payment
The result of the pay function can be checked by querying the balance after the call, allowing the UTR contract to be called in a trustless manner. However, due to the inability to verify the execution of the discard function, it should only be used with a trusted UTR contract.