智能合约测试与部署
1. 测试框架概述
智能合约测试是确保代码安全性和正确性的关键环节。与传统软件开发不同,智能合约部署后无法修改,因此测试的重要性不言而喻。
1.1 测试的重要性
智能合约测试的核心目标:
- 安全性验证:防止资金损失和漏洞利用
- 功能正确性:确保业务逻辑符合预期
- Gas优化验证:降低用户交易成本
- 边界条件检查:处理极端情况
- 回归测试:确保新功能不破坏现有功能
1.2 测试类型
测试金字塔
/\
/集\
/成测\
/试 试\
/__单元测试__\
- 单元测试:测试单个函数或合约
- 集成测试:测试多个合约交互
- Fork测试:在主网Fork上测试
- 模糊测试:随机输入测试边界条件
- 不变性测试:验证系统不变量
2. Hardhat 测试框架
2.1 Hardhat 项目初始化
# 创建项目目录
mkdir defi-protocol-test
cd defi-protocol-test
# 初始化 npm 项目
npm init -y
# 安装 Hardhat
npm install --save-dev hardhat
# 初始化 Hardhat 项目
npx hardhat init
选择 "Create a TypeScript project" 选项。
2.2 Hardhat 配置文件
// hardhat.config.ts
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import "@nomicfoundation/hardhat-verify";
import "hardhat-gas-reporter";
import "solidity-coverage";
import "hardhat-deploy";
const config: HardhatUserConfig = {
solidity: {
version: "0.8.20",
settings: {
optimizer: {
enabled: true,
runs: 200,
},
viaIR: true, // 启用 IR 优化
},
},
networks: {
hardhat: {
chainId: 31337,
forking: {
url: process.env.MAINNET_RPC_URL || "",
enabled: process.env.FORKING === "true",
},
},
sepolia: {
url: process.env.SEPOLIA_RPC_URL || "",
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
chainId: 11155111,
},
mainnet: {
url: process.env.MAINNET_RPC_URL || "",
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
chainId: 1,
},
},
gasReporter: {
enabled: process.env.REPORT_GAS === "true",
currency: "USD",
coinmarketcap: process.env.COINMARKETCAP_API_KEY,
excludeContracts: ["mocks/"],
},
etherscan: {
apiKey: {
mainnet: process.env.ETHERSCAN_API_KEY || "",
sepolia: process.env.ETHERSCAN_API_KEY || "",
},
},
paths: {
sources: "./contracts",
tests: "./test",
cache: "./cache",
artifacts: "./artifacts",
},
mocha: {
timeout: 200000, // 200 seconds
},
};
export default config;
2.3 环境变量配置
# .env
MAINNET_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY
SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY
PRIVATE_KEY=your_private_key_here
ETHERSCAN_API_KEY=your_etherscan_api_key
COINMARKETCAP_API_KEY=your_coinmarketcap_api_key
REPORT_GAS=true
FORKING=false
2.4 示例合约 - ERC20 代币
// contracts/Token.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract TestToken is ERC20, Ownable {
uint256 public constant MAX_SUPPLY = 1_000_000 * 10**18;
constructor() ERC20("Test Token", "TEST") Ownable(msg.sender) {
_mint(msg.sender, 100_000 * 10**18);
}
function mint(address to, uint256 amount) external onlyOwner {
require(totalSupply() + amount <= MAX_SUPPLY, "Exceeds max supply");
_mint(to, amount);
}
function burn(uint256 amount) external {
_burn(msg.sender, amount);
}
}
2.5 单元测试示例
// test/Token.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";
import { TestToken } from "../typechain-types";
import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers";
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
describe("TestToken", function () {
// Fixture: 部署合约的快照
async function deployTokenFixture() {
const [owner, addr1, addr2] = await ethers.getSigners();
const Token = await ethers.getContractFactory("TestToken");
const token = await Token.deploy();
return { token, owner, addr1, addr2 };
}
describe("Deployment", function () {
it("Should set the right owner", async function () {
const { token, owner } = await loadFixture(deployTokenFixture);
expect(await token.owner()).to.equal(owner.address);
});
it("Should assign the initial supply to the owner", async function () {
const { token, owner } = await loadFixture(deployTokenFixture);
const ownerBalance = await token.balanceOf(owner.address);
expect(ownerBalance).to.equal(ethers.parseEther("100000"));
});
it("Should set the correct token name and symbol", async function () {
const { token } = await loadFixture(deployTokenFixture);
expect(await token.name()).to.equal("Test Token");
expect(await token.symbol()).to.equal("TEST");
});
});
describe("Minting", function () {
it("Should mint tokens to specified address", async function () {
const { token, addr1 } = await loadFixture(deployTokenFixture);
const mintAmount = ethers.parseEther("1000");
await token.mint(addr1.address, mintAmount);
expect(await token.balanceOf(addr1.address)).to.equal(mintAmount);
});
it("Should fail if non-owner tries to mint", async function () {
const { token, addr1 } = await loadFixture(deployTokenFixture);
const mintAmount = ethers.parseEther("1000");
await expect(
token.connect(addr1).mint(addr1.address, mintAmount)
).to.be.revertedWithCustomError(token, "OwnableUnauthorizedAccount");
});
it("Should fail if minting exceeds max supply", async function () {
const { token, owner } = await loadFixture(deployTokenFixture);
const maxSupply = await token.MAX_SUPPLY();
const currentSupply = await token.totalSupply();
const excessAmount = maxSupply - currentSupply + 1n;
await expect(
token.mint(owner.address, excessAmount)
).to.be.revertedWith("Exceeds max supply");
});
it("Should emit Transfer event on mint", async function () {
const { token, addr1 } = await loadFixture(deployTokenFixture);
const mintAmount = ethers.parseEther("1000");
await expect(token.mint(addr1.address, mintAmount))
.to.emit(token, "Transfer")
.withArgs(ethers.ZeroAddress, addr1.address, mintAmount);
});
});
describe("Burning", function () {
it("Should burn tokens from caller", async function () {
const { token, owner } = await loadFixture(deployTokenFixture);
const initialBalance = await token.balanceOf(owner.address);
const burnAmount = ethers.parseEther("1000");
await token.burn(burnAmount);
expect(await token.balanceOf(owner.address))
.to.equal(initialBalance - burnAmount);
});
it("Should fail if burning more than balance", async function () {
const { token, addr1 } = await loadFixture(deployTokenFixture);
const burnAmount = ethers.parseEther("1000");
await expect(
token.connect(addr1).burn(burnAmount)
).to.be.revertedWithCustomError(token, "ERC20InsufficientBalance");
});
it("Should decrease total supply on burn", async function () {
const { token, owner } = await loadFixture(deployTokenFixture);
const initialSupply = await token.totalSupply();
const burnAmount = ethers.parseEther("1000");
await token.burn(burnAmount);
expect(await token.totalSupply()).to.equal(initialSupply - burnAmount);
});
});
describe("Transfers", function () {
it("Should transfer tokens between accounts", async function () {
const { token, owner, addr1, addr2 } = await loadFixture(deployTokenFixture);
const transferAmount = ethers.parseEther("50");
// Transfer from owner to addr1
await token.transfer(addr1.address, transferAmount);
expect(await token.balanceOf(addr1.address)).to.equal(transferAmount);
// Transfer from addr1 to addr2
await token.connect(addr1).transfer(addr2.address, transferAmount);
expect(await token.balanceOf(addr2.address)).to.equal(transferAmount);
});
it("Should fail if sender has insufficient balance", async function () {
const { token, addr1 } = await loadFixture(deployTokenFixture);
const transferAmount = ethers.parseEther("1");
await expect(
token.connect(addr1).transfer(addr1.address, transferAmount)
).to.be.revertedWithCustomError(token, "ERC20InsufficientBalance");
});
it("Should update balances after transfers", async function () {
const { token, owner, addr1 } = await loadFixture(deployTokenFixture);
const initialOwnerBalance = await token.balanceOf(owner.address);
const transferAmount = ethers.parseEther("100");
await token.transfer(addr1.address, transferAmount);
expect(await token.balanceOf(owner.address))
.to.equal(initialOwnerBalance - transferAmount);
expect(await token.balanceOf(addr1.address)).to.equal(transferAmount);
});
});
});
2.6 复杂合约测试 - Staking Pool
// contracts/StakingPool.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract StakingPool is Ownable, ReentrancyGuard {
using SafeERC20 for IERC20;
IERC20 public immutable stakingToken;
IERC20 public immutable rewardToken;
uint256 public rewardRate; // 每秒奖励代币数量
uint256 public lastUpdateTime;
uint256 public rewardPerTokenStored;
uint256 public totalStaked;
mapping(address => uint256) public stakedBalance;
mapping(address => uint256) public userRewardPerTokenPaid;
mapping(address => uint256) public rewards;
event Staked(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
event RewardPaid(address indexed user, uint256 reward);
event RewardRateUpdated(uint256 newRate);
constructor(
address _stakingToken,
address _rewardToken,
uint256 _rewardRate
) Ownable(msg.sender) {
stakingToken = IERC20(_stakingToken);
rewardToken = IERC20(_rewardToken);
rewardRate = _rewardRate;
}
function rewardPerToken() public view returns (uint256) {
if (totalStaked == 0) {
return rewardPerTokenStored;
}
return rewardPerTokenStored +
(((block.timestamp - lastUpdateTime) * rewardRate * 1e18) / totalStaked);
}
function earned(address account) public view returns (uint256) {
return ((stakedBalance[account] *
(rewardPerToken() - userRewardPerTokenPaid[account])) / 1e18) +
rewards[account];
}
modifier updateReward(address account) {
rewardPerTokenStored = rewardPerToken();
lastUpdateTime = block.timestamp;
if (account != address(0)) {
rewards[account] = earned(account);
userRewardPerTokenPaid[account] = rewardPerTokenStored;
}
_;
}
function stake(uint256 amount) external nonReentrant updateReward(msg.sender) {
require(amount > 0, "Cannot stake 0");
totalStaked += amount;
stakedBalance[msg.sender] += amount;
stakingToken.safeTransferFrom(msg.sender, address(this), amount);
emit Staked(msg.sender, amount);
}
function withdraw(uint256 amount) public nonReentrant updateReward(msg.sender) {
require(amount > 0, "Cannot withdraw 0");
require(stakedBalance[msg.sender] >= amount, "Insufficient balance");
totalStaked -= amount;
stakedBalance[msg.sender] -= amount;
stakingToken.safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
}
function getReward() public nonReentrant updateReward(msg.sender) {
uint256 reward = rewards[msg.sender];
if (reward > 0) {
rewards[msg.sender] = 0;
rewardToken.safeTransfer(msg.sender, reward);
emit RewardPaid(msg.sender, reward);
}
}
function exit() external {
withdraw(stakedBalance[msg.sender]);
getReward();
}
function setRewardRate(uint256 _rewardRate) external onlyOwner updateReward(address(0)) {
rewardRate = _rewardRate;
emit RewardRateUpdated(_rewardRate);
}
}
// test/StakingPool.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";
import { time } from "@nomicfoundation/hardhat-network-helpers";
import { TestToken, StakingPool } from "../typechain-types";
import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers";
describe("StakingPool", function () {
let stakingToken: TestToken;
let rewardToken: TestToken;
let stakingPool: StakingPool;
let owner: SignerWithAddress;
let user1: SignerWithAddress;
let user2: SignerWithAddress;
const REWARD_RATE = ethers.parseEther("100"); // 100 tokens per second
const INITIAL_BALANCE = ethers.parseEther("10000");
beforeEach(async function () {
[owner, user1, user2] = await ethers.getSigners();
// Deploy tokens
const Token = await ethers.getContractFactory("TestToken");
stakingToken = await Token.deploy();
rewardToken = await Token.deploy();
// Deploy staking pool
const StakingPool = await ethers.getContractFactory("StakingPool");
stakingPool = await StakingPool.deploy(
await stakingToken.getAddress(),
await rewardToken.getAddress(),
REWARD_RATE
);
// Mint tokens to users
await stakingToken.mint(user1.address, INITIAL_BALANCE);
await stakingToken.mint(user2.address, INITIAL_BALANCE);
// Mint reward tokens to pool
await rewardToken.mint(await stakingPool.getAddress(), ethers.parseEther("1000000"));
// Approve staking pool
await stakingToken.connect(user1).approve(
await stakingPool.getAddress(),
ethers.MaxUint256
);
await stakingToken.connect(user2).approve(
await stakingPool.getAddress(),
ethers.MaxUint256
);
});
describe("Staking", function () {
it("Should allow users to stake tokens", async function () {
const stakeAmount = ethers.parseEther("1000");
await expect(stakingPool.connect(user1).stake(stakeAmount))
.to.emit(stakingPool, "Staked")
.withArgs(user1.address, stakeAmount);
expect(await stakingPool.stakedBalance(user1.address)).to.equal(stakeAmount);
expect(await stakingPool.totalStaked()).to.equal(stakeAmount);
});
it("Should fail when staking 0 tokens", async function () {
await expect(
stakingPool.connect(user1).stake(0)
).to.be.revertedWith("Cannot stake 0");
});
it("Should fail when staking without approval", async function () {
const NewToken = await ethers.getContractFactory("TestToken");
const newToken = await NewToken.deploy();
const StakingPool = await ethers.getContractFactory("StakingPool");
const newPool = await StakingPool.deploy(
await newToken.getAddress(),
await rewardToken.getAddress(),
REWARD_RATE
);
await newToken.mint(user1.address, INITIAL_BALANCE);
await expect(
newPool.connect(user1).stake(ethers.parseEther("1000"))
).to.be.reverted;
});
});
describe("Withdrawing", function () {
beforeEach(async function () {
await stakingPool.connect(user1).stake(ethers.parseEther("1000"));
});
it("Should allow users to withdraw staked tokens", async function () {
const withdrawAmount = ethers.parseEther("500");
const initialBalance = await stakingToken.balanceOf(user1.address);
await expect(stakingPool.connect(user1).withdraw(withdrawAmount))
.to.emit(stakingPool, "Withdrawn")
.withArgs(user1.address, withdrawAmount);
expect(await stakingPool.stakedBalance(user1.address))
.to.equal(ethers.parseEther("500"));
expect(await stakingToken.balanceOf(user1.address))
.to.equal(initialBalance + withdrawAmount);
});
it("Should fail when withdrawing more than staked", async function () {
await expect(
stakingPool.connect(user1).withdraw(ethers.parseEther("2000"))
).to.be.revertedWith("Insufficient balance");
});
it("Should fail when withdrawing 0 tokens", async function () {
await expect(
stakingPool.connect(user1).withdraw(0)
).to.be.revertedWith("Cannot withdraw 0");
});
});
describe("Rewards", function () {
it("Should calculate rewards correctly for single staker", async function () {
const stakeAmount = ethers.parseEther("1000");
await stakingPool.connect(user1).stake(stakeAmount);
// Fast forward 100 seconds
await time.increase(100);
const expectedReward = REWARD_RATE * 100n;
const earned = await stakingPool.earned(user1.address);
// Allow small rounding error
expect(earned).to.be.closeTo(expectedReward, ethers.parseEther("1"));
});
it("Should calculate rewards correctly for multiple stakers", async function () {
await stakingPool.connect(user1).stake(ethers.parseEther("1000"));
await time.increase(50);
await stakingPool.connect(user2).stake(ethers.parseEther("1000"));
await time.increase(50);
const earned1 = await stakingPool.earned(user1.address);
const earned2 = await stakingPool.earned(user2.address);
// User1 earns for 100 seconds with different rates
// First 50 seconds: full reward rate
// Next 50 seconds: half reward rate (sharing with user2)
const expectedEarned1 = REWARD_RATE * 50n + (REWARD_RATE * 50n) / 2n;
// User2 earns for 50 seconds at half rate
const expectedEarned2 = (REWARD_RATE * 50n) / 2n;
expect(earned1).to.be.closeTo(expectedEarned1, ethers.parseEther("10"));
expect(earned2).to.be.closeTo(expectedEarned2, ethers.parseEther("10"));
});
it("Should allow users to claim rewards", async function () {
await stakingPool.connect(user1).stake(ethers.parseEther("1000"));
await time.increase(100);
const earned = await stakingPool.earned(user1.address);
const initialBalance = await rewardToken.balanceOf(user1.address);
await expect(stakingPool.connect(user1).getReward())
.to.emit(stakingPool, "RewardPaid");
const finalBalance = await rewardToken.balanceOf(user1.address);
expect(finalBalance - initialBalance).to.be.closeTo(earned, ethers.parseEther("1"));
expect(await stakingPool.rewards(user1.address)).to.equal(0);
});
it("Should handle exit correctly", async function () {
const stakeAmount = ethers.parseEther("1000");
await stakingPool.connect(user1).stake(stakeAmount);
await time.increase(100);
const initialStakingBalance = await stakingToken.balanceOf(user1.address);
const initialRewardBalance = await rewardToken.balanceOf(user1.address);
await stakingPool.connect(user1).exit();
expect(await stakingPool.stakedBalance(user1.address)).to.equal(0);
expect(await stakingToken.balanceOf(user1.address))
.to.equal(initialStakingBalance + stakeAmount);
expect(await rewardToken.balanceOf(user1.address))
.to.be.greaterThan(initialRewardBalance);
});
});
describe("Admin Functions", function () {
it("Should allow owner to update reward rate", async function () {
const newRate = ethers.parseEther("200");
await expect(stakingPool.setRewardRate(newRate))
.to.emit(stakingPool, "RewardRateUpdated")
.withArgs(newRate);
expect(await stakingPool.rewardRate()).to.equal(newRate);
});
it("Should fail when non-owner tries to update reward rate", async function () {
await expect(
stakingPool.connect(user1).setRewardRate(ethers.parseEther("200"))
).to.be.revertedWithCustomError(stakingPool, "OwnableUnauthorizedAccount");
});
});
describe("Edge Cases", function () {
it("Should handle zero total staked correctly", async function () {
expect(await stakingPool.rewardPerToken()).to.equal(0);
});
it("Should handle multiple stakes and withdraws", async function () {
await stakingPool.connect(user1).stake(ethers.parseEther("1000"));
await time.increase(50);
await stakingPool.connect(user1).stake(ethers.parseEther("500"));
await time.increase(50);
await stakingPool.connect(user1).withdraw(ethers.parseEther("700"));
await time.increase(50);
const earned = await stakingPool.earned(user1.address);
expect(earned).to.be.greaterThan(0);
});
});
});
3. Foundry 测试框架
3.1 Foundry 安装与初始化
# 安装 Foundry
curl -L https://foundry.paradigm.xyz | bash
foundryup
# 创建新项目
forge init foundry-defi-protocol
cd foundry-defi-protocol
# 安装依赖
forge install OpenZeppelin/openzeppelin-contracts
3.2 Foundry 配置
# foundry.toml
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc_version = "0.8.20"
optimizer = true
optimizer_runs = 200
via_ir = true
[profile.ci]
fuzz = { runs = 5000 }
invariant = { runs = 1000 }
[rpc_endpoints]
mainnet = "${MAINNET_RPC_URL}"
sepolia = "${SEPOLIA_RPC_URL}"
[etherscan]
mainnet = { key = "${ETHERSCAN_API_KEY}" }
sepolia = { key = "${ETHERSCAN_API_KEY}" }
[fmt]
line_length = 100
tab_width = 4
bracket_spacing = true
3.3 Foundry 测试示例
// test/Token.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/Token.sol";
contract TokenTest is Test {
TestToken public token;
address public owner;
address public user1;
address public user2;
uint256 constant INITIAL_SUPPLY = 100_000 * 10**18;
uint256 constant MAX_SUPPLY = 1_000_000 * 10**18;
function setUp() public {
owner = address(this);
user1 = makeAddr("user1");
user2 = makeAddr("user2");
token = new TestToken();
}
function test_InitialState() public {
assertEq(token.name(), "Test Token");
assertEq(token.symbol(), "TEST");
assertEq(token.totalSupply(), INITIAL_SUPPLY);
assertEq(token.balanceOf(owner), INITIAL_SUPPLY);
}
function test_Mint() public {
uint256 mintAmount = 1000 * 10**18;
token.mint(user1, mintAmount);
assertEq(token.balanceOf(user1), mintAmount);
assertEq(token.totalSupply(), INITIAL_SUPPLY + mintAmount);
}
function testFail_MintExceedsMaxSupply() public {
uint256 excessAmount = MAX_SUPPLY - INITIAL_SUPPLY + 1;
token.mint(user1, excessAmount);
}
function testRevert_NonOwnerMint() public {
vm.prank(user1);
vm.expectRevert();
token.mint(user1, 1000 * 10**18);
}
function test_Transfer() public {
uint256 transferAmount = 100 * 10**18;
token.transfer(user1, transferAmount);
assertEq(token.balanceOf(user1), transferAmount);
assertEq(token.balanceOf(owner), INITIAL_SUPPLY - transferAmount);
}
function test_Burn() public {
uint256 burnAmount = 1000 * 10**18;
uint256 initialSupply = token.totalSupply();
token.burn(burnAmount);
assertEq(token.balanceOf(owner), INITIAL_SUPPLY - burnAmount);
assertEq(token.totalSupply(), initialSupply - burnAmount);
}
// Fuzz Testing
function testFuzz_Mint(address to, uint256 amount) public {
vm.assume(to != address(0));
vm.assume(amount > 0 && amount <= MAX_SUPPLY - INITIAL_SUPPLY);
token.mint(to, amount);
assertEq(token.balanceOf(to), amount);
}
function testFuzz_Transfer(address to, uint256 amount) public {
vm.assume(to != address(0));
vm.assume(amount <= INITIAL_SUPPLY);
token.transfer(to, amount);
assertEq(token.balanceOf(to), amount);
}
}
3.4 高级测试 - 模糊测试与不变性测试
// test/StakingPool.invariant.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/StakingPool.sol";
import "../src/Token.sol";
contract StakingPoolHandler is Test {
StakingPool public pool;
TestToken public stakingToken;
TestToken public rewardToken;
address[] public actors;
address internal currentActor;
uint256 public ghost_stakeSum;
uint256 public ghost_withdrawSum;
constructor(StakingPool _pool, TestToken _stakingToken, TestToken _rewardToken) {
pool = _pool;
stakingToken = _stakingToken;
rewardToken = _rewardToken;
}
function stake(uint256 amount, uint256 actorSeed) public {
currentActor = actors[bound(actorSeed, 0, actors.length - 1)];
amount = bound(amount, 1, stakingToken.balanceOf(currentActor));
vm.startPrank(currentActor);
stakingToken.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
ghost_stakeSum += amount;
}
function withdraw(uint256 amount, uint256 actorSeed) public {
currentActor = actors[bound(actorSeed, 0, actors.length - 1)];
amount = bound(amount, 0, pool.stakedBalance(currentActor));
if (amount == 0) return;
vm.prank(currentActor);
pool.withdraw(amount);
ghost_withdrawSum += amount;
}
function getReward(uint256 actorSeed) public {
currentActor = actors[bound(actorSeed, 0, actors.length - 1)];
vm.prank(currentActor);
pool.getReward();
}
function addActor(address actor) external {
actors.push(actor);
}
}
contract StakingPoolInvariantTest is Test {
StakingPool public pool;
TestToken public stakingToken;
TestToken public rewardToken;
StakingPoolHandler public handler;
function setUp() public {
stakingToken = new TestToken();
rewardToken = new TestToken();
pool = new StakingPool(
address(stakingToken),
address(rewardToken),
100 * 10**18
);
handler = new StakingPoolHandler(pool, stakingToken, rewardToken);
// Setup actors
for (uint i = 0; i < 10; i++) {
address actor = makeAddr(string(abi.encodePacked("actor", vm.toString(i))));
handler.addActor(actor);
stakingToken.mint(actor, 10000 * 10**18);
}
// Fund reward pool
rewardToken.mint(address(pool), 1000000 * 10**18);
targetContract(address(handler));
}
// Invariant: total staked should equal sum of all user balances
function invariant_TotalStakedEqualsUserBalances() public {
uint256 sumOfBalances;
for (uint i = 0; i < 10; i++) {
address actor = makeAddr(string(abi.encodePacked("actor", vm.toString(i))));
sumOfBalances += pool.stakedBalance(actor);
}
assertEq(pool.totalStaked(), sumOfBalances);
}
// Invariant: pool should hold correct amount of staking tokens
function invariant_PoolHoldsCorrectStakingTokens() public {
assertEq(
stakingToken.balanceOf(address(pool)),
pool.totalStaked()
);
}
// Invariant: ghost accounting should match
function invariant_GhostAccountingMatches() public {
assertEq(
handler.ghost_stakeSum() - handler.ghost_withdrawSum(),
pool.totalStaked()
);
}
}
3.5 Fork 测试
// test/UniswapFork.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "@uniswap/v2-periphery/contracts/interfaces/IUniswapV2Router02.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract UniswapForkTest is Test {
IUniswapV2Router02 public router;
IERC20 public weth;
IERC20 public usdc;
address constant ROUTER_ADDRESS = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D;
address constant WETH_ADDRESS = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
address constant USDC_ADDRESS = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
address public user;
function setUp() public {
// Fork mainnet at specific block
vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 18000000);
router = IUniswapV2Router02(ROUTER_ADDRESS);
weth = IERC20(WETH_ADDRESS);
usdc = IERC20(USDC_ADDRESS);
user = makeAddr("user");
vm.deal(user, 100 ether);
}
function test_SwapETHForUSDC() public {
uint256 amountIn = 1 ether;
uint256 minAmountOut = 0;
address[] memory path = new address[](2);
path[0] = WETH_ADDRESS;
path[1] = USDC_ADDRESS;
vm.startPrank(user);
uint256[] memory amounts = router.swapExactETHForTokens{value: amountIn}(
minAmountOut,
path,
user,
block.timestamp + 300
);
vm.stopPrank();
assertGt(usdc.balanceOf(user), 0);
assertGt(amounts[1], 0);
}
function test_GetAmountsOut() public {
uint256 amountIn = 1 ether;
address[] memory path = new address[](2);
path[0] = WETH_ADDRESS;
path[1] = USDC_ADDRESS;
uint256[] memory amounts = router.getAmountsOut(amountIn, path);
assertGt(amounts[1], 0);
console.log("1 ETH =", amounts[1] / 10**6, "USDC");
}
}
4. 集成测试
4.1 多合约交互测试
// test/integration/DeFiProtocol.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";
import { time } from "@nomicfoundation/hardhat-network-helpers";
describe("DeFi Protocol Integration", function () {
let token: any;
let stakingPool: any;
let governance: any;
let owner: any;
let users: any[];
before(async function () {
[owner, ...users] = await ethers.getSigners();
// Deploy all contracts
const Token = await ethers.getContractFactory("TestToken");
token = await Token.deploy();
const rewardToken = await Token.deploy();
const StakingPool = await ethers.getContractFactory("StakingPool");
stakingPool = await StakingPool.deploy(
await token.getAddress(),
await rewardToken.getAddress(),
ethers.parseEther("100")
);
// Distribute tokens
for (const user of users.slice(0, 5)) {
await token.mint(user.address, ethers.parseEther("10000"));
await token.connect(user).approve(
await stakingPool.getAddress(),
ethers.MaxUint256
);
}
await rewardToken.mint(await stakingPool.getAddress(), ethers.parseEther("1000000"));
});
it("Complete user journey: stake -> earn -> withdraw", async function () {
const user = users[0];
const stakeAmount = ethers.parseEther("1000");
// 1. User stakes tokens
await stakingPool.connect(user).stake(stakeAmount);
expect(await stakingPool.stakedBalance(user.address)).to.equal(stakeAmount);
// 2. Time passes and user earns rewards
await time.increase(3600); // 1 hour
const earned = await stakingPool.earned(user.address);
expect(earned).to.be.greaterThan(0);
// 3. User claims rewards
await stakingPool.connect(user).getReward();
// 4. User withdraws stake
await stakingPool.connect(user).withdraw(stakeAmount);
expect(await stakingPool.stakedBalance(user.address)).to.equal(0);
});
it("Multiple users staking scenario", async function () {
const stakeAmounts = [
ethers.parseEther("1000"),
ethers.parseEther("2000"),
ethers.parseEther("500"),
];
// Users stake at different times
for (let i = 0; i < 3; i++) {
await stakingPool.connect(users[i]).stake(stakeAmounts[i]);
await time.increase(600); // 10 minutes between stakes
}
// Check total staked
const expectedTotal = stakeAmounts.reduce((a, b) => a + b, 0n);
expect(await stakingPool.totalStaked()).to.equal(expectedTotal);
// Verify rewards are distributed proportionally
await time.increase(3600);
const rewards = await Promise.all(
users.slice(0, 3).map(u => stakingPool.earned(u.address))
);
// User with 2x stake should have approximately 2x rewards
expect(rewards[1]).to.be.greaterThan(rewards[0]);
expect(rewards[0]).to.be.greaterThan(rewards[2]);
});
});
5. 测试覆盖率
5.1 Hardhat 覆盖率
# 安装覆盖率工具
npm install --save-dev solidity-coverage
# 运行覆盖率测试
npx hardhat coverage
// hardhat.config.js 中已包含
require("solidity-coverage");
生成的覆盖率报告:
-------------------|----------|----------|----------|----------|----------------|
File | % Stmts | % Branch | % Funcs | % Lines |Uncovered Lines |
-------------------|----------|----------|----------|----------|----------------|
contracts/ | 100 | 95.83 | 100 | 100 | |
StakingPool.sol | 100 | 95.83 | 100 | 100 | |
Token.sol | 100 | 100 | 100 | 100 | |
-------------------|----------|----------|----------|----------|----------------|
All files | 100 | 95.83 | 100 | 100 | |
-------------------|----------|----------|----------|----------|----------------|
5.2 Foundry 覆盖率
# 运行覆盖率测试
forge coverage
# 生成详细报告
forge coverage --report lcov
genhtml lcov.info -o coverage
6. Gas 优化测试
6.1 Gas Reporter 配置
// hardhat.config.ts 中的 gasReporter 配置
gasReporter: {
enabled: process.env.REPORT_GAS === "true",
currency: "USD",
coinmarketcap: process.env.COINMARKETCAP_API_KEY,
excludeContracts: ["mocks/"],
outputFile: "gas-report.txt",
noColors: true,
}
6.2 Gas 优化示例
优化前:
// 未优化的版本
function batchTransfer(address[] memory recipients, uint256[] memory amounts) external {
require(recipients.length == amounts.length, "Length mismatch");
for (uint256 i = 0; i < recipients.length; i++) {
_transfer(msg.sender, recipients[i], amounts[i]);
}
}
优化后:
// 优化版本
function batchTransfer(address[] calldata recipients, uint256[] calldata amounts) external {
uint256 length = recipients.length;
require(length == amounts.length, "Length mismatch");
for (uint256 i; i < length;) {
_transfer(msg.sender, recipients[i], amounts[i]);
unchecked { ++i; }
}
}
优化对比测试:
describe("Gas Optimization", function () {
it("Should compare gas costs", async function () {
const recipients = [addr1.address, addr2.address, addr3.address];
const amounts = [
ethers.parseEther("100"),
ethers.parseEther("200"),
ethers.parseEther("300")
];
const tx = await token.batchTransfer(recipients, amounts);
const receipt = await tx.wait();
console.log("Gas used:", receipt!.gasUsed.toString());
});
});
7. 部署脚本
7.1 Hardhat 部署脚本
// scripts/deploy.ts
import { ethers, network } from "hardhat";
import { verify } from "./verify";
async function main() {
console.log("Deploying to network:", network.name);
const [deployer] = await ethers.getSigners();
console.log("Deploying with account:", deployer.address);
const balance = await ethers.provider.getBalance(deployer.address);
console.log("Account balance:", ethers.formatEther(balance), "ETH");
// Deploy Token
console.log("\nDeploying TestToken...");
const Token = await ethers.getContractFactory("TestToken");
const token = await Token.deploy();
await token.waitForDeployment();
const tokenAddress = await token.getAddress();
console.log("TestToken deployed to:", tokenAddress);
// Deploy Reward Token
console.log("\nDeploying Reward Token...");
const rewardToken = await Token.deploy();
await rewardToken.waitForDeployment();
const rewardTokenAddress = await rewardToken.getAddress();
console.log("Reward Token deployed to:", rewardTokenAddress);
// Deploy Staking Pool
console.log("\nDeploying StakingPool...");
const rewardRate = ethers.parseEther("100"); // 100 tokens per second
const StakingPool = await ethers.getContractFactory("StakingPool");
const stakingPool = await StakingPool.deploy(
tokenAddress,
rewardTokenAddress,
rewardRate
);
await stakingPool.waitForDeployment();
const stakingPoolAddress = await stakingPool.getAddress();
console.log("StakingPool deployed to:", stakingPoolAddress);
// Initial setup
console.log("\nInitial setup...");
const rewardAmount = ethers.parseEther("1000000");
await rewardToken.mint(stakingPoolAddress, rewardAmount);
console.log("Minted", ethers.formatEther(rewardAmount), "reward tokens to pool");
// Save deployment info
const deploymentInfo = {
network: network.name,
deployer: deployer.address,
contracts: {
TestToken: tokenAddress,
RewardToken: rewardTokenAddress,
StakingPool: stakingPoolAddress,
},
timestamp: new Date().toISOString(),
};
const fs = require("fs");
const path = require("path");
const deploymentsDir = path.join(__dirname, "../deployments");
if (!fs.existsSync(deploymentsDir)) {
fs.mkdirSync(deploymentsDir);
}
fs.writeFileSync(
path.join(deploymentsDir, `${network.name}.json`),
JSON.stringify(deploymentInfo, null, 2)
);
console.log("\nDeployment info saved to:", `deployments/${network.name}.json`);
// Verify contracts on Etherscan
if (network.name !== "hardhat" && network.name !== "localhost") {
console.log("\nWaiting for block confirmations...");
await token.deploymentTransaction()?.wait(6);
console.log("\nVerifying contracts on Etherscan...");
await verify(tokenAddress, []);
await verify(rewardTokenAddress, []);
await verify(stakingPoolAddress, [tokenAddress, rewardTokenAddress, rewardRate]);
}
console.log("\nDeployment complete!");
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
7.2 验证脚本
// scripts/verify.ts
import { run } from "hardhat";
export async function verify(contractAddress: string, args: any[]) {
console.log("Verifying contract at:", contractAddress);
try {
await run("verify:verify", {
address: contractAddress,
constructorArguments: args,
});
console.log("Contract verified successfully");
} catch (error: any) {
if (error.message.toLowerCase().includes("already verified")) {
console.log("Contract already verified");
} else {
console.error("Verification failed:", error);
}
}
}
7.3 Foundry 部署脚本
// script/Deploy.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Script.sol";
import "../src/Token.sol";
import "../src/StakingPool.sol";
contract DeployScript is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
// Deploy tokens
TestToken stakingToken = new TestToken();
console.log("Staking Token deployed at:", address(stakingToken));
TestToken rewardToken = new TestToken();
console.log("Reward Token deployed at:", address(rewardToken));
// Deploy staking pool
uint256 rewardRate = 100 * 10**18;
StakingPool pool = new StakingPool(
address(stakingToken),
address(rewardToken),
rewardRate
);
console.log("Staking Pool deployed at:", address(pool));
// Initial setup
rewardToken.mint(address(pool), 1_000_000 * 10**18);
console.log("Minted 1M reward tokens to pool");
vm.stopBroadcast();
// Save deployment addresses
string memory deploymentData = string(
abi.encodePacked(
"StakingToken=", vm.toString(address(stakingToken)), "\n",
"RewardToken=", vm.toString(address(rewardToken)), "\n",
"StakingPool=", vm.toString(address(pool)), "\n"
)
);
vm.writeFile("deployments.txt", deploymentData);
}
}
运行部署:
# 部署到 Sepolia
forge script script/Deploy.s.sol:DeployScript --rpc-url $SEPOLIA_RPC_URL --broadcast --verify
# 部署到主网 (谨慎!)
forge script script/Deploy.s.sol:DeployScript --rpc-url $MAINNET_RPC_URL --broadcast --verify
8. CI/CD 集成
8.1 GitHub Actions 配置
# .github/workflows/test.yml
name: Smart Contract Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run Hardhat tests
run: npx hardhat test
env:
REPORT_GAS: true
- name: Run coverage
run: npx hardhat coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
flags: hardhat
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
- name: Run Forge tests
run: forge test -vvv
- name: Run Forge coverage
run: forge coverage
- name: Check contract sizes
run: npx hardhat size-contracts
- name: Run Slither
uses: crytic/slither-action@v0.3.0
with:
node-version: 18
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Run Solhint
run: npx solhint 'contracts/**/*.sol'
- name: Run Prettier
run: npx prettier --check 'contracts/**/*.sol'
- name: Run ESLint
run: npx eslint 'test/**/*.ts' 'scripts/**/*.ts'
8.2 部署工作流
# .github/workflows/deploy.yml
name: Deploy Contracts
on:
push:
tags:
- 'v*'
jobs:
deploy-testnet:
runs-on: ubuntu-latest
environment: testnet
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Deploy to Sepolia
run: npx hardhat run scripts/deploy.ts --network sepolia
env:
SEPOLIA_RPC_URL: ${{ secrets.SEPOLIA_RPC_URL }}
PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }}
ETHERSCAN_API_KEY: ${{ secrets.ETHERSCAN_API_KEY }}
- name: Upload deployment artifacts
uses: actions/upload-artifact@v3
with:
name: deployments
path: deployments/
deploy-mainnet:
runs-on: ubuntu-latest
environment: production
needs: deploy-testnet
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Deploy to Mainnet
run: npx hardhat run scripts/deploy.ts --network mainnet
env:
MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }}
PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }}
ETHERSCAN_API_KEY: ${{ secrets.ETHERSCAN_API_KEY }}
- name: Create Release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: false
9. 测试最佳实践
9.1 测试组织结构
test/
├── unit/
│ ├── Token.test.ts
│ ├── StakingPool.test.ts
│ └── Governance.test.ts
├── integration/
│ ├── DeFiProtocol.test.ts
│ └── CrossContractInteractions.test.ts
├── fork/
│ ├── UniswapIntegration.test.ts
│ └── AaveIntegration.test.ts
├── fuzzing/
│ └── StakingPool.invariant.t.sol
└── helpers/
├── fixtures.ts
└── utils.ts
9.2 测试辅助工具
// test/helpers/fixtures.ts
import { ethers } from "hardhat";
export async function deployTokenFixture() {
const [owner, ...users] = await ethers.getSigners();
const Token = await ethers.getContractFactory("TestToken");
const token = await Token.deploy();
return { token, owner, users };
}
export async function deployStakingFixture() {
const { token, owner, users } = await deployTokenFixture();
const RewardToken = await ethers.getContractFactory("TestToken");
const rewardToken = await RewardToken.deploy();
const StakingPool = await ethers.getContractFactory("StakingPool");
const stakingPool = await StakingPool.deploy(
await token.getAddress(),
await rewardToken.getAddress(),
ethers.parseEther("100")
);
// Setup
await rewardToken.mint(
await stakingPool.getAddress(),
ethers.parseEther("1000000")
);
for (const user of users) {
await token.mint(user.address, ethers.parseEther("10000"));
await token.connect(user).approve(
await stakingPool.getAddress(),
ethers.MaxUint256
);
}
return { token, rewardToken, stakingPool, owner, users };
}
// test/helpers/utils.ts
import { ethers } from "hardhat";
import { time } from "@nomicfoundation/hardhat-network-helpers";
export async function mineBlocks(count: number) {
for (let i = 0; i < count; i++) {
await ethers.provider.send("evm_mine", []);
}
}
export async function increaseTime(seconds: number) {
await time.increase(seconds);
}
export async function setNextBlockTimestamp(timestamp: number) {
await time.setNextBlockTimestamp(timestamp);
}
export function calculateExpectedReward(
stakedAmount: bigint,
rewardRate: bigint,
duration: number
): bigint {
return (stakedAmount * rewardRate * BigInt(duration)) / ethers.parseEther("1");
}
export async function expectRevert(
promise: Promise<any>,
expectedError: string
) {
try {
await promise;
throw new Error("Expected transaction to revert");
} catch (error: any) {
if (!error.message.includes(expectedError)) {
throw error;
}
}
}
9.3 快照测试
import { takeSnapshot, SnapshotRestorer } from "@nomicfoundation/hardhat-network-helpers";
describe("StakingPool with Snapshots", function () {
let snapshot: SnapshotRestorer;
beforeEach(async function () {
// Setup
snapshot = await takeSnapshot();
});
afterEach(async function () {
await snapshot.restore();
});
it("Test 1", async function () {
// This test starts from snapshot
});
it("Test 2", async function () {
// This test also starts from snapshot
});
});
10. 安全测试
10.1 Slither 静态分析
# 安装 Slither
pip3 install slither-analyzer
# 运行分析
slither .
# 生成报告
slither . --json slither-report.json
# 检查特定问题
slither . --detect reentrancy-eth,uninitialized-state
10.2 Mythril 符号执行
# 安装 Mythril
pip3 install mythril
# 分析合约
myth analyze contracts/StakingPool.sol --solc-json mythril-config.json
10.3 Echidna 模糊测试
# echidna.yaml
testMode: assertion
testLimit: 50000
deployer: "0x30000"
sender: ["0x10000", "0x20000", "0x30000"]
// contracts/test/EchidnaTest.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "../StakingPool.sol";
contract EchidnaTest {
StakingPool pool;
TestToken stakingToken;
TestToken rewardToken;
constructor() {
stakingToken = new TestToken();
rewardToken = new TestToken();
pool = new StakingPool(
address(stakingToken),
address(rewardToken),
100 * 10**18
);
stakingToken.mint(address(this), 1000000 * 10**18);
rewardToken.mint(address(pool), 1000000 * 10**18);
stakingToken.approve(address(pool), type(uint256).max);
}
function echidna_total_staked_equals_balance() public view returns (bool) {
return pool.totalStaked() == stakingToken.balanceOf(address(pool));
}
function echidna_user_stake_lte_total() public view returns (bool) {
return pool.stakedBalance(address(this)) <= pool.totalStaked();
}
}
运行:
echidna-test contracts/test/EchidnaTest.sol --contract EchidnaTest --config echidna.yaml
11. 升级测试
11.1 可升级合约测试
// contracts/StakingPoolV2.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract StakingPoolV2 is Initializable, OwnableUpgradeable, ReentrancyGuardUpgradeable {
IERC20 public stakingToken;
IERC20 public rewardToken;
uint256 public rewardRate;
uint256 public lastUpdateTime;
uint256 public rewardPerTokenStored;
uint256 public totalStaked;
// V2 新增: 锁定期
uint256 public lockDuration;
mapping(address => uint256) public stakeTimestamp;
mapping(address => uint256) public stakedBalance;
mapping(address => uint256) public userRewardPerTokenPaid;
mapping(address => uint256) public rewards;
function initialize(
address _stakingToken,
address _rewardToken,
uint256 _rewardRate
) public initializer {
__Ownable_init(msg.sender);
__ReentrancyGuard_init();
stakingToken = IERC20(_stakingToken);
rewardToken = IERC20(_rewardToken);
rewardRate = _rewardRate;
lockDuration = 7 days; // V2 新增
}
function setLockDuration(uint256 _duration) external onlyOwner {
lockDuration = _duration;
}
// 重写 withdraw 添加锁定期检查
function withdraw(uint256 amount) external {
require(
block.timestamp >= stakeTimestamp[msg.sender] + lockDuration,
"Tokens are locked"
);
// ... rest of withdraw logic
}
}
// test/upgrade/StakingPoolUpgrade.test.ts
import { expect } from "chai";
import { ethers, upgrades } from "hardhat";
describe("StakingPool Upgrade", function () {
it("Should upgrade successfully", async function () {
const [owner] = await ethers.getSigners();
// Deploy V1
const Token = await ethers.getContractFactory("TestToken");
const stakingToken = await Token.deploy();
const rewardToken = await Token.deploy();
const StakingPoolV1 = await ethers.getContractFactory("StakingPool");
const proxy = await upgrades.deployProxy(
StakingPoolV1,
[
await stakingToken.getAddress(),
await rewardToken.getAddress(),
ethers.parseEther("100")
],
{ initializer: 'initialize' }
);
// Interact with V1
await stakingToken.mint(owner.address, ethers.parseEther("1000"));
await stakingToken.approve(await proxy.getAddress(), ethers.MaxUint256);
await proxy.stake(ethers.parseEther("500"));
const stakedBefore = await proxy.stakedBalance(owner.address);
// Upgrade to V2
const StakingPoolV2 = await ethers.getContractFactory("StakingPoolV2");
const upgraded = await upgrades.upgradeProxy(
await proxy.getAddress(),
StakingPoolV2
);
// Verify state is preserved
expect(await upgraded.stakedBalance(owner.address)).to.equal(stakedBefore);
// Verify new functionality
expect(await upgraded.lockDuration()).to.equal(7 * 24 * 3600);
await upgraded.setLockDuration(14 * 24 * 3600);
expect(await upgraded.lockDuration()).to.equal(14 * 24 * 3600);
});
});
12. 性能基准测试
// test/benchmarks/GasBenchmark.test.ts
import { ethers } from "hardhat";
describe("Gas Benchmarks", function () {
it("Benchmark different operations", async function () {
const [owner, user] = await ethers.getSigners();
const Token = await ethers.getContractFactory("TestToken");
const token = await Token.deploy();
await token.mint(owner.address, ethers.parseEther("100000"));
// Benchmark transfer
const tx1 = await token.transfer(user.address, ethers.parseEther("100"));
const receipt1 = await tx1.wait();
console.log("Transfer gas:", receipt1!.gasUsed.toString());
// Benchmark mint
const tx2 = await token.mint(user.address, ethers.parseEther("100"));
const receipt2 = await tx2.wait();
console.log("Mint gas:", receipt2!.gasUsed.toString());
// Benchmark burn
const tx3 = await token.burn(ethers.parseEther("100"));
const receipt3 = await tx3.wait();
console.log("Burn gas:", receipt3!.gasUsed.toString());
});
});