Skip to content
On this page

L2-12: NFT Permissions(NFT権限管理)

1. 問題

NFTは単なるデジタルコレクティブルではありません — オンチェーンの「会員証」「通行証」「能力証明書」です。DAppがユーザーの保有するNFTに基づいて異なるレベルのアクセス権限を付与したい場合、中核となる問題は「呼び出し元が特定のERC-721 NFTを保有しているか」をSolidityコントラクトで確実に検証する方法です。これは一見シンプルに見えます — balanceOf(msg.sender) を呼び出すだけ — しかし実際に伴う詳細はそれよりもはるかに複雑です。

第一に、権限は二元的な有/無ではありません。異なるリソースには異なる最低保有量が必要になる場合があります。例えば、1つのNFTを保有していれば一般エリアにアクセスでき、5つ保有していればVIPエリアにアクセスできます。第二に、権限は動的に管理される必要があります。管理者はコントラクトを再デプロイすることなく、新しいリソースを作成し権限要件を変更できるべきです。第三に、権限システムはコントラクトの修飾子(modifier)と統合され、関数の入口で透過的にチェックを行う必要があります。最後に、権限システムのエラーメッセージは、拒否されたユーザーが何が不足しているのか理解できるよう十分に明確でなければなりません。

本チャレンジ(src/level2/NFTPermissions.sol 参照)では、ERC-721の残高チェックを通じてNFT保有量に基づく権限検証を行う、完全なトークンゲートアクセス制御システムの実装が求められます。

2. 理由

トークンゲーティング(Token-gating)は、現代のWeb3アプリケーションの基盤インフラの一つです。トークン化コミュニティ(Friends With Benefitsなど)からオンチェーンゲーム(「Sword NFTを保有していないとダンジョンに入れない」など)、コンテンツプラットフォーム(「Genesis NFTを保有していないとプレミアム記事を読めない」など)に至るまで、NFT所有権に基づく権限制御は至る所で使われています。

技術面では、NFTPermissionsは「コントラクト間相互作用」を理解するための優れた演習です。あなたの権限コントラクトは外部のERC-721コントラクトを呼び出す必要があります。このクロスコントラクト呼び出しはAB問題を含みます。コントラクトAがコントラクトBの balanceOf を呼び出すと、Bは誤ったデータを返したり、大量のガスを消費したり、revertしたりする可能性があります。防御的プログラミングが必要です:明示的なERC-721インターフェース、ガス消費の制限(Solidityのview呼び出しはstaticcall下ではガスを消費しませんが制限はあります)、および外部コントラクトが存在しないエッジケースの処理です。

さらに重要なのは、NFTPermissionsが「修飾子を権限ミドルウェアとして」使用するデザインパターンを示していることです。権限ロジックはmodifierにカプセル化され、ビジネスロジックの関数本体はクリーンなまま保たれ、権限チェックは関数実行前に自動的に発動します。この関心の分離は、Solidityコントラクトアーキテクチャ設計の中核的実践です。

3. 解決策

NFTPermissions コントラクト(src/level2/NFTPermissions.sol 参照)はリソース+修飾子の二層アーキテクチャを採用しています:

solidity
contract NFTPermissions {
    struct Resource {
        IERC721 nftContract;      // チェック対象のNFTコントラクト
        uint256 requiredBalance;  // 最低保有量
        bool exists;              // リソースが存在するか
    }

    // リソースマッピング: resourceId → Resource 設定
    mapping(bytes32 => Resource) private _resources;

    // NFT保護された新しいリソースを作成
    function createResource(
        bytes32 resourceId,
        IERC721 nftContract,
        uint256 requiredBalance
    ) external {
        if (_resources[resourceId].exists) revert ResourceAlreadyExists(resourceId);

        _resources[resourceId] = Resource({
            nftContract: nftContract,
            requiredBalance: requiredBalance,
            exists: true
        });

        emit ResourceCreated(resourceId, address(nftContract), requiredBalance);
    }

    // コア権限チェック
    function checkBalanceAccess(address user, bytes32 resourceId)
        public view returns (bool hasAccess)
    {
        Resource storage resource = _resources[resourceId];
        if (!resource.exists) revert ResourceNotFound(resourceId);

        return resource.nftContract.balanceOf(user) >= resource.requiredBalance;
    }

    // 権限修飾子 — 関数入口で透過的にチェック
    modifier onlyNFTHolder(bytes32 _resourceId) {
        if (!checkBalanceAccess(msg.sender, _resourceId)) {
            revert UnauthorizedAccess(msg.sender, _resourceId);
        }
        _;
    }

    // 保護された機能 — 修飾子が自動的に権限チェックを実行
    function accessProtected(bytes32 resourceId)
        external onlyNFTHolder(resourceId) returns (bool success)
    {
        emit ResourceAccessed(resourceId, msg.sender);
        return true;
    }
}

権限検証の三層モデル

第1層: 誰がアクセスするか?       → msg.sender
第2層: 何にアクセスするか?       → resourceId → Resource(nftContract, requiredBalance)
第3層: 条件を満たすか?           → nftContract.balanceOf(msg.sender) >= requiredBalance

リソースIDの設計

リソースIDとして uint256string ではなく bytes32 を使用することには、複数の利点があります:

  • keccak256("VIP_ROOM") は決定的なbytes32を生成し、クロスコントラクト参照に便利
  • bytes32はストレージ内で完全な32バイトスロットを占有し、ガス計算がシンプル
  • 事前計算可能(オフチェーンで生成してフロントエンドにハードコード可能)、コントラクトデプロイ後の戻り値に依存しない

4. 遭遇した落とし穴

  • 外部コントラクト呼び出しの無防備: resource.nftContract.balanceOf(user) は外部呼び出し — nftContract アドレスが実際のERC-721コントラクトでない(または悪意のあるコントラクトである)場合、revertしたり誤ったデータを返したりする
  • リソース上書き: リソース作成時に exists フラグをチェックしないと、既存のリソースが誤って上書きされる — 古い権限設定が失われる
  • resourceIdの衝突: 単純な文字列ハッシュをIDとして使用する場合、異なる管理者が誤って同じリソースIDを作成する可能性がある(両者が keccak256("VIP") を使用した場合など)
  • 残高のみチェック、特定のtoken IDをチェックしない: 一部のシナリオでは「任意のNFTを少なくとも1つ保有」ではなく「NFT #42を保有」が必要 — balanceOf ベースのチェックではこの要件を満たせない
  • NFT移転による権限変更: ユーザーがNFTを発行 → アクセス権獲得 → NFTを転出 → アクセス権が取り消されない。権限チェックはユーザーがトランザクションを開始した時点でのみ行われる(同期チェック)のであり、継続的監視(非同期取消)ではない

5. 落とし穴の原因

外部コントラクト呼び出しのリスクは、Solidityの「外部インターフェースを信頼する」という前提に起因します。IERC721(nftContract).balanceOf(user) を呼び出すとき、実際に実行されるのは対象アドレスのコードです — このアドレスは悪意のあるコントラクトであり、balanceOf 内で任意のコードを実行する可能性があります。checkBalanceAccessview 関数(staticcallコンテキスト、状態変更不可)ですが、悪意のあるコントラクトは以下のことが可能です:

  • 大量のガスを消費する(DoS攻撃)
  • 巧妙に作成されたが技術的には有効なデータを返す(常に requiredBalance 以上を返すなど)
  • 特定の条件下でrevertする(呼び出し元が権限チェックを通過できなくなる)

リソース上書きは「冪等性保護」の欠如という典型的な問題です。分散システムにおいて、リソース作成は冪等であるべきです — 同じリソースIDの重複作成は、何も操作しないか明示的にエラーとするべきです。exists をチェックしないということは、最後の書き込みが前のものを無言で上書きすることを意味し、権限が予期せず低下(または上昇)する可能性があります。

balanceOf のみに基づく権限モデルでは、「#99(一般)を1つ保有」と「#1(レジェンド)を1つ保有」を区別できません — 両者は balanceOf から見ると等価です。特定のトークンIDが必要なシナリオ(各トークンIDが特定の証明書を表すクレデンシャルNFTなど)では、ownerOf(tokenId) を使用するか、ERC-1155の balanceOf(account, id) を使用する必要があります。

6. 落とし穴の解決方法

外部コントラクト呼び出しに防御層を追加します。本番環境では、リソース作成時に対象コントラクトがERC-721インターフェースを実装しているかどうかを検証できます(ERC-165 supportsInterface 経由):

solidity
function createResource(bytes32 resourceId, IERC721 nftContract, uint256 requiredBalance) external {
    if (_resources[resourceId].exists) revert ResourceAlreadyExists(resourceId);

    // オプション:ERC-721互換性を検証(ERC-165チェック)
    // require(nftContract.supportsInterface(0x80ac58cd), "Not ERC721");

    _resources[resourceId] = Resource(nftContract, requiredBalance, true);
    emit ResourceCreated(resourceId, address(nftContract), requiredBalance);
}

リソース上書き問題に対しては、exists フラグを使用することが冪等性を実現する標準的な方法です:

solidity
if (_resources[resourceId].exists) revert ResourceAlreadyExists(resourceId);

これにより、各リソースIDは一度だけ作成できることが保証されます。本当に更新が必要な場合は、別途 updateResource 関数を提供し、追加の権限を要求すべきです。

特定のトークンIDのチェックについては、権限モデルを拡張して両方のモードをサポートできます:

solidity
struct Resource {
    IERC721 nftContract;
    uint256 requiredBalance;
    uint256 specificTokenId;   // 0 は特定IDチェックなしを意味
    bool exists;
}

function checkAccess(address user, bytes32 resourceId) public view returns (bool) {
    Resource storage r = _resources[resourceId];
    if (r.specificTokenId != 0) {
        // 特定のトークンIDを保有しているかチェック
        return r.nftContract.ownerOf(r.specificTokenId) == user;
    }
    // それ以外は残高をチェック
    return r.nftContract.balanceOf(user) >= r.requiredBalance;
}

NFT移転による権限変更については、accessProtected が呼び出される時点でリアルタイムチェックが行われます — これは純粋な同期権限モデルの自然な動作です。「継続的保有」要件が必要な場合は、保有時間記録と最低保有期間チェックを追加できます。ただし、オンチェーンタイムスタンプはブロックレベル(約12秒)の精度しかないことに注意が必要です。

7. 技術的要点

要点説明
トークンゲーティングパターンbalanceOf(user) >= requiredBalance で権限を判定
修飾子をミドルウェアとしてonlyNFTHolder(resourceId) が権限ロジックをカプセル化、ビジネスコードに侵入しない
リソースストレージmapping(bytes32 → Resource) — O(1) ルックアップ
ERC-721インターフェース依存IERC721 インターフェース経由で呼び出し、具体的な実装に依存しない
外部呼び出しリスク対象コントラクトが悪意/存在しない可能性 — ERC-165検証を検討
同期権限チェック関数呼び出し時にリアルタイムチェック、「継続的監視」は非対応
resourceId名前空間keccak256("MY_RESOURCE") で決定的なIDを生成
クロスコントラクト構成権限コントラクト + NFTコントラクト = アクセス制御システム

Built with AiAda