# [ERC7739] ネスト型EIP-712と防御的リハッシュで署名リプレイ攻撃を防ぐ仕組みを理解しよう！

> ERC7739は、1つの外部所有アカウント（EOA）が複数のスマートアカウントを所有している場合に発生する、署名のリプレイ攻撃（過去に使用した署名を再利用して不正に取引を成立させる攻撃）を防ぐための標準仕様を定義しています。

- URL: https://cardene.dev/articles/23ec7be557f2144d9d87
- 公開日: 2025-08-15
- 更新日: 2025-08-15
- トピック: Blockchain
- タグ: ethereum, solidity, smartcontract, ブロックチェーン

## はじめに

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

https://amzn.asia/d/gxvJ0Pw

今回は、複数のスマートアカウントを1つのEOAで管理する場合に発生する署名の不正再利用（リプレイ攻撃）を、防御的リハッシュとEIP-712型構造を組み合わせて安全に防ぐ方法を標準化している**ERC7739**についてまとめていきます！

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

https://eips.ethereum.org/EIPS/eip-7739

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

https://cardene.notion.site/ERC-EIP-2a03fa3ea33d43baa9ed82288f98d4a9?pvs=4

## 概要

**ERC7739**は、1つの外部所有アカウント（EOA）が複数のスマートアカウントを所有している場合に発生する、署名のリプレイ攻撃（過去に使用した署名を再利用して不正に取引を成立させる攻撃）を防ぐための標準仕様を定義しています。
防御策として、**ERC1271**の署名検証においてネストされた**EIP712**型構造体を用いた「**防御的リハッシュ（defensive rehashing）**」を行います。
この方法により、ウォレットの署名リクエスト時に署名対象の内容を人間が読める形で保持できます。

## 動機

スマートアカウントは、**ERC1271**に準拠した `isValidSignature` 関数で署名の正当性を検証します。
しかし、単純な実装では脆弱性があり、複数のスマートアカウントを1つのEOAが管理している場合、署名にスマートアカウントのアドレスが含まれなければ同じ署名が別のアカウントでも使えてしまう（リプレイ可能）という問題があります。

:::message
**ERC1271**については以下の記事を参考にしてください。
:::

[\[ERC1271\] コントラクトで署名を検証する仕組みを理解しよう！](/articles/da237b1b8cc185491512)

特に、Permit2など多くの人気アプリケーションではこの問題が発生する可能性があります。
そのため、多くのスマートアカウント実装では、防御的リハッシュとして以下のような追加情報をハッシュに含める手法を採用しています。

* 元のハッシュ値
* スマートアカウント自身のアドレス
* チェーンID

これにより、異なるアカウントや異なるチェーンでは同じ署名が通用しなくなります。
ただし、**EIP712**を用いて単純に実装すると、署名対象のデータがウォレットクライアント上で人間から見えない不透明な形式になってしまいます。

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

[\[EIP712\] 型付き構造化データのハッシュ化と署名の仕組みを理解しよう！](/articles/f54bc85cb66de5a54257)

**ERC7739**の方式では、**EIP712**に対応したウォレットであれば署名内容を可視化でき、既存のウォレットクライアントやアプリケーションフロントエンドの改修が不要です。
さらに、必要であればユーザーがクライアント側のJavaScriptを追加することで、防御的リハッシュを有効化することもできます。

## 仕様

**ERC7739**は、**ERC1271**の `isValidSignature` 関数における署名検証に、防御的リハッシュ（Defensive Rehashing）を導入する方法を定義しています。
目的は、1つの外部所有アカウント（EOA）が複数のスマートアカウントを所有している場合に発生する署名リプレイ攻撃を防ぎつつ、署名内容をウォレットで可視化できるようにすることです。

この仕組みでは、2種類のワークフローを想定しています。

1. **TypedDataSign ワークフロー**（**EIP712**形式の署名）
2. **PersonalSign ワークフロー**（**EIP191**形式の署名）

それぞれに応じて最終ハッシュの計算方法や署名フォーマットが異なります。

:::message
**EIP191**については以下の記事を参考にしてください。
:::

[\[ERC191\] 署名済みデータを安全に使用する仕組みを理解しよう！](/articles/ee4edd15ad6fe7f58f7e)

### 必要な依存仕様

この標準を実装するには以下が必要です。

* **EIP712**
    * 型付き構造データのハッシュ化と署名方式。
    * 最終ハッシュを構築するためのロジックを提供します。
* **ERC1271**
    * コントラクト署名の標準検証メソッド。`isValidSignature(bytes32 hash, bytes calldata signature)` 関数を提供します。
* **ERC5267**
    * **EIP712**ドメイン情報取得仕様。
    * `eip712Domain()` 関数でドメイン情報を取得し、最終ハッシュの計算に利用します。

:::message
**ERC5267**については以下の記事を参考にしてください。
:::

[\[ERC5267\] EIP712ドメインの生成に必要な情報を渡す仕組みを理解しよう！](/articles/3f0d49e6796d6f35d321)

### `TypedDataSign` ワークフロー

**EIP712**形式で計算された元ハッシュを、さらにスマートアカウント固有の情報を加えてリハッシュする方式です。
これにより、アカウントやチェーンごとに異なる署名となり、リプレイ攻撃を防ぎます。

#### 最終ハッシュの計算式

```
keccak256(
  \x19\x01 ‖ APP_DOMAIN_SEPARATOR ‖
    hashStruct(TypedDataSign({
      contents: hashStruct(originalStruct),
      name: eip712Domain().name,
      version: eip712Domain().version,
      chainId: eip712Domain().chainId,
      verifyingContract: eip712Domain().verifyingContract,
      salt: eip712Domain().salt
    }))
)
```

この処理は最終的な署名対象ハッシュの作り方を示しています。
目的は、元の署名対象データ（`originalStruct`）に加え、スマートアカウント固有の情報（ドメイン情報やアカウント固有の定数）を含めた新しいハッシュを作ることで、署名の再利用（リプレイ）を防ぎつつ、**EIP712**形式の署名可視化を維持することです。

式の構造は、**EIP712**の `\x19\x01` プレフィックスに、アプリケーション固有のドメインセパレーター（`APP_DOMAIN_SEPARATOR`）と、TypedDataSign構造体のハッシュを結合し、その結果をkeccak256でハッシュ化します。

##### 計算の流れ

1. 固定プレフィックス `\x19\x01`

これは**EIP712**で定義されている識別子で、「**このデータは型付き構造体の署名用である**」ということを示します。

2. `APP_DOMAIN_SEPARATOR`

アプリケーション固有のドメイン識別子です。
これを追加することで、同じ構造のデータでも別アプリケーションや別用途で使い回せないようにします。

3. `hashStruct(TypedDataSign({...}))`

新たに定義した `TypedDataSign` 構造体をハッシュ化します。
この構造体は以下のフィールドを持ちます。

* `contents`
    * 元データ構造（`originalStruct`）のハッシュ。
* `name`
    * `eip712Domain().name` の文字列ハッシュ。
* `version`
    * ドメインバージョンの文字列ハッシュ。
* `chainId`
    * チェーンID。
* `verifyingContract`
    * 検証を行うコントラクトのアドレス。
* `salt`
    * ドメイン固有の32バイトの識別子。

このように、元データの内容＋アカウント固有情報＋チェーン固有情報を全て含めたハッシュが作られます。

#### Solidity実装例

```javascript
finalTypedDataSignHash = 
    keccak256(
        abi.encodePacked(
            hex"1901",                           // (1) 固定プレフィックス
            bytes32(APP_DOMAIN_SEPARATOR),       // (2) アプリ固有ドメイン
            keccak256(                           // (3) TypedDataSign構造体のハッシュ
                abi.encode(
                    typedDataSignTypehash,       // 型情報のハッシュ
                    bytes32(hashStruct(originalStruct)), // 元構造体のハッシュ
                    keccak256(bytes(eip712Domain().name)), 
                    keccak256(bytes(eip712Domain().version)),
                    uint256(eip712Domain().chainId),
                    uint256(uint160(eip712Domain().verifyingContract)),
                    bytes32(eip712Domain().salt)
                )
            )
        )
    );
```

#### `typedDataSignTypehash` の計算方法

`typedDataSignTypehash` は「**TypedDataSignという構造体の型定義**」をハッシュ化したものです。
これを入れることで、「**どういう型構造のデータが署名されているのか**」をウォレットクライアントが理解できます。

計算式は以下です。

```javascript
typedDataSignTypehash = 
    keccak256(
        abi.encodePacked(
            "TypedDataSign(",
                contentsName, " contents,",       // ユーザー定義型のフィールド
                "string name,",
                "string version,",
                "uint256 chainId,",
                "address verifyingContract,",
                "bytes32 salt"
            ")",
            contentsType                          // contentsNameの完全な型定義
        )
    );
```

`contentsName` は `contentsType`（例: `"Mail(address from,address to,string message)"`）から、最初の `"("` の手前までを切り出した部分文字列です。
例）

* `contentsType` → `"Mail(address from,address to,string message)"`
* `contentsName` → `"Mail"`

コードでは、ライブラリ `LibString` の `slice` と `indexOf` 関数で抽出します。

```javascript
contentsName = 
    LibString.slice(
        contentsType,                 // 対象文字列
        0,                             // 開始位置（先頭）
        LibString.indexOf(contentsType, "(") // "(" の位置まで
    );
```

セキュリティ上、`contentsName` が空である場合や特定の不正文字を含む場合は署名を無効とすることが推奨されています。

### 署名フォーマット

`isValidSignature` に渡す署名は以下のように拡張されます。

```
originalSignature ‖ APP_DOMAIN_SEPARATOR ‖ contents ‖ contentsDescription ‖ uint16(contentsDescription.length)
```

Solidity実装例。

```javascript
signature = 
    abi.encodePacked(
        bytes(originalSignature),
        bytes32(APP_DOMAIN_SEPARATOR),
        bytes32(contents),
        bytes(contentsDescription),
        uint16(contentsDescription.length)
    );
```

この `APP_DOMAIN_SEPARATOR` と `contents` を使い、次の式でハッシュが正しいかを検証します。

```javascript
hash == keccak256(
    abi.encodePacked(
        hex"1901",
        bytes32(APP_DOMAIN_SEPARATOR),
        bytes32(contents)
    )
)
```

一致しない場合、その署名とハッシュは無効となります。

### PersonalSign ワークフロー

**EIP191**形式の署名を扱う方式で、Ethereum メッセージ署名のプレフィックスを含めてハッシュを生成します。
PersonalSignワークフローは、プレーン文字列の署名（`personal_sign`/`eth_sign`系）でよく使われる **EIP191**形式を、**EIP712**のように変換することで、スマートアカウント固有の文脈（ドメイン）にひも付けます。
これにより、同じメッセージでもスマートアカウントやチェーンが異なる環境では再利用（リプレイ）できないようにしつつ、アプリ側は従来どおり**EIP191**のハッシュ（`hash`）だけ渡せばよい、という互換性を保ちます。

#### 最終ハッシュの計算式

```
keccak256(
  \x19\x01 ‖ ACCOUNT_DOMAIN_SEPARATOR ‖
    hashStruct(PersonalSign({
      prefixed: keccak256(bytes(
        \x19Ethereum Signed Message:\n ‖
        base10(len) ‖
        someString
      ))
    }))
)
```

* `\x19\x01` は**EIP712**の識別プレフィックスです。
* `ACCOUNT_DOMAIN_SEPARATOR` はスマートアカウント自身の**EIP712**ドメイン識別子です（**ERC5267**の `eip712Domain()` から求められます）。
    * アカウントとチェーンに結び付けるために使用されます。
* `PersonalSign(bytes prefixed)` という単純な1フィールドの型で**EIP191**の「**プレフィックス付きメッセージハッシュ**」を内包します。
    * これが `hashStruct(PersonalSign({...}))` の部分です。


#### Solidity実装例

```javascript
finalPersonalSignHash = 
    keccak256(
        abi.encodePacked(
            hex"1901",                          // 1) EIP-712識別プレフィックス
            bytes32(ACCOUNT_DOMAIN_SEPARATOR),  // 2) スマートアカウント固有のドメイン
            keccak256(
                abi.encode(
                    keccak256("PersonalSign(bytes prefixed)"), // 3) 型定義のtypehash
                    hash                                       // 4) EIP-191のメッセージハッシュ
                )
            )
        )
    );
```

ここで `hash` はアプリケーションコントラクト側で計算されます。
例:

```javascript
hash = keccak256(
    abi.encodePacked(
        "\x19Ethereum Signed Message:\n",
        LibString.toString(someString.length), // 10進ASCIIで長さ
        someString                              // 元メッセージの生バイト列
    )
);
```

* **EIP191の前置き**（`"\x19Ethereum Signed Message:\n" + 10進の長さ + 本文`）を自前で連結してkeccak256でハッシュ化した値が `hash` です。
  長さは16進ではなく10進ASCIIで表記します（例：`"5"`、`"32"`）。
* `someString` はそのままのバイト列で扱います。
    * 文字列ならUTF-8の生バイト、任意バイナリでもOKです。
* スマートアカウント側は `hash` の生成手順を知る必要はありません。
    * アプリが計算した `hash` を受け取って二重にラップするだけです。

PersonalSignではTypedDataSignのような追加データは不要です。

:::message
#### なぜEIP712でラップするのか

PersonalSignは「誰が・どの文脈で」署名したかのメタ情報を持ちません。
そこで**EIP712**に準拠することで、`ACCOUNT_DOMAIN_SEPARATOR` を通じてアカウントのアドレスやチェーンID、バージョン等のドメイン情報を最終ハッシュへ反映します。
結果として、同じ `hash` でも別アカウント／別チェーンでは検証が通らないため、リプレイ攻撃が防げます。

**手順（実装の流れ）**

1. アプリ側で**EIP191**形式に従い `hash` を計算します（上記例）。
2. スマートアカウント側で `ACCOUNT_DOMAIN_SEPARATOR` を用意します（`eip712Domain()` 情報から計算可能）。
3. `keccak256("PersonalSign(bytes prefixed)")` を typehash として用い、`abi.encode(typehash, hash)` を keccak256 します。
4. `hex"1901" || ACCOUNT_DOMAIN_SEPARATOR || (手順3の結果)` を `abi.encodePacked` で連結して keccak256。
    - これが `finalPersonalSignHash` です。
5. 署名検証は、この `finalPersonalSignHash` と署名を `ecrecover` 等（ERC-1271の内部方針）で照合します。
:::

### サポート検出

この標準を実装したスマートアカウントは、以下の呼び出しに対して特定の値を返すことで対応を示します。

```javascript
isValidSignature(
  0x7739773977397739773977397739773977397739773977397739773977397739,
  ""
) 
// => bytes4(0x77390001)
```

将来アップデートされる場合は、この値をインクリメントします。

### ワークフロー判定ロジック

`isValidSignature` の関数シグネチャは変わらないため、署名データからどちらのワークフローを使うかを判別します。

```javascript
if (
    hash == keccak256(
        abi.encodePacked(
            hex"1901",
            bytes32(APP_DOMAIN_SEPARATOR),
            bytes32(contents)
        )
    )
) {
    // TypedDataSign ワークフロー
} else {
    // PersonalSign ワークフロー
}
```

### 防御的リハッシュの省略条件

以下の場合、防御的リハッシュをスキップできます。

* `isValidSignature` がオフチェーンで呼び出される場合
* 渡された `hash` にスマートアカウントのアドレスが既に含まれている場合

これは、既存のアプリが新しい仕様に対応していなくても、安全な場合は旧来の検証を行えるようにするためです。

## 補足

### TypedDataSign 構造の設計意図

`typedDataSignTypehash` は、オンチェーン上で動的に生成する必要があります。
これは、署名リクエスト時にウォレットでユーザーが署名内容を確認できるようにするためであり、`contents` がユーザー定義型であることを保証します。
また、`eip712Domain` の各フィールドは `TypedDataSign` 構造体内に直接展開されます。
これは、`verifyingContract` 側のドメイン型と異なる場合に型の衝突が発生するのを避けるためです。
さらに、**ERC5267**に存在する `bytes1 bitmap` フィールドや `uint256[] extensions` 配列は省略されています。
これらはフィールドが存在しない場合とゼロ値の場合を区別できるものの、防御的リハッシュのセキュリティ向上には寄与しないためです。
`extensions` はオフチェーンでEIP番号を通知する目的で使われる項目です。

### contentsDescription の暗黙モードと明示モード

`contents` 構造がネストされた型を含む場合、**EIP712**の辞書順ソートによって `contentsName` が `contentsType` の先頭に来ないことがあります。
このようなケースに対応するため、明示モード（explicit mode）が必要になります。
暗黙モード（implicit mode）は、`contentsType` の先頭が `contentsName` である場合に使用されます。

### isValidSignature によるサポート検出

モジュール型スマートアカウントでの実装を簡単にするため、新たな関数を定義せずに`isValidSignature` の戻り値として特定のマジックナンバーを返す方式を採用しています。
これにより、追加のインターフェース定義を不要にしつつ、標準対応を判別できます。

### contentsName の先頭文字制限

`contentsName` が7ビットASCIIの小文字で始まる場合は署名を拒否することが推奨されています。
これは、言語間の互換性と将来の拡張性を確保するためです。
例えば、Solidityでは `uint256` と表記する型が、他言語では `u256` のように異なる名称になる可能性があります。

## 互換性について

### 過去のドラフトとの違い

以前のドラフト版では、標準対応を検出するために `supportsNestedTypedDataSign()` という関数が定義されており、この関数は `bytes4(0xd620c85a)` を返していました。
現在の仕様ではこの方式を廃止し、`isValidSignature` でのマジックナンバー返却に統一されています。
これにより、既存の**ERC1271&&互換コードへの影響を最小限に抑えつつ、新仕様への移行が容易になります。

## セキュリティ

### 不正な `contentsName` の拒否

`eth_signTypedData` を利用する主要な実装では、カスタム型名（`contentsName`）の内容を厳密に検証していない場合があります。
このため、悪意のあるWebサイトが制御文字や特殊記号を含んだ `contentsName` を細工すると、**EIP712**署名データの型定義部分を意図的に壊すことが可能です。
これにより、ウォレットクライアントが正しい型構造を表示できず、ユーザーには単なる不透明なハッシュが署名対象として提示される危険性があります。

この攻撃は、ユーザーに署名内容を理解させずに署名させるフィッシング攻撃の一種です。
そのため、スマートコントラクト側で `contentsName` を検証し、無効な文字列を拒否することが推奨されます。
これにより、この攻撃経路を遮断できます。

### 複数署名者による連鎖利用の不可能性

この方式を**ERC1271**署名のリプレイ防止策として採用したアカウントは、同じ方式を使う別の署名者を組み合わせることができません。
理由は、`TypedDataSign` 構造体の中に「**署名対象のメッセージ型**」が含まれているためです。
もし署名対象のメッセージ自体が別の `TypedDataSign` 構造体だった場合、**EIP712**メッセージ本体には2つの異なる `TypedDataSign` 型が混在することになります。
しかし、**EIP712**の仕様上、これらの型定義を一つの署名リクエストとして表現することはできません。

その結果、同じ防御的リハッシュ方式を複数の署名者で階層的に組み合わせることは技術的に不可能となります。

## 引用

vectorized (@vectorized), Sihoon Lee (@push0ebp), Francisco Giordano (@frangio), Hadrien Croubois (@Amxx), Ernesto García (@ernestognw), Im Juno (@junomonster), howydev (@howydev), Atarpara (@Atarpara), 0xcuriousapple (@0xcuriousapple), "ERC-7739: Readable Typed Signatures for Smart Accounts [DRAFT]," Ethereum Improvement Proposals, no. 7739, May 2024. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7739.

## 最後に

今回は「複数のスマートアカウントを1つのEOAで管理する場合に発生する署名の不正再利用（リプレイ攻撃）を、防御的リハッシュとEIP-712型構造を組み合わせて安全に防ぐ方法を標準化している**ERC7739**」についてまとめてきました！
いかがだったでしょうか？

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

[Twitter @cardene777](https://twitter.com/cardene777)

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

https://chaldene.net/

https://zenn.dev/heku

https://cardene.substack.com/

https://mirror.xyz/0xcE77b9fCd390847627c84359fC1Bc02fC78f0e58

https://cardene.notion.site/ERC-EIP-2a03fa3ea33d43baa9ed82288f98d4a9?pvs=4
