- 示例工程
- 区块链
【免费下载链接】Dapp-Learning
Dapp learning project for developers at all stages. Becoming and cultivating sovereign individuals. Nonprofit organization.
导读
本文基于 Dapp-Learning 仓库中 ERC721A-Nov16-2022.md 的研究笔记,深入拆解 Azuki 团队开源的 ERC721A 在 2022 年 11 月版本中为节省 Gas 所做的三类核心优化:用log4汇编替代普通emit发出Transfer事件、将多组状态变量打包进单个uint256槽位、以及在transferFrom中配合稀疏存储做所有权查询与后置初始化。读完本文,你将能理解 ERC721A 相比 OpenZeppelin ERC721 实现到底"省在哪里",并能把这套位打包(bit-packing)、事件签名常量化和循环前置条件等技巧复用到自己的合约优化实践中。
阅读前建议先通读仓库内的基础篇 ERC721A.md(ERC721A 算法分析与设计)与 Comparative.md(ERC721 与 ERC721A 对比分析),本文是其 Gas 细节层面的续篇。
一、背景:为什么 ERC721A 需要"细节级"Gas 优化
ERC721A 的核心改进是把批量铸造(batch mint)的算法复杂度从O(N)降为O(1):铸造 N 个 NFT 时,不再像 OpenZeppelin 实现那样循环调用 N 次_safeMint、执行至少2N次SSTORE,而是只写一次owner信息,后续tokenId的归属通过"稀疏存储 + 向前回溯查询"推导,具体原理见 ERC721A.md。
然而"批量铸造只写一次"只是把大头省下来了,细节处的 Gas 仍然可抠。2022 年 11 月版本(对应 chiru-labs/ERC721A 仓库0476a00259b5b3ec39fd8e01300b2ff5f2276e7b提交的contracts/ERC721A.sol)在这几处做了进一步打磨:
- 事件发出:用汇编
log4直接发Transfer事件,绕过编译器为emit生成的额外字节码与每次的签名哈希计算; - 状态写入:把
balance、numberMinted、numberBurned、aux打包进一个uint256,把addr、startTimestamp、burned、nextInitialized、extraData打包进另一个uint256,两次写入覆盖原本需要多次SSTORE才能完成的 8 条数据; - 转移路径:借助打包后的
nextInitialized标志位与"初始化前置不变量",避免在已初始化的 token 上做无谓的向前循环; - 条件判断:用嵌套
if替代复合布尔条件,再省约 20 Gas。
下面逐项拆解。
二、事件 Gas 优化:用log4汇编代替emit Transfer
2.1 事件定义
ERC721 的Transfer事件标准定义为:
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);三个参数全部是indexed。这是后续能用log4直发的关键前提:所有参数都进 topic,事件 data 部分为空。
2.2 以前(普通)的调用方式
emit Transfer(address(0), toMasked, startTokenId);emit最终也会编译为 EVM 的LOG4指令,但编译器会额外生成:每次执行时计算/加载事件签名、组织 topic 顺序等字节码;且keccak256("Transfer(address,address,uint256)")这个签名哈希如果由运行时反复计算,也是一笔可观的成本。
2.3 节省 Gas 的调用方式
先在合约里定义一个常量,把事件签名的哈希在部署(编译)期算好:
// _TRANSFER_EVENT_SIGNATURE = keccak256("Transfer(address,address,uint256)")因为Transfer的三个参数都是indexed,事件没有非索引数据段,所以log4的 data 部分直接传0, 0:
assembly { log4( 0, // Start of data (0, since no data). 0, // End of data (0, since no data). _TRANSFER_EVENT_SIGNATURE, // Signature,即 "Transfer(address,address,uint256)" 的 keccak256 哈希 0, // `address(0)`,作为第一个 topic(from) toMasked, // `to`,作为第二个 topic startTokenId // `tokenId`,作为第三个 topic ) }2.4 原理与收益
log4是 EVM 的LOG4操作码:栈上依次传入offset、size(data 起止位置)以及 4 个 topic。按 EVM 费用模型,LOG系列操作码本身有固定 Gas,每多一个 topic 再按 topic 计费。这里恰好 4 个 topic(签名 + from + to + tokenId),所以用log4是语义最贴合、成本结构最透明的选择。- 手动写汇编后,
keccak256签名哈希变成编译期常量,运行时不再重复计算;同时省去编译器为emit生成的多余指令,实现"每条 Transfer 事件省一点"的累积效果。 - 需要说明的是:原笔记中将签名描述为"sha256",实际以太坊事件签名使用的是
keccak256(SHA-3),即keccak256("Transfer(address,address,uint256)"),这是 EVM 事件机制的既定事实。
三、状态变量 Gas 优化:位打包(Bit-Packing)
3.1 以前:四个槽位、四次写入
旧版(或朴素实现)中,每个地址和每个 token 都占用独立的存储槽:
struct TokenOwnership { address addr; uint64 startTimestamp; }铸造时依次写:
_balances[to] += quantity; _numberMinted[to] += quantity; _ownerships[tokenId].addr = to; _ownerships[tokenId].startTimestamp = uint64(block.timestamp);每一条都是独立的SSTORE。而SSTORE是合约执行中最昂贵的操作之一(槽位从零变非零尤其昂贵),批量铸造时这些写入会按 token 数量线性放大。
3.2 优化后:两条 mapping,每个槽位塞满 256 位
ERC721A 2022-11 版本把"每个地址一份数据"和"每个 token 一份数据"分别压缩为单个uint256:
// Mapping owner address to address data. // // Bits Layout: // - [0..63] `balance` // - [64..127] `numberMinted` // - [128..191] `numberBurned` // - [192..255] `aux` mapping(address => uint256) private _packedAddressData;// Bits Layout: // - [0..159] `addr` // - [160..223] `startTimestamp` // - [224] `burned` // - [225] `nextInitialized` // - [232..255] `extraData` mapping(uint256 => uint256) private _packedOwnerships;关键常量(以原笔记列出的为例):
uint256 private constant _BITPOS_NUMBER_MINTED = 64;从位布局看,_packedAddressData的[64..127]正是numberMinted的起始位;同理_packedOwnerships的[0..159]是 160 位地址、[160..223]是startTimestamp、第 224 位是burned、第 225 位是nextInitialized、[232..255]是extraData。
3.3_mint中的打包写入
// The mask of the lower 160 bits for addresses. uint256 private constant _BITMASK_ADDRESS = (1 << 160) - 1; function _mint(address to, uint256 quantity) internal { // ... _packedAddressData[to] += quantity * ((1 << _BITPOS_NUMBER_MINTED) | 1); _packedOwnerships[startTokenId] = _packOwnershipData( to, // 第 224 位 BURNED 记录为 0 // 第 225 位 _nextInitializedFlag(quantity) 记录下一个位置是否已经初始化 // (mint 一个则为已初始化,否则为未初始化,只记录 0 或 1) // 第 232..255 位 _nextExtraData(address(0), to, 0) 记录额外数据 _nextInitializedFlag(quantity) | _nextExtraData(address(0), to, 0) ); // ... }其中_packOwnershipData用汇编把"地址、时间戳、标志位"三者拼进一个uint256:
function _packOwnershipData(address owner, uint256 flags) private view returns (uint256 result) { assembly { // 一个地址只需要 160 位,256 - 160 剩下的空间可用来存时间戳等数据 owner := and(owner, _BITMASK_ADDRESS) // 先做与运算,把高 96 位清空,腾出空间存时间戳 // `owner | (block.timestamp << _BITPOS_START_TIMESTAMP) | flags`. // shl(x, y): 逻辑左移,将 y 左移 x 位 result := or(owner, or(shl(_BITPOS_START_TIMESTAMP, timestamp()), flags)) } }3.4 两次写入、八条数据
原笔记点明:优化后虽然只有两次状态变量写入,却一次性覆盖了8 条数据:
numberMintedbalance- token 归属权(owner 地址)
- 下一个 token 数据是否已初始化(0/1 标志)
timestamp- 额外数据:from 是否为 0
- 额外数据:to 是否为 0
- 上一条(前一 token)的额外数据
_packedAddressData[to] += quantity * ((1 << _BITPOS_NUMBER_MINTED) | 1)的精妙之处在于:quantity乘以(1 << 64) | 1,等于"低 64 位的 balance 加上quantity,高 64 位起的numberMinted也加上quantity",一次算术运算同时维护两个计数器,不需要分别读改写两个槽位。原笔记给出的示例是:假设quantity = 10,则
_packedAddressData[to] += 0x...000000000000000A000000000000000A即高 64 位(numberMinted)与低 64 位(balance)各加0xA(10)。这一行代码是整份笔记中最具代表性的"一石二鸟"优化。
3.5 收益的本质
每少一次SSTORE,就少一份可观的 Gas 开销;而把多个逻辑字段塞进一个uint256,让一次SSTORE完成多字段更新,正是 EVM 存储模型下最有效的省 Gas 手段之一。这也是为什么_packedOwnerships宁愿用 160 位地址 + 位偏移运算,也不愿单独为startTimestamp、burned等字段各开一个槽位。
四、transfer 路径:稀疏存储的查询与后置初始化
4.1 所有权查询:向前回溯_packedOwnershipOf
批量铸造只写了区间内第一个 token 的所有权,后续 token 的槽位是空的(0),因此ownerOf需要向前回溯:
function _packedOwnershipOf(uint256 tokenId) private view returns (uint256 packed) { if (_startTokenId() <= tokenId) { packed = _packedOwnerships[tokenId]; // If not burned. if (packed & _BITMASK_BURNED == 0) { // 如果数据是空,说明是连续 mint 的,那么向前循环查找 if (packed == 0) { if (tokenId >= _currentIndex) revert OwnerQueryForNonexistentToken(); // Invariant: // There will always be an initialized ownership slot // (i.e. `ownership.addr != address(0) && ownership.burned == false`) // before an unintialized ownership slot // (i.e. `ownership.addr == address(0) && ownership.burned == false`) // Hence, `tokenId` will not underflow. // // We can directly compare the packed value. // If the address is zero, packed will be zero. for (;;) { unchecked { packed = _packedOwnerships[--tokenId]; } if (packed == 0) continue; return packed; } } // 否则,说明数据存在并且没有被焚毁,这里可以跳过循环 // 这里可以节省 2143 个 gas 在那些已经初始化的 token 转账上(因为不用循环了) return packed; } } revert OwnerQueryForNonexistentToken(); }注意这里的关键不变量(注释中的Invariant):在任何未初始化槽位之前,必然存在一个已初始化的槽位。因此从tokenId向前递减查找时,--tokenId不会下溢到非法区间;并且只有槽位为 0(说明该 token 属于某次批量铸造的"尾部")才需要循环,凡是已经单独初始化过的 token 直接返回,省去 2143 Gas 的循环开销(该数字出自原笔记对源码行为的解读)。
4.2 转移:transferFrom的四步
function transferFrom( address from, address to, uint256 tokenId ) public payable virtual override { uint256 prevOwnershipPacked = _packedOwnershipOf(tokenId); // ...一些授权的转账操作省略 // Underflow of the sender's balance is impossible because we check for // ownership above and the recipient's balance can't realistically overflow. // Counter overflow is incredibly unrealistic as `tokenId` would have to be 2**256. unchecked { // 1. 取到 ownership 数据后对 balance 进行操作(to++,from--) --_packedAddressData[from]; // Updates: `balance -= 1`. ++_packedAddressData[to]; // Updates: `balance += 1`. // Updates: // - `address` to the next owner. // - `startTimestamp` to the timestamp of transfering. // - `burned` to `false`. // - `nextInitialized` to `true`. // 2. 初始化/修改当前 nft 的 ownership 数据 _packedOwnerships[tokenId] = _packOwnershipData( to, _BITMASK_NEXT_INITIALIZED | _nextExtraData(from, to, prevOwnershipPacked) ); // If the next slot may not have been initialized (i.e. `nextInitialized == false`). // 3. 如果后一位还没初始化(ownership 的第 225 位为 0),则对后一位 nft 的 ownership 数据做初始化 // (把第 1 步找到的数据直接赋值给它) if (prevOwnershipPacked & _BITMASK_NEXT_INITIALIZED == 0) { uint256 nextTokenId = tokenId + 1; // If the next slot's address is zero and not burned (i.e. packed value is zero). if (_packedOwnerships[nextTokenId] == 0) { // If the next slot is within bounds. if (nextTokenId != _currentIndex) { // Initialize the next slot to maintain correctness for `ownerOf(tokenId + 1)`. _packedOwnerships[nextTokenId] = prevOwnershipPacked; } } } } emit Transfer(from, to, tokenId); _afterTokenTransfers(from, to, tokenId, 1); }转移流程的逻辑闭环:
- 取所有权:
_packedOwnershipOf(tokenId)返回打包数据(可能经向前回溯得到); - 更新余额:在
unchecked块里对发送方--、接收方++,仍是对同一个_packedAddressData槽位的低 64 位做加减; - 重写当前 token 槽位:写入新 owner、当前时间戳,并置位
nextInitialized(第 225 位为 1),同时把from、to、旧数据传给_nextExtraData保留额外数据; - 后置初始化下一 token:只有当旧数据的
nextInitialized为 0(说明tokenId + 1属于某次批量铸造的未初始化尾部)且下一槽位确实为空且在界内时,才把prevOwnershipPacked复制给tokenId + 1,从而保证此后ownerOf(tokenId + 1)依然正确。
这一步正是 ERC721A.md 中"转移打破连续性时需补充_owner信息"的打包版实现:原本需要单独写_owners[nextTokenId] = from,现在只是按位判断nextInitialized标志并按需复制整个打包槽位,一次SSTORE即可完成。
五、控制流 Gas 优化:嵌套 if 替代复合条件
5.1 原来(复合布尔条件)的写法
if (!_isSenderApprovedOrOwner(approvedAddress, from, _msgSenderERC721A()) && !isApprovedForAll(from, _msgSenderERC721A())) { revert TransferCallerNotOwnerNorApproved(); }复合条件无论结果如何,&&两侧的求值、短路判断和最终跳转都会产生额外字节码。
5.2 改成嵌套 if
// The nested ifs save around 20+ gas over a compound boolean condition. if (!_isSenderApprovedOrOwner(approvedAddress, from, _msgSenderERC721A())) if (!isApprovedForAll(from, _msgSenderERC721A())) revert TransferCallerNotOwnerNorApproved();按源码注释的说法,嵌套 if 相比复合布尔条件可节省 20+ Gas。其收益来自更紧凑的控制流字节码:第一个条件不满足时直接跳过整个分支,不再为第二个条件做求值与合并判断,编译器生成的跳转也更少。虽然单次省得不多,但transferFrom/safeTransferFrom是高频路径,积少成多。
六、权衡与适用场景
把 2022 年 11 月版本的细节优化放回整体设计看,ERC721A 的取舍非常清晰:
- 批量铸造是主战场:
O(N)到O(1)的批量写、事件log4化、地址数据位打包,全部服务于"一次发行大量 NFT"的场景(PFP、游戏资产等); - 转移路径做了补偿:稀疏存储让
transfer需要回溯查询与后置初始化,但通过nextInitialized标志位和"已初始化槽位直接返回"的短路逻辑,把额外开销压到最低; - 代价是复杂度:位偏移、位掩码、汇编
log4、嵌套 if 都显著提高了实现与审计的复杂度。仓库 Comparative.md 的对比表指出:ERC721A 所有权查询需递减遍历、枚举方法tokenOfOwnerByIndex需遍历全部tokenId(O(N))、非连续tokenId场景(如时间戳生成)会失效——这些局限性在高频转移或需要任意tokenId的项目中需要开发者自行权衡。
对于想进一步验证"以前 vs 现在"差异的读者,仓库 basic/10-hardhat-ipfs-erc721/contracts/ERC721.sol 提供了 OpenZeppelin 风格的对照实现:那里_owners(mapping tokenId → address)与_balances(mapping address → uint256)各自独立成槽、逐个铸造逐个写入,正好可以作为本文"以前调用"部分的反面教材。
七、小结
ERC721A 2022 年 11 月版本的 Gas 优化可以概括为四句话:
- 事件侧:全索引事件用汇编
log4直发,签名哈希编译期固化,data 段传空; - 存储侧:把多个语义字段按位打包进单个
uint256,一次SSTORE完成多条数据的读写,_packedAddressData[to] += quantity * ((1 << 64) | 1)一行同时更新 balance 与 numberMinted; - 查询侧:稀疏存储配合"已初始化槽位必有前置"的不变量,让
ownerOf/transferFrom只在必要时向前回溯,已初始化 token 直接短路; - 控制流侧:嵌套 if 替代复合布尔条件,为高频转移路径再省约 20 Gas。
这四类技巧不局限于 ERC721A,任何追求极致 Gas 的合约都可以复用:事件签名常量 +log4、位打包存储、位标志位驱动的短路逻辑、以及紧凑的控制流写法,是 Solidity 开发者应当掌握的通用优化范式。
- 示例工程
- 区块链
【免费下载链接】Dapp-Learning
Dapp learning project for developers at all stages. Becoming and cultivating sovereign individuals. Nonprofit organization.
相关推荐
别再把API密钥写进配置文件:WTF安全存储Secrets的完整实践指南
别再把API密钥写进配置文件:WTF安全存储Secrets的完整实践指南 还在把 GitHub、Jenkins 或 CircleCI 的 API 密钥直接写进
开发工具1 个聚类键换 25 倍加速:Milvus 聚类压缩完整落地指南
1 个聚类键换 25 倍加速:Milvus 聚类压缩完整落地指南 过滤查询被全量扫描拖到秒级,账单跟着一起涨 Milvus 集合里 768 维向量单条约 3 K
数据库向量数据库分布式数据库后端Expo expo-status-bar 变更日志全解:从版本演进看状态栏 API 的取舍与实现细节
Expo expo status bar 变更日志全解:从版本演进看状态栏 API 的取舍与实现细节 本文以 Expo 官方仓库中 packages/expo
移动开发前端跨平台原生移动
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考