diff --git a/nft/ERC721A/Comparative.md b/nft/ERC721A/Comparative.md new file mode 100644 index 000000000..7e8f484a4 --- /dev/null +++ b/nft/ERC721A/Comparative.md @@ -0,0 +1,61 @@ +### **ERC721 与 ERC721A 的对比分析** + +以下是 ERC721 和 ERC721A 的详细对比,从功能、性能、存储优化、复杂性等方面进行了深入分析: + + + +| **特性** | **ERC721** | **ERC721A** | +|--------------------------|-------------------------------------------------|----------------------------------------------------------------------------------------------------| +| **批量铸造支持** | 不支持,需逐个调用 `_mint` | 支持批量铸造,通过优化算法,一次调用即可完成多 NFT 的铸造 | +| **算法复杂度** | \(O(N)\),逐个 NFT 铸造和存储操作 | \(O(1)\),批量更新持有人和存储信息,优化了大规模铸造的性能 | +| **存储机制** | 每个 `tokenId` 都存储独立的 `owner` 信息 | 仅存储第一个 `tokenId` 的 `owner` 信息,后续的 `tokenId` 默认为同一地址直到发生变更 | +| **Gas 成本** | 每个 NFT 的铸造成本较高,需更新多个存储变量 | 显著降低批量铸造的 Gas 成本,尤其是在生成大批量 NFT 时效果显著 | +| **转移性能** | 单个转移效率高,更新对应 `tokenId` 的 `owner` 信息 | 单个转移需额外处理稀疏存储区间(如更新下一个 `tokenId` 的 `owner`),但批量铸造后的稀疏存储优化了存储操作 | +| **批量转移** | 需要逐个转移,性能受限 | 支持批量转移,但性能与实现逻辑有关,无法完全达到批量铸造的优化效果 | +| **所有权查询** | 查询效率较高,直接访问 `_owners[tokenId]` | 查询需处理稀疏存储,通过递减遍历找到最近的 `owner` 信息 | +| **事件兼容性** | 满足 EIP721 的 `Transfer` 事件标准 | 满足标准,批量铸造时会为每个 `tokenId` 发出单独的 `Transfer` 事件 | +| **存储消耗** | 每个 `tokenId` 都记录独立的存储 | 减少了存储消耗,仅在必要时更新 `_owners` 和 `_balances` | +| **枚举方法(tokenOfOwnerByIndex)** | 基于全局 mapping 实现,性能较高 | 需遍历所有 `tokenId` 以确定归属,复杂度为 \(O(N)\) | +| **适用场景** | 适用于小批量、高频转移的 NFT 项目 | 适用于大批量生成的 NFT 项目,如 PFP 系列或游戏资产发行 | +| **实现复杂度** | 简单直接,适合新手开发 | 实现复杂度更高,需精确管理稀疏存储和动态查询逻辑 | + +--- + +### **关键差异分析** + +1. **批量铸造性能** + - **ERC721**:每次铸造需独立调用 `_mint` 方法,更新所有者信息和余额,性能受限,Gas 成本随着铸造数量线性增长。 + - **ERC721A**:通过批量处理仅更新必要的存储信息,使得铸造复杂度降低为 \(O(1)\),在大批量铸造中优势显著。 + +2. **存储优化** + - **ERC721**:每个 `tokenId` 都需要独立存储 `owner` 信息,增加了存储成本。 + - **ERC721A**:仅存储批量铸造中第一个 `tokenId` 的 `owner` 信息,后续 `tokenId` 使用稀疏存储机制,大幅减少存储消耗。 + +3. **转移操作** + - **ERC721**:直接更新对应的 `tokenId` 信息,操作简单高效。 + - **ERC721A**:需在转移后处理稀疏存储的边界条件,例如更新下一个 `tokenId` 的所有者信息,增加了复杂性。 + +4. **所有权查询** + - **ERC721**:直接读取 `_owners[tokenId]`,查询效率较高。 + - **ERC721A**:需通过递减遍历找到最近的 `owner` 信息,查询复杂度较高,但仍在可接受范围内。 + +5. **适用场景** + - **ERC721**:更适合小批量、高频转移的 NFT 项目,如独特艺术品或小规模发行的收藏品。 + - **ERC721A**:更适合大规模、批量发行的 NFT 项目,如 PFP 系列(头像项目)或游戏资产,因其能显著节省 Gas 成本。 + + + +### **适用建议** + +| 项目类型 | 推荐标准 | +|---------------------------------------|-----------------------------------| +| 独特艺术品或小批量 NFT 项目 | **ERC721**,因其简单直观且转移性能更优 | +| PFP 项目(头像项目)或大规模 NFT 项目 | **ERC721A**,因其批量铸造性能更优 | +| 需要高频转移和复杂逻辑的项目 | **ERC721**,便于实现和维护 | +| 关注 Gas 成本和存储优化的项目 | **ERC721A**,能显著降低铸造成本 | + + + +### **总结** + +ERC721A 的设计显著优化了批量铸造场景的性能和存储成本,特别适用于需要大量生成 NFT 的项目。然而,其实现复杂性和高频操作场景的性能限制需要在实际开发中仔细权衡。开发者可根据项目特点选择适合的标准,并结合两者的优点构建更高效的解决方案。 \ No newline at end of file diff --git a/nft/ERC721A/ERC721A.md b/nft/ERC721A/ERC721A.md index 6765fac74..802b38e54 100644 --- a/nft/ERC721A/ERC721A.md +++ b/nft/ERC721A/ERC721A.md @@ -1,13 +1,15 @@ -**ERC721A 算法分析与设计** +# **ERC721A 算法分析与设计** -## 参考链接: +## **参考链接** -1. [OpenZeppelin的EIP721实现](https://learnblockchain.cn/article/3041) -2. [Azuki的EIP721A实现](https://www.azuki.com/erc721a) +1. [OpenZeppelin 的 EIP721 实现](https://learnblockchain.cn/article/3041) +2. [Azuki 的 EIP721A 实现](https://www.azuki.com/erc721a) -## OpenZeppelin实现的缺点 -在一个典型的NFT中,通常会利用OZ的EIP721模板来做如下实现: + +## **OpenZeppelin 实现的缺点** + +在典型的 NFT 项目中,开发者通常利用 OpenZeppelin (OZ) 提供的 EIP721 模板进行实现。以下是一个常见的铸造逻辑示例: ```javascript function mintNFT(uint256 numberOfNfts) public payable { @@ -20,182 +22,165 @@ function mintNFT(uint256 numberOfNfts) public payable { require(numberOfNfts.mul(getNFTPrice()) == msg.value); //执行for循环,每个循环里都触发mint一次,写入一个全局变量 for (uint i = 0; i < numberOfNfts; i++) { - uint index = totalSupply(); - _safeMint(msg.sender, index); + uint index = totalSupply(); + _safeMint(msg.sender, index); } } ``` -其中,_safeMint是OZ中提供的mint API函数,其具体调用如下: +### **缺点分析** +1. **单次 mint 的低效率** + OZ 的实现中,每次调用 `_safeMint` 都会触发多次存储操作 (`SSTORE`)。在一个典型的 `mint` 操作中,以下两个全局变量会被频繁更新: + - `_balances`: 记录地址拥有的 NFT 数量。 + - `_owners`: 记录每个 `tokenId` 对应的拥有者地址。 -```javascript -function _safeMint( - address to, - uint256 tokenId, - bytes memory _data - ) internal virtual { - _mint(to, tokenId); - require( - _checkOnERC721Received(address(0), to, tokenId, _data), - "ERC721: transfer to non ERC721Receiver implementer" - ); - } -function _mint(address to, uint256 tokenId) internal virtual { - require(to != address(0), "ERC721: mint to the zero address"); - require(!_exists(tokenId), "ERC721: token already minted"); +2. **性能瓶颈** + 当批量铸造 N 个 NFT 时,算法复杂度为 \(O(N)\),因为需要循环调用 N 次 `mint` 方法。单次 `mint` 至少涉及两次 `SSTORE` 操作,批量铸造 N 个 NFT 则需执行至少 \(2N\) 次 `SSTORE` 操作,导致 Gas 成本显著增加。 - _beforeTokenTransfer(address(0), to, tokenId); - _balances[to] += 1; - _owners[tokenId] = to; - emit Transfer(address(0), to, tokenId); +## **ERC721A 的改进** + +ERC721A 的核心目标是优化批量铸造的效率,通过提供一个批量铸造的 API,使算法复杂度从 \(O(N)\) 降为 \(O(1)\)。以下是其基本实现思路: + +### **批量 Mint 的简单实现** + +ERC721A 的批量铸造通过改写 `_mint` 函数实现,允许开发者一次性指定铸造的 NFT 数量并批量更新全局变量。 + +```javascript +function _mint(address to, uint256 quantity) internal virtual { + uint256 tokenId = _currIndex; + _balances[to] += quantity; + _owners[tokenId] = to; - _afterTokenTransfer(address(0), to, tokenId); + for (uint256 i = 0; i < quantity; i++) { + emit Transfer(address(0), to, tokenId); + tokenId++; } -``` -从上述的实现过程中可以看到,对于普通的NFT mint过程,其算法复杂度是O(N),即用户需要mint N个NFT,则需要循环调用N次单独的mint方法。 + _currIndex = tokenId; +} +``` -其最核心的部分在于:OZ的实现中,在mint方法内部,维护了两个全局的mapping。 -分别是用于记录用户拥有的NFT数量的balance和记录tokenID到用户映射的owners。不管是mint还是transfer,其内部都需要去更新这两个全局变量。单就mint来讲,mint 1个NFT就需要进行至少2次SSTORE。而mint N个NFT则需要进行至少2N次SSTORE。 -## ERC721A的改进 +### **改进分析** -从OpenZeppelin的实现缺点来看,其主要缺点在于没有提供批量Mint的API,使得用户批量Mint时,其算法复杂度达到O(N).故ERC721A提出了一种批量Mint的API,使得其算法复杂度降为O(1). +#### 1. **性能优化:从 \(O(N)\) 到 \(O(1)\)** +- **算法复杂度**: + 批量铸造中虽然包含 `for` 循环,但该循环仅用于发出 `Transfer` 事件,不涉及昂贵的存储操作(如 `SSTORE`)。Gas 成本大幅降低。 +- **存储优化**: + 批量铸造时,仅记录第一个 `tokenId` 的 `owner`,后续 `tokenId` 的 `owner` 被默认为同一地址,直到下一次铸造或转移操作。 -### 最简单的想法: +#### 2. **存储稀疏性** +- 在批量铸造过程中,仅存储第一个 `tokenId` 的 `owner` 信息,节省了对后续 `tokenId` 的存储开销。例如,当用户 `Alice` 批量铸造 5 个 NFT 时,仅记录 `_owners[2] = Alice`,其余 `tokenId` 的 `owner` 默认为 `address(0)`,直到发生转移。 -最简单的想法莫过于直接修改_mint函数,将批量mint的数量也作为参数传入,然后在_mint函数里面修改balance和owners两个全局变量。由于是批量mint,与OZ的单独mint方式不同的是,其需要在mint函数内部维护一个全局递增的tokenID。另一个需要注意的事情是:根据EIP721规范,当任何NFT的ownership发生变化时,都需要发出一个Transfer事件。故这里也需要通过For循环的方式来批量发出Transfer事件。 +#### 3. **实现 `ownerOf` 的改进逻辑** + 因为 `_owners` 数组稀疏存储,只记录部分 `tokenId` 的 `owner`,需要通过递减查询逻辑定位实际拥有者。 ```javascript -function _mint(address to, uint256 quantity) internal virtual { -...//checks - uint256 tokenId = _currIndex; - _balances[to] += quantity; - _owners[tokenId] = to; -···//emit Event - for (uint256 i = 0; i < quantity; i++) { - emit Transfer(address(0),to,tokenId); - tokenId++; +function ownershipOf(uint256 tokenId) internal view returns (TokenOwnership memory) { + require(_exists(tokenId), "OwnerQueryForNonexistentToken"); + + for (uint256 curr = tokenId; curr >= 0; curr--) { + address owner = _owners[curr]; + if (owner != address(0)) { + return TokenOwnership(owner, _startTimestamps[curr]); } - //update index - _currIndex = tokenId; + } + + revert("Ownership Error"); } ``` -对该简单想法的分析: -1. 是O(1)还是O(N)? 新的mint肯定是O(1)的算法复杂度。容易引起误解的是里面仍然包含了一个for循环,但是该for循环里面只做了emit事件的操作。从OPCODE的角度来看,在for循环里面,其实只有LOG4 和 ADD两个OPCODE,没有特别消耗Gas的SSTORE。(tokenId++只是一个局部变量的++,而非全局变量的++,对应的OPCODE也只是ADD而没有SSTORE) -2. 在上述的mint实现中,事实上只在用户mint的开头更新了一下对应的tokenId的归属,对于后续tokenId事实上没有去更新相应的tokenId归属,即仍然为address(0). 如下图所示:alice在mint5个之后,系统事实上只在tokenId=2的地方记录了其_owners[2]=alice, 其余的tokenId如3,4,5,6,为节约SSTORE的次数,其_owners仍然为address(0)![](assets/20220211_115434_image.png)当下一个用户Bob来批量mint时,其会从7直接开始mint。 -![20220211151047.png](https://img.learnblockchain.cn/attachments/2022/02/Pjcvo7vN62060bfa586c9.png) +## **关键问题与解决方案** -该最简单想法的问题: +### **1. 非连续 TokenID 的适用性** +- **问题**: + ERC721A 假定 `tokenId` 为连续单调递增的整数序列。如果 `tokenId` 是不连续的(如时间戳生成),算法会失效。 +- **解决方案**: + 对于非连续 `tokenId` 场景,可以补充每个 `tokenId` 的独立存储逻辑,但这将牺牲批量铸造的 Gas 优势。 -1. 因为并不是每一个tokenId都记录了对应的owner,那么对于owners[tokenId]=address(0)的那部分tokenId其owner应该为谁? - 如果是OZ的实现,每一个tokenId都在owners[tokenId]存在对应的owner地址,如果其为address(0),说明该tokenId还没有被mint出来,即_exists[tokenId]=false。 - 但是对于ERC721A算法,一个tokenId的owners为address(0)存在两种可能的情况:1. 该tokenId确实还没有mint出来,不存在;2. 该tokenId属于某一个owner,但不是该owner批量mint的第一个。 - 即,应该如何实现ownerOf方法: - 观察mint得到的tokenId,可以发现其均为连续,单调递增的整数序列,即:0,1,2,3... 单纯只考虑mint的情况,不考虑transfer的情况,可以得到一个简单的算法,即:将该tokenId依次递减,遇到的首个不为address(0)的地址即为该tokenId的Owner。该算法如何排除还没有mint出来的那部分tokenId呢?可以通过比较tokenId与当前的currIndex,如果tokenId=currIndex,则说明该tokenId还没有被mint出来。 -![20220211151107.png](https://img.learnblockchain.cn/attachments/2022/02/2noHMnvS62060c0f4f7e4.png) -```javascript -function _exists(uint256 tokenId) internal view returns (bool) { - tokenId < _currentIndex; -} +### **2. 转移操作的处理** +- **问题**: + 当 `transfer` 操作打破 `tokenId` 的连续性时,需要更新相关的 `_owners` 信息。例如,当 `Alice` 转移 `tokenId = 3` 给 `Bob` 时,需要明确更新 `tokenId = 4` 的 `_owner` 信息。 +- **解决方案**: + 在 `transfer` 方法中,通过判断后续 `tokenId` 的 `_owner` 是否为 `address(0)`,动态补充存储信息。 -function ownershipOf(uint256 tokenId) internal view returns (TokenOwnership memory) { -//check exists - require(_exists(tokenId),"OwnerQueryForNonexistentToken"); -//遍历 递减 - for (uint256 curr = tokenId;curr >= 0;curr--) { - address owner = _owners[curr]; - if (owner != address(0)) { - return owner; - } - } - revert("Ownership Error"); -} -function ownerOf(uint256 _tokenId) external view returns (address) { - return ownershipOf(_tokenId).addr; -} -``` +```javascript +function _transfer(address from, address to, uint256 tokenId) private { + TokenOwnership memory prevOwnership = ownershipOf(tokenId); + require(from == prevOwnership.addr); -2. 如果用户alice在mint后在transfer给bob,此时alice的tokenId区域就不连续了,此时应该如何来更新?即如何设计transfer方法 - - 对于alice,其拥有2,3,4,5,6这5个NFT,当其把3转给bob时,系统的更新应该如下:首先把tokenId=3的NFT的owner更新bob,然后在tokenId=4的NFT的owner应该由原来的address(0)更新为alice。这里的transfer不是批量操作,而是单个NFT的操作。对于多个NFT的批量transfer,这种算法仍然需要O(N). - ![20220211151137.png](https://img.learnblockchain.cn/attachments/2022/02/fDg1Xr2R62060c2d43d19.png) + _balances[from] -= 1; + _balances[to] += 1; + _owners[tokenId] = to; -具体实现逻辑如下: + uint256 nextTokenId = tokenId + 1; + if (_owners[nextTokenId] == address(0) && _exists(nextTokenId)) { + _owners[nextTokenId] = from; + } -```javascript -function _transfer(address from,address to,uint256 tokenId) private { - //check ownership - TokenOwnership memory prevOwnership = ownershipOf(tokenId); - require(from == prevOwnership.addr); - //update from&to - balance[from] -= 1; - balance[to] += 1; - _owners[tokenId] = to; - uint256 nextTokenId = tokenId + 1; - if (_owners[nextTokenId] == address(0) && _exists(nextTokenId)) { - _owners[nextTokenId] = from; - } - emit Transfer(from,to,tokenId); + emit Transfer(from, to, tokenId); } ``` -3. 第三个问题是如何实现tokenOfOwnerByIndex这一个枚举方法? - 在OZ中,其实现是基于一个全局的mapping:mapping(address=>mapping(uint256=>uint256)) private _ownedTokens; - 然而在ERC721A中,并没有给每一个TokenId都储存一个对应的owner,自然也无法通过这种方式来获取到某个owner的tokenid列表。 - 鉴于ERC721A解决的问题比较特殊,即所有的tokenId都是连续的整数。一个最简单的思路可以是:遍历整个tokenId序列,找到属于该owner的所有tokenId,并按照时间戳的顺序来对所有的tokenId进行排序,对于同一个时间戳的,应该按照tokenId从小到大的顺序排序。 - 根据EIP721标准,其顺序并不作相应的要求。故可以不使用上面的排序方式。只要保证有序就行。 - - 具体的遍历过程应该如下:从tokenId=0开始遍历,拿到当前tokenId的owner,记录为curr。如果下一个tokenId的owner是address(0),则curr保持不变;如果下一个tokenId的owner不是address(0),则curr相应的更新。如果curr==alice,则判断tokensIdsIndex==index,如果不等,则tokensIdsIndex++.如果相等,则直接返回tokenId。 -![20220211151200.png](https://img.learnblockchain.cn/attachments/2022/02/HeHrTLtO62060c4422b97.png) + +### **3. 枚举方法的实现** +- **问题**: + ERC721 标准要求实现 `tokenOfOwnerByIndex` 方法,而 ERC721A 的稀疏存储设计无法直接提供一个高效的索引查找方式。 +- **解决方案**: + 遍历整个 `tokenId` 序列,并根据 `owner` 归属动态生成结果。虽然效率较低,但在批量铸造场景中依然满足标准要求。 ```javascript function tokenOfOwnerByIndex(address owner, uint256 index) public view override returns (uint256) { - //check index <= balance - require(index <= balanceOf(owner),"OwnerIndexOutOfBounds"); + require(index < balanceOf(owner), "OwnerIndexOutOfBounds"); + uint256 max = totalSupply(); uint256 tokenIdsIndex; - uint256 curr; + address currOwner; + for (uint256 i = 0; i < max; i++) { - address alice = _ownes[i]; - if (owner != address(0)) { - curr = alice; - } - if (curr == owner) { - if (index == tokenIdsIndex) return i; - tokenIdsIndex++; - } + if (_owners[i] != address(0)) { + currOwner = _owners[i]; + } + + if (currOwner == owner) { + if (tokenIdsIndex == index) return i; + tokenIdsIndex++; + } } - revert("error"); + revert("Error"); } ``` -## ERC721A 算法的局限性 -从上面的分析可以看出,ERC721A算法相较于OpenZeppelin的EIP721实现有比较大的突破,但是也有自身的局限性。还有部分我暂未理解清楚: -局限性: +## **ERC721A 的局限性** + +1. **TokenID 必须连续** + 批量铸造依赖于连续递增的 `tokenId`。非连续场景(如基于时间戳生成的 `tokenId`)需要额外适配。 + +2. **高频转移性能受限** + 稀疏存储的设计在频繁转移操作中增加了存储更新的复杂性。 + + + +## **`startTimestamp` 的用途** + +`startTimestamp` 是 `TokenOwnership` 结构的一部分,用于记录 `tokenId` 的持有起始时间。主要用途包括: +1. **持有时长计算**:奖励长期持有者或限制交易解锁时间。 +2. **市场排序支持**:支持基于持有时间的 NFT 排序功能。 +3. **历史溯源**:提供持有时间信息,便于审计和查询。 -ERC721A针对的NFT批量铸造过程,需要tokenId从0开始连续单调递增,如果tokenId是不连续的正整数,比如用timestamp来作为tokenId,该算法其实就会失效。 -没看懂的部分: -1. 为什么需要一个timestamp? - ``` - struct TokenOwnership { - address addr; - uint64 startTimestamp; - } - ``` +### **总结** -这个startTimestamp有什么用? +ERC721A 在批量铸造场景下极大优化了 Gas 成本,但需要针对高频转移和非连续 `tokenId` 的场景进一步适配。其设计适合一次性生成大量 NFT 的场景,如 PFP 项目或游戏资产发行。 \ No newline at end of file