本文是 Solidity 学习系列的第二篇,在第一篇《Solidity基础知识学习》的语法基础之上,通过三个完整的实战项目进阶:

  1. 捐赠合约:接收 ETH、记账、权限控制、事件、时间限制、排行榜、提款,并部署到 Sepolia 测试网;
  2. NFT 拍卖市场:ERC721、ETH 和 ERC20 混合出价、Chainlink 预言机、UUPS 可升级合约、动态手续费,用 Hardhat 开发、测试、部署;
  3. 基础练习:投票合约与 5 道经典算法题的 Solidity 实现,以及 gas 优化写法。

所有合约都已部署到 Sepolia 测试网。

一、捐赠合约 BeggingContract

这个原名是"讨饭合约",本文统一称为捐赠合约。部署在链上的合约名仍是 BeggingContract:链上合约一旦部署就无法改名。

捐赠合约是学习 Solidity 资金处理最好的入门例子。它只有一百多行,却覆盖了智能合约里最核心的几件事:接收 ETH、记账、权限控制、事件、时间条件、向外转账。

1.1 需求说明

基础功能

  1. 任何人都可以向合约捐赠 ETH:既可以调用 donate(),也可以直接向合约地址转账。
  2. 合约记录每个地址的累计捐赠金额。
  3. 只有合约所有者(owner)可以提取合约里的全部资金。
  4. 任何人都可以查询某个地址的捐赠金额。

扩展功能

  1. 捐赠事件:每次捐赠都触发事件,记录捐赠人和金额。
  2. 捐赠排行榜:查询累计捐赠金额最多的前 3 个地址。
  3. 时间限制:只有在指定时间段内才能捐赠。

交付要求

部署到 Sepolia 测试网,并测试 donate、withdraw、getDonation 都能正常工作。

1.2 合约设计

状态变量:合约存在链上的数据。

变量类型作用
owneraddress合约所有者,部署时设为部署者
donationsmapping(address => uint256)每个地址的累计捐赠金额,单位 wei
topDonorsaddress[3]排行榜前 3 名,按金额从高到低排列
startTime / endTimeuint256允许捐赠的时间段,单位是秒级时间戳

函数

函数谁能调用是否收 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 分三步:

  1. 查找:捐赠者是否已经在榜上;
  2. 上榜:不在榜上的,累计金额必须比第 3 名多,才能替换第 3 名;
  3. 冒泡:和前一名比较,金额更多就交换位置,直到排好。

举例,假设当前榜单是 [B: 3, D: 2, A: 1]:

新的捐赠过程结果
E 捐 1E 累计 1,不比第 3 名 A(1)多,不上榜[B: 3, D: 2, A: 1]
E 再捐 1E 累计 2 > A 的 1,替换第 3 名;再和 D(2)比,相等不交换[B: 3, D: 2, E: 2]
A 捐 10A 累计 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常见用途
wei110⁻¹⁸最小单位,合约内部只用它
gwei10⁹10⁻⁹gas 价格,比如"gas 费 20 gwei"
finney10¹⁵0.001很少用
ether10¹⁸1日常说的"1 个 ETH"

不管界面上选什么单位,最终都会换算成 wei 发送,合约里的 msg.value 永远是 wei。不同工具里,捐赠金额填在不同的地方:

工具金额填在哪
RemixDeploy & Run 面板的 VALUE 输入框,可选单位
EtherscanWrite Contract 中的 payableAmount (ether),单位是 ETH
BlockscoutSend native ETH,单位是 wei;填 0.01 后要点 ×10¹⁸
ethers.jscontract.donate({ value: ethers.parseEther("1") })
castcast 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),上传这个文件即可。

Etherscan 源码验证成功

上传 Standard JSON Input 后,Etherscan 提示 "Successfully generated matching Bytecode and ABI",源码验证通过

1.7 Sepolia 测试网实测

合约地址:0x7fb2b27ebDa5e371539EEb3325E14f779Be533Dd(已在 Etherscan 验证源码)

部署后的状态

项目结果
owner0x83Da…d88C(部署者)
捐赠时间段部署时刻起开放 7 天
排行榜3 个空位

捐赠测试:用三个账户分别调用 donate

捐赠人金额交易
0x83Da…d88C1 ETH0x9abf25f8…
0x455B…D6a02 ETH0xc216770f…
0xF78c…74Bf20.5 ETH0x6a7b3129…

捐赠交易记录

合约页面的交易列表:三笔 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 安全性与改进方向

问题说明改进方式
单一 ownerowner 私钥丢失,资金就永远取不出来;私钥泄露,资金会被转走使用多签钱包作为 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 需求说明

基础功能

  1. NFT 合约:基于 ERC721 标准,支持铸造和转移。
  2. 创建拍卖:卖家可以把自己的 NFT 上架拍卖,设置起拍价和拍卖时长。
  3. 出价:买家可以用 ETH 或 ERC20 代币出价,出价必须高于当前最高价。
  4. 结束拍卖:到期后,NFT 转给出价最高者,资金转给卖家;没人出价则 NFT 退回卖家。

扩展功能

  1. Chainlink 预言机:获取 ETH、ERC20 对美元的价格,把不同币种的出价统一换算成美元来比较。
  2. 合约升级:使用 UUPS 或透明代理模式,部署后可以升级合约逻辑。
  3. 动态手续费:根据成交金额动态调整平台手续费。

交付要求

  1. 使用 Hardhat 开发,编写覆盖全部功能的单元测试和集成测试;
  2. 用 Hardhat 部署脚本部署到 Sepolia 测试网;
  3. 提交代码、测试报告(含覆盖率)、部署地址和项目文档。

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 的写函数

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 步:

  1. 检查:拍卖必须存在(seller != 0),而且还没到截止时间。
  2. 收款:
    • 用 ETH 出价时,金额以 msg.value 为准,忽略 amount 参数,原因见 1.4.2;
    • 用 ERC20 出价时,用 safeTransferFrom 从出价人账户扣款(需要事先 approve),同时禁止附带 ETH,防止 ETH 被误转进合约后无人认领。
  3. 比价:通过 getUsdValue 换算成美元,必须同时满足两个条件:不低于起拍价,并且严格高于当前最高价。
  4. 更新并退款:先把最高出价改成新的,再把钱退给上一个出价人。这就是 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);
    }
}
/// @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());
}

不同币种的精度不一样:

数据小数位数
ETH18
USDT / USDC6
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,0003%(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所有人
#00x83Da…(部署者)
#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 发一个,让他来当卖家。

部署者铸造 NFT

部署者(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 转给别人。但拍卖合约不能随便拿走别人的东西,必须得到主人的许可:

  1. approve(拍卖合约, 0):收款人1 签了一张授权书,"允许拍卖合约转走我的 #0";
  2. 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 被原样退回。

用 ETH 出价

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

用 USDT 出价并自动退款

部署者用 50 USDT 出价的交易详情:同一笔交易里,50 USDT 转进拍卖合约,0.01 ETH 自动退回给收款人2

2.5.6 拍卖结束后发生了什么?

拍卖合约在同一笔交易里做完三件事:

动作登记簿 / 余额的变化
转移 NFTMyNFT 登记簿:#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,有三种方法:

  1. Etherscan:打开赢家的地址页,点 NFT Transfers 标签,能看到 "MyNFT #0" 转进来的记录;
  2. 合约查询:在 MyNFT 的 Read Contract 里调用 ownerOf(0),返回的是赢家地址;
  3. MetaMask:切换到赢家账户 → NFTs 标签 → Import NFT,填合约地址 0x7f25CceE06E0e6E4A43bB2D05Aa748707343c495、Token ID 0,就会显示出来。因为没有设置图片,显示的是一个默认占位图标。

NFT 的流转记录

拍卖合约的 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 <代理合约地址>

MyNFT 源码验证

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

NFTAuction 实现合约源码验证

用同一个 JSON 文件验证 NFTAuction 实现合约;代理合约由 Etherscan 自动识别,不需要单独上传源码

2.7 Sepolia 测试网实测

部署地址

合约地址
MyNFT0x7f25CceE06E0e6E4A43bB2D05Aa748707343c495
NFTAuction 代理(用户交互地址)0xD26429D7E3CeeA027A19d0A37F06429026257D5D
NFTAuction 实现合约0x86aAC6602a715819E8393B8dCB5B952595627784

测试步骤

#操作发起账户结果交易
1mint(收款人1):铸造 NFT #0部署者✅0x92b7ffce…
2approve(拍卖代理, 0)收款人1✅0xb3c38563…
3createAuction(MyNFT, 0, $1, 900 秒)收款人1✅ NFT 转入合约托管0x737b3e77…
4bid:0.01 ETH,折合 $25.00收款人2✅ 成为最高出价0x78de5c05…
5bid:5 USDT,折合 $5.00部署者❌ bid too low,符合预期0x13cf94f2…
6bid:50 USDT,折合 $49.99部署者✅ 成为最高出价,0.01 ETH 自动退给收款人20x82262ef8…
7endAuction(0)部署者✅ NFT 给赢家,48.5 USDT 给卖家,1.5 USDT 给平台0x93e3a97e…

每一步的 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 #048.5 USDT卖出 NFT,拿到 50 USDT 扣除 3% 手续费后的部分
ETH 出价人(收款人2)0.01 ETH0.01 ETH(退款)竞价失败,全额退回,只花了 gas
部署者(USDT 出价人)50 USDTNFT #0以 50 USDT 赢得 NFT
部署者(平台)—1.5 USDT收取 3% 手续费

各角色的地址页(Etherscan → Token Transfers),只看最上面和本次拍卖相关的几行:

卖家的资金变化

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

ETH 出价人的资金变化

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 报错 ERC721NonexistentTokenNFT 还没铸造,tokenId 不存在先用 owner 账户调用 mint
出价报 bid too low金额少填了一个 0:5000000 只是 5 USDTUSDT 是 6 位小数,50 USDT 要填 50000000
起拍价变成天文数字在 Blockscout 上误点了输入框旁边的 ×10¹⁸只有 ETH 金额框需要 ×10¹⁸,其他参数不要点
Network sepolia doesn't exist在错误的目录执行命令,没读到 .env进入项目目录再执行;输出 injected env (2) 才说明读到了
测试网上没法"快进时间"只能等区块时间真正走到截止时间测试时把 duration 设短一点,比如 900(15 分钟)
Etherscan 页面报 500Etherscan 服务端故障换 Blockscout 浏览器,或者等 Etherscan 恢复

小技巧:在 Etherscan / Blockscout 上调用写函数前,先点 Simulate(或用 cast call 模拟)。它会在本地预执行一次,不花 gas,能提前发现参数错误。

2.8.2 安全性与改进方向

问题说明改进方式
退款采用"推"模式出价被超过时直接把钱转回给上一个出价人。如果上一个出价人是拒收 ETH 的合约,之后所有出价都会失败,拍卖被卡死改为"拉"模式:只记录待退款金额,由出价人自己调用 withdraw 领取
没有检查喂价是否过期latestRoundData 返回的价格可能很久没更新检查返回值里的 updatedAt,超过一定时间就拒绝出价
手续费直接转给 ownerowner 如果是拒收 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整数转罗马数字LeetCode 12,范围 1~39991994 → "MCMXCIV"
4罗马数字转整数LeetCode 13"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…1O(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),只比较代码本身的差异:

函数测试输入优化前优化后降幅
resetVotes10 个候选人237,91443,331(固定,与人数无关)82%
vote平均71,97837,97047%
reverse43 个字符14,8464,14072%
intToRoman388811,3853,73767%
romanToInt"MMMDCCCLXXXVIII"9,5354,62651%
merge50 + 50 个元素54,70041,23525%
search100 个元素3,0031,47951%

优化版的 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 数组定长)。