身份注册表:agent 即 NFT
30 秒导读:
IdentityRegistry把每个 AI agent 铸成一枚 ERC-721 NFT——于是「agent 身份」天然可浏览、可转让、可交易,直接吃到整个 NFT 生态。链上只存两样轻东西:一个指向链下注册文件的 URL(agentURI),和一组任意键值metadata。唯一的例外是收款钱包agentWallet:它是保留键,不能随手写,必须由钱包本人签名证明「我同意收 agent 的钱」,而且agent 一转手,钱包立刻清零。
本章只讲身份注册表。声誉打分见 声誉注册表,独立复核见 验证注册表,部署与演进见 部署与演进。全景与阅读地图见 index。
1. 这是什么(零基础也 能懂)
一句话定义: 一个把「AI agent 的链上身份」实现成 NFT 的智能合约。
解决什么问题: 想象一堆 AI agent 要在链上互相打交道——A 想雇 B 干活、要给 B 打钱、要查 B 是谁、以后还想把 A 这个 agent 连同它的声誉一起卖掉。你需要一个所有人都认、谁都能查的身份台账:
- 每个 agent 有个全局唯一的号(
agentId)。 - 能查到它的自我描述放在哪(
agentURI→ 一个链下 JSON)。 - 能挂一些结构化标签(
metadata,如支持的协议、能力清单)。 - 能安全地登记一个收款地址,让别人放心打钱。
- 这套身份本身能被拥有、能被转让。
关键设计: 上面这些需求,ERC-721(NFT 标准)几乎全给现成了——所有权、转让、tokenURI(每个 token 挂一个链下元数据 URL)、钱包和市场的原生支持。所以 ERC-8004 干脆让 agent = NFT。
用起来什么样: 注册一个 agent 就是铸一枚 NFT,拿回一个 agentId:
// 示意,非源码:注册一个 agent
uint256 agentId = registry.register("https://alice.ai/agent.json");
// agentId == 1(第一个 agent)
// 之后:registry.ownerOf(1) 就是你,可以像任何 NFT 一样转让它
一句话直觉: 把 agent 的身份想成一张可转让的会员卡。卡号(agentId)不变、卡背面印着资料页网址(agentURI)、卡上能贴便利贴(metadata);但「往这张卡绑定的账户打钱」这件事,必须账户本人签字画押——而且卡一换主人,绑定的收款账户立刻作废。
2. 顶层全景(它大概怎么转)
IdentityRegistry 继承三样东西,各管一摊(src/IdentityRegistry.sol:31):
| 基类 | 给了什么能力 | 为什么要 |
|---|---|---|
ERC721URIStorage | NFT 所有权 / 转让 / 每 token 一个 tokenURI | agent 即 NFT;agentURI 复用 tokenURI 存储 |
ReentrancyGuard | nonReentrant 修饰符 | register 铸币会回调外部合约,防重入 |
IIdentityRegistry | 接口:结构体 / 事件 / 函数签名 | 对外契约,见 src/interfaces/IIdentityRegistry.sol |
链上到底存了什么? 只有三份状态(src/IdentityRegistry.sol:38-44):
| 状态变量 | 类型 | 存什么 |
|---|---|---|
_agentIdCounter | Counters.Counter | 下一个要发的 agentId,自增 |
_metadata | agentId → key → bytes | 每个 agent 的任意键值标签 |
_agentWallet | agentId → address | 每个 agent 的收款钱包(保留键,单独存) |
注意 agentURI 不在这张表里——它借住在父类 ERC721URIStorage 的 tokenURI 存储里。
一次注册,数据往哪走(从左到右,自上而下):
register(agentURI, metadata)
│
├─ _mintAgent(msg.sender, agentURI) IdentityRegistry.sol:270
│ ├─ agentId = counter.current() ← 取号(从 1 起)
│ ├─ counter.increment() ← 号码进一
│ ├─ _safeMint(to, agentId) ← 铸 NFT(可能回调 to)
│ ├─ _setTokenURI(agentId, uri) ← agentURI 存进 tokenURI 槽
│ ├─ _agentWallet[id] = to ← 钱包默认=铸造者本人
│ └─ emit Registered(id, uri, to)
│
└─ _setMetadataBatch(agentId, metadata) IdentityRegistry.sol:294
└─ 逐条写入,但拦截 "agentWallet" 键
三条读写通道,权限各不同:
| 想干的事 | 入口函数 | 谁能做 |
|---|---|---|
| 注册新 agent | register() × 3 重载 | 任何人(铸给自己) |
| 改资料页网址 | setAgentURI | owner 或被授权者 |
| 写普通标签 | setMetadata / 注册时批量 | owner 或被授权者 |
| 设收款钱包 | setAgentWallet | owner 授权 且 钱包本人签名 |
| 清收款钱包 | unsetAgentWallet | owner 或被授权者 |
| 转让 agent | ERC-721 transferFrom 等 | owner 或被授权者(钱包被清零) |
3. 核心原理(逐个机制,由浅入深)
3.1 agentId 从 1 起,0 永远是「不存在」
要解决的小问题: 智能合约里,address 和 uint256 的默认值都是 0。如果 agentId 也能是 0,那「查一个没注册的 agent」和「查 0 号 agent」就分不清了。
做法: 构造函数一上来就把计数器 +1,让第一个真实 agent 从 1 开始;0 号永远空着代表「不存在」(src/IdentityRegistry.sol:61-75):
constructor() ERC721("ERC-8004 Trustless Agent", "AGENT") {
// Agent IDs start from 1 (0 is reserved for non-existent agents)
_agentIdCounter.increment(); // :63,计数器从 0 抬到 1
...
}
取 号在 _mintAgent 里:先读当前值当 agentId,再自增(src/IdentityRegistry.sol:274-275)。所以第一次 register 拿到的是 1,不是 0。
totalAgents() 因此要减 1 才是真实数量:计数器指向的是「下一个待发的号」(src/IdentityRegistry.sol:249-251)。
3.2 三种 register():重载按参数个数分流
思路: 注册时你手头信息可能有多有少——有的连资料页都还没写好。ERC-8004 用 Solidity 的函数重载(同名、按参数个数区分)给你三个入口,全都汇流到内部的 _mintAgent:
| 重载 | 何时用 | 源码 |
|---|---|---|
register(agentURI, metadata[]) | 资料页 + 一批标签一次搞定 | :85-95 |
register(agentURI) | 只有资料页 | :102-104 |
register() | 啥都没有,先占个号,以后再 setAgentURI | :111-113 |
三者都带 nonReentrant。原因在 _mintAgent 里的 _safeMint:它会回调接收方合约的 onERC721Received,这是个外部调用入口,必须防重入(src/IdentityRegistry.sol:277)。
最全的那个重载,拿到号后才批量写 metadata,而且这一步会拒绝 agentWallet 键(见 3.4):
function register(string calldata agentURI, MetadataEntry[] calldata metadata)
external nonReentrant returns (uint256 agentId)
{
agentId = _mintAgent(msg.sender, agentURI); // :89
if (metadata.length > 0) {
_setMetadataBatch(agentId, metadata); // :93,会拦 agentWallet
}
}
3.3 agentURI 复用 tokenURI:链上只存指针
要解决的小问题: agent 的完整自我介绍(名字、能力、endpoint、支持协议……)可能很大,全上链太贵。
做法: 只在链上存一个 URL,指向链下的注册 JSON;完整信息放链下(IPFS / HTTP 皆可)。而「每个 NFT 挂一个链下 URL」正是 ERC721URIStorage 的 tokenURI 干的事——agentURI 直接复用它,不另开存储:
- 铸造时:
_setTokenURI(agentId, agentURI)(src/IdentityRegistry.sol:280)。 - 事后改:
setAgentURI同样调_setTokenURI(src/IdentityRegistry.sol:168-175)。
所以 registry.tokenURI(agentId) 和 agent 的 agentURI 是同一个东西。NFT 市场、钱包直接就能显示 agent 的资料页,零额外适配。
setAgentURI 要 owner 或被授权者,且非空(src/IdentityRegistry.sol:169-170),改完发 URIUpdated 事件。
3.4 键值 metadata 与 agentWallet 的「保留键」拦截
要解决的小问题: agent 想挂任意标签(如 "supportedProtocols")——这用一个 key → bytes 的映射就行。但有一个键很特殊:agentWallet(收款地址)。如果它能像普通标签一样随便写,那我就能把别人的地址写成我 agent 的收款钱包,或者把自己伪造成某个知名地址来骗打款。
做法: 把 agentWallet 定为保留键——所有普通写入通道都用 keccak 哈希比对拦掉这个键,逼你走专门的、要签名的 setAgentWallet。
两处写入通道,两处拦截,报错文案不同:
// setMetadata 单条写入,:132-135
require(
keccak256(bytes(metadataKey)) != keccak256(bytes("agentWallet")),
"Cannot set agentWallet via setMetadata"
);
// _setMetadataBatch 批量写入,:300-303
require(
keccak256(bytes(metadata[i].metadataKey)) != keccak256(bytes("agentWallet")),
"Cannot set agentWallet via metadata"
);
为什么比哈希而不是比字符串?Solidity 里两个
string不能直接==;keccak256(bytes(...))把变长字符串压成定长bytes32再比,是链上比字符串相等的惯用法。
读的一侧也统一了。 agentWallet 虽然单独存在 _agentWallet 映射里,但 getMetadata 特判这个键,把地址 abi.encode 成 bytes 返回——于是「读标签」这一个接口对普通键和 agentWallet 都好使(src/IdentityRegistry.sol:155-157):
if (keccak256(bytes(metadataKey)) == keccak256(bytes("agentWallet"))) {
return abi.encode(_agentWallet[agentId]); // 读也从保留通道走
}
return _metadata[agentId][metadataKey];
一个易忽略的细节:MetadataSet 事件把 key 发了两遍。 一次作为 indexed(被哈希,用于按键过滤日志),一次作为普通字段(能读出原文)(src/IdentityRegistry.sol:139 与 :305-310)。indexed string 在日志里存的是哈希、查得快但读不出原文,所以再附一份明文——这是 Solidity 里「既想索引又想能读原文」的常见双发模式。
3.5 setAgentWallet:EIP-712 结构化签名 + ECDSA→ERC-1271 双路验签
要解决的小问题: 我想给 agent 登记一个收款钱包。光靠 agent owner 说了不算——必须那个钱包本人同意收款,否则我能把任何人的地址填进去。
思路: 让 newWallet 对一段结构化数据签名,合约验签通过才认。用 EIP-712(结构化数据签名标准)而不是裸哈希签名,好处是钱包 App 能把「你正在授权成为 agent #7 的收款钱包,截止某时」清清楚楚展示给用户,而不是一串看不懂的 hex,防钓鱼。
四道门,缺一不可(src/IdentityRegistry.sol:186-218):
setAgentWallet(agentId, newWallet, deadline, signature)
│
├─① _isApprovedOrOwner(msg.sender, agentId) :192 发起人得是 owner/被授权者
├─② newWallet != address(0) :193 不能是零地址
├─③ block.timestamp <= deadline :194 签名没过期
└─④ 签名确实出自 newWallet :197-212 下面详解
├─ 先按 EOA 验:ECDSA.tryRecover :203
└─ 不行再按合约钱包验:ERC-1271 :208-210
第 ④ 步的双路验签是精华。先构造 EIP-712 摘要,再走两条路:
// 1) 结构哈希:把 (typehash, agentId, newWallet, deadline) 打包哈希 :197-199
bytes32 structHash = keccak256(
abi.encode(_SET_AGENT_WALLET_TYPEHASH, agentId, newWallet, deadline)
);
// 2) EIP-712 摘要:"\x19\x01" ‖ 域分隔符 ‖ 结构哈希 :200
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _DOMAIN_SEPARATOR, structHash));
// 3a) 先当普通钱包(EOA)验:从签名恢复出签名者地址 :203-205
(address recoveredSigner, ECDSA.RecoverError error) = ECDSA.tryRecover(digest, signature);
bool validSignature = (error == ECDSA.RecoverError.NoError && recoveredSigner == newWallet);
// 3b) EOA 恢复失败,再当智能合约钱包(ERC-1271)验 :208-210
if (!validSignature) {
validSignature = SignatureChecker.isValidSignatureNow(newWallet, digest, signature);
}
require(validSignature, "Invalid signature"); // :212
为什么要两路?
- EOA(普通私钥账户):签名可以数学反推出签名者地址。用
tryRecover(不 revert、返回错误码的安全版),恢复出的地址等于newWallet即通过。 - 智能合约钱包(如多签):没有私钥、反推不出地址,得反过来「问合约:这个签名对你有效吗」——这就是 ERC-1271。OZ 的
SignatureChecker.isValidSignatureNow会去调那个合约的isValidSignature。
先试 EOA、失败再试 ERC-1271,一段代码 同时兼容两类钱包。
域分隔符 _DOMAIN_SEPARATOR 在构造时算好并设为 immutable,把签名钉死在「本合约 + 本链 + 名字 ERC-8004 IdentityRegistry + 版本 1.1」上,防止签名被搬到别的合约或别的链复用(src/IdentityRegistry.sol:48-54、:66-74)。两个 typehash 常量也在同处定义。
验签通过后写入 _agentWallet[agentId] 并发 AgentWalletSet(src/IdentityRegistry.sol:215-217)。
3.6 unsetAgentWallet 与「转让即清零」
unsetAgentWallet 是一个直接把钱包清零的通道,只要 owner 或被授权者即可、不需要签名(把地址设回 0 不会误导任何人往错误地址打钱,所以无需证明)(src/IdentityRegistry.sol:235-241)。它同样发 AgentWalletSet,只是 newWallet 为零地址。
注:
unsetAgentWallet是参考实现在收款钱包机制上后加的一条「主动清空」路径(相对早期只有 set)(inferred)。合约域分隔符里硬编码的版本字符串是"1.1"(src/IdentityRegistry.sol:70)。
更关键的是被动清零——转让即作废。 合约 override 了 ERC-721 的 _transfer:任何转让发生前,先把该 agent 的 agentWallet 归零,再调父类真正转手(src/IdentityRegistry.sol:318-328):
function _transfer(address from, address to, uint256 tokenId)
internal virtual override
{
_agentWallet[tokenId] = address(0); // :325 先清零收款钱包
super._transfer(from, to, tokenId); // :327 再真正转让
}
安全考量: 收款钱包是旧主人证明过的。agent 一旦易主,旧钱包的授权就不该继续生效——否则你买下一个 agent,别人打给它的钱还流向前主人的地址。清零后,新主人必须重新走 setAgentWallet 签名流程,才能重新启用收款。这把「所有权转移」和「资金流向」强制解绑。
一个值得注意的不对称: 铸造时 _mintAgent 把钱包默认设成铸造者本人(_agentWallet[id] = to,src/IdentityRegistry.sol:284)——因为铸造者就是签名铸币的人,默认收自己天经地义。但转让时却清零而非改设为新主人(:325),因为合约无法替新主人「同意收款」,必须让他自己签。一处默认信任、一处默认怀疑,分界正是「这个地址有没有主动表过态」。
4. 巧妙之处(可带走的技术)
- 让 agent = NFT,白嫖整个 ERC-721 生态。 所有权、转让、市场挂单、
tokenURI展示全是现成的,合约自己几乎不写这些逻辑(src/IdentityRegistry.sol:31继承)。 - 计数器从 1 起 + 0 留空。 一行
increment()就把「不存在」和「0 号」永久区分开(:63)。 - 保留键用 keccak 比对拦截。 把敏感字段
agentWallet从普通 KV 通道里「挖走」,逼它走签名路径,同时读侧又用特判把接口重新统一(:132-135、:300-303、:155-157)。 - 验签双路 EOA→合约钱包。
tryRecover+SignatureChecker一段代码同时吃普通钱包和多签(:203-210)。 - 转让即清零,把所有权和资金流向解绑。 override
_transfer一行搞定,不给「买了 agent、钱还进旧主人口袋」留缝(:325)。
5. 边界与局限(诚实)
- 签名无 nonce。
setAgentWallet的结构哈希只含agentId / newWallet / deadline,没有 nonce(src/IdentityRegistry.sol:197-199)。在deadline之前,同一份签名可被重复提交;若中途unsetAgentWallet清了钱包,持旧签名者仍能在过期前把它再设 回去。因结果都是设成同一个已授权地址,危害有限,但不是严格一次性(inferred)。 - 域分隔符锁死构造时的
block.chainid(immutable)。 若链发生硬分叉(chainid 改变),域分隔符不会重算,可能影响新链上验签的隔离性(:54、:71)(inferred)。 - 链上不校验
agentURI内容。 合约只存 URL,不验证链下 JSON 是否存在、格式是否合规——那是链下消费方的事。 - metadata 值是裸
bytes,无 schema 约束。 键、值的语义完全靠链下约定,合约不解释(:41)。 - 本章不覆盖声誉与验证。 打分逻辑见 声誉注册表;独立复核与承诺哈希见 验证注册表。
6. 代码地图(导航索引)
src/IdentityRegistry.sol(实现)与 src/interfaces/IIdentityRegistry.sol(接口)@ 2e5e79d。
| 主题 | 文件 | 符号 |
|---|---|---|
| 合约声明 / 三基类继承 | src/IdentityRegistry.sol | IdentityRegistry(:31) |
| 三份链上状态 | src/IdentityRegistry.sol | _agentIdCounter _metadata _agentWallet(:38-44) |
| EIP-712 常量与域分隔符 | src/IdentityRegistry.sol | _TYPE_HASH _SET_AGENT_WALLET_TYPEHASH _DOMAIN_SEPARATOR(:48-54) |
| 构造:计数器抬到 1 + 建域分隔符 | src/IdentityRegistry.sol | constructor(:61-75) |
| 三种注册重载 | src/IdentityRegistry.sol | register(:85 / :102 / :111) |
| 铸币核心(取号 / safeMint / 默认钱包) | src/IdentityRegistry.sol | _mintAgent(:270) |
| 单条写标签 + 拦 agentWallet | src/IdentityRegistry.sol | setMetadata(:125) |
| 批量写标签 + 拦 agentWallet | src/IdentityRegistry.sol | _setMetadataBatch(:294) |
| 读标签(agentWallet 特判) | src/IdentityRegistry.sol | getMetadata(:148) |
| 改资料页网址(复用 tokenURI) | src/IdentityRegistry.sol | setAgentURI(:168) |
| 设收款钱包(EIP-712 + ERC-1271 双路) | src/IdentityRegistry.sol | setAgentWallet(:186) |
| 清收款钱包(无需签名) | src/IdentityRegistry.sol | unsetAgentWallet(:235) |
| 转让即清零钱包 | src/IdentityRegistry.sol | _transfer(:318) |
| 数量 / 存在性视图 | src/IdentityRegistry.sol | totalAgents(:249) agentExists(:258) |
| 结构体 MetadataEntry | src/interfaces/IIdentityRegistry.sol | MetadataEntry(:27) |
| 事件定义 | src/interfaces/IIdentityRegistry.sol | Registered(:40) MetadataSet(:49) URIUpdated(:62) AgentWalletSet(:70) |