跳到主要内容

验证注册表:独立复核与承诺哈希

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-41Response(响应半):44-51
validatorAddress 被点名的验证者validatorAddress 实际回应的验证者
agentId 被验的 agentagentId 被验的 agent
requestURI 要验什么(链下数据地址)response 0-100 的分数
requestHash 对请求载荷的承诺responseHash 对证据的承诺(可选)
timestamp 请求时间tag 分类标签 + lastUpdate 最后更新时间

注意:请求半没有分数,响应半才有分数。这个区别是后面"三态查询"的基础。

3.2 requestHash:一次不可反悔的承诺

它要解决的小问题: 请求里那个 requestURI 指向链下数据(比如 IPFS 上"要验的这份工作")。链下数据可能被偷偷替换。怎么保证"验证者最后验的,就是发起时说的那份"?

思路: 强制附一个 requestHash——请求载荷的 KECCAK-256 哈希,当作承诺。合约不算这个哈希、也不校验它和 URI 内容是否相符(那是链下的事),但它做两件硬约束:

  1. 哈希必须非零(src/ValidationRegistry.sol:98):

    require(requestHash != bytes32(0), "Request hash required");

    Jan 2026 更新把它从"可选"改成"强制"(src/ValidationRegistry.sol:22),逼每条请求都带承诺。空承诺 = 没承诺,直接拒。

  2. 哈希同时被用作主键(见 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):

  1. 分数限 0-100(:154):require(response <= 100, ...)response 本身是 uint8,配合这行把上界压到 100。

  2. 请求得存在(:157-158):按 requestHash 取出请求,validatorAddress == address(0) 就说明没这条请求,revert "Request not found"

  3. 调用者必须是被点名的那个验证者(: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"的场景。

3.6 getSummary:按验证者 / 标签过滤求平均

它要解决的小问题: 一个 agent 攒了很多条验证,想要一个"总评分"。但不同验证者、不同类别的验证不该混在一起平均。

思路: getSummary(src/ValidationRegistry.sol:235)遍历该 agent 的所有 requestHash(从 _agentValidations 取,:240),边遍历边过滤,最后对通过过滤且已响应的那些求算术平均。三层过滤逻辑:

对该 agent 的每条 requestHash:
├─ 没响应(validatorAddress==0)? ── 跳过 (:250)
├─ 传了 validatorAddresses 白名单?
│ └─ 不在白名单里? ── 跳过 (:253-262)
├─ 传了 tag?
│ └─ tag 不匹配? ── 跳过 (:265)
└─ 计入:totalResponse += 分; validCount++ (:267-268)
平均 = validCount>0 ? totalResponse/validCount : 0 (:272)

几个务实细节:

  • tag 比较用 keccak256(bytes(...)) 比哈希而非逐字符比串(:265),这是 Solidity 里比 string 的惯用法。
  • 平均是整数除法(uint8(totalResponse / validCount),:272),会向下取整、丢小数——够用因为分数本就是 0-100 的粗粒度。
  • 合约明确注明:这函数为链下消费设计,agent 请求多时不带过滤直接调可能爆 gas,所以强烈建议用 validatorAddresses / tag 过滤(src/ValidationRegistry.sol:225-228)。聚合本就"预期发生在链下"。

4. 巧妙之处(可带走的技术)

  1. 一个 hash 身兼三职。 requestHash 同时是:请求 map 的主键、响应 map 的主键、以及对被验数据的承诺。用一个值把"缝合请求与响应"和"防篡改承诺"两件事一次解决(:54-57, :98)。

  2. 可变性设计一正一反,各服务一个目的。 请求半"占用即锁死"防劫持(:117),响应半"可覆盖"支持渐进评分(:164)。同一个合约里两种相反的可变性,不是疏忽而是设计。

  3. 不 revert 反而更有用。 getValidationStatus 靠"返回默认值 + 配 requestExists"表达三态(:208-212),比"要么返回要么 revert"的二值接口能传更多信息,且不逼调用者用 try/catch 兜异常。

  4. 把 DoS 风险摊开写在注释里。 合约不假装 getSummary 万能,而是直说"popular agent 别裸调、用过滤、聚合上链下"(:225-228)——把边界诚实交给使用者。


5. 边界与局限(诚实)

  • 合约不验证任何东西。 它只记 URI + hash + 分数。stake/zkML/TEE 全是链下或验证者合约的事,这里只是意图声明(src/IValidationRegistry.sol:14)。
  • 自身标注 EXPERIMENTAL。 源码头部明说还在跟 TEE 社区改,2026 年内会变(src/ValidationRegistry.sol:12-14)。
  • 承诺哈希只防"事后换数据",不验"哈希与 URI 内容相符"。 合约从不重算 requestHash——URI 指向的内容和哈希对不对得上,得靠验证者链下自己核。
  • getSummary 链上聚合有 gas 上限风险,大 agent 必须过滤或改走链下(:225-228)。
  • 响应可无限覆盖。 好处是渐进评分,代价是"最终分"取决于验证者最后一次调用——链下消费方要以 lastUpdate 为准(:170)。

6. 横向对比(同库兄弟章)

维度验证注册表(本章)声誉注册表 02
谁能写需授权 + 只有被点名验证者能回分无需预授权,任何人可留反馈(ReputationRegistry 注释)
核心值0-100 的验证分带符号定点评分
防自我操纵显式禁自我验证(:112-113)机制不同,见该章

身份从哪来、agent 为何是 NFT,见 01-identity-registry.md;部署与规范演进见 04-deploy-and-evolution.md;全景与阅读地图见 index.md


7. 代码地图(导航索引)

主题文件路径符号名
合约本体src/ValidationRegistry.solValidationRegistry
接口定义src/interfaces/IValidationRegistry.solIValidationRegistry
请求结构src/ValidationRegistry.sol:35struct Request
响应结构src/ValidationRegistry.sol:44struct Response
占用标记表src/ValidationRegistry.sol:66_requestExists
阶段一:发起请求src/ValidationRegistry.sol:89validationRequest
授权链校验src/ValidationRegistry.sol:102-108ownerOf/isApprovedForAll/getApproved
哈希非零 + 防劫持src/ValidationRegistry.sol:98,:117requestHash != bytes32(0) / !_requestExists
防自我验证src/ValidationRegistry.sol:112-113Self-validation not allowed
阶段二:回填响应src/ValidationRegistry.sol:146validationResponse
验证者鉴权 + 分数上界src/ValidationRegistry.sol:161,:154Not authorized validator / Response must be 0-100
三态查询src/ValidationRegistry.sol:198getValidationStatus
请求是否存在src/ValidationRegistry.sol:298requestExists
读请求详情(不存在则 revert)src/ValidationRegistry.sol:310getRequest
过滤聚合求平均src/ValidationRegistry.sol:235getSummary