# 从技术角度浅析Azuki项目

By [NETICI LABS](https://paragraph.com/@netici-labs) · 2022-07-29

---

🔵_这是NETICI LABS的第一篇文章分享。NL试图打造一个有温度的 Web3 【玩具】试验室。这里有硬核技术分析文章，也有市场观察分析，希望大家一起来玩。_

_🟡The next big thing will start out looking like a toy._

_🖌作者：arc0xc9_

[https://twitter.com/masonlog](https://twitter.com/masonlog)

### 一、项目背景

团队：Chiru Labs，位于洛杉矶，团队成员有守望先锋的前角色艺术总监、Facebook前工程师、谷歌的前工程师等。

### 二、典型用例

1\. 项目方链下准备好图片和白名单。图片上传到IPFS上。

2\. 项目方部署合约：在2022年1月10日，项目方部署了合约，并携带如下参数： a) batchsize：5。表示每个用户最多挖5个 b) collectionSize：10000 c) amountForDev: 200 d) amountForAuctionAndDev:8900

3\. 项目方设置baseUri：设置基础URL为“[https://azuki-prereveal.s3-us-west-1.amazonaws.com/metadata”](https://azuki-prereveal.s3-us-west-1.amazonaws.com/metadata%E2%80%9D)

注意这个时候，每个token对应哪张图片，还不是确定的，根据这里面访问具体的token，发现图片url都是一个bean.gif:

![](https://storage.googleapis.com/papyrus_images/13c27181c505fac33325c07b767bdbebf105853632ed314959fa1c5c12365caf.png)

4\. 项目方设置拍卖起始时间：通过setAuctionSaleStartTime函数，将售卖启动时间设置为2022-1-13: 02:00:00 北京时间

5\. 项目方获取保留token：通过devMint函数，为自己保留200个token。

6\. 用户开始竞拍：项目开售后，立即有大量用户调用auctionMint，挖掘token。而且是批量挖掘。甚至可以看到很多人在等着上线那一瞬间开抢，产生了许多失败的交易：

![](https://storage.googleapis.com/papyrus_images/6d2cd325c3ed041704d6861761f01440225b3b6cea9f8539f7380550d6b5ed1d.png)

关于auction模式的拍卖价格，采用下降式荷兰式拍卖，即价格随时间推移，线性衰减到一个终止价格。如果当前时间已经过了拍卖时间，则采用最终价格。价格下降的公式为： startPrice - Δt \* (startPrice - endPrice)/auctionInterval 具体参数如下：

a） 起始售价：1 ether（彼时约3300U） b） 终止售价：0.15 ether c） 拍卖时长：340分钟，即5小时零40分钟。 d） 价格下降机制：在通常的荷兰式拍卖中，每下降1s，那么价格也会随之下降。在Azuki中，对此稍有不同，它的价格是每20分钟下降一次。也就是说，340分钟的拍卖时间被均分为17份，每一份时间值20分钟。那么每经过这么一份时间，价格就会下降(1-0.15)/17 = 0.05 ether。

这一次拍卖非常成功，在340分钟内，所有拍卖的NFT都已经售卖完毕，售罄后仍有大量失败的auctionMint请求，报错：

![](https://storage.googleapis.com/papyrus_images/0aacea5629becae9503d05de5f96afc0a1897f6f51e1589a0316fd6c35709c90.png)

7\. 项目方设置白名单。于2022-1-14日 北京时间 凌晨两点前，多次调用seedAllowList，将数百个白名单账户设置到合约中。某一次调用产生了大概99307153个gas，对应gas price（190gwei），这笔交易的gas消耗约1.77 ether（当时5700刀）。相比之下普通的auctionMint个87393个gas，结合gas price（5669 gwei），仅产生0.5 ether（当时1500 u）

8\. 项目方结束拍卖，开启白名单和公售。于2022-1-14 凌晨两点调用endAuctionAndSetupNonAuctionSaleInfo。开启白名单售卖和公售。附带参数：

*   mintlistPriceWei：“500000000000000000”，即0.5 ether。这是白名单用户的mint价格
    
*   publicPriceWei：“1000000000000000000”，即1 ether。这是公售价格
    
*   publicSaleStartTime：“1642269600”，即2022年1月16日凌晨2点
    

9\. 白名单用户开始mint：调用allowlistMint。一次性只能挖一个，挖完后，链上会删除该账户信息。

10\. 用户开始public sale mint：调用public sale mint.

11.项目方关闭公售。在2022-1-19 凌晨1点，调用endAuctionAndSetupNonAuctionSaleInfo，附带参数：

*   mintlistPriceWei：“500000000000000000”，即0.5 ether。这是白名单用户的mint价格。
    
*   publicPriceWei：“0”，即1 ether。这是公售价格。
    
*   publicSaleStartTime：“1674145770”，即2023年1月20日.
    

12\. 项目方setBaseUri：setBaseUri，设置为“[https://ikzttp.mypinata.cloud/ipfs/QmeBWSnYPEnUimvpPfNHuvgcK9wFH9Sa6cZ4KDfgkfJJis/”。](https://ikzttp.mypinata.cloud/ipfs/QmeBWSnYPEnUimvpPfNHuvgcK9wFH9Sa6cZ4KDfgkfJJis/%E2%80%9D%E3%80%82)

用户可以通过这个地址访问自己的元数据了：

![](https://storage.googleapis.com/papyrus_images/a7c137eb02546d3c3857cfe87180c53289eab589a880cfd04d9594599e49e33a.png)

13\. 项目方提现：项目调用withdrawMoney来提现。

14\. 接入二级市场：项目方通过OpenSea的wyvern，接入交易市场。

15\. 用户查看自己的图片：调用getTokenUri，查到自己的元数据URL，访问后得到元数据JSON。然后顺着JSON里的链接，找到自己的图片。

### 三、要点回顾

1.ERC721A：号称使用了更剩gas的ERC721A标准，也就是一次gas费，铸造多个NFT。

2.售卖分四个阶段：项目方（免费）、拍卖（1 ether to 0.15 ether）、白名单售卖（0.5 ether）、公售（1 ether）。

3.tokenId和图片的映射由项目方自己说了算：不像无聊猿猴那样在链上计算了（由合约说了算）

4.数据存储：售卖前，元数据和图片（也就是那个红豆）放到中心化服务器上。售卖结束后，元数据和图片都存在IPFS上（借助pianata这个专用的NFT服务）

### 四、 ERC721A 协议内容

ERC721A和ERC721的差别不在于接口，而在于实现。

从IERC721A.sol的接口文件来看，包含的接口和通常的ERC721Enumerable无差别： balanceOf、ownerOf、safeTransferFrom、transferFrom、approve、setApproveForAll 、getApproved、isApprovedForAll、name、symbol、tokenURI，在早期版本还包括totalSupply, tokenByIndex, tokenOfOwnerByIndex(目前版本则干脆直接砍掉了) 实现的差别主要体现在ERC721A.sol，也就是实现层面。

**_💡优化1：砍掉了ERC721Enumerable的多余存储_**

首先从存储的内容来看，核心状态只包括如下：

*   用户的信息：userAddress -> (balance， numberMinted)
    
*   NFT的信息：tokenId -> (addr, startMintTime)。在新版本还陆续增加了一些信息，例如24位的extraData
    
*   NFT许可授权：tokenId->addr
    
*   用户授权信息：address->(address->allowed)
    

这里面，去除了ERC721Enumerable中的大数组，这些数组用于支持tokenByIndex，tokenOfOwnerByIndex等操作，维护它们，就意味着每次操作都要多写状态变量，从而消耗额外的gas。

![](https://storage.googleapis.com/papyrus_images/53734c8f4e828fab8509a37e33abd90d44f9bd34ae12115c7eb504b76dac4be1.png)

那么早期721A是怎么实现tokenByIndex和tokenOfOwnerByIndex的？对于前者，由于tokenId和index一直相等（因为不支持burn），因此返回index即可。对于tokenOfOwnerByIndex，则会遍历所有token，直到查找到特定用户的特定token。由于这个操作在查询端，不计入交易，所以性能虽然低，但也可以接受。注意，新版本中已经移除了这几个函数，同时增加了burn的支持。

**_💡优化2：mint支持批操作_**

从mint的方式来看：mint的id由合约通过计数器生成，而不是ERC721那样由外部指定。此外，mint支持传入quantity，这样数据更新可以一次性完成。而对于OZ，如果想一次性挖出多个token，则需要调用多次\_safeMint。下图是OZ的\_mint函数：

![](https://storage.googleapis.com/papyrus_images/487395c6f84ce87a57c738d12d4eab5d8737ad80434bfb9b5092eaf36efc4d0c.png)

而对于ERC721A，只需直接给相关数据结构更新1次即可：

![](https://storage.googleapis.com/papyrus_images/187be455a3da65600b26839f81a28a96904c77d1fa61dd2d717d4476258533c1.png)

**_💡优化3：最大限度减少更新数据的频率_**

假如Alice挖掘了5个token，那么只有第一个token会记录它的属主信息。如果想查找剩余4个token的属主，则向前找到第一个属主不为空的token，取它的属主。

![注：本图来自https://mirror.xyz/xyyme.eth/BSamlJFCpc1NyCNlaLPTC9zGN87qAuktBPEeR3jDBhs](https://storage.googleapis.com/papyrus_images/2986c174749613183cbec14a45893bc1b4c89f11d19e5fb9738da6a1d2d9d755.png)

注：本图来自https://mirror.xyz/xyyme.eth/BSamlJFCpc1NyCNlaLPTC9zGN87qAuktBPEeR3jDBhs

回看的逻辑如下：

![](https://storage.googleapis.com/papyrus_images/3a37ae5aa66ab4093975477ced613603a06843796400890c20b2a8c61b898b3b.png)

如果发生了token所有权转让，则该token下一个的属主进行更新：

![注：本图来自https://mirror.xyz/xyyme.eth/BSamlJFCpc1NyCNlaLPTC9zGN87qAuktBPEeR3jDBhs](https://storage.googleapis.com/papyrus_images/15724ccb24ad32c1c8f5f6fa66afcd5d2bd36bb5628bb1ad4cfb22e9480d3470.png)

注：本图来自https://mirror.xyz/xyyme.eth/BSamlJFCpc1NyCNlaLPTC9zGN87qAuktBPEeR3jDBhs

\*\*_🔵优点_：\*\*ERC721A的主要优势是降低了mint的成本。

*   存储优化：在ERC721Enumerable中，存储了allTokens，在ERC721A中去除了这些存储只用一个index记录目前总共的供应量。721A砍掉了这部分数据结构，节省了更新它们的gas。
    
*   批量操作：mint支持传入quantity，这样数据更新可以一次性完成。 节省更新：此外，每个用户挖一组token的时候，只有第一个token保存属主信息。
    

**_🔴缺点_** ：

*   token由ERC721A合约维护，且必须连续递增。在ERC721中，tokenId是用户自己维护的，可以任意指定方式
    
*   砍掉了enumerable的功能
    

**_📍编码细节_**

*   有很多callerIsUser修饰符，用于确保不是合约在调用，而是外部账户在调用自己的函数
    
*   转账操作采用call，不像BAYC采用transfer。这是符合业界要求的做法
    

![](https://storage.googleapis.com/papyrus_images/2cef5275f33694e196c11abdd69bf36fa1d0c40cf566f225d4f370e9f06d993b.png)

transfer的三大特点：如果接受者是合约，则必须定义一个payable 的fallback函数(or receive())；如果转账失败，会自动回滚； 为fallback提供了2300gas，仅够丢出事件，用于防止重入攻击。

![](https://storage.googleapis.com/papyrus_images/bd3eb5b1fe7021d9e3f044d4c1609811c39a0a94eddbecb9d34740b55d973db7.png)

![](https://storage.googleapis.com/papyrus_images/ce4075b9c3f64da197650131f95ce8728306b9458def323d712d5460abb0f1e8.png)

call的特点：可以自定义调用哪个函数，而不仅仅是fallback函数；如果转账失败，不会自动回滚，需通过success返回值回滚；可以自定义gas(默认还是2300)。

![](https://storage.googleapis.com/papyrus_images/358f231b23dc91cb7104d94855a66a61d8721815982c7f579517b374615b8aaa.png)

尽可能的压缩存储，比如时间戳就用32位存储：

![](https://storage.googleapis.com/papyrus_images/19c7cccc3210ca47facc31082f8e93fcf9dd5ca05536e6a3b9093558acbb1616.png)

注意，压缩存储不仅对struct有效，对状态的存储也有效，两个uint128总共占据1个slot。

![](https://storage.googleapis.com/papyrus_images/c5dbf42106da5681ece482513f02a155238044e779ab99db98c81d5c8343116f.png)

*   管理类合约，和mint本身都不抛出事件，事件依赖ERC721A自己来抛
    
*   ERC721A方面，大量运用assembly，包括shl等位操作
    
*   ERC721A方面，采用了定义error + revert的方式，比较优雅的抛出错误
    

### 🖋Reference

*   azuki合约：[https://etherscan.io/address/0xed5af388653567af2f388e6224dc7c4b3241c544#code](https://etherscan.io/address/0xed5af388653567af2f388e6224dc7c4b3241c544#code)
    
*   portal：[https://www.azuki.com/zh/gallery](https://www.azuki.com/zh/gallery)
    
*   erc721A解读：[https://mirror.xyz/xyyme.eth/BSamlJFCpc1NyCNlaLPTC9zGN87qAuktBPEeR3jDBhs](https://mirror.xyz/xyyme.eth/BSamlJFCpc1NyCNlaLPTC9zGN87qAuktBPEeR3jDBhs) transaction fee vs gas fee： [https://cryptobriefing.com/understanding-ethereums-gas-transaction-fees/](https://cryptobriefing.com/understanding-ethereums-gas-transaction-fees/)
    
*   transfer vs call： [https://blockchain-academy.hs-mittweida.de/courses/solidity-coding-beginners-to-intermediate/lessons/solidity-2-sending-ether-receiving-ether-emitting-events/topic/sending-ether-send-vs-transfer-vs-call](https://blockchain-academy.hs-mittweida.de/courses/solidity-coding-beginners-to-intermediate/lessons/solidity-2-sending-ether-receiving-ether-emitting-events/topic/sending-ether-send-vs-transfer-vs-call)
    
*   ERC721A： [https://github.com/chiru-labs/ERC721A/blob/main/contracts/IERC721A.sol](https://github.com/chiru-labs/ERC721A/blob/main/contracts/IERC721A.sol) [https://www.frank.hk/blog/azuki-erc721a/?hmsr=joyk.com&utm\_source=joyk.com&utm\_medium=referral](https://www.frank.hk/blog/azuki-erc721a/?hmsr=joyk.com&utm_source=joyk.com&utm_medium=referral)
    
*   shr : [https://medium.com/epik-systems/understanding-solidity-assembly-using-shr-and-shl-for-byte-manipulation-a9f3503cc8d9](https://medium.com/epik-systems/understanding-solidity-assembly-using-shr-and-shl-for-byte-manipulation-a9f3503cc8d9)
    

🟡🔵🔴

Twitter: neticilabs

即刻：NeticiLabs

[https://twitter.com/neticiofficial](https://twitter.com/neticiofficial)

---

*Originally published on [NETICI LABS](https://paragraph.com/@netici-labs/azuki)*
