乐于分享
好东西不私藏

硬核深度解析:BAYC“无聊猿” 源码中的随机偏移逻辑与公平性博弈

硬核深度解析:BAYC“无聊猿” 源码中的随机偏移逻辑与公平性博弈

图片使用gemini制作

BAYC项目NFT发行流程
  1. 初始承诺阶段:建立“信任锚点”

    首先,项目方把所有猴子图片上传ipfs,然后人为排定一个初始顺序,并且把图片的hash摘要进行拼接、再算出总的BAYC_PROVENANCE、上链进行承诺,这个总哈希是对 10,000 个图片哈希值拼接后的长字符串再次进行计算得出的,确保了序列中即便只有一个像素的改动,总哈希也会完全不同。  这相当于项目方公开了一个“快照证明”。一旦合约里记录了这个哈希,项目方就无法偷偷调换某张图片的顺序或属性。如果发售后大家发现图片对不上,直接算一遍哈希就能拆穿。

  2. 铸造阶段:盲盒状态

    发售,用户支付eth进行mint,按 0, 1, 2… 的顺序生成tokenId。这个时候有了两个顺序,链上的tokenID,以及链下的图片初始顺序。 此时图片已经在 IPFS 上了(初始顺序已知),但谁也不知道自己的 TokenID 会对应哪一张。因为随机种子 startingIndex 还没生成,大家手里拿到的都是“盲盒”。

  3. 揭示阶段:随机偏移映射

    所有都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;  //最大发行量 10000    bool 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 revealTimeStamppublic onlyOwner {        REVEAL_TIMESTAMP = revealTimeStamp;    }     /*         * Set provenance once it's calculated 项目方设置承诺    */    function setProvenanceHash(string memory provenanceHashpublic onlyOwner {        BAYC_PROVENANCE = provenanceHash;    }    function setBaseURI(string memory baseURIpublic 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 numberOfTokenspublic 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++) { //批量mint            uint 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 block         if (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,要么通过emergencySetStartingIndexBlock        startingIndex = 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 sequence        if (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 revealTimeStamppublic 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”(信任但验证)精神的具象化,开启了一场关于透明度与随机性的社会实验。

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » 硬核深度解析:BAYC“无聊猿” 源码中的随机偏移逻辑与公平性博弈

评论 抢沙发

3 + 7 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
×
订阅图标按钮