4、荷兰拍卖合约设计:合约架构设计、状态变量定义、构造函数与初始化、出价函数实现、结束拍卖函数
荷兰拍卖,说白了就是价格从高往低走。跟咱们平时在拍卖行举牌加价正好反过来——一开始定个高价,没人买就慢慢降价,直到有人出手。
我最早接触这个模式是在做NFT项目的时候。那时候团队想搞个公平发售,又不想让gas war把用户体验搞崩。嗯,荷兰拍卖确实是个好方案。
合约架构设计
先聊聊整体思路。荷兰拍卖合约的核心逻辑其实不复杂:
- 一个计时器,控制拍卖进度
- 一个价格曲线,决定当前价格
- 一个出价入口,让用户买入
- 一个结束机制,收尾清算
我个人习惯把合约拆成三层:
- 数据层:存状态变量,比如起拍价、结束时间、已售数量
- 逻辑层:出价函数、价格计算、结束判断
- 交互层:接收ETH、铸造代币、事件通知
这样拆的好处是,后期如果要升级或者加功能,改动范围小。我在项目里吃过亏,一开始把所有逻辑揉在一个函数里,后来加个白名单功能,改得我头皮发麻。
状态变量定义
状态变量是合约的「记忆」。我一般会先列个清单,想清楚要存什么:
// 拍卖参数
uint256 public startPrice; // 起拍价(最高价)
uint256 public endPrice; // 底价(最低价)
uint256 public startTime; // 拍卖开始时间
uint256 public duration; // 拍卖持续时长
// 拍卖状态
uint256 public totalSold; // 已售数量
uint256 public maxSupply; // 最大供应量
address public beneficiary; // 收款地址
// 价格计算缓存
uint256 private priceDrop; // 每秒钟降价幅度
这里有个细节要注意——priceDrop 我用了 private。为什么?因为这个值可以通过起拍价、底价和时长算出来,没必要公开。省点gas,也少些外部依赖。
我曾经见过一个项目,把priceDrop设成public,结果前端直接读这个值算价格,后来参数改了但忘记更新合约,价格算出来全是错的。嗯,教训深刻。
构造函数与初始化
构造函数只跑一次,用来「定调子」。我会把参数校验放在这里:
constructor(
uint256 _startPrice,
uint256 _endPrice,
uint256 _duration,
uint256 _maxSupply,
address _beneficiary
) {
require(_startPrice > _endPrice, "起拍价必须高于底价");
require(_duration > 0, "时长必须大于0");
require(_maxSupply > 0, "供应量必须大于0");
require(_beneficiary != address(0), "收款地址不能为零地址");
startPrice = _startPrice;
endPrice = _endPrice;
duration = _duration;
maxSupply = _maxSupply;
beneficiary = _beneficiary;
startTime = block.timestamp;
// 计算每秒降价幅度
priceDrop = (_startPrice - _endPrice) / _duration;
}
你想想看,如果起拍价比底价还低,那还叫荷兰拍卖吗?直接送得了。所以这个校验必须做。
另外,startTime 我直接用了 block.timestamp。这意味着合约一部署,拍卖就开始了。有些项目会留一个延迟启动的接口,但我个人觉得,既然部署了就别磨叽,直接开拍。
出价函数实现
出价函数是用户直接交互的入口。核心逻辑就三步:
- 算当前价格
- 检查用户给的ETH够不够
- 铸币并记录
function bid(uint256 _amount) external payable {
// 1. 检查拍卖是否进行中
require(block.timestamp < startTime + duration, "拍卖已结束");
require(totalSold + _amount <= maxSupply, "库存不足");
// 2. 计算当前价格
uint256 currentPrice = getCurrentPrice();
// 3. 检查用户支付是否足够
require(msg.value >= currentPrice * _amount, "ETH不够");
// 4. 铸币(这里假设有个ERC721的_mint函数)
for (uint256 i = 0; i < _amount; i++) {
_mint(msg.sender, totalSold + i);
}
totalSold += _amount;
// 5. 多退少补
if (msg.value > currentPrice * _amount) {
payable(msg.sender).transfer(msg.value - currentPrice * _amount);
}
emit Bid(msg.sender, _amount, currentPrice);
}
这里有个坑——重入攻击。虽然 transfer 只发2300 gas,够安全,但我建议还是加上 nonReentrant 修饰符。我在审计项目时见过一个荷兰拍卖合约,就是因为没用防重入,被黑客用回调函数反复提款。嗯,那场面,惨不忍睹。
价格计算函数长这样:
function getCurrentPrice() public view returns (uint256) {
uint256 elapsed = block.timestamp - startTime;
if (elapsed >= duration) {
return endPrice; // 拍卖结束,按底价卖
}
return startPrice - (priceDrop * elapsed);
}
说白了就是线性下降。从起拍价开始,每秒降一点,直到底价。这个模型简单易懂,用户也能自己算,不用猜。
结束拍卖函数
拍卖结束后,需要做两件事:把收到的ETH转给项目方,把没卖完的代币处理掉。
function endAuction() external {
require(block.timestamp >= startTime + duration, "拍卖还没结束");
require(!auctionEnded, "已经结束过了");
auctionEnded = true;
// 转ETH给项目方
uint256 balance = address(this).balance;
(bool success, ) = beneficiary.call{value: balance}("");
require(success, "转账失败");
// 如果有未卖完的代币,可以销毁或转给项目方
uint256 unsold = maxSupply - totalSold;
if (unsold > 0) {
// 这里根据业务逻辑处理,比如销毁
}
emit AuctionEnded(totalSold, balance);
}
注意,我用了 .call{value: balance}("") 而不是 transfer。为什么?因为 transfer 有2300 gas限制,万一收款地址是个合约,gas不够就炸了。用 call 更灵活,但记得检查返回值。
整体流程一览
我画了张图,帮你把整个流程串起来:
这张图把整个流程串起来了。从部署开始,到用户出价,再到拍卖结束收尾。你写合约的时候,脑子里要有这张图,每一步该做什么,清清楚楚。
好了,荷兰拍卖合约的核心设计就这些。代码量不大,但细节不少。尤其是价格计算和资金安全,这两块出问题,后果很严重。我建议你写完合约后,先跑一遍测试网,模拟各种边界情况——比如有人卡在最后一秒出价,或者同时多笔交易进来。嗯,测试做充分了,上线才睡得着觉。