はじめに

初めまして。 CryptoGamesというブロックチェーンゲーム企業でエンジニアをしている cardene(かるでね) です! スマートコントラクトを書いたり、フロントエンド・バックエンド・インフラと幅広く触れています。

代表的なゲームはクリプトスペルズというブロックチェーンゲームです。

今回は、ルーターコントラクトにより、トークンをapproveしてからコールするのではなく、transferしてからコールするUniversal Token Router(UTR)の仕組みを提案しているERC6120についてまとめていきます!

以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。

6120は現在(2024年1月17日)では「Review」段階です。

他にも様々なEIPについてまとめています。

概要

イーサリアムのトランザクションでは、本来「transferしてコールする」(transfer-and-call)というのが基本的な動作です。 しかし、ERC20 やその他のトークン規格はこの方式を前提としていないため、すでに存在するトークンコントラクトに新しい規格を適用するのは難しいです。

その結果、アプリケーションやルータコントラクトでは、「approveしてからコールする」(approve-then-call)というパターンを使う必要があります。 この方法では、各コントラクト、トークン、アカウントに追加の承認(approve または permit)が必要となり、ユーザー体験の低下、手数料やネットワークストレージの消費、セキュリティリスクの増大などの問題があります。 承認されるコントラクトが未監査や未検証、アップグレード可能なプロキシコントラクトの場合、リスクはさらに高まります。 また、このapprove-then-call パターンはエラーが発生しやすく、許可関連のバグや脆弱性が多く見つかっています。

Universal Token Router(UTR) は、これらの問題を解決するために設計されました。 UTRはトークンの許可をアプリケーションロジックから分離し、ETHと同じ方法で他のアプリケーションコントラクトの承認なしに、任意のトークンをコントラクトコールで使用できるようにします。

UTRに承認されたトークンは、所有者が直接署名したトランザクションでのみ使用できます。 トークンの移動は明確で、トークンのタイプ(ETH、ERC20、ERC721、ERC1155)、amountIn、amountOutMin、recipientなどが明確に表示されます。

ERC20については以下の記事を参考にしてください。

ERC721については以下の記事を参考にしてください。

ERC1155については以下の記事を参考にしてください。

Universal Token Router コントラクトは、EIP1014 の SingletonFactory コントラクトを使用して、アドレス 0x8Bd6072372189A12A2889a56b6ec982fD02b0B87 で全てのEVM互換ネットワークにデプロイされています。 これにより、新しいトークンコントラクトは、インタラクティブな使用時に承認トランザクションを必要とせず、UTRを信頼できるアドレスとして事前に設定できます。 これは、ユーザー体験の向上とセキュリティの強化に大きく貢献します。

transfer-and-callとapprove-then-call

Transfer-and-Call まず、Ethereumのスマートコントラクトは一般的にEther(ETH)やトークンといったデジタルアセットを管理するために使用されます。 Transfer-and-Callは、スマートコントラクトが他のアドレスに一定数量のトークンを送信し、そのトークンの送信後に他のスマートコントラクトの特定の関数を呼び出すプロセスを指します。 通常、この手順は次のようになります。 1. トークンを送信するスマートコントラクトが、所定の数量のトークンを別のアドレスに送信(transfer)します。 2. トークンを受け取ったアドレスには、受け取ったトークンに対して何らかの操作を行うスマートコントラクトが存在します。 3. トークンを受け取ったアドレスのスマートコントラクトは、そのトークンを受け取ると特定の関数を実行します。

Approve-then-Call Approve-then-Callは、トークンの所有者(通常はスマートコントラクト)が、他のスマートコントラクトに対してトークンの操作を許可し、その後に特定の関数を呼び出すプロセスです。 この手順は通常、以下のように進行します。 1. トークンの所有者(スマートコントラクト)は、特定のアドレスに対してトークン操作を許可する(approve)。 2. トークンを操作したい別のスマートコントラクトが、許可を受けて指定されたトークン操作を実行します。 3. 許可を受けたスマートコントラクトは、指定されたトークン操作を実行し、その後に特定の関数を呼び出すことができます。

要するに、Transfer-and-Callはトークンを直接送信してから別のスマートコントラクトを呼び出す方法であり、Approve-then-Callはトークンの所有者から許可を受けてから操作を行い、その後に関数を呼び出す方法です。 どちらの方法も、トークンの操作を安全に他のスマートコントラクトと組み合わせるための方法です。

動機

UTRは、ユーザーがトークンをコントラクトに承認する時のセキュリティを強化するために設計されています。 ユーザーは、以下の2つの重要なセキュリティ条件が満たされていることを信頼します。

  1. トークンは、ユーザーの許可(msg.sender や ecrecover 経由)を得てのみ使用される。
  2. delegatecall(例えば、アップグレード可能なプロキシなど)は使用されない。

これらのセキュリティ条件を守ることで、UTRは様々なインタラクティブアプリケーション間で共有され、既存のトークンのほとんどの承認トランザクションや、新しいトークンのすべての承認トランザクションを実行する必要がなくなります。

以前のEIPでは、ユーザーは承認されたトークンを使ってトランザクションに署名する時、フロントエンドがトランザクションを正確に送信することを完全に信頼していました。 しかし、これはフィッシングサイトによる大きなリスクを伴います。

UTRの関数引数は、トランザクションに署名する時のユーザーのマニフェストとして機能し、ウォレットのサポートを受けて、ユーザーは期待されるトークンの挙動を確認してレビューできます。 これにより、フィッシングサイトをより簡単に検出し、回避することが可能になります。

多くのアプリケーションコントラクトは既にUTRと互換性があり、以下のような利点を受け取れます。

  • 他のアプリケーションとユーザートークンの許可を安全に共有する。
  • 周辺コントラクトを頻繁に更新することが可能。
  • ルータコントラクトの開発とセキュリティ監査に関するコストを削減。

さらに、UTRは「プロセスによるセキュリティ」ではなく「結果によるセキュリティ」を推進し、トークン残高の変更を直接クエリして出力を検証することで、誤ったコントラクトや悪意のあるコントラクトとの相互作用でも、ユーザートランザクションの安全性を確保します。 トークン以外の結果に関しては、アプリケーションヘルパーコントラクトがUTRの出力検証のために追加の結果チェック機能を提供することができます。

仕様

UTRコントラクトのメインインターフェース。

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

出力検証

javascript
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;
}

UTRでは、トークンバランスの変化を検証するために「Output」という構造体が定義されています。 この構造体には以下のフィールドが含まれます。

  • recipient
    • 受取人のアドレス。
  • eip
    • トークン標準(0はETH、それ以外はEIP番号)。
  • token
    • トークンコントラクトのアドレス。
  • id
    • ERC721 や ERC1155 のトークンID。
  • amountOutMin
    • 最小出力量。

exec 関数の実行中、出力ごとに受取アドレスのトークンバランスが記録され、開始時と終了時のバランスが比較されます。 もしバランスの変化が amountOutMin より少なければ、トランザクションは INSUFFICIENT_OUTPUT_AMOUNT というエラーでrevertします。

特別なID ERC_721_BALANCE は ERC721 用に予約されており、出力アクションで使用して、受取アドレスが所有するすべてのIDの合計数を検証することができます。 このIDは、keccak256('UniversalTokenRouter.ERC_721_BALANCE') で計算されます。

javascript
ERC_721_BALANCE = keccak256('UniversalTokenRouter.ERC_721_BALANCE')

Action

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

「Action」構造体は、トークンの入力とコントラクトの呼び出しを定義します。 この構造体には以下のフィールドが含まれています。

  • inputs
    • 入力されるトークンの配列。
  • code
    • コントラクトコードのアドレス。
  • data
    • コントラクトの入力データ。

UTRによって呼び出されるためには、アクションコードコントラクトは ERC165 インターフェースをID 0x61206120 で実装していなければなりません。 このインターフェースチェックにより、UTRによるトークンのallowance-spending関数(例えば、transferFrom)の直接的な呼び出しが防がれます。 したがって、新しいトークンコントラクトは、このインターフェースIDを実装してはいけません。

NotToken という抽象コントラクトは ERC165 を実装しており、supportsInterface 関数を通じて特定のインターフェースIDのサポートを宣言します。 この関数は、ID 0x61206120 または他のサポートされているインターフェースIDが入力された場合に true を返します。

ERC165については以下の記事を参考にしてください。

例として、Application というコントラクトは NotToken を継承しており、UTRとの互換性を持つコントラクトとして利用することができます。

javascript
abstract contract NotToken is ERC165 {
    // IERC165-supportsInterface
    function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
        return
            interfaceId == 0x61206120 ||
            super.supportsInterface(interfaceId);
    }
}

contract Application is NotToken {
    // this contract can be used with the UTR
}

Input

javascript
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;
}

「Input」構造体は、アクションコントラクトの実行前に準備またはtransferする入力トークンを定義します。

「Input」構造体には次のフィールドが含まれています。

  • mode
    • 処理モード。
  • recipient
    • 受取アドレス。
  • eip
    • トークン標準(0はETH、それ以外はEIP番号)。
  • token
    • トークンコントラクトのアドレス。
  • id
    • ERC721 または ERC1155 のトークンID。
  • amountIn
    • 入力量。

mode は以下の値のいずれかを取ります。

  • PAYMENT = 0
    • トークンを msg.sender から受取アドレスにtransferするための支払いを保留する。
    • 同一トランザクション内のどこからでもUTRの pay を呼び出すことで実行されます。
  • TRANSFER = 1
    • トークンを直接 msg.sender から受取アドレスにtransferする。
  • CALL_VALUE = 2
    • アクションに渡すETH量を記録する。

inputs 引数内の各入力は順番に処理されます。 簡単化のため、重複する PAYMENT や CALL_VALUE 入力は有効ですが、最後の amountIn 値のみが使用されます。

Payment Input

Universal Token Router(UTR) における「PAYMENT」という入力モードについて説明です。 これは、特に「transfer-in-callback」パターンを使用するアプリケーションコントラクト(例:フラッシュローンコントラクト、Uniswap/v3-core、Derivableなど)に推奨されるモードです。

「PAYMENT」モードを使用する各入力について、msg.sender から受取アドレスにトークンをtransferするために、同一トランザクション内のどこからでもUTRの pay を呼び出すことができます。 ただし、transferされるトークンの量は amountIn までとなります。

UTRの処理フローは次のようになります。

  1. UTRは PAYMENT モードの支払いをUTRの pay で保留します。
  2. アクションコードがアプリケーションコントラクトを呼び出します。
  3. アプリケーションコントラクトからUTRの pay が呼び出されます。
  4. UTRは保留されていたすべての支払いをクリアします。
  5. トランザクションは終了します。
text
UTR
 |
 | PAYMENT
 | (payments pended for UTR.pay)
 |
 |                                  Application Contracts
action.code.call ---------------------> |
                                        |
UTR.pay <----------------------- (call) |
                                        |
 | <-------------------------- (return) |
 |
 | (clear all pending payments)
 |
END

トークンの許可(allowance)と PAYMENT は本質的に異なります。

  • 許可(allowance)
    • 特定のアドレスがいつでも、任意の人にトークンをtransferすることを許可します。
  • PAYMENT
    • トランザクション内でのみ、誰でも特定の受取アドレスにトークンをtransferすることを許可します。
支払いの使用(Spend Payment)
javascript
interface IUniversalTokenRouter {
    function pay(bytes memory payment, uint amount);
}

IUniversalTokenRouter インターフェースには pay という関数があります。 この関数を呼び出すためには、payment パラメータを次のようにエンコードする必要があります。

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

payment バイトは、カスタム支払いロジックを実行するためのコンテキストやペイロードを渡すために、アダプターUTRコントラクトによっても使用されることがあります。

支払いの破棄(Discard Payment)

場合によっては、transferを実行するのではなく支払いを破棄することが有用です。 例えば、アプリケーションコントラクトが payment.payer から自身のトークンをburnしたい場合などです。 以下の関数を使用すると、呼び出し元のアドレスに対する支払いを検証し、その一部を破棄することができます。

javascript
interface IUniversalTokenRouter {
    function discard(bytes memory payment, uint amount);
}
支払いの有効期間(Payment Lifetime)

支払いはUTRストレージに記録され、そのトランザクション内のみで input.action 外部呼び出しによって使用されることを意図しています。 すべての支払いストレージは、UTRの exec が終了する前にクリアされます。

ネイティブトークン Transfer

UTRは、ユーザーが実行ロジックでETHをtransferする必要がある場合に備えて、receive() 関数を持つべきです。 ルーターにtransferされた msg.value は、異なるアクションにまたがる複数の入力で使用することができます。 ETHのルーター内での移動については、呼び出し元が全責任を負いますが、exec 関数は、関数が終了する前に残ったETHを返金するべきです。

使用例

Uniswap V2 Router

従来の関数(Legacy Function)

UniswapV2Router01 の swapExactTokensForTokens 関数は以下のように定義されています。

js
UniswapV2Router01.swapExactTokensForTokens(
    uint amountIn,
    uint amountOutMin,
    address[] calldata path,
    address to,
    uint deadline
)
改良されたバージョン

UniswapV2Helper01.swapExactTokensForTokens は、トークンtransfer部分を除いた上記関数の改良版です。

ユーザーが従来の関数の代わりにスワップを実行するために署名するトランザクションは以下のようになります。

js
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,
    ]),
}])

このトランザクションでは、UTRを使用してトークンのtransferを行い、UniswapV2Helper01 コントラクトによって実際のスワップロジックが実行されます。 これにより、従来の方法と比べてより効率的かつ安全にトークンスワップを行うことができます。

Uniswap V3 Router

従来のルータコントラクト(Legacy Router Contract)
js
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);
    }
}

SwapRouter というコントラクトには、プールが入力トークンを支払うために呼び出す pay 関数が含まれています。 この関数内で、TransferHelper.safeTransferFrom を使用してトークンの支払いを行います。

UTRを使用するヘルパーコントラクト
js
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
        bytes memory payment = abi.encode(payer, recipient, 20, token, 0);
        UTR.pay(payment, value);
    }
}

SwapHelper というコントラクトにも同様に pay 関数がありますが、こちらではUTRの pay 関数を使用してトークンの支払いを行います。 この関数は、abi.encode を使用してトークンの支払い情報をエンコードし、UTRに渡します。

UTRを使用したトランザクションの実行

ユーザーが exactInput 機能を PAYMENT モードで実行するために署名するトランザクションは以下のようになります。

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

このトランザクションでは、UTRを使用してトークンの支払いを行い、SwapHelper コントラクトによって実際のスワップロジックが実行されます。 これにより、従来の方法と比べてより効率的かつ安全にトークンスワップを行うことができます。

Allowance Adapter

Allowance Adapter コントラクト
js
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];
            // clear all allowance
            IERC20(input.token).approve(spender, 0);
            uint leftOver = IERC20(input.token).balanceOf(address(this));
            if (leftOver > 0) {
                TransferHelper.safeTransfer(input.token, leftOverRecipient, leftOver);
            }
        }
    }
}

AllowanceAdapter コントラクトは、許可(allowance)を使用するアプリケーションやルータコントラクトのための、再入可能性(reentrancy)を考慮した ERC20 アダプタです。 このコントラクトには approveAndCall 関数が含まれており、以下のような構造です。

  • 入力として複数のトークンとその量を受け取ります。
  • 各トークンに対して、指定された支出者(spender)に対する承認(approve)を行います。
  • その後、支出者のコントラクトにデータとETH(msg.value)を送信します。
  • トランザクションが成功しない場合は、エラーをrevertします。
  • 最後に、すべての許可をクリアし、残ったトークンを指定された受取人(leftOverRecipient)に転送します。
UTRを使用したトランザクションの構築
js
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,
}])

UTRを使用してUniswap V2 Routerとやりとりするために、以下のようなトランザクションが構築されます。

  1. uniswapRouter から swapExactTokensForTokens 関数のトランザクションデータを取得します。
  2. AllowanceAdapter から approveAndCall 関数のトランザクションデータを取得します。
  3. UTRの exec 関数を呼び出して、上記のデータを使用してトランザクションを実行します。

このプロセスにより、UTRを介してUniswap V2 Routerとのトランザクションを実行する時に、トークンを直接承認することなく、セキュアで効率的な取引を可能にします。

補足

このテキストは、Universal Token Router(UTR)における「Permitタイプの署名」のサポートに関する方針について説明しています。

Universal Token Routerは、新しいトークンに対するすべてのインタラクティブな承認(approve)署名を排除し、古いトークンのほとんどについても同様に排除することを目的としています。 このため、Permitタイプの署名はサポートされていません。

Permitタイプの署名は、ERC20 トークンなどで使用される、ユーザーがトークンの承認を署名によって行う方法です。 しかし、UTRはこのようなインタラクティブな承認プロセスを不要にすることで、より効率的かつ安全なトークン取引を可能にすることを目指しています。 そのため、Permitタイプの署名はUTRではサポートされていないのです。

互換性

トークン

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

/**
 * @dev Implementation of the {ERC20} token standard that support a trusted ERC6120 contract as an unlimited spender.
 */
contract ERC20WithUTR is ERC20 {
    address immutable UTR;

    /**
     * @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(string memory name, string memory symbol, address utr) ERC20(name, symbol) {
        UTR = utr;
    }

    /**
     * @dev See {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);
    }

    /**
     * Does not check or update the allowance if `spender` is the UTR.
     */
    function _spendAllowance(address owner, address spender, uint256 amount) internal virtual override {
        if (spender == UTR && spender != address(0)) {
            return;
        }
        super._spendAllowance(owner, spender, amount);
    }
}

古いトークンコントラクト(ERC20、ERC721、ERC1155)

これらのコントラクトは、各アカウントごとに一度、UTRに対する承認が必要です。 これは、UTRがトークンを移動させるために必要な承認を得るための手順です。

新しいトークンコントラクト

新しいトークンコントラクトは、UTRを信頼できるアドレス(spender)として事前に設定することができます。 これにより、インタラクティブな使用のための承認トランザクションは必要ありません。

ERC20WithUTR は、UTRを無制限の支出者としてサポートする ERC20 トークン標準の実装です。 このコントラクトは、以下の特徴を持っています。

  • UTR は不変(immutable)で、コンストラクタで一度設定されます。
  • allowance 関数では、spender がUTRである場合、無限の許可を返します。
  • _spendAllowance 関数では、spender がUTRであれば、承認をチェックまたは更新しません。

このアプローチにより、新しいトークンコントラクトはUTRを使用して効率的かつ安全にトークンを移動させることができます。 これにより、トークンの承認プロセスが簡略化され、ユーザーエクスペリエンスが向上します。

アプリケーション

UTRと非互換なアプリケーションコントラクト

UTRと互換性がない唯一のアプリケーションコントラクトは、内部ストレージで msg.sender を受取アドレスとして使用し、所有権の移転機能がないものです。 これは、UTRを使用する時に、受取アドレスが msg.sender とは異なる可能性があるためです。

UTRと互換性のあるアプリケーションコントラクト

受取アドレス(または to)引数を受け入れるすべてのアプリケーションコントラクトは、そのままUTRと互換性があります。

トークンtransferに関するアダプターの必要性

ERC20、ERC721、ERC1155 トークンを msg.sender にtransferするアプリケーションコントラクトは、その機能に受取アドレスを追加するための追加のアダプターが必要です。

アダプターコントラクトの例

WethAdapter というコントラクトは、WETHトークンに対するサンプルアダプターです。 このコントラクトは deposit 関数を持っており、IWETH(WETH).deposit() を呼び出した後に TransferHelper.safeTransfer を使用してトークンを指定された受取アドレスにtransferします。

js
// 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);
    }
}
ヘルパーとアダプターコントラクトの追加

追加のヘルパーやアダプターコントラクトが必要な場合もあります。 これらのコントラクトはトークンや許可(allowance)を保持せず、頻繁に更新される可能性があり、核となるアプリケーションコントラクトのセキュリティへの影響はほとんどもしくは全くありません。

実装

js
/// @title The implemetation of the EIP-6120.
/// @author Derivable 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'));

    /// @dev transient pending payments
    mapping(bytes32 => uint256) t_payments;

    /// @dev accepting ETH for user execution (e.g. WETH.withdraw)
    receive() external payable {}

    /// The main entry point of the router
    /// @param outputs token behaviour for output verification
    /// @param actions router actions and inputs for execution
    function exec(
        Output[] memory outputs,
        Action[] memory actions
    ) external payable virtual override {
    unchecked {
        // track the expected balances before any action is executed
        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;
        }

        address sender = msg.sender;

        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 and id are ignored
                    value = input.amountIn;
                } else {
                    if (mode == PAYMENT) {
                        bytes32 key = keccak256(abi.encode(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 {
                        revert('UTR: INVALID_MODE');
                    }
                }
            }
            if (action.code != address(0) || action.data.length > 0 || value > 0) {
                require(
                    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))
                    }
                }
            }
            // clear all transient storages
            for (uint256 j = 0; j < action.inputs.length; ++j) {
                Input memory input = action.inputs[j];
                if (input.mode == PAYMENT) {
                    // transient storages
                    bytes32 key = keccak256(abi.encodePacked(
                        sender, input.recipient, input.eip, input.token, input.id
                    ));
                    delete t_payments[key];
                }
            }
        }

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

        // verify balance changes
        for (uint256 i = 0; i < outputs.length; ++i) {
            Output memory output = outputs[i];
            uint256 balance = _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
    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);
    }

    /// 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
    function discard(bytes memory payment, uint256 amount) public virtual override {
        bytes32 key = keccak256(payment);
        require(t_payments[key] >= amount, 'UTR: INSUFFICIENT_PAYMENT');
        unchecked {
            t_payments[key] -= 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 Tokens

トークンコントラクトは、ID 0x61206120 の ERC165 インターフェースを決してサポートしてはいけません。 このIDは、UTRを使用して呼び出される非トークンコントラクト用に予約されています。 UTRに承認され、インターフェースID 0x61206120 を持つ任意のトークンは、制限なしに誰でも使用することができます。

つまり、トークンコントラクトがこの特定のインターフェースIDをサポートしていると、UTRを通じてそのトークンが無制限に移動されるリスクがあります。 これはセキュリティ上の大きな問題を引き起こす可能性があるため、トークンコントラクトはこのインターフェースIDをサポートすべきではありません。 UTRとの互換性を確保するためには、このIDのサポートを避ける必要があります。

Reentrancy

UTRコントラクトへのトークンtransfer

UTRコントラクトにtransferされたトークンは永久に失われます。 これは、一度UTRコントラクトにtransferされたトークンを外部にtransferする方法がないためです。 トークンを一時的に保持する必要があるアプリケーションは、安全な実行のために再入可能性ガード(reentrancy guard)を備えた独自のヘルパーコントラクトを使用するべきです。

UTRコントラクトへのETHtransfer

アクションコールで使われる前に、ETHはUTRコントラクトにtransferされる必要があります(CALL_VALUEモードを使用して)。 このETHの値は、アクションコード内の再入可能なコールや不正なトークン関数を使用してUTRから抜き取られる可能性があります。 ただし、ユーザーがそのトランザクションで使うよりも多くのETHをtransferしない場合、このような抜き取りは不可能になります。

例

js
// 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
})

この例では、UTRコントラクトに100 weiをtransferし、そのうち60 weiのみをアクションコールで使用します。 これにより、最大40 weiが抜き取られる可能性があることを示しています。 トランザクションで使う量以上のETHをtransferしないことで、このようなリスクを避けることができます。

支払破棄

pay 関数の実行結果の確認

UTRコントラクトの pay 関数の結果は、コール後のバランスを照会することで確認することができます。 これにより、UTRコントラクトを信頼できない状況でも安全に呼び出すことが可能です。

discard 関数の使用に関する注意

discard 関数の実行を検証する方法がないため、この関数は信頼できるUTRコントラクトとのみ使用するべきです。 discard 関数は、特定の支払いを破棄するために使われますが、その実行が適切に行われたかを外部から検証することはできません。 したがって、この関数は慎重に、信頼できる状況でのみ使用する必要があります。

引用

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

最後に

今回は「ルーターコントラクトにより、トークンをapproveしてからコールするのではなく、transferしてからコールするUniversal Token Router(UTR)の仕組みを提案しているERC6120」についてまとめてきました! いかがだったでしょうか?

質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!

他の媒体でも情報発信しているのでぜひ他も見ていってください!