Web3 中级实战:用 Solidity + Hardhat 构建并审计一个可升级 DeFi 质押合约
很多人学 Solidity 时,前几课都停留在“发币”“留言板”“投票合约”这种级别;一到 DeFi 场景,难度就突然上来了:要处理资金、奖励计算、权限、升级、测试,还得考虑攻击面。
这篇我就带你做一个可升级的 DeFi 质押合约,用 Solidity + Hardhat + OpenZeppelin Upgrades 从头搭起来,并顺带做一轮基础审计思路。
这篇不是概念罗列,而是按“能跑起来、能测、能升级、知道哪里危险”这个目标来写。你如果已经写过普通 Solidity 合约,但还没真正做过 upgradeable DeFi 项目,这篇会比较合适。
背景与问题
在 DeFi 里,质押(Staking)是最常见的模式之一:
- 用户存入某个 ERC20 Token
- 按时间获得奖励
- 可随时领取奖励或提取本金
- 项目方后续可能要调整奖励率、增加新逻辑
问题也正出在这里:
-
合约一旦部署默认不可修改
- 如果奖励逻辑写错了,老合约就很难修
- 如果后续要加暂停、黑名单、手续费、治理入口,也没法直接改
-
DeFi 资金型合约容易出安全事故
- 重入攻击
- 权限控制失误
- 精度丢失导致奖励异常
- 存储布局破坏导致升级后数据错乱
-
很多人会写“能跑”的质押合约,但不会写“可维护”的
- 没有测试升级流程
- 没有事件日志
- 没有审计思维
- 遇到 “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 - 每个用户维护:
userRewardPerTokenPaidrewards
当用户质押、提取、领取奖励时,先更新全局和用户状态。
奖励公式核心是:
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 个部分:
- 测试用 ERC20
- 可升级质押合约 V1
- 部署与测试脚本
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;
逐步验证清单
如果你想确认这套代码真的“活着”,可以按这个顺序验证:
- 部署两个 Mock Token
- 部署 Staking V1 Proxy
- owner 先向 staking 注入 reward token
- user approve stake token
- user stake
- 快进时间
- 调
earned(user)看奖励是否增长 - 调
getReward()看奖励 token 是否到账 - 调
withdraw()看本金是否回退 - 升级到 V2
- 验证历史
balances是否还在 - 调用
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. 奖励不增长或增长异常
排查顺序:
rewardRate是否设置正确totalStaked是否为 0- 时间是否真的推进了
- 是否在 stake/withdraw/getReward 前执行了
updateReward - 计算精度是否使用了
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 加入暂停能力
- 梳理了常见坑、升级风险和基础审计思路
如果你准备自己动手,我给你三个最实用的建议:
-
先把测试写扎实,再谈升级
- 尤其是升级前后存储一致性测试
-
默认把资金合约当成高危系统
SafeERC20、nonReentrant、事件、权限边界都别省
-
明确边界条件
- 本文实现适用于常规 ERC20
- 不直接兼容 fee-on-transfer、rebasing 等特殊 token
- 生产环境建议加多签、暂停、参数上限、审计流程
一句话收尾:
可升级 DeFi 合约最难的不是“写出来”,而是“升级后依然正确”。
你只要把“奖励结算 + 存储布局 + 权限边界”这三件事盯紧,项目质量就会比大多数练手合约高一个层级。