はじめに
初めまして。
『DApps開発入門』という本や色々記事を書いているかるでねです。
以下でも情報発信しているので、興味ある記事があればぜひ読んでみてください!
AIエージェントが利用者の好みや過去の操作を覚える時、その内容は通常、事業者の非公開データベースへ保存されます。
しかし、事業者を移したり、後から監査したりする場面では、現在の記憶がどの更新を経て作られたのかを第三者が確認できません。
ERC8350は、メモリの中身をEthereumへ載せず、許可された更新が途切れず一列につながったことを検証するための規格です。
規格が定めるデータ、権限、署名、状態遷移と、公式参考実装の処理をコードとともに解説します。
概要
旅行を手配するAIエージェントが、利用者から「窓側の席を優先する」と教えられた場面を考えます。
事業者は、その好みを非公開データベースへ保存できます。
ただし、保存内容のhashだけをEthereumへ記録しても、その値がどの履歴の続きなのか、誰が更新を許可したのか、途中の更新が抜けていないかまでは分かりません。
ERC8350は、1つのメモリ履歴を表す空間を作り、更新ごとに現在位置、非公開データを表す値、解釈方法、保存先を表す値をまとめます。
更新を許可するアカウントの承認と、直前の状態から1つだけ進んでいることをregistryが確認し、最新地点を更新します。
生のメモリ、暗号鍵、非公開の保存先はregistryへ渡しません。
この仕組みにより、監査者は開示された証拠から「この更新が、登録された権限者に承認され、既知の履歴の直後へ追加された」と確認できます。
一方で、記録された内容が真実か、AIが実際にその記憶を使ったか、非公開データを現在も取得できるかは証明しません。
履歴を一列に固定する
各メモリ履歴では、空のheadの連番が0で、最初のtransitionの連番が1です。
さらに、直前までの履歴をまとめた値を持ちます。
この1列だけのエージェントメモリ履歴を、規格ではMemory Spaceと呼びます。
新しい更新は、保存中の連番よりちょうど1大きく、直前の値が現在値と一致する場合だけ受理されます。
この条件により、途中を飛ばした更新や古い地点へ戻る更新は拒否されます。
分岐や複数の更新を後で統合する仕組みは規格の対象外です。
内容を隠して更新を照合する
registryへ置くのは、生の好みや会話ではなく、秘密の値から計算した固定長のcommitmentです。
commitmentは、後から元データと秘密の値を開示した時に、登録時と同じ内容だったか照合するための値です。
更新内容、由来、保存場所は別々のcommitmentへ分けます。
この分離により、例えば更新内容だけを監査者へ開示し、保存場所は伏せたままにできます。
開始から検証まで
まず管理者がメモリ空間を登録し、日常の更新を承認するアカウントを指定します。
AIエージェントの運用側は、非公開データからcommitmentを作り、現在の履歴位置とまとめて署名を受けます。
送信者は署名済みの更新をレジストリへ提出し、レジストリは権限と連続性を確認して最新地点を進めます。
監査者はイベントと現在値を読み、同じ計算を再実行できます。
必要な場合だけ、運用側から開示された非公開データをcommitmentと照合します。
動機
AIエージェントのメモリを別の事業者へ移す時、移行先は渡されたデータが最新なのか、途中で差し替えられていないかを独力で判断できません。
単一のhashを公開する方法では、そのhashを誰が承認したか、どの状態を進めたか、別の実装でも同じ識別値になるかが決まりません。
反対に、生のプロンプト、利用者の好み、埋め込み表現、運用方針をEthereumへ書けば、内容が公開され、保存費用も増えます。
ERC8350は、公開する値を固定長の申告と署名へ絞り、内容を保管・解釈する仕組みをオフチェーンへ残します。
単純なhashで不足する情報
単純なhash、一列のregistry、分岐可能な履歴を比較すると、用途の違いが見えます。
| 方式 | 権限の確認 | 1つ前との接続 | 後続の数 | 適する用途 |
|---|---|---|---|---|
| 単純なhash | 別の仕組みが必要 | 分からない | 決まらない | 内容が変わっていないかだけ確認する |
| ERC8350 | controllerとauthorizerを記録 | 直前のrootと連番で確認 | 1つ | 監査できる一本道の履歴を作る |
| 分岐対応の履歴 | 設計次第 | 複数の親子関係を管理 | 複数 | 並行編集やmergeが必要な履歴を作る |
ERC8350は、任意の履歴モデルではなく、1つの確定済みheadを順番に進める用途へ絞っています。
非公開メモリをchainへ置かない理由
プロンプトや利用者の好みには個人情報が含まれる可能性があります。
保存先のURIには、事業者名、tenant ID、access tokenなどが含まれることもあります。
ERC8350は、生データを非公開のまま保管し、それぞれを表すcommitmentだけを署名対象へ入れます。
これにより、送信を代行するrelayerも、署名済みの保存先や更新内容を別の値へ差し替えられません。
実装間で同じ判断を再現する
同じ更新から異なる識別子が作られると、監査者とregistryが同じ履歴を参照できません。
ERC8350は、型文字列、フィールド順、ハッシュ式、署名ドメインを固定します。
公式テストベクタには、入力、コミットメント、型ハッシュ、transition ID、更新後のstate root、署名digestが掲載されています。
実装者は自分の計算結果を公式値と比較し、符号化やfield順の違いを検出できます。
採用条件と代替
更新を1件ずつ確定し、後から順序と承認者を検証したい場合にERC8350が適します。
単発のファイルhashだけで足りる用途や、複数branchを並行してmergeする用途では、より単純な記録または別の履歴モデルを選びます。
規格は、どのAIモデル、ベクトルDB、検索方式を使うかを定めません。
メモリ内容の真実性や可用性まで必要な場合は、データ可用性、provenance、proofの仕組みを別に組み合わせます。
仕様
ERC8350は、メモリ履歴を識別する値、更新申告、履歴をつなぐ計算、権限、署名とinterfaceを定めます。
2026年10月15日時点ではDraftです。
MemorySpace
MemorySpaceは、1本のメモリ履歴を分離する名前空間です。
最初のcontrollerと32-byteのsaltから、以下の式でspaceIdを作ります。
MEMORY_SPACE_TYPE =
"MemorySpace(address initialController,bytes32 salt)"
spaceId = keccak256(abi.encode(
keccak256(bytes(MEMORY_SPACE_TYPE)),
initialController,
salt
))
| 入力 | 役割 |
|---|---|
initialController |
最初に空間と権限設定を管理するアカウントです。zero addressは使えません。 |
salt |
同じcontrollerが複数の空間を作るための32-byte値です。登録まで非公開にできます。 |
spaceId |
controllerとsaltの組から決まるメモリ空間の識別子です。 |
controllerを計算へ含めるため、第三者が同じsaltだけを使って先に空間を登録することを防ぎます。
saltは登録関数のcalldataへ渡るため、登録後も秘密にする値ではありません。
ExperienceDelta
ExperienceDeltaは、1回のメモリ更新を固定幅で表す申告です。
運用側が作り、authorizerが署名し、レジストリと監査者が同じフィールドを読みます。
struct ExperienceDelta {
bytes32 spaceId;
uint64 sequence;
bytes32 prevStateRoot;
bytes32 deltaCommitment;
bytes32 provenanceCommitment;
bytes32 profileId;
bytes32 locatorCommitment;
}
| フィールド | 作る値と意味 | 条件 |
|---|---|---|
spaceId |
更新するメモリ空間を指定します。 | 登録済みの空間である必要があります。 |
sequence |
その空間で何番目の更新かを示します。 | 最初は1で、現在値より1だけ大きくします。 |
prevStateRoot |
更新直前までの履歴を表します。 | 最初はzero、以降は現在のrootと一致させます。 |
deltaCommitment |
非公開の更新内容または暗号文を表します。 | zeroは使えません。 |
provenanceCommitment |
入力や推論attestationなど、更新の由来を表します。 | 省略時はzeroです。 |
profileId |
commitmentの作り方と非公開データの解釈方法を区別します。 | zeroは使えません。 |
locatorCommitment |
非公開の保存先を表します。 | 省略時はzeroです。生のURIは入れません。 |
生のメモリ、commitment用salt、暗号鍵、raw locatorは含めません。
誰が書いたという自己申告や時刻もfieldへ入れず、承認者は署名、観測時刻はchain側の記録で扱います。
Transition ID
Transition IDは、1回のExperienceDeltaを実装間で同じ値として指す識別子です。
JSONなどへ変換せず、正確な型文字列と7つのfieldをabi.encodeして計算します。
ExperienceDelta(bytes32 spaceId,uint64 sequence,bytes32 prevStateRoot,bytes32 deltaCommitment,bytes32 provenanceCommitment,bytes32 profileId,bytes32 locatorCommitment)
transitionId = keccak256(abi.encode(
EXPERIENCE_DELTA_TYPEHASH,
delta.spaceId,
delta.sequence,
delta.prevStateRoot,
delta.deltaCommitment,
delta.provenanceCommitment,
delta.profileId,
delta.locatorCommitment
));
公式テストベクタの1件目では、transitionIdは0xdd00dd6e...91e7f4e5になります。
transitionId自体はchainに依存しません。
一方、署名を検証するdigestにはchainとregistryが含まれるため、両者を同じ値として扱いません。
State Root
State Rootは、現在までに受理された更新の並びを表す累積値です。
直前のrootと今回のtransitionIdから更新後の値を計算します。
MEMORY_STATE_TYPE =
"MemoryState(bytes32 prevStateRoot,bytes32 transitionId)"
nextStateRoot = keccak256(abi.encode(
keccak256(bytes(MEMORY_STATE_TYPE)),
prevStateRoot,
transitionId
))
| 確認対象 | 受理条件 | 失敗時 |
|---|---|---|
| 連番 | sequence == currentSequence + 1 |
飛び越し、重複、古い連番を拒否します。 |
| 直前のroot | prevStateRoot == currentStateRoot |
別branchや古いheadからの更新を拒否します。 |
成功時にはtransitionId、nextStateRoot、sequenceを同じtransactionで保存します。
どれかの確認で失敗するとtransaction全体がrevertし、headは変わりません。
権限モデル
1つの空間には、管理操作を行うcontrollerと、日常のメモリ更新を認めるauthorizerがあります。
| 役割 | 許可する操作 | 侵害された場合の影響 |
|---|---|---|
controller |
controllerとauthorizerを同時に交換します。 | 攻撃者が両方の権限を置き換えられます。 |
authorizer |
後続のExperienceDeltaを承認します。 |
攻撃者が正しい形式の偽更新を追加できます。 |
relayer |
署名済みtransactionを提出します。 | 値は変更できませんが、提出の保留や競合を起こせます。 |
権限更新では、現在のconfigNonceより1大きい値を署名対象へ含めます。
古い権限設定の署名を再利用しても、nonceが現在値と一致しないため受理されません。
署名domain
空間登録、権限更新、状態遷移の署名には、EIP712のdomainを使います。
EIP712は、ウォレットとコントラクトが同じ型付きデータをハッシュ化する形式です。
署名domainにはchain IDとregistry addressを入れます。
これにより同じ構造への署名を別chainや別registryで再利用できません。
EIP712については以下の記事を参考にしてください。
| フィールド | 値 | 防ぐ取り違え |
|---|---|---|
name |
AgentMemoryState |
別用途の署名との混同を避けます。 |
version |
1 |
非互換な署名形式を区別します。 |
chainId |
現在のchain ID | 別chainでのreplayを防ぎます。 |
verifyingContract |
レジストリのアドレス | 別のレジストリでのreplayを防ぎます。 |
署名対象はkeccak256(0x1901 || domainSeparator || structHash)です。
独立に検証できるtransition参照には、チェーン、レジストリのアドレス、spaceId、transitionIdを含めます。
強制logicの不変性
履歴が一列になる保証は、保存されたデータではなく、受理条件を実行するcodeにあります。
実装を交換できるproxyでは、後から連番や署名の確認を外したcodeへ変更できてしまいます。
準拠レジストリは、線形性、root chaining、署名検証を実行するロジックを交換できるupgrade機構の背後へ置けません。
公式vectorと同じhashを計算できることと、deploymentが将来も同じ受理条件を守ることは別の確認です。
代表的なproxyでは標準化されたstorage slotを読みます。
slot確認と公開sourceを組み合わせ、強制logicを交換する権限が残っていないか調べます。
deployment監査では、公開sourceとbytecode、owner、admin、initializer、upgrade関数、任意のdelegatecall、ERC1967 slot、独自proxy機構を確認します。
ERC1967の標準slotが空でも、独自のupgrade経路がない証明にはなりません。
ERC1967については以下の記事を参考にしてください。
IAgentMemoryState
規格のinterfaceには、1つの構造体、3つのイベント、6つの必須関数があります。
参考実装は、監査や統合を助ける任意の読み取り・計算関数も追加しています。
ExperienceDelta
struct ExperienceDelta {
bytes32 spaceId;
uint64 sequence;
bytes32 prevStateRoot;
bytes32 deltaCommitment;
bytes32 provenanceCommitment;
bytes32 profileId;
bytes32 locatorCommitment;
}
registryへ提出する1回分の更新申告です。
field順はv1のwire formatの一部であり、並べ替えるとtransitionIdが変わります。
- フィールド
| フィールド | 詳細 |
|---|---|
spaceId |
更新するMemory Spaceです。 |
sequence |
その空間での更新連番です。 |
prevStateRoot |
更新直前のstate rootです。 |
deltaCommitment |
非公開の更新内容を表す値です。 |
provenanceCommitment |
更新の由来を表す任意値です。 |
profileId |
commitmentとprivate witnessの解釈規則を区別する値です。 |
locatorCommitment |
非公開の保存先を表す任意値です。 |
core registryは、profileIdの導出方法、衝突回避、登録場所、governance、profile定義の配布方法を定めません。
検証者は対応するprofile定義を別経路で取得し、その定義が示す符号化と検証規則でprivate witnessを確認します。
SpaceRegistered
event SpaceRegistered(
bytes32 indexed spaceId,
address indexed controller,
address indexed authorizer
);
新しいメモリ空間の登録完了を通知します。
登録直後のtransitionId、stateRoot、sequence、configNonceはzeroですが、このイベントには含みません。
- フィールド
| フィールド | 詳細 |
|---|---|
spaceId |
登録したMemory Spaceです。 |
controller |
最初の管理者です。 |
authorizer |
最初の状態遷移承認者です。 |
このイベントだけでは、spaceIdの導出に使ったsaltやcontrollerの承認を復元できません。
独立監査では登録transactionのcalldataと送信者または署名も確認します。
SpaceAuthorizationUpdated
event SpaceAuthorizationUpdated(
bytes32 indexed spaceId,
address indexed controller,
address indexed authorizer,
uint64 configNonce
);
controllerまたはauthorizerを更新した結果を通知します。
2つのaddressとconfigNonceは更新後の値です。
監査者は登録からイベントを順に追い、各状態遷移が発生した時点のauthorizerを復元します。
- フィールド
| フィールド | 詳細 |
|---|---|
spaceId |
権限を更新したMemory Spaceです。 |
controller |
更新後のcontrollerです。 |
authorizer |
更新後のauthorizerです。 |
configNonce |
更新後の権限設定連番です。 |
TransitionCommitted
event TransitionCommitted(
bytes32 indexed spaceId,
bytes32 indexed transitionId,
uint64 indexed sequence,
bytes32 prevStateRoot,
bytes32 nextStateRoot,
bytes32 deltaCommitment,
bytes32 provenanceCommitment,
bytes32 profileId,
bytes32 locatorCommitment,
address authorizer
);
状態遷移が受理され、headが更新されたことを通知します。
監査者は、直前と更新後のroot、コミットメント、profile、承認時点のauthorizerを取得できます。
イベントに署名bytesは含まれません。
非空署名の検証にはtransactionのcalldataが必要で、空署名の検証にはtransactionの送信者を確認します。
- フィールド
| フィールド | 詳細 |
|---|---|
spaceId |
更新したMemory Spaceです。 |
transitionId |
今回の更新識別子です。 |
sequence |
受理した更新連番です。 |
prevStateRoot |
更新直前のrootです。 |
nextStateRoot |
更新後のrootです。 |
deltaCommitment |
非公開の更新内容を表す値です。 |
provenanceCommitment |
更新の由来を表す任意値です。 |
profileId |
解釈profileの識別値です。 |
locatorCommitment |
非公開の保存先を表す任意値です。 |
authorizer |
受理時点の状態遷移承認者です。 |
deriveSpaceId
function deriveSpaceId(address initialController, bytes32 salt)
external pure returns (bytes32 spaceId);
最初のcontrollerとsaltからspaceIdを計算します。
登録前にclientとregistryが同じ識別子を得るための関数です。
-
controllerを確認
initialControllerがzero addressでないことを確認します。 -
識別子を計算
型ハッシュ、controller、saltを
abi.encodeし、keccak256を適用します。
- 引数
| 引数 | 詳細 |
|---|---|
initialController |
最初に空間を管理するaddressです。 |
salt |
空間を区別する32-byte値です。 |
- 戻り値
| 戻り値 | 詳細 |
|---|---|
spaceId |
型ハッシュ、controller、saltから計算した識別子です。 |
-
実行条件
-
状態を変更しない
pure関数であり、任意のcallerが実行できます。 -
initialControllerがzero addressの場合は失敗します。
registerSpace
function registerSpace(
bytes32 spaceId,
address controller,
address authorizer,
bytes32 salt,
bytes calldata controllerSignature
) external;
新しいメモリ空間を登録し、最初のcontrollerとauthorizerを保存します。
relayerが提出する場合は、controllerがSpaceRegistrationへ付けた署名を渡します。
署名対象の型文字列とstruct hashは以下です。
SpaceRegistration(bytes32 spaceId,address controller,address authorizer)
keccak256(abi.encode(
SPACE_REGISTRATION_TYPEHASH,
spaceId,
controller,
authorizer
))
-
入力を確認
zero値、導出した
spaceIdとの一致、未登録であることを確認します。 -
controllerの承認を確認
controller本人の直接呼び出しまたは署名から承認を確認します。
-
初期状態を保存
controllerとauthorizerを保存し、headとnonceをzeroで初期化します。
-
登録を通知
SpaceRegisteredイベントを発行します。
- 引数
| 引数 | 詳細 |
|---|---|
spaceId |
登録する空間の識別子です。 |
controller |
権限設定を管理するアドレスです。 |
authorizer |
状態遷移を承認するアドレスです。 |
salt |
spaceIdの導出に使った値です。 |
controllerSignature |
controllerによるEIP712署名です。公式参考実装では、本人が直接呼び出す時に空にできます。 |
- 発行イベント
成功時にSpaceRegisteredイベントを発行します。
-
実行条件
-
指定した空間が未登録である場合に実行できます。
-
controllerとauthorizerはzero address以外を指定します。
-
導出した
spaceIdと引数の値を一致させます。 -
controllerの承認を有効にします。
-
空署名による本人の直接呼び出しを受理する処理は規格上MAYであり、公式参考実装は
msg.sender == controllerの場合だけ受理します。
updateSpaceAuthorization
function updateSpaceAuthorization(
bytes32 spaceId,
address newController,
address newAuthorizer,
bytes calldata controllerSignature
) external;
現在のcontrollerによる承認を確認し、controllerとauthorizerを同じtransactionで置き換えます。
一方だけを維持する場合は、そのaddressを新しい値にも指定します。
署名対象の型文字列とstruct hashは以下です。
SpaceAuthorization(bytes32 spaceId,address newController,address newAuthorizer,uint64 nonce)
keccak256(abi.encode(
SPACE_AUTHORIZATION_TYPEHASH,
spaceId,
newController,
newAuthorizer,
nonce
))
-
空間を読み取り
登録済みの
SpaceRecordと現在のconfigNonceを読みます。 -
更新内容をhash化
2つの新しいaddressと
configNonce + 1を署名対象へ入れます。 -
controllerの承認を確認
現在のcontroller本人による直接呼び出しまたは署名を検証します。
-
権限をまとめて更新
2つのaddressとnonceを保存し、イベントを発行します。
- 引数
| 引数 | 詳細 |
|---|---|
spaceId |
権限を変更する空間です。 |
newController |
更新後のcontrollerです。 |
newAuthorizer |
更新後のauthorizerです。 |
controllerSignature |
現在のcontrollerが新しいaddressと後続nonceを承認した署名です。 |
- 発行イベント
成功時にSpaceAuthorizationUpdatedイベントを発行します。
-
実行条件
-
空間が登録済みである場合に実行できます。
-
2つの新しいaddressはzero以外を指定します。
-
現在のcontrollerの承認を有効にします。
-
空署名による本人の直接呼び出しを受理する処理は規格上MAYであり、公式参考実装は
msg.senderが現在のcontrollerと一致する場合だけ受理します。 -
条件を満たさないtransactionはrevertし、2つのaddressと
configNonceを変更しません。
commitTransition
function commitTransition(
ExperienceDelta calldata delta,
bytes calldata authorizerSignature
) external returns (bytes32 transitionId, bytes32 nextStateRoot);
登録済み空間のheadを、ちょうど1つ先の状態へ進めます。
現在のauthorizerによる署名を確認してから、個別transitionと最新headを保存します。
-
空間と必須値を確認
登録済み空間、非zeroの
deltaCommitmentとprofileIdを確認します。 -
履歴の接続を確認
連番が現在値より1大きく、
prevStateRootが現在値と一致することを確認します。 -
更新と署名を確認
transitionIdを計算し、重複とauthorizerの承認を確認します。 -
2つの保存先を更新
個別transitionを保存し、空間のhead、root、sequenceを更新します。
-
確定結果を通知
TransitionCommittedイベントを発行し、2つの計算値を返します。
- 引数
| 引数 | 詳細 |
|---|---|
delta |
7つのfieldを持つ更新申告です。 |
authorizerSignature |
現在のauthorizerがtransitionIdへ付けた署名です。公式参考実装では、本人が直接呼び出す時に空にできます。 |
- 戻り値
| 戻り値 | 詳細 |
|---|---|
transitionId |
今回の申告から計算した識別子です。 |
nextStateRoot |
直前のrootとtransitionIdから計算した更新後のrootです。 |
- 発行イベント
成功時にTransitionCommittedイベントを発行します。
-
実行条件
-
空間が登録済みである場合に実行できます。
-
deltaCommitmentとprofileIdはzero以外を指定します。 -
連番を現在値より1大きくし、
prevStateRootを現在値と一致させます。 -
現在のauthorizerの承認を有効にします。
-
空署名による本人の直接呼び出しを受理する処理は規格上MAYであり、公式参考実装は
msg.senderが現在のauthorizerと一致する場合だけ受理します。 -
公式参考実装は、すでに保存された同じ
transitionIdも拒否します。
head
function head(bytes32 spaceId) external view returns (
bytes32 transitionId,
bytes32 stateRoot,
uint64 sequence
);
指定した空間で最後に受理された更新と現在の履歴位置を返します。
clientは新しい更新を作る直前にこの値を読み、sequence + 1と現在のstateRootを新しい申告へ入れます。
-
空間を確認
spaceIdに対応する登録済みの記録を取得します。 -
現在値を返す
headの
transitionId、stateRoot、sequenceを返します。
- 引数
| 引数 | 詳細 |
|---|---|
spaceId |
読み取るメモリ空間です。 |
- 戻り値
| 戻り値 | 詳細 |
|---|---|
transitionId |
現在のheadになっている更新です。 |
stateRoot |
現在までの履歴を累積した値です。 |
sequence |
最後に受理された連番です。 |
-
実行条件
-
任意のcallerが読み取れます。
-
規範interfaceは未登録空間に対する戻り方を定めません。
-
公式参考実装は未登録空間を
UnknownSpaceでrevertします。
spaceAuthorization
function spaceAuthorization(bytes32 spaceId) external view returns (
address controller,
address authorizer,
uint64 configNonce
);
指定した空間の現在の権限設定を返します。
署名を作る前と検証する時に、誰の承認が必要かを確認できます。
-
空間を確認
spaceIdに対応する登録済みの記録を取得します。 -
権限設定を返す
controller、authorizer、
configNonceを返します。
- 引数
| 引数 | 詳細 |
|---|---|
spaceId |
読み取るメモリ空間です。 |
- 戻り値
| 戻り値 | 詳細 |
|---|---|
controller |
現在の管理者です。 |
authorizer |
現在の状態遷移承認者です。 |
configNonce |
権限更新の現在の連番です。 |
-
実行条件
-
任意のcallerが読み取れます。
-
規範interfaceは未登録空間に対する戻り方を定めません。
-
公式参考実装は未登録空間を
UnknownSpaceでrevertします。
補足
規格を統合する時は、commitmentの作成、外部参照、eventからの再構築、競合時の再試行をregistryの受理条件と分けて実装します。
非公開データのcommitment
規格が推奨するbaselineでは、用途ごとにdomain tagを分け、元データのハッシュ、profile、秘密のsaltをまとめます。
v1で固定するdomain tagは以下の文字列から計算します。
DELTA_DOMAIN = keccak256(bytes("AgentMemoryState.deltaCommitment.v1"))
PROVENANCE_DOMAIN = keccak256(bytes("AgentMemoryState.provenanceCommitment.v1"))
LOCATOR_DOMAIN = keccak256(bytes("AgentMemoryState.locatorCommitment.v1"))
deltaCommitment = keccak256(abi.encode(
DELTA_DOMAIN,
profileId,
deltaSalt,
keccak256(payloadBytes)
))
provenanceCommitment = keccak256(abi.encode(
PROVENANCE_DOMAIN,
provenanceSalt,
keccak256(provenanceBytes)
))
locatorCommitment = keccak256(abi.encode(
LOCATOR_DOMAIN,
locatorSalt,
keccak256(bytes(locator))
))
この計算で使うprofileIdは、private witnessの符号化と照合手順を識別します。
core registryはprofile定義を配布しないため、検証者は運用者やprofile管理者が公開する定義を別経路で取得します。
3つのsaltは、それぞれ独立した32-byteの乱数にします。
候補が少ない値へ公開saltを付けるだけでは、攻撃者が候補を1つずつ試す辞書攻撃を防げません。
秘密saltを維持するか、新しい鍵とnonceで暗号化したデータをcommitします。
検証可能なtransition参照
spaceIdはcontrollerとsaltだけから作るため、同じ値を複数chainで使えます。
各chainの履歴は別々に進められるため、spaceIdとtransitionIdだけでは検証対象を一意に決められません。
外部文書や監査レポートから参照する時は、チェーン、レジストリのアドレス、spaceId、transitionIdを残します。
前の2つがなければEIP712 domainを再構築できず、署名を独立に確認できません。
履歴の再構築と競合時の再試行
監査者は十分なconfirmationを待ってから、登録イベント、権限更新、状態遷移をblock順、log順に読みます。
各transactionのcalldataも取得し、登録ではsaltとcontrollerの承認、権限更新ではcontrollerの承認、状態遷移ではauthorizerの承認を確認します。
非空署名はcalldataの署名bytesで検証し、空署名はtransactionの送信者が当時のcontrollerまたはauthorizer本人だったことを確認します。
各transitionでは型hash、transitionId、更新後のroot、連番、直前rootを再計算します。
confirmation前にreorgが起きた場合、indexerは分岐前のblockまで戻ってイベントとtransactionを読み直します。
confirmation数と再同期方法は規格が定めないため、clientとindexerが運用条件として決めます。
2人のrelayerが同じheadを読んで異なる次更新を作ると、先に確定した1件だけが受理されます。
後から送った更新は直前rootまたは連番が合わずrevertします。
クライアントは最新のheadを読み直し、新しい値で申告を作り、authorizerから再署名を受けます。
回復と終了の非保証
controllerはauthorizerを交換できますが、controller自身を失った場合の別の回復者や待機期間は規格が定めません。
空間の終了、凍結、権限放棄を表す標準状態もありません。
回復や停止が必要なdeploymentは、controllerをmultisigやpolicy contractにし、その運用規則をregistry外で決めます。
ただし、強制logicを交換できるupgrade権限は持たせられません。
容量と実行コスト
ExperienceDeltaは固定幅ですが、空間数やtransition数に上限はありません。
sequenceとconfigNonceはuint64であり、実用上は非常に大きいものの有限です。
どちらかがuint64の最大値へ達すると、公式参考実装ではSolidityのchecked arithmeticにより+ 1がrevertします。
規格は上限到達後の移行、終了、再開方法を定めません。
参考実装は各transitionの一部をstorageへ保存し、詳細をイベントにも残します。
長期運用では、storage書き込み、event取得、indexer、archive nodeの費用を見積もります。
観測時刻と対象外
実装は、chain上でtransitionを観測した時刻としてblock.timestampを保存できます。
アプリが申告した時刻で更新順を決めてはいけません。
エージェント識別、生メモリの保存と取得、データ可用性、推論の正しさ、分岐と統合、削除証明、支払い、トークン化、cross-chain移行は対象外です。
テストケース
公式vectorを再計算する
公式テストベクタは、型文字列から計算する3つのtypehashと、同じ入力から得るtransitionId、nextStateRootを固定しています。
experienceDeltaTypehash = 0x4f020f86bc06d852f1fde17853b4d92a70214eeab8e09718028124af097d070d
memoryStateTypehash = 0xf3148762556cbf851baf4b9a205e18ff4e6b366a58a3a1ef58e8626ba41beadb
memorySpaceTypehash = 0x9ae5478f084ad3b841da58a9cb2354d153cddec59ee64d0cb741fa9d08884531
transitionId = 0xdd00dd6eb3aec704b5455502647a0caacf23be6c724eda4a60d9645291e7f4e5
nextStateRoot = 0x9684a8d3571c5cd9c1e3abb1b0c0797b9fef6965e9002aeefba91e8cb1163754
domainSeparator = 0xc8e1b4a128b4de377427c24ccea92cac6d7c77f8fe55f4b2984432bbc3b3d5b9
signingDigest = 0xaa76377b6dc395d65e2c346a4ff3176bcbe3d27223e01b68be450f81f0f5f494
registrationId = 0x7bea30aa6018c9c8a3e66bd9573377af482dc791994af8c50698e3fc9d4e5d64
authorizationId = 0xa1fef7a8e52a533f7839f51962fa6e3ef9f8a8081efbac1eb13651801f4d1a92
JSONの入力と計算先を対応させると以下です。
| JSONの入力 | 計算 | 比較する値 |
|---|---|---|
types.experienceDelta、types.memoryState、types.memorySpace
|
各文字列をUTF-8 bytesへ変換してkeccak256を計算します。 |
expectedの3つのtypehashです。 |
deltaの7フィールド |
hashExperienceDeltaと同じ順でABI符号化します。 |
expected.transitionIdです。 |
delta.prevStateRootと計算済みtransitionId
|
computeNextStateRootと同じ順でABI符号化します。 |
expected.nextStateRootです。 |
eip712のname、version、chainId、verifyingContract |
EIP712 domainを符号化します。 |
eip712.domainSeparatorです。 |
計算済みdomain separatorとtransitionId
|
0x1901を前置してハッシュ化します。 |
eip712.signingDigestです。 |
spaceAuthorizationのcontroller、authorizer、spaceSalt |
deriveSpaceIdと登録用struct hashを順に計算します。 |
spaceAuthorization.registrationIdです。 |
同じ空間、新しい2つの権限address、updateNonce
|
権限更新用struct hashを計算します。 |
spaceAuthorization.authorizationIdです。 |
machine-readable vectorは以下です。
失敗条件と保証の限界
実装では少なくとも以下を検証します。
| 確認対象 | 期待する結果 |
|---|---|
| 正しい型文字列とフィールド順 | 公式のtransitionIdとnextStateRootを再現します。 |
| 最初の更新 |
sequence = 1、prevStateRoot = 0だけを受理します。 |
| 連番、直前root、必須フィールド | 飛び越し、古いroot、zeroの必須値を拒否します。 |
| 公式参考実装の直接呼び出し | 空署名を本人だけ受理します。 |
| contract署名とEOA署名 | magic valueとcanonicalな65-byte ECDSA署名を正しく判定します。 |
| 権限更新後の古い署名 | 古いauthorizerの署名を拒否します。 |
| transactionの失敗 | 個別transitionとheadのどちらも変更しません。 |
公式値との一致は、符号化とハッシュ計算がv1に一致することを示します。
権限管理、データ可用性、レジストリのupgrade不能性までは保証しません。
互換性
ERC8350は既存interfaceを変更せず、新しいregistryを追加します。
一方、v1で固定された型文字列やdomain tagを変えると、同じデータから別の識別値が作られます。
v1で固定される値
| 固定対象 | 変更時の影響 |
|---|---|
ExperienceDeltaの型文字列とfield順 |
transitionIdが変わります。 |
MemoryStateの型文字列 |
nextStateRootが変わります。 |
| EIP712 domainのnameとversion | 署名digestが変わります。 |
| baseline commitmentのdomain tag | commitmentが変わります。 |
別形式を導入する場合は、v1と同じ履歴へ暗黙に混ぜず、versionと移行地点を明示します。
旧prototypeからの移行
JCSで作る識別子、署名対象に含まれないURI、固定されたメモリ種別、previousDelta pointerを使う旧prototypeはv1とwire互換ではありません。
移行時は旧履歴の最終値を外部checkpointとして記録し、新しいMemorySpaceでv1履歴を開始します。
旧値をv1のfieldへ機械的に詰め替えて同じ識別子になるとは扱いません。
Draftと採用状況
ERC8350は2026年10月15日時点でDraftです。
Ethereum Magiciansの議論にはSepoliaのregistry deploymentが示されていますが、規格本文は特定deploymentを必須にしません。
公式参考実装や提案者のSepolia deploymentは、第三者の本番採用を意味しません。
2026年10月15日時点で、第三者による本番採用は確認できません。
baseline commitmentを必須にするか、profileIdを誰が管理するかは議論中です。
導入前に最新仕様、対象registryのbytecodeと権限、利用するprofileの定義と配布元、監査状況を確認します。
profile IDの導出、衝突回避、登録場所、governanceはレジストリ外で決めます。
参考実装
公式参考実装は、IAgentMemoryState.sol、AgentMemoryStateRegistry.sol、最小のECDSA.solから構成されます。
ここではファイル名の紹介だけでなく、入力をどの順番で確認し、どこへ保存し、どの条件でrevertするかを追います。
AgentMemoryStateRegistry
registry本体は、空間の現在値と個別transitionを別のmappingへ保存します。
constructorはdeploy時のchain IDとdomain separatorをimmutable値へcacheします。
保存される2つの記録
struct SpaceRecord {
address controller;
address authorizer;
bytes32 transitionId;
bytes32 stateRoot;
uint64 sequence;
uint64 configNonce;
}
struct StoredTransition {
bytes32 spaceId;
bytes32 nextStateRoot;
uint64 sequence;
uint64 committedAt;
}
mapping(bytes32 spaceId => SpaceRecord) private _spaces;
mapping(bytes32 transitionId => StoredTransition) private _transitions;
| 保存先 | key | 保存する内容 | 用途 |
|---|---|---|---|
_spaces |
spaceId |
現在の権限、head、連番、権限nonce | 後続の更新を受理できるか判断します。 |
_transitions |
transitionId |
所属空間、更新後root、連番、観測時刻 | 個別更新を後から参照します。 |
committedAtは参考実装が追加する観測時刻で、規格の必須fieldではありません。
登録と権限更新
登録関数は、zero値、spaceIdの導出結果、重複、controllerの承認を順番に確認します。
bytes32 expectedSpaceId = deriveSpaceId(controller, salt);
if (spaceId != expectedSpaceId) {
revert InvalidSpaceId(expectedSpaceId, spaceId);
}
if (_spaces[spaceId].controller != address(0)) {
revert SpaceAlreadyRegistered();
}
bytes32 registrationId = keccak256(
abi.encode(
SPACE_REGISTRATION_TYPEHASH,
spaceId,
controller,
authorizer
)
);
_requireAuthorization(
controller,
_digest(registrationId),
controllerSignature,
true
);
承認後にcontrollerとauthorizerを保存し、head、sequence、configNonceをzeroで初期化します。
途中で失敗すればstorageへ何も書きません。
権限更新関数は、現在のconfigNonce + 1を署名対象へ入れます。
署名検証後に2つのaddressとnonceをまとめて更新するため、片方だけが変わる中間状態はありません。
状態遷移の確定
状態遷移関数は、入力と現在のheadを確認してからhashと署名を検証し、最後に2つのmappingを更新します。
SpaceRecord storage space = _space(delta.spaceId);
if (delta.deltaCommitment == bytes32(0)) {
revert ZeroDeltaCommitment();
}
if (delta.profileId == bytes32(0)) {
revert ZeroProfileId();
}
uint64 expectedSequence = space.sequence + 1;
if (delta.sequence != expectedSequence) {
revert BadSequence(expectedSequence, delta.sequence);
}
if (delta.prevStateRoot != space.stateRoot) {
revert BadPreviousState(space.stateRoot, delta.prevStateRoot);
}
transitionId = hashExperienceDelta(delta);
if (_transitions[transitionId].spaceId != bytes32(0)) {
revert TransitionAlreadyExists();
}
_requireAuthorization(
space.authorizer,
_digest(transitionId),
authorizerSignature,
true
);
TransitionAlreadyExistsは公式参考実装が加える防御です。
規格の線形性条件に加え、個別transition mappingのkey衝突を明示的に止めます。
署名が通ると、更新後のrootを計算し、個別記録、最新head、イベントの順に処理します。
EVMのtransactionは原子的なので、後段でrevertすれば先に行ったstorage書き込みも戻ります。
署名の検証順
準拠レジストリは、署名者のcode有無だけでEOAとcontract accountを二分しません。
署名者にcodeがある場合はERC1271を先に試し、不成立ならcanonicalな65-byte ECDSA署名を確認します。
この順序は、委任codeを持つEOAのunderlying keyによる署名を誤って拒否しないための規範要件です。
本人による空署名の直接呼び出しは規格上MAYです。
公式参考実装は、この任意経路を先頭に加え、直接呼び出し、ERC1271、ECDSAの順に受理方法を試します。
if (
allowDirectCall &&
msg.sender == signer &&
signature.length == 0
) return;
if (
signer.code.length != 0 &&
_isValidERC1271Signature(signer, digest, signature)
) return;
if (signature.length != 65) {
revert InvalidAuthorization();
}
if (ECDSA.recover(digest, signature) != signer) {
revert InvalidAuthorization();
}
コントラクト署名者には署名検証関数を静的に呼び出します。
所定のmagic valueが返った時だけ、そのpolicyが承認した署名として扱います。
ERC1271は、contract accountがisValidSignatureのmagic valueで承認結果を返す規格です。
参考実装はSTATICCALLが成功し、戻り値が32 bytes以上あり、先頭のbytes4が0x1626ba7eである場合だけ受理します。
ERC1271については以下の記事を参考にしてください。
codeを持つaddressが必ずコントラクト署名者とは限りません。
contract署名が成立しない時は、正規のEOA署名も続けて確認します。
EIP7702で委任codeを持つEOAはcode.length != 0ですが、委任先がERC1271を実装するとは限りません。
ERC1271が不成立でもECDSAへ進むことで、underlying keyによる正しい署名を受理できます。
EIP7702については以下の記事を参考にしてください。
ECDSA署名の正規化
公式ECDSA.solは、65-byte署名をr、s、vへ分解し、ecrecoverを実行します。
if (signature.length != 65) {
revert InvalidSignatureLength();
}
if (
uint256(s) >
0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0
) {
revert InvalidSignatureS();
}
address signer = ecrecover(digest, v, r, s);
if (signer == address(0)) {
revert InvalidSignature();
}
sをsecp256k1曲線の下半分へ制限し、同じ署名を別表現へ変えるmalleabilityを防ぎます。
復元結果がzero addressの場合も失敗します。
libraryがrevertすると状態遷移全体もrevertし、storageは変わりません。
任意の読み取りと計算補助
参考実装は、必須interface以外に以下の関数を公開します。
| 関数 | 結果と用途 |
|---|---|
transition |
個別transitionの空間、次root、連番、観測時刻を返します。 |
hashExperienceDelta |
clientと同じtransitionIdを計算します。 |
computeNextStateRoot |
直前rootとtransitionIdから更新後のrootを計算します。 |
hashSpaceRegistration |
登録用struct hashを計算します。 |
hashSpaceAuthorization |
権限更新用struct hashを計算します。 |
signingDigest |
現在のchainとregistry用の署名digestを返します。 |
computeDomainSeparator |
指定したchainとregistry用のdomain separatorを計算します。 |
computeSigningDigest |
指定domainのdigestを計算します。 |
domainSeparator |
現在のdomain separatorを返します。 |
これらは統合と検証を助けますが、準拠の必須関数ではありません。
失敗を分けるerror
AgentMemoryStateRegistryコントラクトは11個のcustom errorを使い、拒否理由を分けます。
ZeroSpaceIdからZeroProfileIdまでのcustom errorを、入力、登録、参照、承認、履歴の失敗へ分類します。
| 分類 | error | 拒否する条件 |
|---|---|---|
| 入力 | ZeroSpaceId |
zeroのspaceIdを登録します。 |
| 入力 | ZeroAddress |
必須の権限addressがzeroです。 |
| 登録 | InvalidSpaceId |
controllerとsaltから導出した値が一致しません。 |
| 登録 | SpaceAlreadyRegistered |
同じ空間が登録済みです。 |
| 参照 | UnknownSpace |
未登録の空間を読み書きします。 |
| 承認 | InvalidAuthorization |
3つの経路のどれでも承認できません。 |
| 履歴 | BadSequence |
後続の連番ではありません。 |
| 履歴 | BadPreviousState |
現在のstate rootと一致しません。 |
| 履歴 | TransitionAlreadyExists |
同じtransitionIdが保存済みです。 |
| 入力 | ZeroDeltaCommitment |
更新内容のcommitmentがzeroです。 |
| 入力 | ZeroProfileId |
解釈profileがzeroです。 |
失敗したtransactionでは、空間、transition、nonce、headのどれも更新されません。
セキュリティ
ERC8350は、署名された更新と線形履歴を検証する規格です。
非公開データそのものの秘密性、可用性、真実性を自動で保証するものではありません。
辞書攻撃とmetadata漏えい
候補が少ない値へ単純にkeccak256を適用しても、攻撃者は候補を総当たりできます。
これは低entropyの平文に対するdictionary attacksです。
deltaSaltを秘密にするか、新しい鍵とnonceで作ったciphertextをcommitmentの対象にします。
公開したdeltaSaltは事前計算を難しくしても、狙った候補を順番に試す攻撃までは止めません。
内容を隠しても、更新時刻、頻度、空間同士の関係、profileIdは公開されます。
同じsalt、暗号文、locator witnessを再利用すると、同じ内容であることも推測されます。
権限侵害と署名検証
authorizerが侵害されると、攻撃者は形式上正しい更新を追加できます。
controllerが侵害されると、新しいcontrollerとauthorizerへ権限を置き換えられます。
価値の高い空間では、multisig、threshold policy、rate limit、運用上の回復手順を使います。
ただし、EIP7702のdelegation後もunderlying keyは有効です。
delegate policyだけを安全境界と考えず、元の鍵も同じ強さで保護します。
contract署名はSTATICCALLの成功だけで受理せず、32 bytes以上の戻り値から0x1626ba7eを確認します。
replayとrelayer
EIP712 domainは、別chainと別registryへの署名replayを防ぎます。
domainのchainIdとverifyingContractにより、同じ署名を別の場所へ移せません。
spaceId、sequence、prevStateRoot、locator commitmentを署名対象へ含めるため、relayerは別の空間、履歴位置、保存先へ移せません。
relayerは、正しいtransactionを送らずに保留することや、別の提出と競合させることはできます。
複数relayerや直接提出の経路は運用側で用意します。
2件が同じheadを使うraceでは先に確定した1件だけが残り、もう1件は再署名まで進めません。
可用性と真実性
正しいcommitmentは、対応するデータが現在も取得できることを証明しません。
正しい署名は、内容が事実であることや、AIが推論で使ったことを証明しません。
データ可用性、保存証明、推論証明、provenanceの真実性が必要なら、別の検証機構を使います。
ERC8350から確認できるのは、設定されたauthorizerが、その履歴位置への更新を承認したことです。
削除の限界
暗号鍵を破棄したり削除attestationを発行したりしても、第三者がすでに複製したデータまで消えたことは証明できません。
削除拡張を作る場合は、どの保存先と管理者を対象にしたattestationかを明記します。
upgradeの監査
upgrade可能なregistryは、利用者が署名した後にhashや承認の規則を変えられます。
公式vectorを再現できても、将来のcodeが線形性を守るとは限りません。
監査では、ERC1967 slotだけでなく、公開source、deployed bytecode、owner、admin、initializer、beacon、独自proxy、delegatecallを確認します。
標準slotだけでなく、upgrade authorityにつながる全経路を確認します。
sequence linearity、root chaining、署名検証を交換できないことをdeployment単位で判断します。
最後に
ERC8350は、AIエージェントの非公開メモリそのものをEthereumで証明する規格ではありません。
公開するのは固定長のcommitmentと履歴位置に絞り、controllerが権限を管理し、authorizerが各更新を承認します。
registryは連番、直前root、署名を確認し、1つ先のheadだけを受理します。
これにより、事業者をまたぐcheckpoint、選択的な監査、既知のheadからのrollback検出に使える共通参照を作れます。
導入時は、コミットメントの秘密性、データ可用性、鍵の侵害、競合時の再試行、レジストリのロジックが交換不能であることを別々に確認してください。
他でも色々記事を書いているのでぜひよろしければ読んでいってください!
参考資料




