はじめに

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

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

今回は、ERC2612で提案されているにapprove + transferFromのメタトランザクションを、より安全に実行する仕組みを提案しているERC3009についてまとめていきます!

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

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

この規格はステージがStagnant(6ヶ月以上更新がない状態)なので、暫く更新されておらず提案された当時の状態と現在の状態と異なる部分があります。 EIPの各ステージについては以下を参考にしてください。

概要

この規格では、署名されたapproveを使用してトークンをtransfer可能にするインターフェースを提案しています。

EIP712という、ERC20に準拠した署名によってアトミック性を持ったメタトランザクションを可能にする仕組みを使用することで、以下のようなことができます。

イーサリアム上のトランザクションを実行するには、ガス代をETHで支払う必要があります。 メタトランザクションを使うことで、ユーザーが直接ガス料金を支払う必要がなくなり、他のアドレスがガス代を支払うことができます。

  • ガス代のデリゲート
    • 他のアドレスにガス代の支払いを委任できます。
  • トークンでのガス代支払い
    • ネイティブトークンであるETHではなく、任意のトークンでガス代を支払うことできます。
  • アトミックなトランザクション
    • 1回のトランザクションで複数のトークンを送付したり、その他の操作をまとめて実行できます。
    • アトミックというのは、1つでも処理が失敗したらそのトランザクションの処理を全て失敗にするということです。
  • トランザクション送信の委任
    • ガス代の支払いの委任に通じる部分ですが、トランザクションの送信を他のアドレスにしてもらうことが可能です。
  • バッチトランザクション
    • 複数のトランザクションを一括で処理できます。
  • nonceの再利用防止
    • nonceとは、特定のアドレスがこれまで送ってきたトランザクションの数です。
    • トランザクションをが成功/失敗すると、このnonce値に1加算されるのですが、このnonceが1加算されないと、同じトランザクションを2度送れてしまいます。
    • これを防止する仕組みを実装しつつ複数のトランザクションを実行できます。

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

動機

EIP2612という既存の規格によりメタトランザクションは可能になっています。 この規格とEIP2612の違いは以下になります。

  • EIP2612は連続したnonce(トランザクションの一意の識別子)を使用しますが、この規格ではランダムな32バイトのnonceを使用します。
  • EIP2612はERC20のapprove + transferFromパターンに依存しています。

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

連続したnonceを使用する場合、以下の理由から複数トランザクションの同時実行ができません。

  • Dappsがブロックチェーン上で処理されていないnonceを再利用する可能性がある。
    • ガス代の急激な高騰により、トランザクションがmempool(実行予定のトランザクションの溜まり場)に長期間溜まったままになってしまうことで起きる可能性があります。

一方、この規格で提案されているように、非連続のnonceを使用することでユーザーは好きなだけトランザクションを送ることができます。

現状、アドレスごとnonceの管理がされているため、コントラクト側でnonceの管理をしてもアドレスの方のnonce管理は意識する必要があります。 ここで提案されているのは、コントラクトで管理しているnonceが連番でないため、トランザクションの順番を気にせず実行することができるということです。

また、ガス代についてもEIP1559の導入により、急激な高騰を防ぐ仕組みが導入されています。 EIP1559については以下の記事を参考にしてください。

ERC20のallowanceメカニズムには以下の問題点があります。

  • 複数回の引き出し攻撃に弱い

リエントランシー攻撃のことです。 複数回同じ関数を実行し続けることができてしまう脆弱性のことです。

より詳しくは以下の記事を参考にしてください。

  • トランザクションの順番が変わる

トランザクションは作成した順番通りに実行されるとは限りません。 例えば、より多くのガス代を支払うことで、先にトランザクションを通すことができてしまいます。 これにより、トランザクションを盗み見るということができてしまいます。

より詳しくは以下の記事を参考にしてください。

  • 無限allowance
    • ERC20のapprove実行時に、極端に大きい値を設定することでほぼ無限に設定したアドレスがトークンの操作権限を持ってしまう。

アップグレード可能なコントラクトの普及により、上記の攻撃が発生しやすくなっています。

なぜ発生しやすくなっているかはわからないですが、アップグレード可能なので注意力が落ちているということかなと思いました。

ERC20のallowanceの問題点を解決するために、ERC777やERC677などの代替トークン標準が提案されました。 しかし、互換性やセキュリティの問題からこれらの標準はあまり普及していません。

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

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

仕様

イベント

js
event AuthorizationUsed(
    address indexed authorizer,
    bytes32 indexed nonce
);

// keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")
bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = 0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267;

// keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")
bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = 0xd099cc98ef71107a616c4f0f941f04c322d8e254fe26b3c6668db87aae413de8;

/**
 * @notice Returns the state of an authorization
 * @dev Nonces are randomly generated 32-byte data unique to the authorizer's
 * address
 * @param authorizer    Authorizer's address
 * @param nonce         Nonce of the authorization
 * @return True if the nonce is used
 */
function authorizationState(
    address authorizer,
    bytes32 nonce
) external view returns (bool);

/**
 * @notice Execute a transfer with a signed authorization
 * @param from          Payer's address (Authorizer)
 * @param to            Payee's address
 * @param value         Amount to be transferred
 * @param validAfter    The time after which this is valid (unix time)
 * @param validBefore   The time before which this is valid (unix time)
 * @param nonce         Unique nonce
 * @param v             v of the signature
 * @param r             r of the signature
 * @param s             s of the signature
 */
function transferWithAuthorization(
    address from,
    address to,
    uint256 value,
    uint256 validAfter,
    uint256 validBefore,
    bytes32 nonce,
    uint8 v,
    bytes32 r,
    bytes32 s
) external;

/**
 * @notice Receive a transfer with a signed authorization from the payer
 * @dev This has an additional check to ensure that the payee's address matches
 * the caller of this function to prevent front-running attacks. (See security
 * considerations)
 * @param from          Payer's address (Authorizer)
 * @param to            Payee's address
 * @param value         Amount to be transferred
 * @param validAfter    The time after which this is valid (unix time)
 * @param validBefore   The time before which this is valid (unix time)
 * @param nonce         Unique nonce
 * @param v             v of the signature
 * @param r             r of the signature
 * @param s             s of the signature
 */
function receiveWithAuthorization(
    address from,
    address to,
    uint256 value,
    uint256 validAfter,
    uint256 validBefore,
    bytes32 nonce,
    uint8 v,
    bytes32 r,
    bytes32 s
) external;
AuthorizationUsed
js
event AuthorizationUsed(
    address indexed authorizer,
    bytes32 indexed nonce
);

特定の認証が使用された時に発行されるイベント。

  • authorizer
    • 認証を行ったユーザーのアドレス。
  • nonce
    • 認証に関連付けられた一意の値。
TRANSFER_WITH_AUTHORIZATION_TYPEHASH
js
bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = 0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267;

TransferWithAuthorization関数のための型ハッシュ値。 EIP712標準に基づき、構造化データの署名に使用されます。

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

`RECEIVE_WITH_AUTHORIZATION_TYPEHASH
js
bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = 0xd099cc98ef71107a616c4f0f941f04c322d8e254fe26b3c6668db87aae413de8;

ReceiveWithAuthorization関数のための型ハッシュ値。 EIP712標準に基づき、構造化データの署名に使用されます。

authorizationState
js
function authorizationState(
    address authorizer,
    bytes32 nonce
) external view returns (bool);

指定されたnonceが使用済みか確認する関数。

  • 引数:
    • authorizer
      • 認証を行うユーザーのアドレス。
    • nonce
      • 認証に関連付けられた一意の値。
  • 戻り値
    • nonceが使用済みであればtrue、そうでなければfalse。
transferWithAuthorization
js
function transferWithAuthorization(
    address from,
    address to,
    uint256 value,
    uint256 validAfter,
    uint256 validBefore,
    bytes32 nonce,
    uint8 v,
    bytes32 r,
    bytes32 s
) external;

署名された認証を使用してトークンをtransferする関数。

  • 引数
    • from
      • トークンを支払うユーザーのアドレス(認証者)。
    • to
      • トークンを受け取るユーザーのアドレス。
    • value
      • transferするトークンの量。
    • validAfter
      • この時刻以降に有効となる(UNIX時間)。
    • validBefore
      • この時刻以前に有効となる(UNIX時間)。
    • nonce
      • 一意の値。
    • v, r, s
      • 署名パーツ。
receiveWithAuthorization
js
function receiveWithAuthorization(
    address from,
    address to,
    uint256 value,
    uint256 validAfter,
    uint256 validBefore,
    bytes32 nonce,
    uint8 v,
    bytes32 r,
    bytes32 s
) external;

署名された認証を使用してトークンを受け取る関数。 セキュリティ上の理由から、トークンを受け取るアドレスがこの関数を呼び出す必要があります。

  • 引数
    • from
      • トークンを支払うユーザーのアドレス(認証者)。
    • to
      • トークンを受け取るユーザーのアドレス。
    • value
      • transferするトークンの量。
    • validAfter
      • この時刻以降に有効となる(UNIX時間)。
    • validBefore
      • この時刻以前に有効となる(UNIX時間)。
    • nonce
      • 一意の値。
    • v, r, s
      • 署名パーツ。

オプション

js
event AuthorizationCanceled(
    address indexed authorizer,
    bytes32 indexed nonce
);

// keccak256("CancelAuthorization(address authorizer,bytes32 nonce)")
bytes32 public constant CANCEL_AUTHORIZATION_TYPEHASH = 0x158b0a9edf7a828aad02f63cd515c68ef2f50ba807396f6d12842833a1597429;

/**
 * @notice Attempt to cancel an authorization
 * @param authorizer    Authorizer's address
 * @param nonce         Nonce of the authorization
 * @param v             v of the signature
 * @param r             r of the signature
 * @param s             s of the signature
 */
function cancelAuthorization(
    address authorizer,
    bytes32 nonce,
    uint8 v,
    bytes32 r,
    bytes32 s
) external;
AuthorizationCanceled
js
event AuthorizationCanceled(
    address indexed authorizer,
    bytes32 indexed nonce
);

特定の認証がキャンセルされた時に発行されるイベント。

  • authorizer
    • 認証を行ったユーザーのアドレス。
  • nonce
    • 認証に関連付けられた一意のノンス。
js
bytes32 public constant CANCEL_AUTHORIZATION_TYPEHASH = 0x158b0a9edf7a828aad02f63cd515c68ef2f50ba807396f6d12842833a1597429;

cancelAuthorization関数のための型ハッシュ値。 EIP712標準に基づき、構造化データの署名に使用されます。

cancelAuthorization
solidity
function cancelAuthorization(
    address authorizer,
    bytes32 nonce,
    uint8 v,
    bytes32 r,
    bytes32 s
) external;

署名された認証をキャンセルする関数。 これにより、まだ使用されていない認証を無効化することができます。

  • 引数:
    • authorizer
      • 認証を行うユーザーのアドレス。
    • nonce
      • 認証に関連付けられた一意のノンス。
    • v, r, s
      • 署名の部分。

EIP712による署名の取得方法

EIP712の仕様に基づいて、署名部分(v, r, s)を取得するためには以下のような手順を取ります。

  1. ドメインセパレータの生成

    • ドメインセパレータは、署名の一意性を保証するために使用されるデータで以下のように生成します。
    js
    bytes32 DomainSeparator = keccak256(abi.encode(
       keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
       keccak256("USD Coin"),       // name
       keccak256("2"),              // version
       1,                           // chainId
       0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48  // verifyingContract
    ));
  2. メッセージハッシュの生成

    • キャンセルする認証に関するデータをハッシュ化します。
    js
    bytes32 structHash = keccak256(abi.encode(
       CANCEL_AUTHORIZATION_TYPEHASH,
       authorizer,
       nonce
    ));
  3. EIP712ハッシュの生成

    • ドメインセパレータとメッセージハッシュを組み合わせて、EIP712のハッシュを生成します。
    js
    bytes32 digest = keccak256(abi.encodePacked(
       "\x19\x01",
       DomainSeparator,
       structHash
    ));
  4. 署名部分の取得

    • digestを署名することで、署名部分v, r, sを取得します。
    • これを使って認証をキャンセルします。

上記の内容を以下のように説明します。

ドメインセパレータとTypeHash

ドメインセパレータとTypeHashを使って、特定のメッセージに対するハッシュを生成してユーザーの秘密鍵で署名できます。

ドメインセパレータ

ドメインセパレータは以下のように生成されます。

js
bytes32 DomainSeparator = keccak256(abi.encode(
    keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
    keccak256("USD Coin"),       // name
    keccak256("2"),              // version
    1,                           // chainId
    0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48  // verifyingContract
));

各メッセージのTypeHashとパラメータ

以下は各メッセージのTypeHashとパラメータの例です。

TransferWithAuthorization
js
bytes32 TypeHash = keccak256(
  "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"
);
Params := { From, To, Value, ValidAfter, ValidBefore, Nonce };
ReceiveWithAuthorization
js
bytes32 TypeHash = keccak256(
  "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"
);
Params := { From, To, Value, ValidAfter, ValidBefore, Nonce };
CancelAuthorization
js
bytes32 TypeHash = keccak256(
  "CancelAuthorization(address authorizer,bytes32 nonce)"
);
Params := { Authorizer, Nonce };

ダイジェストの生成

各メッセージに対して、ダイジェストを生成します。

js
bytes32 Digest = keccak256(
  abi.encodePacked(
    "\x19\x01",
    DomainSeparator,
    keccak256(abi.encode(TypeHash, Params...))
  )
);

このダイジェストは、トークンホルダーの秘密鍵で署名されます。

js
{ v, r, s } := Sign(Digest, PrivateKey);

receiveWithAuthorizationの簡略化

スマートコントラクトの関数は、receiveWithAuthorizationコールの全ての引数をバイト型の1つの引数として受け取ることで、引数の数を減らすことができます。

例
js
bytes4 private constant _RECEIVE_WITH_AUTHORIZATION_SELECTOR = 0xef55bec6;

function deposit(address token, bytes calldata receiveAuthorization)
    external
    nonReentrant
{
    (address from, address to, uint256 amount) = abi.decode(
        receiveAuthorization[0:96],
        (address, address, uint256)
    );
    require(to == address(this), "Recipient is not this contract");

    (bool success, ) = token.call(
        abi.encodePacked(
            _RECEIVE_WITH_AUTHORIZATION_SELECTOR,
            receiveAuthorization
        )
    );
    require(success, "Failed to transfer tokens");

    // additional logic...
}
  • _RECEIVE_WITH_AUTHORIZATION_SELECTORは、receiveWithAuthorization関数の最初の4バイトのセレクタです。
  • deposit関数は、receiveAuthorizationのバイトデータをデコードし、from, to, amountを抽出します。
  • token.callは、receiveWithAuthorization関数を呼び出すために、エンコードされたデータを使用します。

Web3プロバイダーでの使用

approveの署名はWeb3プロバイダーのeth_signTypedData{_v4}を使用して取得できます。

例

js
const data = {
  types: {
    EIP712Domain: [
      { name: "name", type: "string" },
      { name: "version", type: "string" },
      { name: "chainId", type: "uint256" },
      { name: "verifyingContract", type: "address" },
    ],
    TransferWithAuthorization: [
      { name: "from", type: "address" },
      { name: "to", type: "address" },
      { name: "value", type: "uint256" },
      { name: "validAfter", type: "uint256" },
      { name: "validBefore", type: "uint256" },
      { name: "nonce", type: "bytes32" },
    ],
  },
  domain: {
    name: tokenName,
    version: tokenVersion,
    chainId: selectedChainId,
    verifyingContract: tokenAddress,
  },
  primaryType: "TransferWithAuthorization",
  message: {
    from: userAddress,
    to: recipientAddress,
    value: amountBN.toString(10),
    validAfter: 0,
    validBefore: Math.floor(Date.now() / 1000) + 3600, // Valid for an hour
    nonce: Web3.utils.randomHex(32),
  },
};

const signature = await ethereum.request({
  method: "eth_signTypedData_v4",
  params: [userAddress, JSON.stringify(data)],
});

const v = "0x" + signature.slice(130, 132);
const r = signature.slice(0, 66);
const s = "0x" + signature.slice(66, 130);

補足

ユニークなランダムnonceの重要性

順序付きノンスの問題点

順序付きnonceは、トランザクションの順序付けに良いとされますが、メタトランザクションにおいては以下の問題点があります。

  • 高すぎるノンスの問題

    • イーサリアムのネイティブトランザクションでは、高すぎるnonceを持つトランザクションは、低い未使用ノンスのトランザクションが実行されるまで保留されます。
    • しかし、メタトランザクションでは、高すぎるnonceを持つトランザクションは即座に失敗してガス代が無駄になります。
  • マイナーのトランザクション再順序付け

    • マイナーはトランザクションを任意の順序でブロックに含めることができるため、正しいnonceを使用してもメタトランザクションが失敗する可能性があります。
    • 例えば、ユーザーがnonceを3、4、5のトランザクションを送信しても、マイナーが4、5、3の順で含めた場合、nonceが3のトランザクションのみが成功します。
  • アプリケーション間でのnonce管理

    • 異なるアプリケーションを同時に使用する場合、オフチェーンのnonceトラッカーがない限り次のnonceを正しく決定することが難しいです。
    • ガス価格が高いと、トランザクションがプール内で長時間処理されないことがあり、その間に同じnonceが意図せず再利用される可能性があります。

これらの理由から、トランザクションの順序付けを保証する唯一の方法は、リレイヤーが各トランザクションの確認を待ってから次のトランザクションを順次送信することであり、順序付きnonceの利点は薄れます。

有効期間(Valid After and Valid Before)

リレイヤーにトランザクションの送信を依頼する場合、トランザクション送信のタイミングを正確に制御することは難しいです。 validAfterとvalidBeforeのパラメータを使用することで、トランザクションが未来の特定の時刻以降、または特定の期限前のみ有効となるようにスケジュールできます。 これにより、トランザクションの実行タイミングをコントトールできます。

EIP712の重要性

EIP712は、署名が特定のトークンコントラクトのインスタンスに対してのみ有効であり、異なるネットワーク(異なるチェーンID)で再利用されることを防ぎます。 これを実現するために、コントラクトアドレスとチェーンIDを含むKeccak-256ハッシュダイジェスト(ドメインセパレータ)を生成します。 ドメインセパレータを生成する時には、verifyingContractとchainIdのフィールドを含めることが推奨されます。

互換性

新しく作成されるコントラクトは、アトミックトランザクションを作成するために、EIP3009を直接利用できるメリットがあり、既存コントラクトは従来のapprove + transferFromパターンを使用することができます。

EIP3009を既存のERC20に追加するには、フォワーダーコントラクトを構築する必要があります。

  1. approveからユーザーと入金額を抽出。
  2. receiveWithAuthorizationを呼び出して、指定された資金をユーザーからフォワーダーコントラクトにtransfer。
  3. 親コントラクトがフォワーダーからトークンを操作できるようにallowanceを設定。
  4. 親コントラクト上のメソッドを呼び出して、フォワーダーから設定されたallowance分のトークンを使用。
  5. 得られたトークンの所有権をユーザーに戻す。

コントラクト例

IDeFiToken インターフェース
js
interface IDeFiToken {
    function deposit(uint256 amount) external returns (uint256);
    function transfer(address account, uint256 amount) external returns (bool);
}
  • deposit
    • 指定された量のトークンを入金し、新しく発行されたトークンの数を返す関数。
  • transfer
    • 指定されたアカウントにトークンを送付する関数。
DepositForwarder コントラクト
js
contract DepositForwarder {
    bytes4 private constant _RECEIVE_WITH_AUTHORIZATION_SELECTOR = 0xef55bec6;

    IDeFiToken private _parent;
    IERC20 private _token;

    constructor(IDeFiToken parent, IERC20 token) public {
        _parent = parent;
        _token = token;
    }

    function deposit(bytes calldata receiveAuthorization)
        external
        nonReentrant
        returns (uint256)
    {
        (address from, address to, uint256 amount) = abi.decode(
            receiveAuthorization[0:96],
            (address, address, uint256)
        );
        require(to == address(this), "Recipient is not this contract");

        (bool success, ) = address(_token).call(
            abi.encodePacked(
                _RECEIVE_WITH_AUTHORIZATION_SELECTOR,
                receiveAuthorization
            )
        );
        require(success, "Failed to transfer to the forwarder");

        require(
            _token.approve(address(_parent), amount),
            "Failed to set the allowance"
        );

        uint256 tokensMinted = _parent.deposit(amount);
        require(
            _parent.transfer(from, tokensMinted),
            "Failed to transfer the minted tokens"
        );

        uint256 remainder = _token.balanceOf(address(this));
        if (remainder > 0) {
            require(
                _token.transfer(from, remainder),
                "Failed to refund the remainder"
            );
        }

        return tokensMinted;
    }
}
  • コンストラクタ

    • DepositForwarderコントラクトは、IDeFiToken(親コントラクト)とIERC20(トークンコントラクト)のアドレスを受け取ります。
    • これにより、フォワーダーが親コントラクトのメソッドを呼び出し、トークンのtransferを管理できるようになります。
  • deposit 関数

    • 認証データのデコード
      • receiveAuthorizationからfrom(送信者)、to(受信者)、amount(金額)をデコードします。
    • トークンのtransfer
      • receiveWithAuthorizationを呼び出して、指定されたトークンをユーザーからフォワーダーコントラクトにtransferします。
    • allowanceの設定
      • フォワーダーコントラクトが親コントラクトに対してトークンを使用できるようにallowanceを設定します。
    • 親コントラクトへの入金
      • 親コントラクトのdeposit関数を呼び出して、トークンを入金して新しく発行されたトークンを受け取ります。
    • 発行されたトークンのtransfer
      • 新しく発行されたトークンを送信者にtransferします。
    • 残高の返金
      • フォワーダーに残っているトークンがあれば、それを送信者に送付します。

この方法により、既存のERC20allowanceパターンを使用するコントラクトに対しても、EIP3009の機能を追加することができます。

テスト

以下にテストコードが格納されています。

実装

EIP3009.sol

js
abstract contract EIP3009 is IERC20Transfer, EIP712Domain {
    // keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")
    bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = 0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267;

    // keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")
    bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = 0xd099cc98ef71107a616c4f0f941f04c322d8e254fe26b3c6668db87aae413de8;

    mapping(address => mapping(bytes32 => bool)) internal _authorizationStates;

    event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce);

    string internal constant _INVALID_SIGNATURE_ERROR = "EIP3009: invalid signature";

    function authorizationState(address authorizer, bytes32 nonce)
        external
        view
        returns (bool)
    {
        return _authorizationStates[authorizer][nonce];
    }

    function transferWithAuthorization(
        address from,
        address to,
        uint256 value,
        uint256 validAfter,
        uint256 validBefore,
        bytes32 nonce,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external {
        require(now > validAfter, "EIP3009: authorization is not yet valid");
        require(now < validBefore, "EIP3009: authorization is expired");
        require(
            !_authorizationStates[from][nonce],
            "EIP3009: authorization is used"
        );

        bytes memory data = abi.encode(
            TRANSFER_WITH_AUTHORIZATION_TYPEHASH,
            from,
            to,
            value,
            validAfter,
            validBefore,
            nonce
        );
        require(
            EIP712.recover(DOMAIN_SEPARATOR, v, r, s, data) == from,
            "EIP3009: invalid signature"
        );

        _authorizationStates[from][nonce] = true;
        emit AuthorizationUsed(from, nonce);

        _transfer(from, to, value);
    }
}

IERC20Transfer.sol

js
abstract contract IERC20Transfer {
    function _transfer(
        address sender,
        address recipient,
        uint256 amount
    ) internal virtual;
}

EIP712Domain.sol

js
abstract contract EIP712Domain {
    bytes32 public DOMAIN_SEPARATOR;
}

EIP712.sol

js
library EIP712 {
    // keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
    bytes32 public constant EIP712_DOMAIN_TYPEHASH = 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f;

    function makeDomainSeparator(string memory name, string memory version)
        internal
        view
        returns (bytes32)
    {
        uint256 chainId;
        assembly {
            chainId := chainid()
        }

        return
            keccak256(
                abi.encode(
                    EIP712_DOMAIN_TYPEHASH,
                    keccak256(bytes(name)),
                    keccak256(bytes(version)),
                    address(this),
                    bytes32(chainId)
                )
            );
    }

    function recover(
        bytes32 domainSeparator,
        uint8 v,
        bytes32 r,
        bytes32 s,
        bytes memory typeHashAndData
    ) internal pure returns (address) {
        bytes32 digest = keccak256(
            abi.encodePacked(
                "\x19\x01",
                domainSeparator,
                keccak256(typeHashAndData)
            )
        );
        address recovered = ecrecover(digest, v, r, s);
        require(recovered != address(0), "EIP712: invalid signature");
        return recovered;
    }
}

EIP3009を完全に実装するには、以下のリポジトリ内のコードとEIP2612のコード、上記のEIP712のコードを実装する必要があります。

セキュリティ

以下は、提供された内容についての説明です。

セキュリティ考慮事項

receiveWithAuthorizationの使用推奨

他のコントラクトから呼び出す場合は、以下の理由からtransferWithAuthorizationではなくreceiveWithAuthorizationを使用することが推奨されます。

  • フロントランニング攻撃のリスク
    • トランザクションプールを監視する攻撃者が、transferWithAuthorizationのapproveを抽出し、トランザクションをフロントランして、ラッパー関数を呼び出さずにtransferを実行する可能性があります。
    • これにより、未処理のままトークンがロックされてしまいます。
    • receiveWithAuthorizationは、追加のチェックを行い、呼び出し元が受け取りアドレスであることを確認することでこの問題を防ぎます。
nonceのリーダーバイト

複数のコントラクトの関数がapproveを受け取る場合、他の関数で同じapproveが使用されないようにする必要があります。 そこで、nonceの先頭バイトを特定の識別子として使用し間違った実行をしないようにします。

複数のtransferを同時に送信する場合の注意点

リレイヤーやマイナーがトランザクションの処理順序を決定します。 これは、トランザクションが相互に依存していない場合には問題ないです。 しかし、相互に依存するトランザクションの場合、署名されたapproveを一度に1つずつ送信することが推奨されます。

ecrecoverの使用におけるゼロアドレスの拒否

ecrecoverを使用する時、ゼロアドレスを拒否する必要があります。 これは、不正なtransferやゼロアドレスからのトークンのapproveを防ぐためです。 組み込みのecrecoverは、不正な署名が提供された場合にゼロアドレスを返すためチェックが重要です。

引用

Peter Jihoon Kim (@petejkim), Kevin Britz (@kbrizzle), David Knott (@DavidLKnott), "ERC-3009: Transfer With Authorization [DRAFT]," Ethereum Improvement Proposals, no. 3009, September 2020. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-3009.

最後に

今回は「ERC2612で提案されているにapprove + transferFromのメタトランザクションを、より安全に実行する仕組みを提案しているERC3009」についてまとめてきました! いかがだったでしょうか?

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

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