【実務・中級編】 NFTロイヤリティ回避を防止するEIP-2981の実装と強制 – IoT・OT(制御システム) & ブロックチェーンセキュリティ防御ガイド

お疲れ。最近、あちのマーケットプレイスで「NFTのロイヤリティが踏み倒される」インシデントが後を絶たないが、君たちのチームはどう対策している?

「うちは標準のマーケットプレイスを使っているから大丈夫」なんて楽観視しているなら、今すぐその甘い考えを捨ててもらったほうがいい。昨今の攻撃者や一部の悪質なプラットフォームは、賢しらにフロントエンドのUIをバイパスし、スマートコントラクトへ直接 eth_sendTransaction を投げてクリエイターへの分配金を綺麗に消し去っている。

今回は、この泥沼のようなロイヤリティ回避問題に終止符を打つための決定版、EIP-2981をベースにした「コントラクトレベルでのロイヤリティ強制実装」について、現場のセキュリティチーフの視点から徹底的に叩き込んでやる。

—

1. なぜマーケットプレイスの「自主的ロイヤリティ」は崩壊したのか?

Web3の黎明期、NFTのロイヤリティ(二次流通時のクリエイター還元)は、マーケットプレイス側の「善意(オプトイン仕様)」に依存していた。
「このプラットフォームで売買されたら、売上の〇%をクリエイターに送金します」というUI上の取り決めだ。

だが、攻撃者や利益至上主義のトレーダーにとって、そんなものはただの「手数料泥棒」に映る。彼らはどうしたか?
マーケットプレイスのフロントエンドを使わず、直接スマートコントラクトの transferFrom やカスタムの swap 関数を呼び出すボットを組んだ。あるいは、ロイヤリティを徴収しない悪質な新興マーケットプレイスに流動性を集めた。結果、クリエイターの財布には一文の銭も入らず、ガス代だけが消えていく惨状が生まれた。

ここで思い出してほしい。「ブロックチェーンの世界において、フロントエンドの制限はセキュリティではない。セキュリティとはコントラクトのコードそのものである」という鉄則をね。

—

2. EIP-2981と「強制」のギャップをどう埋めるか?

標準規格である EIP-2981 は非常に優秀だ。NFTコントラクト自体に「うちの作品のロイヤリティは誰に、何%払うべきか」を問い合わせる標準インターフェース(royaltyInfo(uint256 tokenId, uint256 salePrice))を定義した。

しかし、ここで一つ大きな罠がある。EIP-2981は「教えてあげる機能」でああって、「強制的に徴収する機能」ではないということだ。マーケットプレイス側がこの関数を無視してトランザクションを通せば、ロイヤリティは簡単にスルーされる。

だからこそ、我々スマートコントラクト開発者は、「NFTの移転(転送)そのものをフックし、ロイヤリティが支払われていない、あるいは計算されていないトランザクションをリバート(reject)する仕組み」をコントラクト内部に組み込まなければならない。

—

3. 【実務向け】EIP-2981準拠 & ロイヤリティ強制実装サンプル(Solidity)

百聞は一見に如かずだ。OpenZeppelinのライブラリをベースに、マーケットプレイスのバイパスを防ぐための厳格なトランスファー制御とEIP-2981を実装したセキュアなコントラクトのサンプルコードを提示する。

実務でそのまま組み込めるよう、細かくコメントを入れているので熟読してほしい。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/interfaces/EIP2981.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

/**
 * @title SecureEnforcedRoyaltyNFT
 * @notice EIP-2981の実装に加え、特定の認可されたマーケットプレイス以外からの直接転送や、
 *         ロイヤリティ未払いのトレードを水際で阻止するセキュアなNFTコントラクト。
 */
contract SecureEnforcedRoyaltyNFT is ERC721, EIP2981, Ownable {
    
    // ロイヤリティの受取人アドレスと料率(例: 500 = 5%)
    address private _royaltyReceiver;
    uint96 private _royaltyFeeNumerator;

    // 認可されたマーケットプレイスのコントラクトアドレスを管理するホワイトリスト
    mapping(address => bool) public approvedMarketplaces;

    // イベント定義
    event MarketplaceAuthorizationUpdated(address indexed marketplace, bool status);
    event RoyaltyParamsUpdated(address indexed receiver, uint96 feeNumerator);

    constructor(
        string memory name,
        string memory symbol,
        address royaltyReceiver,
        uint96 royaltyFeeNumerator
    ) ERC721(name, symbol) Ownable(msg.sender) {
        _royaltyReceiver = royaltyReceiver;
        _royaltyFeeNumerator = royaltyFeeNumerator;
        
        // EIP-2981のデフォルトロイヤリティを設定(ベースは10000分率。500なら5%)
        _setDefaultRoyalty(royaltyReceiver, royaltyFeeNumerator);
    }

    /**
     * @notice マーケットプレイスのホワイトリストを更新する(オーナーのみ実行可能)
     */
    function setMarketplaceApproval(address marketplace, bool approved) external onlyOwner {
        approvedMarketplaces[marketplace] = approved;
        emit MarketplaceAuthorizationUpdated(marketplace, approved);
    }

    /**
     * @inheritdoc IERC165
     */
    function supportsInterface(bytes4 interfaceId)
        public
        view
        virtual
        override(ERC721, EIP2981)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }

    /**
     * @dev 転送時のフック関数。
     *      ここで「誰が」「どこへ」送ろうとしているかを監視し、
     *      野良マーケットプレイスや不正な直接トランスファーによるロイヤリティ回避をブロックする。
     */
    function _update(
        address to, 
        uint256 tokenId, 
        address auth
    ) internal virtual override returns (address) {
        address from = _ownerOf(tokenId);

        // ミント時(from == address(0)や、バーン時(to == address(0))、
        // またはオーナー自身による移動は除外する
        if (from != address(0) && to != address(0) && msg.sender != owner()) {
            
            // 【セキュリティ・チェック】
            // 呼び出し元(msg.sender)が、事前に承認されたマーケットプレイス、
            // またはこのコントラクト自身であるかを厳格に検証する。
            // これにより、悪質なコントラクトや直接呼び出しによるロイヤリティ逃れをシャットアウトする。
            require(
                approvedMarketplaces[msg.sender] || msg.sender == address(this),
                "SecureEnforcedRoyaltyNFT: Transfer bypassed via unauthorized marketplace"
            );
        }

        return super._update(to, tokenId, auth);
    }

    /**
     * @notice 緊急時にロイヤリティパラメータを修正するための管理者関数
     */
    function setDefaultRoyalty(address receiver, uint96 feeNumerator) external onlyOwner {
        _royaltyReceiver = receiver;
        _royaltyFeeNumerator = feeNumerator;
        _setDefaultRoyalty(receiver, feeNumerator);
        emit RoyaltyParamsUpdated(receiver, feeNumerator);
    }
}

—

4. セキュリティチーフからの実務的アドバイス

上記のコードを実装するだけでは、まだ現場の運用としては片手落ちだ。インシデントを防ぐために、以下の運用ルールをチーム全体で徹底してほしい。

1. ホワイトリストの管理厳格化
approvedMarketplaces に登録するアドレスは、必ず監査済みの信頼できる大手マーケットプレイス(BlurやOpenSeaの最新プロキシコントラクトなど)の公式アドレスに限定すること。マルチシグ(Gnosis Safeなど)をオーナー権限に紐づけ、勝手に怪しいアドレスが追加されないガバナンス体制が必須だ。
2. 「強制」のトレードオフを理解する
コントラクト側で転送をガチガチに縛る(SBT的なアプローチや厳格なホワイトリスト化)と、ユーザー間の個人間トレード(P2P)や、DeFiプロトコルへの担保差し入れ(Lending)の際に不都合が生じることがある。「どこまでのユースケースを許容し、どこからをブロックするか」のビジネス要件を、必ず法務やプロダクトマネージャーとすり合わせておくこと。
3. フロントエンド(Webアプリ)側のフォールバック実装
スマートコントラクト側で require をかけて弾くだけでなく、フロントエンドのJavaScript/TypeScript層(Ethers.jsやWagmiなどを使用)でも、トランザクション送信前にEIP-2981の royaltyInfo を呼び出し、ユーザーに対して「正しくロイヤリティが徴収されるルートを通っているか」を警告・案内するUI/UXを実装するのがプロの仕事だ。

セキュリティとは、単に教科書通りのコードを書くことではない。攻撃者の思考の斜め上をいき、「システムがいかに悪用され得るか」を常に先回りして塞ぎ続けることだ。

今回の実装は、君たちのプロジェクトを守る強力な盾になるはずだ。早速コードベースに組み込み、テストネットでのファジングテストを回してくれ。健闘を祈る。

コメント

タイトルとURLをコピーしました