はじめに

『DApps開発入門』という本や色々記事を書いているかるでねです。

今回は、安全にERC20トークンをメタトランザクション(approve + transferFrom)で送付する仕組みを提案しているERC7758についてまとめていきます!

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

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

概要

ERC7758では、EIP712を活用してERC20トークンのメタトランザクションを可能にする仕組みを提案しています。 この仕組みを活用することで以下を実現できます。

  • ガス代を他者に負担してもらう。
  • ETHの代わりにERC20トークンでガス代を支払う。
  • 複数のトークン送付や処理を1回のトランザクションでまとめて実行する。
  • トークンを送る時に受信者がトランザクションを送信する仕組みの実装。
  • 複数のトランザクションを効率的に処理して計算効率の向上や手数料の負担軽減。
  • トランザクションの失敗を防ぐためにnonce管理を改善。

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

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

動機

メタトランザクションとは?

通常、EVM互換のブロックチェーンでトランザクションを実行するには、送信者(トランザクションを実行したい人)がガス代をETHで支払う必要があります しかし、メタトランザクションを使うことで、送信者が直接トランザクションを送るのではなく、別の誰か(リレーサービスや運営のEOAアドレスなど)がトランザクションをブロックチェーンに送信し、ガス代を支払うことが可能になります。

具体例

AliceがBobにERC20トークンを送りたいとします。 しかし、AliceはETHを持っていないためトランザクションを送ることができない状態です。 そこで、Aliceは「署名付きメッセージ」(EIP712に準拠)を作成してBob に送ります。 Bobがそのメッセージを使ってブロックチェーン上でトランザクションを送信し、Aliceの代わりにガス代を支払います。

この方法を利用すると、ユーザーがETHを持っていなくても別のアドレスがガス代を負担しつつトランザクションを実行して、ERC20トークンのやりとりが可能になります。

ERC2612との違い

ERC2612もメタトランザクションを可能にする仕組みですが、今回の提案とは以下の点で異なります。

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

Nonceの管理

ERC2612では 連番のnonceを使用しますが、本提案ではランダムな32バイトのnonceを使用します。

EOAアドレスでトランザクションを作成するとき、連番されるユニークな値がトランザクション内に含まれます。 この値をnonceといい、同じトランザクションが複数回実行されないようにしています。

ERC2612では トランザクションが順番に処理されることが前提となっています。 もし未処理のトランザクションがあれば、それが処理されるまで次のトランザクションが実行できません。 また、DAppsが意図せず同じnonceを再利用してしまったり、トランザクションの処理順序が原因で意図しない順番でトランザクションが処理される可能性があります。 この問題により、ガス価格が高騰しトランザクションが詰まりやすい環境では、ユーザーが複数のトランザクションを同時に処理しようとすると失敗しやすくなるという課題があります。

ERC7758により、ランダムなnonceを使用することで、複数のトランザクションを同時に発行しても競合が発生しなくなります。 また、並列処理が可能となり、送信者が複数のトランザクションを安全に送れます。

承認(Approve)と送金(TransferFrom)の仕組み

ERC2612は、ERC20の「approve → transferFrom」パターンを利用しますが、本提案ではそのパターンを使用しません。 理由としては、ERC20の「approve → transferFrom」パターンにはセキュリティ上の問題があるためです。

approve + transferFromパターンの問題点

Multiple Withdrawal Attack

ERC20のapproveを使ってトークンの送金許可を実行すると、悪意のあるアカウントがその許可を利用して何度も送金できる可能性があります。 例えば、DAppがapproveを実行した直後に攻撃者がtransferFromを複数回実行すれば、ユーザーが意図しないトークンの流出が起きてしまいます。

Infinite Allowance

多くのDAppsは、利便性を考えて「Infinite Allowance」(approveを一度発行すれば無制限にトークンを使用できる)を推奨しています。 しかし、もしDAppやスマートコントラクトがハッキングされた場合、攻撃者は無限にトークンを盗むことが可能になります。 これらの問題を回避するため、本提案では「approve → transferFrom」の仕組みを利用しないことを推奨しています。

ERC777はなぜ普及しなかったのか?

ERC777は、ERC20のapproveの問題を解決するために登場した新しいトークン標準ですが普及しませんでした。 理由としては以下があります。

ERC20との互換性の問題

既存のDAppsやウォレットがERC777に対応していませんでした。 ERC777には「Hooks」という新しい概念があり、古いコントラクトがそれに対応できませんでした。

セキュリティの懸念

Hooksの仕組みを悪用すれば、特定のトランザクションを妨害したり攻撃者が意図しない処理をさせる可能性があります。 特に「Reentrancy Attack」のリスクが高まります。 そのため、ERC777は理論的には優れたトークン標準であるものの、実際の導入は進まずERC20の代替として広く使用されませんでした。

仕様

インターフェース

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;

AuthorizationUsed

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

概要
**EIP712準拠の署名を使用してトークン転送が実行された時に発行されるイベント。

詳細
このイベントは、transferWithAuthorization関数やreceiveWithAuthorization関数を通じてトークンが送付されたときに発行され、トランザクションの署名が使用されたことを示します。 これにより、同じ署名の再利用を防ぐことができます。

パラメータ

  • authorizer
    • 署名を行ったアドレス(トークンの送信者)。
  • nonce
    • 署名に関連付けられた一意の識別子(ランダムな32バイトの値)。

TRANSFER_WITH_AUTHORIZATION_TYPEHASH

js
bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH = 0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267;

概要
EIP712に準拠したtransferWithAuthorization関数のデータ型(型ハッシュ)を表す定数。

詳細
EIP712では、メッセージの構造を明確に定義してハッシュを用いて署名を行います。 この変数は transferWithAuthorization関数のデータ構造をkeccak256によってハッシュ化したもので、署名の検証時にトランザクションの整合性をチェックするために使用されます。


RECEIVE_WITH_AUTHORIZATION_TYPEHASH

js
bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = 0xd099cc98ef71107a616c4f0f941f04c322d8e254fe26b3c6668db87aae413de8;

概要
EIP712に準拠したreceiveWithAuthorization関数のデータ型(型ハッシュ)を表す定数。

詳細
EIP712では、メッセージの型を定義してそのハッシュを使用して署名を行います。 この変数はreceiveWithAuthorization関数のデータ構造をkeccak256によってハッシュ化したもので、署名の検証時にreceiveWithAuthorization関数のリクエストが正当なものかチェックするために使用されます。


authorizationState

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

概要
指定されたnonceがすでに使用されたかどうかを確認する関数。

詳細
この関数は、transferWithAuthorization関数やreceiveWithAuthorization関数を実行する時に使用されたnonceが、すでに使用されたかどうかをチェックします。
一度使用されたnonceは再利用できないため、リプレイ攻撃を防ぐ役割を果たします。

引数

  • authorizer
    • 署名を行ったアドレス(トークンの送信者)。
  • nonce
    • 一意の識別子(ランダムな32バイトの値)。

戻り値

  • bool
    • trueの場合、nonceはすでに使用済み。
    • falseの場合nonceは未使用であり、トランザクション実行が可能。

transferWithAuthorization

js
function transferWithAuthorization(
    address from,
    address to,
    uint256 value,
    uint256 validAfter,
    uint256 validBefore,
    bytes32 nonce,
    uint8 v,
    bytes32 r,
    bytes32 s
) external;

概要
EIP712署名を利用してトークンを転送する関数。

詳細
送金者(from)が事前にオフチェーンで署名したデータを、受取アドレス(to)または第三者がブロックチェーンに送信することでトークンを送付できる。
ETHを持っていなくてもトークン送金が可能になり、ガス代を他者に負担してもらうことができます。
また、nonceを用いることで署名の再利用を防ぎます。

引数

  • from
    • 送金者(署名を行ったトークンの所有者)。
  • to
    • 受取アドレス(トークンを受け取るアドレス)。
  • value
    • 送付するトークンの金額。
  • validAfter
    • この署名が有効になる開始時間(UNIXタイム)。
  • validBefore
    • この署名が無効になる時間(UNIXタイム)。
  • nonce
    • 一意の識別子(ランダムな32バイトの値)。
  • v
    • 署名のvコンポーネント(EIP712の署名要素)。
  • r
    • 署名のrコンポーネント(EIP712の署名要素)。
  • s
    • 署名のsコンポーネント(EIP712の署名要素)。

receiveWithAuthorization

js
function receiveWithAuthorization(
    address from,
    address to,
    uint256 value,
    uint256 validAfter,
    uint256 validBefore,
    bytes32 nonce,
    uint8 v,
    bytes32 r,
    bytes32 s
) external;

概要
EIP712署名を利用してトークンを送付するが、受取アドレス自身がトランザクションを実行することを保証する関数。

詳細
transferWithAuthorization関数とは異なり、この関数では受取アドレス(to)が msg.senderと一致している必要があります。
この仕様により、第三者がフロントランニング攻撃(取引を先回りして処理する攻撃)を防ぐことができます。
また、他の関数と同様にnonceを利用することで、署名の再利用を防ぎます。

引数

  • from
    • 送金者(署名を行ったトークンの所有者)。
  • to
    • 受取アドレス(トークンを受け取るアドレス)。
  • value
    • 送付するトークンの金額。
  • validAfter
    • この署名が有効になる開始時間(UNIXタイム)。
  • validBefore
    • この署名が無効になる時間(UNIXタイム)。
  • nonce
    • 一意の識別子(ランダムな32バイトの値)。
  • v
    • 署名のvコンポーネント(EIP712の署名要素)。
  • r
    • 署名のrコンポーネント(EIP712の署名要素)。
  • s
    • 署名のsコンポーネント(EIP712の署名要素)。

オプション機能

AuthorizationCanceled

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

概要
cancelAuthorizationによって署名(EIP712に準拠)が取り消されたときに発行されるイベント。

詳細
このイベントは、未使用の署名を取り消した時に発行され、nonceがキャンセルされたことを記録します。
これにより、不要になった署名が悪用されるのを防ぐことができます。

引数

  • authorizer
    • 署名を行ったアドレス(トークンの所有者)。
  • nonce
    • 一意の識別子(キャンセルされたランダムな32バイトの値)。

CANCEL_AUTHORIZATION_TYPEHASH

js
bytes32 public constant CANCEL_AUTHORIZATION_TYPEHASH = 0x158b0a9edf7a828aad02f63cd515c68ef2f50ba807396f6d12842833a1597429;

概要

EIP712に準拠したcancelAuthorization関数のデータ型を表す定数。

詳細
EIP712では、メッセージの型を定義してそのハッシュを使用して署名を行います。
この変数はcancelAuthorization関数のデータ構造をkeccak256によってハッシュ化したもので、署名の検証時にcancelAuthorization関数のリクエストが正当なものかチェックするために使用されます。


cancelAuthorization

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

概要

**未使用の署名をキャンセル(無効化)する関数。

詳細
トランザクションが実行される前であれば、署名を取り消すことが可能です。
これにより、万が一署名が漏洩した場合でも不正利用を防ぐことができます。
注意点として、nonceをキャンセルするとそのnonceは今後使用できなくなります。

引数

  • authorizer
    • 署名を行ったアドレス(トークンの所有者)。
  • nonce
    • キャンセルする一意の識別子(ランダムな32バイトの値)。
  • v
    • 署名のvコンポーネント(EIP712の署名要素)。
  • r
    • 署名のrコンポーネント(EIP712の署名要素)。
  • s
    • 署名のsコンポーネント(EIP712の署名要素)。

具体例

ドメインセパレーター(DomainSeparator)の生成
js
DomainSeparator := Keccak256(ABIEncode(
  Keccak256(
    "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
  ),
  Keccak256("USD Coin"),                      // name
  Keccak256("2"),                             // version
  1,                                          // chainId
  0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48  // verifyingContract
))

EIP712の署名では、署名の一意性を保証するために「EIP712Domain」という構造体(DomainSeparator)を定義します。 DomainSeparatorは、対象となるコントラクトやトークンごとに異なるハッシュ値を生成し、異なるコントラクトでの署名の混同や異なるブロックチェーンでのリプレイ攻撃を防ぎます。

  • name(例: "USD Coin")
    • トークンの名前。
  • version(例: "2")
    • スマートコントラクトのバージョン。
  • chainId(例: 1)
    • トランザクションを実行するブロックチェーンのチェーンID(Ethereum メインネット = 1)。
  • verifyingContract(例: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48)
    • トランザクションを処理する コントラクトのアドレス。

型ハッシュ(TypeHash)の計算
js
// Transfer With Authorization
TypeHash := Keccak256(
  "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"
)
Params := { From, To, Value, ValidAfter, ValidBefore, Nonce }

// ReceiveWithAuthorization
TypeHash := Keccak256(
  "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"
)
Params := { From, To, Value, ValidAfter, ValidBefore, Nonce }

// CancelAuthorization
TypeHash := Keccak256(
  "CancelAuthorization(address authorizer,bytes32 nonce)"
)
Params := { Authorizer, Nonce }

トランザクションの種類ごとに型ハッシュ(TypeHash)を作成し、どの種類のトランザクションかを識別します。
これは EIP712の型付きメッセージ(Typed Message)を処理するための重要な仕組み**。

型ハッシュ(TypeHash)とは?

各関数(TransferWithAuthorization、ReceiveWithAuthorization、CancelAuthorization)ごとに固定のデータ型を定義し、そのハッシュ値を事前に計算します。
これを利用することで、署名の対象となるデータの種類を明確に区別できるようになります。


トランザクションデータのハッシュ化と署名
js
// "‖" denotes concatenation.
Digest := Keccak256(
  0x1901 ‖ DomainSeparator ‖ Keccak256(ABIEncode(TypeHash, Params...))
)

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

以下のトランザクションデータをkeccak256でハッシュ化し、そのハッシュをユーザーの秘密鍵で署名します。

  • 0x1901(固定プレフィックス)
  • DomainSeparator(ドメインセパレーター)
  • Keccak256(ABIEncode(TypeHash, Params...))(型ハッシュとデータのエンコード)

最終的な署名結果としては取得できる、v, r, sの署名データをコントラクトで検証することで、オフチェーン署名が改ざんされていないことを保証できます。


receiveWithAuthorizationをラップするコントラクト
js
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");

    ...
}

receiveWithAuthorization関数をラップし、簡潔に署名済みトランザクションを処理する関数。

  1. receiveAuthorization関数のデータをデコードし、from、to、amountを取得します。
  2. to(受取アドレス)がコントラクト自身かどうかチェック。
  3. receiveWithAuthorization関数を実行してトークン送付。
  4. 成功しなければエラーを返す。

このように、署名済みデータを1つのbytes型として渡してトランザクションを簡潔に実行します。

Web3プロバイダー

EIP712の署名をWeb3プロバイダー(Metamaskなど)を利用して取得するサンプルコードも記載されています。


EIP712メッセージ構造の定義

javascript
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, // 有効期限を1時間後に設定
    nonce: Web3.utils.randomHex(32),
  },
};

このコードでは、EIP712の型付きメッセージ(Typed Message)を定義し、トランザクションの署名データを作成しています。

詳細

  • EIP712のメッセージ構造を定義

    • types
      • 型情報の定義
    • domain
      • 署名の一意性を保証するドメイン情報
    • primaryType
      • 今回のメッセージの主な型
    • message
      • 署名するデータ本体
  • ドメイン情報(domain)

    • name
      • トークンの名前(例: "USD Coin")
    • version
      • トークンのバージョン(例: "2")
    • chainId
      • 対象のブロックチェーンID(例: 1 = Ethereum メインネット)
    • verifyingContract
      • 処理を行うコントラクトアドレス
  • メッセージデータ(message)

    • from
      • 送金者のアドレス
    • to
      • 受取アドレス
    • value
      • 送金するトークンの量(BigNumber形式から文字列に変換)
    • validAfter
      • この署名が有効になる開始時間(0は即時有効)
    • validBefore
      • 有効期限(現在の時刻から+1時間)
    • nonce
      • 32バイトのランダムな値(署名の再利用を防ぐ)

eth_signTypedData_v4を使って署名を取得

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

概要

eth_signTypedData_v4を使用して、EIP712形式の署名を取得しています。

eth_signTypedData_v4とは、EthereumでEIP712署名を取得するためのメソッドです。 MetamaskなどのWeb3プロバイダーを介して呼び出してユーザーがウォレットで署名を承認すると、署名データを取得できます。

引数

  • userAddress
    • 署名を行うユーザーのアドレス。
  • JSON.stringify(data)
    • 署名するEIP712メッセージデータ。

戻り値

  • signature
    • v, r, s を含む署名データ(0x で始まる 130 文字の16進数)

取得した署名をv, r, sに分割

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

eth_signTypedData_v4で取得した署名データをコントラクトで利用できる v, r, sに分割しています。

Ethereum の署名(65バイト = 130文字の16進数)は以下のフォーマットになっています。

領域 バイト数 取得方法
r 32バイト signature.slice(0, 66)
s 32バイト signature.slice(66, 130)
v 1バイト signature.slice(130, 132)
  • r
    • signatureの最初の32バイト0〜65文字)
  • s
    • signatureの次の32バイト(66〜129文字)
  • v
    • signatureの最後の1バイト130〜131文字)
    • vは0x1b(27)または0x1c(28)の値を持つ。

署名をコントラクトで利用

この署名(v, r, s)は、コントラクトのtransferWithAuthorization関数やreceiveWithAuthorization関数の引数として使用します。

補足

連番のnonceの問題点とランダムnonceの採用

通常、Ethereum のトランザクションには連番のnonceが使われますが、メタトランザクションではこれが機能せず、ランダムnonceを採用する必要があります。

例えば、nonce = 3のトランザクションが処理される前にnonce = 4のトランザクションを送ると、nonce = 4は保留 (pending) となり、nonce = 3が確定するまで実行されません。

しかし、メタトランザクションではこの仕組みが機能しません。 メタトランザクションでは、ユーザー自身ではなくリレイヤー(第三者)がトランザクションを送信するため、異なるリレイヤー間で順序が保証されません。

これにより以下の問題点があります。

順番通りに処理されない

例えば、ユーザーがnonce = 3, 4, 5のトランザクションを発行したとしても、リレイヤーの送信順やトランザクションがブロックに含まれる順番によって「4 → 5 → 3」のように順番がバラバラになります。 この結果、3は有効なnonceなのに処理されないということがおきます。

  • 高すぎるnonceのトランザクションは即座に失敗

通常のEthereumトランザクションでnonceが高すぎる場合は「保留状態」になりますが、メタトランザクションでは即座に失敗してガス代が無駄になります。

異なるアプリケーションでの競合

複数のアプリを使う場合、アプリごとにnonceの管理方法が異なるため、未承認のノンスが発生して重複する可能性があります。

高ガス価格環境での問題

ガス価格が高騰してトランザクションが詰まると、メタトランザクションが長時間保留され、同じnonceが別のトランザクションで誤って再利用される可能性があります。

解決策

この解決策として、ランダムnonceを採用する方法があります。

nonceをランダムな32バイトの値にすることで、トランザクションの順序の影響を受けなくします。 これにより、リレイヤーが異なる順番で処理しても問題がなく、ガスの無駄やトランザクションの失敗を防ぎます。

validAfterとvalidBeforeの必要性

メタトランザクションでは、ユーザーが直接トランザクションを送信するのではなく、リレイヤーが送信するため送信タイミングを制御できません。 そのため、以下の問題点を防ぐために「いつからいつまで有効か」を指定するvalidAfterとvalidBeforeが必要です。

意図しないタイミングでの送信

例えば、「給料日(30日)になったらトークンを送信する」つもりで署名を作成しても、リレイヤーが29日に送信してしまう可能性があります。

意図しない遅すぎる送信

例えば、「30分以内に送金しないと取引が無効になる」ような場合、リレイヤーが 31分後に送信すると不要な取引が発生してしまいます。

解決策

トランザクションが有効になる「開始時間」であるvalidAfterとトランザクションが無効になる「終了時間」であるvalidBeforeを定義します。

EIP712による署名の安全性の確保

EIP712は、Ethereumの署名データにコントラクトのアドレスやチェーンIDを含めることで、署名の安全性を向上させる技術です。

Ethereum の基本的なeth_sign署名はどのコントラクトやどのネットワークでも再利用できるため、リプレイ攻撃のリスクがあります。

EIP712では以下の対策をしています。

Domain Separatorを使う

verifyingContractとchainIdを含めることで、特定のコントラクト・チェーン以外では署名が無効になります。

JSON構造化データを利用

eth_signTypedData_v4を使って、ユーザーが「何に署名しているのか」を確認できます。

互換性

既存のERC20トークンはapprove + transferFromの仕組みに依存しているため、EIP712メタトランザクションに直接対応できていません。 そのため、既存のコントラクトと互換性を持たせるための「Forwarder Contract」が必要です

DepositForwarderというフォワードコントラクトを導入することで、EIP712メタトランザクションを利用して既存のERC20コントラクトと連携できるようになります。

DepositForwarderは、メタトランザクションを使ってトークンを預け入れできるようにするラッパーコントラクトです。

処理の流れ

  1. 署名付きのreceiveAuthorizationデータを受け取る。
  2. receiveWithAuthorizationを実行して、送信者(from)からフォワードコントラクトへトークンを送付する。
  3. 送付されたトークンをapproveして、親コントラクトに引き渡す。
  4. 親コントラクトのdepositメソッドを呼び出し、トークンを預け入れ(ステーキングなど)を行う。
  5. 親コントラクトでミントされたトークンを元の送信者(from)に返す。
  6. 万が一、余剰トークンが残っていれば元の送信者(from)に返金。

参考実装

EIP7758.sol
abstract contract EIP7758 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 = "EIP7758: 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, "EIP7758: authorization is not yet valid");
        require(now < validBefore, "EIP7758: authorization is expired");
        require(
            !_authorizationStates[from][nonce],
            "EIP7758: 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,
            "EIP7758: invalid signature"
        );

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

        _transfer(from, to, value);
    }
}
IERC20Transfer.sol
abstract contract IERC20Transfer {
    function _transfer(
        address sender,
        address recipient,
        uint256 amount
    ) internal virtual;
}
EIP712Domain.sol
abstract contract EIP712Domain {
    bytes32 public DOMAIN_SEPARATOR;
}
EIP712.sol
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)),
                    bytes32(chainId),
                    address(this)
                )
            );
    }

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

セキュリティ

transferWithAuthorizationではなくreceiveWithAuthorization を使用する理由

フロントランニング攻撃を防ぐため。

フロントランニングとは

誰かがトランザクション(トランザクションA)を送信する前に、別の誰か(攻撃者)がそれを事前に察知して別のトランザクション(トランザクションB)を先に処理する攻撃。 Ethereumでは、トランザクションは「Mempool」と呼ばれる待機領域に一時的に保管されます。 攻撃者はこのメモリプールを監視し、特定のトランザクション(例:transferWithAuthorization)を自分が先に実行することで、意図しない結果を引き起こすことが可能になります。

攻撃シナリオ

  1. AliceがtransferWithAuthorization関数を実行する署名付きデータを作成し、リレイヤーを介して実行しようとする。
  2. 攻撃者がメモリプールを監視し、AliceのtransferWithAuthorizationのデータを取得。
  3. 攻撃者は、自分のアカウントをtoに変更し、Aliceの代わりにtransferWithAuthorizationを先に実行。
  4. その結果、Aliceの意図したラッパー関数(フォワードコントラクト)が実行されず、資金が適切に処理されない可能性がある。

解決策

receiveWithAuthorization関数を使用する。 receiveWithAuthorizationは、送信者 (from)ではなく、受取アドレス(to) がトランザクションを実行する必要があるため、攻撃者が勝手にトランザクションを横取りできません。 msg.senderがtoと一致しているかどうかをチェックすることでフロントランニングを防ぎます。

追加の対策

  • nonceの先頭バイトを識別子として使用する。
    • 例:用途ごとに異なるnonceプレフィックスを定義。
  • これにより、異なる目的のreceiveWithAuthorization関数の誤用を防ぐ。

複数のトランザクションを同時に送信する時の注意点

通常のEthereumトランザクションでは、nonceによって実行順序が決まるが、メタトランザクションではnonceがランダムなので、順序を保証できません。 また、異なるリレイヤーが異なる順序で送信する可能性があるため、依存関係のあるトランザクションを同時に送ると失敗するリスクがあります。

解決策

依存関係のあるトランザクションは1つずつ送信する

例えば、「A → B → C」という順序が重要な場合、Aが確定するまでBを送信しない。

オフチェーンで順序管理を行う

署名データに「順番情報」を持たせ、リレイヤー側で順番に処理する仕組みを作る。

異なるアプリ間でのnonce管理に注意

未確定のnonceを使ったトランザクションがあると、異なるアプリが同じnonceを再利用する可能性があります。 そのため、各アプリケーションがオフチェーンでnonceを管理することで解決できます。


ecrecoverのゼロアドレス(0x0)チェックの必要性

ecrecoverを使用する時に、ゼロアドレス(0x0)を拒否する必要があります。

ecrecoverは、Ethereumの公開鍵復元関数で、署名データ(v, r, s)から元の署名者アドレスを復元する ために使用されます。 しかし、不正な署名データを渡すと、ゼロアドレス(0x0000000000000000000000000000000000000000)が返ってくることがあります。

ここでもしecrecoverの結果を直接使用してtransferやapproveを行うと、ゼロアドレスのアカウントが勝手にトークンを取得できる可能性があります。

解決策

以下のような仕組みでゼロアドレスのチェックします。

js
address recovered = ecrecover(digest, v, r, s);
require(recovered != address(0), "Invalid signature");

参考

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

最後に

今回は「安全にERC20トークンをメタトランザクション(approve + transferFrom)で送付する仕組みを提案しているERC7758」についてまとめてきました! いかがだったでしょうか?

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

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