跳转到内容
123xiao | 无名键客

《Web3 中级实战:用 Solidity + Hardhat 构建并审计一个可升级 DeFi 质押合约》

字数: 0 阅读时长: 1 分钟

Web3 中级实战:用 Solidity + Hardhat 构建并审计一个可升级 DeFi 质押合约

很多人学 Solidity 时,前几课都停留在“发币”“留言板”“投票合约”这种级别;一到 DeFi 场景,难度就突然上来了:要处理资金、奖励计算、权限、升级、测试,还得考虑攻击面。
这篇我就带你做一个可升级的 DeFi 质押合约,用 Solidity + Hardhat + OpenZeppelin Upgrades 从头搭起来,并顺带做一轮基础审计思路

这篇不是概念罗列,而是按“能跑起来、能测、能升级、知道哪里危险”这个目标来写。你如果已经写过普通 Solidity 合约,但还没真正做过 upgradeable DeFi 项目,这篇会比较合适。


背景与问题

在 DeFi 里,质押(Staking)是最常见的模式之一:

  • 用户存入某个 ERC20 Token
  • 按时间获得奖励
  • 可随时领取奖励或提取本金
  • 项目方后续可能要调整奖励率、增加新逻辑

问题也正出在这里:

  1. 合约一旦部署默认不可修改

    • 如果奖励逻辑写错了,老合约就很难修
    • 如果后续要加暂停、黑名单、手续费、治理入口,也没法直接改
  2. DeFi 资金型合约容易出安全事故

    • 重入攻击
    • 权限控制失误
    • 精度丢失导致奖励异常
    • 存储布局破坏导致升级后数据错乱
  3. 很多人会写“能跑”的质押合约,但不会写“可维护”的

    • 没有测试升级流程
    • 没有事件日志
    • 没有审计思维
    • 遇到 “Transparent proxy admin cannot fallback” 这类报错就卡住

所以这篇我们要解决的是:
如何实现一个最小可用、支持升级、具备基础安全防护的 DeFi 质押系统。


前置知识

建议你已经了解下面这些内容:

  • Solidity 基础语法
  • ERC20 标准
  • Hardhat 基本用法
  • msg.sendermapping、事件、modifier
  • 合约升级的基本概念:代理(Proxy)与实现(Implementation)

如果你还没接触过可升级合约,也不用慌,下面会边做边解释。


环境准备

1. 初始化项目

mkdir upgradeable-staking
cd upgradeable-staking
npm init -y
npm install --save-dev hardhat
npx hardhat

选择一个基础 JavaScript 项目即可。

2. 安装依赖

npm install @openzeppelin/contracts @openzeppelin/contracts-upgradeable
npm install --save-dev @openzeppelin/hardhat-upgrades @nomicfoundation/hardhat-toolbox

3. 配置 hardhat.config.js

require("@nomicfoundation/hardhat-toolbox");
require("@openzeppelin/hardhat-upgrades");

module.exports = {
  solidity: {
    version: "0.8.20",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200
      }
    }
  }
};

核心原理

在正式写代码前,先把质押合约背后的几个关键点捋顺。

1. 可升级合约的本质

可升级并不是“修改已部署合约代码”,而是:

  • 用户一直与 Proxy 合约 交互
  • Proxy 把调用委托给 Implementation
  • 将来升级时,只替换 Implementation 地址
  • 数据仍然存放在 Proxy 的存储里

这意味着两件很重要的事:

  • 不能用构造函数初始化状态,要用 initialize
  • 升级时必须保证存储布局兼容

2. 奖励计算模型

我们用一个典型但不算太复杂的模型:

  • 全局维护 rewardPerTokenStored
  • 每个用户维护:
    • userRewardPerTokenPaid
    • rewards

当用户质押、提取、领取奖励时,先更新全局和用户状态。

奖励公式核心是:

rewardPerToken += (时间差 * rewardRate * 1e18) / totalStaked

用户可领取奖励:

earned(user) =
(balance[user] * (rewardPerToken - userRewardPerTokenPaid[user]) / 1e18)
+ rewards[user]

这种模型优点是:

  • 不需要每秒给每个人单独记账
  • gas 成本相对可控
  • 是很多 Staking/Mining 合约的经典写法

3. 为什么要用 ReentrancyGuardSafeERC20

这两个我建议在资金合约里尽量默认启用:

  • ReentrancyGuardUpgradeable:防止重入
  • SafeERC20Upgradeable:兼容一些“不太标准”的 ERC20 行为

我自己第一次写 staking 时,觉得“ERC20 转账不就 transferFrom 一下吗”,后来才发现现实世界的 token 并不总是那么听话。


架构总览

flowchart LR
    U[用户] --> P[Upgradeable Proxy]
    A[管理员/Owner] --> P
    P --> I1[Staking Implementation V1]
    P -.升级.-> I2[Staking Implementation V2]
    P --> T1[Stake Token ERC20]
    P --> T2[Reward Token ERC20]

这张图重点看两点:

  • 用户始终调用 Proxy
  • 升级只是替换实现,不是迁移用户数据

质押流程图

sequenceDiagram
    participant User
    participant Proxy as Staking Proxy
    participant Token as Stake Token
    participant Reward as Reward Token

    User->>Proxy: stake(amount)
    Proxy->>Proxy: updateReward(user)
    Proxy->>Token: transferFrom(user, proxy, amount)
    Proxy-->>User: emit Staked

    User->>Proxy: getReward()
    Proxy->>Proxy: updateReward(user)
    Proxy->>Reward: transfer(user, reward)
    Proxy-->>User: emit RewardPaid

    User->>Proxy: withdraw(amount)
    Proxy->>Proxy: updateReward(user)
    Proxy->>Token: transfer(user, amount)
    Proxy-->>User: emit Withdrawn

实战代码(可运行)

下面我们会实现 3 个部分:

  1. 测试用 ERC20
  2. 可升级质押合约 V1
  3. 部署与测试脚本

1. 测试 Token:contracts/MockERC20.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MockERC20 is ERC20 {
    constructor(string memory name_, string memory symbol_, uint256 initialSupply) ERC20(name_, symbol_) {
        _mint(msg.sender, initialSupply);
    }

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

这个合约只是为了本地测试方便,生产环境里你通常会接现有 token。


2. 可升级质押合约 V1:contracts/StakingUpgradeableV1.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol";

contract StakingUpgradeableV1 is Initializable, OwnableUpgradeable, ReentrancyGuardUpgradeable {
    using SafeERC20Upgradeable for IERC20Upgradeable;

    IERC20Upgradeable public stakeToken;
    IERC20Upgradeable public rewardToken;

    uint256 public rewardRate;
    uint256 public lastUpdateTime;
    uint256 public rewardPerTokenStored;
    uint256 public totalStaked;

    mapping(address => uint256) public balances;
    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 newRewardRate);
    event RewardFunded(uint256 amount);

    function initialize(
        address _stakeToken,
        address _rewardToken,
        uint256 _rewardRate
    ) public initializer {
        __Ownable_init();
        __ReentrancyGuard_init();

        require(_stakeToken != address(0), "invalid stake token");
        require(_rewardToken != address(0), "invalid reward token");

        stakeToken = IERC20Upgradeable(_stakeToken);
        rewardToken = IERC20Upgradeable(_rewardToken);
        rewardRate = _rewardRate;
        lastUpdateTime = block.timestamp;
    }

    modifier updateReward(address account) {
        rewardPerTokenStored = rewardPerToken();
        lastUpdateTime = block.timestamp;

        if (account != address(0)) {
            rewards[account] = earned(account);
            userRewardPerTokenPaid[account] = rewardPerTokenStored;
        }
        _;
    }

    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 (
            (balances[account] * (rewardPerToken() - userRewardPerTokenPaid[account])) / 1e18
        ) + rewards[account];
    }

    function stake(uint256 amount) external nonReentrant updateReward(msg.sender) {
        require(amount > 0, "amount = 0");

        totalStaked += amount;
        balances[msg.sender] += amount;

        stakeToken.safeTransferFrom(msg.sender, address(this), amount);
        emit Staked(msg.sender, amount);
    }

    function withdraw(uint256 amount) public nonReentrant updateReward(msg.sender) {
        require(amount > 0, "amount = 0");
        require(balances[msg.sender] >= amount, "insufficient balance");

        totalStaked -= amount;
        balances[msg.sender] -= amount;

        stakeToken.safeTransfer(msg.sender, amount);
        emit Withdrawn(msg.sender, amount);
    }

    function getReward() public nonReentrant updateReward(msg.sender) {
        uint256 reward = rewards[msg.sender];
        require(reward > 0, "no reward");

        rewards[msg.sender] = 0;
        rewardToken.safeTransfer(msg.sender, reward);

        emit RewardPaid(msg.sender, reward);
    }

    function exit() external {
        withdraw(balances[msg.sender]);
        getReward();
    }

    function setRewardRate(uint256 _rewardRate) external onlyOwner updateReward(address(0)) {
        rewardRate = _rewardRate;
        emit RewardRateUpdated(_rewardRate);
    }

    function fundRewards(uint256 amount) external onlyOwner {
        require(amount > 0, "amount = 0");
        rewardToken.safeTransferFrom(msg.sender, address(this), amount);
        emit RewardFunded(amount);
    }

    uint256[45] private __gap;
}

代码解读:为什么这样写

initialize 代替构造函数

因为升级代理模式下,构造函数不会按你预期初始化 Proxy 的存储。
这就是为什么 upgradeable 合约要继承 Initializable,并在部署后调用 initialize

updateReward 作为 modifier

它的思路是:

  • 先把全局奖励累计到当前时间
  • 再把某个用户的未结算奖励记下来
  • 最后执行真正的业务逻辑

这样不管用户是 stakewithdraw 还是 getReward,奖励都不会算乱。

__gap 的作用

uint256[45] private __gap;

这是 OpenZeppelin 推荐的存储预留槽位,用来给未来版本新增变量留空间。
不是所有情况下都必须完全照抄这个数字,但保留 gap 是一个很好的习惯。


部署脚本

创建 scripts/deploy.js

const { ethers, upgrades } = require("hardhat");

async function main() {
  const [deployer] = await ethers.getSigners();
  console.log("Deploy by:", deployer.address);

  const MockERC20 = await ethers.getContractFactory("MockERC20");

  const stakeToken = await MockERC20.deploy(
    "Stake Token",
    "STK",
    ethers.parseEther("1000000")
  );
  await stakeToken.waitForDeployment();

  const rewardToken = await MockERC20.deploy(
    "Reward Token",
    "RWD",
    ethers.parseEther("1000000")
  );
  await rewardToken.waitForDeployment();

  const Staking = await ethers.getContractFactory("StakingUpgradeableV1");
  const staking = await upgrades.deployProxy(
    Staking,
    [
      await stakeToken.getAddress(),
      await rewardToken.getAddress(),
      ethers.parseEther("1")
    ],
    { initializer: "initialize" }
  );

  await staking.waitForDeployment();

  console.log("StakeToken:", await stakeToken.getAddress());
  console.log("RewardToken:", await rewardToken.getAddress());
  console.log("Staking Proxy:", await staking.getAddress());
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

运行:

npx hardhat run scripts/deploy.js

测试代码

真正让你理解合约是否靠谱的,不是部署成功,而是测试。
创建 test/staking.js

const { expect } = require("chai");
const { ethers, upgrades } = require("hardhat");

describe("StakingUpgradeableV1", function () {
  let owner, user1, user2;
  let stakeToken, rewardToken, staking;

  beforeEach(async function () {
    [owner, user1, user2] = await ethers.getSigners();

    const MockERC20 = await ethers.getContractFactory("MockERC20");
    stakeToken = await MockERC20.deploy(
      "Stake Token",
      "STK",
      ethers.parseEther("1000000")
    );
    await stakeToken.waitForDeployment();

    rewardToken = await MockERC20.deploy(
      "Reward Token",
      "RWD",
      ethers.parseEther("1000000")
    );
    await rewardToken.waitForDeployment();

    const Staking = await ethers.getContractFactory("StakingUpgradeableV1");
    staking = await upgrades.deployProxy(
      Staking,
      [
        await stakeToken.getAddress(),
        await rewardToken.getAddress(),
        ethers.parseEther("1")
      ],
      { initializer: "initialize" }
    );
    await staking.waitForDeployment();

    await stakeToken.mint(user1.address, ethers.parseEther("1000"));
    await rewardToken.approve(await staking.getAddress(), ethers.parseEther("10000"));
    await staking.fundRewards(ethers.parseEther("10000"));
  });

  it("should allow user to stake and earn rewards", async function () {
    await stakeToken.connect(user1).approve(await staking.getAddress(), ethers.parseEther("100"));
    await staking.connect(user1).stake(ethers.parseEther("100"));

    await ethers.provider.send("evm_increaseTime", [100]);
    await ethers.provider.send("evm_mine");

    const earned = await staking.earned(user1.address);
    expect(earned).to.be.gt(0);

    const before = await rewardToken.balanceOf(user1.address);
    await staking.connect(user1).getReward();
    const after = await rewardToken.balanceOf(user1.address);

    expect(after).to.be.gt(before);
  });

  it("should allow withdraw", async function () {
    await stakeToken.connect(user1).approve(await staking.getAddress(), ethers.parseEther("50"));
    await staking.connect(user1).stake(ethers.parseEther("50"));
    await staking.connect(user1).withdraw(ethers.parseEther("20"));

    const balance = await staking.balances(user1.address);
    expect(balance).to.equal(ethers.parseEther("30"));
  });

  it("only owner can set reward rate", async function () {
    await expect(
      staking.connect(user1).setRewardRate(ethers.parseEther("2"))
    ).to.be.reverted;

    await staking.connect(owner).setRewardRate(ethers.parseEther("2"));
    expect(await staking.rewardRate()).to.equal(ethers.parseEther("2"));
  });
});

运行测试:

npx hardhat test

升级到 V2:增加暂停功能

中级实战里,光会部署 V1 不够,至少要走一遍升级流程。
这里我们做一个很常见的增强:加入暂停开关

创建 contracts/StakingUpgradeableV2.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "./StakingUpgradeableV1.sol";
import "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";

contract StakingUpgradeableV2 is StakingUpgradeableV1, PausableUpgradeable {
    event EmergencyPaused(address indexed operator);
    event EmergencyUnpaused(address indexed operator);

    function initializeV2() public reinitializer(2) {
        __Pausable_init();
    }

    function pause() external onlyOwner {
        _pause();
        emit EmergencyPaused(msg.sender);
    }

    function unpause() external onlyOwner {
        _unpause();
        emit EmergencyUnpaused(msg.sender);
    }

    function stake(uint256 amount) external override nonReentrant updateReward(msg.sender) whenNotPaused {
        require(amount > 0, "amount = 0");

        totalStaked += amount;
        balances[msg.sender] += amount;

        stakeToken.safeTransferFrom(msg.sender, address(this), amount);
        emit Staked(msg.sender, amount);
    }

    function withdraw(uint256 amount) public override nonReentrant updateReward(msg.sender) whenNotPaused {
        require(amount > 0, "amount = 0");
        require(balances[msg.sender] >= amount, "insufficient balance");

        totalStaked -= amount;
        balances[msg.sender] -= amount;

        stakeToken.safeTransfer(msg.sender, amount);
        emit Withdrawn(msg.sender, amount);
    }

    function getReward() public override nonReentrant updateReward(msg.sender) whenNotPaused {
        uint256 reward = rewards[msg.sender];
        require(reward > 0, "no reward");

        rewards[msg.sender] = 0;
        rewardToken.safeTransfer(msg.sender, reward);

        emit RewardPaid(msg.sender, reward);
    }

    uint256[49] private __gapV2;
}

这里有一个重要前提:V1 中 stake/withdraw/getReward 需要允许 override
所以我们需要把 V1 这几个函数声明改成 virtual

请把 V1 中以下函数签名改掉:

function stake(uint256 amount) external virtual nonReentrant updateReward(msg.sender) { ... }
function withdraw(uint256 amount) public virtual nonReentrant updateReward(msg.sender) { ... }
function getReward() public virtual nonReentrant updateReward(msg.sender) { ... }

升级脚本

创建 scripts/upgrade.js

const { ethers, upgrades } = require("hardhat");

async function main() {
  const proxyAddress = "替换为你部署出来的 Proxy 地址";

  const StakingV2 = await ethers.getContractFactory("StakingUpgradeableV2");
  const stakingV2 = await upgrades.upgradeProxy(proxyAddress, StakingV2);

  await stakingV2.waitForDeployment();
  console.log("Upgraded Proxy:", await stakingV2.getAddress());

  const tx = await stakingV2.initializeV2();
  await tx.wait();

  console.log("V2 initialized");
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

执行:

npx hardhat run scripts/upgrade.js

升级后的状态关系图

stateDiagram-v2
    [*] --> V1_Active
    V1_Active --> V2_Upgraded: upgradeProxy
    V2_Upgraded --> Paused: pause()
    Paused --> V2_Upgraded: unpause()

升级测试

创建 test/upgrade.js

const { expect } = require("chai");
const { ethers, upgrades } = require("hardhat");

describe("Staking upgrade", function () {
  it("should preserve storage after upgrade", async function () {
    const [owner, user1] = await ethers.getSigners();

    const MockERC20 = await ethers.getContractFactory("MockERC20");
    const stakeToken = await MockERC20.deploy(
      "Stake Token",
      "STK",
      ethers.parseEther("1000000")
    );
    await stakeToken.waitForDeployment();

    const rewardToken = await MockERC20.deploy(
      "Reward Token",
      "RWD",
      ethers.parseEther("1000000")
    );
    await rewardToken.waitForDeployment();

    const V1 = await ethers.getContractFactory("StakingUpgradeableV1");
    const staking = await upgrades.deployProxy(
      V1,
      [
        await stakeToken.getAddress(),
        await rewardToken.getAddress(),
        ethers.parseEther("1")
      ],
      { initializer: "initialize" }
    );
    await staking.waitForDeployment();

    await stakeToken.mint(user1.address, ethers.parseEther("100"));
    await rewardToken.approve(await staking.getAddress(), ethers.parseEther("1000"));
    await staking.fundRewards(ethers.parseEther("1000"));

    await stakeToken.connect(user1).approve(await staking.getAddress(), ethers.parseEther("100"));
    await staking.connect(user1).stake(ethers.parseEther("100"));

    const beforeBalance = await staking.balances(user1.address);

    const V2 = await ethers.getContractFactory("StakingUpgradeableV2");
    const upgraded = await upgrades.upgradeProxy(await staking.getAddress(), V2);
    await upgraded.waitForDeployment();
    await upgraded.initializeV2();

    const afterBalance = await upgraded.balances(user1.address);
    expect(afterBalance).to.equal(beforeBalance);

    await upgraded.pause();
    await expect(
      upgraded.connect(user1).stake(ethers.parseEther("1"))
    ).to.be.revertedWithCustomError;
  });
});

不同版本的 OpenZeppelin/Hardhat 对 revert 的断言格式可能略有差异。
如果这里报错,你可以先改成更宽松的:

await expect(
  upgraded.connect(user1).stake(ethers.parseEther("1"))
).to.be.reverted;

逐步验证清单

如果你想确认这套代码真的“活着”,可以按这个顺序验证:

  1. 部署两个 Mock Token
  2. 部署 Staking V1 Proxy
  3. owner 先向 staking 注入 reward token
  4. user approve stake token
  5. user stake
  6. 快进时间
  7. earned(user) 看奖励是否增长
  8. getReward() 看奖励 token 是否到账
  9. withdraw() 看本金是否回退
  10. 升级到 V2
  11. 验证历史 balances 是否还在
  12. 调用 pause() 后验证 stake/withdraw/getReward 是否受限

这个 checklist 很实用。很多时候你以为“升级成功了”,其实只是 proxy 地址没变,但业务状态已经坏掉了。


常见坑与排查

这一节我尽量写得实战一点,都是做 upgradeable 合约时经常撞上的坑。

1. 用了构造函数,结果初始化失效

现象:

  • 部署后 owner 不对
  • token 地址是 0
  • rewardRate 没初始化

原因:

Upgradeable 合约不能依赖传统构造函数去初始化 Proxy 存储。

解决:

  • 使用 initialize
  • 继承 Initializable
  • 部署时通过 deployProxy(..., { initializer: "initialize" })

2. 升级后数据乱了

现象:

  • balances 变成奇怪数字
  • 原本的 stake 记录消失
  • owner 地址异常

原因:

大概率是存储布局变了。比如:

  • 在旧变量中间插入了新变量
  • 删除了旧变量
  • 修改了继承顺序
  • 错误调整了 gap

解决:

  • 只能在末尾追加变量
  • 不要随便改已有变量顺序和类型
  • @openzeppelin/hardhat-upgrades 的校验能力
  • 升级前必须写 storage preservation 测试

3. 奖励不增长或增长异常

排查顺序:

  1. rewardRate 是否设置正确
  2. totalStaked 是否为 0
  3. 时间是否真的推进了
  4. 是否在 stake/withdraw/getReward 前执行了 updateReward
  5. 计算精度是否使用了 1e18

很多人本地测试奖励不动,其实只是忘了:

await ethers.provider.send("evm_increaseTime", [100]);
await ethers.provider.send("evm_mine");

4. 领取奖励时报余额不足

现象:

getReward() revert,或者 reward token 转账失败。

原因:

合约里没有足够的 reward token。

解决:

  • 部署后先 fundRewards
  • 上线前做奖励池资金测算
  • 可以增加 recoverERC20 时排除 stake token / reward token 的误提逻辑

5. override / virtual 编译错误

如果你在 V2 重写 V1 的函数,V1 必须标记 virtual,V2 必须标记 override

比如:

function stake(uint256 amount) external virtual ...

6. Proxy Admin 相关报错

比如常见的:

TransparentUpgradeableProxy: admin cannot fallback to proxy target

原因:

你用 admin 身份去直接调用代理逻辑函数了,而 Transparent Proxy 对 admin 调用有特殊限制。

建议:

  • 日常业务调用尽量使用普通账户
  • 管理员只做升级和管理操作
  • 搞清楚你使用的是 Transparent 还是 UUPS 模式

安全/性能最佳实践

这部分很关键。能跑和能上主网,差得往往就是这些细节。

1. 先更新状态,再转账

我们的 stake/withdraw/getReward 中,核心状态更新都放在外部 token 转账前后合理位置,并且配合 nonReentrant
这是典型的 Checks-Effects-Interactions 思路。

2. 所有资金函数加重入保护

即使你觉得“这里只是 ERC20,不会像 ETH 那么危险”,也别太乐观。
和外部合约交互就意味着存在不可控行为。

3. 奖励参数变更前先结算全局状态

setRewardRate 用了:

updateReward(address(0))

这一步很重要。否则改参数时,历史奖励区间会被新参数污染。

4. 对零地址、零金额做显式校验

这类校验看起来啰嗦,但能减少很多脏数据和误操作。

5. 给管理员能力加边界

生产环境建议至少加上:

  • Pausable
  • 多签钱包作为 owner
  • 升级权限交给 Timelock / Governance
  • 奖励率修改上限
  • 紧急提币机制但要严格限制

6. 不要假设所有 ERC20 都标准

有的 token:

  • 不返回 bool
  • 会收税
  • 会在转账时触发额外逻辑
  • 余额变化不等于 amount

如果你要支持 fee-on-transfer token,当前这版还不够,需要改成按实际到账量记账,比如:

uint256 beforeBal = stakeToken.balanceOf(address(this));
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
uint256 received = stakeToken.balanceOf(address(this)) - beforeBal;

然后用 received 更新用户质押数量。
这就是一个很典型的边界条件
本文的实现默认 stake token 和 reward token 都是常规 ERC20。

7. 用事件做审计与运维追踪

至少要记录:

  • 用户质押
  • 用户提取
  • 奖励领取
  • 奖励率变更
  • 管理员注资
  • 升级与暂停动作

没有事件,链上排障会非常痛苦。

8. 测试不只测 happy path

最低建议覆盖:

  • 零金额质押/提取
  • 提取超额
  • 未注资时领奖励
  • 多用户同时参与
  • 升级前后数据一致
  • pause 状态下行为限制
  • owner 权限控制

一点“像审计”的检查思路

如果你以后要自己审代码,我建议按下面这套顺序看。

1. 资金流

先问两个问题:

  • 钱从哪里进?
  • 钱从哪里出?

在本合约里:

  • 进:stake()fundRewards()
  • 出:withdraw()getReward()

然后检查每条路径有没有:

  • 权限问题
  • 重入问题
  • 余额问题
  • 事件问题

2. 记账是否自洽

重点核对:

  • totalStaked 是否等于所有用户余额之和
  • 提取时是否同步减少
  • 奖励结算时是否重复计算或漏算

3. 时间相关逻辑

凡是和 block.timestamp 有关,都要想:

  • 是否可被矿工轻微操纵
  • 是否存在极端时间跳跃
  • 长时间无人交互时状态是否还能正确结算

我们的模型对小幅时间偏差通常是可接受的,但如果是高价值协议,仍要做更严格的参数控制。

4. 升级安全

升级类合约重点审:

  • 是否用了 initializer / reinitializer
  • 存储布局是否兼容
  • 升级权限是不是过大
  • 新版本是否绕开旧安全限制

可以继续扩展的方向

这个版本已经能作为一个“中级可升级 Staking 模板”,但离生产级还差一些增强项,比如:

  • 固定奖励周期(start/end time)
  • 多奖励 token
  • 基于区块而非时间的奖励
  • 提前退出罚金
  • 白名单或治理控制参数
  • APR/APY 前端辅助接口
  • 使用 UUPS 替代 Transparent Proxy

如果你的业务要上线,至少建议再补:

  • fuzz 测试
  • Slither 静态分析
  • gas profiling
  • 权限模型评审

总结

这篇我们完成了一个完整的中级 Web3 实战闭环:

  • 用 Solidity 写了一个经典奖励模型的质押合约
  • 用 Hardhat + OpenZeppelin Upgrades 部署了可升级 Proxy
  • 通过测试验证了质押、提取、领取奖励
  • 升级到 V2 加入暂停能力
  • 梳理了常见坑、升级风险和基础审计思路

如果你准备自己动手,我给你三个最实用的建议:

  1. 先把测试写扎实,再谈升级

    • 尤其是升级前后存储一致性测试
  2. 默认把资金合约当成高危系统

    • SafeERC20nonReentrant、事件、权限边界都别省
  3. 明确边界条件

    • 本文实现适用于常规 ERC20
    • 不直接兼容 fee-on-transfer、rebasing 等特殊 token
    • 生产环境建议加多签、暂停、参数上限、审计流程

一句话收尾:
可升级 DeFi 合约最难的不是“写出来”,而是“升级后依然正确”。
你只要把“奖励结算 + 存储布局 + 权限边界”这三件事盯紧,项目质量就会比大多数练手合约高一个层级。


分享到:

上一篇
《从 0 到生产可用:基于开源项目搭建企业内部知识库与检索增强问答系统实战》
下一篇
《大模型在企业知识库问答中的RAG落地实践:从数据清洗、检索优化到效果评测》