Cover photo

从技术角度浅析Azuki项目

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

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

🖌作者:arc0xc9

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”

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

post image

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

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

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

post image

关于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请求,报错:

post image

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/”。

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

post image

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。

post image

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

💡优化2:mint支持批操作

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

post image

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

post image

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

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

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

回看的逻辑如下:

post image

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

注:本图来自https://mirror.xyz/xyyme.eth/BSamlJFCpc1NyCNlaLPTC9zGN87qAuktBPEeR3jDBhs
注:本图来自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。这是符合业界要求的做法

post image

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

post image
post image

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

post image

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

post image

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

post image
  • 管理类合约,和mint本身都不抛出事件,事件依赖ERC721A自己来抛

  • ERC721A方面,大量运用assembly,包括shl等位操作

  • ERC721A方面,采用了定义error + revert的方式,比较优雅的抛出错误

🖋Reference

🟡🔵🔴

Twitter: neticilabs

即刻:NeticiLabs

https://twitter.com/neticiofficial