Alert Source Discuss
⚠️ Review Standards Track: ERC

ERC-6120: 通用 Token 路由器

单个路由器合约允许以 transfer-and-call 模式将 token 发送到应用程序合约,而不是 approve-then-call 模式。

Authors Derion (@derion-io), Zergity (@Zergity), Ngo Quang Anh (@anhnq82), BerlinP (@BerlinP), Khanh Pham (@blackskin18), Hal Blackburn (@h4l)
Created 2022-12-12
Requires EIP-20, EIP-165, EIP-721, EIP-1014, EIP-1155

摘要

ETH 在交易中被设计为以transfer-and-call作为默认行为。不幸的是,ERC-20在设计时没有考虑到这种模式,并且较新的标准无法应用于已经部署的 token 合约。

应用程序和路由器合约必须使用 approve-then-call 模式,对于 $n$ 个合约、$m$ 个 token 和 $l$ 个账户,这会额外花费 $n\times m\times l$ 个 approve (或 permit) 签名。这些授权交易不仅会产生糟糕的用户体验,花费大量用户费用和网络存储,还会给用户带来严重的安全风险,因为他们经常需要批准未经审计、未验证和可升级的代理合约。approve-then-call 模式也相当容易出错,因为最近发现了许多与授权相关的错误和漏洞。

通用 Token 路由器 (UTR) 将 token 授权与应用程序逻辑分离,允许以与 ETH 相同的方式在合约调用中花费任何 token,而无需批准任何其他应用程序合约。

批准给通用 Token 路由器的 token 只能在由其所有者直接签名的交易中使用,并且它们具有清晰可见的 token 转移行为,包括 token 类型(ETH、ERC-20ERC-721ERC-1155)、amountInamountOutMinrecipient

通用 Token 路由器合约使用在所有 EVM 兼容网络上的 0x69c4620b62D99f524c5B4dE45442FE2D7dD59576 部署的 EIP-1014 SingletonFactory 合约。这使得新的 token 合约可以将其预配置为受信任的消费方,从而消除了在交互使用期间进行批准交易的需要。

动机

当用户将其 token 批准给合约时,他们信任:

  • 它仅在获得他们的许可(来自 msg.senderecrecover)后才能花费 token
  • 它不使用 delegatecall(例如,可升级的代理)

通过确保与上述相同的安全条件,通用 Token 路由器可以被所有交互式应用程序共享,从而节省了旧 token 的大多数批准交易和所有新 token 的批准交易。

在本 EIP 之前,当用户签署交易以花费其批准的 token 时,他们完全信任前端代码能够诚实和正确地构建这些交易。这使他们面临巨大的网络钓鱼网站风险。

通用 Token 路由器的函数参数可以充当用户签署交易时的清单。在钱包的支持下,用户可以看到并审查他们期望的 token 行为,而不是盲目地信任应用程序合约和前端代码。对于用户而言,网络钓鱼网站将更容易被检测和避免。

大多数应用程序合约已经与通用 Token 路由器兼容,并且可以使用它来获得以下好处:

  • 与所有其他应用程序安全地共享用户 token 授权。
  • 根据需要经常更新其外围合约。
  • 节省路由器合约的开发和安全审计成本。

通用 Token 路由器促进了去中心化应用程序中的security-by-result模型,而不是security-by-process。通过直接查询 token 余额变化以进行输出验证,即使在与错误或恶意合约交互时,也可以确保用户交易的安全。对于非 token 结果,应用程序帮助程序合约可以为 UTR 的输出验证提供额外的结果检查功能。

规范

本文档中的关键词“必须 (MUST)”、“禁止 (MUST NOT)”、“必需 (REQUIRED)”、“应 (SHALL)”、“不应 (SHALL NOT)”、“应该 (SHOULD)”、“不应该 (SHOULD NOT)”、“推荐 (RECOMMENDED)”、“不推荐 (NOT RECOMMENDED)”、“可以 (MAY)”和“可选 (OPTIONAL)”应按照 RFC 2119 和 RFC 8174 中的描述进行解释。

UTR 合约的主要接口:

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

输出验证

Output 定义了用于验证的预期 token 余额变化。

struct Output {
    address recipient;
    uint eip;           // token 标准:0 表示 ETH 或 EIP 编号
    address token;      // token 合约地址
    uint id;            // ERC-721 和 ERC-1155 的 token id
    uint amountOutMin;
}

对于 outputs 中的每个项目,recipient 地址的 token 余额在 exec 函数的开始和结束时都会被记录。如果任何余额变化的数量小于其 amountOutMin,则交易将以 INSUFFICIENT_OUTPUT_AMOUNT 错误回退。

一个特殊的 id ERC_721_BALANCE 保留给 ERC-721,可以在输出操作中使用它来验证 recipient 地址拥有的所有 id 的总数。

ERC_721_BALANCE = keccak256('UniversalTokenRouter.ERC_721_BALANCE')

操作

Action 定义了 token 输入和合约调用。

struct Action {
    Input[] inputs;
    address code;       // 合约代码地址
    bytes data;         // 合约输入数据
}

操作代码合约必须实现 NotToken 合约或具有 ID 0x61206120ERC-165 接口,才能被 UTR 调用。此接口检查可防止 UTR 直接调用 token 授权消费 函数(例如,transferFrom)。因此,新的 token 合约不得实现此接口 ID。

/**
 * 此合约将与 ERC20、ERC721 和 ERC1155 标准冲突,
 * 阻止 token 合约意外地实现它。
 */
abstract contract NotToken  {
    function allowance(address, address) external pure returns (string memory) {
        return "THIS IS NOT A TOKEN";
    }
    function isApprovedForAll(address, address) external pure returns (string memory) {
        return "THIS IS NOT A TOKEN";
    }
}

contract Application is NotToken {
    // 此合约可以与 UTR 一起使用
}

输入

Input 定义了在执行操作合约之前要转移或准备的输入 token。

struct Input {
    uint mode;
    address recipient;
    uint eip;           // token 标准:0 表示 ETH 或 EIP 编号
    address token;      // token 合约地址
    uint id;            // ERC-721 和 ERC-1155 的 token id
    uint amountIn;
}

mode 采用以下值之一:

  • PAYMENT = 0:挂起 token 付款,以便通过在同一交易中的任何位置调用 UTR.pay,将 token 从 msg.sender 转移到 recipient
  • TRANSFER = 1:将 token 直接从 msg.sender 转移到 recipient
  • CALL_VALUE = 2:记录要作为调用 value 传递给操作的 ETH 金额。

按顺序处理 inputs 参数中的每个输入。为简单起见,重复的 PAYMENTCALL_VALUE 输入是有效的,但仅使用最后一个 amountIn 值。

付款输入

PAYMENT 是使用 transfer-in-callback 模式的应用程序合约的推荐模式。例如,flashloan 合约、Uniswap/v3-core、Derivable 等。

对于具有 PAYMENT 模式的每个 Input,通过在同一交易中的任何位置调用 UTR.pay,最多可以将 amountIn 的 token 从 msg.sender 转移到 recipient

UTR
 |
 | PAYMENT
 | (为 UTR.pay 挂起的付款)
 |
 |                                  应用程序合约
action.code.call ---------------------> |
                                        |
UTR.pay <----------------------- (调用) |
                                        |
 | <-------------------------- (返回) |
 |
 | (清除所有挂起的付款)
 |
END

Token 的授权和 PAYMENT 本质上是不同的,因为:

  • 授权:允许特定的 spender 随时将 token 转移给任何人。
  • PAYMENT: 只允许任何人在该交易中将 token 转移给特定的 recipient
花费付款
interface IUniversalTokenRouter {
    function pay(bytes memory payment, uint amount);
}

要调用 paypayment 参数必须按如下方式编码:

payment = abi.encode(
    payer,      // address
    recipient,  // address
    eip,        // uint256
    token,      // address
    id          // uint256
);

适配器 UTR 合约也可以使用 payment 字节传递上下文和有效负载,以执行自定义付款逻辑。

丢弃付款

有时,放弃付款而不是执行转账是很有用的,例如,当应用程序合约想要从 payment.payer 销毁自己的 token 时。可以使用以下函数来验证支付给调用者地址的付款并丢弃其中的一部分。

interface IUniversalTokenRouter {
    function discard(bytes memory payment, uint amount);
}

有关重要的安全注意事项,请参阅安全注意事项中的放弃付款部分。

发件人身份验证

放弃付款还可以使用路由器进行发件人身份验证,这在使用常规路由器时是永远无法实现的。通过输入伪付款(不是 token 付款),UTR 允许目标合约验证发件人的地址以进行身份验证,以及正常的 token 转移和付款。

contract AuthChecker is NotToken {
    // 必须信任对 discard 函数的正确实现
    address immutable UTR;

    function actionMustSentBySender(address sender) external {
        bytes memory payment = abi.encode(sender, address(this), 0, address(0), 0);
        IUniversalTokenRouter(UTR).discard(payment, 1);
    }
}
await utr.exec([], [{
    inputs: [{
        mode: PAYMENT,
        eip: 0,
        token: AddressZero,
        id: 0,
        amountIn: 1,
        recipient: paymentTest.address,
    }],
    code: authChecker.address,
    data: (await authChecker.populateTransaction.actionMustSentBySender(owner.address)).data,
}])

有关重要的安全注意事项,请参阅安全注意事项中的放弃付款部分。

付款有效期

付款记录在 UTR 存储中,并且仅用于在交易中的 input.action 外部调用中花费这些款项。所有付款存储将在 UTR.exec 结束后清除。

本地 Token 转移

UTR 应该具有 receive() 函数,用于需要转入 ETH 的用户执行逻辑。转入路由器的 msg.value 可以在不同操作的多个输入中使用。虽然调用者对 ETH 进出路由器的移动承担全部责任,但 exec 函数应该在函数结束前退还任何剩余的 ETH

有关重入风险和缓解措施的信息,请参阅安全注意事项中的 重入 部分。

使用示例

Uniswap V2 路由器

旧函数:

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

UniswapV2Helper01.swapExactTokensForTokens 是它的修改版本,没有 token 转移部分。

此交易由用户签署以执行交换,而不是旧函数:

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 路由器

旧路由器合约:

contract SwapRouter {
    // 此函数由资金池调用以支付输入 token
    function pay(
        address token,
        address payer,
        address recipient,
        uint256 value
    ) internal {
        ...
        // 拉取付款
        TransferHelper.safeTransferFrom(token, payer, recipient, value);
    }
}

UTR 一起使用的帮助程序合约:

contract SwapHelper {
    // 此函数由资金池调用以支付输入 token
    function pay(
        address token,
        address payer,
        address recipient,
        uint256 value
    ) internal {
        ...
        // 拉取付款
        bytes memory payment = abi.encode(payer, recipient, 20, token, 0);
        UTR.pay(payment, value);
    }
}

此交易由用户签署以使用 PAYMENT 模式执行 exactInput 功能:

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", [...]),
}])

授权适配器

用于使用直接授权的应用程序和路由器合约的简单非重入 ERC-20 适配器。

contract AllowanceAdapter is ReentrancyGuard {
    struct Input {
        address token;
        uint amountIn;
    }

    function approveAndCall(
        Input[] memory inputs,
        address spender,
        bytes memory data,
        address leftOverRecipient
    ) external payable nonReentrant {
        for (uint i = 0; i < inputs.length; ++i) {
            Input memory input = inputs[i];
            IERC20(input.token).approve(spender, input.amountIn);
        }

        (bool success, bytes memory result) = spender.call{value: msg.value}(data);
        if (!success) {
            assembly {
                revert(add(result, 32), mload(result))
            }
        }

        for (uint i = 0; i < inputs.length; ++i) {
            Input memory input = inputs[i];
            // 清除所有授权
            IERC20(input.token).approve(spender, 0);
            uint leftOver = IERC20(input.token).balanceOf(address(this));
            if (leftOver > 0) {
                TransferHelper.safeTransfer(input.token, leftOverRecipient, leftOver);
            }
        }
    }
}

构造此交易以利用 UTR 与 Uniswap V2 路由器交互,而无需批准任何 token:

const { data: routerData } = await uniswapRouter.populateTransaction.swapExactTokensForTokens(
    amountIn,
    amountOutMin,
    path,
    to,
    deadline,
)

const { data: adapterData } = await adapter.populateTransaction.approveAndCall(
    [{
        token: path[0],
        amountIn,
    }],
    uniswapRouter.address,
    routerData,
    leftOverRecipient,
)

await utr.exec([], [{
    inputs: [{
        mode: TRANSFER,
        recipient: adapter.address,
        eip: 20,
        token: path[0],
        id: 0,
        amountIn,
    }],
    code: adapter.address,
    data: adapterData,
}])

基本原理

不支持 Permit 类型签名,因为通用 Token 路由器的目的是消除新 token 的所有交互式 approve 签名,以及大多数旧 token 的交互式签名。

向后兼容性

Token

旧的 token 合约(ERC-20、ERC-721 和 ERC-1155)需要为每个账户批准一次通用 Token 路由器。

新的 token 合约可以将通用 Token 路由器预配置为受信任的消费方,并且交互使用不需要批准交易。

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

/**
 * @dev {ERC20} token 标准的实现,该实现支持受信任的 ERC6120 合约作为无限消费方。
 */
contract ERC20WithUTR is ERC20 {
    address immutable UTR;

    /**
     * @dev 设置 {name}、{symbol} 和 ERC6120 的 {utr} 地址的值。
     *
     * 所有这三个值都是不可变的:它们只能在
     * 构造过程中设置一次。
     *
     * @param utr 可以为零以禁用受信任的 ERC6120 支持。
     */
    constructor(string memory name, string memory symbol, address utr) ERC20(name, symbol) {
        UTR = utr;
    }

    /**
     * @dev 参见 {IERC20-allowance}。
     */
    function allowance(address owner, address spender) public view virtual override returns (uint256) {
        if (spender == UTR && spender != address(0)) {
            return type(uint256).max;
        }
        return super.allowance(owner, spender);
    }

    /**
     * 如果 `spender` 是 UTR,则不选中或更新授权。
     */
    function _spendAllowance(address owner, address spender, uint256 amount) internal virtual override {
        if (spender == UTR && spender != address(0)) {
            return;
        }
        super._spendAllowance(owner, spender, amount);
    }
}

应用程序

唯一与 UTR 不兼容的应用程序合约是在其内部存储中使用 msg.sender 作为受益人地址,而没有任何所有权转移功能的合约。

所有接受 recipient(或 to)参数作为受益人地址的应用程序合约都与 UTR 开箱即用兼容。

将 token(ERC-20、ERC-721 和 ERC-1155)转移到 msg.sender 的应用程序合约需要额外的适配器才能将 recipient 添加到其函数中。

// WETH 示例适配器合约
contract WethAdapter {
    function deposit(address recipient) external payable {
        IWETH(WETH).deposit(){value: msg.value};
        TransferHelper.safeTransfer(WETH, recipient, msg.value);
    }
}

可能需要额外的帮助程序和适配器合约,但它们大多是外围的和非侵入性的。它们不持有任何 token 或授权,因此可以频繁更新,并且对核心应用程序合约几乎没有安全影响。

参考实现

Derion Labs 的参考实现,并由 Hacken 审计。

/// @title EIP-6120 的实现。
/// @author Derion Labs
contract UniversalTokenRouter is ERC165, IUniversalTokenRouter {
    uint256 constant PAYMENT       = 0;
    uint256 constant TRANSFER      = 1;
    uint256 constant CALL_VALUE    = 2;

    uint256 constant EIP_ETH       = 0;

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

    /// 路由器的主要入口点
    /// @param outputs 用于输出验证的 token 行为
    /// @param actions 路由器的操作和执行输入
    function exec(
        Output[] memory outputs,
        Action[] memory actions
    ) external payable virtual override {
    unchecked {
        // 跟踪在执行任何操作之前的预期余额
        for (uint256 i = 0; i < outputs.length; ++i) {
            Output memory output = outputs[i];
            uint256 balance = _balanceOf(output);
            uint256 expected = output.amountOutMin + balance;
            require(expected >= balance, 'UTR: OUTPUT_BALANCE_OVERFLOW');
            output.amountOutMin = expected;
        }

        for (uint256 i = 0; i < actions.length; ++i) {
            Action memory action = actions[i];
            uint256 value;
            for (uint256 j = 0; j < action.inputs.length; ++j) {
                Input memory input = action.inputs[j];
                uint256 mode = input.mode;
                if (mode == CALL_VALUE) {
                    // 忽略 eip 和 id
                    value = input.amountIn;
                } else {
                    if (mode == PAYMENT) {
                        bytes32 key = keccak256(abi.encode(
                            msg.sender, input.recipient, input.eip, input.token, input.id
                        ));
                        uint amountIn = input.amountIn;
                        assembly {
                            tstore(key, amountIn)
                        }
                    } else if (mode == TRANSFER) {
                        _transferToken(msg.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(
                    TokenChecker.isNotToken(action.code) ||
                    ERC165Checker.supportsInterface(action.code, 0x61206120),
                    "UTR: NOT_CALLABLE"
                );
                (bool success, bytes memory result) = action.code.call{value: value}(action.data);
                if (!success) {
                    assembly {
                        revert(add(result,32),mload(result))
                    }
                }
            }
            // 清除所有瞬态存储
            for (uint256 j = 0; j < action.inputs.length; ++j) {
                Input memory input = action.inputs[j];
                if (input.mode == PAYMENT) {
                    // 瞬态存储
                    bytes32 key = keccak256(abi.encode(
                        msg.sender, input.recipient, input.eip, input.token, input.id
                    ));
                    assembly {
                        tstore(key, 0)
                    }
                }
            }
        }

        // 退还任何剩余的 ETH
        uint256 leftOver = address(this).balance;
        if (leftOver > 0) {
            TransferHelper.safeTransferETH(msg.sender, leftOver);
        }

        // 验证余额变化
        for (uint256 i = 0; i < outputs.length; ++i) {
            Output memory output = outputs[i];
            uint256 balance = _balanceOf(output);
            // 注意:output.amountOutMin 重用为“expected”
            require(balance >= output.amountOutMin, 'UTR: INSUFFICIENT_OUTPUT_AMOUNT');
        }
    } }
    
    /// 花费挂起的付款。旨在从 input.action 中调用。
    /// @param payment 编码的付款数据
    /// @param amount 要使用于付款的 token 数量
    function pay(bytes memory payment, uint256 amount) external virtual override {
        discard(payment, amount);
        (
            address sender,
            address recipient,
            uint256 eip,
            address token,
            uint256 id
        ) = abi.decode(payment, (address, address, uint256, address, uint256));
        _transferToken(sender, recipient, eip, token, id, amount);
    }

    /// 放弃一部分挂起的付款。可以从 input.action 中调用
    /// 以验证付款,而无需转移任何 token。
    /// @param payment 编码的付款数据
    /// @param amount 要使用于付款的 token 数量
    function discard(bytes memory payment, uint256 amount) public virtual override {
        bytes32 key = keccak256(payment);
        uint256 remain;
        assembly {
            remain := tload(key)
        }
        require(remain >= amount, 'UTR: INSUFFICIENT_PAYMENT');
        assembly {
            tstore(key, sub(remain, amount))
        }
    }

    // IERC165-supportsInterface
    function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
        return
            interfaceId == type(IUniversalTokenRouter).interfaceId ||
            super.supportsInterface(interfaceId);
    }

    function _transferToken(
        address sender,
        address recipient,
        uint256 eip,
        address token,
        uint256 id,
        uint256 amount
    ) internal virtual {
        if (eip == 20) {
            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 {
            revert("UTR: INVALID_EIP");
        }
    }

    function _balanceOf(
        Output memory output
    ) internal view virtual returns (uint256 balance) {
        uint256 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("UTR: INVALID_EIP");
    }
}

安全注意事项

ERC-165 Token

Token 合约绝不能支持具有 ID 0x61206120 的 ERC-165 接口,因为它保留给非 token 合约以通过 UTR 调用。任何具有接口 ID 0x61206120 的 token 获得 UTR 批准后,都可以被任何人花费,没有任何限制。

重入

转移到 UTR 合约的 token 将永久丢失,因为无法将它们转移出去。需要中间地址来持有 token 的应用程序应使用具有重入保护的自己的 Helper 合约来确保安全执行。

ETH 必须在值在操作调用中花费之前(使用 CALL_VALUE)转移到 UTR 合约。可以使用操作代码或恶意 token 函数中的重入调用从 UTR 中提取此 ETH 值。如果用户转入的 ETH 不超过他们将在该交易中花费的 ETH,则此漏洞将不可能发生。

// 转入 100,但只花费 60,
// 因此,在该交易中最多可以利用 40 wei
UniversalTokenRouter.exec([
    ...
], [{
    inputs: [{
        mode: CALL_VALUE,
        eip: 20,
        token: 0,
        id: 0,
        amountIn: 60,   // 花费 60
        recipient: AddressZero,
    }],
    ...
}], {
    value: 100,   // 转入 100
})

放弃付款

可以通过在调用后查询余额来检查 pay 函数的结果,从而允许以无需信任的方式调用 UTR 合约。但是,由于无法验证 discard 函数的执行,因此只能将其与受信任的 UTR 合约一起使用。

版权

版权和相关权利通过 CC0 放弃。

Citation

Please cite this document as:

Derion (@derion-io), Zergity (@Zergity), Ngo Quang Anh (@anhnq82), BerlinP (@BerlinP), Khanh Pham (@blackskin18), Hal Blackburn (@h4l), "ERC-6120: 通用 Token 路由器 [DRAFT]," Ethereum Improvement Proposals, no. 6120, December 2022. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-6120.