ERC-6120 Universal token router
Review · Other · created 2022-12-12
A shared, single router contract that lets any token be used in one call, just like ETH. Instead of approving each app separately, you only need to approve this one router.
Why it is needed
ETH can be sent and a contract executed at the same time, but widely used ERC-20 tokens cannot do this. So to use an app, you must first send an approval transaction authorizing token use, then send a separate execution transaction, a two-step process. This approach is costly and provides a poor user experience, and because token spending permission must be entrusted to contracts that may be unaudited or upgradeable, it has caused many allowance-related bugs and theft incidents. Users also have no way to verify whether an app's frontend constructed a transaction honestly, leaving them exposed to phishing. This standard identifies the coupling of token approval and app logic as the problem and proposes a shared router to separate the two.
What to implement
function pay(bytes memory payment, uint amount);: Actually executes a scheduled payment. Within the same transaction, the app contract calls it to pull amount of tokens from the user to recipient.function discard(bytes memory payment, uint amount);: Destroys part of a payment reservation without moving tokens. Used either to merely verify a payment exists, or when an app burns the payer's tokens directly.
In depth
A single exec call completes input, execution, and verification
exec(outputs, actions) is the sole entry point. The Output struct defines the expected balance change via recipient, eip (0 for ETH, otherwise the EIP number), token, id, and amountOutMin. UTR records the recipient's balance at the start and end of exec, and reverts with INSUFFICIENT_OUTPUT_AMOUNT if the change is less than amountOutMin. For ERC-721, a reserved special id, ERC_721_BALANCE = keccak256('UniversalTokenRouter.ERC_721_BALANCE'), lets you verify the recipient's total holdings instead of a specific id. Because the result is verified rather than the process (security-by-result), even if a faulty contract is used along the way, the transaction becomes invalid if the expected tokens are not received.
Action calls and the mechanism preventing token miscalls
An Action consists of inputs (input tokens), code (the contract address to call), and data (the call data). The action's code contract MUST implement the NotToken contract or the ERC-165 interface ID 0x61206120 for UTR to call it. This check is a safeguard to prevent UTR from directly calling allowance-spending functions such as transferFrom. NotToken deliberately conflicts with the ERC-20, ERC-721, and ERC-1155 standards by overriding allowance and isApprovedForAll to return strings. Therefore, new token contracts MUST NOT implement this interface ID.
Three input modes
The Input struct consists of mode, recipient, eip, token, id, and amountIn, and there are three modes. PAYMENT(0) reserves a payment within the same transaction, allowing anyone to call UTR.pay to move up to amountIn from msg.sender to recipient. TRANSFER(1) sends tokens immediately from msg.sender to recipient. CALL_VALUE(2) records the amount of ETH (value) to pass to the action call. Inputs are processed in order, and if PAYMENT and CALL_VALUE are duplicated, only the last amountIn is valid. Allowance and PAYMENT are different. An allowance lets a given spender send to anyone at any time, while PAYMENT only allows sending to the recipient fixed within that transaction.
pay, discard, and the lifetime of a payment
The payment argument of pay(payment, amount) is encoded as abi.encode(payer, recipient, eip, token, id). PAYMENT mode is recommended for transfer-in-callback patterns, such as flash loans or Uniswap v3, where tokens are pulled inside a callback. discard(payment, amount) is a function that destroys a reservation without moving tokens, used when an app burns the payer's own tokens, or for sender authentication by destroying a single fake, non-token payment unit to verify that a caller-specified address is the actual signer. All payment records are stored in UTR storage and are entirely cleared before exec finishes, so they cannot be used outside the transaction.
Singleton deployment and approval-free new tokens
UTR is deployed at the same address, 0x69c4620b62D99f524c5B4dE45442FE2D7dD59576, on every EVM-compatible network via the EIP-1014 SingletonFactory. Existing tokens (ERC-20, ERC-721, ERC-1155) only need to approve UTR once per account, and new tokens, as in the ERC20WithUTR example, can eliminate the approval transaction entirely by having allowance return type(uint256).max and skipping _spendAllowance when the spender is UTR. Permit-style signatures are not supported, because the purpose of this standard is to eliminate all approval signatures for new tokens and most approvals for existing tokens.
Compatibility with existing apps
App contracts that take a beneficiary address as a recipient (or to) argument are compatible as-is, with no modification needed. The only incompatible contracts are those that record msg.sender internally as the beneficiary with no way to transfer ownership. Contracts that send tokens to msg.sender need an adapter that adds a recipient, as in the WethAdapter example, and existing routers that use allowances directly can be connected via AllowanceAdapter's approveAndCall. Since these helpers and adapters do not hold tokens or allowances, updating them frequently has little impact on the core app contract's security.
A proposal still under review
The original's status is Review, meaning it is a proposal stage whose content may still change. The original includes a reference implementation written by Derion Labs and audited by Hacken.
Things to watch out for
- Tokens implementing the 0x61206120 interface are forbidden: A token contract MUST NOT ever support the ERC-165 interface ID 0x61206120. This ID is reserved for non-token contracts that UTR calls, and if a token supporting it is approved to UTR, anyone could spend that token without restriction.
- Tokens sent to UTR cannot be recovered: Tokens transferred into the UTR contract have no way to be withdrawn and are permanently lost. Apps that need an address to temporarily hold tokens should use their own helper contract equipped with reentrancy protection.
- Leftover ETH can be drained via reentrancy: If more ETH is sent to UTR than will be used for CALL_VALUE, the excess can be drained through a reentrant call inside the action code or through a malicious token function. Sending only as much as will be used in that transaction makes this attack impossible.
- discard should only be used with a trusted UTR: The result of pay can be verified by checking the balance afterward, so UTR can be called without trust, but discard's execution cannot be verified, so it should only be used with a trusted UTR contract.