# [ERC7962] アドレスの代わりに鍵ハッシュでトークンを管理するKey Hash Based Tokensの仕組みを理解しよう！

> ERC7962とは、従来のERC721（NFT）やERC20（FT）のようにEthereumアドレスで所有権を管理するのではなく、公開鍵のハッシュ値（keyHash = keccak256(key)）で所有権を管理するトークンインターフェー

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

## はじめに

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

https://amzn.asia/d/gxvJ0Pw

今回は、Ethereumアドレスの代わりに公開鍵のハッシュ値（keyHash）でトークンの所有権を管理し、プライバシーの向上とガス代の第三者支払いを実現する仕組みを提案している**ERC7962**についてまとめていきます！

:::message
以下にまとめられているものを解説しながらまとめていきます。
:::

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

他にも様々なEIP・BIP・SLIP・CAIP・ENSIP・RFC・ACPについてまとめています。

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

## 概要

**ERC7962**とは、従来の**ERC721**（NFT）や**ERC20**（FT）のようにEthereumアドレスで所有権を管理するのではなく、公開鍵のハッシュ値（`keyHash = keccak256(key)`）で所有権を管理するトークンインターフェースの提案です。

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

[\[ERC721\] ERC721を1からわかりやすく解説](/articles/0570a8d05bc7c9b9593f)

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

[\[ERC20\] ERC20を1からわかりやすく解説](/articles/aeefe2d89dd5332c9f43)

従来のトークンでは、`ownerOf`や`balanceOf`といった関数がEthereumアドレスを返すため、誰がどのトークンを持っているかがオンチェーンで公開されていました。
**ERC7962**では、これらの関数が`bytes32`型の`keyHash`を返すため、アドレスがオンチェーンに記録されません。
トークンを転送する時は、所有者の公開鍵（`key`）と、対応する秘密鍵で生成した**EIP712**署名を提示して所有権を証明します。

この設計には2つの大きなメリットがあります。
1つ目は、アドレスがオンチェーンに記録されないためプライバシーが向上すること。
2つ目は、署名さえあれば誰でも`transfer`関数を呼び出せるため、ガス代を第三者が支払えることです。
所有権の証明と実際のトランザクション送信を分離することで、バッチ処理やガススポンサーシップが自然に実現できます。

この提案では、NFT用の**ERC-KeyHash721**とFT用の**ERC-KeyHash20**の2つのインターフェースを定義しています。

## 動機

#### アドレスベースの所有権とプライバシーの課題

従来の**ERC721**や**ERC20**のトークンでは、所有権がEthereumアドレスに紐づいています。
Ethereumのアドレスはブロックチェーン上で誰でも閲覧できるため、特定のアドレスがどのトークンを保有しているか、いつ誰に送ったかといった情報がすべて公開されます。

これが問題になるのは、アドレスと個人の身元がリンクされた場合です。
例えば、KYC（本人確認）を行った取引所からトークンを引き出すと、その引き出し先アドレスが個人と結びつきます。
そこから先のすべてのトランザクション履歴が追跡可能になり、保有資産や取引パターンが第三者に知られてしまいます。

匿名のコレクティブル（収集品）、プライベートな取引、分散型IDのユースケースでは、所有者を特定されずにトークンを保有・操作できる仕組みが求められています。

#### ガス代支払いと所有権の分離

従来のトークンでは、`transfer`関数を呼び出す人（`msg.sender`）がトークンの所有者であるか、`approve`で承認を受けた人である必要があります。
つまり、トークンを動かすためには所有者自身がトランザクションを送信してガス代を支払うか、事前に第三者に`approve`で権限を付与する必要があります。

しかし、ガス代が高騰している状況や、大量のトークンを一括で処理したい場合には、トークンの所有者以外がガス代を肩代わりできる仕組みが便利です。

**ERC7962**では、`transfer`の呼び出しに`msg.sender`の制約がありません。
所有権の証明は公開鍵と署名で行い、実際にトランザクションを送信する人は誰でもかまいません。
これにより、リレイヤーやスポンサーがガス代を負担するモデルが自然に実現できます。

この考え方は**ERC5564**（ステルスアドレス）のプライバシー原則と共通しており、**ERC7962**はそれをトークンの所有権管理に拡張したものと位置づけられています。

## 仕様

#### 基本的な仕組み

**ERC7962**の中核は、「**key**」と「**keyHash**」という2つの概念です。

**key**とは、65バイトの非圧縮secp256k1公開鍵です。
先頭に`0x04`というプレフィックスがつき、その後にX座標（32バイト）とY座標（32バイト）が続く形式です。

:::message
**secp256k1**

Ethereumで使われている楕円曲線暗号の種類です。
秘密鍵から公開鍵を生成し、公開鍵からEthereumアドレスを導出する時に使われます。
非圧縮形式ではX座標とY座標の両方を含む65バイトになります。
:::

**keyHash**とは、この`key`の`keccak256`ハッシュ値（`bytes32`型）です。
トークンの所有権はこの`keyHash`に紐づけられます。
オンチェーンに記録されるのは`keyHash`だけであり、`key`（公開鍵）自体は転送時にのみ提示されます。

トークンを転送する時の流れは以下のようになります。

1. 所有者は、転送内容を**EIP712**の構造化データとして署名します。
2. 署名と公開鍵（`key`）を添えて`transfer`関数を呼び出します（呼び出すのは所有者本人でなくてもよい）。
3. コントラクトは`keccak256(key)`が現在の`keyHash`と一致するか確認します。
4. 署名を検証し、正当な所有者による操作であることを確認します。
5. 所有権を新しい`keyHash`に移します。

##### `approve`関数が存在しない理由

**ERC7962**では、`approve`関数が意図的に省略されています。
`key`（公開鍵）は転送時にcalldataで公開されるため、使い捨てが前提です。
一度`key`が公開された後は、そのトークンを新しい`keyHash`に移行するのが通常のフローです。
実装によっては、一度公開された`keyHash`の再利用を禁止することもできます。

`approve`が不要な代わりに、署名さえ正当であれば誰でも`transfer`を呼び出せます。
署名は`key`から導出されるアドレスの秘密鍵で生成する必要があるため、第三者が勝手にトークンを動かすことはできません。

#### ERC-KeyHash721

NFT用のインターフェースです。
各トークンは`tokenId`で識別され、所有権は`keyHash`で管理されます。

```javascript
interface IERCKeyHash721 {
    // イベント
    event KeyHashTransfer721(uint256 indexed tokenId, bytes32 indexed fromKeyHash, bytes32 indexed toKeyHash);
    event KeyHashBurn721(uint256 indexed tokenId, bytes32 indexed ownerKeyHash);

    // View関数（ERC721と対応）
    function name() external view returns (string memory);
    function symbol() external view returns (string memory);
    function tokenURI(uint256 tokenId) external view returns (string memory);
    function totalSupply() external view returns (uint256);
    function ownerOf(uint256 tokenId) external view returns (bytes32);

    // 状態変更関数
    function mint(uint256 tokenId, bytes32 keyHash) external;
    function transfer(uint256 tokenId, bytes32 toKeyHash, bytes memory key, bytes memory signature, uint256 deadline) external;
    function destroy(uint256 tokenId, bytes memory key, bytes memory signature, uint256 deadline) external;
}
```

従来の**ERC721**と大きく異なるのは、`ownerOf`関数の戻り値です。
**ERC721**では`address`を返しますが、**ERC-KeyHash721**では`bytes32`（`keyHash`）を返します。
これにより、トークンの所有者が誰かという情報がアドレスレベルでは公開されません。

`name`、`symbol`、`tokenURI`、`totalSupply`は**ERC721**と同じ形式です。
`tokenURI`はコアインターフェースの一部ですが、メタデータが不要な場合は空文字列を返すこともできます。

##### `transfer`

```javascript
function transfer(
    uint256 tokenId,
    bytes32 toKeyHash,
    bytes memory key,
    bytes memory signature,
    uint256 deadline
) external;
```

指定したトークンを現在の所有者の`keyHash`から新しい`toKeyHash`に転送する関数です。
呼び出し者は所有者の`key`（公開鍵）を提示して所有権を証明し、**EIP712**構造化データの署名で操作の正当性を検証します。

| パラメータ | 型 | 説明 |
|-----------|-----|------|
| `tokenId` | `uint256` | 転送するトークンのID。 |
| `toKeyHash` | `bytes32` | 新しい所有者のキーハッシュ。ゼロであってはならない。 |
| `key` | `bytes` | 現在の所有者の65バイト非圧縮secp256k1公開鍵（先頭が`0x04`）。 |
| `signature` | `bytes` | `key`に対応する秘密鍵で生成したECDSA署名。リレイヤーによる不正な中継を防止する。 |
| `deadline` | `uint256` | 署名の有効期限（Unixタイムスタンプ、秒単位）。 |

署名メッセージは以下の**EIP712**構造化データで構成されます。

```javascript
struct Transfer {
    uint256 tokenId;
    bytes32 toKeyHash;
    uint256 nonce;
    uint256 deadline;
}
```

この関数が呼ばれると、`KeyHashTransfer721(tokenId, fromKeyHash, toKeyHash)`イベントが発行されます。

実行に必要な条件は以下の通りです。

- トークンが存在すること（`fromKeyHash`がゼロでない）。
- `keccak256(key)`が現在の`fromKeyHash`と一致すること。
- 署名が有効であること。
- `block.timestamp`が`deadline`以下であること。
- `toKeyHash`がゼロでないこと。

##### `destroy`

```javascript
function destroy(
    uint256 tokenId,
    bytes memory key,
    bytes memory signature,
    uint256 deadline
) external;
```

指定したトークンを永久に削除（Burn）する関数です。
`transfer`と同様に、所有者の公開鍵と署名で所有権を証明します。

- **引数**

| パラメータ | 型 | 説明 |
|-----------|-----|------|
| `tokenId` | `uint256` | 削除するトークンのID。 |
| `key` | `bytes` | 現在の所有者の65バイト非圧縮secp256k1公開鍵。 |
| `signature` | `bytes` | `key`に対応する秘密鍵で生成したECDSA署名。 |
| `deadline` | `uint256` | 署名の有効期限（Unixタイムスタンプ、秒単位）。 |

署名メッセージは以下の**EIP712**構造化データで構成されます。

```javascript
struct Destroy {
    uint256 tokenId;
    uint256 nonce;
    uint256 deadline;
}
```

この関数が呼ばれると、`KeyHashBurn721(tokenId, ownerKeyHash)`イベントが発行されます。
削除されたトークンは`_destroyedTokens`マッピングで記録され、以降の`ownerOf`や`transfer`は失敗します。

`destroy`は`transfer`とは別の型ハッシュ（`DESTROY_TYPEHASH`）を使用します。
これにより、転送用の署名を削除に流用したり、その逆を行うことはできません。

#### ERC-KeyHash20

FT（Fungible Token）用のインターフェースです。
残高は`keyHash`に紐づけて管理されます。

```javascript
interface IERCKeyHash20 {
    // イベント
    event KeyHashTransfer20(bytes32 indexed fromKeyHash, bytes32 indexed toKeyHash, uint256 amount);

    // View関数（ERC20と対応）
    function name() external view returns (string memory);
    function symbol() external view returns (string memory);
    function decimals() external view returns (uint8);
    function totalSupply() external view returns (uint256);
    function balanceOf(bytes32 keyHash) external view returns (uint256);

    // 状態変更関数
    function mint(bytes32 keyHash, uint256 amount) external;
    function transfer(bytes32 fromKeyHash, bytes32 toKeyHash, uint256 amount, bytes memory key, bytes memory signature, uint256 deadline, bytes32 leftKeyHash) external;
}
```

従来の**ERC20**との違いは、`balanceOf`関数がアドレスではなく`bytes32`型の`keyHash`を引数に取る点です。

##### `transfer`

```javascript
function transfer(
    bytes32 fromKeyHash,
    bytes32 toKeyHash,
    uint256 amount,
    bytes memory key,
    bytes memory signature,
    uint256 deadline,
    bytes32 leftKeyHash
) external;
```

`fromKeyHash`から`toKeyHash`に`amount`分のトークンを転送し、残りの残高を`leftKeyHash`に割り当てる関数です。

| パラメータ | 型 | 説明 |
|-----------|-----|------|
| `fromKeyHash` | `bytes32` | トークン所有者のキーハッシュ。 |
| `toKeyHash` | `bytes32` | 受取人のキーハッシュ。 |
| `amount` | `uint256` | 転送する量。 |
| `key` | `bytes` | 所有者の65バイト非圧縮secp256k1公開鍵（先頭が`0x04`）。 |
| `signature` | `bytes` | ECDSA署名。 |
| `deadline` | `uint256` | 署名の有効期限。 |
| `leftKeyHash` | `bytes32` | 残高の残りを割り当てる先のキーハッシュ。`toKeyHash`とも`fromKeyHash`とも異なる値でなければならない（鍵のローテーションとリンク不可能性を強制するための制約）。 |

この`leftKeyHash`の仕組みは、BitcoinのUTXO（未使用トランザクション出力）モデルに似ています。
例えば、ある`keyHash`に100トークンがあり、30トークンを送りたい場合を考えます。
`toKeyHash`に30トークンが送られ、残りの70トークンは`leftKeyHash`に割り当てられます。
`fromKeyHash`の残高はゼロになります。

```mermaid
graph LR
    A["fromKeyHash<br/>残高: 100"] --> B["toKeyHash<br/>+30"]
    A --> C["leftKeyHash<br/>+70"]
    A --> D["fromKeyHash<br/>残高: 0"]
```

`leftKeyHash`は`fromKeyHash`とも`toKeyHash`とも異なる値でなければなりません。
この制約は「**strict mode**」と呼ばれ、おつりを元のアカウントや送り先に統合することを禁止します。
毎回新しい`keyHash`を使うことで、同じ公開鍵の再利用を防ぎ、取引間のリンク可能性を低減しています。

署名メッセージは以下の構造です。

```javascript
struct Transfer {
    bytes32 fromKeyHash;
    bytes32 toKeyHash;
    uint256 amount;
    uint256 nonce;
    uint256 deadline;
    bytes32 leftKeyHash;
}
```

実行に必要な条件は以下の通りです。

- `fromKeyHash`の残高が`amount`以上であること。
- `keccak256(key)`が`fromKeyHash`と一致すること。
- 署名が有効であること。
- `block.timestamp`が`deadline`以下であること。
- `toKeyHash`と`leftKeyHash`がゼロでないこと。
- `leftKeyHash`が`toKeyHash`とも`fromKeyHash`とも異なること。

#### 署名検証フロー

`transfer`関数における署名検証は、以下の6ステップで行われます。

```mermaid
sequenceDiagram
    participant Caller as 呼び出し者
    participant Contract as コントラクト
    participant EIP712 as EIP712ハッシュ
    participant ECDSA as ecrecover

    Caller->>Contract: transfer(key, signature, ...)
    Contract->>Contract: 1. keccak256(key) == keyHash を確認
    Contract->>EIP712: 2. 構造化データのダイジェストを計算
    EIP712-->>Contract: digest
    Contract->>ECDSA: 3. ecrecover(digest, signature)
    ECDSA-->>Contract: signer address
    Contract->>Contract: 4. signer == address(keccak256(key[1:])) を確認
    Contract->>Contract: 5. nonce をインクリメント
    Contract->>Contract: 6. block.timestamp <= deadline を確認
```

各ステップの詳細は以下の通りです。

1. `keccak256(key)`が現在の`keyHash`と一致するか確認します。これにより、呼び出し者が正しい公開鍵を提示していることを検証します。
2. **EIP712**の構造化データ（転送パラメータ、nonce、deadline）からダイジェスト（ハッシュ値）を計算します。
3. `ecrecover`で署名からアドレスを復元します。
4. 復元されたアドレスが、`key`から導出されるアドレス（`key`の先頭1バイト`0x04`を除いた64バイトの`keccak256`の下位20バイト）と一致するか確認します。
5. `_keyNonces[keyHash]`をインクリメントして、同じ署名の再利用（リプレイ攻撃）を防止します。
6. `block.timestamp`が`deadline`以下であることを確認し、期限切れの署名を拒否します。

**EIP712**のダイジェスト計算は以下のように行われます。

```javascript
bytes32 digest = keccak256(abi.encodePacked(
    "\x19\x01",
    DOMAIN_SEPARATOR,
    keccak256(abi.encode(
        TYPE_HASH,
        params // 構造体のフィールド（tokenId, toKeyHash, nonce, deadlineなど）
    ))
));
```

`DOMAIN_SEPARATOR`にはコントラクトアドレスと`chainId`が含まれるため、異なるコントラクトや異なるチェーンでの署名の使い回しは自動的に防止されます。
nonceは`keyHash`ごと、コントラクトごとに追跡されます。
同じ`keyHash`に対する操作は順序付けて実行する必要があります。

#### コントラクトの要件

**ERC7962**に準拠するコントラクトが満たすべき要件をまとめます。

| 要件 | ERC-KeyHash721 | ERC-KeyHash20 |
|------|----------------|---------------|
| 所有権マッピング | `tokenId` → `keyHash` | `keyHash` → 残高 |
| nonceマッピング | `keyHash` → `uint256` | `keyHash` → `uint256` |
| **EIP712**署名ハッシュ | 必須 | 必須 |
| `deadline`の検証 | 必須 | 必須 |
| `key`と署名の検証 | `transfer`で必須 | `transfer`で必須 |
| `leftKeyHash`の制約 | なし | `toKeyHash`・`fromKeyHash`と異なること |

`mint`（新規作成）と`destroy`（削除）はオプションの管理関数です。
実装する場合は、適切なアクセス制御、正確な供給量の管理、keyHashプライバシーモデルの維持（アドレスを公開しないこと）を確保する必要があります。

## 補足

#### キーハッシュ方式の利点

**ERC7962**が採用したキーハッシュ方式には、以下のような利点があります。

![comparison.png](/images/qiita-364e026a-f900-45ac-a377-9f2051ed45dd.webp)

- **プライバシー**
    `ownerOf`や`balanceOf`が返すのは`keyHash`であり、アドレスではありません。
    ユーザーがトークンごとに異なる鍵ペアを使えば、トークン間のリンク可能性を大幅に低減できます。
- **ガス代の分離**
    有効な署名があれば誰でも`transfer`を呼び出せるため、バッチ処理やガススポンサーシップが可能になります。
    所有者がETHを持っていなくても、リレイヤーがガス代を支払ってトークンを移動できます。
- **柔軟性**
    **ERC5564**（ステルスアドレス）の考え方と整合しており、プライバシーを重視するトークンシステムに適しています。

#### 転送設計の意図

`transfer`関数が誰でも呼び出せる設計になっているのは、意図的なものです。
署名によって所有者だけが操作を承認できる仕組みを維持しつつ、ガス代の支払いを第三者に委任できるようになっています。

**EIP712**の構造化署名にはすべての重要なパラメータ（`tokenId`、`toKeyHash`、`amount`、`leftKeyHash`など）が含まれているため、リレイヤーが署名を改ざんしてパラメータを変更することはできません。
さらに、`keyHash`ごとのnonceと`deadline`により、リプレイ攻撃や期限切れの署名の悪用も防止されています。

## 互換性

**ERC7962**は**ERC721**や**ERC20**と互換性がありません。
所有権やBalanceの管理にアドレスではなく`bytes32`型の`keyHash`を使用しているためです。

ただし、アダプタコントラクトを実装することで、既存のシステムとブリッジすることは可能です。
例えば、アダプタが**ERC721**の`ownerOf`を呼ばれた時に、内部的にkeyHash方式のコントラクトから情報を取得して適切なアドレスを返すといった実装が考えられます。
プライバシーを重視するユースケースで特に有用です。

## セキュリティ

#### リプレイ攻撃

リプレイ攻撃とは、過去に使われた有効な署名を再利用して不正な操作を実行する攻撃です。

**ERC7962**では、`keyHash`ごとにnonceを管理しています（`_keyNonces[keyHash]`）。
各操作が成功するたびにnonceがインクリメントされるため、同じ署名は2度と使えません。
この仕組みは**ERC2612**（permit）のnonce管理と同様です。

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

[\[ERC2612\] ERC20のapproveとtransferFromを1つのトランザクションで実行できる仕組みを理解しよう！](/articles/6809173b2ac428b1ccd0)

#### メッセージ改ざん

リレイヤーが署名を傍受し、転送先やトークンIDを変更して不正にトークンを奪う攻撃が考えられます。

**EIP712**の構造化署名にはすべての重要なパラメータが含まれているため、パラメータを1つでも変更するとハッシュ値が変わり、署名の検証に失敗します。
さらに、`TRANSFER_TYPEHASH`や`DESTROY_TYPEHASH`のように関数ごとに異なる型ハッシュを使用しているため、ある関数用の署名を別の関数で使い回すこともできません。

コントラクトは、パラメータの漏れやハッシュの衝突がないことを監査で確認する必要があります。

#### 署名の有効期限

署名が漏洩した場合、有効期限がなければいつでも悪用される可能性があります。

**ERC7962**では`deadline`パラメータにより署名に有効期限を設けています。
`block.timestamp`が`deadline`を超えていれば署名は拒否されるため、漏洩した署名が期限後に悪用されるリスクを低減できます。

#### プライバシーの限界

完全な匿名性が保証されるわけではありません。
`transfer`を呼び出す時、公開鍵（`key`）がcalldata（トランザクションデータ）に含まれます。
この公開鍵から`keccak256(key[1:])`でEthereumアドレスを導出することは、オフチェーンで誰でも可能です。

つまり、転送が実行された時点で、その`keyHash`に対応するアドレスが推測可能になります。
このリスクを軽減するために、トークンごとや取引ごとに新しい鍵ペアを使用し、リンク可能性を最小限に抑えることが推奨されています。
また、`key`は機密情報であるため安全に保管する必要があります。

#### 鍵管理

`key`に対応する秘密鍵を紛失したり漏洩したりすると、そのトークンの制御権を失います。
従来のEthereumウォレットのアドレスに加えて、keyHash用の鍵ペアも管理する必要があるため、鍵管理の負担が増加します。
安全なシステムを使って秘密鍵を保管することが重要です。

#### 署名のMalleability

:::message
**Signature Malleability**

同じメッセージに対して、数学的に有効だが異なるバイト列の署名を生成できる問題です。
これを悪用されると、署名のハッシュを変えて別のトランザクションとして再送信される可能性があります。
:::

実装は、Malleableな署名（低S値でないもの、`v`が27または28以外のもの）を拒否しなければなりません。
OpenZeppelinのECDSAヘルパーはデフォルトでこれらのチェックを行います。

#### ガスコスト

署名検証と**EIP712**のハッシュ計算により、従来の`transfer`関数と比較してガスコストが増加します。
実装を最適化し、必要に応じてガススポンサーシップを活用してコストを相殺することが推奨されます。

## 参考実装

#### ERC-KeyHash721の実装

以下は、**ERC-KeyHash721**の参考実装の全文です。
OpenZeppelinの`ECDSA`と`EIP712`を使用しています。

```javascript:KeyHashERC721.sol
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

contract KeyHashERC721 is EIP712 {
    using ECDSA for bytes32;

    string public name;
    string public symbol;
    mapping(uint256 => bytes32) private _tokenKeyHashes;
    mapping(uint256 => bool) private _destroyedTokens;
    uint256 public totalSupply;
    mapping(bytes32 => uint256) private _keyNonces;

    event KeyHashTransfer721(uint256 indexed tokenId, bytes32 indexed fromKeyHash, bytes32 indexed toKeyHash);
    event KeyHashBurn721(uint256 indexed tokenId, bytes32 indexed ownerKeyHash);

    bytes32 private constant TRANSFER_TYPEHASH = keccak256(
        "Transfer(uint256 tokenId,bytes32 toKeyHash,uint256 nonce,uint256 deadline)"
    );
    bytes32 private constant DESTROY_TYPEHASH = keccak256(
        "Destroy(uint256 tokenId,uint256 nonce,uint256 deadline)"
    );

    constructor(string memory _name, string memory _symbol)
        EIP712("KeyHashERC721", "1")
    {
        name = _name;
        symbol = _symbol;
    }

    function ownerOf(uint256 tokenId) external view returns (bytes32) {
        require(_tokenKeyHashes[tokenId] != 0 && !_destroyedTokens[tokenId], "Token does not exist");
        return _tokenKeyHashes[tokenId];
    }

    function tokenURI(uint256) external pure returns (string memory) { return ""; }

    function transfer(
        uint256 tokenId,
        bytes32 toKeyHash,
        bytes memory key,
        bytes memory signature,
        uint256 deadline
    ) external {
        require(toKeyHash != bytes32(0), "Invalid recipient hash");
        require(_tokenKeyHashes[tokenId] != 0 && !_destroyedTokens[tokenId], "Token does not exist");
        require(block.timestamp <= deadline, "Signature expired");
        require(key.length == 65 && key[0] == 0x04, "BAD_KEY_FMT");
        bytes32 currentKeyHash = _tokenKeyHashes[tokenId];
        require(keccak256(key) == currentKeyHash, "BAD_KEYHASH");

        uint256 nonce = _keyNonces[currentKeyHash];
        bytes32 structHash = keccak256(abi.encode(
            TRANSFER_TYPEHASH,
            tokenId,
            toKeyHash,
            nonce,
            deadline
        ));
        bytes32 digest = _hashTypedDataV4(structHash);
        address signer = digest.recover(signature);
        address expectedAddress = _addressFromUncompressedKey(key);
        require(signer == expectedAddress, "Invalid signature");
        _keyNonces[currentKeyHash] = nonce + 1;

        _tokenKeyHashes[tokenId] = toKeyHash;
        emit KeyHashTransfer721(tokenId, currentKeyHash, toKeyHash);
    }

    function getNonce(bytes32 keyHash) external view returns (uint256) {
        return _keyNonces[keyHash];
    }

    function _addressFromUncompressedKey(bytes memory key) internal pure returns (address) {
        require(key.length == 65 && key[0] == 0x04, "BAD_KEY_FMT");
        bytes32 x;
        bytes32 y;
        assembly {
            x := mload(add(key, 0x21))
            y := mload(add(key, 0x41))
        }
        bytes32 h = keccak256(abi.encodePacked(x, y));
        return address(uint160(uint256(h)));
    }
}
```

`_addressFromUncompressedKey`関数は、65バイトの非圧縮公開鍵からEthereumアドレスを導出するヘルパー関数です。
先頭の`0x04`プレフィックスを除いた64バイト（X座標 + Y座標）の`keccak256`ハッシュの下位20バイトがアドレスになります。
これは、Ethereumが通常の外部所有アカウント（EOA）のアドレスを導出するのと同じ方法です。

`transfer`関数では、まず公開鍵のフォーマット検証（65バイト、先頭`0x04`）を行い、次に`keccak256(key)`が現在の所有者の`keyHash`と一致するか確認します。
その後、**EIP712**の構造化データからダイジェストを計算し、`ecrecover`で復元したアドレスが公開鍵から導出されるアドレスと一致するか検証します。
検証に成功したらnonceをインクリメントし、所有権を新しい`keyHash`に移します。

#### ERC-KeyHash20の実装

以下は、**ERC-KeyHash20**の参考実装の全文です。

```javascript:KeyHashERC20.sol
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

contract KeyHashERC20 is EIP712 {
    using ECDSA for bytes32;

    string public name;
    string public symbol;
    uint8 public decimals;
    mapping(bytes32 => uint256) public balanceOf;
    uint256 public totalSupply;
    mapping(bytes32 => uint256) private _keyNonces;

    event KeyHashTransfer20(bytes32 indexed fromKeyHash, bytes32 indexed toKeyHash, uint256 amount);

    bytes32 private constant TRANSFER_TYPEHASH = keccak256(
        "Transfer(bytes32 fromKeyHash,bytes32 toKeyHash,uint256 amount,uint256 nonce,uint256 deadline,bytes32 leftKeyHash)"
    );

    constructor(string memory _name, string memory _symbol)
        EIP712("KeyHashERC20", "1")
    {
        name = _name;
        symbol = _symbol;
        decimals = 18;
    }

    function transfer(
        bytes32 fromKeyHash,
        bytes32 toKeyHash,
        uint256 amount,
        bytes memory key,
        bytes memory signature,
        uint256 deadline,
        bytes32 leftKeyHash
    ) external {
        require(balanceOf[fromKeyHash] >= amount, "Insufficient balance");
        require(toKeyHash != bytes32(0), "Invalid recipient hash");
        require(leftKeyHash != bytes32(0), "Invalid leftKeyHash");
        require(leftKeyHash != toKeyHash, "LEFT_EQ_TO");
        require(leftKeyHash != fromKeyHash, "LEFT_EQ_FROM");
        require(block.timestamp <= deadline, "Signature expired");
        require(key.length == 65 && key[0] == 0x04, "BAD_KEY_FMT");
        require(keccak256(key) == fromKeyHash, "BAD_KEYHASH");

        uint256 nonce = _keyNonces[fromKeyHash];
        bytes32 structHash = keccak256(abi.encode(
            TRANSFER_TYPEHASH,
            fromKeyHash,
            toKeyHash,
            amount,
            nonce,
            deadline,
            leftKeyHash
        ));
        bytes32 digest = _hashTypedDataV4(structHash);
        address signer = digest.recover(signature);
        address expectedAddress = _addressFromUncompressedKey(key);
        require(signer == expectedAddress, "Invalid signature");
        _keyNonces[fromKeyHash] = nonce + 1;

        uint256 remaining = balanceOf[fromKeyHash] - amount;
        balanceOf[fromKeyHash] = 0;
        balanceOf[toKeyHash] += amount;
        balanceOf[leftKeyHash] += remaining;
        emit KeyHashTransfer20(fromKeyHash, toKeyHash, amount);
    }

    function getNonce(bytes32 keyHash) external view returns (uint256) {
        return _keyNonces[keyHash];
    }

    function _addressFromUncompressedKey(bytes memory key) internal pure returns (address) {
        require(key.length == 65 && key[0] == 0x04, "BAD_KEY_FMT");
        bytes32 x;
        bytes32 y;
        assembly {
            x := mload(add(key, 0x21))
            y := mload(add(key, 0x41))
        }
        bytes32 h = keccak256(abi.encodePacked(x, y));
        return address(uint160(uint256(h)));
    }
}
```

**ERC-KeyHash20**の`transfer`関数で特徴的なのは、残高の処理方法です。
`fromKeyHash`の残高はすべてゼロにリセットされ、`amount`分が`toKeyHash`に、残りが`leftKeyHash`に割り当てられます。
これにより、`fromKeyHash`は使い捨てとなり、同じ公開鍵の再利用を構造的に防止しています。

`leftKeyHash`は`toKeyHash`とも`fromKeyHash`とも異なる値でなければなりません。
この「**strict mode**」の制約により、おつりを元のアカウントや送り先に統合することができず、毎回新しい鍵ペアを使用することが強制されます。

## 引用

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

https://ethereum-magicians.org/t/key-based-tokens/24422

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

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

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

## 最後に

今回は「**ERC7962**によるアドレスの代わりに鍵ハッシュでトークンを管理するKey Hash Based Tokens」についてまとめてきました！
いかがだったでしょうか？

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

https://twitter.com/cardene777

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

https://zenn.dev/heku

https://cardene.substack.com/

https://mirror.xyz/0xcE77b9fCd390847627c84359fC1Bc02fC78f0e58

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