はじめに

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

今回は、外部サービスの利用者がオンチェーン操作なしで受け取り用アドレスを持ち、後に正当な署名でアカウントを取得できる仕組みを提案しているERC6981についてまとめていきます!

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

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

概要

ERC6981は、外部サービスのユーザーに対して「後から取り出せる形でのEthereumアドレス」を紐づけられる仕組みを提案しています。 ここでいう「取り出せる」とは、ユーザー自身が後からそのアドレスを自分のウォレットとして確保できるという意味です。

サービス側は、ユーザーごとに署名済みメッセージと一意のソルト値(アドレス生成を特定するための追加データ)を提供します。 ユーザーはそれらを用いて、レジストリコントラクトを経由し、create2 と呼ばれるコントラクトアドレスの生成方式を使用して、決定的に同じアドレスへスマートコントラクトウォレットをデプロイできます。

「決定的」というのは、同じ署名・同じソルトであれば、いつ誰が実行しても同じアドレスにデプロイされることを意味します。

create2 については以下を参考にしてください。

この仕組みにより、ユーザーは何もオンチェーン操作をしなくても、あらかじめ自分用に用意されたアドレスを持つことができます。 そして、必要になったタイミングでそのアドレスのスマートコントラクトウォレットを実際にデプロイして完全に自分で管理できます。 外部サービスはユーザーの資産の管理を担わずに済み、ユーザーは自身の資産を事前に受け取れるという利点があります。

動機

外部サービスがユーザーにブロックチェーン上の資産を持たせたい場合、従来は以下のような形が一般的でした。

  • サービスが秘密鍵を持つ外部所有アカウント(EOA)をユーザーごとに作る
  • スマートコントラクトウォレットをユーザー名義でデプロイし、その管理情報をサービスのデータベースに保存する
  • 多数のユーザーの資産をまとめる「オムニバス形式」のコントラクトをサービス側が管理する

これらの方法では、サービス側が秘密鍵や資産管理を担うため、情報漏えい・内部不正・鍵管理の失敗など、セキュリティ上の不安が残っていました。 また、サービスが資産を「預かる」という構造は、ユーザー主権という観点でも好ましくありません。

ERC6981は、こうした従来の問題点を避ける新しい枠組みを提示しています。 ユーザーはサービスから提供された署名付きデータを使い、自分自身でスマートコントラクトウォレットを必要なときにデプロイできます。 そのため、サービスが秘密鍵を持つ必要がなく、ユーザー自身がアドレスの最終的な支配者になります。

さらに、この仕組みでは、ユーザーがまだブロックチェーンに触れていなくても、資産を送る先のアドレスを先に確保できます。 ユーザーはそのアドレスに送られた資産を、後からウォレットをデプロイすることで受け取れます。 これにより、ユーザーはブロックチェーン操作を一切行わずに、オンチェーン上のアイデンティティ(同一人物として認識できるアドレス)を持つことができ、そこへ資産を集めておくことも可能になります。

オンチェーンでの操作を課せないため、ブロックチェーンに馴染みのないユーザーでも、サービスを使っているうちに自然とオンチェーン上のアドレスをもてる点が大きなメリットです。 気が向いたタイミングでウォレットを「引き出す」ようにアドレスを自分のものにできます。

仕様

概要

ERC6981は、外部サービスのユーザーに紐づく「予約された所有アカウント」を作るための仕組みを定義しています。 この仕組みは、まだデプロイされていない状態でもユーザーがあらかじめ受け取り用アドレスを持てるようにするものです。 サービス側がユーザーの識別情報とソルト(アドレス生成に使用する一意の値)を結びつけ、そのソルトをもとにレジストリコントラクトへ問い合わせることで、同じ入力から必ず同じアドレスを算出できます。 ユーザーはサービスから署名付きメッセージを受け取り、それを使って後から自身のアカウントをデプロイ・取得できます。

アカウントは以下の要素で構成されます。

  • アカウントレジストリ ソルトを基準に決定的なアドレスを導き出し、署名検証後にユーザーがアカウントを取得できる仕組みを提供するコントラクト。
  • アカウントインスタンス ユーザーが実際に所有するコントラクトウォレットで、決定的なアドレスにデプロイされる。 デプロイ前でもそのアドレス宛に資産を受け取れる。

サービス側はユーザー識別情報とソルトの紐付けを管理し、ユーザーが自身の認証を通過した際に署名を提供します。 ユーザーは署名とメッセージを claimAccount に渡してアカウントインスタンスを取得します。

Account Registry

IAccountRegistry.sol
interface IAccountRegistry {
    /**
     * @dev Registry instances emit the AccountCreated event upon successful account creation
     */
    event AccountCreated(address account, address accountImplementation, uint256 salt);

    /**
     * @dev Registry instances emit the AccountClaimed event upon successful claim of account by owner
     */
    event AccountClaimed(address account, address owner);

    /**
     * @dev Creates a smart contract account.
     *
     * If account has already been created, returns the account address without calling create2.
     *
     * @param salt       - The identifying salt for which the user wishes to deploy an Account Instance
     *
     * Emits AccountCreated event
     * @return the address for which the Account Instance was created
     */
    function createAccount(uint256 salt) external returns (address);

    /**
     * @dev Allows an owner to claim a smart contract account created by this registry.
     *
     * If the account has not already been created, the account will be created first using `createAccount`
     *
     * @param owner      - The initial owner of the new Account Instance
     * @param salt       - The identifying salt for which the user wishes to deploy an Account Instance
     * @param expiration - If expiration > 0, represents expiration time for the signature.  Otherwise
     *                     signature does not expire.
     * @param message    - The keccak256 message which validates the owner, salt, expiration
     * @param signature  - The signature which validates the owner, salt, expiration
     *
     * Emits AccountClaimed event
     * @return the address of the claimed Account Instance
     */
    function claimAccount(
        address owner,
        uint256 salt,
        uint256 expiration,
        bytes32 message,
        bytes calldata signature
    ) external returns (address);

    /**
     * @dev Returns the computed address of a smart contract account for a given identifying salt
     *
     * @return the computed address of the account
     */
    function account(uint256 salt) external view returns (address);

    /**
     * @dev Fallback signature verification for unclaimed accounts
     */
    function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4);
}

イベント

AccountCreated
javascript
event AccountCreated(address account, address accountImplementation, uint256 salt);

アカウントが新しく作成された時に発行されるイベント。 このイベントは、createAccount によってアカウントインスタンスが生成された時に発行されます。 対象となるアカウントアドレス、利用される実装コントラクト、生成に使われたソルトが記録されます。

パラメータ

  • account
    • 作成されたアカウントインスタンスのアドレス。
  • accountImplementation
    • アカウントインスタンスが参照する実装コントラクトのアドレス。
  • salt
    • 決定的なアドレス計算に使用したソルト。
AccountClaimed
javascript
event AccountClaimed(address account, address owner);

アカウントがユーザーにより取得された時に発行されるイベント。 claimAccount が成功し、アカウント所有者がレジストリからユーザーへ完全に移った時に発行されます。 どのアカウントが誰に割り当てられたかが記録されます。

パラメータ

  • account
    • 取得されたアカウントインスタンスのアドレス。
  • owner
    • 新しい所有者のアドレス。

関数

createAccount
javascript
function createAccount(uint256 salt) external returns (address);

指定したソルトからアカウントインスタンスをデプロイする関数。 この関数はソルトを基準に決定的なアドレスへアカウントインスタンスをデプロイします。 デプロイ済みであれば新たに実行せず、既存アドレスをそのまま返します。 インスタンスはERC1167の最小プロキシを使用して生成され、初期の所有者はレジストリになります。 成功時には AccountCreated が発行されます。

引数

  • salt
    • アカウント生成に使う一意のソルト。

戻り値

  • address
    • 生成されたアカウントインスタンスのアドレス。
claimAccount
javascript
function claimAccount(
    address owner,
    uint256 salt,
    uint256 expiration,
    bytes32 message,
    bytes calldata signature
) external returns (address);

ユーザーがアカウントインスタンスの所有権を取得するための関数。 指定されたソルトに対応するアカウントが未作成であれば自動的にデプロイします。 続いて、署名が正しいかどうかを検証します。 署名はユーザー・ソルト・有効期限を基に作られており、EOAの場合はECDSA、コントラクトウォレットの場合はERC1271により検証されます。 有効期限が0でない場合、現在時刻が期限以内である必要があります。

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

検証が成功した場合、レジストリはそのアカウントへの権限を完全に放棄し、setOwner を使って所有権を指定したユーザーへ移します。 成功後に AccountClaimed が発行されます。

引数

  • owner
    • アカウントの新しい所有者。
  • salt
    • 取得するアカウントの識別用ソルト。
  • expiration
    • 署名の有効期限。
    • 0の場合は期限なし。
  • message
    • owner / salt / expiration をまとめてハッシュ化したメッセージ。
  • signature
    • 検証に使用する署名。

戻り値

  • address
    • 所有権を取得したアカウントインスタンスのアドレス。
account
javascript
function account(uint256 salt) external view returns (address);

指定されたソルトに対応するアカウントアドレスを返す関数。 ソルトを使って計算される決定的アドレスを返します。 実際にデプロイされていなくても、アドレスは計算できます。

引数

  • salt
    • アカウントの識別に使うソルト。

戻り値

  • address
    • 算出されたアカウントアドレス。
isValidSignature
javascript
function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4);

未取得状態のアカウント向けの署名検証を行う関数。 アカウントが未取得の場合、この関数がフォールバック検証を行います。 署名は「オリジナルのメッセージハッシュ」と「アカウントアドレス」を組み合わせて作られた複合ハッシュに基づいて生成されます。 この関数は複合ハッシュを再生成し、レジストリの署名者に対して署名が正しいかどうかを確認します。

引数

  • hash
    • 元のメッセージハッシュ。
  • signature
    • 検証対象の署名。

戻り値

  • bytes4
    • 署名が有効かどうかを示す戻り値(ERC1271に準拠)。

Account Instance

IAccount.sol
interface IAccount is IERC1271 {
    /**
     * @dev Sets the owner of the Account Instance.
     *
     * Only callable by the current owner of the instance, or by the registry if the Account
     * Instance has not yet been claimed.
     *
     * @param owner      - The new owner of the Account Instance
     */
    function setOwner(address owner) external;
}

関数

setOwner
javascript
function setOwner(address owner) external;

アカウントインスタンスの所有者を設定する関数。 この関数は、現在の所有者またはレジストリ(未取得状態の場合)のみが呼び出せます。 所有者が更新されることで、アカウントの操作権限が新しいアドレスに移り変わります。

引数

  • owner
    • 新しい所有者のアドレス。

Account Instanceの署名

ERC6981のアカウントインスタンスはERC1271を実装しており、アカウントの状態によって署名の扱いが変化します。

状態ごとの動作は以下です。

状態 説明
デプロイ済・取得済み 所有者自身が署名し、アカウントはERC1271に従って検証する。
デプロイ済・未取得 レジストリ署名者が複合ハッシュを使って署名し、アカウントはレジストリの isValidSignature に確認を委任する。
未デプロイ レジストリ署名者が複合ハッシュを署名し、署名は ERC6492 形式としてラップされる。

署名検証は最終的に ERC6492 の流れに従って行われます。

補足

サービスが所有するレジストリインスタンス

外部サービス向けに「共通のレジストリ」を用意するという考え方もありますが、ERC6981はサービスごとに独自のアカウントレジストリを持てるようにしています。 これは、各サービスが独自の管理方法・認証方式・利用ポリシーに合わせてレジストリを運用できるようにするためです。

サービスごとにレジストリを持つことで、署名検証やアカウント作成の権限などを自由にコントロールできます。 サービスが利用者向けに安全な運用を行うためには、自分たちのルールでレジストリを管理できることが重要になるためです。

また、ERC6981には参考実装として「Registry Factory」が用意されています。 これを使うことで、外部サービスは自分専用のアカウントレジストリを簡単にデプロイできます。 Registry Factoryには以下の特徴があります。

  • アカウントインスタンスが参照する実装コントラクトを変更できない形で固定する仕組み。
  • claimAccount の検証において、EOAであればECDSA、スマートコントラクトであればERC1271での署名確認を行う仕組み。
  • レジストリをデプロイしたサービスが、署名検証に使うアドレスを後から変更できる仕組み。

これにより、サービス側は初めての利用者でも安心して使える署名検証基盤を自前で持つことができます。

アカウントレジストリとアカウント実装の結びつき

アカウントインスタンスは ERC1167(最小プロキシ)を利用して作られます。 この仕組みではプロキシが参照する「実装コントラクトのアドレス」がインスタンスのアドレス計算にも影響します。 つまり、実装アドレスが変わると同じソルトでもアドレスが変わってしまいます。

そこで ERC6981ではレジストリごとに「1つの実装コントラクトを固定する」ようになっています。 これによって、ソルトとアドレスの対応が一貫し、サービス利用者は同じソルトで常に同じアドレスを使えます。

サービスは信頼できる実装コントラクトをレジストリに紐づけてデプロイすることで、利用者からの信頼も得やすくなります。 利用者にとっては「どんな実装の上に自分のアカウントが構築されるか」が重要になるため、この仕組みは透明性と信頼性の向上に役立ちます。

なお、プロキシ先の実装コントラクト自体はアップグレード可能に設計できるため、ユーザーはレジストリが使用している実装に縛られるわけではありません。 将来的に別の仕組みへ移行したい場合にも柔軟に対応できます。

createAccount と claimAccount を分けている理由

ERC6981ではアカウントを作る操作(createAccount)と、所有権を取得する操作(claimAccount)が意図的に分けられています。 これにより、サービスはユーザーに「アカウントがデプロイされていない状態でも使える署名(ERC6492形式)」を提供できます。

この分離によって重要な利点が生まれます。

  • ユーザーはまだアカウントがデプロイされていなくても「存在する前提で署名を生成・検証」できる。
  • サービス側はユーザーがオンチェーン操作をする前から、認証に使えるメッセージを発行できる。

つまり、ユーザーは実際にアカウントを使いたい瞬間が来るまでデプロイを遅らせることができ、利用者の負担が大きく減ります。 先に署名だけを使って認証を行い、後から必要なときにアカウントをデプロイするという柔軟な利用フローが可能になります。

参考実装

Account Registry Factory

AccountRegistryFactory.sol
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.13;

/// @author: manifold.xyz

import {Create2} from "openzeppelin/utils/Create2.sol";

import {Address} from "../../lib/Address.sol";
import {ERC1167ProxyBytecode} from "../../lib/ERC1167ProxyBytecode.sol";
import {IAccountRegistryFactory} from "./IAccountRegistryFactory.sol";

contract AccountRegistryFactory is IAccountRegistryFactory {
    using Address for address;

    error InitializationFailed();

    address private immutable registryImplementation = 0x076B08EDE2B28fab0c1886F029cD6d02C8fF0E94;

    function createRegistry(
        uint96 index,
        address accountImplementation,
        bytes calldata accountInitData
    ) external returns (address) {
        bytes32 salt = _getSalt(msg.sender, index);
        bytes memory code = ERC1167ProxyBytecode.createCode(registryImplementation);
        address _registry = Create2.computeAddress(salt, keccak256(code));

        if (_registry.isDeployed()) return _registry;

        _registry = Create2.deploy(0, salt, code);

        (bool success, ) = _registry.call(
            abi.encodeWithSignature(
                "initialize(address,address,bytes)",
                msg.sender,
                accountImplementation,
                accountInitData
            )
        );
        if (!success) revert InitializationFailed();

        emit AccountRegistryCreated(_registry, accountImplementation, index);

        return _registry;
    }

    function registry(address deployer, uint96 index) external view override returns (address) {
        bytes32 salt = _getSalt(deployer, index);
        bytes memory code = ERC1167ProxyBytecode.createCode(registryImplementation);
        return Create2.computeAddress(salt, keccak256(code));
    }

    function _getSalt(address deployer, uint96 index) private pure returns (bytes32) {
        return bytes32(abi.encodePacked(deployer, index));
    }
}

Account Registry

AccountRegistryImplementation.sol
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.13;

/// @author: manifold.xyz

import {Create2} from "openzeppelin/utils/Create2.sol";
import {ECDSA} from "openzeppelin/utils/cryptography/ECDSA.sol";
import {Ownable} from "openzeppelin/access/Ownable.sol";
import {Initializable} from "openzeppelin/proxy/utils/Initializable.sol";
import {IERC1271} from "openzeppelin/interfaces/IERC1271.sol";
import {SignatureChecker} from "openzeppelin/utils/cryptography/SignatureChecker.sol";

import {Address} from "../../lib/Address.sol";
import {IAccountRegistry} from "../../interfaces/IAccountRegistry.sol";
import {ERC1167ProxyBytecode} from "../../lib/ERC1167ProxyBytecode.sol";

contract AccountRegistryImplementation is Ownable, Initializable, IAccountRegistry {
    using Address for address;
    using ECDSA for bytes32;

    struct Signer {
        address account;
        bool isContract;
    }

    error InitializationFailed();
    error ClaimFailed();
    error Unauthorized();

    address public accountImplementation;
    bytes public accountInitData;
    Signer public signer;

    constructor() {
        _disableInitializers();
    }

    function initialize(
        address owner,
        address accountImplementation_,
        bytes calldata accountInitData_
    ) external initializer {
        _transferOwnership(owner);
        accountImplementation = accountImplementation_;
        accountInitData = accountInitData_;
    }

    /**
     * @dev See {IAccountRegistry-createAccount}
     */
    function createAccount(uint256 salt) external override returns (address) {
        bytes memory code = ERC1167ProxyBytecode.createCode(accountImplementation);
        address _account = Create2.computeAddress(bytes32(salt), keccak256(code));

        if (_account.isDeployed()) return _account;

        _account = Create2.deploy(0, bytes32(salt), code);

        (bool success, ) = _account.call(accountInitData);
        if (!success) revert InitializationFailed();

        emit AccountCreated(_account, accountImplementation, salt);

        return _account;
    }

    /**
     * @dev See {IAccountRegistry-claimAccount}
     */
    function claimAccount(
        address owner,
        uint256 salt,
        uint256 expiration,
        bytes32 message,
        bytes calldata signature
    ) external override returns (address) {
        _verify(owner, salt, expiration, message, signature);
        address _account = this.createAccount(salt);

        (bool success, ) = _account.call(
            abi.encodeWithSignature("transferOwnership(address)", owner)
        );
        if (!success) revert ClaimFailed();

        emit AccountClaimed(_account, owner);
        return _account;
    }

    /**
     * @dev See {IAccountRegistry-account}
     */
    function account(uint256 salt) external view override returns (address) {
        bytes memory code = ERC1167ProxyBytecode.createCode(accountImplementation);
        return Create2.computeAddress(bytes32(salt), keccak256(code));
    }

    /**
     * @dev See {IAccountRegistry-isValidSignature}
     */
    function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4) {
        bytes32 expectedHash = keccak256(abi.encodePacked(hash, msg.sender));
        bool isValid = SignatureChecker.isValidSignatureNow(
            signer.account,
            expectedHash,
            signature
        );
        if (isValid) {
            return IERC1271.isValidSignature.selector;
        }

        return "";
    }

    function updateSigner(address newSigner) external onlyOwner {
        uint32 signerSize;
        assembly {
            signerSize := extcodesize(newSigner)
        }
        signer.account = newSigner;
        signer.isContract = signerSize > 0;
    }

    function _verify(
        address owner,
        uint256 salt,
        uint256 expiration,
        bytes32 message,
        bytes calldata signature
    ) internal view {
        address signatureAccount;

        if (signer.isContract) {
            if (!SignatureChecker.isValidSignatureNow(signer.account, message, signature))
                revert Unauthorized();
        } else {
            signatureAccount = message.recover(signature);
        }

        bytes32 expectedMessage = keccak256(
            abi.encodePacked("\x19Ethereum Signed Message:\n84", owner, salt, expiration)
        );

        if (
            message != expectedMessage ||
            (!signer.isContract && signatureAccount != signer.account) ||
            (expiration != 0 && expiration < block.timestamp)
        ) revert Unauthorized();
    }
}

Example Account Implementation

ERC1967AccountImplementation.sol
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.13;

/// @author: manifold.xyz

import {IERC1271} from "openzeppelin/interfaces/IERC1271.sol";
import {SignatureChecker} from "openzeppelin/utils/cryptography/SignatureChecker.sol";
import {IERC165} from "openzeppelin/utils/introspection/IERC165.sol";
import {ERC165Checker} from "openzeppelin/utils/introspection/ERC165Checker.sol";
import {IERC721} from "openzeppelin/token/ERC721/IERC721.sol";
import {IERC721Receiver} from "openzeppelin/token/ERC721/IERC721Receiver.sol";
import {IERC1155Receiver} from "openzeppelin/token/ERC1155/IERC1155Receiver.sol";
import {Initializable} from "openzeppelin/proxy/utils/Initializable.sol";
import {Ownable} from "openzeppelin/access/Ownable.sol";
import {IERC1967Account} from "./IERC1967Account.sol";

import {IAccount} from "../../interfaces/IAccount.sol";

/**
 * @title ERC1967AccountImplementation
 * @notice A lightweight, upgradeable smart contract wallet implementation
 */
contract ERC1967AccountImplementation is
    IAccount,
    IERC165,
    IERC721Receiver,
    IERC1155Receiver,
    IERC1967Account,
    Initializable,
    Ownable
{
    address public registry;

    constructor() {
        _disableInitializers();
    }

    function initialize() external initializer {
        registry = msg.sender;
        _transferOwnership(registry);
    }

    function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
        return (interfaceId == type(IAccount).interfaceId ||
            interfaceId == type(IERC1967Account).interfaceId ||
            interfaceId == type(IERC1155Receiver).interfaceId ||
            interfaceId == type(IERC721Receiver).interfaceId ||
            interfaceId == type(IERC165).interfaceId);
    }

    function onERC721Received(
        address,
        address,
        uint256,
        bytes memory
    ) public pure returns (bytes4) {
        return this.onERC721Received.selector;
    }

    function onERC1155Received(
        address,
        address,
        uint256,
        uint256,
        bytes memory
    ) public pure returns (bytes4) {
        return this.onERC1155Received.selector;
    }

    function onERC1155BatchReceived(
        address,
        address,
        uint256[] memory,
        uint256[] memory,
        bytes memory
    ) public pure returns (bytes4) {
        return this.onERC1155BatchReceived.selector;
    }

    /**
     * @dev {See IERC1967Account-executeCall}
     */
    function executeCall(
        address _target,
        uint256 _value,
        bytes calldata _data
    ) external payable override onlyOwner returns (bytes memory _result) {
        bool success;
        // solhint-disable-next-line avoid-low-level-calls
        (success, _result) = _target.call{value: _value}(_data);
        require(success, string(_result));
        emit TransactionExecuted(_target, _value, _data);
        return _result;
    }

    /**
     * @dev {See IAccount-setOwner}
     */
    function setOwner(address _owner) external override onlyOwner {
        _transferOwnership(_owner);
    }

    receive() external payable {}

    function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4) {
        if (owner() == registry) {
            return IERC1271(registry).isValidSignature(hash, signature);
        }

        bool isValid = SignatureChecker.isValidSignatureNow(owner(), hash, signature);
        if (isValid) {
            return IERC1271.isValidSignature.selector;
        }

        return "";
    }
}

セキュリティ

フロントランニング(Front-running)

フロントランニングとは、あるトランザクションがまだブロックに取り込まれていない段階で、第三者がその内容を見て先回りで似たトランザクションを送る行為のことです。 Ethereumではトランザクションは一度Mempoolに出回るため、悪意あるアクターがそれを監視して優先度の高いトランザクションを送ることで、先に実行される可能性があります。

ERC6981では、createAccount を使ってアカウントレジストリ経由で予約済みアカウントをデプロイする時に、フロントランニングが理論上は起こり得ます。 具体的には、本来のユーザーが createAccount を呼び出したトランザクションを、第三者が見つけて先に同じソルトで createAccount を実行するケースが考えられます。

しかし、ここで重要なのは「フロントランニングが成功しても、攻撃者がアカウントを奪えないように設計されている」という点です。

悪意あるアクターが本当に利益を得ようとするなら、デプロイされるアカウントの所有者(owner)を自分に変えようとするはずです。 そのためにトランザクションの calldata 内の owner 相当の情報を改ざんして送ろうとしますが、ERC6981のアカウントレジストリは、claimAccount の時に署名検証を行います。 署名は「owner・salt・有効期限」などを含むメッセージに対して作成されているので、もし攻撃者が owner をすり替えた場合、その署名は一致せず、レジストリは署名が不正だと判断してトランザクションをリバートします。

つまり、攻撃者が owner を改ざんしてもトランザクションは失敗し、アカウントの所有権を奪うことはできません。

一方で、攻撃者が calldata を改ざんせず、そのまま createAccount だけを先に実行した場合はどうなるかというと、そのトランザクションは成功しますが、「本来のユーザーと同じ内容のアカウントインスタンスが同じアドレスにデプロイされる」だけです。 アドレスの計算は create2 とソルト、実装アドレスなどに基づいて決まるため、誰が createAccount を呼んだかに関係なく、同じ入力なら同じアドレスにデプロイされます。

この場合、攻撃者は単に先にガスを払ってアカウントをデプロイしただけであり、所有権は依然として本来のユーザーに紐づいています。 その後、正しい署名を持つユーザーが claimAccount を呼び出せば、アカウントレジストリは署名を検証し、所有者をユーザーに設定します。 レジストリは署名が有効であることを確認したうえで、自身の権限を放棄し、アカウントの setOwner を通じてユーザーへ所有権を渡します。

この設計により、フロントランニングに成功したとしても、攻撃者はアカウントのアドレスや所有権を乗っ取ることができず、実質的な被害は発生しません。 最悪でも「攻撃者が代わりにアカウントのデプロイ費用を払ってくれる」だけの結果になります。

最後に

今回は「外部サービスの利用者がオンチェーン操作なしで受け取り用アドレスを持ち、後に正当な署名でアカウントを取得できる仕組みを提案しているERC6981」についてまとめてきました! いかがだったでしょうか?

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

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