はじめに
初めまして。
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つの重要なセキュリティ条件が満たされていることを信頼します。
- トークンは、ユーザーの許可(
msg.sender
やecrecover
経由)を得てのみ使用される。 -
delegatecall
(例えば、アップグレード可能なプロキシなど)は使用されない。
これらのセキュリティ条件を守ることで、UTRは様々なインタラクティブアプリケーション間で共有され、既存のトークンのほとんどの承認トランザクションや、新しいトークンのすべての承認トランザクションを実行する必要がなくなります。
以前のEIPでは、ユーザーは承認されたトークンを使ってトランザクションに署名する時、フロントエンドがトランザクションを正確に送信することを完全に信頼していました。
しかし、これはフィッシングサイトによる大きなリスクを伴います。
UTRの関数引数は、トランザクションに署名する時のユーザーのマニフェストとして機能し、ウォレットのサポートを受けて、ユーザーは期待されるトークンの挙動を確認してレビューできます。
これにより、フィッシングサイトをより簡単に検出し、回避することが可能になります。
多くのアプリケーションコントラクトは既にUTRと互換性があり、以下のような利点を受け取れます。
- 他のアプリケーションとユーザートークンの許可を安全に共有する。
- 周辺コントラクトを頻繁に更新することが可能。
- ルータコントラクトの開発とセキュリティ監査に関するコストを削減。
さらに、UTRは「プロセスによるセキュリティ」ではなく「結果によるセキュリティ」を推進し、トークン残高の変更を直接クエリして出力を検証することで、誤ったコントラクトや悪意のあるコントラクトとの相互作用でも、ユーザートランザクションの安全性を確保します。
トークン以外の結果に関しては、アプリケーションヘルパーコントラクトがUTRの出力検証のために追加の結果チェック機能を提供することができます。
仕様
UTRコントラクトのメインインターフェース。
interface IUniversalTokenRouter {
function exec(
Output[] memory outputs,
Action[] memory actions
) payable;
}
出力検証
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')
で計算されます。
ERC_721_BALANCE = keccak256('UniversalTokenRouter.ERC_721_BALANCE')
Action
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との互換性を持つコントラクトとして利用することができます。
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
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の処理フローは次のようになります。
- UTRは
PAYMENT
モードの支払いをUTRのpay
で保留します。 - アクションコードがアプリケーションコントラクトを呼び出します。
- アプリケーションコントラクトからUTRの
pay
が呼び出されます。 - UTRは保留されていたすべての支払いをクリアします。
- トランザクションは終了します。
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)
interface IUniversalTokenRouter {
function pay(bytes memory payment, uint amount);
}
IUniversalTokenRouter
インターフェースには pay
という関数があります。
この関数を呼び出すためには、payment
パラメータを次のようにエンコードする必要があります。
payment = abi.encode(
payer, // address
recipient, // address
eip, // uint256
token, // address
id // uint256
);
payment
バイトは、カスタム支払いロジックを実行するためのコンテキストやペイロードを渡すために、アダプターUTRコントラクトによっても使用されることがあります。
支払いの破棄(Discard Payment)
場合によっては、transfer
を実行するのではなく支払いを破棄することが有用です。
例えば、アプリケーションコントラクトが payment.payer
から自身のトークンをburn
したい場合などです。
以下の関数を使用すると、呼び出し元のアドレスに対する支払いを検証し、その一部を破棄することができます。
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
関数は以下のように定義されています。
UniswapV2Router01.swapExactTokensForTokens(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
)
改良されたバージョン
UniswapV2Helper01.swapExactTokensForTokens
は、トークンtransfer
部分を除いた上記関数の改良版です。
ユーザーが従来の関数の代わりにスワップを実行するために署名するトランザクションは以下のようになります。
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)
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を使用するヘルパーコントラクト
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
モードで実行するために署名するトランザクションは以下のようになります。
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 コントラクト
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を使用したトランザクションの構築
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とやりとりするために、以下のようなトランザクションが構築されます。
-
uniswapRouter
からswapExactTokensForTokens
関数のトランザクションデータを取得します。 -
AllowanceAdapter
からapproveAndCall
関数のトランザクションデータを取得します。 - UTRの
exec
関数を呼び出して、上記のデータを使用してトランザクションを実行します。
このプロセスにより、UTRを介してUniswap V2 Routerとのトランザクションを実行する時に、トークンを直接承認することなく、セキュアで効率的な取引を可能にします。
補足
このテキストは、Universal Token Router(UTR)における「Permitタイプの署名」のサポートに関する方針について説明しています。
Universal Token Routerは、新しいトークンに対するすべてのインタラクティブな承認(approve
)署名を排除し、古いトークンのほとんどについても同様に排除することを目的としています。
このため、Permit
タイプの署名はサポートされていません。
Permit
タイプの署名は、ERC20 トークンなどで使用される、ユーザーがトークンの承認を署名によって行う方法です。
しかし、UTRはこのようなインタラクティブな承認プロセスを不要にすることで、より効率的かつ安全なトークン取引を可能にすることを目指しています。
そのため、Permit
タイプの署名はUTRではサポートされていないのです。
互換性
トークン
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
します。
// 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)を保持せず、頻繁に更新される可能性があり、核となるアプリケーションコントラクトのセキュリティへの影響はほとんどもしくは全くありません。
実装
/// @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
しない場合、このような抜き取りは不可能になります。
例
// 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などからお気軽に質問してください!
他の媒体でも情報発信しているのでぜひ他も見ていってください!