本文是 Solidity 学习系列的第二篇,在第一篇《Solidity基础知识学习》的语法基础之上,通过三个完整的实战项目进阶:
- 捐赠合约:接收 ETH、记账、权限控制、事件、时间限制、排行榜、提款,并部署到 Sepolia 测试网;
- NFT 拍卖市场:ERC721、ETH 和 ERC20 混合出价、Chainlink 预言机、UUPS 可升级合约、动态手续费,用 Hardhat 开发、测试、部署;
- 基础练习:投票合约与 5 道经典算法题的 Solidity 实现,以及 gas 优化写法。
所有合约都已部署到 Sepolia 测试网。
一、捐赠合约 BeggingContract
这个原名是"讨饭合约",本文统一称为捐赠合约。部署在链上的合约名仍是
BeggingContract:链上合约一旦部署就无法改名。
捐赠合约是学习 Solidity 资金处理最好的入门例子。它只有一百多行,却覆盖了智能合约里最核心的几件事:接收 ETH、记账、权限控制、事件、时间条件、向外转账。
1.1 需求说明
基础功能
- 任何人都可以向合约捐赠 ETH:既可以调用
donate(),也可以直接向合约地址转账。 - 合约记录每个地址的累计捐赠金额。
- 只有合约所有者(owner)可以提取合约里的全部资金。
- 任何人都可以查询某个地址的捐赠金额。
扩展功能
- 捐赠事件:每次捐赠都触发事件,记录捐赠人和金额。
- 捐赠排行榜:查询累计捐赠金额最多的前 3 个地址。
- 时间限制:只有在指定时间段内才能捐赠。
交付要求
部署到 Sepolia 测试网,并测试 donate、withdraw、getDonation 都能正常工作。
1.2 合约设计
状态变量:合约存在链上的数据。
| 变量 | 类型 | 作用 |
|---|---|---|
owner | address | 合约所有者,部署时设为部署者 |
donations | mapping(address => uint256) | 每个地址的累计捐赠金额,单位 wei |
topDonors | address[3] | 排行榜前 3 名,按金额从高到低排列 |
startTime / endTime | uint256 | 允许捐赠的时间段,单位是秒级时间戳 |
函数
| 函数 | 谁能调用 | 是否收 ETH | 作用 |
|---|---|---|---|
donate() | 任何人 | ✅ payable | 捐赠,记账,更新排行榜,触发事件 |
receive() | 任何人 | ✅ | 直接向合约地址转账时自动触发,内部调用 donate() |
withdraw() | 仅 owner | — | 提取合约全部余额 |
getDonation(address) | 任何人(只读) | — | 查询某地址的累计捐赠 |
getTopDonors() | 任何人(只读) | — | 查询排行榜 |
setDonationPeriod(start, end) | 仅 owner | — | 修改允许捐赠的时间段 |
事件:Donation(address indexed donor, uint256 amount)
修饰符:onlyOwner,限制只有 owner 能调用。
1.3 完整代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title 捐赠合约(讨饭合约):接收捐赠,所有者提款
contract BeggingContract {
address public owner;
// 捐赠者 => 累计捐赠金额(wei)
mapping(address => uint256) public donations;
// 扩展功能 1:捐赠事件
event Donation(address indexed donor, uint256 amount);
// 扩展功能 2:捐赠排行榜,按累计金额从高到低,空位为 address(0)
address[3] private topDonors;
// 扩展功能 3:允许捐赠的时间段 [startTime, endTime](区块时间戳,秒)
uint256 public startTime;
uint256 public endTime;
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
/// @dev 默认从部署时刻起开放 7 天,owner 可通过 setDonationPeriod 修改
constructor() {
owner = msg.sender;
startTime = block.timestamp;
endTime = block.timestamp + 7 days;
}
/// @notice 捐赠以太币(仅限捐赠时间段内)
function donate() public payable {
require(msg.value > 0, "amount must > 0");
require(block.timestamp >= startTime && block.timestamp <= endTime, "not in donation period");
donations[msg.sender] += msg.value;
_updateTopDonors(msg.sender);
emit Donation(msg.sender, msg.value);
}
/// @notice owner 设置捐赠时间段
function setDonationPeriod(uint256 _startTime, uint256 _endTime) external onlyOwner {
require(_startTime < _endTime, "invalid period");
startTime = _startTime;
endTime = _endTime;
}
/// @notice 捐赠金额最多的前 3 个地址及其金额(不足 3 人时空位为 address(0) 和 0)
function getTopDonors() external view returns (address[3] memory donors, uint256[3] memory amounts) {
donors = topDonors;
for (uint256 i = 0; i < 3; i++) {
amounts[i] = donations[donors[i]];
}
}
/// @dev 捐赠后更新排行榜。
/// 榜单只有 3 个位置,每次捐赠时增量维护,查询时就不用遍历所有捐赠者
/// (mapping 本身也没法遍历)。
function _updateTopDonors(address donor) private {
uint256 amount = donations[donor];
// 1. 找 donor 是否已在榜上
uint256 i = 0;
while (i < 3 && topDonors[i] != donor) {
i++;
}
// 2. 不在榜上:只有比第 3 名多才能上榜,直接替换第 3 名
// (空位是 address(0),其 donations 为 0,所以任何正数捐赠都能填进空位)
if (i == 3) {
if (amount <= donations[topDonors[2]]) {
return;
}
i = 2;
topDonors[2] = donor;
}
// 3. 向前冒泡:比前一名多就交换,直到位置正确(金额相同时先到者排前面)
while (i > 0 && amount > donations[topDonors[i - 1]]) {
(topDonors[i], topDonors[i - 1]) = (topDonors[i - 1], topDonors[i]);
i--;
}
}
/// @notice 直接向合约地址转账(如 MetaMask 直接转)也算捐赠
receive() external payable {
donate();
}
/// @notice 所有者提取全部余额
function withdraw() external onlyOwner {
// 获取当前合约的所有余额
uint256 amount = address(this).balance;
// 题目要求使用 transfer(固定 2300 gas,owner 为 EOA 时没问题)
// payable(owner).transfer(amount);
// 使用 call 代替 transfer,并安全地处理返回值
(bool success, ) = payable(owner).call{value: amount}("");
require(success, "Transfer failed.");
}
/// @notice 查询某地址的累计捐赠金额
function getDonation(address donor) external view returns (uint256) {
return donations[donor];
}
}
1.4 代码逐段讲解
1.4.1 所有者与权限控制:owner 和 onlyOwner
address public owner;
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
constructor() {
owner = msg.sender;
}
constructor只在部署时执行一次。部署交易的发送者就是msg.sender,所以部署者自动成为 owner。modifier是可以复用的前置检查。_;表示"检查通过后,在这里执行被修饰的函数体"。- 给
withdraw加上onlyOwner后,其他人调用会被require拦下,交易回滚并报not owner。
实际项目中一般直接继承 OpenZeppelin 的
Ownable,它还额外提供transferOwnership(转让所有权)、renounceOwnership(放弃所有权)等功能。这里手写是为了看清原理。
1.4.2 捐赠:payable 与 msg.value
function donate() public payable {
require(msg.value > 0, "amount must > 0");
...
donations[msg.sender] += msg.value;
}
为什么 donate() 没有金额参数? ETH 不是靠函数参数传进来的,而是放在交易本身的 value 字段里,跟着交易一起转给合约:
交易 = { to: 合约地址, data: donate() 的调用数据, value: 1 ether }
↑ 调用哪个函数、传什么参数 ↑ 实际转多少钱
- 函数必须加
payable,交易才允许携带 ETH;不带payable的函数收到 ETH 会直接 revert。 - 交易执行时 ETH 已经转进合约了,
msg.value只是告诉合约这次收到了多少(单位 wei)。 msg.sender是这次调用的发起者,用它作为 mapping 的 key 记账。+=是累加:同一个地址多次捐赠,金额会累计起来。
如果用参数记账,会出现漏洞:
function donate(uint256 amount) public payable {
donations[msg.sender] += amount; // 漏洞:记账用的是参数,不是实际收到的钱
}
攻击者可以传 amount = 100 ether,实际只付 value = 0,账上却记了 100 ETH。所以 ETH 记账永远用 msg.value。
对比:ERC20 代币转账是调用代币合约的函数,不走交易的
value字段,所以 ERC20 相关的函数确实需要amount参数(见第二部分拍卖合约的bid)。
1.4.3 receive:直接转账也算捐赠
receive() external payable {
donate();
}
用户在 MetaMask 里直接给合约地址转账时,交易不调用任何函数(data 为空)。这时合约会执行 receive()。这里让它转去调用 donate(),直接转账也会记账、更新排行榜、受时间限制约束。
| 场景 | 执行哪个函数 |
|---|---|
转账,data 为空 | receive() |
转账或调用,data 匹配不上任何函数 | fallback() |
| 两者都没有定义 | 交易 revert,转不进来 |
本合约没有定义 fallback(),所以调用不存在的函数会直接失败,这正是我们想要的。
1.4.4 事件:Donation
event Donation(address indexed donor, uint256 amount);
emit Donation(msg.sender, msg.value);
- 事件写在交易日志(log)里,合约自己读不到,是给链下程序看的:前端、Etherscan、数据索引服务都靠它监听"发生了什么"。
- 写日志比写存储便宜得多,适合记录历史流水。
indexed参数可以被高效检索,比如"查询某个地址的所有捐赠记录"。一个事件最多 3 个indexed参数。
1.4.5 时间限制
require(block.timestamp >= startTime && block.timestamp <= endTime, "not in donation period");
block.timestamp 是当前区块的时间,单位是秒,从 1970-01-01 00:00:00 UTC 开始计算,也叫 Unix 时间戳。
- 构造函数里默认开放 7 天:
endTime = block.timestamp + 7 days。Solidity 支持seconds、minutes、hours、days、weeks这些时间单位。 - owner 可以调用
setDonationPeriod(start, end)修改时间段,两个参数都填秒级时间戳。 - 获取当前时间戳:终端运行
date +%s。常用时长:1 小时 =3600,1 天 =86400,7 天 =604800。
常见误区:JavaScript 的
Date.now()返回的是毫秒,有 13 位。合约要的是 10 位的秒,直接填进去,时间会变成几万年以后。安全提示:出块者可以把区块时间微调几秒,所以
block.timestamp不适合做精确到秒的判断,比如随机数或抢购。"开放 7 天"这种粒度完全没问题。
1.4.6 捐赠排行榜
为什么不在查询时排序? mapping 不能遍历,查询时根本拿不到所有捐赠者。所以只能在每次捐赠时,增量维护一个固定长度的榜单 address[3]。
_updateTopDonors 分三步:
- 查找:捐赠者是否已经在榜上;
- 上榜:不在榜上的,累计金额必须比第 3 名多,才能替换第 3 名;
- 冒泡:和前一名比较,金额更多就交换位置,直到排好。
举例,假设当前榜单是 [B: 3, D: 2, A: 1]:
| 新的捐赠 | 过程 | 结果 |
|---|---|---|
| E 捐 1 | E 累计 1,不比第 3 名 A(1)多,不上榜 | [B: 3, D: 2, A: 1] |
| E 再捐 1 | E 累计 2 > A 的 1,替换第 3 名;再和 D(2)比,相等不交换 | [B: 3, D: 2, E: 2] |
| A 捐 10 | A 累计 11,替换第 3 名后一路冒泡到第一 | [A: 11, B: 3, D: 2] |
榜单只有 3 个位置,每次最多比较 3 次,gas 是固定的,不会随捐赠人数增长。空位是 address(0),它的捐赠额是 0,所以任何正数捐赠都能填进空位。
1.4.7 提款:transfer 还是 call?
function withdraw() external onlyOwner {
uint256 amount = address(this).balance;
(bool success, ) = payable(owner).call{value: amount}("");
require(success, "Transfer failed.");
}
address(this).balance 是合约当前持有的 ETH。向外转 ETH 有三种写法:
| 写法 | 转发 gas | 失败时 | 建议 |
|---|---|---|---|
transfer(amount) | 固定 2300 | 自动 revert | 不推荐:接收方是合约钱包(如多签)时,可能因 gas 不够而失败 |
send(amount) | 固定 2300 | 返回 false | 不推荐 |
call{value: amount}("") | 全部剩余 gas | 返回 false,需要自己检查 | 推荐,但要注意防重入 |
call 会把全部 gas 交给接收方,接收方如果是合约,就有机会在收款时回调本合约,这就是重入攻击。防御方法是 CEI 模式(Checks-Effects-Interactions):先检查,再修改状态,最后才向外转账。本合约的 withdraw 只有 owner 能调用,转账后也不再修改状态,所以没有重入风险。
1.4.8 查询:getDonation 与 public 变量
mapping(address => uint256) public donations;
function getDonation(address donor) external view returns (uint256) {
return donations[donor];
}
- 状态变量声明为
public时,编译器会自动生成同名的查询函数,所以donations(address)本身就能查询。getDonation是题目要求的显式接口,功能完全一样。 view表示只读不改。链下直接调用 view 函数不花 gas,也不需要发交易。
1.5 ETH 的单位
| 单位 | 等于多少 wei | 等于多少 ether | 常见用途 |
|---|---|---|---|
| wei | 1 | 10⁻¹⁸ | 最小单位,合约内部只用它 |
| gwei | 10⁹ | 10⁻⁹ | gas 价格,比如"gas 费 20 gwei" |
| finney | 10¹⁵ | 0.001 | 很少用 |
| ether | 10¹⁸ | 1 | 日常说的"1 个 ETH" |
不管界面上选什么单位,最终都会换算成 wei 发送,合约里的 msg.value 永远是 wei。不同工具里,捐赠金额填在不同的地方:
| 工具 | 金额填在哪 |
|---|---|
| Remix | Deploy & Run 面板的 VALUE 输入框,可选单位 |
| Etherscan | Write Contract 中的 payableAmount (ether),单位是 ETH |
| Blockscout | Send native ETH,单位是 wei;填 0.01 后要点 ×10¹⁸ |
| ethers.js | contract.donate({ value: ethers.parseEther("1") }) |
| cast | cast send <合约> "donate()" --value 0.01ether ... |
| MetaMask | 直接向合约地址转账,触发 receive() |
1.6 部署与源码验证
用 Foundry 部署到 Sepolia。--account 使用 Foundry 的加密钱包,私钥不会以明文写在任何文件里:
forge create BeggingContract.sol:BeggingContract \
--rpc-url https://ethereum-sepolia-rpc.publicnode.com \
--account deployer --broadcast
部署后要在 Etherscan 上验证源码,页面上才会出现 Read Contract / Write Contract 按钮,别人也能看到源码。最稳妥的做法是用 Standard JSON Input 验证:它把源码和全部编译设置(编译器版本、optimizer、EVM 版本)打包成一个文件,上传时不用手动逐项填写,不容易出错:
FOUNDRY_SRC=. forge verify-contract <合约地址> BeggingContract.sol:BeggingContract \
--root . --compiler-version 0.8.37 --show-standard-json-input > standard-input.json
然后在 Etherscan 的 Verify & Publish 页面,Compiler Type 选 Solidity (Standard-Json-Input),上传这个文件即可。

上传 Standard JSON Input 后,Etherscan 提示 "Successfully generated matching Bytecode and ABI",源码验证通过
1.7 Sepolia 测试网实测
合约地址:0x7fb2b27ebDa5e371539EEb3325E14f779Be533Dd(已在 Etherscan 验证源码)
部署后的状态
| 项目 | 结果 |
|---|---|
| owner | 0x83Da…d88C(部署者) |
| 捐赠时间段 | 部署时刻起开放 7 天 |
| 排行榜 | 3 个空位 |
捐赠测试:用三个账户分别调用 donate
| 捐赠人 | 金额 | 交易 |
|---|---|---|
0x83Da…d88C | 1 ETH | |
0x455B…D6a0 | 2 ETH | |
0xF78c…74Bf2 | 0.5 ETH |

合约页面的交易列表:三笔 Donate 交易都已上链,合约余额 3.5 ETH
查询结果
- 合约余额:1 + 2 + 0.5 = 3.5 ETH;
getDonation(0x455B…D6a0)返回2000000000000000000,也就是 2 ETH;getTopDonors()返回[0x455B…, 0x83Da…, 0xF78c…],金额分别为[2, 1, 0.5]ETH,按从高到低排序,结果正确。

在 Etherscan 的 Read Contract 中调用 getDonation 和 getTopDonors,排行榜按 2 > 1 > 0.5 ETH 排序
用命令行测试的写法(cast)
C=0x7fb2b27ebDa5e371539EEb3325E14f779Be533Dd
R=https://ethereum-sepolia-rpc.publicnode.com
# 捐赠 0.01 ETH
cast send $C "donate()" --value 0.01ether --account deployer --rpc-url $R
# 直接转账(触发 receive)
cast send $C --value 0.005ether --account deployer --rpc-url $R
# 查询(只读,不花 gas)
cast call $C "getDonation(address)(uint256)" <地址> --rpc-url $R
cast call $C "getTopDonors()(address[3],uint256[3])" --rpc-url $R
# 模拟失败场景:金额为 0 时应报 amount must > 0
cast call $C "donate()" --from <地址> --rpc-url $R
# 模拟非 owner 提款:应报 not owner
cast call $C "withdraw()" --from 0x000000000000000000000000000000000000dEaD --rpc-url $R
# owner 提款
cast send $C "withdraw()" --account deployer --rpc-url $R
截至本文写作时,合约中的 3.5 ETH 还没有提取,
withdraw的链上测试按上面最后一条命令执行即可。
1.8 安全性与改进方向
| 问题 | 说明 | 改进方式 |
|---|---|---|
| 单一 owner | owner 私钥丢失,资金就永远取不出来;私钥泄露,资金会被转走 | 使用多签钱包作为 owner,或者继承 OpenZeppelin Ownable2Step 支持安全转让 |
| require 字符串 | 报错字符串会增加部署成本和 revert 时的 gas | 改用自定义错误:error NotOwner(); if (...) revert NotOwner(); |
| owner 存在 storage | 每次 onlyOwner 检查都要读一次存储(2100 gas) | owner 不需要变更时,声明为 immutable |
| 排行榜同额 | 金额相同时,先达到这个金额的人排在前面 | 按业务需要决定是否调整规则 |
| 时间依赖 | block.timestamp 可以被出块者微调几秒 | 本场景的粒度是天,可以接受 |
二、NFT 拍卖市场
捐赠合约只处理 ETH 一种资产;NFT 拍卖市场则同时涉及 NFT(ERC721)、ETH、ERC20 代币三种资产,还要接入链下价格(Chainlink 预言机)、支持合约升级(UUPS 代理)。它是一个完整的 Hardhat 工程:开发、测试、部署、验证全流程都会走一遍。
2.1 需求说明
基础功能
- NFT 合约:基于 ERC721 标准,支持铸造和转移。
- 创建拍卖:卖家可以把自己的 NFT 上架拍卖,设置起拍价和拍卖时长。
- 出价:买家可以用 ETH 或 ERC20 代币出价,出价必须高于当前最高价。
- 结束拍卖:到期后,NFT 转给出价最高者,资金转给卖家;没人出价则 NFT 退回卖家。
扩展功能
- Chainlink 预言机:获取 ETH、ERC20 对美元的价格,把不同币种的出价统一换算成美元来比较。
- 合约升级:使用 UUPS 或透明代理模式,部署后可以升级合约逻辑。
- 动态手续费:根据成交金额动态调整平台手续费。
交付要求
- 使用 Hardhat 开发,编写覆盖全部功能的单元测试和集成测试;
- 用 Hardhat 部署脚本部署到 Sepolia 测试网;
- 提交代码、测试报告(含覆盖率)、部署地址和项目文档。
2.2 合约设计
合约组成
| 合约 | 作用 | 来源 |
|---|---|---|
MyNFT | 被拍卖的 NFT,owner 可以铸造 | 自己编写,继承 OpenZeppelin ERC721 |
NFTAuction | 拍卖逻辑:上架、出价、结束、价格换算、手续费 | 自己编写,继承 UUPSUpgradeable、OwnableUpgradeable |
ERC1967Proxy | 代理合约,保存所有数据,用户只和它交互 | @openzeppelin/hardhat-upgrades 插件自带 |
NFTAuctionV2 | 升级演示版本,新增 version() | 自己编写,只在测试中使用 |
MockERC20 / MockV3Aggregator | 本地测试用的代币和喂价 | 自己编写,只在测试中使用 |
拍卖数据结构
struct Auction {
address seller;
address nft;
uint256 tokenId;
uint256 endTime;
uint256 startPriceUsd; // 起拍价(美元,8 位小数,与 Chainlink 一致)
address highestBidder;
address bidToken; // 最高出价所用币种,address(0) 表示 ETH
uint256 highestBid; // 最高出价的币数量
uint256 highestBidUsd; // 最高出价折合美元(8 位小数)
bool ended;
}
函数
| 函数 | 谁能调用 | 作用 |
|---|---|---|
createAuction(nft, tokenId, startPriceUsd, duration) | NFT 持有人 | 上架,NFT 转进合约托管 |
bid(auctionId, token, amount) | 任何人 | 出价;token = 0x0 表示用 ETH,否则用 ERC20 |
endAuction(auctionId) | 任何人(到期后) | 结算:NFT 给赢家,钱给卖家和平台 |
getUsdValue(token, amount) | 任何人(只读) | 用 Chainlink 把金额换算成美元 |
getFeeBps(usdValue) | 任何人(只读) | 查询某个成交价对应的手续费率 |
setPriceFeed(token, feed) | 仅 owner | 给某个币种配置喂价合约 |
upgradeToAndCall(newImpl, data) | 仅 owner | 升级到新的实现合约(继承自 UUPS) |
事件:AuctionCreated、BidPlaced(带折算后的美元价值)、AuctionEnded(带手续费)
2.2.1 为什么部署了 3 个合约?
| 合约 | 作用 |
|---|---|
| MyNFT | 被拍卖的 NFT |
| NFTAuction 实现合约 | 只存代码,不存数据 |
| ERC1967Proxy 代理合约 | 存所有数据,用户只和它交互 |
部署到链上的合约代码不能修改。UUPS 的做法是把"数据"和"代码"分开:
用户 ──调用──▶ 代理合约(数据:拍卖、出价都存在这里)
│ delegatecall:借用实现合约的代码,读写代理自己的数据
▼
实现合约 V1(只有代码)
升级时部署一个新的实现合约 V2,再让代理指向它。代理地址和里面的数据都不变,用户感觉不到变化。对外公布的拍卖合约地址,是代理合约的地址。
upgrades.deployProxy(...) 一次性完成三件事:部署实现合约、部署 ERC1967Proxy、调用 initialize()。所以项目的 contracts/ 目录里看不到 ERC1967Proxy.sol:它由 @openzeppelin/hardhat-upgrades 插件提供,插件里自带编译好的版本。
2.2.2 ERC1967 是什么?UUPS 和透明代理有什么区别?
ERC-1967 规定代理合约把"实现合约地址"存在一个固定的存储槽:
slot = keccak256("eip1967.proxy.implementation") - 1
= 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
选这么一个随机的位置,是为了避免和实现合约自己的变量冲突(实现合约的变量从 0 号槽开始存)。Etherscan、Blockscout 等工具都按这个标准读取,所以能自动识别代理合约,页面上显示 "Read as Proxy / Write as Proxy"。

区块浏览器识别出 EIP-1967 代理,并显示实现合约地址 0x86aA…7784;Read/Write proxy 页面列出的是实现合约的函数

通过代理调用的写函数:bid、createAuction、endAuction,以及 UUPS 的 upgradeToAndCall
| 模式 | 升级函数放在哪 | 特点 |
|---|---|---|
| UUPS | 实现合约里(继承 UUPSUpgradeable,用 _authorizeUpgrade 控制权限) | 代理合约非常精简,gas 更省;本文使用这种模式 |
| 透明代理 | 代理合约里,由一个单独的 ProxyAdmin 合约管理 | 升级逻辑和业务逻辑分离,但每次调用要多一次判断 |
另外,实现合约在构造函数里调用了 _disableInitializers(),防止别人直接初始化实现合约、抢走 owner 权限。
2.3 完整代码
2.3.1 NFT 合约 MyNFT
基于 OpenZeppelin ERC721,owner 可以铸造,持有人可以转移(转移功能由 ERC721 标准自带)。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/// @title 拍卖用的 NFT(ERC721),转移功能由 ERC721 标准自带
contract MyNFT is ERC721, Ownable {
uint256 public nextTokenId;
constructor() ERC721("MyNFT", "MNFT") Ownable(msg.sender) {}
/// @notice 所有者给 to 铸造一个 NFT,返回 tokenId
function mint(address to) external onlyOwner returns (uint256 tokenId) {
tokenId = nextTokenId++;
_safeMint(to, tokenId);
}
}

MyNFT 在区块浏览器上的写函数:approve、mint、safeTransferFrom、transferFrom 等,除 mint 外都来自 ERC721 标准
2.3.2 拍卖合约 NFTAuction
核心函数:createAuction 上架、bid 出价(ETH 或 ERC20)、endAuction 结束拍卖、getUsdValue 价格换算、getFeeBps 动态手续费。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
/// @title NFT 拍卖市场(UUPS 可升级)
/// @notice 出价可用 ETH 或 ERC20,统一按 Chainlink 价格换算成美元比较高低
contract NFTAuction is Initializable, UUPSUpgradeable, OwnableUpgradeable {
using SafeERC20 for IERC20;
struct Auction {
address seller;
address nft;
uint256 tokenId;
uint256 endTime;
uint256 startPriceUsd; // 起拍价(美元,8 位小数,与 Chainlink 一致)
address highestBidder;
address bidToken; // 最高出价所用币种,address(0) 表示 ETH
uint256 highestBid; // 最高出价的币数量
uint256 highestBidUsd; // 最高出价折合美元(8 位小数)
bool ended;
}
// 拍卖 id => 拍卖信息
mapping(uint256 => Auction) public auctions;
uint256 public nextAuctionId;
// 币种 => Chainlink 喂价合约(address(0) 为 ETH/USD)
mapping(address => AggregatorV3Interface) public priceFeeds;
event AuctionCreated(uint256 indexed auctionId, address indexed seller, address nft, uint256 tokenId, uint256 endTime);
event BidPlaced(uint256 indexed auctionId, address indexed bidder, address token, uint256 amount, uint256 usdValue);
event AuctionEnded(uint256 indexed auctionId, address winner, address token, uint256 amount, uint256 fee);
// 额外挑战:动态手续费,按成交价(美元)分档,成交价越高费率越低。
// 费率单位为基点(1 bp = 0.01%),用常量而非存储变量:不占存储槽、读取零成本,
// 需要调整费率时通过 UUPS 升级修改即可。
uint256 private constant TIER1_USD = 1_000 * 1e8; // $1,000(8 位小数)
uint256 private constant TIER2_USD = 10_000 * 1e8; // $10,000
uint256 private constant FEE_BPS_LOW = 300; // < $1,000:3%
uint256 private constant FEE_BPS_MID = 200; // $1,000 ~ $10,000:2%
uint256 private constant FEE_BPS_HIGH = 100; // >= $10,000:1%
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
// 锁住实现合约本身,防止有人直接初始化实现合约
_disableInitializers();
}
/// @notice 代理合约的“构造函数”,部署时只调用一次
function initialize() public initializer {
__Ownable_init(msg.sender);
}
/// @notice 设置某币种的喂价合约,token = address(0) 表示 ETH
function setPriceFeed(address token, address feed) external onlyOwner {
priceFeeds[token] = AggregatorV3Interface(feed);
}
/// @notice 把 amount 个 token 换算成美元(结果 8 位小数)
function getUsdValue(address token, uint256 amount) public view returns (uint256) {
AggregatorV3Interface feed = priceFeeds[token];
require(address(feed) != address(0), "no price feed");
(, int256 price,,,) = feed.latestRoundData();
require(price > 0, "invalid price");
// 喂价本身带 feed.decimals() 位小数,这里统一折算成 8 位,方便不同币种直接比较
uint8 tokenDecimals = token == address(0) ? 18 : IERC20Metadata(token).decimals();
return amount * uint256(price) * 1e8 / (10 ** tokenDecimals) / (10 ** feed.decimals());
}
/// @notice 上架拍卖:NFT 先托管到本合约,需提前 approve
function createAuction(address nft, uint256 tokenId, uint256 startPriceUsd, uint256 duration)
external
returns (uint256 auctionId)
{
require(duration > 0, "duration must > 0");
IERC721(nft).transferFrom(msg.sender, address(this), tokenId);
auctionId = nextAuctionId++;
Auction storage a = auctions[auctionId];
a.seller = msg.sender;
a.nft = nft;
a.tokenId = tokenId;
a.endTime = block.timestamp + duration;
a.startPriceUsd = startPriceUsd;
emit AuctionCreated(auctionId, msg.sender, nft, tokenId, a.endTime);
}
/// @notice 出价。token = address(0) 时用 ETH(amount 取 msg.value),否则用 ERC20(需提前 approve)
function bid(uint256 auctionId, address token, uint256 amount) external payable {
Auction storage a = auctions[auctionId];
require(a.seller != address(0), "auction not exist");
require(block.timestamp < a.endTime, "auction ended");
if (token == address(0)) {
amount = msg.value;
} else {
require(msg.value == 0, "do not send ETH");
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
}
uint256 usd = getUsdValue(token, amount);
require(usd >= a.startPriceUsd && usd > a.highestBidUsd, "bid too low");
// 先记下上一个出价者,更新状态后再退款(先改状态后转账,防重入)
address prevBidder = a.highestBidder;
address prevToken = a.bidToken;
uint256 prevAmount = a.highestBid;
a.highestBidder = msg.sender;
a.bidToken = token;
a.highestBid = amount;
a.highestBidUsd = usd;
if (prevBidder != address(0)) {
_pay(prevToken, prevBidder, prevAmount);
}
emit BidPlaced(auctionId, msg.sender, token, amount, usd);
}
/// @notice 结束拍卖:任何人都可在到期后调用。NFT 给最高出价者,钱给卖家;无人出价则 NFT 退回卖家
function endAuction(uint256 auctionId) external {
Auction storage a = auctions[auctionId];
require(a.seller != address(0), "auction not exist");
require(block.timestamp >= a.endTime, "auction not over");
require(!a.ended, "already ended");
a.ended = true;
uint256 fee;
if (a.highestBidder == address(0)) {
IERC721(a.nft).transferFrom(address(this), a.seller, a.tokenId);
} else {
IERC721(a.nft).transferFrom(address(this), a.highestBidder, a.tokenId);
// 手续费按成交价的美元价值定档,但用出价币种本身扣除(ETH 成交就扣 ETH)
fee = a.highestBid * getFeeBps(a.highestBidUsd) / 10_000;
if (fee > 0) {
_pay(a.bidToken, owner(), fee); // 手续费归平台(合约 owner)
}
_pay(a.bidToken, a.seller, a.highestBid - fee);
}
emit AuctionEnded(auctionId, a.highestBidder, a.bidToken, a.highestBid, fee);
}
/// @notice 根据成交价(美元,8 位小数)返回手续费率(基点)
function getFeeBps(uint256 usdValue) public pure returns (uint256) {
if (usdValue < TIER1_USD) return FEE_BPS_LOW;
if (usdValue < TIER2_USD) return FEE_BPS_MID;
return FEE_BPS_HIGH;
}
/// @dev 统一的付款函数:ETH 或 ERC20
function _pay(address token, address to, uint256 amount) private {
if (token == address(0)) {
(bool ok,) = to.call{value: amount}("");
require(ok, "ETH transfer failed");
} else {
IERC20(token).safeTransfer(to, amount);
}
}
/// @dev UUPS 要求:限制只有 owner 能升级
function _authorizeUpgrade(address) internal override onlyOwner {}
}
2.3.3 代理合约 ERC1967Proxy
由 @openzeppelin/hardhat-upgrades 插件提供,项目里不需要自己写,这里列出源码便于理解(原理见 2.4.1、2.4.2)。
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.6.0) (proxy/ERC1967/ERC1967Proxy.sol)
pragma solidity ^0.8.22;
import {Proxy} from "../Proxy.sol";
import {ERC1967Utils} from "./ERC1967Utils.sol";
contract ERC1967Proxy is Proxy {
error ERC1967ProxyUninitialized();
constructor(address implementation, bytes memory _data) payable {
if (!_unsafeAllowUninitialized() && _data.length == 0) {
revert ERC1967ProxyUninitialized();
}
ERC1967Utils.upgradeToAndCall(implementation, _data);
}
function _implementation() internal view virtual override returns (address) {
return ERC1967Utils.getImplementation();
}
function _unsafeAllowUninitialized() internal pure virtual returns (bool) {
return false;
}
}
2.4 代码逐段讲解
2.4.1 上架:createAuction
/// @notice 上架拍卖:NFT 先托管到本合约,需提前 approve
function createAuction(address nft, uint256 tokenId, uint256 startPriceUsd, uint256 duration)
external
returns (uint256 auctionId)
{
require(duration > 0, "duration must > 0");
IERC721(nft).transferFrom(msg.sender, address(this), tokenId);
auctionId = nextAuctionId++;
Auction storage a = auctions[auctionId];
a.seller = msg.sender;
a.nft = nft;
a.tokenId = tokenId;
a.endTime = block.timestamp + duration;
a.startPriceUsd = startPriceUsd;
emit AuctionCreated(auctionId, msg.sender, nft, tokenId, a.endTime);
}
- 前提:卖家必须先在 NFT 合约上调用
approve(拍卖合约, tokenId),否则transferFrom会因为没有授权而失败。 - 托管:
transferFrom(msg.sender, address(this), tokenId)把 NFT 从卖家转进拍卖合约。拍卖期间卖家就无法再把它转给别人,买家不用担心"拍到了却拿不到货"。 - 记录:用
storage指针a直接写入 mapping 中的结构体,auctionId从 0 开始自增。 - 单位:
startPriceUsd是 8 位小数的美元,$10 要写成1000000000;duration的单位是秒。
2.4.2 出价:bid
/// @notice 出价。token = address(0) 时用 ETH(amount 取 msg.value),否则用 ERC20(需提前 approve)
function bid(uint256 auctionId, address token, uint256 amount) external payable {
Auction storage a = auctions[auctionId];
require(a.seller != address(0), "auction not exist");
require(block.timestamp < a.endTime, "auction ended");
if (token == address(0)) {
amount = msg.value;
} else {
require(msg.value == 0, "do not send ETH");
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
}
uint256 usd = getUsdValue(token, amount);
require(usd >= a.startPriceUsd && usd > a.highestBidUsd, "bid too low");
// 先记下上一个出价者,更新状态后再退款(先改状态后转账,防重入)
address prevBidder = a.highestBidder;
address prevToken = a.bidToken;
uint256 prevAmount = a.highestBid;
a.highestBidder = msg.sender;
a.bidToken = token;
a.highestBid = amount;
a.highestBidUsd = usd;
if (prevBidder != address(0)) {
_pay(prevToken, prevBidder, prevAmount);
}
emit BidPlaced(auctionId, msg.sender, token, amount, usd);
}
拆开来看是 4 步:
- 检查:拍卖必须存在(
seller != 0),而且还没到截止时间。 - 收款:
- 用 ETH 出价时,金额以
msg.value为准,忽略amount参数,原因见 1.4.2; - 用 ERC20 出价时,用
safeTransferFrom从出价人账户扣款(需要事先approve),同时禁止附带 ETH,防止 ETH 被误转进合约后无人认领。
- 用 ETH 出价时,金额以
- 比价:通过
getUsdValue换算成美元,必须同时满足两个条件:不低于起拍价,并且严格高于当前最高价。 - 更新并退款:先把最高出价改成新的,再把钱退给上一个出价人。这就是 1.4.7 提到的 CEI 模式:即使退款时对方是合约、试图重入,状态也已经更新,重入不会得逞。
SafeERC20 的作用:有些代币(比如主网上的 USDT)的 transfer 不返回 bool,直接用 IERC20 接口调用会失败。safeTransfer 和 safeTransferFrom 兼容这两种写法。
2.4.3 结束拍卖:endAuction
/// @notice 结束拍卖:任何人都可在到期后调用。NFT 给最高出价者,钱给卖家;无人出价则 NFT 退回卖家
function endAuction(uint256 auctionId) external {
Auction storage a = auctions[auctionId];
require(a.seller != address(0), "auction not exist");
require(block.timestamp >= a.endTime, "auction not over");
require(!a.ended, "already ended");
a.ended = true;
uint256 fee;
if (a.highestBidder == address(0)) {
IERC721(a.nft).transferFrom(address(this), a.seller, a.tokenId);
} else {
IERC721(a.nft).transferFrom(address(this), a.highestBidder, a.tokenId);
// 手续费按成交价的美元价值定档,但用出价币种本身扣除(ETH 成交就扣 ETH)
fee = a.highestBid * getFeeBps(a.highestBidUsd) / 10_000;
if (fee > 0) {
_pay(a.bidToken, owner(), fee); // 手续费归平台(合约 owner)
}
_pay(a.bidToken, a.seller, a.highestBid - fee);
}
emit AuctionEnded(auctionId, a.highestBidder, a.bidToken, a.highestBid, fee);
}
/// @notice 根据成交价(美元,8 位小数)返回手续费率(基点)
function getFeeBps(uint256 usdValue) public pure returns (uint256) {
if (usdValue < TIER1_USD) return FEE_BPS_LOW;
if (usdValue < TIER2_USD) return FEE_BPS_MID;
return FEE_BPS_HIGH;
}
- 任何人都可以调用:合约没有限制必须由卖家结束,过了截止时间谁都能触发结算,避免卖家不调用导致资金一直锁在合约里。
- 先标记再转账:
a.ended = true写在所有转账之前,配合require(!a.ended),同一场拍卖无法被重复结算。 - 两种结果:没人出价时,NFT 退回卖家;有人出价时,NFT 给赢家,成交款扣掉手续费后给卖家,手续费给平台(合约 owner)。
- 统一付款:
_pay根据币种自动选择付 ETH 还是付 ERC20:
/// @dev 统一的付款函数:ETH 或 ERC20
function _pay(address token, address to, uint256 amount) private {
if (token == address(0)) {
(bool ok,) = to.call{value: amount}("");
require(ok, "ETH transfer failed");
} else {
IERC20(token).safeTransfer(to, amount);
}
}
2.4.4 价格换算:getUsdValue 与 Chainlink
/// @notice 把 amount 个 token 换算成美元(结果 8 位小数)
function getUsdValue(address token, uint256 amount) public view returns (uint256) {
AggregatorV3Interface feed = priceFeeds[token];
require(address(feed) != address(0), "no price feed");
(, int256 price,,,) = feed.latestRoundData();
require(price > 0, "invalid price");
// 喂价本身带 feed.decimals() 位小数,这里统一折算成 8 位,方便不同币种直接比较
uint8 tokenDecimals = token == address(0) ? 18 : IERC20Metadata(token).decimals();
return amount * uint256(price) * 1e8 / (10 ** tokenDecimals) / (10 ** feed.decimals());
}
不同币种的精度不一样:
| 数据 | 小数位数 |
|---|---|
| ETH | 18 |
| USDT / USDC | 6 |
| Chainlink 喂价(X/USD) | 8 |
合约把出价统一换算成 8 位小数的美元,ETH 出价和 USDT 出价才能直接比较:
美元价值 = amount × price × 1e8 / 10^(代币精度 + 喂价精度)
例如:
- 0.01 ETH × $2500.12 = $25.00,链上值是
2500120000; - 50 USDT × $0.9999 = $49.99,链上值是
4999360600。
所以起拍价也要按 8 位小数填写:$10 要填 1000000000(1 后面 9 个 0);USDT 金额按 6 位小数填写:50 USDT 要填 50000000。
Sepolia 上没有 USDT/USD 喂价,本文用同样锚定 1 美元的 USDC/USD 喂价近似换算 USDT。
2.4.5 动态手续费:getFeeBps
按成交价折合的美元分档收费,成交价越高费率越低。费率单位是基点(1 bp = 0.01%):
| 成交价 | 费率 |
|---|---|
| 低于 $1,000 | 3%(300 bp) |
| $1,000 到 $10,000 之间 | 2%(200 bp) |
| $10,000 及以上 | 1%(100 bp) |
手续费在结束拍卖时,用成交时的币种扣除:fee = 成交金额 × 费率 / 10000。
2.4.6 合约升级:initialize 与 _authorizeUpgrade
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
// 锁住实现合约本身,防止有人直接初始化实现合约
_disableInitializers();
}
/// @notice 代理合约的“构造函数”,部署时只调用一次
function initialize() public initializer {
__Ownable_init(msg.sender);
}
/// @notice 设置某币种的喂价合约,token = address(0) 表示 ETH
function setPriceFeed(address token, address feed) external onlyOwner {
priceFeeds[token] = AggregatorV3Interface(feed);
}
- 为什么不用构造函数初始化:代理合约通过
delegatecall借用实现合约的代码,而实现合约的构造函数只在部署实现合约时执行一次,写的是实现合约自己的存储,代理里什么都没有。所以初始化逻辑要放在普通函数initialize()里,由代理调用一次;initializer修饰符保证它只能执行一次。 _disableInitializers():锁住实现合约本身,防止有人直接调用实现合约的initialize()抢走 owner 权限。_authorizeUpgrade:UUPS 要求子合约实现这个函数来决定谁有权升级。这里加了onlyOwner,只有 owner 能升级。- 升级测试:单元测试里用
upgrades.upgradeProxy升级到NFTAuctionV2,验证了三件事:代理地址不变、新函数version()可以调用、旧的拍卖数据还在。
2.5 核心概念:NFT 到底是什么?
第一次在测试网上跑完拍卖流程时,我其实一直没想明白:部署者为什么要把 NFT 铸造给别人?卖家没花钱,NFT #0 是从哪来的?出价的人到底在买什么?赢家的 NFT 又在哪?
先说结论:NFT 本质上就是 NFT 合约里的一条记录,写的是"第几号归谁"。 拍卖中大家争的,就是"#0 号归谁"这条记录的所有权。赢家的 NFT 不在钱包里,而是记在 MyNFT 合约里。拍卖结束后在链上查询,这条记录是:
MyNFT 合约(0x7f25…c495)中:
ownerOf(0) = 0x83Da…d88C ← NFT #0 现在归部署者(赢家)
下面按这些疑问逐个解释。
2.5.1 NFT 合约就是一本"登记簿"
可以把 MyNFT 合约想成一本房产登记簿,每个 NFT 就是登记簿上的一行:
| 编号 tokenId | 所有人 |
|---|---|
| #0 | 0x83Da…(部署者) |
| #1 | (还没登记) |
- NFT = 登记簿里某一行的所有权。"拥有 NFT #0"的意思是登记簿上写着 #0 归你。
- 合约代码里,这本登记簿就是 ERC721 内部的一个 mapping:
tokenId => 地址,用ownerOf(tokenId)查询。 - 每个编号独一无二、不能分割,这就是 Non-Fungible(不可替代)的含义。1 ETH 和另一个 1 ETH 完全一样,但 #0 和 #1 是两个不同的东西。
常见误区:很多人以为 NFT 就是一张图片。其实图片存在别处(IPFS 或服务器),NFT 合约里只存一个指向图片的链接
tokenURI。本文的 MyNFT 为了简单没有设置图片,链上查询tokenURI(0)返回空字符串。所以 NFT #0 只是一个光秃秃的编号,但机制和带图片的 NFT 完全一样。
2.5.2 铸造(mint):在登记簿上新增一行
mint(收款人1) 的效果是:登记簿新增一行 "#0 → 收款人1"。
- 为什么是部署者来铸造? MyNFT 的
mint加了onlyOwner,只有合约的 owner(部署者)能新增登记,相当于"发行方"。 - 为什么铸给收款人1? 这是在搭测试场景。要测拍卖,就得先有一个手里有 NFT 的卖家,所以由发行方直接给收款人1 发一个,让他来当卖家。

部署者(owner)调用 MyNFT 的 mint,把 #0 铸造给收款人1,MetaMask 弹出确认窗口
2.5.3 卖家为什么没花钱?NFT #0 从哪来?
NFT #0 来自上面的铸造:部署者凭空给收款人1 发了一个,所以收款人1 一分钱没花。连铸造交易的 gas,也是发起交易的部署者付的。
现实中,卖家的 NFT 一般有两个来源:
- 自己是创作者,自己铸造的:比如艺术家把作品铸成 NFT,只花 gas 费;
- 之前从别人那里买来的。
我们的测试相当于"发行方把作品白送给了卖家",目的只是让卖家有东西可卖。
2.5.4 授权(approve):签一份"委托书"
拍卖时,NFT 要先交给拍卖合约保管,防止卖家一边拍卖、一边偷偷把 NFT 转给别人。但拍卖合约不能随便拿走别人的东西,必须得到主人的许可:
approve(拍卖合约, 0):收款人1 签了一张授权书,"允许拍卖合约转走我的 #0";createAuction(...):拍卖合约拿着这张授权书,把 #0 从收款人1 名下转到自己名下保管。
这就像把房子交给拍卖行之前,先签一份委托书。ERC20 也一样:用 USDT 出价前,要先 approve 拍卖合约可以扣多少 USDT。

收款人1 调用 approve:MetaMask 明确提示"你正在授权他人转走你的 NFT #0",被授权方(Spender)就是拍卖合约 0xD264…7D5D

收款人1 调用 createAuction 上架,MetaMask 显示 "You send #0":NFT 将从卖家转进拍卖合约托管
2.5.5 出价的人买的是什么?
买的就是 "NFT #0 在 MyNFT 登记簿上的所有权"。
- 收款人2 出 0.01 ETH,约 $25;部署者出 50 USDT,约 $49.99;
- 拍卖合约用 Chainlink 喂价把两种币都换算成美元再比较,部署者出得多,赢了;
- 输了的收款人2,0.01 ETH 被原样退回。

收款人2 用 0.01 ETH 出价:Blockscout 上 ETH 金额填在 Send native ETH 输入框(单位 wei),MetaMask 显示 Amount 0.01 SepoliaETH

部署者用 50 USDT 出价的交易详情:同一笔交易里,50 USDT 转进拍卖合约,0.01 ETH 自动退回给收款人2
2.5.6 拍卖结束后发生了什么?
拍卖合约在同一笔交易里做完三件事:
| 动作 | 登记簿 / 余额的变化 |
|---|---|
| 转移 NFT | MyNFT 登记簿:#0 从"拍卖合约"改成"部署者" |
| 付款给卖家 | 48.5 USDT 转给收款人1 |
| 收手续费 | 1.5 USDT 转给平台(也是部署者) |

拍卖合约的 Token Transfers,从下往上就是完整的资金流程:ETH 出价 → USDT 出价并退回 ETH → 结束拍卖,48.5 USDT 给卖家、1.5 USDT 给平台
2.5.7 赢家的 NFT 在哪?怎么看到?
NFT 永远存在 NFT 合约里,钱包只是一个"查看器"。 USDT 也是一样:USDT 记在 USDT 合约里,MetaMask 只是去读你的余额。
想亲眼看到这个 NFT,有三种方法:
- Etherscan:打开赢家的地址页,点 NFT Transfers 标签,能看到 "MyNFT #0" 转进来的记录;
- 合约查询:在 MyNFT 的 Read Contract 里调用
ownerOf(0),返回的是赢家地址; - MetaMask:切换到赢家账户 → NFTs 标签 → Import NFT,填合约地址
0x7f25CceE06E0e6E4A43bB2D05Aa748707343c495、Token ID0,就会显示出来。因为没有设置图片,显示的是一个默认占位图标。

拍卖合约的 NFT Transfers:上架时 #0 从卖家转入(IN),结束拍卖时转给赢家(OUT)。第 2 行的 Method 显示为 "Redeem Dele…" 是 Etherscan 识别错误,实际调用的是 createAuction
2.5.8 一句话总结
发行方给卖家登记了一个编号 #0(铸造)→ 卖家委托拍卖合约保管(授权 + 上架)→ 两人用不同的币竞价,按美元比高低 → 拍卖合约把 #0 的登记改到赢家名下,把钱分给卖家和平台。
2.6 测试、部署与源码验证
2.6.1 单元测试与集成测试
16 个测试用例全部通过,行覆盖率 100%,分支覆盖率 89%。
const { expect } = require("chai");
const { ethers, upgrades } = require("hardhat");
const { time, loadFixture } = require("@nomicfoundation/hardhat-network-helpers");
const ETH = ethers.ZeroAddress; // address(0) 代表 ETH
const usd = (n) => BigInt(n) * 10n ** 8n; // 美元金额,8 位小数
const usdc = (n) => BigInt(n) * 10n ** 6n; // MockERC20 为 6 位小数
const DAY = 24 * 60 * 60;
describe("NFTAuction", function () {
// 公共部署流程:NFT + ERC20 + 两个喂价 + 拍卖代理,并给卖家铸造一个 NFT 上架
async function deployFixture() {
const [owner, seller, alice, bob] = await ethers.getSigners();
const nft = await ethers.deployContract("MyNFT");
const token = await ethers.deployContract("MockERC20");
const ethFeed = await ethers.deployContract("MockV3Aggregator", [8, usd(2000)]); // 1 ETH = 2000 USD
const tokenFeed = await ethers.deployContract("MockV3Aggregator", [8, usd(1)]); // 1 mUSDC = 1 USD
const Auction = await ethers.getContractFactory("NFTAuction");
const auction = await upgrades.deployProxy(Auction, [], { kind: "uups" });
await auction.setPriceFeed(ETH, ethFeed.target);
await auction.setPriceFeed(token.target, tokenFeed.target);
await nft.mint(seller.address); // tokenId = 0
await nft.connect(seller).approve(auction.target, 0);
await auction.connect(seller).createAuction(nft.target, 0, usd(100), DAY); // auctionId = 0
for (const u of [alice, bob]) {
await token.mint(u.address, usdc(100000));
await token.connect(u).approve(auction.target, ethers.MaxUint256);
}
return { nft, token, auction, owner, seller, alice, bob };
}
describe("NFT", function () {
it("只有 owner 能铸造,持有人可转移", async function () {
const { nft, seller, alice } = await loadFixture(deployFixture);
await expect(nft.connect(alice).mint(alice.address)).to.be.revertedWithCustomError(nft, "OwnableUnauthorizedAccount");
await nft.mint(alice.address); // tokenId = 1
await nft.connect(alice).transferFrom(alice.address, seller.address, 1);
expect(await nft.ownerOf(1)).to.equal(seller.address);
});
});
describe("价格换算", function () {
it("ETH 和 ERC20 都能换算成美元", async function () {
const { auction, token } = await loadFixture(deployFixture);
expect(await auction.getUsdValue(ETH, ethers.parseEther("1.5"))).to.equal(usd(3000));
expect(await auction.getUsdValue(token.target, usdc(250))).to.equal(usd(250));
});
it("未配置喂价的币种会 revert", async function () {
const { auction, alice } = await loadFixture(deployFixture);
await expect(auction.getUsdValue(alice.address, 1)).to.be.revertedWith("no price feed");
});
});
describe("拍卖流程", function () {
it("创建拍卖后 NFT 托管在合约中", async function () {
const { nft, auction, seller } = await loadFixture(deployFixture);
expect(await nft.ownerOf(0)).to.equal(auction.target);
expect((await auction.auctions(0)).seller).to.equal(seller.address);
});
it("低于起拍价或不高于当前最高价的出价被拒绝", async function () {
const { auction, alice, bob, token } = await loadFixture(deployFixture);
await expect(auction.connect(alice).bid(0, token.target, usdc(50))).to.be.revertedWith("bid too low");
await auction.connect(alice).bid(0, token.target, usdc(200));
// 0.1 ETH = 200 USD,与当前最高价相等,不算更高
await expect(auction.connect(bob).bid(0, ETH, 0, { value: ethers.parseEther("0.1") })).to.be.revertedWith("bid too low");
});
it("ETH 与 ERC20 混合出价,按美元比较,被超越者自动退款", async function () {
const { auction, token, alice, bob } = await loadFixture(deployFixture);
await auction.connect(alice).bid(0, token.target, usdc(200)); // 200 USD
const before = await token.balanceOf(alice.address);
// bob 出 0.2 ETH = 400 USD,超过 alice
await expect(auction.connect(bob).bid(0, ETH, 0, { value: ethers.parseEther("0.2") }))
.to.emit(auction, "BidPlaced")
.withArgs(0, bob.address, ETH, ethers.parseEther("0.2"), usd(400));
expect(await token.balanceOf(alice.address)).to.equal(before + usdc(200));
// alice 再用 ERC20 出 500 USD 超过 bob,bob 的 ETH 被退回
await expect(auction.connect(alice).bid(0, token.target, usdc(500))).to.changeEtherBalance(bob, ethers.parseEther("0.2"));
const a = await auction.auctions(0);
expect(a.highestBidder).to.equal(alice.address);
expect(a.highestBidUsd).to.equal(usd(500));
});
it("用 ERC20 出价时不能附带 ETH", async function () {
const { auction, token, alice } = await loadFixture(deployFixture);
await expect(auction.connect(alice).bid(0, token.target, usdc(200), { value: 1 })).to.be.revertedWith("do not send ETH");
});
it("到期前不能结束,到期后不能出价", async function () {
const { auction, alice } = await loadFixture(deployFixture);
await expect(auction.endAuction(0)).to.be.revertedWith("auction not over");
await time.increase(DAY);
await expect(auction.connect(alice).bid(0, ETH, 0, { value: ethers.parseEther("1") })).to.be.revertedWith("auction ended");
});
it("结束拍卖(ETH 成交):NFT 给赢家,扣 2% 手续费后 ETH 给卖家,不可重复结束", async function () {
const { nft, auction, owner, seller, bob } = await loadFixture(deployFixture);
await auction.connect(bob).bid(0, ETH, 0, { value: ethers.parseEther("1") }); // $2,000 落在 2% 档
await time.increase(DAY);
// 由 bob 调用 endAuction,避免 owner 自己付 gas 干扰余额断言
await expect(auction.connect(bob).endAuction(0)).to.changeEtherBalances(
[seller, owner],
[ethers.parseEther("0.98"), ethers.parseEther("0.02")]
);
expect(await nft.ownerOf(0)).to.equal(bob.address);
await expect(auction.endAuction(0)).to.be.revertedWith("already ended");
});
it("结束拍卖(ERC20 成交):扣 3% 手续费后代币给卖家", async function () {
const { nft, token, auction, owner, seller, alice } = await loadFixture(deployFixture);
await auction.connect(alice).bid(0, token.target, usdc(300)); // $300 落在 3% 档
await time.increase(DAY);
await expect(auction.endAuction(0))
.to.emit(auction, "AuctionEnded")
.withArgs(0, alice.address, token.target, usdc(300), usdc(9));
expect(await nft.ownerOf(0)).to.equal(alice.address);
expect(await token.balanceOf(seller.address)).to.equal(usdc(291));
expect(await token.balanceOf(owner.address)).to.equal(usdc(9));
});
it("高价成交(>= $10,000)只收 1% 手续费", async function () {
const { auction, owner, seller, bob } = await loadFixture(deployFixture);
await auction.connect(bob).bid(0, ETH, 0, { value: ethers.parseEther("6") }); // $12,000
await time.increase(DAY);
await expect(auction.connect(bob).endAuction(0)).to.changeEtherBalances(
[seller, owner],
[ethers.parseEther("5.94"), ethers.parseEther("0.06")]
);
});
it("无人出价时 NFT 退回卖家", async function () {
const { nft, auction, seller } = await loadFixture(deployFixture);
await time.increase(DAY);
await auction.endAuction(0);
expect(await nft.ownerOf(0)).to.equal(seller.address);
});
it("不存在的拍卖会 revert", async function () {
const { auction, alice } = await loadFixture(deployFixture);
await expect(auction.connect(alice).bid(99, ETH, 0, { value: 1 })).to.be.revertedWith("auction not exist");
await expect(auction.endAuction(99)).to.be.revertedWith("auction not exist");
});
});
describe("动态手续费", function () {
it("按成交价分档,边界值归入更低费率档", async function () {
const { auction } = await loadFixture(deployFixture);
expect(await auction.getFeeBps(usd(999))).to.equal(300);
expect(await auction.getFeeBps(usd(1000))).to.equal(200); // 恰好 $1,000 进入 2% 档
expect(await auction.getFeeBps(usd(9999))).to.equal(200);
expect(await auction.getFeeBps(usd(10000))).to.equal(100); // 恰好 $10,000 进入 1% 档
});
});
describe("UUPS 升级", function () {
it("升级到 V2 后数据保留、新函数可用", async function () {
const { auction, alice } = await loadFixture(deployFixture);
await auction.connect(alice).bid(0, ETH, 0, { value: ethers.parseEther("1") });
const V2 = await ethers.getContractFactory("NFTAuctionV2");
const upgraded = await upgrades.upgradeProxy(auction.target, V2);
expect(upgraded.target).to.equal(auction.target); // 代理地址不变
expect(await upgraded.version()).to.equal("v2");
expect((await upgraded.auctions(0)).highestBidder).to.equal(alice.address); // 存储数据还在
});
it("非 owner 不能升级、不能设置喂价", async function () {
const { auction, alice } = await loadFixture(deployFixture);
const V2 = await ethers.getContractFactory("NFTAuctionV2", alice);
await expect(upgrades.upgradeProxy(auction.target, V2)).to.be.revertedWithCustomError(auction, "OwnableUnauthorizedAccount");
await expect(auction.connect(alice).setPriceFeed(ETH, alice.address)).to.be.revertedWithCustomError(auction, "OwnableUnauthorizedAccount");
});
});
});
2.6.2 部署脚本
部署 MyNFT 和拍卖合约(实现 + 代理),配置 ETH、USDT 的喂价,最后自检价格换算是否正确。
const { ethers, upgrades } = require("hardhat");
// Sepolia 上的 Chainlink 喂价地址(已在链上核实 description()),来自 https://docs.chain.link/data-feeds/price-feeds/addresses
const SEPOLIA_ETH_USD = "0x694AA1769357215DE4FAC081bf1f309aDC325306";
const SEPOLIA_USDC_USD = "0xA2F78ab2355fe2f984D808B5CeE7FD0A93D5270E";
// Sepolia 上的公共测试 USDT(6 位小数,非 Tether 官方)
const SEPOLIA_USDT = "0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0";
async function main() {
const [deployer] = await ethers.getSigners();
console.log("部署账户:", deployer.address);
const nft = await ethers.deployContract("MyNFT");
await nft.waitForDeployment();
console.log("MyNFT:", nft.target);
// deployProxy 会部署实现合约 + ERC1967 代理,并调用 initialize()
const auction = await upgrades.deployProxy(await ethers.getContractFactory("NFTAuction"), [], { kind: "uups" });
await auction.waitForDeployment();
console.log("NFTAuction 代理:", auction.target);
console.log("NFTAuction 实现:", await upgrades.erc1967.getImplementationAddress(auction.target));
await (await auction.setPriceFeed(ethers.ZeroAddress, SEPOLIA_ETH_USD)).wait();
console.log("已设置 ETH/USD 喂价");
// Sepolia 没有 USDT/USD 喂价,用同为 1 美元锚定的 USDC/USD 近似
await (await auction.setPriceFeed(SEPOLIA_USDT, SEPOLIA_USDC_USD)).wait();
console.log("已设置 USDT 喂价(使用 USDC/USD)");
// 自检:读一次换算结果,确认喂价配置正确(结果为 8 位小数美元)
const ethUsd = await auction.getUsdValue(ethers.ZeroAddress, ethers.parseEther("1"));
const usdtUsd = await auction.getUsdValue(SEPOLIA_USDT, ethers.parseUnits("100", 6));
console.log("自检 1 ETH =", ethers.formatUnits(ethUsd, 8), "USD");
console.log("自检 100 USDT =", ethers.formatUnits(usdtUsd, 8), "USD");
}
main().catch((e) => {
console.error(e);
process.exitCode = 1;
});
2.6.3 源码验证
部署后用 Hardhat 编译产物(artifacts/build-info/*.json 中的 input 字段)导出 Standard JSON Input,在 Etherscan 上分别验证 MyNFT 和 NFTAuction 实现合约。代理合约是 OpenZeppelin 标准的 ERC1967Proxy,Etherscan 会自动匹配验证,并识别出它指向的实现合约。也可以在 .env 配置 ETHERSCAN_API_KEY 后,用一条命令完成:
npx hardhat verify --network sepolia <代理合约地址>

用 Hardhat 编译产物导出的 Standard JSON Input 验证 MyNFT

用同一个 JSON 文件验证 NFTAuction 实现合约;代理合约由 Etherscan 自动识别,不需要单独上传源码
2.7 Sepolia 测试网实测
部署地址
| 合约 | 地址 |
|---|---|
| MyNFT | 0x7f25CceE06E0e6E4A43bB2D05Aa748707343c495 |
| NFTAuction 代理(用户交互地址) | 0xD26429D7E3CeeA027A19d0A37F06429026257D5D |
| NFTAuction 实现合约 | 0x86aAC6602a715819E8393B8dCB5B952595627784 |
测试步骤
| # | 操作 | 发起账户 | 结果 | 交易 |
|---|---|---|---|---|
| 1 | mint(收款人1):铸造 NFT #0 | 部署者 | ✅ | |
| 2 | approve(拍卖代理, 0) | 收款人1 | ✅ | |
| 3 | createAuction(MyNFT, 0, $1, 900 秒) | 收款人1 | ✅ NFT 转入合约托管 | |
| 4 | bid:0.01 ETH,折合 $25.00 | 收款人2 | ✅ 成为最高出价 | |
| 5 | bid:5 USDT,折合 $5.00 | 部署者 | ❌ bid too low,符合预期 | |
| 6 | bid:50 USDT,折合 $49.99 | 部署者 | ✅ 成为最高出价,0.01 ETH 自动退给收款人2 | |
| 7 | endAuction(0) | 部署者 | ✅ NFT 给赢家,48.5 USDT 给卖家,1.5 USDT 给平台 |
每一步的 MetaMask 和区块浏览器截图见 2.5 节。
拍卖合约在整个过程中充当托管方:NFT 和出价资金都先转进合约,拍卖结束后再统一结算,买卖双方不需要互相信任。
卖家(收款人1) 拍卖合约(托管) ETH 出价人(收款人2) 部署者(USDT 出价人 / 平台)
│ │ │ │
①上架 │── NFT #0 ───────────▶│ │ │
│ │◀──────── 0.01 ETH ────────│ ②ETH 出价($25.00) │
│ │◀───────────────────────── 50 USDT ────────────────────│ ③USDT 出价($49.99)
│ │──────── 0.01 ETH ────────▶│ 被超过,自动退款 │
│ │ │ │
④结束 │◀── 48.5 USDT ─────────│ │ │
│ │──────────────────────── NFT #0 ──────────────────────▶│ 赢得 NFT
│ │──────────────────────── 1.5 USDT ────────────────────▶│ 平台手续费
各角色的最终结果:
| 角色 | 转出 | 转入 | 结果 |
|---|---|---|---|
| 卖家(收款人1) | NFT #0 | 48.5 USDT | 卖出 NFT,拿到 50 USDT 扣除 3% 手续费后的部分 |
| ETH 出价人(收款人2) | 0.01 ETH | 0.01 ETH(退款) | 竞价失败,全额退回,只花了 gas |
| 部署者(USDT 出价人) | 50 USDT | NFT #0 | 以 50 USDT 赢得 NFT |
| 部署者(平台) | — | 1.5 USDT | 收取 3% 手续费 |
各角色的地址页(Etherscan → Token Transfers),只看最上面和本次拍卖相关的几行:

卖家(收款人1):End Auction 收到 48.5 USDT 成交款

ETH 出价人(收款人2):Bid 转出 0.01 ETH,随后在 USDT 出价的交易中收到 0.01 ETH 退款

部署者(USDT 出价人 / 平台):Bid 转出 50 USDT,End Auction 收到 1.5 USDT 手续费
2.8 踩坑记录与改进方向
2.8.1 测试网实操中踩过的坑
| 现象 | 原因 | 解决 |
|---|---|---|
approve 报错 ERC721NonexistentToken | NFT 还没铸造,tokenId 不存在 | 先用 owner 账户调用 mint |
出价报 bid too low | 金额少填了一个 0:5000000 只是 5 USDT | USDT 是 6 位小数,50 USDT 要填 50000000 |
| 起拍价变成天文数字 | 在 Blockscout 上误点了输入框旁边的 ×10¹⁸ | 只有 ETH 金额框需要 ×10¹⁸,其他参数不要点 |
Network sepolia doesn't exist | 在错误的目录执行命令,没读到 .env | 进入项目目录再执行;输出 injected env (2) 才说明读到了 |
| 测试网上没法"快进时间" | 只能等区块时间真正走到截止时间 | 测试时把 duration 设短一点,比如 900(15 分钟) |
| Etherscan 页面报 500 | Etherscan 服务端故障 | 换 Blockscout 浏览器,或者等 Etherscan 恢复 |
小技巧:在 Etherscan / Blockscout 上调用写函数前,先点 Simulate(或用 cast call 模拟)。它会在本地预执行一次,不花 gas,能提前发现参数错误。
2.8.2 安全性与改进方向
| 问题 | 说明 | 改进方式 |
|---|---|---|
| 退款采用"推"模式 | 出价被超过时直接把钱转回给上一个出价人。如果上一个出价人是拒收 ETH 的合约,之后所有出价都会失败,拍卖被卡死 | 改为"拉"模式:只记录待退款金额,由出价人自己调用 withdraw 领取 |
| 没有检查喂价是否过期 | latestRoundData 返回的价格可能很久没更新 | 检查返回值里的 updatedAt,超过一定时间就拒绝出价 |
| 手续费直接转给 owner | owner 如果是拒收 ETH 的合约,endAuction 会一直失败,NFT 卡在合约里 | 手续费先记在合约里,由 owner 自行提取 |
| USDT 使用 USDC/USD 喂价 | 测试网没有 USDT/USD 喂价,用锚定同样 1 美元的 USDC 近似 | 主网上使用对应币种的官方喂价 |
| 存储没有打包 | Auction 结构体占 10 个存储槽,每次出价的写入成本较高 | 字段重排、用 uint64 存时间戳、用 uint96 存美元金额,可以压缩到 6 个槽 |
| require 字符串 | 部署体积和 revert 开销都更大 | 改用自定义错误 error BidTooLow(); |
三、基础练习:投票合约与算法题
最后是一组基础练习:一个投票合约,加上 5 道经典算法题。题目本身不难,难点在于 Solidity 和普通编程语言的差异:mapping 不能遍历、uint 不能为负、每一步运算都要花 gas。
3.1 需求说明
| # | 题目 | 要求 | 示例 |
|---|---|---|---|
| 1 | 投票合约 Voting | 用 mapping 存储候选人得票数;vote 投票、getVotes 查询、resetVotes 重置所有票数 | 给 a 投两票,getVotes("a") 返回 2;重置后返回 0 |
| 2 | 反转字符串 | 反转一个字符串 | "abcde" → "edcba" |
| 3 | 整数转罗马数字 | 1994 → "MCMXCIV" | |
| 4 | 罗马数字转整数 | "MCMXCIV" → 1994 | |
| 5 | 合并两个有序数组 | 把两个升序数组合并成一个升序数组 | [1,3,5] + [2,4] → [1,2,3,4,5] |
| 6 | 二分查找 | 在升序数组中查找目标值,找到返回下标,找不到返回 -1 | 在 [1,2,3,4,5] 中找 5 → 4 |
3.2 解题思路总览
| 题目 | 思路 | 时间复杂度 | Solidity 特有的注意点 |
|---|---|---|---|
| 投票合约 | mapping 计票,另用数组记录出现过的候选人 | 投票 O(1),重置 O(n) | mapping 不能遍历,重置时必须另外记录有哪些 key |
| 反转字符串 | 转成 bytes,双指针首尾交换 | O(n) | string 不能按下标访问,要先转成 bytes;按字节反转只适用于 ASCII |
| 整数转罗马数字 | 贪心:从大到小依次减去 1000、900、500…1 | O(1)(最多 15 个字符) | 字符串拼接要用 abi.encodePacked 或 string.concat |
| 罗马数字转整数 | 从右往左遍历,比右边小就减,否则加 | O(n) | uint 不能为负,从左往右读 "IV" 时先减 1 会下溢 |
| 合并有序数组 | 双指针,每次取较小的元素 | O(m + n) | 结果数组要预先用 new uint256[](len) 分配好长度 |
| 二分查找 | 左闭右开区间 [left, right) | O(log n) | 用 right = mid 而不是 mid - 1,避免 mid = 0 时下溢 |
3.3 完整代码
3.3.1 投票合约 Voting
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title 简单投票合约
contract Voting {
// 候选人 => 得票数
mapping(string => uint256) public votes;
// mapping 无法遍历,所以额外记录出现过的候选人,供 resetVotes 使用
string[] private candidates;
/// @notice 给候选人投一票
function vote(string calldata candidate) external {
// 第一次出现的候选人加入列表
if (votes[candidate] == 0) {
candidates.push(candidate);
}
votes[candidate] += 1;
}
/// @notice 查询候选人得票数
function getVotes(string calldata candidate) external view returns (uint256) {
return votes[candidate];
}
/// @notice 重置所有候选人的得票数
function resetVotes() external {
for (uint256 i = 0; i < candidates.length; i++) {
delete votes[candidates[i]];
}
delete candidates;
}
}
3.3.2 反转字符串
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title 反转字符串
contract ReverseString {
/// @notice "abcde" => "edcba"(按字节反转,仅适用于 ASCII 字符)
function reverse(string calldata s) external pure returns (string memory) {
bytes memory b = bytes(s);
// 双指针首尾交换
uint256 n = b.length;
for (uint256 i = 0; i < n / 2; i++) {
(b[i], b[n - 1 - i]) = (b[n - 1 - i], b[i]);
}
return string(b);
}
}
3.3.3 整数转罗马数字
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title 整数转罗马数字(1 ~ 3999)
contract IntToRoman {
function intToRoman(uint256 num) external pure returns (string memory) {
require(num > 0 && num < 4000, "out of range");
// 从大到小排列,包含 900/400/90/40/9/4 这类特殊组合,直接贪心即可
uint16[13] memory values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
string[13] memory symbols = ["M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"];
bytes memory result;
for (uint256 i = 0; i < 13; i++) {
while (num >= values[i]) {
result = abi.encodePacked(result, symbols[i]);
num -= values[i];
}
}
return string(result);
}
}
3.3.4 罗马数字转整数
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title 罗马数字转整数
contract RomanToInt {
function romanToInt(string calldata s) external pure returns (uint256) {
bytes memory b = bytes(s);
uint256 total = 0;
uint256 prev = 0;
// 从右往左遍历:比右边小就减(如 IV 中的 I),否则加。
// 不从左往右是因为 uint 不能为负,"IV" 先减 1 会直接下溢 revert。
for (uint256 i = b.length; i > 0; i--) {
uint256 cur = _value(b[i - 1]);
if (cur < prev) {
total -= cur;
} else {
total += cur;
}
prev = cur;
}
return total;
}
function _value(bytes1 c) private pure returns (uint256) {
if (c == "I") return 1;
if (c == "V") return 5;
if (c == "X") return 10;
if (c == "L") return 50;
if (c == "C") return 100;
if (c == "D") return 500;
if (c == "M") return 1000;
revert("invalid char");
}
}
3.3.5 合并两个有序数组
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title 合并两个有序数组
contract MergeSortedArray {
/// @notice [1,3,5] + [2,4] => [1,2,3,4,5]
function merge(uint256[] calldata a, uint256[] calldata b) external pure returns (uint256[] memory) {
uint256[] memory result = new uint256[](a.length + b.length);
uint256 i = 0;
uint256 j = 0;
uint256 k = 0;
// 双指针:每次取两个数组当前较小的元素
while (i < a.length && j < b.length) {
result[k++] = a[i] <= b[j] ? a[i++] : b[j++];
}
// 把剩余部分直接拷过去
while (i < a.length) result[k++] = a[i++];
while (j < b.length) result[k++] = b[j++];
return result;
}
}
3.3.6 二分查找
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title 二分查找
contract BinarySearch {
/// @notice 在升序数组中查找 target,找到返回下标,找不到返回 -1
function search(uint256[] calldata nums, uint256 target) external pure returns (int256) {
// 使用左闭右开区间 [left, right),避免 right = mid - 1 在 mid = 0 时下溢
uint256 left = 0;
uint256 right = nums.length;
while (left < right) {
uint256 mid = left + (right - left) / 2;
if (nums[mid] == target) return int256(mid);
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid;
}
}
return -1;
}
}
3.4 代码逐段讲解
3.4.1 投票合约:mapping 不能遍历
mapping 只能按 key 查询,无法列出所有 key。所以 resetVotes 不知道该清空哪些候选人,只能在 vote 时把第一次出现的候选人记进 candidates 数组,重置时遍历这个数组逐个 delete。
问题在于:候选人越多,重置越贵,而且数组本身也会越来越大。候选人足够多时,resetVotes 甚至可能超过区块的 gas 上限,永远无法执行成功。3.5.1 给出了 O(1) 的解法。
3.4.2 反转字符串:string 与 bytes
Solidity 的 string 不支持下标访问和长度查询,必须先转成 bytes。bytes(s) 把 calldata 中的字符串拷贝到 memory,然后用双指针首尾交换。
注意:这是按字节反转。中文等多字节 UTF-8 字符会被拆散,所以只适用于 ASCII 字符串。
3.4.3 整数转罗马数字:贪心
把 13 个"面值"(包括 900、400、90、40、9、4 这些特殊组合)从大到小排好,每次尽量减去最大的面值,同时拼上对应的符号。因为特殊组合已经被当作独立的面值,所以不需要额外的判断逻辑。
3.4.4 罗马数字转整数:为什么从右往左
规则是"小的数字写在大的左边时表示减法",比如 IV = 5 - 1。很多语言的标准写法是从左往右,遇到"比右边小"就减。但在 Solidity 里,total 是 uint256,读到 "IV" 的 I 时 total = 0 - 1,会直接下溢 revert。
从右往左就没有这个问题:先加上较大的 V(total = 5),再减去较小的 I,total 永远不会小于 0。
3.4.5 合并两个有序数组:预分配长度
memory 数组不能 push,必须在创建时确定长度:new uint256[](a.length + b.length)。然后用两个指针分别指向两个数组,每次把较小的元素写入结果,最后把剩余部分直接拷贝过去。
3.4.6 二分查找:左闭右开防下溢
常见写法是闭区间 [left, right],未命中时 right = mid - 1。但 right 是 uint256,当 mid = 0 时 mid - 1 会下溢。改用左闭右开区间 [left, right):初始 right = nums.length,未命中时 right = mid,就不会出现减法。
mid = left + (right - left) / 2 是防止 left + right 溢出的经典写法。
3.5 Gas 优化
链上每一次运算、每一次存储读写都要付费,所以 Solidity 的"好代码"除了正确,还要省 gas。下面是每道题的优化版本。
常用的优化手段
| 手段 | 原理 |
|---|---|
| 用 O(1) 操作代替循环 | 存储写入是最贵的操作(新写一个槽约 2 万 gas),循环删除的成本随数据量线性增长 |
unchecked { } | Solidity 0.8 默认检查每次运算是否溢出;能证明不会溢出时关掉检查 |
直接读 calldata | 避免把参数拷贝到 memory |
| 预分配内存 | 避免反复 abi.encodePacked 导致的内存重新分配 |
| 内联汇编 | 去掉数组越界检查等额外开销,但可读性差,只在热点代码中使用 |
3.5.1 投票合约:用"轮次"实现 O(1) 重置
不再逐个删除票数,而是给票数加一个"轮次"维度:重置时只把轮次加 1,旧轮次的数据仍在链上,但再也读不到,相当于全部清零。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title 简单投票合约
/// @notice 不逐个删除票数,而是给票数加一个“轮次”维度:重置 = 轮次 + 1,
/// 旧轮次的数据仍在链上但再也读不到,相当于全部清零。
/// 这样 resetVotes 的 gas 是固定的,不随候选人数量增长,也不需要额外存候选人列表。
contract Voting {
uint256 public round;
// 轮次 => 候选人 => 得票数
mapping(uint256 => mapping(string => uint256)) private _votes;
/// @notice 给候选人投一票
function vote(string calldata candidate) external {
// 票数不可能达到 2^256,省掉溢出检查
unchecked {
++_votes[round][candidate];
}
}
/// @notice 查询候选人在当前轮次的得票数
function getVotes(string calldata candidate) external view returns (uint256) {
return _votes[round][candidate];
}
/// @notice 重置所有候选人的得票数:只改一个存储槽
function resetVotes() external {
unchecked {
++round;
}
}
}
3.5.2 反转字符串:内联汇编直接读 calldata
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title 反转字符串
contract ReverseString {
/// @notice "abcde" => "edcba"(按字节反转,仅适用于 ASCII 字符)
function reverse(string calldata s) external pure returns (string memory r) {
uint256 n = bytes(s).length;
r = new string(n);
// 直接从 calldata 逐字节读、写到结果的对称位置:
// 省掉“先把 calldata 拷进 memory 再两两交换”的拷贝开销,以及 Solidity 数组下标越界检查
assembly {
let dst := add(r, 0x20) // 跳过长度字段,指向数据区
let last := sub(n, 1)
for { let i := 0 } lt(i, n) { i := add(i, 1) } {
// calldataload 读 32 字节,byte(0, ...) 取最高位那一个字节
mstore8(add(dst, sub(last, i)), byte(0, calldataload(add(s.offset, i))))
}
}
}
}
3.5.3 整数转罗马数字:预分配缓冲区,逐位写入
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title 整数转罗马数字(1 ~ 3999)
contract IntToRoman {
function intToRoman(uint256 num) external pure returns (string memory) {
require(num > 0 && num < 4000, "out of range");
// 结果最长 15 个字符(3888 = MMMDCCCLXXXVIII),一次性分配好缓冲区,
// 避免原来每拼一个字符就 abi.encodePacked 重新分配一次内存
bytes memory buf = new bytes(15);
uint256 len;
len = _digit(buf, len, num / 1000, "M", "?", "?"); // 千位最大是 3,用不到 5 和 10 的符号
len = _digit(buf, len, (num / 100) % 10, "C", "D", "M");
len = _digit(buf, len, (num / 10) % 10, "X", "L", "C");
len = _digit(buf, len, num % 10, "I", "V", "X");
// 把长度字段改成实际长度(只会变短,不会越界)
assembly {
mstore(buf, len)
}
return string(buf);
}
/// @dev 按罗马规则写一位十进制数 d:one/five/ten 分别是该位 1、5、10 对应的符号
/// 例:个位 one=I five=V ten=X,d=4 写 "IV",d=9 写 "IX",d=7 写 "VII"
function _digit(bytes memory buf, uint256 len, uint256 d, bytes1 one, bytes1 five, bytes1 ten)
private
pure
returns (uint256)
{
// len 最大 15、d 最大 9,不会溢出
unchecked {
if (d == 9) {
buf[len++] = one;
buf[len++] = ten;
} else if (d == 4) {
buf[len++] = one;
buf[len++] = five;
} else {
if (d >= 5) {
buf[len++] = five;
d -= 5;
}
for (; d > 0; --d) {
buf[len++] = one;
}
}
}
return len;
}
}
3.5.4 罗马数字转整数:位表查值
把 7 个字母的值压缩进一个 uint256 常量,用位移查表,代替 7 个 if 判断。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title 罗马数字转整数
contract RomanToInt {
// 查表:把 7 个字母的值压进一个 uint256 常量(编译期折叠,运行时零存储读取)。
// 下标 = 字符 - 'C'(67),每个值占 10 bit(最大 1000 < 1024),共 22 个槽 = 220 bit。
// C=0 D=1 I=6 L=9 M=10 V=19 X=21,其余位置为 0 表示非法字符。
uint256 private constant TABLE =
(100 << 0) | (500 << 10) | (1 << 60) | (50 << 90) | (1000 << 100) | (5 << 190) | (10 << 210);
function romanToInt(string calldata s) external pure returns (uint256 total) {
// 直接读 calldata,不拷贝到 memory
bytes calldata b = bytes(s);
uint256 prev;
// 从右往左:比右边小就减(如 IV 中的 I),否则加。
// unchecked 安全性:每段连续的“减”都紧跟在一次更大值的“加”之后,
// 而罗马数字相邻量级至少差 2 倍,比它小的所有值加起来也小于它,所以 total 永不为负。
unchecked {
for (uint256 i = b.length; i > 0; --i) {
uint256 c = uint8(b[i - 1]);
uint256 cur = (c >= 67 && c <= 88) ? (TABLE >> ((c - 67) * 10)) & 0x3FF : 0;
require(cur != 0, "invalid char");
if (cur < prev) {
total -= cur;
} else {
total += cur;
}
prev = cur;
}
}
}
}
3.5.5 合并两个有序数组:unchecked 下标
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title 合并两个有序数组
contract MergeSortedArray {
/// @notice [1,3,5] + [2,4] => [1,2,3,4,5]
function merge(uint256[] calldata a, uint256[] calldata b) external pure returns (uint256[] memory result) {
uint256 la = a.length;
uint256 lb = b.length;
result = new uint256[](la + lb);
uint256 i;
uint256 j;
uint256 k;
// 下标都被 la/lb 限制住,不可能溢出,去掉 checked 运算
unchecked {
// 双指针:每次取两个数组当前较小的元素
while (i < la && j < lb) {
uint256 x = a[i];
uint256 y = b[j];
if (x <= y) {
result[k++] = x;
++i;
} else {
result[k++] = y;
++j;
}
}
// 把剩余部分直接拷过去
while (i < la) result[k++] = a[i++];
while (j < lb) result[k++] = b[j++];
}
}
}
3.5.6 二分查找:unchecked + 位移
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title 二分查找
contract BinarySearch {
/// @notice 在升序数组中查找 target,找到返回下标,找不到返回 -1
function search(uint256[] calldata nums, uint256 target) external pure returns (int256) {
// 使用左闭右开区间 [left, right),避免 right = mid - 1 在 mid = 0 时下溢
uint256 left;
uint256 right = nums.length;
// left/right 都不超过数组长度,(left + right) 不会溢出,可安全 unchecked
unchecked {
while (left < right) {
uint256 mid = (left + right) >> 1;
uint256 v = nums[mid];
if (v == target) return int256(mid);
if (v < target) {
left = mid + 1;
} else {
right = mid;
}
}
}
return -1;
}
}
3.5.7 优化效果
用 Foundry 测量,两个版本使用相同的编译参数(optimizer 开启,runs = 200),只比较代码本身的差异:
| 函数 | 测试输入 | 优化前 | 优化后 | 降幅 |
|---|---|---|---|---|
resetVotes | 10 个候选人 | 237,914 | 43,331(固定,与人数无关) | 82% |
vote | 平均 | 71,978 | 37,970 | 47% |
reverse | 43 个字符 | 14,846 | 4,140 | 72% |
intToRoman | 3888 | 11,385 | 3,737 | 67% |
romanToInt | "MMMDCCCLXXXVIII" | 9,535 | 4,626 | 51% |
merge | 50 + 50 个元素 | 54,700 | 41,235 | 25% |
search | 100 个元素 | 3,003 | 1,479 | 51% |
优化版的 Voting 也有代价:
vote和getVotes每次都要多读一次轮次(约 2,000 gas);旧轮次的数据会一直留在链上。重置越频繁、候选人越多,这个方案越划算。
3.6 测试验证
所有合约都用 Foundry 编写了测试,覆盖题目示例和边界情况;优化前、优化后两个版本都跑过同一套测试,全部通过:
| 题目 | 测试用例 |
|---|---|
| 投票合约 | 给 a 投 2 票、b 投 1 票 → 重置后两人都是 0 |
| 反转字符串 | "abcde" → "edcba" |
| 整数转罗马数字 | 1994 → "MCMXCIV",3749 → "MMMDCCXLIX" |
| 罗马数字转整数 | "MCMXCIV" → 1994,"IV" → 4,"LVIII" → 58 |
| 合并有序数组 | [1,3,5] + [2,4] → [1,2,3,4,5] |
| 二分查找 | 在 [1,2,3,4,5] 中找 1 → 0、找 5 → 4、找 9 → -1;空数组 → -1 |
3.7 常见坑与小结
| 坑 | 出现在哪 | 解决办法 |
|---|---|---|
| mapping 不能遍历 | 投票合约重置 | 另外记录 key,或者用"轮次"让旧数据失效 |
| uint 减法下溢 | 罗马数字转整数、二分查找 | 调整遍历方向或区间定义,避免出现可能为负的减法 |
| string 不能按下标访问 | 反转字符串 | 先转成 bytes |
| memory 数组不能 push | 合并数组 | 创建时预先分配好长度 |
| 循环写存储,gas 随数据量增长 | 投票合约重置 | 用 O(1) 的设计代替循环 |
小结:写 Solidity 时要时刻记住三件事:类型有边界(uint 不能为负,0.8 版本起溢出会 revert)、存储很昂贵(能不写存储就不写,能 O(1) 就不要 O(n))、数据结构有限制(mapping 不能遍历,memory 数组定长)。