1、NFT合约基础回顾:ERC-721与ERC-1155标准核心差异、元数据存储方案(链上/链下)、合约所有权与权限模型

好,咱们直接进入正题。做NFT交易合约审计,第一步得把地基打牢。说白了,你得先搞清楚你审计的对象到底是个什么玩意儿。ERC-721和ERC-1155,这两个标准是绕不开的。我见过太多审计报告,漏洞根源就是对这两个标准理解不透彻。

1.1 ERC-721与ERC-1155:一对“亲兄弟”

先聊聊ERC-721。这是最经典的NFT标准,一个Token ID对应一个唯一的资产。你想想看,CryptoPunks、BAYC,都是这个路子。每个NFT都是独一无二的,不能拆分。

ERC-1155就不一样了。它是个多代币标准。一个合约里可以同时管理同质化代币(比如游戏金币)和非同质化代币(比如武器皮肤)。我去年审计过一个GameFi项目,他们用ERC-1155管理所有游戏资产,一个合约搞定,省了不少Gas费。

核心差异在哪?我列个表,你一看就明白:

特性 ERC-721 ERC-1155
代币唯一性 每个Token ID唯一 Token ID可对应多个副本
批量操作 不支持原生批量 原生支持批量铸造/转账
Gas效率 单次操作Gas较高 批量操作Gas大幅降低
安全钩子 _beforeTokenTransfer _beforeTokenTransfer + onERC1155Received
典型场景 数字艺术品、收藏品 游戏道具、混合资产

审计要点: 检查合约是否混用了两个标准。比如,一个ERC-721合约里出现了ERC-1155的批量转账逻辑,这通常是个危险信号。我见过一个项目,在ERC-721的safeTransferFrom里偷偷调用了ERC-1155的批量接口,结果导致资产被重复铸造。

1.2 元数据存储方案:链上还是链下?

元数据,就是描述NFT的那堆信息——图片URL、名称、属性。存储方案直接决定了NFT的“生死”。

链下存储是最常见的。把元数据JSON文件放在IPFS或中心化服务器上,合约里只存一个URI。优点是便宜,缺点是——嗯,你懂的,服务器一挂,NFT就变成了一张“白纸”。

我建议你审计时重点关注tokenURI()函数的实现。很多项目直接返回一个固定的Base URI,然后拼接Token ID。这有个坑:如果项目方修改了Base URI,所有NFT的元数据都会变。说白了,项目方可以“换皮”。

// 不安全的实现
function tokenURI(uint256 tokenId) public view override returns (string memory) {
    return string(abi.encodePacked(baseURI, tokenId.toString()));
}

// 更安全的做法:使用内容寻址(如IPFS哈希)
function tokenURI(uint256 tokenId) public view override returns (string memory) {
    require(_exists(tokenId), "URI query for nonexistent token");
    return string(abi.encodePacked("ipfs://", _tokenURIs[tokenId]));
}

链上存储就硬核多了。元数据直接写在合约里,或者用SVG/Base64编码。优点是去中心化,谁也改不了。缺点嘛,部署成本高,而且元数据一旦上链就改不了了——这对某些需要动态更新的项目来说是个麻烦。

我的经验: 审计时一定要检查元数据是否可被篡改。我曾经审计过一个项目,他们的setBaseURI()函数没有加权限控制,结果被攻击者改成了钓鱼网站的链接。嗯,这种低级错误其实挺常见的。

1.3 合约所有权与权限模型

所有权模型,说白了就是“谁说了算”。OpenZeppelin的Ownable是最基础的方案——一个owner地址拥有所有管理权限。但说实话,这在NFT项目里风险很大。单点故障,你懂的。

我更喜欢AccessControl这种基于角色的方案。你可以定义MINTER_ROLEPAUSER_ROLEBURNER_ROLE等角色,把权限分散给不同地址。这样即使某个角色私钥泄露,损失也是可控的。

避坑指南: 我曾经审计过一个项目,他们的onlyOwner修饰符用在了mint()函数上。结果项目方为了省Gas,把owner设成了一个多签钱包。但多签钱包的调用需要时间,导致用户铸造体验极差。更糟的是,owner权限可以随时调用withdraw()把合约里的ETH转走——这其实是个中心化风险。

权限模型还有一个容易被忽视的点:代理合约的权限。如果你用的是UUPS或透明代理,逻辑合约的initialize()函数必须加initializer修饰符。否则,攻击者可以直接调用initialize()把自己设为owner。

// 正确的初始化方式
function initialize(address _owner) external initializer {
    __Ownable_init();
    transferOwnership(_owner);
}

// 错误的做法——没有initializer保护
function initialize(address _owner) external {
    owner = _owner; // 任何人都可以调用!
}

嗯,这里要注意:__Ownable_init()transferOwnership()的顺序不能乱。先初始化Ownable,再转移所有权。我见过有人写反了,结果owner变成了零地址,整个合约直接废了。

最后提一嘴权限升级。很多项目用OwnabletransferOwnership()来更换owner。但如果你用的是多签钱包,建议加上时间锁。这样即使owner私钥被盗,你还有时间反应。我有个朋友的项目就是吃了这个亏——owner被钓鱼,攻击者直接调用了withdraw(),几百万美元瞬间没了。

总结一下: 审计NFT合约,先看标准选对了没,再看元数据能不能被篡改,最后检查权限模型有没有单点风险。这三个点踩住了,至少能防住80%的常见漏洞。