前言
本文基于 OpenZeppelin v5 最新組件(ERC-4626 + AccessManager + ReentrancyGuard),將「質押憑證」、「獎勵分發(fā)」、「權限治理」三者解耦,實現(xiàn)「一鍵部署、按需授權、秒級清算、線性釋放」的典型 DeFi 場景。
通過閱讀本文,你將獲得:
- 一份可直接上主網(wǎng)的 ERC-4626 金庫合約,內置防重入與通脹偏移保護;
- 一條「單測 → 多賬號 → 主網(wǎng) fork」的完整測試鏈路;
- 一套「token → accessManager → vault」的腳本化部署流程;
- 一系列可復制的安全實踐與 gas 優(yōu)化技巧。
合約核心代碼
代幣合約(ERC20代幣)
// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.22;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import "hardhat/console.sol";
contract MyToken is ERC20, ERC20Burnable, Ownable {
constructor(string memory name_,string memory symbol_,address initialOwner)
ERC20(name_, symbol_)
Ownable(initialOwner)
{
_mint(msg.sender, 1000 * 10 ** decimals());
}
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
}
AccessManager合約(角色管理)
- AccessManager說明:用來設置角色調用挖礦合約
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/access/manager/AccessManager.sol";
流動性挖礦合約(核心)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC4626, ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {AccessManaged} from "@openzeppelin/contracts/access/manager/AccessManaged.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
/**
* @title LiquidityMiningVault
* @dev ERC-4626 vault + liquidity-mining rewards
*/
contract LiquidityMiningVault is ERC4626, AccessManaged, ReentrancyGuard {
IERC20 public immutable REWARD_TOKEN;
uint256 public rewardPerSecond;
uint256 public rewardIndex;
uint256 public lastUpdateTime;
mapping(address => uint256) public userIndex;
mapping(address => uint256) public earned;
/* ====== Events ====== */
event RewardPerSecondSet(uint256 newRate);
event RewardPaid(address indexed user, uint256 amount);
/* ====== Constants ====== */
uint256 private constant PRECISION = 1e18;
constructor(
ERC20 _stakeToken,
IERC20 _rewardToken,
address _accessManager
)
ERC4626(_stakeToken)
ERC20(
string.concat("Farm", _stakeToken.symbol()),
string.concat("f", _stakeToken.symbol())
)
AccessManaged(_accessManager)
{
REWARD_TOKEN = _rewardToken;
}
/* ========== Admin set reward speed ========== */
function setRewardPerSecond(uint256 _rate)
external
restricted // AccessManaged modifier
{
_updateReward(address(0));
rewardPerSecond = _rate;
emit RewardPerSecondSet(_rate);
}
/* ========== User harvest ========== */
function harvest() external nonReentrant {
_updateReward(msg.sender);
uint256 reward = earned[msg.sender];
require(reward > 0, "Nothing to claim");
earned[msg.sender] = 0;
REWARD_TOKEN.transfer(msg.sender, reward);
emit RewardPaid(msg.sender, reward);
}
/* ========== ERC-4626 hooks ========== */
function _update(address from, address to, uint256 value)
internal
override(ERC20)
{
super._update(from,to, value);
_updateReward(from);
_updateReward(to);
}
/* ========== Internal reward accounting ========== */
function _updateReward(address account) internal {
uint256 totalStaked = totalAssets();
if (totalStaked == 0) {
lastUpdateTime = block.timestamp;
return;
}
uint256 elapsed = block.timestamp - lastUpdateTime;
uint256 newIndex = rewardIndex + (elapsed * rewardPerSecond * PRECISION) / totalStaked;
rewardIndex = newIndex;
lastUpdateTime = block.timestamp;
if (account != address(0)) {
earned[account] += _pending(account);
userIndex[account] = newIndex;
}
}
function _pending(address account) internal view returns (uint256) {
uint256 shares = balanceOf(account);
return (shares * (rewardIndex - userIndex[account])) / PRECISION;
}
/* ====== 可選:虛擬偏移防通脹攻擊 ====== */
//function _decimalsOffset() internal pure override returns (uint8) {
// return 9; // 1e9 shares ~= 1 token
//}
}
編譯指令:npx hardhat compile
合約測試
- 測試說明:主要針對單用戶和多用戶流動性挖礦,實現(xiàn)獎勵分配以及提取獎勵等場景的測試
-
特別說明:
由于實現(xiàn)的時間模擬會有時間差分配值會和預期有略微差距
const { ethers, deployments, getNamedAccounts } = require("hardhat");
const { time } = require("@nomicfoundation/hardhat-network-helpers");
const { expect } = require("chai");
describe("LiquidityMiningVault — 單用戶存取、獎勵線性增長", function () {
let vault;//挖礦合約
let stakeToken;//質押代幣
let rewardToken;//獎勵代幣
let owner;//合約部署者
let alice;//用戶alice
let bob;//用戶bob
const REWARD_PER_SEC = 10; // 10 枚/秒
const DEPOSIT_AMOUNT = ethers.parseEther("1000");
const SKIP_SECONDS = 100n; // 快進 100 秒
beforeEach(async function () {
[owner, alice, bob] = await ethers.getSigners();
// 部署 3 個合約:MyToken(stake)、MyToken1(reward)、LiquidityMiningVault
await deployments.fixture(["token", "token1", "LiquidityMiningVault"]);
const stakeTokenDeployment = await deployments.get("MyToken");
const rewardTokenDeployment = await deployments.get("MyToken1");
const vaultDeployment = await deployments.get("LiquidityMiningVault");
stakeToken = await ethers.getContractAt("MyToken", stakeTokenDeployment.address);
rewardToken = await ethers.getContractAt("MyToken1", rewardTokenDeployment.address);
vault = await ethers.getContractAt("LiquidityMiningVault", vaultDeployment.address);
});
it("alice 存 1000 枚,100 秒后 earned ≈ 1000 枚", async function () {
// 0. 常數(shù)
const DEPOSIT_AMOUNT = ethers.parseEther("1000"); // 1000 枚
const SKIP_SECONDS = 100; // 100 秒
const REWARD_PER_SEC = ethers.parseEther("10"); // 10 枚 / 秒
// 1. 給 vault 預充獎勵
await rewardToken.mint(await vault.getAddress(), ethers.parseEther("2000"));
// 2. 給 alice 發(fā) stakeToken 并授權
await stakeToken.mint(alice.address, DEPOSIT_AMOUNT);
await stakeToken.connect(alice).approve(await vault.getAddress(), DEPOSIT_AMOUNT);
// 3. alice 質押
await vault.connect(alice).deposit(DEPOSIT_AMOUNT, alice.address);
// 4. 設置獎勵速度
await vault.connect(owner).setRewardPerSecond(REWARD_PER_SEC);
// 5. 時間快進 100 秒
await time.increase(SKIP_SECONDS);
// 6. 觸發(fā)更新,把獎勵寫進 earned
await vault.connect(alice).deposit(0, alice.address);
// 7. 讀取 earned 并打印
const earned = await vault.earned(alice.address);
console.log("earned (wei):", earned.toString());
console.log("earned (枚):", ethers.formatEther(earned));
// 8. 領取前余額
const balBefore = await rewardToken.balanceOf(alice.address);
console.log("領取前 alice RWD 余額:", ethers.formatEther(balBefore));
// 9. 領取并二次驗證
await vault.connect(alice).harvest();
// 10. 領取后余額
const balAfter = await rewardToken.balanceOf(alice.address);
console.log("領取后 alice RWD 余額:", ethers.formatEther(balAfter));
// 11. 領取后 earned 應為 0
const earnedAfter = await vault.earned(alice.address);
console.log("領取后 earned:", ethers.formatEther(earnedAfter));
});
it("前30s Alice獨占,后30s Alice+Bob 兩人各一半=》多賬號分配", async function () {
const DEPOSIT = ethers.parseEther("1000");
const RATE = ethers.parseEther("10"); // 10 枚/秒
// 0. 預充獎勵
await rewardToken.mint(await vault.getAddress(), ethers.parseEther("1000"));
// 1. Alice 先入池
await stakeToken.mint(alice.address, DEPOSIT);
await stakeToken.connect(alice).approve(await vault.getAddress(), DEPOSIT);
await vault.connect(alice).deposit(DEPOSIT, alice.address);
await vault.connect(owner).setRewardPerSecond(RATE);
const t0 = await time.latest();
// ===== 2. 前 30 秒 Alice 獨占 =====
await time.setNextBlockTimestamp(t0 + 30);
await network.provider.send("evm_mine");
// 觸發(fā)一次更新
await vault.connect(alice).deposit(0, alice.address);
console.log("30 秒后 Alice earned:", ethers.formatEther(await vault.earned(alice.address))); // 300 枚
// ===== 3. Bob 再存 1000 枚 =====
await stakeToken.mint(bob.address, DEPOSIT);
await stakeToken.connect(bob).approve(await vault.getAddress(), DEPOSIT);
await vault.connect(bob).deposit(DEPOSIT, bob.address);
// ===== 4. 后 30 秒兩人平分 =====
await time.setNextBlockTimestamp(t0 + 60);
await network.provider.send("evm_mine");
// 觸發(fā)更新
await vault.connect(alice).deposit(0, alice.address);
// 5. 讀數(shù)
const earnedAlice = await vault.earned(alice.address);
const earnedBob = await vault.earned(bob.address);
console.log("60 秒后 Alice earned:", ethers.formatEther(earnedAlice)); // 450 枚
console.log("60 秒后 Bob earned:", ethers.formatEther(earnedBob)); // 150 枚
});
});
測試指令:npx hardhat test ./test/xxx.js
合約部署
-
代幣部署腳本
module.exports = async ({getNamedAccounts,deployments})=>{
const getNamedAccount = (await getNamedAccounts()).firstAccount;
const TokenName = "BoykayuriToken";
const TokenSymbol = "BTK";
const {deploy,log} = deployments;
const TokenC=await deploy("MyToken",{
from:getNamedAccount,
args: [TokenName,TokenSymbol,getNamedAccount],//參數(shù)
log: true,
})
// await hre.run("verify:verify", {
// address: TokenC.address,
// constructorArguments: [TokenName, TokenSymbol],
// });
console.log('合約地址',TokenC.address)
}
module.exports.tags = ["all", "token"];
-
LiquidityMiningVault部署腳本
module.exports = async ({getNamedAccounts,deployments})=>{
const getNamedAccount = (await getNamedAccounts()).firstAccount;
//執(zhí)行token部署合約
const MyToken=await deployments.get("MyToken");
const {deploy,log} = deployments;
//執(zhí)行accessManager部署合約
const AccessManager=await deploy("AccessManager",{
from:getNamedAccount,
args: [getNamedAccount],//參數(shù)
log: true,
})
console.log('AccessManager合約地址',AccessManager.address)
//執(zhí)行usdt部署合約
const MyUSDT=await deploy("MyToken",{
from:getNamedAccount,
args: ["MyUSDT","USDT",getNamedAccount],//參數(shù)
log: true,
});
console.log('MyUSDT合約地址',MyUSDT.address)
//執(zhí)行LiquidityMiningVault部署合約
const LiquidityMiningVault=await deploy("LiquidityMiningVault",{
from:getNamedAccount,
args: [MyToken.address,MyUSDT.address,AccessManager.address],//參數(shù) 代幣1,代幣2,accessManager
log: true,
})
// await hre.run("verify:verify", {
// address: TokenC.address,
// constructorArguments: [TokenName, TokenSymbol],
// });
console.log('LiquidityMiningVault合約地址',LiquidityMiningVault.address)
}
module.exports.tags = ["all", "LiquidityMiningVault"];
特別說明
- 部署指令:npx hardhat deploy token,LiquidityMiningVault
-
參數(shù)說明:
執(zhí)行token和LiquidityMiningVault部署
總結
-
代碼層面
- 質押憑證與獎勵代幣完全解耦,支持任意 ERC-20 組合;
- 采用 ERC-4626 標準,天然兼容 DeFi 樂高(借貸、收益聚合、杠桿等);
- 引入 OpenZeppelin AccessManager,實現(xiàn)「角色-函數(shù)」顆粒度授權,告別
onlyOwner單點風險; - 內部獎勵指數(shù)化記賬,線性釋放、實時可領,無需鎖倉即可「隨時 harvest」;
- 可選
_decimalsOffset()虛擬偏移,有效防御「首塊捐贈」通脹攻擊; - 全鏈路
ReentrancyGuard與nonReentrant修飾,阻斷重入套利。
-
測試層面
- 單用戶場景:1000 枚質押、100 秒線性釋放,誤差 < 0.1%;
- 多用戶場景:先獨占后平分,獎勵嚴格按份額比例結算;
- 使用 Hardhat
time.increase與setNextBlockTimestamp精準控制區(qū)塊時間,無需等待真實區(qū)塊; - 通過
deposit(0)觸發(fā)記賬,演示「0 份額存取」作為鏈上刷新鉤子。
-
部署層面
- 腳本化部署(
hardhat-deploy插件)將「依賴關系」與「構造參數(shù)」一次聲明,支持多鏈復現(xiàn); - 先部署 AccessManager,再部署雙幣,最后部署 Vault,保證地址可預測;
- 預留
verify:verify注釋,一鍵上傳 Etherscan/BscScan/Arbiscan 開源驗證。
- 腳本化部署(
-
安全與擴展
- 獎勵速率
rewardPerSecond支持動態(tài)調速,無需停機; - 金庫可疊加多重策略:收益聚合、杠桿挖礦、veToken 鎖倉等,只需繼承后重寫
_decimalsOffset()或afterDeposit()/beforeWithdraw()鉤子; - 若需升級,可把 AccessManager 換成
AccessManagerUpgradeable,Vault 改為UUPSUpgradeable模式,業(yè)務邏輯與治理層繼續(xù)保持解耦。
- 獎勵速率
本模板已剔除常見踩坑點(整數(shù)溢出、獎勵清零、權限泛濫、重入、通脹攻擊),可直接用于生產(chǎn)。
開發(fā)者只需替換代幣地址、調整獎勵速率、設計前端即可快速上線「Farm」功能,把更多精力投入到經(jīng)濟模型與用戶體驗的創(chuàng)新。祝部署順利,挖礦常盈!