- Published on
有奖励机制的质押合约
- Authors

- Name
- 卢翔宇
- @y9840836216317
有奖励机制的质押合约
项目目标:
RewardToken.sol: 一个标准的ERC20代币,作为质押的奖励。
StakingRewards.sol: 核心质押合约。用户可以质押一种代币(比如ETH或者另一个ERC20),并根据质押时间和数量获得RewardToken奖励。
这个项目将帮助我们学习:
合约间的交互。
ERC20代币标准。
状态管理(用户余额、奖励等)。
时间相关的逻辑处理。
权限控制(谁可以设置奖励率等)。
Foundry的全套测试方法(单元、模糊、不变量测试)。
使用脚本进行部署和验证。
项目构建大纲
我们把整个开发过程分为五个阶段,一步一步来完成。
第一阶段:项目初始化与环境搭建 (Project Setup)
初始化Foundry项目:
使用 forge init my-staking-project 创建项目骨架。
讲解项目结构 (src, test, script, lib, foundry.toml)。
安装依赖:
使用 forge install OpenZeppelin/openzeppelin-contracts 安装OpenZeppelin合约库。这是行业标准,我们将用它来实现ERC20代币和一些安全工具。
配置 remappings.txt 或 foundry.toml 以便在代码中正确引用库。
forge remappings > remappings.txt这样的话在导入依赖时就会更方便:import "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";->import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
第二阶段:核心合约开发 (Contract Development)
编写 RewardToken.sol:
继承OpenZeppelin的 ERC20.sol。
添加一个 Ownable 权限,并提供一个只有所有者能调用的 mint 函数,以便给质押合约提供奖励代币。
遵循NatSpec标准编写函数文档。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
/**
* @title RewardToken
* @author Your Name
* @notice An ERC20 token used as a reward for the staking contract.
* @dev This token is mintable only by the owner of the contract.
*/
contract RewardToken is ERC20, Ownable {
/**
* @notice Custom error for when a non-owner tries to mint.
*/
error RewardToken__NotOwner();
/**
* @notice Custom error for minting to the zero address.
*/
error RewardToken__MintToZeroAddress();
/**
* @notice Emitted when tokens are minted.
* @param to The address that received the tokens.
* @param amount The amount of tokens minted.
*/
event TokensMinted(address indexed to, uint256 amount);
/**
* @dev Sets the initial owner, token name, and symbol.
* @param _initialOwner The address that will be the owner of the contract.
*/
/constructor函数仅在合约创建时执行一次
constructor(address _initialOwner) ERC20("Reward Token", "RWT") Ownable(_initialOwner) {}
/**
* @notice Mints new tokens and assigns them to an account.
* @dev Can only be called by the contract owner.
* @param to The address to mint tokens to.
* @param amount The amount of tokens to mint.
*/
function mint(address to, uint256 amount) public onlyOwner {
/address(0)是指一个空地址
if (to == address(0)) {
revert RewardToken__MintToZeroAddress();
}
/*_mint是ERC20内置的一个函数,用于发行代币,在接收者 `to` 的地址上增加
amount数量的代币余额。*/
_mint(to, amount);
emit TokensMinted(to, amount);
}
}
测试文件RewardToken.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {RewardToken} from "src/RewardToken.sol";
/**
* @title RewardToken Test Suite
* @notice Tests for the RewardToken contract.
*/
contract RewardTokenTest is Test {
RewardToken public rewardToken;
address public owner;
address public user;
/**
* @notice Sets up the testing environment before each test case.
*/
function setUp() public {
// Create an owner and a regular user for tests
owner = makeAddr("owner");
user = makeAddr("user");
// Deploy the RewardToken contract, setting 'owner' as the initial owner
vm.startPrank(owner);
rewardToken = new RewardToken(owner);
vm.stopPrank();
}
/*//////////////////////////////////////////////////////////////
CONSTRUCTOR TESTS
//////////////////////////////////////////////////////////////*/
function test_InitialState() public {
assertEq(rewardToken.name(), "Reward Token", "Token name should be 'Reward Token'");
assertEq(rewardToken.symbol(), "RWT", "Token symbol should be 'RWT'");
assertEq(rewardToken.owner(), owner, "Initial owner should be set correctly");
assertEq(rewardToken.totalSupply(), 0, "Initial total supply should be 0");
}
/*//////////////////////////////////////////////////////////////
MINT TESTS
//////////////////////////////////////////////////////////////*/
function test_Mint_Success() public {
uint256 mintAmount = 100 ether;
vm.prank(owner); // The owner is the one calling mint
rewardToken.mint(user, mintAmount);
assertEq(rewardToken.balanceOf(user), mintAmount, "User balance should be updated after mint");
assertEq(rewardToken.totalSupply(), mintAmount, "Total supply should be updated after mint");
}
function testFuzz_Mint_Success(uint96 amount) public {
// We use uint96 for fuzzing to get a good range of values without easily overflowing.
// Let's assume 0 is not a valid mint amount for this specific test logic.
vm.assume(amount > 0);
uint256 totalSupplyBefore = rewardToken.totalSupply();
uint256 userBalanceBefore = rewardToken.balanceOf(user);
vm.prank(owner);
rewardToken.mint(user, amount);
assertEq(rewardToken.balanceOf(user), userBalanceBefore + amount, "User balance should increase by amount");
assertEq(rewardToken.totalSupply(), totalSupplyBefore + amount, "Total supply should increase by amount");
}
function test_RevertWhen_MintByNonOwner() public {
uint256 mintAmount = 100 ether;
// We expect this call to revert with Ownable's specific error message.
// The error signature is `OwnableUnauthorizedAccount(address account)`.
vm.expectRevert(abi.encodeWithSelector(bytes4(keccak256("OwnableUnauthorizedAccount(address)")), user));
// 'user' (a non-owner) tries to call mint
vm.prank(user);
rewardToken.mint(user, mintAmount);
}
function test_RevertWhen_MintToZeroAddress() public {
uint256 mintAmount = 100 ether;
// We expect this to revert with our custom error.
vm.expectRevert(RewardToken.RewardToken__MintToZeroAddress.selector);
vm.prank(owner);
rewardToken.mint(address(0), mintAmount);
}
function test_Event_TokensMinted() public {
uint256 mintAmount = 100 ether;
// Expect an event to be emitted.
// We check:
// 1. The topic at index 1 is the 'to' address (indexed parameter).
// 2. The event is emitted from the rewardToken contract address.
vm.expectEmit(true, false, false, true);
emit RewardToken.TokensMinted(user, mintAmount);
vm.prank(owner);
rewardToken.mint(user, mintAmount);
}
}
- 测试代码讲解
Imports: 我们导入了 Test (Foundry测试框架的核心) 和我们要测试的 RewardToken 合约。
setUp() 函数:
这个函数在每个 test_ 函数运行之前都会被执行一次,确保每个测试都在一个干净、独立的环境中开始。
makeAddr("name"): Foundry的作弊码(cheatcode),可以方便地创建命名地址,让测试更具可读性。
vm.startPrank(owner) / vm.stopPrank(): vm 代表 Foundry 提供的虚拟机(Virtual Machine)接口,通过它可以使用各种作弊码这告诉Foundry,在这之间的所有合约调用(比如 new RewardToken(...))都由 owner 这个地址发起。这对于设置初始状态非常有用。
test_InitialState():
一个简单的单元测试,检查合约部署后的初始状态是否符合预期(代币名称、符号、所有者等)。
assertEq(...): 最常用的断言,检查两个值是否相等。
test_Mint_Success():
测试 mint 函数的成功路径。
vm.prank(owner): 模拟 owner 地址单次调用 mint 函数。这与 startPrank/stopPrank 不同,它只对紧接着的下一行代码生效。
我们断言用户的余额和总供应量都正确增加了。
testFuzz_Mint_Success(uint96 amount):
这是一个模糊测试。Foundry会自动生成几百个(默认256个)不同的 amount 值来调用这个函数。
vm.assume(amount > 0): 我们告诉模糊测试器忽略 amount 为0的情况,因为我们可能想在另一个测试中专门处理它。
这能帮我们发现一些在单元测试中想不到的边界情况。
test_RevertWhen_MintByNonOwner():
测试失败路径。我们想要确保非所有者调用 mint 时,交易会回滚。
vm.expectRevert(...): 这是测试回滚的关键。它告诉Foundry,下一行代码必须因为指定的错误而回滚。
我们使用 abi.encodeWithSelector 来精确匹配 Ownable 合约抛出的 OwnableUnauthorizedAccount(address) 错误。
test_RevertWhen_MintToZeroAddress():
与上面类似,但这次我们检查的是我们自己定义的 RewardToken__MintToZeroAddress 错误。
使用 .selector 可以方便地获取自定义错误的4字节标识符。
test_Event_TokensMinted():
测试事件是否被正确触发。
vm.expectEmit(...): 声明我们期望一个事件被触发。我们可以精细地检查 topic 和 data。在这里,我们检查了 to 地址(作为indexed topic)和事件来源地址。
紧接着 vm.expectEmit 之后的函数调用就是我们要检查的目标。
状态变量:
质押代币和奖励代币的地址。
奖励率 (reward rate)。
每个用户的质押信息 (e.g., mapping(address => uint256) public stakedBalances;)。
记录用户应得但未领取的奖励 (e.g., mapping(address => uint256) public rewards;)。
核心功能函数:
stake(uint256 amount): 用户存入质押代币。
withdraw(uint256 amount): 用户取回质押代币。
claimReward(): 用户领取所有累积的奖励。
exit(): 用户取回所有质押并领取所有奖励。
辅助/视图函数:
- earned(address account): 计算某个用户当前累积了多少奖励。
管理员函数 (Owner-only):
setRewardRate(uint256 newRate): 设置新的奖励分发速率。
notifyRewardAmount(uint256 amount): 向合约中补充奖励代币,并根据数量和持续时间更新奖励率。
事件 (Events): 为 Stake, Withdraw, RewardPaid 等关键操作定义事件。
错误处理: 使用自定义错误(Custom Errors)来处理失败场景,例如 NotEnoughStaked, ZeroAmount。
安全: 使用OpenZeppelin的 ReentrancyGuard 防止重入攻击。
第三阶段:全面的测试 (Comprehensive Testing)
这是Foundry的精髓所在,我们会花大量时间在这里。
单元测试 (Unit Tests):
在 test/ 目录下创建 StakingRewards.t.sol。
编写 setUp() 函数来部署合约和初始化测试环境。
对每个功能进行独立的、可预测的测试。
test_Stake_Success(): 测试成功质押后,用户的质押余额和合约总质押量是否正确更新。
test_RevertWhen_StakeZeroAmount(): 测试质押0数量时是否如预期一样回滚。
test_Withdraw_Success(): 测试成功取回。
test_RewardCalculation(): 在不同时间点检查奖励计算的准确性。
test_ClaimReward(): 测试领取奖励后,用户收到正确的代币数量,且待领取奖励清零。
test_AdminFunctions(): 测试只有owner才能调用管理员函数。
模糊测试 (Fuzz Testing):
testFuzz_StakeAndWithdraw(uint96 amount): 使用随机数量来测试质押和取回操作,确保在各种数值下合约状态保持一致。使用uint96等较小的类型来有效覆盖边界值。
testFuzz_MultipleUsers(address user1, address user2, ...): 模拟多个用户随机进行操作。
不变量测试 (Invariant Testing):
这是最能保证合约核心逻辑健壮性的方法。
定义几个“永恒为真”的规则(Invariants):
Invariant 1: 合约中记录的总质押量 (totalSupply) 必须始终等于所有用户质押余额的总和。
Invariant 2: 合约持有的奖励代币 (RewardToken) 余额必须始终大于或等于所有用户累积但未领取的奖励总和。
我们将编写一个Handler合约来随机调用stake, withdraw, claimReward等函数,然后由Foundry的引擎来验证这些不变量是否被打破。
第四阶段:部署脚本 (Deployment Scripting)
编写部署脚本:
在 script/ 目录下创建 Deploy.s.sol。
脚本将执行以下操作:
读取部署者的私钥(安全地通过.env文件)。
vm.startBroadcast()。
部署 RewardToken 合约。
部署 StakingRewards 合约,并将 RewardToken 的地址传入其构造函数。
执行部署后设置:例如,用 RewardToken 的 mint 函数给 StakingRewards 合约铸造一批初始奖励代币。
将 RewardToken 的 owner 权限转移给一个多签钱包或某个安全的地址。
vm.stopBroadcast()。
执行部署:
在Anvil本地节点上进行模拟部署。
在Sepolia等测试网上进行实际部署和Etherscan验证。
讲解部署命令:
forge script ... --rpc-url <rpc_url> --broadcast --verify。
第五阶段:进阶与优化 (Advanced Topics & Refinements)
Gas 优化:
使用 forge snapshot 来比较不同实现方式的Gas成本。
讨论一些常见的Gas优化技巧。
代码质量与安全:
运行 forge lint 来检查代码风格和潜在的安全问题。
再次回顾“检查-生效-交互”(Checks-Effects-Interactions)模式,以及重入攻击的防范。
文档生成:
- 使用 forge doc 根据我们编写的NatSpec注释生成项目文档。
Reference
[[Foundry自动化测试方法]]