硬核深度解析:BAYC“无聊猿” 源码中的随机偏移逻辑与公平性博弈
图片使用gemini制作
-
初始承诺阶段:建立“信任锚点”
首先,项目方把所有猴子图片上传ipfs,然后人为排定一个初始顺序,并且把图片的hash摘要进行拼接、再算出总的
BAYC_PROVENANCE、上链进行承诺,这个总哈希是对 10,000 个图片哈希值拼接后的长字符串再次进行计算得出的,确保了序列中即便只有一个像素的改动,总哈希也会完全不同。 这相当于项目方公开了一个“快照证明”。一旦合约里记录了这个哈希,项目方就无法偷偷调换某张图片的顺序或属性。如果发售后大家发现图片对不上,直接算一遍哈希就能拆穿。 -
铸造阶段:盲盒状态
发售,用户支付eth进行
mint,按 0, 1, 2… 的顺序生成tokenId。这个时候有了两个顺序,链上的tokenID,以及链下的图片初始顺序。 此时图片已经在 IPFS 上了(初始顺序已知),但谁也不知道自己的 TokenID 会对应哪一张。因为随机种子startingIndex还没生成,大家手里拿到的都是“盲盒”。 -
揭示阶段:随机偏移映射
所有都mint完之后或到了reveal揭示时间,链上根据此时的blockhash生成
startingIndex。也即随机种子。链下按照图片在初始顺序中的位置 = (tokenId + startingIndex) % 10000这样的公式去初始顺序寻找图片进行映射分配。具体来说,比如链上startingIndex = 8853 ,那么tokenId = 0的NFT应该对应初始顺序的第8853个图片,
在
startingIndex确定后,(1)项目方会按照计算好的映射关系,生成 10,000 个 JSON 元数据文件。
(2)将这些 JSON 文件上传到 IPFS。
(3)将合约的
baseURI指向这个新的文件夹。这样,当用户在 OpenSea上刷新元数据时,平台请求
tokenURI(0),合约返回新的路径,就能看到那只对应的猴子了。0号token对应的元数据如下所示:image字段放第8853个图片对应的ipfs链接、attributes放对应图片的描述信息。
{"image": "ipfs://QmRRPWG96cmgTn2qSzjwr2qvfNEuhunv6FNeMFGa9bx6mQ","attributes": [{"trait_type": "Earring","value": "Silver Hoop"},{"trait_type": "Background","value": "Orange"},{"trait_type": "Fur","value": "Robot"},{"trait_type": "Clothes","value": "Striped Tee"},{"trait_type": "Mouth","value": "Discomfort"},{"trait_type": "Eyes","value": "X Eyes"}]}
BAYC主合约BoredApeYachtClub分析
BoredApeYachtClub合约继承了OpenZeppelin开发的ERC721的标准实现。合约地址 0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d
contract BoredApeYachtClub is ERC721, Ownable {using SafeMath for uint256;string public BAYC_PROVENANCE = ""; //hash承诺,把所有图片人为排定一个初始顺序,并且把图片的hash摘要进行拼接、再算出总的hash摘要上链进行承诺uint256 public startingIndexBlock; //揭示阶段初始区块编号uint256 public startingIndex; //偏移量,即随机种子uint256 public constant apePrice = 80000000000000000; //0.08 ETH 发行价格uint public constant maxApePurchase = 20; //每次最多mint 20个uint256 public MAX_APES; //最大发行量 10000bool public saleIsActive = false; //宣发期 -> 正式发售uint256 public REVEAL_TIMESTAMP; //揭示时间。constructor(string memory name, string memory symbol, uint256 maxNftSupply, uint256 saleStart) ERC721(name, symbol) {MAX_APES = maxNftSupply;REVEAL_TIMESTAMP = saleStart + (86400 * 9); //开始发售9天之后为揭示时间}function withdraw() public onlyOwner { //项目方提取赚的发行收益uint balance = address(this).balance;msg.sender.transfer(balance);}/*** Set some Bored Apes aside*/function reserveApes() public onlyOwner { //项目方自己mint保留30个。uint supply = totalSupply();uint i;for (i = 0; i < 30; i++) {_safeMint(msg.sender, supply + i);}}/*** DM Gargamel in Discord that you're standing right behind him.*/function setRevealTimestamp(uint256 revealTimeStamp) public onlyOwner {REVEAL_TIMESTAMP = revealTimeStamp;}/** Set provenance once it's calculated 项目方设置承诺*/function setProvenanceHash(string memory provenanceHash) public onlyOwner {BAYC_PROVENANCE = provenanceHash;}function setBaseURI(string memory baseURI) public onlyOwner { //项目方设置bashUrl_setBaseURI(baseURI);}/** Pause sale if active, make active if paused*/function flipSaleState() public onlyOwner { //切换状态saleIsActive = !saleIsActive;}/*** Mints Bored Apes 批量铸造NFT*/function mintApe(uint numberOfTokens) public payable {require(saleIsActive, "Sale must be active to mint Ape"); //是否已到开放mint时间require(numberOfTokens <= maxApePurchase, "Can only mint 20 tokens at a time"); //每次最多mint 20个require(totalSupply().add(numberOfTokens) <= MAX_APES, "Purchase would exceed max supply of Apes"); //是否超过总供给数量require(apePrice.mul(numberOfTokens) <= msg.value, "Ether value sent is not correct"); //发送来的eth要超过初始发行价格for(uint i = 0; i < numberOfTokens; i++) { //批量mintuint mintIndex = totalSupply();if (totalSupply() < MAX_APES) {_safeMint(msg.sender, mintIndex);}}// If we haven't set the starting index and this is either 1) the last saleable token or 2) the first token to be sold after// the end of pre-sale, set the starting index blockif (startingIndexBlock == 0 && (totalSupply() == MAX_APES || block.timestamp >= REVEAL_TIMESTAMP)) {startingIndexBlock = block.number; //全卖完了或者到揭示时间了,那么就把这时候的block.number设置为startingIndexBlock}}/*** Set the starting index for the collection 允许任何人调用可以防止项目方拖延reveal*/function setStartingIndex() public { //startingIndexBlock确定了,startingIndex也就确定了require(startingIndex == 0, "Starting index is already set");require(startingIndexBlock != 0, "Starting index block must be set"); //startingIndexBlock要先被设置,要么通过mintApe,要么通过emergencySetStartingIndexBlockstartingIndex = uint(blockhash(startingIndexBlock)) % MAX_APES; //计算startingIndex的算法// Just a sanity case in the worst case if this function is called late (EVM only stores last 256 block hashes)///startingIndexBlock设置之后,过了255个区块才有人调用setStartingIndex,但项目方应该避免这种兜底情况发生。if (block.number.sub(startingIndexBlock) > 255) {startingIndex = uint(blockhash(block.number - 1)) % MAX_APES;}// Prevent default sequenceif (startingIndex == 0) {startingIndex = startingIndex.add(1); //如果刚好区块编号整除MAX_APES,那强制改成1}}/*** Set the starting index block for the collection, essentially unblocking* setting starting index*/function emergencySetStartingIndexBlock() public onlyOwner { //如果mintApe的时候没能设置startingIndexBlock(9天没卖完、且到9天了也没人mint触发),项目方可以手动设置。require(startingIndex == 0, "Starting index is already set");startingIndexBlock = block.number;}}
对照前面的发行流程的拆解和代码中的注释,读者应该不难理解这个合约。
这里只提一下不期望发生的特殊情况:
特殊情况 A:未售罄即揭示(Unsold Reveal) 如果在 9 天时间到期时仍有大量 NFT 未售罄,通过 mintApe 或 emergencySetStartingIndexBlock 触发 setStartingIndex 后,由于偏移量已成定值,剩余的 NFT 序列将从“抽奖”变为“明牌”。任何人都可以通过计算,精确锁定剩余盲盒中哪些是稀有款并进行定向扫货。
特殊情况 B:随机性退化(Fallback Vulnerability) 若在 startingIndexBlock 确定后的 256 个区块内(约 1 小时)无人触发随机数生成,逻辑会退化为使用执行交易时的前一区块哈希:startingIndex = uint(blockhash(block.number - 1)) % MAX_APES。 此时,由于 block.number - 1 的哈希值在交易被打包前就已是链上公开信息,“科学家”可以监控内存池(Mempool)。一旦发现有人发起 setStartingIndex 交易,由于此时随机数已具备确定性,他们可以立即预计算结果。如果发现该结果能让下一批 TokenID 映射到稀有图片,便会通过提高 Gas 费或直接使用 Flashbots 捆绑交易,抢在普通用户之前精准掠夺稀有款。
整个游戏的性质就从“抽奖”变成了“明牌抢购”。
这也引出了合约中的一个彩蛋。
合约中的彩蛋
/*** DM Gargamel in Discord that you're standing right behind him.*/function setRevealTimestamp(uint256 revealTimeStamp) public onlyOwner {REVEAL_TIMESTAMP = revealTimeStamp;}
这行注释是 BAYC 早期非常著名的一个“冷幽默”,也是早期 NFT 社区文化的一个缩影。
通过之前的合约分析我们知道,揭示时间默认是 REVEAL_TIMESTAMP = saleStart + (86400 * 9) ,即开始发售9天之后为揭示时间,如果一开始没有上线就卖完,那么也会在9天后进行揭示,但是setRevealTimestamp是特权函数,项目方理论上可以拖迟揭示:而一旦揭示了、没卖完的就更难卖出去了,所以这里项目方幽默了一下,想表达的意思是自己不会去做这样的事情。
Gargamel 是 Yuga Labs 四位创始人之一(Greg Solano)的网名。在 BAYC 刚发售时,团队还是匿名状态,Gargamel 是社区里最活跃的发言人之一。
揭示(Reveal)对项目方来说是一把双刃剑:
-
没揭示前(盲盒状态): 每一张没卖出去的票都可能是“稀有金皮”。这种预期能支撑二级市场的流动性和一级市场的铸造热情。 -
揭示后(开箱后): 一旦揭示,大部分普通属性的猴子会瞬间贬值,如果此时还没卖完,剩下的“普通款”就很难再卖给新玩家了。
所以,项目方确实有动力去延迟揭示时间,直到所有 NFT 都被 Mint 完。
那句注释的意思是:
“如果你发现我们(项目方)利用
setRevealTimestamp偷偷推迟揭示时间,请直接去 Discord 给创始人(Gargamel)发私信,告诉他你就站在他身后盯着他呢。”
这是项目方的一种自嘲式的承诺:
-
承认特权: 代码里确实写了 onlyOwner(我有改时间的权力)。为IPFS 延迟、元数据准备问题、技术事故等留足应急运营空间。 -
交付监督权: 但我把这个权力摆在明面上,并鼓励社区来“盯着”我。 -
消解紧张感: 用一种很调皮的方式(站在你身后),来缓解社区对项目方“暗箱操作”或“拖延症”的恐惧。
到这里,读者朋友感受出一些web3世界中带有理想主义的“密码学自由主义 + 社区自治实验 + meme 表达”的气质了吗。这不仅是一段代码,更是 Web3 世界“Trust but Verify”(信任但验证)精神的具象化,开启了一场关于透明度与随机性的社会实验。
夜雨聆风
