跳到主要内容

声誉注册表:带符号定点评分

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)
读聚合汇总(条数 + 求和)getSummaryview,免费读
读单条 / 读全部评分readFeedback / readAllFeedbackview,免费读
查某条评分的回应数getResponseCountview,免费读

一句话直觉: 把它想成"链上的大众点评,但没有平台审核"——门是敞开的,好评差评刷分评都能进,过滤留给读的人自己做。

这一版最关键的转变(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 + taggiveFeedback → 合约校验 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(语义标签)人类读法valuevalueDecimals还原
starred87/100 评分87087
tradingYield-3.2% 收益率-321-32 / 10 = -3.2
uptime99.77% 可用率997729977 / 100 = 99.77
revenues$560 营收5600560
responseTime560ms 响应5600560

读这张表的要点: 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 当"合法索引"的下界(如 revokeFeedbackrequire,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)是软删除——不真删数据,只把 isRevokedtrue(src/ReputationRegistry.sol:152)。两道门:序号必须落在 (0, _lastIndex] 区间、且没被撤过。关键是它以 msg.sender 为键——你只能撤自己写的评分,撤不了别人的。撤回后,聚合和默认读取会跳过这条(if (fb.isRevoked) continue),但 readAllFeedbackincludeRevoked=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 && isRevokedtag1/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。 getSummaryreadAllFeedbackgetResponseCount 都是遍历所有 client × 所有 index 的双层循环。一个被刷了海量评分的 agent,不带 clientAddresses 过滤直接调,轻则 view 超 gas 上限读不出、重则拖垮链下 RPC。合约注释反复强调热门 agent 必须传 clientAddresses 过滤(src/ReputationRegistry.sol:185-189298-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. 横向对比(同库兄弟)

注册表管什么写入门槛本章链接
Identityagent 身份(agent 即 NFT)需拥有身份01
Reputation口碑评分无,任何人可写本章
Validation独立第三方复核 + 承诺哈希需被指派/承诺03

三者共用同一个 IdentityRegistry 做"agent 是否存在"的前置校验(本合约在 src/ReputationRegistry.sol:104agentExists)。声誉是三者里写入最开放的一个——正因为开放,它把信任判断整体推给了链下。部署与演进见 04


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

主题文件路径符号名
评分数据结构src/ReputationRegistry.solFeedback(:42)
三层嵌套存储src/ReputationRegistry.sol_feedback(:51)、_lastIndex(:54)
客户名单 + 去重src/ReputationRegistry.sol_clients(:57)、_clientExists(:60)
回应计数存储src/ReputationRegistry.sol_responseCount(:63)
写评分(核心)src/ReputationRegistry.solgiveFeedback(:90)
撤回(软删除)src/ReputationRegistry.solrevokeFeedback(:148)
追加回应src/ReputationRegistry.solappendResponse(:165)
两趟归一化聚合src/ReputationRegistry.solgetSummary(:198)
读单条src/ReputationRegistry.solreadFeedback(:280)
读全部(数-填两段)src/ReputationRegistry.solreadAllFeedback(:315)、_countValidFeedback(:369)、_populateFeedbackArrays(:394)
回应数(有局限)src/ReputationRegistry.solgetResponseCount(:445)
客户 / 序号读取src/ReputationRegistry.solgetClients(:488)、getLastIndex(:498)
事件与函数签名src/interfaces/IReputationRegistry.solNewFeedback(:35)、IReputationRegistry(:27)