EIP8790 公民身份 NFT 的设计目标不是收藏展示,而是为地址绑定可验证的公民资格。React 应用迁移到这一标准时,如果只是替换合约地址和 ABI,很容易把身份有效性判断遗漏在前端业务之外。典型症状是用户钱包里仍持有该 NFT,但身份早已过期或被吊销,路由守卫却依然放行。本文以 ethers.js v6 与 React 18 为例,说明如何从合约读取层、签名校验层和路由状态层完成迁移。

一、迁移前必须理解 EIP8790 的数据模型变化
传统 NFT 应用通常只关心 tokenURI、ownerOf 和 balanceOf 这几个接口。只要地址持有 token,前端就展示资产卡片,这种逻辑对头像类、门票类 NFT 勉强够用。但 EIP8790 公民身份 NFT 在 ERC-721 基础上增加了一组身份属性,包括签发机构 issuer、身份主体 subject、签发时间 issuedAt、过期时间 expiresAt 以及吊销标记 revoked。这些字段通常不会拆散在 ERC-721 的枚举接口中,而是通过专门的 getIdentity 函数一次性返回结构化数据。
迁移前需要先看清这份数据结构的差异。下面是一个简化的 EIP8790 合约接口:
interface IERC8790 {
struct Identity {
address subject;
address issuer;
uint64 issuedAt;
uint64 expiresAt;
bool revoked;
string metadataURI;
}
function getIdentity(uint256 tokenId) external view returns (Identity memory);
function isValid(uint256 tokenId) external view returns (bool);
function verifySignature(uint256 tokenId, bytes memory signature) external view returns (bool);
}
从这段接口可以看出,isValid 并不是简单返回 true 或 false 的静态标记。它需要结合当前区块时间、吊销列表和签发机构状态综合判断。如果 React 应用仍然只调用 ownerOf 来判断用户是否拥有公民身份,就完全绕开了过期和吊销逻辑。这是迁移中最先要纠正的认知偏差。
另一个容易忽略的变化是元数据来源。普通 NFT 项目通常把 tokenURI 指向 IPFS 或中心化服务器,前端直接请求 JSON 文件渲染头像。EIP8790 公民身份 NFT 的 metadataURI 可能指向加密身份声明,甚至需要携带签名后从授权接口获取。因此,前端不能再假设元数据永远公开可读,而要在读取元数据前先确认身份当前是否有效。
二、React 应用接入 EIP8790 合约读取层
迁移的第一步是把原先的 NFT 读取 hook 改为 EIP8790 专用 hook。这里不能只是把 ABI 换掉,还要把返回结果从单个字符串扩展为包含有效期、吊销状态和签发机构的完整对象。下面是一个基于 ethers.js v6 的 React hook 示例:
import { useCallback, useEffect, useState } from 'react';
import { ethers } from 'ethers';
const EIP8790_ABI = [
'function getIdentity(uint256 tokenId) external view returns (tuple(address subject,address issuer,uint64 issuedAt,uint64 expiresAt,bool revoked,string metadataURI))',
'function isValid(uint256 tokenId) external view returns (bool)',
];
export function useEIP8790Identity(contractAddress, tokenId) {
const [identity, setIdentity] = useState(null);
const [valid, setValid] = useState(false);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
if (!window.ethereum || !tokenId) return;
setLoading(true);
try {
const provider = new ethers.BrowserProvider(window.ethereum);
const contract = new ethers.Contract(contractAddress, EIP8790_ABI, provider);
const raw = await contract.getIdentity(tokenId);
const isValidNow = await contract.isValid(tokenId);
setIdentity({
subject: raw.subject,
issuer: raw.issuer,
issuedAt: Number(raw.issuedAt),
expiresAt: Number(raw.expiresAt),
revoked: raw.revoked,
metadataURI: raw.metadataURI,
});
setValid(isValidNow);
} catch (error) {
console.error(error);
setIdentity(null);
setValid(false);
} finally {
setLoading(false);
}
}, [contractAddress, tokenId]);
useEffect(() => {
load();
}, [load]);
return { identity, valid, loading, reload: load };
}
这个 hook 与普通 NFT 读取逻辑最明显的区别是同时调用了 getIdentity 和 isValid。单独调用 getIdentity 只能拿到原始字段,并不能代替前端判断有效性。虽然也可以在前端根据 expiresAt 和 revoked 自行计算,但合约端可能还包含其他约束,例如签发机构被冻结、身份被继承覆盖等情况,因此应以 isValid 的结果为准。
如果你的项目使用了 React Query 或 SWR 做服务端状态缓存,迁移时需要特别注意缓存时长。公民身份有效期可能短至几小时,而吊销操作可以随时发生。建议将 staleTime 设置为 30 秒以内,或者在用户进入受保护页面前强制执行一次 reload。长期缓存 valid 状态会让已经吊销的身份继续通过前端检查,这是非常危险的。
三、签发签名验证与前端防绕过
EIP8790 公民身份 NFT 通常由官方机构签发,因此身份数据往往带有签发者的链下签名。合约端虽然可以通过 verifySignature 验证签名,但部分 DApp 为了降低 gas 成本,会把签名验证放在前端完成。这种做法本身没有错,关键在于前端必须保存可信的签发机构地址列表,并对每个身份声明做完整校验。
下面给出一个离线验证签发签名的函数。它把身份关键字段打包成哈希,再通过 ethers.verifyMessage 恢复签名地址,最后与 issuer 比较:
import { ethers } from 'ethers';
async function verifyIssuerSignature(identity, signature) {
const messageHash = ethers.solidityPackedKeccak256(
['address', 'address', 'uint64', 'uint64'],
[identity.subject, identity.issuer, identity.issuedAt, identity.expiresAt]
);
const recovered = ethers.verifyMessage(ethers.getBytes(messageHash), signature);
return recovered.toLowerCase() === identity.issuer.toLowerCase();
}
这里有几个容易出错的地方。第一,打包字段顺序必须与签发时完全一致,否则恢复出的签名地址会完全不同。第二,不要用 Date.now() 与 expiresAt 直接比较,前者是毫秒时间戳,后者通常是秒级时间戳,单位不统一会造成误判。第三,签名验证只能证明这份身份数据确实来自签发机构,不能证明该身份当前未被吊销,所以它必须配合合约查询一起使用。
签发机构地址列表也不建议写死在单个组件里。可以将可信地址配置在环境变量中,或者从链上注册表合约读取。这样当签发机构密钥轮换或新增分支机构时,React 应用无需重新构建发布。更重要的是,如果地址列表只存在于前端代码中,攻击者可以通过修改本地 bundle 替换签发地址,因此涉及高价值权限的身份校验仍应以合约验证为准。
四、路由守卫与身份过期状态管理
迁移后的路由守卫不应只判断钱包是否连接,还要结合 EIP8790 身份的有效性。一个常见的错误是只在用户首次进入页面时检查一次,之后即使身份过期也不更新。正确做法是把 loading、identity 和 valid 全部纳入守卫条件,任何一项不满足都跳转到无权限页面。示例代码如下:
import { Navigate, Outlet } from 'react-router-dom';
function CitizenshipGuard({ identity, valid, loading }) {
if (loading) {
return <p>正在核验公民身份...</p>;
}
if (!identity || !valid) {
return <Navigate to="/identity/invalid" replace />;
}
return <Outlet />;
}
为了让 valid 能随着时间自动失效,可以在 useEIP8790Identity 中增加一个定时器。当身份快到期时,定时器触发并把 valid 设置为 false,这样路由守卫会立即响应,用户无需刷新页面。实现方式如下:
useEffect(() => {
if (!identity || !identity.expiresAt) return;
const now = Math.floor(Date.now() / 1000);
const delay = Math.max(0, identity.expiresAt - now) * 1000;
const timer = setTimeout(() => {
setValid(false);
}, delay);
return () => clearTimeout(timer);
}, [identity]);
除了定时过期,吊销状态的实时同步同样重要。如果身份在合约中被吊销,而前端没有重新查询,用户仍会停留在受保护页面。可以通过监听合约的 IdentityRevoked 事件来触发状态更新。示例代码:
useEffect(() => {
if (!contract || !tokenId) return;
const filter = contract.filters.IdentityRevoked(tokenId);
const handler = () => {
setValid(false);
};
contract.on(filter, handler);
return () => {
contract.off(filter, handler);
};
}, [contract, tokenId]);
这里要注意 contract.off 的清理逻辑。很多前端项目在组件卸载时忘记移除监听器,导致事件重复绑定,进而引发多次无意义的状态更新。还要确保 tokenId 变化时先移除旧监听再绑定新监听,否则会监听到不属于当前身份的吊销事件。
五、迁移后的测试清单与常见错误
迁移完成并不意味着可以直接上线。EIP8790 身份验证涉及链上状态、前端缓存和用户操作之间的时序关系,比普通 NFT 展示更容易出现边界问题。下面用一张对比表列出传统 NFT 对接与 EIP8790 对接的关键差异:
| 检查项 | 普通 NFT 对接 | EIP8790 公民身份对接 |
|---|---|---|
| 身份判断依据 | balanceOf 或 ownerOf | getIdentity 加 isValid |
| 有效期处理 | 一般无有效期 | 必须处理 expiresAt |
| 吊销处理 | 通常不支持 | 必须处理 revoked 标记 |
| 签发签名 | 一般无签名 | 需校验 issuer 签名 |
| 前端缓存 | 可以长时间缓存 | 需短时缓存或实时刷新 |
迁移后至少要覆盖五类测试场景。第一,身份有效时能够正常进入受保护页面;第二,身份过期后路由守卫自动拦截;第三,身份被吊销后页面权限立即回收;第四,签发签名不合法时即使合约返回有效也不能放行;第五,用户切换钱包地址后必须重置身份状态,避免 A 用户的身份被 B 用户复用。
另一个常见错误是在迁移时保留旧的数据获取逻辑,只是在页面顶部加一个 if (!valid) return null。这样做虽然能挡住部分无效身份,但无法阻止后续组件继续使用已经缓存的 identity 数据。正确做法是把身份读取、签名验证和路由守卫收敛到同一个数据流中,让所有下游组件都从同一个 hook 获取最新状态,而不是各自维护一套查询逻辑。
最后要强调的是,EIP8790 公民身份 NFT 的安全边界在合约、签名和前端路由之间是连续的。任何一层单独加固都不够,只有让合约返回的数据、签名恢复的地址和路由守卫的判断条件保持一致,迁移后的 React 应用才能可靠地区分有效公民、过期身份和伪造声明。