声誉注册表:带符号定点评分
30 秒导读: 这是 ERC-8004 三个注册表里管"口碑"的那个。一句话:任何地址都能直接给某个 agent 写一条评分(Jan 2026 之前还要先拿到 agent 签名的预授权,现在这道门被拆了),评分本身是一个
int128 value配一个uint8 valueDecimals的带符号定点数——既能表达 87/100 的好评,也能表达 -3.2% 的负收益率。合约把每条评分原样存链上,读的时候现场扫描聚合;防刷分(Sybil)不在链上做,交给链下过滤。
本章只讲 ReputationRegistry。它的两个兄弟章节:agent 身份怎么来见 身份注册表,独立第三方复核见 验证注册表;整体全景和阅读顺序见 index。
1. 这是什么(零基础也能懂)
一句话定义: 一个链上的"点评本"。谁都能翻开、谁都能写一条,写的是"我给这个 agent 打几分",分数用一个能带正负号、能带小数的数字表示。
解决谁的问题:
- 你是一个 agent 的使用方(客户),用完某个 AI agent 想留个评价 → 你调
giveFeedback。 - 你是一个 想挑 agent 的人(或另一个 agent),想看某个 agent 口碑如何 → 你读
getSummary/readAllFeedback。 - 你是被点评的 agent,想对某条差评做个公开回应 → 你调
appendResponse。
为什么需要它: 链上的 agent 之间要互相"信任"没有中间人担保。声誉就是那个去中心化的信任信号——把"谁给谁打了什么分"变成公开、可验证、不可篡改的链上记录。
它能做什么(功能清单):
| 动作 | 函数 | 谁能调 |
|---|---|---|
| 写一条评分 | giveFeedback | 任何地址,无需预授权 |
| 撤回自己写过的评分 | revokeFeedback | 只能撤自己写的 |
| 给某条评分追加一条回应 | appendResponse | 任何地址(不限被评 agent) |
| 读聚合汇总(条数 + 求和) | getSummary | view,免费读 |
| 读单条 / 读全部评分 | readFeedback / readAllFeedback | view,免费读 |
| 查某条评分的回应数 | getResponseCount | view,免费读 |
一句话直觉: 把它想成"链上的大众点评,但没有平台审核"——门是敞开的,好评差评刷分评都能进,过滤留给读的人自己做。
这一版最关键的转变(Jan 2026): 老版本要求客户先从 agent 那里拿一张签名的"许可证"(feedbackAuth)才能评分,合约还要做 ECDSA/ERC-1271 验签。新版本把整套预授权和验签都删了,理由写在合约头注释里(src/ReputationRegistry.sol:20-30,ReputationRegistry 的 @dev 块):防刷这件事改由链下声誉系统解决,链上只当一个诚实的记账本。
2. 顶层全景(它大概怎么转)
先看数据怎么流动。这张图从左到右是一次评分的生命周期,写入在左、读出在右:
写入侧(要花 gas) 读出侧(view,免费)
┌───────────────────┐ ┌────────────────────────┐
│ 任何 客户地址 │ │ 索引器 / 前端 / 别的 agent │
└─────────┬─────────┘ └───────────┬────────────┘
│ giveFeedback │ getSummary
│ (value, decimals, tag) │ readAllFeedback
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ ReputationRegistry(一个合约,纯链上存储) │
│ │
│ 先问身份:identityRegistry.agentExists(agentId)? ── 不存在就 revert │
│ │
│ 三层嵌套 mapping 存一条 Feedback: │
│ agentId ──► client地址 ──► index(自增) ──► {value,decimals,tag,revoked} │
│ │
│ 同时维护:_lastIndex(每对 agent-client 的最新序号) │
│ _clients / _clientExists(某 agent 的客户名单 + 去重) │
│ _responseCount(某条评分被某人回应了几次) │
└─────────────────────────────────────────────────────────────────┘
│ 每次写入都 emit ▲
▼ │ 现场遍历所有 client×index 扫描
┌───────────────────┐ ┌───────────┴────────────┐
│ NewFeedback 事件 │ ← 链下索引器主要靠 │ 两趟扫描:先求 maxDecimals │
│ (tag1 发两遍) │ 订阅事件重建视图 │ 再归一化求和 │
└───────────────────┘ └────────────────────────┘
部件一句话职责:
| 部件 | 干什么 | 源码锚点 |
|---|---|---|
identityRegistry(不可变引用) | 评分前先确认 agent 真实存在 | src/ReputationRegistry.sol:39 |
_feedback(三层嵌套 mapping) | 存每一条评分数据 | src/ReputationRegistry.sol:51 |
_lastIndex | 每对 agent-client 的评分序号自增器 | src/ReputationRegistry.sol:54 |
_clients / _clientExists | 某 agent 的客户名单 + O(1) 去重登记 | src/ReputationRegistry.sol:57,60 |
_responseCount | 某条评分被某个 responder 回应了几次 | src/ReputationRegistry.sol:63 |
NewFeedback 事件 | 给链下索引器的主数据源 | src/interfaces/IReputationRegistry.sol:35 |
主线走一遍(高层): 客户带着 agentId + value + valueDecimals + tag 调 giveFeedback → 合约校验 valueDecimals ≤ 18、agent 存在 → 取出这对 agent-client 的 _lastIndex + 1 当新序号 → 把这条 Feedback 塞进三层 mapping → 更新 _lastIndex、必要时把客户登记进 _clients → emit NewFeedback。读侧要汇总时,遍历这个 agent 的所有客户、每个客户的所有序号,现场过滤 + 归一化 + 求和。
3. 核心原理(逐个机制,由浅入深)
3.1 评分怎么表示:带符号定点数
它要解决的小问题: 一个"评分"字段,既要能装 87 分好评,又要装 -3.2% 的负收益率,还要装 99.77% 的高精度百分比和 $560 的金额。用一个整数装不下小数,用浮点数——Solidity 根本没有浮点。
思路: 用定点数——存一个带符号整数 value(int128,可正可负),再存一个"这个整数要往左挪几位小数点"的 valueDecimals(uint8,0-18)。真实数值 = value / 10^valueDecimals。这就是 Feedback 结构的前两个字段(src/ReputationRegistry.sol:42-48):
struct Feedback {
int128 value; // 带符号:-32 就是 -3.2%
uint8 valueDecimals; // 0-18:小数点左移几位
string tag1;
string tag2;
bool isRevoked;
}
取值表(直接复用 README README.md:74-80,也印证在接口注释 IReputationRegistry.sol:19-23):
| tag1(语义标签) | 人类读法 | value | valueDecimals | 还原 |
|---|---|---|---|---|
starred | 87/100 评分 | 87 | 0 | 87 |
tradingYield | -3.2% 收益率 | -32 | 1 | -32 / 10 = -3.2 |
uptime | 99.77% 可用率 | 9977 | 2 | 9977 / 100 = 99.77 |
revenues | $560 营收 | 560 | 0 | 560 |
responseTime | 560ms 响应 | 560 | 0 | 560 |
读这张表的要点: tag1 只是个字符串标签,告诉读者"这个数字是什么语义"。合约完全不理解语义——它只存数字和标签,把"这是收益率还是评分"的解释权交给读的人。所以聚合时把不同 tag1 的数字加一起在合约看来是合法的,在语义上却是垃圾(见 3.4 的坑)。
唯一的写入校验: require(valueDecimals <= 18)(src/ReputationRegistry.sol:101)。value 本身不设范围,负数、零、极大值都收。
3.2 存储模型:三层嵌套 mapping + 自增序号
它要解决的小问题: 同一个客户可能给同一个 agent 打很多次分,要能分别存、分别撤、分别读。所以键不能只是 (agentId, client),得再加一个"第几条"。
存储长这样(src/ReputationRegistry.sol:51):
// agentId => client地址 => 第几条 => Feedback
mapping(uint256 => mapping(address => mapping(uint64 => Feedback))) private _feedback;
序号怎么来: 每对 (agentId, client) 各有一个独立的自增计数器 _lastIndex(src/ReputationRegistry.sol:54)。写入时序号 = 旧值 + 1,注意从 1 开始(src/ReputationRegistry.sol:107):
uint64 currentIndex = _lastIndex[agentId][msg.sender] + 1; // 首条就是 1,不是 0
序号从 1 起是刻意的:所有读函数都用 feedbackIndex > 0 当"合法索引"的下界(如 revokeFeedback 的 require,src/ReputationRegistry.sol:149),0 被留作"无效 / 全部"的哨兵值。
客户名单 + 去重: 光有 _feedback 还不够——读侧要"遍历某 agent 的所有客户",但 mapping 不可枚举。于是额外维护一个可枚举的 _clients[agentId] 数组。为了避免同一客户多次评分时被重复 push,用一个 _clientExists 布尔 mapping 做 O(1) 去重登记(src/ReputationRegistry.sol:57,60):
// 首次评分才登记进名单,靠 _clientExists 一次性判重
if (!_clientExists[agentId][msg.sender]) {
_clients[agentId].push(msg.sender);
_clientExists[agentId][msg.sender] = true;
}
这是链上枚举的经典手法:mapping 管快速查改、伴生一个 array 管遍历、再加一个 bool mapping 防重复入列。三者由 giveFeedback(src/ReputationRegistry.sol:90)在一次写入里一起维护。
3.3 事件:为什么 tag1 发两遍
它要解决的小问题: 链下索引器既想按 tag 过滤事件(比如只订阅 tag1 == "starred" 的评分),又想读到 tag 的完整字符串。而 Solidity 事件里,indexed 的动态类型(string)在日志里存的是 keccak256 哈希,不是原文——能用来匹配,但读不回原字符串。
解法:同一个 tag1 发两遍。 看接口定义(src/interfaces/IReputationRegistry.sol:35-47):
event NewFeedback(
uint256 indexed agentId,
address indexed clientAddress,
uint64 feedbackIndex,
int128 value,
uint8 valueDecimals,
string indexed indexedTag1, // 哈希版:用来 filter
string tag1, // 原文版:用来读
string tag2,
string endpoint,
string feedbackURI,
bytes32 feedbackHash
);
indexedTag1 进 topic(可过滤,但存的是哈希),tag1 进 data(存原文,可读回)。emit 时两个位置传的是同一个 tag1 变量(src/ReputationRegistry.sol:134-135),注释直说"indexed / non-indexed (for reading full value)"。这是"想让动态字段既可过滤又可读"时的标准取舍——多花一点日志空间,换齐两种能力。
3.4 读侧聚合:两趟扫描 + 归一化
它要解决的小问题: getSummary 要把一堆 value/valueDecimals 各不相同的评分加起来。但 87(decimals=0)和 9977(decimals=2)不能直接相加——小数点没对齐。
思路:先对齐,再相加。 找出这批评分里最大的 decimals,把所有数都放大到那个精度,再求和。这就是两趟扫描的由来(src/ReputationRegistry.sol:198):
第一趟(src:219-233):遍历所有 client×index,跳过已撤回/不匹配 tag 的,
只为求出 maxDecimals(这批里最高精度)
第二趟(src:236-255):再遍历一次,每条按 (maxDecimals - 自己的decimals) 放大,
累加进 int256 totalValue,同时数 validCount
放大那一步(src/ReputationRegistry.sol:250-251):
uint8 decimalDiff = maxDecimals - fb.valueDecimals;
int256 normalizedValue = int256(fb.value) * int256(10 ** decimalDiff);
totalValue += normalizedValue;
为什么中间用 int256 而不是 int128: 放大 + 累加很容易撑爆 int128,所以先用更宽的 int256 累加,最后再收窄回 int128。收窄时做饱和截断而非回绕(src/ReputationRegistry.sol:259-265):
if (totalValue > type(int128).max) {
summaryValue = type(int128).max; // 溢出就顶到最大值
} else if (totalValue < type(int128).min) {
summaryValue = type(int128).min; // 负溢出顶到最小值
} else {
summaryValue = int128(totalValue);
}
返回三元组: count(有效条数)、summaryValue(归一化后的求和)、summaryValueDecimals(= maxDecimals,告诉调用方这个和该按几位小数还原)。
两个必须知道的坑:
- 饱和截断会悄悄骗人。 一旦真实总和超过
int128范围,返回的就是被顶住的边界值,而不是真值,且没有任何报错信号。调用方拿到type(int128).max无法区分"真的这么大"还是"溢出了"。 - 跨 tag 求和在语义上是垃圾。 合约不理解语义(见 3.1),如果调用方不传
tag1过滤,它会把starred的 87 和tradingYield的 -32 归一化后加一起——数字合法,含义没有。所以getSummary真正的用法是带 tag 过滤去求"同一类评分"的和。
3.5 软删除与回应:撤回、追加
撤回(revokeFeedback,src/ReputationRegistry.sol:148)是软删除——不真删数据,只把 isRevoked 置 true(src/ReputationRegistry.sol:152)。两道门:序号必须落在 (0, _lastIndex] 区间、且没被撤过。关键是它以 msg.sender 为键——你只能撤自己写的评分,撤不了别人的。撤回后,聚合和默认读取会跳过这条(if (fb.isRevoked) continue),但 readAllFeedback 传 includeRevoked=true 仍能读到。
追加回应(appendResponse,src/ReputationRegistry.sol:165)权限是敞开的——任何地址都能给任何一条评分追加回应,不限于被评的 agent。它不存回应内容(内容在 responseURI 指向的链下),链上只把"这个 responder 对这条评分回应过几次"累 加进 _responseCount(src/ReputationRegistry.sol:176):
// 按 responder 分别计数,而不是维护一个总数
_responseCount[agentId][clientAddress][feedbackIndex][msg.sender]++;
按 responder 分开计数是省 gas 的写侧设计,但给读侧留了个硬伤,见 3.6。
3.6 读回应数的已知局限
getResponseCount(src/ReputationRegistry.sol:445)有一条反直觉的行为:不传 responders 名单,永远返回 0(src/ReputationRegistry.sol:452):
if (responders.length == 0) {
return 0; // 不是"没有回应",是"这个函数查不了"
}
根源在 3.5 的存储选择:_responseCount 只按 (...,feedbackIndex,responder) 分桶记数,从不维护"这条评分总共被回应几次"的聚合值。所以要拿到非零结果,调用方必须显式传入想统计的 responder 地址列表,函数才能把这些桶加起来(src/ReputationRegistry.sol:456-480 按 client / index 是否给定分三种遍历粒度)。合约头注释(src/ReputationRegistry.sol:432-443)把这条当作"为优化写操作 gas 而做的设计取舍"明说了。
4. 深入实现:读全部评分的两段式
readAllFeedback(src/ReputationRegistry.sol:315)要返回七个平行数组(clients、indexes、values、decimals、tag1s、tag2s、revoked),每个下标对应一条评分。难点是 Solidity 的 memory 数组必须在创建时定长,而"有多少条符合过滤"事先不知道。
解法是经典的两段式:先数再填。
_countValidFeedback(src:369) ──► 只遍历、只 count,返回 totalCount
│
▼
按 totalCount 一次性 new 出七个定长数组(src:341-347)
│
▼
_populateFeedbackArrays(src:394) ──► 再遍历一次,把每条塞进下标 idx,idx++
两段用的过滤条件必须字字一致(都是 !includeRevoked && isRevoked、tag1/tag2 的 keccak256 比对),否则数出来的 count 和填进去的数量对不上会越界或留空洞。这也是为什么两个内部函数把同一套过滤逻辑抄了两遍——不是冗余,是"两趟必须同步"的约束。
getSummary 的两趟(3.4)是"先求 maxDecimals 再求和",readAllFeedback 的两趟是"先数长度再填数组"——两处都是同一个模式:链上无法动态扩容,就用一趟侦察 + 一趟执行。
5. 巧妙之处(可借鉴的技术)
| 妙在哪 | 一句话 | 锚点 |
|---|---|---|
| 定点数塞进整数 | int128 value + uint8 decimals 一对整数表达带符号小数,绕开 Solidity 没有浮点 | src/ReputationRegistry.sol:42-48 |
| mapping + array + bool 三件套 | 让不可枚举的 mapping 变得可遍历又不重复入列 | src/ReputationRegistry.sol:57-60,122-125 |
| indexed 字段发两遍 | 动态 string 既可过滤(哈希)又可读回(原文) | src/ReputationRegistry.sol:134-135 |
| 宽累加 + 饱和收窄 | 用 int256 累加防溢出,收窄时截断而非回绕 | src/ReputationRegistry.sol:251,259-265 |
| 数-填两段式 | 先侦察长度再填定长数组,规避 memory 不能扩容 | src/ReputationRegistry.sol:338-363 |
6. 边界与局限(诚实清单)
- view 函数对热门 agent 会 gas 爆 / DoS。
getSummary、readAllFeedback、getResponseCount都是遍历所有 client × 所有 index 的双层循环。一个被刷了海量评分的 agent,不带clientAddresses过滤直接调,轻则 view 超 gas 上限读不出、重则拖垮链下 RPC。合约注释反复强调热门 agent 必须传clientAddresses过滤(src/ReputationRegistry.sol:185-189、298-301)。注意接口注释写clientAddresses"MUST be provided"(IReputationRegistry.sol:123),但实现其实允许留空并回退到全量_clients(src/ReputationRegistry.sol:205-209)——真正的强约束在文档里,不在代码里。 - Sybil / 刷分完全不在链上防。 这一版删掉了预授权和验签(
src/ReputationRegistry.sol:20-30),门是敞开的。任何地址能无限刷评分,链上不做任何身份或质押门槛。防刷要靠链下过滤(按可信 client 白名单聚合、按质押/历史加权等)。链上只保证"记录不可篡改",不保证"记录可信"。 - 聚合会悄悄溢出、也会跨语义乱加。 见 3.4:饱和截断无信号、不带 tag 过滤会把不同语义的数字加一起。
getSummary只适合"单一 tag、可信 client 子集"的场景。 - 回应数默认查不到。 见 3.6:
getResponseCount不传 responders 返回 0,不是真实统计。 - 撤回是软删除。 数据永远留链上,
isRevoked=true只影响默认过滤,不省存储、不可恢复性地"抹掉"。
7. 横向对比(同库兄弟)
| 注册表 | 管什么 | 写入门槛 | 本章链接 |
|---|---|---|---|
| Identity | agent 身份(agent 即 NFT) | 需拥有身份 | 01 |
| Reputation | 口碑评分 | 无,任何人可写 | 本章 |
| Validation | 独立第三方复核 + 承诺哈希 | 需被指派/承诺 | 03 |
三者共用同一个 IdentityRegistry 做"agent 是否存在"的前置校验(本合约在 src/ReputationRegistry.sol:104 调 agentExists)。声誉是三者里写入最开放的一个——正因为开放,它把信任判断整体推给了链下。部署与演进见 04。
8. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 评分数据结构 | src/ReputationRegistry.sol | Feedback(:42) |
| 三层嵌套存储 | src/ReputationRegistry.sol | _feedback(:51)、_lastIndex(:54) |
| 客户名单 + 去重 | src/ReputationRegistry.sol | _clients(:57)、_clientExists(:60) |
| 回应计数存储 | src/ReputationRegistry.sol | _responseCount(:63) |
| 写评分(核心) | src/ReputationRegistry.sol | giveFeedback(:90) |
| 撤回(软删除) | src/ReputationRegistry.sol | revokeFeedback(:148) |
| 追加回应 | src/ReputationRegistry.sol | appendResponse(:165) |
| 两趟归一化聚合 | src/ReputationRegistry.sol | getSummary(:198) |
| 读单条 | src/ReputationRegistry.sol | readFeedback(:280) |
| 读全部(数-填两段) | src/ReputationRegistry.sol | readAllFeedback(:315)、_countValidFeedback(:369)、_populateFeedbackArrays(:394) |
| 回应数(有局限) | src/ReputationRegistry.sol | getResponseCount(:445) |
| 客户 / 序号读取 | src/ReputationRegistry.sol | getClients(:488)、getLastIndex(:498) |
| 事件与函数签名 | src/interfaces/IReputationRegistry.sol | NewFeedback(:35)、IReputationRegistry(:27) |