Published on

有奖励机制的质押合约

Authors

有奖励机制的质押合约

项目目标:

  1. RewardToken.sol: 一个标准的ERC20代币,作为质押的奖励。

  2. StakingRewards.sol: 核心质押合约。用户可以质押一种代币(比如ETH或者另一个ERC20),并根据质押时间和数量获得RewardToken奖励。

这个项目将帮助我们学习:

  • 合约间的交互。

  • ERC20代币标准。

  • 状态管理(用户余额、奖励等)。

  • 时间相关的逻辑处理。

  • 权限控制(谁可以设置奖励率等)。

  • Foundry的全套测试方法(单元、模糊、不变量测试)。

  • 使用脚本进行部署和验证。


项目构建大纲

我们把整个开发过程分为五个阶段,一步一步来完成。

第一阶段:项目初始化与环境搭建 (Project Setup)

  1. 初始化Foundry项目:

    • 使用 forge init my-staking-project 创建项目骨架。

    • 讲解项目结构 (src, test, script, lib, foundry.toml)。

  2. 安装依赖:

    • 使用 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)

  1. 编写 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);
    }
}
  • 测试代码讲解
  1. Imports: 我们导入了 Test (Foundry测试框架的核心) 和我们要测试的 RewardToken 合约。

  2. setUp() 函数:

    • 这个函数在每个 test_ 函数运行之前都会被执行一次,确保每个测试都在一个干净、独立的环境中开始。

    • makeAddr("name"): Foundry的作弊码(cheatcode),可以方便地创建命名地址,让测试更具可读性。

    • vm.startPrank(owner) / vm.stopPrank(): vm 代表 Foundry 提供的虚拟机(Virtual Machine)接口,通过它可以使用各种作弊码这告诉Foundry,在这之间的所有合约调用(比如 new RewardToken(...))都由 owner 这个地址发起。这对于设置初始状态非常有用。

  3. test_InitialState():

    • 一个简单的单元测试,检查合约部署后的初始状态是否符合预期(代币名称、符号、所有者等)。

    • assertEq(...): 最常用的断言,检查两个值是否相等。

  4. test_Mint_Success():

    • 测试 mint 函数的成功路径。

    • vm.prank(owner): 模拟 owner 地址单次调用 mint 函数。这与 startPrank/stopPrank 不同,它只对紧接着的下一行代码生效。

    • 我们断言用户的余额和总供应量都正确增加了。

  5. testFuzz_Mint_Success(uint96 amount):

    • 这是一个模糊测试。Foundry会自动生成几百个(默认256个)不同的 amount 值来调用这个函数。

    • vm.assume(amount > 0): 我们告诉模糊测试器忽略 amount 为0的情况,因为我们可能想在另一个测试中专门处理它。

    • 这能帮我们发现一些在单元测试中想不到的边界情况。

  6. test_RevertWhen_MintByNonOwner():

    • 测试失败路径。我们想要确保非所有者调用 mint 时,交易会回滚

    • vm.expectRevert(...): 这是测试回滚的关键。它告诉Foundry,下一行代码必须因为指定的错误而回滚。

    • 我们使用 abi.encodeWithSelector 来精确匹配 Ownable 合约抛出的 OwnableUnauthorizedAccount(address) 错误。

  7. test_RevertWhen_MintToZeroAddress():

    • 与上面类似,但这次我们检查的是我们自己定义的 RewardToken__MintToZeroAddress 错误。

    • 使用 .selector 可以方便地获取自定义错误的4字节标识符。

  8. test_Event_TokensMinted():

    • 测试事件是否被正确触发。

    • vm.expectEmit(...): 声明我们期望一个事件被触发。我们可以精细地检查 topic 和 data。在这里,我们检查了 to 地址(作为indexed topic)和事件来源地址。

    • 紧接着 vm.expectEmit 之后的函数调用就是我们要检查的目标。

  9. 状态变量:

    • 质押代币和奖励代币的地址。

    • 奖励率 (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的精髓所在,我们会花大量时间在这里。

  1. 单元测试 (Unit Tests):

    • 在 test/ 目录下创建 StakingRewards.t.sol。

    • 编写 setUp() 函数来部署合约和初始化测试环境。

    • 对每个功能进行独立的、可预测的测试。

      • test_Stake_Success(): 测试成功质押后,用户的质押余额和合约总质押量是否正确更新。

      • test_RevertWhen_StakeZeroAmount(): 测试质押0数量时是否如预期一样回滚。

      • test_Withdraw_Success(): 测试成功取回。

      • test_RewardCalculation(): 在不同时间点检查奖励计算的准确性。

      • test_ClaimReward(): 测试领取奖励后,用户收到正确的代币数量,且待领取奖励清零。

      • test_AdminFunctions(): 测试只有owner才能调用管理员函数。

  2. 模糊测试 (Fuzz Testing):

    • testFuzz_StakeAndWithdraw(uint96 amount): 使用随机数量来测试质押和取回操作,确保在各种数值下合约状态保持一致。使用uint96等较小的类型来有效覆盖边界值。

    • testFuzz_MultipleUsers(address user1, address user2, ...): 模拟多个用户随机进行操作。

  3. 不变量测试 (Invariant Testing):

    • 这是最能保证合约核心逻辑健壮性的方法。

    • 定义几个“永恒为真”的规则(Invariants):

      • Invariant 1: 合约中记录的总质押量 (totalSupply) 必须始终等于所有用户质押余额的总和。

      • Invariant 2: 合约持有的奖励代币 (RewardToken) 余额必须始终大于或等于所有用户累积但未领取的奖励总和。

    • 我们将编写一个Handler合约来随机调用stake, withdraw, claimReward等函数,然后由Foundry的引擎来验证这些不变量是否被打破。

第四阶段:部署脚本 (Deployment Scripting)

  1. 编写部署脚本:

    • 在 script/ 目录下创建 Deploy.s.sol。

    • 脚本将执行以下操作:

      1. 读取部署者的私钥(安全地通过.env文件)。

      2. vm.startBroadcast()。

      3. 部署 RewardToken 合约。

      4. 部署 StakingRewards 合约,并将 RewardToken 的地址传入其构造函数。

      5. 执行部署后设置:例如,用 RewardToken 的 mint 函数给 StakingRewards 合约铸造一批初始奖励代币。

      6. 将 RewardToken 的 owner 权限转移给一个多签钱包或某个安全的地址。

      7. vm.stopBroadcast()。

  2. 执行部署:

    • 在Anvil本地节点上进行模拟部署。

    • 在Sepolia等测试网上进行实际部署和Etherscan验证。

    • 讲解部署命令:forge script ... --rpc-url <rpc_url> --broadcast --verify

第五阶段:进阶与优化 (Advanced Topics & Refinements)

  1. Gas 优化:

    • 使用 forge snapshot 来比较不同实现方式的Gas成本。

    • 讨论一些常见的Gas优化技巧。

  2. 代码质量与安全:

    • 运行 forge lint 来检查代码风格和潜在的安全问题。

    • 再次回顾“检查-生效-交互”(Checks-Effects-Interactions)模式,以及重入攻击的防范。

  3. 文档生成:

    • 使用 forge doc 根据我们编写的NatSpec注释生成项目文档。

Reference

[[Foundry自动化测试方法]]