设计一个用于灵活造币和领取的NFT智能合约

724 阅读10分钟

设计一个用于灵活造币和领取的NFT智能合约

我们也耗尽了气体。了解原因以及我们如何优化它

有关的智能合约是我们为Humans Of NFT开发的合约。这个项目的核心是一个艺术项目--旨在探索合作以及NFT系列如何由社区创造,而不仅仅是为他们创造。

我们的智能合约需要考虑各种不同的造币需求,以涵盖我们项目的各种元素,我们将在下文中详细探讨。

1500个人类中的每一个都有一个由社区成员贡献的手写简历。

这篇文章是 的第一篇文章,我们将在其中探讨合同的实现--它通常是为那些对我们的技术方法感兴趣的人准备的,但将以这样的方式来写,希望即使是技术水平不高的人也能从其中学到一些东西。

在我们深入探讨技术问题之前,让我们提供一些背景,说明为什么合同需要这么多的功能。

  • 我们的创世集(229个人类)是用Opensea的ERC1155共享合约铸造的。我们想通过燃烧原始代币并在合约中捕获它们,将旧的收藏品合并到新的收藏品中,使用我们自己的ERC721合约。
  • 我们的作者计划中的作者通过为我们的人类提交简介("传记 "的缩写,即背景故事)获得免费的薄荷糖。我们需要给他们提供一种方法,让他们在不支付铸币费的情况下认领他们的人类。每位作者根据他们提交的简介数量获得不同数量的硬币。
  • 我们需要为使用钱包地址的个人保留35个具有固定代币ID的荣誉人类(定制的独一无二的)。
  • 我们有一个预售名单,并希望控制谁可以进入,以及限制可以铸造的代币的数量。
  • 我们的公开销售是对所有人开放的,但我们想限制每笔交易和每个地址的硬币数量。

最后,但肯定不是最不重要的,我们想利用一个随机的铸币策略。许多项目在随机化元数据时利用出处哈希值作为种子。我们只是在铸币厂上线前透露了元数据。这有三个主要目的。

  1. 我们想在铸币活动之前揭示我们的全部收藏。我们已经被过度炒作的铸币活动烧毁了太多次,这些铸币在曝光后产生了非常令人失望的艺术品,所以我们想向我们的社区准确地展示他们得到的东西。
  2. 我们想让这个过程尽可能的公平和透明,取消团队铸造精选或稀有代币的选项。我们和其他人有同样的几率。
  3. 我们想规避披露的需要,以消除狙击的可能性,并确保如果我们没有铸币出来,藏品不会在未被披露的情况下被搁置。

在铸币活动前浏览藏品

一个重要的免责声明

在继续之前,我想对这篇文章说,我不是Solidity专家。我已经做了很多年的开发,在各种非常不同的项目上工作,但这是我部署到以太坊主网的第一个智能合约,所以这是NDA(非开发者建议)。

我很幸运地得到了一些非常聪明的人的帮助,他们一路指导我。我只是想借此机会分享我的思考过程和我们做出某些决定的原因,希望它可以帮助哪怕是一个开始自己的NFT项目的人,因为如果不向那些慷慨分享自己经验的人学习,我就不可能做到这一点。外面有很多很好的资源,我将在这篇文章的底部链接到我使用的一些资源。

我们经过验证的合约可以在Etherscan上找到,如果在任何时候你想在阅读本指南时参考一下代码的话。

https://etherscan.io/address/0x8575B2Dbbd7608A1629aDAA952abA74Bcc53d22A#code

在造币时随机分配代币ID

值得一提的是,这个策略采用了一种伪随机数的生成方法。要 "正确 "地做到这一点,需要使用类似Chainlink的VRF(可验证的随机函数)(1)

我们做了一个有根据的假设,即我们相对不为人知的、小规模的收藏品(1500枚)和它的低价格(0.025 Eth)几乎没有激励某人想出一个复杂的方法来尝试和利用它。

此外,利用像VRF这样的方法会使我们的造币过程过于昂贵。在做了大量的研究和阅读了无数的论坛帖子后,我看到了1001-digital(2)的一些ERC721扩展,其中包括随机代币分配。

RandomlyAssigned 扩展对我们来说并不适用,因为我们需要在已知的ID和随机的ID之间 "分割 "收集(我们将很快解释)。

同时,你可以从构造函数中看到,"人类 "契约继承了该扩展,以及其他一些契约。

constructor(  string memory uri,  address adminSigner,  address openseaAddress )  ERC721('Humans Of NFT', 'HUMAN')  RandomlyAssigned(   MAX_HUMANS_SUPPLY,   NUMBER_OF_GENESIS_HUMANS + NUMBER_OF_RESERVED_HUMANS  ) {  _defaultUri = uri;  _adminSigner = adminSigner;  _openseaSharedContractAddress = openseaAddress; }

RandomlyAssigned 构造函数现在需要两个参数。

  • 集合的总大小 (MAX_HUMANS_SUPPLY )
  • 开始随机标记ID分配的索引(NUMBER_OF_GENESIS_HUMANS + NUMBER_OF_RESERVED_HUMANS )

在我们的案例中,我们有229个来自Genesis集合的Humans,所以我们想为Burn-to-Claim机制保留token ID1–229 。换句话说,Genesis ID #1的所有者应该收到新集合中的ID #1(即替换令牌)。

然后,我们为特定的地址保留了代币ID230-264 ,这样,收到荣誉代币的个人就可以使用预先确定的ID来认领他们的Humans。

这就在265-1500 之间留下了一个令牌ID池,它应该是随机分布的,我们可以从RandomlyAssigned 构造函数中看到这一点。

// RandomlyAssigned.sol

maxSupply_ 参数并没有在RandomlyAssigned 契约中使用,而是被传递给它所继承的WithLimitedSupply 契约。

实际的随机化利用了一种流行的生成伪随机数的方法(显然我不能归功于它),它从一个使用区块特定数据以及函数调用者地址生成的哈希值(msg.sender )中投出一个uint256

然后,它将结果存储在一个tokenMatrix 地图中,该地图存储哪些ID已经被使用过。

function nextToken() internal override returns (uint256) {  uint256 maxIndex = maxAvailableSupply() - tokenCount();  uint256 random = uint256(   keccak256(    abi.encodePacked(     msg.sender,     block.coinbase,     block.difficulty,     block.gaslimit,     block.timestamp    )   )  ) % maxIndex;

maxIndex 是可以从可用池中分配的最高ID。maxAvailableSupply() 返回池中ID的数量(即1500 — 229 — 35 = 1236 ),tokenCount() 返回已经从池中铸造的代币数量。

因此,如果我们已经用随机ID铸造了150个代币,那么maxIndex = 1236 — 150 ,得出1086 ,因此我们的maxIndex 是1086。我们将使用keccak256 算法生成的哈希值转换为uint256 ,然后在除以maxIndex (总是产生一个小于maxIndex 的整数)时,取模运算(%) 产生的余数。

如果你还记得在RandomlyAssigned 构造函数中,我们将startFrom 变量设置为等于我们保留的代币数量(即Genesis Tokens + Honoraries)+1。

因此,当我们最终返回新的随机代币ID时,它将落在229 < random_id <= 1500

回到合约本身,可用的代币池是在WithLimitedSupply 构造函数中设置的。

constructor(uint256 maxSupply_, uint256 reserved_) {  _maxAvailableSupply = maxSupply_ - reserved_; }

然后,铸币函数的每个变体都利用一个修改器来检查所要求的代币数量是否落在随机ID池的可用范围内,以防止有人在所需范围之外铸币。

 /// @param amount Check whether number of tokens are still available /// @dev Check whether tokens are still available modifier ensureAvailabilityFor(uint256 amount) {  require(   availableTokenCount() >= amount,   'Requested number of tokens not available'  );  _; }

在主合同中,我们有一个方便的函数,叫做_mintRandomId() ,负责生成一个随机的ID,并将选定的代币铸造到提供的地址。

/// @dev internal check to ensure a genesis token ID, or ID outside of the collection, doesn't get mintedfunction _mintRandomId(address to) private {  uint256 id = nextToken();  assert(    id > NUMBER_OF_GENESIS_HUMANS + NUMBER_OF_RESERVED_HUMANS &&    id <= MAX_HUMANS_SUPPLY);   _safeMint(to, id);}

总而言之,这个方法是相对直接的--我们承认这不是一个防弹的解决方案,但我们可以很高兴地说,它像预期的那样工作,我们为我们不透露的启动集合的方法感到自豪。

一些意外的结果

由于我们在最后部署到mainnet 之前进行了大量的测试,合同完全按照我们的意图运作。我们为我们采取的方法和所有的事情(大部分)都很顺利而感到自豪。

在铸币过程中,我们确实遇到了两个小问题,不幸的是,这两个问题本来是可以避免的,但我们把它们作为经验教训。这两个问题都不是由合同中的缺陷造成的,而是由我们的铸币厂网站前端的一些有缺陷的逻辑造成的。

尽管我们在本地环境和测试网进行了所有的测试,但直到我们在主网上开启预售活动时,我们才遇到了这个特殊问题。我们开始收到一些用户的报告,说他们的交易失败了,因为他们已经没有油了。

在做了一些初步调查后(尽管有点慌乱),我们确定Metamask在估计一些(但不是所有)交易的气体限制方面做得非常差。

我们仍然不能100%确定为什么会出现这种情况,但我在这个阶段的假设是,这至少有一部分是由于代币ID的随机化。不管怎么说,这是一个相对简单的修复,需要在前端部署一个小补丁。

const GAS_LIMIT_PER: number = 200000;

上面的片段显示了我们实施的简单修复,这涉及到根据正在铸造的代币数量,为每笔交易手动设置gasLimit

应该注意的是,我们严重高估了限额,这导致了较高的气体估计,但实际交易使用的气体要少得多。

在预售期间铸造1个代币时的气体用量

另一个问题稍微 "严重 "一些。事实是,这只是一个疏忽,是我们的一个业余错误。现在回想起来,我认为这是我们漏掉的东西,因为我们根本没有考虑到藏品这么快就卖完的情况。

我们真的预计到这个系列需要几天的时间才能卖完,更不用说在一分钟内就卖完了,所以我们根本没有想到要为这种情况做检查。

显然,事后看来,这是一个愚蠢的错误,因为我们真的应该考虑到所有的情况。我们的错误在于,如果availableTokenCount() ,我们没有阻止用户调用mint函数,0

此外,用户界面引用了不正确的变量,导致显示给用户的供应量一旦达到零就会重置。这样做的结果是,许多人继续尝试铸币,即使没有更多的代币可用。

由于包含了ensureAvailabilityFor 修改器,合同按预期恢复了交易,但用户仍然为失败的交易产生了气体费用。我们在几分钟内对前端进行了修复,并最终退还了170多笔失败交易的汽油费损失。

值得庆幸的是,没有一笔交易的损失超过0.004 Eth,所以损失很小。总而言之,这是一个宝贵的教训,值得庆幸的是,这个教训并不昂贵。

在我们的下一篇文章中,我们将深入探讨我们如何通过使用签名优惠券来处理链外的预售/允许清单。

[

处理链外的NFT预售/允许名单

使用链外生成的签名优惠券而不是链上允许列表的新颖方法。

medium.com

](medium.com/@humansofnf…)

附录

(1)docs.chain.link/docs/chainl…

(2)github.com/1001-digita…