本文介绍了如何使用Solidity和Hardhat构建一个时间锁定的收益金库。它包含两个智能合约: MockERC20代币(用于测试)和TimeLockedYieldVault。该金库合约接受存款,锁定7天,并在提款时支付5%的固定利息。文章涵盖了使用OpenZeppelin库、管理具有锁定期存款、编写测试以及使用Ignition在本地Hardhat网络上部署合约的步骤。
一个 时间锁定的收益金库(Time-Locked Yield Vault) 是一个智能合约,它允许用户存入代币,将它们锁定一段预定的时间,并在提款时赚取利息。
在本文中,我们将使用 Solidity 和 Hardhat 构建一个 时间锁定的收益金库(Time-Locked Yield Vault)。
以下是我们的合约将要实现的功能:
我们将指导你逐步编写和测试这个智能合约。让我们开始吧。
让我们使用 Hardhat 设置我们的项目。如果你不熟悉 Hardhat,请先查看我们的关于如何初始化 Hardhat 项目的指南。
我们将把我们的项目命名为 time-locked-yield-vault
。
首先,创建一个新目录并初始化 Hardhat:
$ mkdir time-locked-yield-vault
$ cd time-locked-yield-vault
$ npx hardhat
...
接下来,安装 OpenZeppelin Contracts 库:
$ npm install @openzeppelin/contracts
我们使用
@openzeppelin/contracts
来访问安全、经过实战检验的 ERC-20 代币、所有权模式和其他 Solidity 标准的实现。它可以节省时间并帮助我们避免从头开始编写底层代码。
现在,让我们安装 ChaiMatchers:
$ npm install --save-dev @nomicfoundation/hardhat-chai-matchers
我们将在测试中使用它们来获得 anyValue
匹配。另外,我们必须将这些依赖项添加到我们的 hardhat.config.js
中:
require("@nomicfoundation/hardhat-chai-matchers");
有了这些,我们就可以开始构建了。
我们将为这个项目构建两个智能合约。
MockERC20 —— 这是一个简单的 ERC-20 代币实现。它包括所有权控制,因此我们可以为测试和开发铸造代币。
TimeLockedYieldVault —— 这个合约处理所有核心逻辑。它连接到我们的 ERC-20 代币并管理存款、锁定周期和提款。
这两个合约协同工作以支持金库。让我们继续并开始构建它们。
我们将使用一个自定义代币来测试我们的金库。这个模拟代币遵循 ERC-20 标准,并且包括所有权控制,因此我们可以在开发期间铸造和管理供应。
这是代码:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/// @title MockERC20 Token
/// @author kivanov
/// @notice This is a simple ERC20 token with ownership control for testing or simulation purposes.
/// @dev Useful for unit testing DeFi protocols where token behavior (minting, transfer) is simulated.
/// Based on OpenZeppelin's ERC20 and Ownable implementations.
contract MockERC20 is ERC20, Ownable {
/// @notice Deploys the MockERC20 token with initial parameters.
/// @param name Name of the token (e.g., "Mock DAI").
/// @param symbol Token symbol (e.g., "mDAI").
/// @param initialSupply Amount of tokens to mint initially (in wei).
/// @param initialOwner Address that receives admin (ownership) rights.
constructor(
string memory name,
string memory symbol,
uint256 initialSupply,
address initialOwner
) ERC20(name, symbol) Ownable(initialOwner) {
_mint(initialOwner, initialSupply);
}
}
让我们分解一下:
SPDX-License-Identifier: MIT
这一行告诉编译器和工具,这个合约使用了 MIT 许可证。这是一个在 Solidity 项目中常用的宽松的开源许可证。包含它还有助于避免编译期间的许可证警告。
pragma solidity ^0.8.28;
这一行定义了合约可以编译的 Solidity 的最低版本。我们将其锁定为 0.8.28 及以上版本,以获得兼容性和安全功能,例如内置的溢出检查。
来自 OpenZeppelin 的 Ownable
合约为我们提供了一个简单的管理系统。它允许我们定义谁拥有合约,并将函数(如铸造)限制给该所有者。我们在构造函数中传递 initialOwner
以在部署时定义所有权。这种模式在测试和模拟中很有用。例如,在编写单元测试时,我们可以铸造新的代币或模拟基于所有权的访问控制。
我们为什么要记录一切
即使对于模拟合约,详细的注释和 NatSpec 注解 也很重要。它们:
清晰的文档在 DeFi 中尤其有价值,因为误解合约行为可能导致错误或资金损失。让我们保持透明,即使在测试环境中也是如此。
让我们看看支持我们金库逻辑的核心智能合约。该合约管理存款,将代币锁定一段固定的时间,并在用户提款时应用固定的收益:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "hardhat/console.sol";
/// @title Time-Locked Yield Vault (Mock Version)
/// @author kivanov
/// @notice Allows users to deposit ERC20 tokens and earn a fixed 5% yield after a 7-day lock period.
/// @dev This mock vault assumes the token has pre-funded the vault contract. No minting is performed.
/// Designed for simulations, testing, or educational purposes. Not production-ready.
/// Withdrawals depend on pre-funded balance in the vault to pay interest.
contract TimeLockedYieldVault is ReentrancyGuard {
/// @notice The ERC20 token used in the vault.
IERC20 public immutable token;
/// @notice Fixed interest rate (5%).
uint256 public constant INTEREST_RATE = 5;
/// @notice Lock duration after deposit before withdrawal is allowed (7 days).
uint256 public constant LOCK_DURATION = 7 days;
/// @dev Struct representing a user deposit and lock status.
struct Deposit {
uint256 amount;
uint256 unlockTime;
bool withdrawn;
}
/// @notice Tracks deposits per user.
mapping(address => Deposit[]) public deposits;
/// @notice Emitted when a user deposits tokens into the vault.
event Deposited(address indexed user, uint256 amount, uint256 unlockTime);
/// @notice Emitted when a user withdraws tokens from the vault.
event Withdrawn(address indexed user, uint256 amount);
/// @param _token Address of the ERC20 token used in this vault.
constructor(address _token) {
require(_token != address(0), "Invalid token address");
token = IERC20(_token);
console.log("token.totalSupply: ", token.totalSupply());
}
/// @notice Deposit tokens into the vault and start lock period.
/// @param _amount Amount of tokens to deposit (must be > 0).
function deposit(uint256 _amount) external {
require(_amount > 0, "Amount must be greater than zero");
bool success = token.transferFrom(msg.sender, address(this), _amount);
require(success, "Transfer failed");
uint256 unlockTime = block.timestamp + LOCK_DURATION;
deposits[msg.sender].push(
Deposit({amount: _amount, unlockTime: unlockTime, withdrawn: false})
);
emit Deposited(msg.sender, _amount, unlockTime);
}
/// @notice Withdraw the principal and fixed interest after the lock period.
/// @param _depositIndex Index of the deposit to withdraw.
function withdraw(uint256 _depositIndex) external nonReentrant {
require(
_depositIndex < deposits[msg.sender].length,
"Invalid deposit index"
);
Deposit storage userDeposit = deposits[msg.sender][_depositIndex];
require(!userDeposit.withdrawn, "Already withdrawn");
require(
block.timestamp >= userDeposit.unlockTime,
"Funds are still locked"
);
uint256 interest = (userDeposit.amount * INTEREST_RATE) / 100;
uint256 payout = userDeposit.amount + interest;
require(
token.balanceOf(address(this)) >= payout,
"Insufficient vault balance"
);
console.log("token.totalSupply (withdraw): ", token.totalSupply());
console.log("payout (withdraw): ", payout);
userDeposit.withdrawn = true;
require(token.transfer(msg.sender, payout), "Transfer failed");
emit Withdrawn(msg.sender, payout);
}
/// @notice View all deposits for a given user.
/// @param _user The address of the user.
/// @return Array of Deposit structs.
function getDeposits(
address _user
) external view returns (Deposit[] memory) {
return deposits[_user];
}
}
关键属性 token
:我们接受存款的 ERC-20 代币。它是不可变的,这意味着我们在部署期间设置一次,并且永远不会更改它。
INTEREST_RATE
:锁定周期后应用的固定 5% 的利息。
LOCK_DURATION
:锁定时间设置为 7 天。
事件 Deposited
:当用户存入代币时触发。
Withdrawn
:当用户成功提取本金和利息时触发。
这些事件可以帮助跟踪链上的活动,并且对前端、仪表板或链下索引工具很有用。
Deposit
结构体 我们定义一个名为 Deposit
的结构体来跟踪每个用户的存款详细信息:
struct Deposit {
uint256 amount;
uint256 unlockTime;
bool withdrawn;
}
每个用户可以进行多次存款。我们将它们作为数组存储在 deposits[msg.sender]
下。这种结构使我们能够管理每个用户的多个独立锁定周期,从而使金库更加灵活和真实。
重入保护 我们继承自 OpenZeppelin 的 ReentrancyGuard
,并用 nonReentrant
标记 withdraw
函数。
为什么?为了防止重入攻击 —— DeFi 中最常见的漏洞之一。如果没有这个保护,恶意合约可能会在余额更新之前重复调用 withdraw
,从而耗尽金库。
验证 我们验证了几件事以保持合约的安全性和可预测性:
require
来捕获任何失败。控制台日志 我们还添加了来自 Hardhat 的 console.log
语句。这些在测试期间很有用,尤其是在检查代币供应和支付时。
让我们用一个完整的测试套件来验证我们的 TimeLockedYieldVault
。我们将使用 Hardhat、Chai 和 Ethers.js。以下是测试代码:
const { expect } = require("chai");
const { ethers } = require("hardhat");
const { anyValue } = require("@nomicfoundation/hardhat-chai-matchers/withArgs");
describe("TimeLockedYieldVault", function () {
let Token, token, Vault, vault, owner, addr1;
const initialSupply = ethers.parseEther("1000");
const depositAmount = ethers.parseEther("100");
const expectedAmount = ethers.parseEther("105");
const zero = ethers.parseEther("0");
beforeEach(async function () {
[owner, addr1] = await ethers.getSigners();
// Deploy mock ERC20 token
Token = await ethers.getContractFactory("MockERC20");
token = await Token.deploy("Mock DAI", "mDAI", initialSupply, owner.getAddress());
await token.waitForDeployment();
// Deploy vault contract
Vault = await ethers.getContractFactory("TimeLockedYieldVault");
vault = await Vault.deploy(token.getAddress());
await vault.waitForDeployment();
// Transfer ownership of the token to the vault
await token.transferOwnership(vault.getAddress());
// Transfer tokens to addr1
await token.transfer(vault.getAddress(), initialSupply - depositAmount);
await token.transfer(addr1.getAddress(), depositAmount);
});
it("Should allow deposit and withdrawal after lock period", async function () {
await token.connect(addr1).approve(vault.getAddress(), depositAmount);
await expect(vault.connect(addr1).deposit(depositAmount))
.to.emit(vault, "Deposited")
.withArgs(addr1.getAddress(), depositAmount, anyValue);
// Increase time by 8 days
await ethers.provider.send("evm_increaseTime", [8 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine");
await expect(vault.connect(addr1).withdraw(0))
.to.emit(vault, "Withdrawn")
.withArgs(addr1.getAddress(), expectedAmount);
const finalBalance = await token.balanceOf(addr1.getAddress());
await expect(finalBalance).to.be.equal(expectedAmount);
});
it("Should not allow withdrawal before lock period", async function () {
await token.connect(addr1).approve(vault.getAddress(), depositAmount);
await vault.connect(addr1).deposit(depositAmount);
await expect(vault.connect(addr1).withdraw(0)).to.be.revertedWith("Funds are still locked");
const finalBalance = await token.balanceOf(addr1.getAddress());
const initialBalance = await token.balanceOf(addr1.getAddress());
expect(finalBalance).to.be.equal(initialBalance);
});
it("Should not allow withdrawal when the tokens were already withdrawn", async function () {
await token.connect(addr1).approve(vault.getAddress(), depositAmount);
await vault.connect(addr1).deposit(depositAmount);
await ethers.provider.send("evm_increaseTime", [8 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine");
await vault.connect(addr1).withdraw(0);
await expect(vault.connect(addr1).withdraw(0)).to.be.revertedWith("Already withdrawn");
const finalBalance = await token.balanceOf(addr1.getAddress());
const initialBalance = await token.balanceOf(addr1.getAddress());
expect(finalBalance).to.be.equal(initialBalance);
});
it("Should not allow deposit the sum equal to zero", async function () {
await expect(vault.connect(addr1).deposit(zero)).to.be.revertedWith("Amount must be greater than zero");
const finalBalance = await token.balanceOf(addr1.getAddress());
const initialBalance = await token.balanceOf(addr1.getAddress());
expect(finalBalance).to.be.equal(initialBalance);
});
it("Should return deposits for user after deposit", async function () {
// Approve token and deposit
await token.connect(addr1).approve(vault.getAddress(), depositAmount);
await vault.connect(addr1).deposit(depositAmount);
// Fetch deposits
const deposits = await vault.getDeposits(addr1.getAddress());
expect(deposits.length).to.equal(1);
expect(deposits[0].amount).to.equal(depositAmount);
expect(deposits[0].withdrawn).to.be.false;
});
it("Should return empty deposits array if user has not deposited", async function () {
const deposits = await vault.getDeposits(addr1.getAddress());
expect(deposits.length).to.equal(0);
});
it("Should return empty array for address(0)", async function () {
const deposits = await vault.getDeposits(ethers.ZeroAddress);
expect(deposits.length).to.equal(0);
});
});
在每个测试之前,我们部署以下内容的新实例:
MockERC20
)TimeLockedYieldVault
)我们分配测试帐户:owner
和 addr1
。我们还使用测试代币为金库和用户帐户 ( addr1
) 提供资金,并将代币的所有权转移到金库。此设置确保了每个测试的干净、隔离的条件。
存款和提款流程
我们检查happy path。用户存入代币,等待 8 天,然后成功提款。我们期望有一个 Withdrawn
事件,并且金额正确(包括 5% 的收益)。我们还在此处检查发出的事件:
当用户存款时,会发出 Deposited
事件。
当用户在锁定后提款时,会发出 Withdrawn
事件。
阻止提前提款 我们尝试在锁定周期结束前提款。这应该会失败,并显示 "Funds are still locked"
。
阻止重复提款 我们验证是否不允许从同一笔存款中提款两次。第二次调用必须使用 "Already withdrawn"
回滚。
阻止零存款 存入零代币应失败,并显示 "Amount must be greater than zero"
。
存储存款 我们测试金库是否正确记录存款。在一次存款后,用户的 deposits
数组应该有一个条目,金额正确且 withdrawn = false
。
没有存款的备用方案 我们检查调用 没有存款 的地址的 getDeposits
是否返回一个空数组。我们还确认它是否适用于零地址。
这些测试帮助我们尽早发现错误,并保证核心金库逻辑在不同条件下都能正常工作。我们涵盖了预期的流程和边缘情况,包括滥用和边界条件。
运行测试用例,我们会看到类似于以下图片的内容:
$ npx hardhat test
TimeLockedYieldVault
token.totalSupply: 1000000000000000000000
token.totalSupply (withdraw): 1000000000000000000000
payout (withdraw): 105000000000000000000
✔ Should allow deposit and withdrawal after lock period
token.totalSupply: 1000000000000000000000
✔ Should not allow withdrawal before lock period
token.totalSupply: 1000000000000000000000
token.totalSupply (withdraw): 1000000000000000000000
payout (withdraw): 105000000000000000000
✔ Should not allow withdrawal when the tokens were already withdrawn
token.totalSupply: 1000000000000000000000
✔ Should not allow deposit the sum equal to zero
token.totalSupply: 1000000000000000000000
✔ Should return deposits for user after deposit
token.totalSupply: 1000000000000000000000
✔ Should return empty deposits array if user has not deposited
token.totalSupply: 1000000000000000000000
✔ Should return empty array for address(0)
7 passing
让我们使用 Hardhat Ignition 将我们的智能合约部署到本地 Hardhat 节点。这是部署脚本 (ignition/modules/TimeLockedVault.ts
):
const { buildModule } = require("@nomicfoundation/hardhat-ignition/modules");
const ONE_ETHER = 10n ** 18n;
const INITIAL_SUPPLY = 1_000_000n * ONE_ETHER;
module.exports = buildModule("TimeLockedVaultModule", (m) => {
const tokenName = m.getParameter("tokenName", "Mock Token");
const tokenSymbol = m.getParameter("tokenSymbol", "MTKN");
const initialSupply = m.getParameter("initialSupply", INITIAL_SUPPLY);
const deployer = m.getAccount(0);
// Deploy MockERC20 token with initial supply to deployer
const token = m.contract("MockERC20", [\
tokenName,\
tokenSymbol,\
initialSupply,\
deployer,\
]);
// Deploy TimeLockedYieldVault using the token address
const vault = m.contract("TimeLockedYieldVault", [token]);
// After both contracts are deployed, transfer tokens to the vault
m.call(token, "transfer", [vault, INITIAL_SUPPLY]);
return { token, vault };
});
TimeLockedVaultModule
的模块。Mock DAI
的模拟 ERC20 代币,符号为 mDAI
,初始供应量为 1000 mDAI
。TimeLockedYieldVault
合约,并将代币地址传递给它的构造函数。让我们运行一个本地 Hardhat 节点并部署合约:
$ npx hardhat node
$ npx hardhat ignition deploy ./ignition/modules/TimeLockedVault.ts --network localhost
你将看到控制台日志显示已部署的代币和金库的地址。你现在可以与本地区块链上的合约进行交互了。
在本文中,我们使用 Solidity 和 Hardhat 构建了一个 时间锁定的收益金库(Time-Locked Yield Vault)。
我们创建了两个智能合约:
我们学习了如何:
Ownable
和 ReentrancyGuard
。这个项目为我们构建具有时间锁定和固定利息的 DeFi 收益金库奠定了坚实的基础。你现在可以使用真实的收益策略扩展它,或将其集成到更大的协议中。
👉 在这里查看完整的源代码:
- 原文链接: coinsbench.com/time-lock...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!