はじめに

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

今回は、NFTなどのトークンが特定のアクションを送受信できる仕組みを提案しているERC5050についてまとめていきます!

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

他にも様々なEIPについてまとめています。

概要

ERC5050は、ユーザーが起こす「アクション」を異なるトークン間でやり取りできる、汎用的なアクションメッセージングプロトコルを提案しています。

このプロトコルは、単にアクションを送受信するだけではなく、ステートを保持・管理するための任意のコントラクト(state controller contract、または environment と呼ばれます)と連携することで、より高度なインタラクションが可能になります。

例えば、ゲームにおいてプレイヤーの操作や状態の変化をトークン同士でやり取りしたり、アイテムの使用・交換・合成などを実現するための基盤として機能します。

また、オフチェーンサービスによるコントラクトの自動発見・機能一覧の取得・人間が読める形式での情報提供を可能にする共通インターフェースも提供されており、インタラクティブなトークンサービスを構築できます。

動機

Ethereumにおける代表的なトークン規格であるERC721 や ERC1155 は、NFTやゲーム内アイテムなどの「オブジェクト」を定義するために使われています。

しかし近年、ゲームや分散型アイデンティティの分野では、単に静的なオブジェクトを持つだけではなく、オブジェクト間の動的な相互作用や状態変化=「デジタル物理」を持たせようとする動きが活発になっています。

これにより、NFT同士やその他のトークンが互いに「アクション(例えば攻撃、装備、融合、移動など)」を送信・受信できる仕組みが必要になってきました。

この標準が実現すれば、インタラクティブなトークンやアプリケーションの開発が、よりオープンで再利用可能な形で進められるようになります。

このプロトコルのメリット

  • アクション可能なトークンをアプリケーションが自動的に発見・利用できるようになる
  • ゲームやメタバースなどにおける分散型のデジタル物理レイヤーを構築できる
  • ステート変化を伴う動的なNFTを簡単かつ安全に実現できる
  • チェーン間でアクションを伝播させるアクションブリッジとしても応用できる
    • 例:L1上のアセットの行動ログをL2に保存
    • L1アセットがL2アセットと相互作用
    • L2でのアクションをL1に最終反映(ロールアップ)

仕様

インターフェース

js
pragma solidity ^0.8.0;

/// @param _address The address of the interactive object
/// @param tokenId The token that is interacting (optional)
struct Object {
    address _address;
    uint256 _tokenId;
}

/// @param selector The bytes4(keccack256()) encoding of the action string
/// @param user The address of the sender
/// @param from The initiating object
/// @param to The receiving object
/// @param state The state controller contract
/// @param data Additional data with no specified format
struct Action {
    bytes4 selector;
    address user;
    Object from;
    Object to;
    address state;
    bytes data;
}

/// @title EIP-5050 Interactive NFTs with Modular Environments
interface IERC5050Sender {
    /// @notice Send an action to the target address
    /// @dev The action's `fromContract` is automatically set to `address(this)`,
    /// and the `from` parameter is set to `msg.sender`.
    /// @param action The action to send
    function sendAction(Action memory action) external payable;

    /// @notice Check if an action is valid based on its hash and nonce
    /// @dev When an action passes through all three possible contracts
    /// (`fromContract`, `to`, and `state`) the `state` contract validates the
    /// action with the initiating `fromContract` using a nonced action hash.
    /// This hash is calculated and saved to storage on the `fromContract` before
    /// action handling is initiated. The `state` contract calculates the hash
    /// and verifies it and nonce with the `fromContract`.
    /// @param _hash The hash to validate
    /// @param _nonce The nonce to validate
    function isValid(bytes32 _hash, uint256 _nonce) external returns (bool);

    /// @notice Retrieve list of actions that can be sent.
    /// @dev Intended for use by off-chain applications to query compatible contracts,
    /// and to advertise functionality in human-readable form.
    function sendableActions() external view returns (string[] memory);

    /// @notice Change or reaffirm the approved address for an action
    /// @dev The zero address indicates there is no approved address.
    ///  Throws unless `msg.sender` is the `_account`, or an authorized
    ///  operator of the `_account`.
    /// @param _account The account of the account-action pair to approve
    /// @param _action The action of the account-action pair to approve
    /// @param _approved The new approved account-action controller
    function approveForAction(
        address _account,
        bytes4 _action,
        address _approved
    ) external returns (bool);

    /// @notice Enable or disable approval for a third party ("operator") to conduct
    ///  all actions on behalf of `msg.sender`
    /// @dev Emits the ApprovalForAll event. The contract MUST allow
    ///  an unbounded number of operators per owner.
    /// @param _operator Address to add to the set of authorized operators
    /// @param _approved True if the operator is approved, false to revoke approval
    function setApprovalForAllActions(address _operator, bool _approved)
        external;

    /// @notice Get the approved address for an account-action pair
    /// @dev Throws if `_tokenId` is not a valid NFT.
    /// @param _account The account of the account-action to find the approved address for
    /// @param _action The action of the account-action to find the approved address for
    /// @return The approved address for this account-action, or the zero address if
    ///  there is none
    function getApprovedForAction(address _account, bytes4 _action)
        external
        view
        returns (address);

    /// @notice Query if an address is an authorized operator for another address
    /// @param _account The address on whose behalf actions are performed
    /// @param _operator The address that acts on behalf of the account
    /// @return True if `_operator` is an approved operator for `_account`, false otherwise
    function isApprovedForAllActions(address _account, address _operator)
        external
        view
        returns (bool);

    /// @dev This emits when an action is sent (`sendAction()`)
    event SendAction(
        bytes4 indexed name,
        address _from,
        address indexed _fromContract,
        uint256 _tokenId,
        address indexed _to,
        uint256 _toTokenId,
        address _state,
        bytes _data
    );

    /// @dev This emits when the approved address for an account-action pair
    ///  is changed or reaffirmed. The zero address indicates there is no
    ///  approved address.
    event ApprovalForAction(
        address indexed _account,
        bytes4 indexed _action,
        address indexed _approved
    );

    /// @dev This emits when an operator is enabled or disabled for an account.
    ///  The operator can conduct all actions on behalf of the account.
    event ApprovalForAllActions(
        address indexed _account,
        address indexed _operator,
        bool _approved
    );
}

interface IERC5050Receiver {
    /// @notice Handle an action
    /// @dev Both the `to` contract and `state` contract are called via
    /// `onActionReceived()`.
    /// @param action The action to handle
    function onActionReceived(Action calldata action, uint256 _nonce)
        external
        payable;

    /// @notice Retrieve list of actions that can be received.
    /// @dev Intended for use by off-chain applications to query compatible contracts,
    /// and to advertise functionality in human-readable form.
    function receivableActions() external view returns (string[] memory);

    /// @dev This emits when a valid action is received.
    event ActionReceived(
        bytes4 indexed name,
        address _from,
        address indexed _fromContract,
        uint256 _tokenId,
        address indexed _to,
        uint256 _toTokenId,
        address _state,
        bytes _data
    );
}

ERC5050を実装するコントラクトは、他のコントラクトやクライアントが機能を検出できるように、必ずERC165の supportsInterface() を実装する必要があります。

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

  • IERC5050Sender を実装している場合は、0xc8c6c9f3 を渡されたときに true を返します。
  • IERC5050Receiver を実装している場合は、0x1a3f02f4 を渡されたときに true を返します。

これにより、外部サービスがインタラクティブなコントラクトを自動検出できるようになります。

構造体

Object

アクションを起こす/受ける対象のオブジェクトを示す構造体。

js
struct Object {
    address _address;
    uint256 _tokenId;
}

この構造体は、NFTなどのインタラクティブなオブジェクトを一意に識別するためのもので、コントラクトアドレスと tokenId の組み合わせで構成されています。トークンIDが不要な場合(ERC20など)でも利用できます。

Action

送受信されるアクションの詳細を示す構造体。

js
struct Action {
    bytes4 selector;
    address user;
    Object from;
    Object to;
    address state;
    bytes data;
}

この構造体には以下の情報を含みます。

  • selector
    • アクション名を keccak256 でハッシュ化した bytes4 値(例:bytes4(keccak256("attack")))。
  • user
    • アクションを実行したアドレス。
  • from / to
    • アクションの発信元/受信先のオブジェクト。
  • state
    • ステート制御を行うコントラクトアドレス。
  • data
    • アクションに付随する任意の追加データ。

IERC5050Sender

IERC5050Senderは、アクションを「送る」ためのコントラクトが実装すべき関数群を定義しています

関数

sendAction
js
function sendAction(Action memory action) external payable;

指定したアクションをターゲットに送信する関数。 送信元のコントラクトは自動的に address(this) に設定されます。

isValid
js
function isValid(bytes32 _hash, uint256 _nonce) external returns (bool);

アクションの有効性を、ハッシュとノンスに基づいて検証する関数。 これは複数のコントラクトを経由したアクションの整合性を保つために使われます。

sendableActions
js
function sendableActions() external view returns (string[] memory);

送信可能なアクションの一覧を取得する関数。 これはオフチェーンサービスが機能を可視化するために使用されます。

approveForAction / setApprovalForAllActions
js
function approveForAction(address _account, bytes4 _action, address _approved) external returns (bool);
function setApprovalForAllActions(address _operator, bool _approved) external;

特定のアカウント・アクションの組み合わせ、もしくは全てのアクションに対して、第三者の操作を許可/取り消しする関数。

getApprovedForAction / isApprovedForAllActions
js
function getApprovedForAction(address _account, bytes4 _action) external view returns (address);
function isApprovedForAllActions(address _account, address _operator) external view returns (bool);

アクションに対して許可されたアドレスや、全てのアクションに対するオペレーターの状態を確認する関数。

イベント

  • SendAction
    • アクション送信時に発行されるイベント。
  • ApprovalForAction
    • アクション単位での承認変更時に発行されるイベント。
  • ApprovalForAllActions
    • 全体の承認変更時に発行されるイベント。

IERC5050Receiver

IERC5050Receiver は、受信したアクションを処理するためのコントラクトが実装すべき関数群です。

関数

onActionReceived
js
function onActionReceived(Action calldata action, uint256 _nonce) external payable;

送信されたアクションを処理する関数。 state コントラクト経由でもこの関数が呼び出されます。

receivableActions
js
function receivableActions() external view returns (string[] memory);

受信可能なアクションの一覧を取得する関数。 オフチェーンでの可視化に利用されます。

イベント

  • ActionReceived
    • 有効なアクションを受け取った際に発火

アクションの命名規則

ERC5050では、アクション名の設計において ドット(.)区切りと矢印(>)区切りの2種類のセパレータを使うことが推奨されています。

ドット区切り(名前空間の定義)

  • 形式
    • namespace.action
  • 例:spells.cast は spells というカテゴリに属する cast アクションを表す。

この形式を使うことで、同じ「cast」でも用途に応じて明確に分類できます。 例えば、spells.cast(魔法を唱える)と building.cast(鋳造する)は異なる文脈で使えるようになります。

矢印区切り(アクションの順序指定)

  • 形式
    • action1>action2
  • 例:settle>build は「settle(定住)」アクションの後に「build(建築)」を行うという順序を示す

このようにアクションの流れを記述できることで、複数のアクションが関係する高度な挙動を記述可能になります。

ステートコントラクト(state contract)の役割と仕組み

ステートコントラクトは任意

ERC5050では、アクションを処理する時に「ステート管理コントラクト」を使うことができますが必須ではありません。 単純なケースでは、ユーザーが直接トークンにアクションを送ったり、トークン間で直接アクションをやり取りするだけで済みます。

この場合、それぞれのトークンコントラクトが自分自身の状態を制御する形になります。

ステートコントラクトが必要な理由

ステートコントラクトを使うことで、複数の送信者・受信者が同じステート環境を共有することができます。

  • トークンA・Bが共通のゲーム環境(例:バトルフィールド)でやり取りを行う。
  • ユーザーが指定したステートコントラクトによって状態が一元管理される。

このとき、アクションが最終的に正当なものであるかどうかを判断するのは、ステートコントラクトの責任になります。

ステートコントラクトを利用するワークフロー

  1. トークンが登録される

    • トークン所有者がステートコントラクトに register を呼び出す。
    • 初期状態(例:HP, ステータスなど)を設定。
  2. アクションの発生

    • 所有者がアクション(例:攻撃)を発行。
    • sendAction() により、送信元・受信先・ステートコントラクトが指定される。
  3. アクションの処理

    • 受信側トークンが処理を行い、必要なら状態に応じた応答を返す。
    • ステートコントラクトがアクションのハッシュ・nonce を使って正当性を検証し、状態を更新する。
  4. トークンのメタデータ更新

    • トークンA/Bはステートコントラクトのステートに基づいて、見た目や情報を更新する。

ステートコントラクトのモジュール性と応用

ERC5050の設計では、ステートコントラクトはモジュール的に構成可能です。 つまり、同じロジックを持った複数の環境を作成し、任意に差し替えることができます。

これにより、以下のような応用が可能です。

  • アグリゲーターがアクションイベントを分析して、どのステートコントラクトがよく使われているかを把握。
  • トークンが「特定のステートコントラクトのみ使用可能」とする制限付き設計。
  • デフォルトのステートコントラクトを設定しつつ、ユーザーが自由に変更できる設計(見た目がステートに依存するNFTに有用)。
  • チェーンをまたいだ設計(例:L1でアクション検証、L2で保存)によってコスト最適化。

例:FightGame ステートコントラクト

以下は、ERC5050が想定するユースケースの一例です。

  • ステートコントラクト FightGame は戦闘環境を提供する。
  • ユーザーは FightGame.register(contract, tokenId) を呼び出し、HPや攻撃力などの初期ステータスを取得。
  • Fighters コントラクトのトークンAが sendAction(AttackAction) を呼び出し、Pacifists コントラクトのトークンBに攻撃を仕掛ける。
  • トークンBがこのアクションを処理し、次に FightGame がそのアクションを検証・承認してステータスを更新。
  • AとBのメタデータは、FightGame の状態やトークン内部の処理結果に応じて変化。

拡張機能

ERC5050はNFTのインタラクティブ性を標準化する仕様ですが、実際のユースケースに応じて追加で実装可能な拡張機能も定義されています。 これにより、ユーザー体験の向上、既存コントラクトとの互換性確保、セキュリティ強化、クロスチェーン対応などが実現できます。

インタラクティブなユーザーインターフェースの提供(Interactive)

一部のコントラクトでは、ユーザーが直感的にアクションを操作できるようなカスタムUIが存在します。

IERC5050Interactive.sol
pragma solidity ^0.8.0;

/// @title EIP-5050 Interactive NFTs with Modular Environments
interface IERC5050Interactive {
    function interfaceURI(bytes4 _action) external view returns (string);
}

interfaceURI

bytes4のアクションセレクターを引数に取り、そのアクションに対応するUIのURI(例:HTML, JSONなど)を返す関数。 オフチェーンアプリケーションがこのURIを取得し、ユーザーに対して適切なインターフェースを表示できます。

これにより、例えば「spells.cast」アクションに対して魔法を選ぶUIが表示されるような体験が可能になります。

アクションプロキシによる後方互換性とクロスチェーン対応(Action Proxies)

アクションプロキシは、アップグレードできない既存コントラクトとの互換性を保ったり、クロスチェーンブリッジを構成するために活用されます。

実装例と役割

アクションプロキシは、ERC1820(インターフェース検出の標準)を拡張した形で実装されることがあります ERC173(所有者管理)の setManager() を使って管理者を設定し、特定のアクションを代行実行します。 これにより、アップグレードできない古いNFTコントラクトや異なるチェーン上のNFTとも、アクション連携が可能になります。

このプロキシは、柔軟なインフラ構築に非常に有用です。

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

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

アクションの制御を可能にするコントローラー機構(Controllable)

セキュリティやチェーン間の制御の観点から、信頼されたコントラクトにアクション実行を強制させることができるようにする拡張機能です。

IControllable.sol
pragma solidity ^0.8.0;

/// @title EIP-5050 Action Controller
interface IControllable {
    
    /// @notice Enable or disable approval for a third party ("controller") to force
    ///  handling of a given action without performing EIP-5050 validity checks.
    /// @dev Emits the ControllerApproval event. The contract MUST allow
    ///  an unbounded number of controllers per action.
    /// @param _controller Address to add to the set of authorized controllers
    /// @param _action Selector of the action for which the controller is approved / disapproved
    /// @param _approved True if the controller is approved, false to revoke approval
    function setControllerApproval(address _controller, bytes4 _action, bool _approved)
        external;

    /// @notice Enable or disable approval for a third party ("controller") to force
    ///  action handling without performing EIP-5050 validity checks. 
    /// @dev Emits the ControllerApproval event. The contract MUST allow
    ///  an unbounded number of controllers per action.
    /// @param _controller Address to add to the set of authorized controllers
    /// @param _approved True if the controller is approved, false to revoke approval
    function setControllerApprovalForAll(address _controller, bool _approved)
        external;

    /// @notice Query if an address is an authorized controller for a given action.
    /// @param _controller The trusted third party address that can force action handling
    /// @param _action The action selector to query against
    /// @return True if `_controller` is an approved operator for `_account`, false otherwise
    function isApprovedController(address _controller, bytes4 _action)
        external
        view
        returns (bool);
    
    /// @dev This emits when a controller is enabled or disabled for the given
    ///  action. The controller can force `action` handling on the emitting contract, 
    ///  bypassing the standard EIP-5050 validity checks.
    event ControllerApproval(
        address indexed _controller,
        bytes4 indexed _action,
        bool _approved
    );
    
    /// @dev This emits when a controller is enabled or disabled for all actions.
    ///  Disabling all action approval for a controller does not override explicit action
    ///  action approvals. Controller's approved for all actions can force action handling 
    ///  on the emitting contract for any action.
    event ControllerApprovalForAll(
        address indexed _controller,
        bool _approved
    );
}

事前に信頼されたコントローラー(例:L2のブリッジコントラクト)に対し、アクションの検証なしに強制的にアクションを実行させます。 これにより、バリデーションやロールバックのリスクを避けつつ、確実なアクション実行を保証できます。

重要なポイント
  • 通常のアクション検証(require/revert)はスキップされる。
  • コントローラーはチェーン間ブリッジやセキュアなワークフローの構築に重要。
  • イベント ControllerApproval と ControllerApprovalForAll により監視可能。

メタデータの更新に対応するERC4906(Metadata Update)

ERC5050を利用するインタラクティブNFTでは、アクションによってトークンの見た目や属性が変化することがあります。

このような場合に、ERC4906 に準拠したイベント(MetadataUpdate)を発行させることで、外部のマーケットプレイスやビューワーが即座に変化を検知できるようになります。

利用例
  • バトルでHPが減少し、見た目が傷ついた状態に変わる。
  • アイテム使用によって進化し、メタデータが更新される。

ERC4906の活用により、ユーザー体験のリアルタイム性が向上します。

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

補足

この標準の中核的な価値

ERC5050が実現しようとしているのは以下の4つです。

  • 共通のインタラクション定義 NFTなどのオブジェクト間でのアクションの定義・公開・実行を標準化。
  • 任意のステート管理の仕組み 必要に応じて状態を共有できる「ステートコントラクト」により、柔軟な相互運用性と有効性の検証を可能にする。
  • 開発者フレンドリー 導入が簡単で、既存のコントラクト構成にも導入しやすい。
  • ユーザーフレンドリー 使い勝手がよく、複雑な操作を要求しない設計。

アクションの命名とセレクタの考え方

アクションは2つの方法で扱われます。

  • ユーザー向けには、人間が読める形式の文字列(例:spells.cast)。
  • コントラクト内部では、bytes4セレクタ(bytes4(keccak256("spells.cast")))。

この設計により、UIやオフチェーンサービスは分かりやすく処理でき、コントラクト側では効率的にセレクタで処理できます。

また、この方式は名前空間の分類(ドット区切り)やアクションの順序指定(矢印区切り)にも対応しており、柔軟な設計が可能です。

アクションの検証とセキュリティ設計

アクションの正当性を担保するために、以下の設計が採用されています。

  • ハッシュ + ノンスによるアクション検証
    アクションデータをハッシュ化し、fromContract 側に保存。 これを state コントラクトが検証します。

この方式は以下の理由から採用されました。

  • 非常にガス効率が良い。
  • 開発者・利用者からの支持が高い。

検討されたが採用されなかった方式

  • 署名付きメッセージによる検証
    • UXが悪化(署名と送信の2ステップが必要)。
    • ガスコストが高い。
    • 実際の脅威モデル(ユーザーが意図せず悪意のあるコントラクトに誘導される)には対応が困難。

ERC5050では、「ユーザー」ではなく「トークンコントラクト」がアクションの主導者と見なされます。 誰でも他のトークンにアクションを送ることはでき、その受け入れ判断は受信側のコントラクトが行うという方針です。

ステートコントラクトに関する設計意図

ステート管理を専用のコントラクトに分離することで、以下のような利点があります。

  • アクションにおけるステートの扱いを明示的に示すことでより透明な設計が可能。
  • 状態の定義や検証を、共通化された環境(environment)に委任できる。
  • 送信・受信側コントラクトがステートを直接共有する必要がない。

このような設計により、ゲームごとのロジックやルールに応じた独自のステートコントラクトが容易に実装できます。

ガスコストと複雑性に関する考察

アクションチェーンにおけるガス消費の問題について、以下の点が強調されています。

  • コントラクトごとのアクション処理は任意の複雑さを持つことが可能。
  • しかし、ループ処理(for-loop)などは極力避けるべき。
  • 開発者はガス消費を最小限に抑える努力をすることが推奨されている。

将来的な代替案として、「push-pull型のアクションチェーン」も検討されていますが、現時点では採用されていません。

互換性

ERC5050は新たなアクション処理モデルを導入するため、アップグレードできない既存トークンコントラクトは直接この標準に対応できません。 しかし、以下の方法により古いNFTや既存アセットを巻き込んだ新しいインタラクティブ体験が可能になります。

  • 既存トークンの外側にアクションプロキシを配置し、ERC5050の形式に変換する。
  • クロスチェーンブリッジにも応用可能なため、非常に柔軟な互換性レイヤーとなる。

参考実装

FightGame.sol
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.0;

import {ERC5050State, Action} from "./ERC5050State.sol";
import {ERC5050, Action} from "./ERC5050.sol";

struct TokenInfo {
   uint256 health;
   uint256 healthRemaining;
   uint256 power;
   uint256 blockedAt;
   uint256 blockPower;
   uint256 lockedUntilBlock;
   uint256 wins;
   bool hasRegistered;
}

interface IFightGame {
     function getStats(address _contract, uint256 _tokenId) external view returns (TokenInfo);
}

contract FightGame is IFightGame, ERC5050State {
    
    bytes4 constant LIGHT_ATTACK_SELECTOR = bytes4(keccak256("fg.light-attack"));
    bytes4 constant HEAVY_ATTACK_SELECTOR = bytes4(keccak256("fg.heavy-attack"));
    bytes4 constant BLOCK_SELECTOR = bytes4(keccak256("fg.block"));
    
    uint256 constant BLOCK_DECAY = 100;
    uint256 constant LIGHT_ATTACK_DECAY = 200;
    uint256 constant HEAVY_ATTACK_DECAY = 500;
    
    mapping(address => mapping(uint256 => TokenInfo)) state;
    
    constructor()  {
        _registerReceivable("fg.light-attack");
        _registerReceivable("fg.heavy-attack");
        _registerReceivable("fg.block");
    }
    
    function register(address _contract, uint256 _tokenId) external {
        require(msg.sender == ownerOf(_contract, _tokenId), "sender not token owner");
        require(!state[_contract][_tokenId].hasRegistered, "token already registered");
        state[_contract][_tokenId] = TokenInfo(100, 100, 5, 0, 0, 0, true);
    }
    
    function getStats(address _contract, uint256 _tokenId) external view returns (TokenInfo){
        return state[_contract][_tokenId];
    }
    
    function onActionReceived(Action calldata action, uint256 _nonce)
        external
        payable
        override
        onlyReceivableAction(action, _nonce)
    {
        TokenInfo storage from = state[action.from._address][action.from._tokenId];
        require(from.healthRemaining > 0, "health 0");
        require(block.number > from.lockedUntilBlock, "token locked");
        if (action.selector == BLOCK_SELECTOR) {
            from.blockPower = from.power * 3;
            from.blockedAt = block.number;
            from.lockedUntilBlock = block.number + BLOCK_DECAY;
            return;
        }
        
        TokenInfo storage to = state[action.to._address][action.to._tokenId];
        require(to.healthRemaining > 0, "target health 0");
        
        uint256 damage;
        if (action.selector == LIGHT_ATTACK_SELECTOR ) {
            damage = from.power;
            from.lockedUntilBlock = block.number + LIGHT_ATTACK_DECAY;
        }
        
        if (action.selector == HEAVY_ATTACK_SELECTOR) {
            damage = from.power * 3;
            from.lockedUntilBlock = block.number + HEAVY_ATTACK_DECAY;
        }
        if(to.blockedAt + BLOCK_DECAY > block.number) {
            if(to.blockPower >= damage){
                to.blockPower -= damage;
                return;
            }
            damage -= to.blockPower;
        }
        if(to.healthRemaining > damage){
            to.healthRemaining -= damage;
            return;
        }
        
        // Winner gains loser's power and some health 
        from.power += to.power;
        from.healthRemaining += to.power;
        from.wins++;
        to.healthRemaining = 0;
    }
}

contract Fighter is ERC5050, ERC721 {
    
    IFightGame stateContract;
    
    constructor(address _stateContract)  {
        _registerAction("fg.light-attack");
        _registerAction("fg.heavy-attack");
        _registerSendable("fg.block");
        stateContract = IFightGame(_stateContract);
    }
    
    // Update NFT render / metadata based on game stats
    function tokenURICharacterEmoji(uint256 tokenId)
        public
        view
        override
        returns (string memory)
    {
        TokenInfo memory stats = stateContract.getStats(address(this), tokenId);
        if(stats.healthRemaining == 0){
            return unicode"😵";
        }
        if(stats.power > 100){
            return unicode"🦾";
        }
        if(stats.power > 50){
            return unicode"💪";
        }
        if(stats.power > 20){
            return unicode"🤩";
        }
        if(stats.power > 5){
            return unicode"😃";
        }
        return unicode"😀";
    }
}
Spells.sol
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.0;

import {ERC5050, Action} from "./ERC5050.sol";

contract Spells is ERC5050, ERC721 {

    bytes4 constant CAST_SELECTOR = bytes4(keccak256("cast"));
    bytes4 constant ATTUNE_SELECTOR = bytes4(keccak256("attune"));

    mapping(uint256 => uint256) spellDust;
    mapping(uint256 => string) attunement;

    constructor() ERC721("Spells", unicode"🔮") {
        _registerSendable("cast");
        _registerReceivable("attune");
    }

    function sendAction(Action memory action)
        external
        payable
        override
        onlySendableAction(action)
    {
        require(
            msg.sender == ownerOf(action.from._tokenId),
            "Spells: invalid sender"
        );
        _sendAction(action);
    }

    function onActionReceived(Action calldata action, uint256 _nonce)
        external
        payable
        override
        onlyReceivableAction(action, _nonce)
    {
        if (action.selector == ATTUNE_SELECTOR) {
            string memory unicodeChar;
            bytes memory _data = action.data;
            assembly {
                // Read unicode character from first 6 bytes (\u5050)
                unicodeChar := shr(208, _data)
            }
            attunement[action.to._tokenId] = unicodeChar;
        }
        // Pass action to state receiver if specified
        _onActionReceived(action, _nonce);
    }

    string[12] private dust = [
        unicode"․",
        unicode"∴",
        unicode"`"
    ];

    string[5] private spells = [
        "Conjuring",
        "Divining",
        "Transforming",
        "Hexing",
        "Banishing"
    ];

    function tokenURI(uint256 tokenId)
        public
        view
        override
        returns (string memory)
    {
        string
            memory out = '<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet" viewBox="0 0 400 350"><style>.base { fill: lightyellow; font-family: serif; font-size: 14px; } .chant { font-style: italic;} .dust {font-family: monospace; font-size: 8px; letter-spacing:5px;}.sm{font-size: 10px;} .sigil{font-family: monospace, font-size:13}</style><rect width="100%" height="100%" fill="#171717" /><text x="14" y="24" class="base">';

        out = string.concat(
            out,
            string.concat(spells[_spellType(tokenId)], " Spell"),
            '</text><text x="376" y="336" class="base sigil">',
            attunement[tokenId]
        );
        out = string.concat(out, "</text></svg>");
        string memory json = Base64.encode(
            bytes(
                string(
                    abi.encodePacked(
                        '{"name": "Spell #',
                        Strings.toString(tokenId),
                        '", "description": "Cast spells, attune spells.", "image": "data:image/svg+xml;base64,',
                        Base64.encode(bytes(out)),
                        '"}'
                    )
                )
            )
        );
        return string(abi.encodePacked("data:application/json;base64,", json));
    }

    function _spellType(uint256 tokenId) internal pure returns (uint256) {
        uint256 rand = _random(Strings.toString(tokenId));
        return rand % 6;
    }

    function _random(string memory input) internal pure returns (uint256) {
        return uint256(keccak256(abi.encodePacked(input)));
    }
}

セキュリティ

アクション検証の課題

このプロトコルでは、アクションが複数のコントラクト間をリレー形式で移動します。 例えば、以下のような流れです。

text
送信元コントラクト → 受信先コントラクト → ステート管理コントラクト

このように中継される形式では、受信側のコントラクトが「このアクションは本当にfromで指定されたコントラクトから発信されたのか?」をネイティブに検証する方法がありません。

そのため、ユーザーの署名による認証という選択肢もありますが、ERC5050ではそれを採用していません。

なぜ署名認証を使わないのか?

署名メッセージ方式は、以下のような理由からユーザー体験とガスコストの両面で不利と判断されました。

  • ユーザーは毎回2ステップ操作(署名 → トランザクション送信)を求められる。
  • 署名の検証処理はガスコストが高い。

そのため、ERC5050では署名を使わず、代わりに「アクション発行者であるコントラクト(およびそのトークン)」に焦点を当てた設計が採用されています。

トークン主導型の検証モデル

このプロトコルでは、ユーザーではなくコントラクトがアクションの主体と見なされます。

  • 誰でも任意のアクションを他のトークンに送ることができる(例:「誰でもビル・ゲイツにツイートはできる」)。
  • しかし、そのアクションをどう扱うかは受信側が決める。
  • 高価値なアクションについては、ステートコントラクトやホワイトリストを使ってアクセス制御が可能。

この設計により、セキュリティと柔軟性の両立が図られています。

引用

Alexi (@alexi), "ERC-5050: Interactive NFTs with Modular Environments [DRAFT]," Ethereum Improvement Proposals, no. 5050, April 2021. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-5050.

最後に

今回は「NFTなどのトークンが特定のアクションを送受信できる仕組みを提案しているERC5050」についてまとめてきました! いかがだったでしょうか?

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

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