はじめに

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

今回は、AIのモデルや記憶、設定などのデータを安全に管理・譲渡できる新しいNFTの仕組みを提案しているERC7857についてまとめていきます!

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

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

概要

ERC7857では、AIエージェント(人工知能の自律的な行動主体)を対象とした、NFT(Non-Fungible Token/代替不可能トークン)の新しい標準インターフェースを定義しています。 従来のNFTが「静的なメタデータ(変更されない情報)」を扱うのに対し、ERC7857はAIエージェントの能力を表す動的で秘匿性の高いメタデータを管理するための仕組みを導入しています。

この新しいNFT標準の特徴は、データの真正性(正しく改ざんされていないこと)を保証しつつ、プライバシーを保護した状態での送付を可能にすることです。 具体的には、以下のような検証手法に対応できる統一インターフェースを定義しています。

  • TEE(Trusted Execution Environment) 安全な実行環境でプログラムを実行し、外部からの改ざんや情報漏洩を防ぐ仕組み。
  • ZKP(Zero-Knowledge Proof/ゼロ知識証明) ある情報を「知っている」ことを、内容を明かさずに証明できる技術。

これにより、AIエージェントのモデル・記憶・キャラクター設定などの機密情報を安全に管理・譲渡できるようになります。 機密性と検証可能性を両立させながら、価値のあるエージェントデータの安全な所有と移転を実現するのがこの標準の目的です。

動機

AI技術の進化により、エージェント(自律的にタスクを遂行するAI)は人々の生活の中で実用的な存在になってきました。 同時に、ブロックチェーン技術とエージェントを組み合わせる動きが進み、ユーザー自身がエージェントを作成・所有できるプロジェクトも登場しています。 しかし、ここで欠けている重要な要素が「エージェントの分散型所有管理」です。

AIエージェントには以下のような特徴があります。

特徴 説明
唯一性 各エージェントは独自のモデル・記憶・キャラクターを持ち、同じものは存在しません。
所有権の明確性 エージェントの開発には計算資源や知的財産が投じられており、所有権が明確である必要があります。
機密性を持つメタデータ モデルや記憶、キャラクター設定などは機密性の高いデータであり、一般に公開すべきではありません。

これらの理由から、AIエージェントは非代替性(固有性)を持つ資産としてNFTで表現するのに適しています。 しかし、既存のNFT規格(例:ERC721)では、AIエージェントを適切に表現することはできません。 その最大の理由は、メタデータの取り扱いが静的かつ公開前提であるためです。

NFTの課題

従来のNFTでは、画像や音楽のようなデジタル資産を「所有」する仕組みを提供していますが、AIエージェントのように中身(メタデータ)自体が資産である場合には不十分です。 AIエージェントのメタデータは以下のような性質を持っています。

  • 価値の本質がデータそのものである(モデル・記憶・キャラクター構造など)
  • 暗号化して保護しなければ知的財産が漏洩する
  • 所有権移転の時にも機密性を保ったまま検証可能でなければならない

そのため、NFTの送付時には以下のような要件を満たす必要があります。

要件 内容
暗号化メタデータの検証可能な送付 送付時に、実際に暗号化されたエージェントデータが正しく移動したことを確認できる必要があります。
新しい所有者の安全なアクセス 新しい所有者だけが、適切な権限を持ってエージェントの中身にアクセスできるようにする必要があります。
実行環境での所有権検証 エージェントを動かす環境(例:AIランタイム)が、所有者を検証して正しいデータを読み込めることが求められます。

仕様

ERC7857は、以下の3つの主要インターフェースから成り立っています。

  • メインNFTインターフェース
  • メタデータインターフェース
  • データ検証インターフェース

それぞれが連携し、暗号化されたエージェントデータを安全に管理・譲渡できるように設計されています。

データ検証システム

この仕組みは、NFTに紐づく機密データの所有・転送の正当性を検証するためのシステムです。 主に「オンチェーン検証」と「オフチェーン証明」の2つのコンポーネントで構成されています。

コンポーネント 説明
オンチェーン検証(Verifier) スマートコントラクトとして実装され、提出された証明(Proof)を検証します。TEEまたはZKP方式を使用できます。
オフチェーン証明(Prover) データの所有・転送に関する暗号学的な証明を生成します。暗号化データと鍵を扱います。

オフチェーン証明(Prover)の仕組み

検証方式 説明
TEE方式 信頼できるハードウェア内で証明を生成します。秘密鍵を安全に扱い、データの再暗号化を直接行うことができます。
ZKP方式 暗号的なゼロ知識証明を生成します。ただし複数当事者の秘密鍵を同時に扱うことはできず、再暗号化キーがProverに知られる点が異なります。

サポートする2種類の証明

証明の種類 目的 検証方法
所有証明(Ownership Proof) データを実際に保有していることを示す証明。 verifyOwnership() 関数でオンチェーン検証。
送付有効性証明(Transfer Validity Proof) データ送付が正しく行われたことを示す証明。 verifyTransferValidity() 関数でオンチェーン検証。

検証の流れ

  1. Proverが元データと新しい暗号化データに基づいて証明を生成します。
  2. 受信者の公開鍵を使って新しい鍵を暗号化します。
  3. オンチェーンVerifierが、提出された証明を検証します。
  4. データの整合性、暗号化の正当性、ストレージ上でのデータ存在を確認します。

所有証明は任意ですが、NFTのミント(発行)や更新時には所有証明を行うことが推奨されています。

データ検証インターフェース

OracleType

javascript
enum OracleType {
    TEE,
    ZKP
}

検証に使用するオラクルの種類を表す列挙型です。 TEE(信頼実行環境)またはZKP(ゼロ知識証明)方式のどちらを使用するかを指定します。

AccessProof

javascript
struct AccessProof {
    bytes32 oldDataHash;
    bytes32 newDataHash;
    bytes nonce;
    bytes encryptedPubKey;
    bytes proof;
}

データアクセス時に受信者(または代理のアシスタント)が署名するアクセス証明です。 古いデータと新しいデータの関係、および暗号化された公開鍵などを含みます。 アクセス権の移転や検証時に利用されます。

パラメータ

  • oldDataHash
    • 古いデータのハッシュ値。
  • newDataHash
    • 新しいデータのハッシュ値。
  • nonce
    • 証明の一意性を保証するための値。
  • encryptedPubKey
    • 新しいデータ鍵を暗号化するための受信者の公開鍵(空欄の場合はEthereumアドレスの公開鍵を使用)。
  • proof
    • 証明データ本体。

OwnershipProof

javascript
struct OwnershipProof {
    OracleType oracleType;
    bytes32 oldDataHash;
    bytes32 newDataHash;
    bytes sealedKey;
    bytes encryptedPubKey;
    bytes nonce;
    bytes proof;
}

データ所有を証明する構造体です。 オフチェーンProverが生成する所有証明を格納します。 TEEまたはZKPを利用して安全にデータ所有の正当性を証明します。

パラメータ

  • oracleType
    • 使用するオラクルの種類(TEEまたはZKP)。
  • oldDataHash
    • 元データのハッシュ値。
  • newDataHash
    • 新しいデータのハッシュ値。
  • sealedKey
    • 新しいデータ鍵を暗号的に保護した値。
  • encryptedPubKey
    • 受信者の公開鍵を暗号化した値。
  • nonce
    • 証明を一意にするための値。
  • proof
    • 実際の証明データ。

TransferValidityProof

javascript
struct TransferValidityProof {
    AccessProof accessProof;
    OwnershipProof ownershipProof;
}

送付の有効性を証明するための構造体です。 アクセス証明と所有証明の両方を含み、データ送付が正しく行われたことを確認するために使われます。

TransferValidityProofOutput

javascript
struct TransferValidityProofOutput {
    bytes32 oldDataHash;
    bytes32 newDataHash;
    bytes sealedKey;
    bytes encryptedPubKey;
    bytes wantedKey;
    address accessAssistant;
    bytes accessProofNonce;
    bytes ownershipProofNonce;
}

送付検証後に出力される検証結果を格納します。 新旧データのハッシュ、暗号鍵、アクセスアシスタントの情報などを含みます。

verifyTransferValidity

javascript
function verifyTransferValidity(
    TransferValidityProof[] calldata _proofs
) external returns (TransferValidityProofOutput[] memory);

送付の正当性を検証する関数。 TEEまたはZKPで生成された証明を検証し、データが正しく再暗号化・送付・ストレージ登録されているかを確認します。

引数

  • _proofs
    • Proverが生成した証明の配列。

戻り値

  • TransferValidityProofOutput[]
    • 検証結果の配列。

メタデータインターフェース

IntelligentData

javascript
struct IntelligentData {
    string dataDescription;
    bytes32 dataHash;
}

AIエージェントに関するメタデータ情報を格納する構造体です。 各NFTトークンに対応するデータの説明と、そのデータハッシュを保持します。

パラメータ

  • dataDescription
    • データの説明。
  • dataHash
    • データのハッシュ値。

IERC7857Metadata

javascript
interface IERC7857Metadata {
    function name() external view returns (string memory);
    function symbol() external view returns (string memory);
    function intelligentDataOf(uint256 _tokenId) external view returns (IntelligentData[] memory);
}
name

NFTコレクションの名前を取得する関数。 コレクション全体の名称を返します。

戻り値

  • string
    • NFTコレクションの名前。
symbol

NFTコレクションのシンボルを取得する関数。 コレクションの識別に用いる短いシンボルを返します。

戻り値

  • string
    • コレクションのシンボル。
intelligentDataOf

指定したトークンのインテリジェントデータを取得する関数。 トークンIDに対応するメタデータ(説明とハッシュ)を返します。

引数

  • _tokenId
    • トークンの識別子。

戻り値

  • IntelligentData[]
    • トークンに紐づくメタデータの配列。

メインNFTインターフェース

このインターフェースは、NFTの発行・送付・クローン・承認管理など、NFTの主要な操作を定義しています。

Approval

javascript
event Approval(
    address indexed _from,
    address indexed _to,
    uint256 indexed _tokenId
);

トークン転送が承認されたときに発行されるイベント。 あるアドレスが、特定のトークンの送付を他者に許可した時に発行されます。

パラメータ

  • _from
    • 承認を与えるアドレス。
  • _to
    • 承認を受けるアドレス。
  • _tokenId
    • 対象となるトークンID。

ApprovalForAll

javascript
event ApprovalForAll(
    address indexed _owner,
    address indexed _operator,
    bool _approved
);

全トークンに対する承認が設定されたときに発行されるイベント。 所有者が特定のオペレーターに対して、自分の全トークンの管理を一括で許可した場合に発行されます。

パラメータ

  • _owner
    • トークン所有者。
  • _operator
    • 承認を受けるオペレーター。
  • _approved
    • 承認状態(trueなら承認済み)。

Authorization

javascript
event Authorization(
    address indexed _from,
    address indexed _to,
    uint256 indexed _tokenId
);

特定のアドレスにトークン使用の権限が付与されたときに発行されるイベント。 所有者が他のアドレスに対して、トークンの利用権限を与えた時に発行されます。 これにより、承認されたユーザーはNFTの機能(例:AIエージェントへのアクセス)を利用できます。

パラメータ

  • _from
    • 権限を付与したアドレス。
  • _to
    • 権限を受け取るアドレス。
  • _tokenId
    • 対象となるトークンID。

AuthorizationRevoked

javascript
event AuthorizationRevoked(
    address indexed _from,
    address indexed _to,
    uint256 indexed _tokenId
);

権限が取り消されたときに発行されるイベント。 以前にトークン使用権限を与えられていたアドレスに対して、その権限が取り消された時に発行されます。

パラメータ

  • _from
    • 権限を取り消したアドレス。
  • _to
    • 権限を失ったアドレス。
  • _tokenId
    • 対象のトークンID。

Transferred

javascript
event Transferred(
    uint256 _tokenId,
    address indexed _from,
    address indexed _to
);

トークンが送付されたときに発行されるイベント。 トークンの所有者が他者へトークンを送った時に発行されます。 データ送付が完了したことを通知する役割を持ちます。

パラメータ

  • _tokenId
    • 送付されたトークンID。
  • _from
    • 送付元のアドレス。
  • _to
    • 送付先のアドレス。

Cloned

javascript
event Cloned(
    uint256 indexed _tokenId,
    uint256 indexed _newTokenId,
    address _from,
    address _to
);

トークンが複製された(クローンされた)ときに発行されるイベント。 元のトークンから新しいトークンを生成した場合に発行されます。 AIエージェントの複製や派生バージョン作成などで利用されます。

パラメータ

  • _tokenId
    • 元のトークンID。
  • _newTokenId
    • 新しく作成されたトークンID。
  • _from
    • クローンを実行したアドレス。
  • _to
    • 新しいトークンの所有者。

PublishedSealedKey

javascript
event PublishedSealedKey(
    address indexed _to,
    uint256 indexed _tokenId,
    bytes[] _sealedKeys
);

暗号化された鍵(Sealed Key)が公開されたときに発行されるイベント。 送付やクローン時に、受信者がデータ復号に必要な暗号鍵を安全に受け取れるよう、暗号化された鍵情報をブロックチェーン上に公開した時に発行されます。

パラメータ

  • _to
    • 鍵を受け取るアドレス。
  • _tokenId
    • 関連するトークンID。
  • _sealedKeys
    • 暗号化された鍵の配列。

DelegateAccess

javascript
event DelegateAccess(address indexed _user, address indexed _assistant);

ユーザーがアシスタント(代理人)にアクセス権を委任したときに発行されるイベント。 ユーザーが自分の代わりにアクセスを行える「アクセスアシスタント」を指定した場合に発行されます。 ZKPなどでアクセス署名を代理実行する仕組みに活用されます。

パラメータ

  • _user
    • アクセスを委任したユーザー。
  • _assistant
    • 委任を受けたアシスタント。

verifier

javascript
function verifier() external view returns (IERC7857DataVerifier);

このNFTが使用するデータ検証コントラクトを取得する関数。 NFTに紐づくデータ検証ロジック(TEE/ZKP)のアドレスを返します。

戻り値

  • IERC7857DataVerifier
    • 検証コントラクトのアドレス。

iTransfer

javascript
function iTransfer(
    address _to,
    uint256 _tokenId,
    TransferValidityProof[] calldata _proofs
) external;

データと所有権を安全に送付する関数。 送付対象のトークンに関連する暗号化データを、受信者に安全に移転します。 TEEまたはZKPによる証明 _proofs を利用して、送付の正当性を確認します。

引数

  • _to
    • 送付先アドレス。
  • _tokenId
    • 対象トークンID。
  • _proofs
    • 送付の正当性を証明する配列。

iClone

javascript
function iClone(
    address _to,
    uint256 _tokenId,
    TransferValidityProof[] calldata _proofs
) external returns (uint256 _newTokenId);

トークンのデータを複製(クローン)する関数。 指定したトークンをもとに、新しいトークンを生成します。 クローン時もデータの整合性と所有権が暗号的に検証されます。

引数

  • _to
    • クローンを受け取るアドレス。
  • _tokenId
    • 元のトークンID。
  • _proofs
    • データ送付の証明。

戻り値

  • _newTokenId
    • 新しく生成されたトークンID。

authorizeUsage

javascript
function authorizeUsage(uint256 _tokenId, address _user) external;

トークン使用権を特定ユーザーに与える関数。 指定したユーザーに、トークンに紐づくAIエージェントなどへのアクセス権を付与します。

引数

  • _tokenId
    • 権限を付与するトークンID。
  • _user
    • 権限を受け取るアドレス。

revokeAuthorization

javascript
function revokeAuthorization(uint256 _tokenId, address _user) external;

特定ユーザーの使用権限を取り消す関数。 以前に承認したトークン利用者からアクセス権を剥奪します。

引数

  • _tokenId
    • 権限を取り消すトークンID。
  • _user
    • 権限を失うアドレス。

approve

javascript
function approve(address _to, uint256 _tokenId) external;

特定のトークン送付を承認する関数。 NFT所有者が、別のアドレスに特定トークンの送付を許可します。

引数

  • _to
    • 承認を受けるアドレス。
  • _tokenId
    • 対象のトークンID。

setApprovalForAll

javascript
function setApprovalForAll(address _operator, bool _approved) external;

全トークン送付権限をまとめて設定する関数。 特定のオペレーターに、ユーザーが所有するすべてのトークンの管理権を付与または解除します。

引数

  • _operator
    • 権限を付与または削除するアドレス。
  • _approved
    • 設定する承認状態(trueで許可)。

delegateAccess

javascript
function delegateAccess(address _assistant) external;

アクセス権をアシスタントに委任する関数。 ユーザーが代理人(アシスタント)にアクセス署名などの操作を任せられるようにします。

引数

  • _assistant
    • 委任先のアドレス。

ownerOf

javascript
function ownerOf(uint256 _tokenId) external view returns (address);

トークンの所有者を取得する関数。 指定したトークンの現在の所有者を返します。

引数

  • _tokenId
    • トークンの識別子。

戻り値

  • address
    • 現在の所有者のアドレス。

authorizedUsersOf

javascript
function authorizedUsersOf(uint256 _tokenId) external view returns (address[] memory);

トークンに対して使用権限を持つユーザー一覧を取得する関数。 指定したトークンを利用できるすべてのユーザーアドレスを返します。

引数

  • _tokenId
    • トークンの識別子。

戻り値

  • address[]
    • 承認されたユーザーの一覧。

getApproved

javascript
function getApproved(uint256 _tokenId) external view returns (address);

特定トークンの承認済み送付先アドレスを取得する関数。 どのアドレスがこのトークンの送付を承認されているかを確認します。

引数

  • _tokenId
    • トークンの識別子。

戻り値

  • address
    • 承認されたアドレス。

isApprovedForAll

javascript
function isApprovedForAll(address _owner, address _operator) external view returns (bool);

オペレーターが所有者の全トークンに対して承認されているか確認する関数。 指定したオペレーターが、所有者のすべてのNFTに対して送付権限を持つかを判定します。

引数

  • _owner
    • NFTの所有者。
  • _operator
    • 承認状態を確認するアドレス。

戻り値

  • bool
    • 承認されていればtrue。

getDelegateAccess

javascript
function getDelegateAccess(address _user) external view returns (address);

ユーザーに設定されているアクセスアシスタントを取得する関数。 指定ユーザーがどのアシスタントにアクセス権を委任しているかを返します。

引数

  • _user
    • 対象ユーザーのアドレス。

戻り値

  • address
    • 委任されているアシスタントのアドレス。

補足

検証の抽象化

この標準では、検証ロジックをNFT本体から分離し、IDataVerifierという独立したインターフェースにまとめています。 これにより、異なる検証方式(TEEまたはZKP)を柔軟に切り替えられる構造を実現しています。

検証方式 説明
TEE(Trusted Execution Environment) 信頼されたハードウェア内で証明を生成し、安全に鍵やデータを扱う方式。
ZKP(Zero-Knowledge Proof) データを公開せずに「正しいこと」を証明する暗号技術。

この検証システムは、2種類の証明をサポートします。

所有証明

Prover(証明者)が元のデータを実際に持っていることを示す証明です。 具体的には、「データハッシュを生成した元データ(pre-image)を知っている」ことを証明します。 この証明は、NFTが本物であること、つまりデータの所有権が正当に主張できることを保証します。

送付有効性証明

データ送付の完全性と安全性を保証するための証明です。 この証明によって以下の点が確認されます。

検証項目 内容
元データの知識 旧データのハッシュを生成する元データを知っている。
暗号化の正当性 古い鍵で復号し、新しい鍵で再暗号化できる。
鍵の安全な送信 受信者の公開鍵を使って新しいデータ鍵を暗号化して送信している。
データの完全性 新しい暗号化データのハッシュ値が正しい。
データの可用性 受信者が署名を行い、データが確かに保存されていることを確認している。

このように、NFTの所有権だけでなく、データそのものの安全な移転と可用性が保証されます。

データ保護

NFTが保持するAIエージェントのメタデータは非常に価値が高く、同時に知的財産でもあります。 この標準では、データを直接ブロックチェーン上に置くのではなく、ハッシュ値と暗号化鍵で間接的に扱う設計になっています。

この方式によって、以下の両方を保証して外部からの不正アクセスや盗用を防ぎます。

  • データの真正性(ハッシュ照合による検証)
  • データの機密性(暗号化による保護)

柔軟なデータ管理

ERC7857は、NFTのデータを扱うために3種類の操作モードをサポートしています。 これは、AIエージェントという性質上、「所有」と「利用」を明確に分離するための仕組みです。

操作モード 説明
完全転送(Full Transfer) データと所有権の両方を新しい所有者に移転します。NFTが完全に譲渡される場合に使用します。
データのクローン(Data Cloning) データを新しいトークンに複製しますが、元の所有権は保持されます。エージェントの派生版を作る際に利用されます。
使用権限の付与(Data Usage Authorization) 所有権は保持したまま、特定のユーザーに使用許可だけを与えます。ユーザーは直接データにはアクセスできず、秘密裏に利用要求を行う形になります。

この最後のモードで中心的な役割を果たすのが「Sealed Executor(シールドエグゼキュータ)」です。

シールドエグゼキュータ

「Sealed Executor」は、ユーザー認証と秘密処理を安全に行う環境を指します。 この環境自体の定義は標準の範囲外ですが、システムの成立に不可欠な要素として想定されています。

概要

Sealed Executorは、NFTのtokenIdをもとに承認グループを取得し、その中のユーザーが署名したリクエストを公開鍵で検証します。 検証に成功すれば、そのユーザーのリクエストを安全に実行し、結果を返します。

実装方法の例
実装方式 説明
信頼できる第三者 許可されたサービスが安全な環境で処理を行う。
TEE(Trusted Execution Environment) ハードウェアベースで秘密計算を実行。
FHE(Fully Homomorphic Encryption/完全準同型暗号) データを暗号化したまま演算を行うことで、プライバシーを維持。

この仕組みによって、ユーザーはデータの中身を知らなくても、AIエージェントを安全に利用できるようになります。 まさに、エージェントの「貸し出し」や「代行利用」を安全に行うための中核的なコンセプトです。

互換性

ERC7857は、従来のNFT標準(ERC721など)を直接継承していません。 理由は、ERC7857が「所有権だけでなくデータ管理そのもの」に焦点を当てているためです。 既存のNFTは主に「所有証明」の枠組みを提供しますが、データ暗号化・再暗号化・鍵管理といった機能は含まれていません。

ただし、実装者が望む場合、ERC721を併用実装することが可能です。 これにより、ウォレットやマーケットプレイスなど、従来のNFT互換環境とも連携できます。 たとえば、OpenSea上でERC721のNFTとして表示しつつ、内部ではERC7857の拡張機能によって安全なデータ管理を行う、といった構成が可能です。

参考実装

Verifier

js
abstract contract BaseVerifier is IERC7857DataVerifier {
    // prevent replay attack
    mapping(bytes32 => bool) internal usedProofs;
    
    // prevent replay attack
    mapping(bytes32 => uint256) internal proofTimestamps;
    
    function _checkAndMarkProof(bytes32 proofNonce) internal {
        require(!usedProofs[proofNonce], "Proof already used");
        usedProofs[proofNonce] = true;
        proofTimestamps[proofNonce] = block.timestamp;
    }
    
    // clean expired proof records (save gas)
    function cleanExpiredProofs(bytes32[] calldata proofNonces) external {
        for (uint256 i = 0; i < proofNonces.length; i++) {
            bytes32 nonce = proofNonces[i];
            if (usedProofs[nonce] && 
                block.timestamp > proofTimestamps[nonce] + 7 days) {
                delete usedProofs[nonce];
                delete proofTimestamps[nonce];
            }
        }
    }

    uint256[50] private __gap;
}

struct AttestationConfig {
    OracleType oracleType;
    address contractAddress;
}

contract Verifier is
    BaseVerifier,
    Initializable,
    AccessControlUpgradeable,
    PausableUpgradeable
{
    using ECDSA for bytes32;
    using MessageHashUtils for bytes32;

    event AttestationContractUpdated(AttestationConfig[] attestationConfigs);

    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

    mapping(OracleType => address) public attestationContract;

    uint256 public maxProofAge;

    string public constant VERSION = "2.0.0";

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(
        AttestationConfig[] calldata _attestationConfigs,
        address _admin
    ) external initializer {
        __AccessControl_init();
        __Pausable_init();

        for (uint256 i = 0; i < _attestationConfigs.length; i++) {
            attestationContract[
                _attestationConfigs[i].oracleType
            ] = _attestationConfigs[i].contractAddress;
        }
        maxProofAge = 7 days;

        _grantRole(DEFAULT_ADMIN_ROLE, _admin);
        _grantRole(ADMIN_ROLE, _admin);
        _grantRole(PAUSER_ROLE, _admin);

        emit AttestationContractUpdated(_attestationConfigs);
    }

    function updateAttestationContract(
        AttestationConfig[] calldata _attestationConfigs
    ) external onlyRole(ADMIN_ROLE) {
        for (uint256 i = 0; i < _attestationConfigs.length; i++) {
            attestationContract[
                _attestationConfigs[i].oracleType
            ] = _attestationConfigs[i].contractAddress;
        }

        emit AttestationContractUpdated(_attestationConfigs);
    }

    function updateMaxProofAge(
        uint256 _maxProofAge
    ) external onlyRole(ADMIN_ROLE) {
        maxProofAge = _maxProofAge;
    }

    function pause() external onlyRole(PAUSER_ROLE) {
        _pause();
    }

    function unpause() external onlyRole(PAUSER_ROLE) {
        _unpause();
    }

    function hashNonce(bytes memory nonce) private pure returns (bytes32) {
        return keccak256(nonce);
    }

    function teeOracleVerify(
        bytes32 messageHash,
        bytes memory signature
    ) internal view returns (bool) {
        return
            TEEVerifier(attestationContract[OracleType.TEE]).verifyTEESignature(
                messageHash,
                signature
            );
    }

    /// @notice Extract and verify signature from the access proof
    /// @param accessProof The access proof
    /// @return The recovered access assistant address
    function verifyAccessibility(
        AccessProof memory accessProof
    ) private pure returns (address) {
        bytes32 messageHash = keccak256(
            abi.encodePacked(
                "\x19Ethereum Signed Message:\n66",
                Strings.toHexString(
                    uint256(
                        keccak256(
                            abi.encodePacked(
                                accessProof.oldDataHash,
                                accessProof.newDataHash,
                                accessProof.encryptedPubKey,
                                accessProof.nonce
                            )
                        )
                    ),
                    32
                )
            )
        );

        address accessAssistant = messageHash.recover(accessProof.proof);
        require(accessAssistant != address(0), "Invalid access assistant");
        return accessAssistant;
    }

    function verfifyOwnershipProof(
        OwnershipProof memory ownershipProof
    ) private view returns (bool) {
        if (ownershipProof.oracleType == OracleType.TEE) {
            bytes32 messageHash = keccak256(
                abi.encodePacked(
                    "\x19Ethereum Signed Message:\n66",
                    Strings.toHexString(
                        uint256(
                            keccak256(
                                abi.encodePacked(
                                    ownershipProof.oldDataHash,
                                    ownershipProof.newDataHash,
                                    ownershipProof.sealedKey,
                                    ownershipProof.encryptedPubKey,
                                    ownershipProof.nonce
                                )
                            )
                        ),
                        32
                    )
                )
            );

            return teeOracleVerify(messageHash, ownershipProof.proof);
        }
        // TODO: add ZKP verification
        else {
            return false;
        }
    }

    /// @notice Process a single transfer validity proof
    /// @param proof The proof data
    /// @return output The processed proof data as a struct
    function processTransferProof(
        TransferValidityProof calldata proof
    ) private view returns (TransferValidityProofOutput memory output) {
        // compare the proof data in access proof and ownership proof
        require(
            proof.accessProof.oldDataHash == proof.ownershipProof.oldDataHash,
            "Invalid oldDataHashes"
        );
        output.oldDataHash = proof.accessProof.oldDataHash;
        require(
            proof.accessProof.newDataHash == proof.ownershipProof.newDataHash,
            "Invalid newDataHashes"
        );
        output.newDataHash = proof.accessProof.newDataHash;

        output.wantedKey = proof.accessProof.encryptedPubKey;
        output.accessProofNonce = proof.accessProof.nonce;
        output.encryptedPubKey = proof.ownershipProof.encryptedPubKey;
        output.sealedKey = proof.ownershipProof.sealedKey;
        output.ownershipProofNonce = proof.ownershipProof.nonce;

        // verify the access assistant
        output.accessAssistant = verifyAccessibility(proof.accessProof);

        bool isOwn = verfifyOwnershipProof(proof.ownershipProof);

        require(isOwn, "Invalid ownership proof");

        return output;
    }

    /// @notice Verify data transfer validity, the _proof prove:
    ///         1. The pre-image of oldDataHashes
    ///         2. The oldKey can decrypt the pre-image and the new key re-encrypt the plaintexts to new ciphertexts
    ///         3. The newKey is encrypted with the receiver's pubKey to get the sealedKey
    ///         4. The hashes of new ciphertexts is newDataHashes (key to note: TEE could support a private key of the receiver)
    ///         5. The newDataHashes identified ciphertexts are available in the storage: need the signature from the receiver signing oldDataHashes and newDataHashes
    /// @param proofs Proof generated by TEE/ZKP oracle
    function verifyTransferValidity(
        TransferValidityProof[] calldata proofs
    )
        public
        virtual
        override
        whenNotPaused
        returns (TransferValidityProofOutput[] memory)
    {
        TransferValidityProofOutput[]
            memory outputs = new TransferValidityProofOutput[](proofs.length);

        for (uint256 i = 0; i < proofs.length; i++) {
            TransferValidityProofOutput memory output = processTransferProof(
                proofs[i]
            );

            outputs[i] = output;

            bytes32 accessProofNonce = hashNonce(output.accessProofNonce);
            _checkAndMarkProof(accessProofNonce);

            bytes32 ownershipProofNonce = hashNonce(output.ownershipProofNonce);
            _checkAndMarkProof(ownershipProofNonce);
        }

        return outputs;
    }

    uint256[50] private __gap;
}

Main NFT

js
contract AgentNFT is
    AccessControlEnumerableUpgradeable,
    IERC7857,
    IERC7857Metadata
{
    event Updated(
        uint256 indexed _tokenId,
        IntelligentData[] _oldDatas,
        IntelligentData[] _newDatas
    );

    event Minted(
        uint256 indexed _tokenId,
        address indexed _creator,
        address indexed _owner
    );

    struct TokenData {
        address owner;
        address[] authorizedUsers;
        address approvedUser;
        IntelligentData[] iDatas;
    }

    /// @custom:storage-location erc7201:agent.storage.AgentNFT
    struct AgentNFTStorage {
        // Token data
        mapping(uint256 => TokenData) tokens;
        mapping(address owner => mapping(address operator => bool)) operatorApprovals;
        mapping(address user => address accessAssistant) accessAssistants;
        uint256 nextTokenId;
        // Contract metadata
        string name;
        string symbol;
        string storageInfo;
        // Core components
        IERC7857DataVerifier verifier;
    }

    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

    string public constant VERSION = "2.0.0";

    // keccak256(abi.encode(uint(keccak256("agent.storage.AgentNFT")) - 1)) & ~bytes32(uint(0xff))
    bytes32 private constant AGENT_NFT_STORAGE_LOCATION =
        0x4aa80aaafbe0e5fe3fe1aa97f3c1f8c65d61f96ef1aab2b448154f4e07594600;

    function _getAgentStorage()
        private
        pure
        returns (AgentNFTStorage storage $)
    {
        assembly {
            $.slot := AGENT_NFT_STORAGE_LOCATION
        }
    }

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize(
        string memory name_,
        string memory symbol_,
        string memory storageInfo_,
        address verifierAddr,
        address admin_
    ) public virtual initializer {
        require(verifierAddr != address(0), "Zero address");

        __AccessControlEnumerable_init();

        _grantRole(DEFAULT_ADMIN_ROLE, admin_);
        _grantRole(ADMIN_ROLE, admin_);
        _grantRole(PAUSER_ROLE, admin_);

        AgentNFTStorage storage $ = _getAgentStorage();
        $.name = name_;
        $.symbol = symbol_;
        $.storageInfo = storageInfo_;
        $.verifier = IERC7857DataVerifier(verifierAddr);
    }

    // Basic getters
    function name() public view virtual returns (string memory) {
        return _getAgentStorage().name;
    }

    function symbol() public view virtual returns (string memory) {
        return _getAgentStorage().symbol;
    }

    function verifier() public view virtual returns (IERC7857DataVerifier) {
        return _getAgentStorage().verifier;
    }

    // Admin functions
    function updateVerifier(
        address newVerifier
    ) public virtual onlyRole(ADMIN_ROLE) {
        require(newVerifier != address(0), "Zero address");
        _getAgentStorage().verifier = IERC7857DataVerifier(newVerifier);
    }

    function update(
        uint256 tokenId,
        IntelligentData[] calldata newDatas
    ) public virtual {
        AgentNFTStorage storage $ = _getAgentStorage();
        TokenData storage token = $.tokens[tokenId];
        require(token.owner == msg.sender, "Not owner");
        require(newDatas.length > 0, "Empty data array");

        IntelligentData[] memory oldDatas = new IntelligentData[](
            token.iDatas.length
        );
        for (uint i = 0; i < token.iDatas.length; i++) {
            oldDatas[i] = token.iDatas[i];
        }

        delete token.iDatas;

        for (uint i = 0; i < newDatas.length; i++) {
            token.iDatas.push(newDatas[i]);
        }

        emit Updated(tokenId, oldDatas, newDatas);
    }

    function mint(
        IntelligentData[] calldata iDatas,
        address to
    ) public payable virtual returns (uint256 tokenId) {
        require(to != address(0), "Zero address");
        require(iDatas.length > 0, "Empty data array");

        AgentNFTStorage storage $ = _getAgentStorage();

        tokenId = $.nextTokenId++;
        TokenData storage newToken = $.tokens[tokenId];
        newToken.owner = to;
        newToken.approvedUser = address(0);

        for (uint i = 0; i < iDatas.length; i++) {
            newToken.iDatas.push(iDatas[i]);
        }

        emit Minted(tokenId, msg.sender, to);
    }

    function _proofCheck(
        address from,
        address to,
        uint256 tokenId,
        TransferValidityProof[] calldata proofs
    )
        internal
        returns (bytes[] memory sealedKeys, IntelligentData[] memory newDatas)
    {
        AgentNFTStorage storage $ = _getAgentStorage();
        require(to != address(0), "Zero address");
        require($.tokens[tokenId].owner == from, "Not owner");
        require(proofs.length > 0, "Empty proofs array");

        TransferValidityProofOutput[] memory proofOutput = $
            .verifier
            .verifyTransferValidity(proofs);

        require(
            proofOutput.length == $.tokens[tokenId].iDatas.length,
            "Proof count mismatch"
        );

        sealedKeys = new bytes[](proofOutput.length);
        newDatas = new IntelligentData[](proofOutput.length);

        for (uint i = 0; i < proofOutput.length; i++) {
            // require the initial data hash is the same as the old data hash
            require(
                proofOutput[i].oldDataHash ==
                    $.tokens[tokenId].iDatas[i].dataHash,
                "Old data hash mismatch"
            );

            // only the receiver itself or the access assistant can sign the access proof
            require(
                proofOutput[i].accessAssistant == $.accessAssistants[to] ||
                    proofOutput[i].accessAssistant == to,
                "Access assistant mismatch"
            );

            bytes memory wantedKey = proofOutput[i].wantedKey;
            bytes memory encryptedPubKey = proofOutput[i].encryptedPubKey;
            if (wantedKey.length == 0) {
                // if the wanted key is empty, the default wanted receiver is receiver itself
                address defaultWantedReceiver = Utils.pubKeyToAddress(
                    encryptedPubKey
                );
                require(
                    defaultWantedReceiver == to,
                    "Default wanted receiver mismatch"
                );
            } else {
                // if the wanted key is not empty, the data is private
                require(
                    Utils.bytesEqual(encryptedPubKey, wantedKey),
                    "encryptedPubKey mismatch"
                );
            }

            sealedKeys[i] = proofOutput[i].sealedKey;
            newDatas[i] = IntelligentData({
                dataDescription: $.tokens[tokenId].iDatas[i].dataDescription,
                dataHash: proofOutput[i].newDataHash
            });
        }
        return (sealedKeys, newDatas);
    }

    function _transfer(
        address from,
        address to,
        uint256 tokenId,
        TransferValidityProof[] calldata proofs
    ) internal {
        AgentNFTStorage storage $ = _getAgentStorage();
        (
            bytes[] memory sealedKeys,
            IntelligentData[] memory newDatas
        ) = _proofCheck(from, to, tokenId, proofs);

        TokenData storage token = $.tokens[tokenId];
        token.owner = to;
        token.approvedUser = address(0);

        delete token.iDatas;
        for (uint i = 0; i < newDatas.length; i++) {
            token.iDatas.push(newDatas[i]);
        }

        emit Transferred(tokenId, from, to);
        emit PublishedSealedKey(to, tokenId, sealedKeys);
    }

    function iTransfer(
        address to,
        uint256 tokenId,
        TransferValidityProof[] calldata proofs
    ) public virtual {
        require(_isApprovedOrOwner(msg.sender, tokenId), "Not authorized");
        _transfer(ownerOf(tokenId), to, tokenId, proofs);
    }

    function transferFrom(
        address from,
        address to,
        uint256 tokenId
    ) public virtual {
        TokenData storage token = _getAgentStorage().tokens[tokenId];
        require(_isApprovedOrOwner(msg.sender, tokenId), "Not authorized");
        require(to != address(0), "Zero address");
        require(token.owner == from, "Not owner");
        token.owner = to;
        token.approvedUser = address(0);

        emit Transferred(tokenId, from, to);
    }

    function iTransferFrom(
        address from,
        address to,
        uint256 tokenId,
        TransferValidityProof[] calldata proofs
    ) public virtual {
        require(_isApprovedOrOwner(msg.sender, tokenId), "Not authorized");
        _transfer(from, to, tokenId, proofs);
    }

    function _clone(
        address from,
        address to,
        uint256 tokenId,
        TransferValidityProof[] calldata proofs
    ) internal returns (uint256) {
        AgentNFTStorage storage $ = _getAgentStorage();

        (
            bytes[] memory sealedKeys,
            IntelligentData[] memory newDatas
        ) = _proofCheck(from, to, tokenId, proofs);

        uint256 newTokenId = $.nextTokenId++;
        TokenData storage newToken = $.tokens[newTokenId];
        newToken.owner = to;
        newToken.approvedUser = address(0);

        for (uint i = 0; i < newDatas.length; i++) {
            newToken.iDatas.push(newDatas[i]);
        }

        emit Cloned(tokenId, newTokenId, from, to);
        emit PublishedSealedKey(to, newTokenId, sealedKeys);

        return newTokenId;
    }

    function iClone(
        address to,
        uint256 tokenId,
        TransferValidityProof[] calldata proofs
    ) public virtual returns (uint256) {
        require(_isApprovedOrOwner(msg.sender, tokenId), "Not authorized");
        return _clone(ownerOf(tokenId), to, tokenId, proofs);
    }

    function iCloneFrom(
        address from,
        address to,
        uint256 tokenId,
        TransferValidityProof[] calldata proofs
    ) public virtual returns (uint256) {
        require(_isApprovedOrOwner(msg.sender, tokenId), "Not authorized");
        return _clone(from, to, tokenId, proofs);
    }

    function authorizeUsage(uint256 tokenId, address to) public virtual {
        require(to != address(0), "Zero address");
        AgentNFTStorage storage $ = _getAgentStorage();
        require($.tokens[tokenId].owner == msg.sender, "Not owner");

        address[] storage authorizedUsers = $.tokens[tokenId].authorizedUsers;
        for (uint i = 0; i < authorizedUsers.length; i++) {
            require(authorizedUsers[i] != to, "Already authorized");
        }

        authorizedUsers.push(to);
        emit Authorization(msg.sender, to, tokenId);
    }

    function ownerOf(uint256 tokenId) public view virtual returns (address) {
        AgentNFTStorage storage $ = _getAgentStorage();
        address owner = $.tokens[tokenId].owner;
        require(owner != address(0), "Token does not exist");
        return owner;
    }

    function authorizedUsersOf(
        uint256 tokenId
    ) public view virtual returns (address[] memory) {
        AgentNFTStorage storage $ = _getAgentStorage();
        require(_exists(tokenId), "Token does not exist");
        return $.tokens[tokenId].authorizedUsers;
    }

    function storageInfo(
        uint256 tokenId
    ) public view virtual returns (string memory) {
        require(_exists(tokenId), "Token does not exist");
        return _getAgentStorage().storageInfo;
    }

    function _exists(uint256 tokenId) internal view returns (bool) {
        return _getAgentStorage().tokens[tokenId].owner != address(0);
    }

    function intelligentDataOf(
        uint256 tokenId
    ) public view virtual returns (IntelligentData[] memory) {
        AgentNFTStorage storage $ = _getAgentStorage();
        require(_exists(tokenId), "Token does not exist");
        return $.tokens[tokenId].iDatas;
    }

    function approve(address to, uint256 tokenId) public virtual {
        address owner = ownerOf(tokenId);
        require(to != owner, "Approval to current owner");
        require(
            msg.sender == owner || isApprovedForAll(owner, msg.sender),
            "Not authorized"
        );

        _getAgentStorage().tokens[tokenId].approvedUser = to;
        emit Approval(owner, to, tokenId);
    }

    function setApprovalForAll(address operator, bool approved) public virtual {
        require(operator != msg.sender, "Approve to caller");
        _getAgentStorage().operatorApprovals[msg.sender][operator] = approved;
        emit ApprovalForAll(msg.sender, operator, approved);
    }

    function getApproved(
        uint256 tokenId
    ) public view virtual returns (address) {
        require(_exists(tokenId), "Token does not exist");
        return _getAgentStorage().tokens[tokenId].approvedUser;
    }

    function isApprovedForAll(
        address owner,
        address operator
    ) public view virtual returns (bool) {
        return _getAgentStorage().operatorApprovals[owner][operator];
    }

    function delegateAccess(address assistant) public virtual {
        require(assistant != address(0), "Zero address");
        _getAgentStorage().accessAssistants[msg.sender] = assistant;
        emit DelegateAccess(msg.sender, assistant);
    }

    function getDelegateAccess(
        address user
    ) public view virtual returns (address) {
        return _getAgentStorage().accessAssistants[user];
    }

    function _isApprovedOrOwner(
        address spender,
        uint256 tokenId
    ) internal view returns (bool) {
        require(_exists(tokenId), "Token does not exist");
        address owner = ownerOf(tokenId);
        return (spender == owner ||
            getApproved(tokenId) == spender ||
            isApprovedForAll(owner, spender));
    }

    function batchAuthorizeUsage(
        uint256 tokenId,
        address[] calldata users
    ) public virtual {
        require(users.length > 0, "Empty users array");
        AgentNFTStorage storage $ = _getAgentStorage();
        require($.tokens[tokenId].owner == msg.sender, "Not owner");

        for (uint i = 0; i < users.length; i++) {
            require(users[i] != address(0), "Zero address in users");
            $.tokens[tokenId].authorizedUsers.push(users[i]);
            emit Authorization(msg.sender, users[i], tokenId);
        }
    }

    function revokeAuthorization(uint256 tokenId, address user) public virtual {
        AgentNFTStorage storage $ = _getAgentStorage();
        require($.tokens[tokenId].owner == msg.sender, "Not owner");
        require(user != address(0), "Zero address");

        address[] storage authorizedUsers = $.tokens[tokenId].authorizedUsers;
        bool found = false;

        for (uint i = 0; i < authorizedUsers.length; i++) {
            if (authorizedUsers[i] == user) {
                authorizedUsers[i] = authorizedUsers[
                    authorizedUsers.length - 1
                ];
                authorizedUsers.pop();
                found = true;
                break;
            }
        }

        require(found, "User not authorized");
        emit AuthorizationRevoked(msg.sender, user, tokenId);
    }
}

セキュリティ

証明の検証

AIエージェントNFTは、TEE(Trusted Execution Environment)やZKP(Zero-Knowledge Proof)といった暗号技術を利用してデータの正当性を証明します。 しかし、この仕組みを安全に運用するためには、すべての証明内容を厳密に検証する必要があります。

主な検証要件
  • 証明の完全性確認 スマートコントラクト上で、証明(Proof)に含まれるすべての主張が正しいことを確認する必要があります。 証明を部分的にしか検証しないと、不正なデータが受け入れられる恐れがあります。

  • リプレイ攻撃(Replay Attack)の防止 過去に提出された証明を再利用して偽の取引を発生させる攻撃を防ぐ必要があります。 これを防ぐため、各証明には一意のnonce(使い捨て値)を含め、同じ証明が再利用されないようにします。

検証方式ごとの違いと鍵管理の影響

TEEとZKPでは、秘密鍵の取り扱い能力が異なります。 この違いが、データの再暗号化や転送後の可視性に直接関係します。

検証方式 鍵の扱い 特徴とリスク
TEE(信頼実行環境) 複数当事者の秘密鍵を安全に扱うことが可能。 データを安全に再暗号化でき、転送後も旧所有者が新しいデータを参照できない。
ZKP(ゼロ知識証明) 暗号的性質上、複数の秘密鍵を扱えない。 再暗号化鍵をProver(送信者)が保持するため、送付やクローン後の更新時に再暗号化が必要。再暗号化しない場合、新しいデータが旧所有者にも見えるリスクがある。

このため、ZKP方式を利用する場合は、送付やクローン後の次回更新時に必ず再暗号化を行う必要があります。 これを怠ると、旧所有者が新しいデータの一部を解読できてしまう恐れがあります。

データのプライバシー

ERC7857では、NFTのメタデータ(AIモデルやメモリなど)そのものはブロックチェーン上に保存されません。 ブロックチェーン上に記録されるのは以下の2つのみです。

保存内容 説明
ハッシュ値(dataHash) データの真正性を検証するための指紋。実データを公開せずに整合性を確認できる。
暗号化鍵(sealedKey) データを復号するための鍵を暗号化して保管。直接の復号は不可能。

これにより、実際のAIエージェントデータはオフチェーン(ブロックチェーン外)で安全に保存・伝送されます。 データそのものがブロックチェーン上に存在しないため、外部から内容を覗かれることはありません。

また、鍵管理も非常に重要です。 TEEでは受信者の秘密鍵を安全に保持できますが、ZKPではこれができないため、再暗号化による更新が求められます。 これにより、常に最新の所有者だけがデータにアクセスできる状態が維持されます。

アクセス制御と状態管理

AIエージェントNFTの各操作(送付・複製・権限付与など)は、すべて所有者だけが実行できるように制限されています。 また、操作によってNFTの状態が変化する際は、必ず一貫性と検証可能性が保たれるように設計されています。

管理項目 要件
データ操作 すべてのデータ操作で整合性と可用性を維持する。
所有権や鍵の更新 変更はアトミック(不可分)に行われ、途中で失敗しない。
承認と権限の更新 すべての変更がブロックチェーン上で追跡・検証可能であること。

これにより、NFTのデータ・鍵・権限などが一貫した状態で維持され、改ざんや不正操作を防ぎます。

シールドエグゼキュータ

「Sealed Executor(シールドエグゼキュータ)」は、この標準の安全な運用に欠かせないコンポーネントです。 ERC7857の仕様には含まれませんが、安全なリクエスト処理と結果の返却を担う環境として想定されています。

主な役割
  • ユーザーを認証し、トークンIDごとの許可された公開鍵リストと照合する。
  • 署名検証に成功した場合のみ、リクエストを秘密裏に実行する。
  • 実行結果を安全にユーザーへ返す。

このように、Sealed Executorは「信頼された秘密計算環境」として機能します。

実装の選択肢
実装方式 説明
信頼できる第三者サーバー 運営者が安全な環境で実行を行う。規制が許される場合に利用可能。
TEE(Trusted Execution Environment) ハードウェアレベルで安全な演算を保証。
FHE(Fully Homomorphic Encryption/完全準同型暗号) データを暗号化したまま演算を行い、内容を知らずに結果を得る。

これにより、AIエージェントの利用者は実データを知ることなく、安全にリクエストを送信し、プライバシーを維持したままAIエージェントの機能を利用できます。

引用

Ming Wu (@sparkmiw), Jason Zeng (@zenghbo), Wei Wu (@Wilbert957), Michael Heinrich (@michaelomg), "ERC-7857: AI Agents NFT with Private Metadata [DRAFT]," Ethereum Improvement Proposals, no. 7857, January 2025. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7857.

最後に

今回は「AIのモデルや記憶、設定などのデータを安全に管理・譲渡できる新しいNFTの仕組みを提案しているERC7857」についてまとめてきました! いかがだったでしょうか?

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

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