EIP8870并不是一个凭空出现的孤立提案,它针对NFT在遗产继承场景中的痛点做了两项关键扩展:一是在元数据标准中增加beneficiary字段与timeLock截止时间,二是定义了合约必须触发的BeneficiaryChanged事件。Wills协议则是在EIP8870之上构建的一套链上遗嘱管理框架,允许NFT持有人指定多个受益人、设置分配比例以及延迟生效时间。React应用迁移过去,本质上是在现有前端逻辑里适配这套新的数据模型和事件流。接下来从合约交互层、Hook封装层和组件展示层分别展开讨论。

合约ABI与数据模型的迁移适配
迁移的第一步是替换或扩展智能合约ABI文件。传统的ERC721 ABI通常只包含transferFrom、safeTransferFrom、approve等基础方法,而EIP8870兼容合约额外暴露了setBeneficiary、getBeneficiary、getTimeLock以及批量分配接口allocateShares。前端如果使用TypeScript,建议为这些新接口单独定义类型,避免在业务组件里混用any。例如,getBeneficiary的返回值是一个结构体,包含地址、比例和生效区块高度,解析时要注意区块高度与前端时间戳的转换。
Wills协议的合约事件比普通NFT更密集,除了Transfer事件之外,还会在受益人变更、时间锁到期、分配执行时发出BeneficiaryChanged、TimeLockExpired、DistributionExecuted等事件。React应用在迁移时,如果沿用原来的单次查询模式,很容易出现链上数据已更新但页面仍显示旧状态的问题。更稳妥的做法是在数据服务层引入事件订阅机制,同时保留查询作为首屏回退。下面是一个ABI片段示例,展示了EIP8870扩展方法的定义方式。
const eip8870Abi = [
{
"inputs": [
{ "internalType": "address", "name": "beneficiary", "type": "address" },
{ "internalType": "uint256", "name": "share", "type": "uint256" },
{ "internalType": "uint256", "name": "lockUntil", "type": "uint256" }
],
"name": "allocateShare",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"anonymous": false,
"inputs": [
{ "indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256" },
{ "indexed": false, "internalType": "address", "name": "beneficiary", "type": "address" },
{ "indexed": false, "internalType": "uint256", "name": "share", "type": "uint256" }
],
"name": "BeneficiaryChanged",
"type": "event"
}
];
实际迁移时,建议将ABI集中放在一个常量文件中,并为每个方法生成对应的调用函数。这样做的好处是当合约升级或EIP8870版本变化时,只需要改动数据层,组件层几乎不受影响。另一个容易忽略的点是gas费估算。allocateShare操作会写链上状态,React组件里如果每次都调用estimateGas,会额外增加网络往返。可以在应用启动时一次性获取gasPrice并缓存,配合用户钱包地址做本地预估。
自定义Hook封装事件订阅与缓存失效
React应用中直接调用web3.js或ethers.js的on方法监听合约事件,是内存泄漏的重灾区。组件卸载后如果事件监听没有移除,回调里调用setState就会触发React警告,严重时导致页面卡死。迁移到EIP8870 + Wills之后,事件种类更多,必须封装一个独立的Hook来统一管理生命周期。这个Hook内部需要维护一个订阅映射,记录每个tokenId对应的事件监听器,组件卸载时执行off操作。
除了事件订阅,缓存失效策略也要重新设计。原本基于tokenId的简单缓存键已不够用,因为同一个tokenId在时间锁到期前后展示的数据完全不同。建议缓存键包含合约地址、tokenId和当前区块号或时间戳的粗粒度分段。例如,将缓存键格式化为wills-cache-{contract}-{tokenId}-{Math.floor(Date.now() / 60000)},这样每分钟自动过期,避免手动清理遗漏。下面这个Hook封装了事件订阅和查询融合逻辑。
import { useEffect, useState, useRef } from 'react';
import { ethers } from 'ethers';
function useEip8870Token(contract, tokenId) {
const [tokenData, setTokenData] = useState(null);
const [loading, setLoading] = useState(true);
const listenerRef = useRef(null);
useEffect(() => {
let isMounted = true;
const loadInitial = async () => {
try {
const beneficiary = await contract.getBeneficiary(tokenId);
const timeLock = await contract.getTimeLock(tokenId);
if (isMounted) {
setTokenData({ beneficiary, timeLock, retryKey: `${contract.address}-${tokenId}` });
setLoading(false);
}
} catch (err) {
if (isMounted) setLoading(false);
}
};
loadInitial();
const handleBeneficiaryChanged = (eventTokenId, beneficiary, share) => {
if (eventTokenId.eq(tokenId) && isMounted) {
setTokenData(prev => ({ ...prev, beneficiary, share }));
}
};
contract.on('BeneficiaryChanged', handleBeneficiaryChanged);
listenerRef.current = handleBeneficiaryChanged;
return () => {
isMounted = false;
if (listenerRef.current) {
contract.off('BeneficiaryChanged', listenerRef.current);
listenerRef.current = null;
}
};
}, [contract, tokenId]);
return { tokenData, loading };
}
这个Hook的关键点在于依赖数组里包含了contract实例。如果外部传入的contract对象在每次渲染时都是新引用,会导致无限循环。解决方式是在父组件中使用useMemo缓存contract实例,或者将contract实例放在Context中提供。另外,事件回调里的beneficiary参数是地址类型,React展示时需要额外调用ens解析或显示缩写地址,这可以再封装一层格式化函数,避免在Hook内部处理展示逻辑。
组件层的时间锁展示与钱包切换处理
时间锁是EIP8870遗嘱NFT区别于普通NFT的核心状态。用户可能已经添加了受益人,但时间锁未到期,此时受益人无法提取NFT。React组件需要明确区分五种视图状态:无受益人、受益人已设置但时间锁未生效、时间锁已到期但未执行分配、分配执行中、分配已完成。这五种状态的判断逻辑不要散落在多个组件里,建议用一个纯函数根据tokenData返回当前状态枚举,然后由UI组件根据枚举渲染对应提示。
钱包切换是一个高频踩坑点。用户从地址A切换到地址B后,组件里缓存的tokenData可能还是地址A的受益人数据。如果在切换钱包时没有重置缓存,就会展示错误信息。迁移到Wills协议后,受益人数据与钱包地址的关联更紧密,重置时机必须放在钱包连接状态变化的useEffect里。可以封装一个全局的walletChanged事件或者利用React Context,在Provider层统一清空所有EIP8870相关的缓存记录。
另一个需要注意的细节是分配执行中的pending状态。Wills协议的执行通常是一个多步交易:先调用requestDistribution,等待一定区块确认后再调用executeDistribution。前端在request提交后,不能立即把按钮置为已完成,而要轮询事件DistributionRequested和DistributionExecuted。轮询间隔建议取区块平均出块时间的2到3倍,避免过高频率消耗RPC配额。下面给出一个状态判断函数的示例。
function getWillState(tokenData, currentBlockNumber) {
if (!tokenData || !tokenData.beneficiary || tokenData.beneficiary === '0x0000000000000000000000000000000000000000') {
return 'NO_BENEFICIARY';
}
if (tokenData.timeLock && tokenData.timeLock.gt(currentBlockNumber)) {
return 'LOCKED';
}
if (!tokenData.distributionExecuted) {
return 'READY_TO_EXECUTE';
}
if (tokenData.executing) {
return 'EXECUTING';
}
return 'COMPLETED';
}
在UI层,每种状态对应不同的按钮文案和禁用逻辑。例如LOCKED状态显示计时器,READY_TO_EXECUTE状态显示执行按钮。计时器组件需要从链上区块高度换算为本地时间,可以用provider.getBlock(currentBlock)获取时间戳。迁移过程中最耗时的通常是这部分状态机重构,但一旦完成,后续Wills协议升级只会增加新的状态,不会破坏已有逻辑。
最后提醒一点,EIP8870的元数据扩展了beneficiary字段后,原有的NFT展示组件可能直接从metadata.image读取图片地址。如果应用迁移时没有保留对旧metadata格式的兼容,部分资源会加载失败。建议在数据层做一次元数据归一化,同时处理标准ERC721元数据和EIP8870扩展元数据,返回统一的字段结构给组件使用。这样即使在迁移过渡期,也能保证前端渲染不中断。

