はじめに

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

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

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

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

概要

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

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

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

従来のトークンでは、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バイト)が続く形式です。

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の残高はゼロになります。

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ステップで行われます。

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

  • プライバシー 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管理と同様です。

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

メッセージ改ざん

リレイヤーが署名を傍受し、転送先やトークン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

Signature Malleability

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

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

ガスコスト

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

参考実装

ERC-KeyHash721の実装

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

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の参考実装の全文です。

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」の制約により、おつりを元のアカウントや送り先に統合することができず、毎回新しい鍵ペアを使用することが強制されます。

引用

最後に

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

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

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