跳转到内容
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.sender、mapping、事件、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. 为什么要用 ReentrancyGuard 和 SafeERC20

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

  • 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

它的思路是:

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

这样不管用户是 stake、withdraw 还是 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. 默认把资金合约当成高危系统

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

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

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


分享到:

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