使用Solidity和Hardhat构建的时间锁定收益金库

本文介绍了如何使用Solidity和Hardhat构建一个时间锁定的收益金库。它包含两个智能合约: MockERC20代币(用于测试)和TimeLockedYieldVault。该金库合约接受存款,锁定7天,并在提款时支付5%的固定利息。文章涵盖了使用OpenZeppelin库、管理具有锁定期存款、编写测试以及使用Ignition在本地Hardhat网络上部署合约的步骤。

概述

一个 时间锁定的收益金库(Time-Locked Yield Vault) 是一个智能合约,它允许用户存入代币,将它们锁定一段预定的时间,并在提款时赚取利息。

在本文中,我们将使用 SolidityHardhat 构建一个 时间锁定的收益金库(Time-Locked Yield Vault)

以下是我们的合约将要实现的功能:

  • 接受任何 ERC-20 代币的存款
  • 将每笔存款锁定一段固定的时间(例如,7 天)
  • 在锁定到期后应用固定的利率(例如,5%)
  • 允许用户提取其原始存款加上利息

我们将指导你逐步编写和测试这个智能合约。让我们开始吧。

先决条件

  • 已安装 Node.js 和 npm。
  • 具备 Solidity 和 Hardhat 的基本知识。
  • 熟悉 ERC20 代币。

项目设置

让我们使用 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 代币并管理存款、锁定周期和提款。

这两个合约协同工作以支持金库。让我们继续并开始构建它们。

MockERC20 实现

我们将使用一个自定义代币来测试我们的金库。这个模拟代币遵循 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 注解 也很重要。它们:

  • 解释目的和行为
  • 帮助其他人(和未来的你)理解代码
  • 与 Hardhat 和 Solidity IDE 等工具集成

清晰的文档在 DeFi 中尤其有价值,因为误解合约行为可能导致错误或资金损失。让我们保持透明,即使在测试环境中也是如此。

TimeLockedYieldVault 实现

让我们看看支持我们金库逻辑的核心智能合约。该合约管理存款,将代币锁定一段固定的时间,并在用户提款时应用固定的收益:

// 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);
  });
});

测试设置

在每个测试之前,我们部署以下内容的新实例:

  • 一个模拟 ERC20 代币 ( MockERC20)
  • 我们的金库合约 ( TimeLockedYieldVault)

我们分配测试帐户:owneraddr1。我们还使用测试代币为金库和用户帐户 ( 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 };
});

这个脚本的作用

  1. 我们定义一个名为 TimeLockedVaultModule 的模块。
  2. 我们部署一个名为 Mock DAI 的模拟 ERC20 代币,符号为 mDAI,初始供应量为 1000 mDAI
  3. 然后我们部署 TimeLockedYieldVault 合约,并将代币地址传递给它的构造函数。
  4. 我们将初始供应量转移到金库,以便我们可以将其用作利息。
  5. 我们返回这两个合约,以便我们可以在部署后与它们进行交互。

部署到本地节点

让我们运行一个本地 Hardhat 节点并部署合约:

$ npx hardhat node
$ npx hardhat ignition deploy ./ignition/modules/TimeLockedVault.ts --network localhost

你将看到控制台日志显示已部署的代币和金库的地址。你现在可以与本地区块链上的合约进行交互了。

总结

在本文中,我们使用 Solidity 和 Hardhat 构建了一个 时间锁定的收益金库(Time-Locked Yield Vault)

我们创建了两个智能合约:

  • 一个用于测试的 MockERC20 代币。
  • 一个 TimeLockedYieldVault,它接受存款,将它们锁定 7 天,并在提款时支付固定的 5% 的利息。

我们学习了如何:

  • 使用 OpenZeppelin 库,如 OwnableReentrancyGuard
  • 使用锁定周期管理存款,并使用结构体和映射跟踪它们。
  • 编写详尽的测试,涵盖存款、提款和边缘情况。
  • 使用 Ignition 在本地 Hardhat 网络上部署合约。

这个项目为我们构建具有时间锁定和固定利息的 DeFi 收益金库奠定了坚实的基础。你现在可以使用真实的收益策略扩展它,或将其集成到更大的协议中。

👉 在这里查看完整的源代码:

GitHub 存储库 — 时间锁定的收益金库示例

  • 原文链接: coinsbench.com/time-lock...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
CoinsBench
CoinsBench
https://coinsbench.com/