发售 ERC721 NFT 的完整合约实现)
WTF Solidity 实战基于荷兰拍卖Dutch Auction发售 ERC721 NFT 的完整合约实现【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity本文基于 WTF-Solidity 仓库第 35 讲内容围绕简化版 Azuki 荷兰拍卖合约 DutchAuction.sol 展开先讲清荷兰拍卖的机制与项目方偏好它的原因再逐字段拆解拍卖定价、mint、提款的核心逻辑并结合源码与 Remix 演示给出可复现的完整实战流程。读完本文你将掌握如何用 Solidity 实现一个带阶梯式降价、可配置起拍时间、支持批量铸造与超额退款的荷兰拍卖 NFT 发售合约。一、荷兰拍卖价格由高到低递减的减价拍卖荷兰拍卖Dutch Auction又称“减价拍卖”拍卖标的竞价从最高价开始随时间依次递减直到第一个竞买人应价达到或超过底价时成交。它与传统英式拍卖出价由低到高不断抬升正好相反其核心特征是价格随时间单调下降先到先得。在币圈很多知名 NFT 项目通过荷兰拍卖发售包括 Azuki 和 World of Women其中 Azuki 通过荷兰拍卖筹集了超过 8000 ETH。项目方偏爱这种拍卖形式主要有两个原因收入最大化荷兰拍卖价格从最高价缓慢下降愿意高价买入的用户先出手能让项目方获得最大的总收入。规避 gas war拍卖持续较长时间通常 6 小时以上用户不必在同一时刻争抢区块天然避免了竞价集中导致的 gas 费用飙升。仓库中的 S09_DoS/readme.md 还记录了一个与荷兰拍卖相关的真实案例2022 年 4 月 NFT 项目 Akutar 使用荷兰拍卖公开发行筹集了 11539.5 ETH但在处理社区 Pass 持有者退款时因合约拒绝服务漏洞导致资金被永久锁定。这说明荷兰拍卖合约的提款与退款逻辑在实际部署时同样需要谨慎设计。二、合约总览继承 ERC721 与 OwnableDutchAuction合约代码基于 Azuki 的合约地址0xed5af388653567af2f388e6224dc7c4b3241c544简化而来仓库中完整源码位于 DutchAuction.sol。它同时继承了之前课程介绍的ERC721合约与本仓库lib/openzeppelin-contracts/contracts/access/Ownable.sol中的Ownable合约// SPDX-License-Identifier: MIT pragma solidity ^0.8.34; import openzeppelin/contracts/access/Ownable.sol; import ../34_ERC721/ERC721.sol; contract DutchAuction is Ownable, ERC721 {两点值得注意本仓库源码将原文档中基于网络 URL 的 importimport https://github.com/.../34_ERC721/ERC721.sol改成了仓库内相对路径../34_ERC721/ERC721.sol即本讲的 ERC721.sol 就是 34_ERC721 课程中从零实现的标准 ERC721含balanceOf、ownerOf、transferFrom、_mint、tokenURI等。Ownable来自 OpenZeppelin v5其构造函数需要显式传入initialOwner本合约传入msg.sender并提供onlyOwner修饰符与owner()、transferOwnership()等接口。onlyOwner修饰符在 Ownable.sol 中通过_checkOwner()校验调用者是否为 owner非 owner 调用会回退合约中setAuctionStartTime、setBaseURI、withdrawMoney均受其保护。三、核心状态变量6 个拍卖参数 3 个辅助变量合约中一共有 9 个状态变量其中 6 个与拍卖定价直接相关状态变量类型值/含义COLLECTION_SIZEuint256 constantNFT 总量本例为 10000AUCTION_START_PRICEuint256 constant起拍价最高价1 etherAUCTION_END_PRICEuint256 constant结束价最低价/地板价0.1 etherAUCTION_TIMEuint256 constant拍卖持续时长为方便测试设为 10 分钟AUCTION_DROP_INTERVALuint256 constant每过多久价格衰减一次1 分钟AUCTION_DROP_PER_STEPuint256 constant每次价格衰减的步长auctionStartTimeuint256拍卖起始时间戳block.timestamp_baseTokenURIstring privatemetadata 的 Base URI_allTokensuint256[] private记录所有已存在 tokenIduint256 public constant COLLECTION_SIZE 10000; // NFT总数 uint256 public constant AUCTION_START_PRICE 1 ether; // 起拍价 uint256 public constant AUCTION_END_PRICE 0.1 ether; // 结束价最低价 uint256 public constant AUCTION_TIME 10 minutes; // 拍卖时间为了测试方便设为10分钟 uint256 public constant AUCTION_DROP_INTERVAL 1 minutes; // 每过多久时间价格衰减一次 uint256 public constant AUCTION_DROP_PER_STEP (AUCTION_START_PRICE - AUCTION_END_PRICE) / (AUCTION_TIME / AUCTION_DROP_INTERVAL); // 每次价格衰减步长 uint256 public auctionStartTime; // 拍卖开始时间戳 string private _baseTokenURI; // metadata URI uint256[] private _allTokens; // 记录所有存在的tokenIdAUCTION_DROP_PER_STEP是纯编译期常量由其它常量推导(1 ether - 0.1 ether) / (10 minutes / 1 minutes) 0.9 ether / 10 0.09 ether即每过 1 分钟价格下降 0.09 ETH10 分钟后恰好从 1 ETH 降到 0.1 ETH。_allTokens与_addTokenToAllTokensEnumeration()对应 ERC721Enumerable 的枚举逻辑totalSupply()返回_allTokens.lengthmint 时把新 tokenId push 进数组用于校验铸造总量是否超过COLLECTION_SIZE详见 DutchAuction.sol。四、合约函数定价、铸造与提款合约共有 9 个函数与 ERC721 相关的approve、transferFrom、safeTransferFrom等由继承的基类提供这里只剖析与拍卖直接相关的 4 个核心函数。4.1 设定拍卖起始时间构造函数在部署时把当前区块时间block.timestamp记录为拍卖起始时间项目方可通过setAuctionStartTime(uint32)在开拍前调整例如延后开售该函数带onlyOwner权限constructor() Ownable(msg.sender) ERC721(WTF Dutch Auction, WTF Dutch Auction) { auctionStartTime block.timestamp; } // auctionStartTime setter函数onlyOwner function setAuctionStartTime(uint32 timestamp) external onlyOwner { auctionStartTime timestamp; }注意setAuctionStartTime的参数类型是uint32而存储变量是uint256Solidity 会自动做类型提升timestamp传 0 是无效的auctionMint中的_saleStartTime ! 0检查正是为了防住“未设置起拍时间”的状态。4.2 获取实时价格getAuctionPrice()getAuctionPrice()是荷兰拍卖定价的核心它根据当前区块时间与拍卖参数计算实时价格分三种情况block.timestamp auctionStartTime尚未开拍返回最高价AUCTION_START_PRICEblock.timestamp - auctionStartTime AUCTION_TIME拍卖结束返回地板价AUCTION_END_PRICE两者之间计算已过去的完整步数steps返回AUCTION_START_PRICE - steps * AUCTION_DROP_PER_STEP。// 获取拍卖实时价格 function getAuctionPrice() public view returns (uint256) { if (block.timestamp auctionStartTime) { return AUCTION_START_PRICE; }else if (block.timestamp - auctionStartTime AUCTION_TIME) { return AUCTION_END_PRICE; } else { uint256 steps (block.timestamp - auctionStartTime) / AUCTION_DROP_INTERVAL; return AUCTION_START_PRICE - (steps * AUCTION_DROP_PER_STEP); } }定价模型是阶梯式而非连续曲线价格在每个AUCTION_DROP_INTERVAL结束时一次性下降一个步长同一时间段内价格保持不变。该函数是view函数不消耗 gas也方便前端随时查询当前报价。4.3 拍卖铸造auctionMint()用户调用auctionMint(uint256 quantity)并附上msg.value的 ETH 参与拍卖、铸造 NFT。执行流程依次为开拍检查_saleStartTime ! 0 block.timestamp _saleStartTime未设置起拍时间或未开拍直接回退总量检查totalSupply() quantity COLLECTION_SIZE防止超过 NFT 上限10000成本计算与余额检查totalCost getAuctionPrice() * quantitymsg.value totalCost不满足则回退循环铸造逐个_mint(msg.sender, mintIndex)并把 tokenId 加入_allTokens超额退款msg.value totalCost时用payable(msg.sender).transfer(msg.value - totalCost)退回多付的 ETH。// 拍卖mint函数 function auctionMint(uint256 quantity) external payable{ uint256 _saleStartTime uint256(auctionStartTime); // 建立local变量减少gas花费 require( _saleStartTime ! 0 block.timestamp _saleStartTime, sale has not started yet ); // 检查是否设置起拍时间拍卖是否开始 require( totalSupply() quantity COLLECTION_SIZE, not enough remaining reserved for auction to support desired mint amount ); // 检查是否超过NFT上限 uint256 totalCost getAuctionPrice() * quantity; // 计算mint成本 require(msg.value totalCost, Need to send more ETH.); // 检查用户是否支付足够ETH // Mint NFT for(uint256 i 0; i quantity; i) { uint256 mintIndex totalSupply(); _mint(msg.sender, mintIndex); _addTokenToAllTokensEnumeration(mintIndex); } // 多余ETH退款 if (msg.value totalCost) { payable(msg.sender).transfer(msg.value - totalCost); //注意一下这里是否有重入的风险 } }两个值得深挖的实现细节tokenId 连续分配tokenId 从 0 开始递增每次取当前totalSupply()与_allTokens一一对应。重入风险提示源码注释明确提示“注意一下这里是否有重入的风险”。transfer只转发 2300 gas 且目标为msg.sender铸造接收方即付款方而合约状态余额、归属在_mint时已更新因此本例相对安全但真实项目在做超额退款时应使用“先更新状态、后外部调用”的 Checks-Effects-Interactions 模式或使用 OpenZeppelin 的ReentrancyGuard。可结合仓库 S01_ReentrancyAttack 一讲深入理解重入攻击的原理。4.4 项目方提款withdrawMoney()拍卖结束后项目方通过withdrawMoney()一次性提走合约中筹集的全部 ETH同样受onlyOwner保护。提款使用第 22 讲介绍过的call方式详见 22_Call 的 Call.sol// 提款函数onlyOwner function withdrawMoney() external onlyOwner { (bool success, ) msg.sender.call{value: address(this).balance}(); require(success, Transfer failed.); }4.5 辅助函数BaseURI 管理除拍卖逻辑外合约还提供了两个 metadata 相关函数_baseURI()覆写基类返回_baseTokenURIsetBaseURI(string calldata baseURI)由 owner 设置 Base URI。ERC721 基类的tokenURI()会将 Base URI 与 tokenId 拼接见 ERC721.sol本项目该字段默认留空如需展示 NFT 图片需先调用setBaseURI。五、Remix 实战演示完整跑通荷兰拍卖以下是仓库 readme 中基于 Remix IDE 的完整演示流程可在 Remix 中导入DutchAuction.sol与34_ERC721目录后编译部署Solidity 版本需与pragma solidity ^0.8.34匹配可在 Remix 的编译设置中选择相应编译器版本。第 1 步部署合约并设置起拍时间部署DutchAuction.sol随后调用setAuctionStartTime()设置拍卖起始时间。本例使用的起始时间为 UTC 时间戳16583382002022 年 7 月 12 日 1 点 30 分实验中可用在线 Unix 时间戳工具查询自己需要的时刻。第 2 步观察荷兰拍卖价格变化通过getAuctionPrice()查询当前拍卖价格拍卖开始前价格恒为最高价AUCTION_START_PRICE开拍后随每个AUCTION_DROP_INTERVAL逐级下降直至到达地板价AUCTION_END_PRICE后不再变化。多次调用即可清晰观察到阶梯式降价曲线。第 3 步调用 auctionMint() 铸造 NFT在auctionMint中输入quantity例如 1并附带足额 ETH。本例由于查询价格时拍卖时间已结束因此仅需按地板价支付即可完成铸造。第 4 步项目方提取 ETH以 owner 身份调用withdrawMoney()合约通过call()将筹集到的全部 ETH 转给合约创建者地址。六、总结这一讲我们介绍了荷兰拍卖的机制并通过简化版 Azuki 代码实现了完整的荷兰拍卖 ERC721 发售合约6 个拍卖参数精确控制总量、起拍价、地板价、时长、降价间隔与降价步长getAuctionPrice()以区块时间驱动阶梯式定价auctionMint()完成开拍校验、总量校验、成本计算、批量铸造与超额退款withdrawMoney()让项目方安全提款配合Ownable实现权限控制。如需动手复现可直接查看仓库中的完整合约 DutchAuction.sol 与继承的 ERC721.sol或对照英文版教程 Languages/en/35_DutchAuction_en/readme.md 与葡萄牙语版 Languages/pt-br/35_DutchAuction/readme.md 巩固理解。最后提醒上线真实项目前务必针对退款与提款的潜在重入风险、拍卖参数的时间单位分钟/小时做充分的安全审计。【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考