数据截至 (上游 commit 2e5e79d872b6)
验证注册表:独立复核与承诺哈希
30 秒导读:
ValidationRegistry是 ERC-8004 三个注册表里管"第三方复核"的那个。agent 干完一份活,想让别人来查它做得对不对——这个合约提供一套两阶段流程:先由 agent 主人发起一条"请我校验"的请求(带一个不可篡改的哈希承诺),再由被点名的那个验证者回一个 0-100 的分数。合约自己不做任何验证,只负责把"谁请谁验、验的是哪份数据、验出多少分"钉在链上。它在源码里被明确标注 EXPERIMENTAL(src/ValidationRegistry.sol:12-14),2026 年内还会随 TEE 社区继续改。
本章只讲 ValidationRegistry。身份注册表见 01-identity-registry.md,声誉注册表见 02-reputation-registry.md。
1. 这是什么(零基础也能懂)
一句话定义: 一个链上"验收单"合约——记录"某 agent 请某个独立验证者复核它的工作,验证者给了多少分"。
解决什么问题 / 给谁用。 假设一个 AI agent 替你跑了一段推理、成交了一笔链上交易,或产出了一份报告。你(或者依赖它的人)想要第三方背书:这活真做对了吗?靠 agent 自己说"我做对了"没意义。于是需要一个独立验证者来查,并把结论留在链上给所有人看。这个合约就是那张"验收单"的存放处。
它能做什么(功能),就四件事:
| 能力 | 谁来做 | 干嘛 |
|---|---|---|
| 发起验证请求 | agent 的主人 / 授权操作员 | 点名一个验证者,附上"要验什么"的 URI + 哈希 |
| 回填验证响应 | 被点名的那个验证者 | 给 0-100 分,可附证据 URI/哈希 + 标签 |
| 查单条状态 | 任何人 | 看某条请求是"不存在 / 待处理 / 已响应" |
| 查聚合摘要 | 任何人(建议链下) | 按验证者、标签过滤,求某 agent 的平均分 |
它刻意不做什么(关键): 合约不判断对错、不跑 zkML、不验 TEE 报告。stake/zkML/TEE 这些只是"这套接口打算支持的验证方法"的意图声明(src/IValidationRegistry.sol:14),真正的验 证逻辑发生在链下或验证者自己的合约里。这里只存 URI + 哈希 + 一个分数。
一句话直觉: 把它想成餐厅后厨的"留样柜 + 质检签收本"。后厨(agent)把菜留一份样、贴上封条编号(requestHash);质检员(验证者)来了,对着编号签一个分数。柜子本身不尝菜,只保证"编号不能被人偷换、签字的必须是被指定的那个质检员"。
2. 顶层全景(它大概怎么转)
怎么读下面这张图: 从左到右是时间顺序,分两个阶段;中间竖线是"两次不同的交易、两个不同的调用者"。
阶段一:请求(agent 主人发起) 阶段二:响应(被点名验证者发起)
───────────────────────────── ─────────────────────────────
│
agent 主人 / 操作员 │ 指定的验证者
│ │ │
│ validationRequest │ │ validationResponse
│ (validator, agentId, │ │ (requestHash, 0-100,
│ requestURI, requestHash) │ │ responseURI, hash, tag)
▼ │ ▼
┌──────────────┐ │ ┌──────────────┐
│ 校验授权链 │ │ │ 校验:调用者 │
│ 校验哈希非零 │ │ │ == 请求里那个 │
│ 校验非自我验证│ │ │ 验证者? │
│ 校验哈希没占用│ │ │ 校验 分数≤100 │
└──────┬───────┘ │ └──────┬───────┘
│ 存入 │ │ 存/覆盖
▼ │ ▼
_requests[hash] │ _responses[hash]
_requestExists[hash]=true │ (可多次调用做渐进状态)
│ │
└──── emit 事件 ───────────┴──── emit 事件 ────► 链下索引/聚合
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
Request 结构 | 存"谁请谁验、验哪份数据"(验证者/agentId/URI/hash/时间戳) | src/ValidationRegistry.sol:35-41 |
Response 结构 | 存"验出多少分"(分数/响应哈希/标签/更新时间) | src/ValidationRegistry.sol:44-51 |
validationRequest | 阶段一入口,做全部前置校验后落库 | src/ValidationRegistry.sol:89 |
validationResponse | 阶段二入口,只允许指定验证者回填 | src/ValidationRegistry.sol:146 |
identityRegistry | 只读依赖:查 agent 存不存在、主人是谁、谁被授权 | src/ValidationRegistry.sol:32 |
主线走一遍(高层): agent 主人调 validationRequest,合约验完四道关(授权 、哈希非零、非自我验证、哈希没被占)后把请求钉进 _requests,并把 requestHash 分别塞进"按 agent 查"和"按验证者查"两个索引数组。之后,那个被点名的验证者调 validationResponse,合约确认"你就是被点的那个人"、"分数在 0-100"后,把响应写进 _responses。全程两次交易、两个角色,链下靠事件做聚合。
3. 核心原理(逐个机制,由浅入深)
3.1 为什么要拆成"请求 / 响应"两阶段
它要解决的小问题: 验证是异步的——请求方和验证方是两个独立主体,验证可能要花时间(跑证明、查数据)。不能用一次调用搞定。
思路: 把一次验证拆成两条独立交易,用一个共同的 requestHash 当主键把两半缝起来。请求先落库,响应后到,两者都以 requestHash 索引(_requests 与 _responses 两张 map 共用同一个 key,src/ValidationRegistry.sol:54-57)。
两个结构各存一半的事实:
Request(请求半):35-41 | Response(响应半):44-51 |
|---|---|
validatorAddress 被点名的验证者 | validatorAddress 实际回应的验证者 |
agentId 被验的 agent | agentId 被验的 agent |
requestURI 要验什么(链下数据地址) | response 0-100 的分数 |
requestHash 对请求载荷的承诺 | responseHash 对证据的承诺(可选) |
timestamp 请求时间 | tag 分类标签 + lastUpdate 最后更新时间 |
注意:请求半没有分数,响应半才有分数。这个区别是后面"三态查询"的基础。
3.2 requestHash:一次不可反悔的承诺
它要解决的小问题: 请求里那个 requestURI 指向链下数据(比如 IPFS 上"要验的这份工作")。链下数据可能被偷偷替换。怎么保证"验证者最后验的,就是发起时说的那份"?
思路: 强制附一个 requestHash——请求载荷的 KECCAK-256 哈希,当作承诺。合约不算这个哈希、也不校验它和 URI 内容是否相符(那是链下的事),但它做两件硬约束:
-
哈希必须非零(
src/ValidationRegistry.sol:98):require(requestHash != bytes32(0), "Request hash required");Jan 2026 更新把它从"可选"改成"强制"(
src/ValidationRegistry.sol:22),逼每条请求都带承诺。空承诺 = 没承诺,直接拒。 -
哈希同时被用作主键(见 3.1),于是"验哪份数据"和"这条请求的身份"绑成同一个值——无法在不换主键的情况下换掉被验数据。
3.3 三道安全闸:授权、防自验、防劫持
validationRequest 在落库前串起三类校验,任意一条不过就 revert。逐条看它防的是什么。
闸一:授权链——你有资格替这个 agent 发起请求吗? 调用者必须是 agent 主人,或被全局授权的操作员,或被单独授权到这个 agentId(src/ValidationRegistry.sol:102-108):
address agentOwner = identityRegistry.ownerOf(agentId);
require(
msg.sender == agentOwner ||
identityRegistry.isApprovedForAll(agentOwner, msg.sender) ||
identityRegistry.getApproved(agentId) == msg.sender,
"Not authorized"
);
这三个判断直接复用 ERC-721 的授权语义(agent 就是 NFT,见 01-identity-registry.md)——主人 / 全体授权 / 单个授权,三选一即可。
闸二:防自我验证——不许自己验自己。 独立验证的前提是验证者独立。合约堵死两条自验路径(src/ValidationRegistry.sol:112-113):
require(validatorAddress != agentOwner, "Self-validation not allowed");
require(validatorAddress != msg.sender, "Self-validation not allowed");
既不能把验证者设成 agent 主人,也不能设成发起请求的那个人(操作员)。否则"独立复核"就成了自说自话。
闸三:防哈希劫持——占用即锁定,不 可覆盖。 一旦某个 requestHash 被用过,它永远不能被第二条请求覆盖(src/ValidationRegistry.sol:117):
require(!_requestExists[requestHash], "Request hash already exists");
_requestExists 是一张专门的布尔表(src/ValidationRegistry.sol:66),落库时置 true(:131)。为什么单独设这张表而不直接看 _requests[hash]?因为它是"这条 requestHash 是否被占用"的权威事实来源,后面查询区分三态时也靠它。有了这道闸,攻击者无法抢先用同一个哈希塞一条指向别处的请求,把真请求顶掉。
三闸串起来的顺序(命中即停):
输入非空? ──否─► revert
│是
授权链通过? ──否─► revert "Not authorized"
│是
非自我验证? ──否─► revert "Self-validation not allowed"
│是
哈希未被占用? ──否─► revert "Request hash already exists"
│是
▼
落库 + 双索引 + _requestExists=true + emit
3.4 validationResponse:只有被点名的人能回分,且能反复回
它要解决的小问题: 谁有资格回这个分数?分数范围怎么控?验证是渐进的(先给个初步分、再给终评),怎么支持?
三个约束(src/ValidationRegistry.sol:146-171):
-
分数限 0-100(
:154):require(response <= 100, ...)。response本身是uint8,配合这行把上界压到 100。 -
请求得存在(
:157-158):按requestHash取出请求,validatorAddress == address(0)就说明没这条请求,revert"Request not found"。 -
调用者 必须是被点名的那个验证者(
:161):require(msg.sender == request.validatorAddress, "Not authorized validator");请求阶段点了谁,就只有谁能回。别的地址回不了。
渐进状态 = 允许覆盖。 响应用直接赋值写入 _responses[requestHash](:164-171),没有"已存在就拒"的检查——所以同一个验证者可以多次调用,每次覆盖上一版,并更新 lastUpdate 时间戳。这就是注释说的 "progressive validation states"(src/ValidationRegistry.sol:139):验证者可以先落一个中间分,过后再改成终评。
对比记忆:请求半一次锁死不可覆盖(闸三),响应半故意可反复覆盖。两者的可变性设计正好相反,各有各的理由。
3.5 三态查询:用默认值区分"不存在 / 待处理 / 已响应"
它要解决的小问题: 查一条请求的状态时,Solidity 的 map 对不存在的 key 会返回全零默认值。怎么把"从没请求过"和"请求了但还没人回分"这两种都长得像"零"的情况分开?
思路: getValidationStatus(src/ValidationRegistry.sol:198)故意不 revert,直接返回 _responses[requestHash] 的字段(不存在就是全默认值)。真正区分三态靠它和 requestExists 组合判断(注释写在 src/ValidationRegistry.sol:208-212):
| 状态 | 判据 | 含义 |
|---|---|---|
| 不存在 | validatorAddress == 0 且 requestExists(hash) == false | 从没发起过这条请求 |
| 待处理 | validatorAddress == 0 且 requestExists(hash) == true | 请求在,验证者还没回分 |
| 已响应 | validatorAddress != 0 | 验证者已回分 |
关键点:响应半的 validatorAddress 只有在 validationResponse 被调用后才会被写成非零(:165)。所以"响应里的验证者地址是否为零"恰好就是"回没回分"的信号。而 requestExists(src/ValidationRegistry.sol:298)这张独立布尔表,补上了"请求本身在不在"这一维,两者交叉就把三态分清了。
getRequest(src/ValidationRegistry.sol:310)则是另一种取法:它查的是请求半,不存在就直接 revert "Request not found"(:317),适合"我确信有这条请求、要读它的 URI"的场景。