From 12e5aac27efa0e27a1ece538848703b3fc421282 Mon Sep 17 00:00:00 2001 From: Sainath More Date: Fri, 25 Jul 2025 15:47:57 +0000 Subject: [PATCH] feat: Add Time-Locked ERC-20 Vault #6 --- README.md | 57 +++++++++++++++ src/TimeLockedVault.sol | 71 ++++++++++++++++++ test/TimeLockedVault.t.sol | 144 +++++++++++++++++++++++++++++++++++++ 3 files changed, 272 insertions(+) create mode 100644 src/TimeLockedVault.sol create mode 100644 test/TimeLockedVault.t.sol diff --git a/README.md b/README.md index 31a02b6..074d980 100644 --- a/README.md +++ b/README.md @@ -68,3 +68,60 @@ $ cast --help alchemy sepolia rpc url + + +Updated README file + +//////// + +# ๐Ÿง  TimeLockedVault + +A TimeLockedVault smart contract that allows users to deposit ERC20 tokens with a time lock. Users can only withdraw their tokens after the lock period has passed. + +โœ… Features: + +- Lock ERC20 tokens for a fixed duration. + +- Prevent early withdrawals. + +- Fully tested using Foundry's Forge framework. +## โœ๏ธ Project Structure Update +This repository includes a custom smart contract called TimeLockedVault.sol, created as part of a feature implementation for locking ERC-20 token deposits with time-based withdrawal restrictions. + +๐Ÿ“„ File: src/TimeLockedVault.sol + +This contract allows users to: + +- Deposit ERC-20 tokens with a lock duration + +- Enforce time-based withdrawal logic + +- Access locked balances and check unlock time + +Unit tests are provided in test/TimeLockedVault.t.sol. +## ๐Ÿงช How to Run the Tests: + +To run tests, run the following command + +```bash + forge test --match-path test/TimeLockedVault.t.sol +``` + +Ensure your .env is set up with RPC URL and private key if needed. +## ๐Ÿ“ Files: +- Contract: src/TimeLockedVault.sol + +- Test: test/TimeLockedVault.t.sol +## ๐Ÿ›  Technologies: +- Solidity ^0.8.20 + +- Foundry (Forge) +## โœ… Optional (only if you add a script): +If you decide to write a deploy script later (like TimeLockedVault.s.sol) + +```bash + forge script script/TimeLockedVault.s.sol:TimeLockedVaultScript --rpc-url --private-key + +``` + +//////// diff --git a/src/TimeLockedVault.sol b/src/TimeLockedVault.sol new file mode 100644 index 0000000..01fe3e6 --- /dev/null +++ b/src/TimeLockedVault.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title TimeLockedVault +/// @notice Vault to deposit WLD tokens and lock them for a user-defined time period. +interface IERC20 { + function transferFrom(address sender, address recipient, uint256 amount) external returns (bool); + function transfer(address recipient, uint256 amount) external returns (bool); +} + +contract TimeLockedVault { + IERC20 public immutable wldToken; + + struct Lock { + uint256 amount; + uint256 unlockTime; + } + + mapping(address => Lock[]) public userLocks; + + event TokensLocked(address indexed user, uint256 amount, uint256 unlockTime); + event TokensWithdrawn(address indexed user, uint256 amount); + + constructor(address _wldTokenAddress) { + require(_wldTokenAddress != address(0), "Invalid token address"); + wldToken = IERC20(_wldTokenAddress); + } + + /// @notice Lock tokens for a specified duration + /// @param amount Amount of tokens to lock + /// @param lockDuration Duration in seconds to lock tokens + function lockTokens(uint256 amount, uint256 lockDuration) external { + require(amount > 0, "Amount must be greater than zero"); + require(lockDuration > 0, "Lock duration must be greater than zero"); + + bool success = wldToken.transferFrom(msg.sender, address(this), amount); + require(success, "Token transfer failed"); + + uint256 unlockTime = block.timestamp + lockDuration; + userLocks[msg.sender].push(Lock(amount, unlockTime)); + + emit TokensLocked(msg.sender, amount, unlockTime); + } + + /// @notice Withdraw tokens if the lock time has expired + /// @param index Index of the user's lock to withdraw + function withdrawTokens(uint256 index) external { + require(index < userLocks[msg.sender].length, "Invalid lock index"); + + Lock memory lock = userLocks[msg.sender][index]; + require(block.timestamp >= lock.unlockTime, "Tokens are still locked"); + + // Remove the lock entry by swapping with the last and popping + uint256 amount = lock.amount; + uint256 lastIndex = userLocks[msg.sender].length - 1; + if (index != lastIndex) { + userLocks[msg.sender][index] = userLocks[msg.sender][lastIndex]; + } + userLocks[msg.sender].pop(); + + bool success = wldToken.transfer(msg.sender, amount); + require(success, "Token transfer failed"); + + emit TokensWithdrawn(msg.sender, amount); + } + + /// @notice Get all lock records for a user + function getLocks(address user) external view returns (Lock[] memory) { + return userLocks[user]; + } +} diff --git a/test/TimeLockedVault.t.sol b/test/TimeLockedVault.t.sol new file mode 100644 index 0000000..cdf6f8a --- /dev/null +++ b/test/TimeLockedVault.t.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "../src/TimeLockedVault.sol"; + +contract MockERC20 is IERC20 { + string public name = "Mock Token"; + string public symbol = "MTKN"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balances; + mapping(address => mapping(address => uint256)) public allowance; + + function mint(address to, uint256 amount) external { + balances[to] += amount; + totalSupply += amount; + } + + function transfer(address recipient, uint256 amount) external override returns (bool) { + require(balances[msg.sender] >= amount, "Insufficient balance"); + balances[msg.sender] -= amount; + balances[recipient] += amount; + return true; + } + + function transferFrom(address sender, address recipient, uint256 amount) external override returns (bool) { + require(balances[sender] >= amount, "Insufficient balance"); + require(allowance[sender][msg.sender] >= amount, "Insufficient allowance"); + balances[sender] -= amount; + balances[recipient] += amount; + allowance[sender][msg.sender] -= amount; + return true; + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + return true; + } + + function balanceOf(address account) external view returns (uint256) { + return balances[account]; + } +} + +contract TimeLockedVaultTest is Test { + TimeLockedVault vault; + MockERC20 token; + address user = address(0xBEEF); + uint256 initialBalance = 1000 ether; + + function setUp() public { + token = new MockERC20(); + vault = new TimeLockedVault(address(token)); + token.mint(user, initialBalance); + vm.prank(user); + token.approve(address(vault), initialBalance); + } + + function testLockTokensSuccess() public { + vm.prank(user); + vault.lockTokens(100 ether, 1 days); + + TimeLockedVault.Lock[] memory locks = vault.getLocks(user); + assertEq(locks.length, 1); + assertEq(locks[0].amount, 100 ether); + } + + function testWithdrawFailsIfStillLocked() public { + vm.prank(user); + vault.lockTokens(100 ether, 1 days); + + vm.prank(user); + vm.expectRevert("Tokens are still locked"); + vault.withdrawTokens(0); + } + + function testWithdrawSucceedsAfterUnlock() public { + vm.prank(user); + vault.lockTokens(100 ether, 1 days); + + // Fast forward time + vm.warp(block.timestamp + 2 days); + + vm.prank(user); + vault.withdrawTokens(0); + + assertEq(token.balanceOf(user), initialBalance); + } + + function testLockFailsIfAmountZero() public { + vm.prank(user); + vm.expectRevert("Amount must be greater than zero"); + vault.lockTokens(0, 1 days); + } + + function testLockFailsIfDurationZero() public { + vm.prank(user); + vm.expectRevert("Lock duration must be greater than zero"); + vault.lockTokens(100 ether, 0); + } + + function testWithdrawFailsWithInvalidIndex() public { + vm.prank(user); + vm.expectRevert("Invalid lock index"); + vault.withdrawTokens(0); + } + + // --- FUZZ TESTS --- + + function testFuzz_LockAndWithdraw(uint256 amount, uint256 duration) public { + amount = bound(amount, 1 ether, 100 ether); // Limit fuzz range + duration = bound(duration, 1, 30 days); + + token.mint(address(this), amount); + token.approve(address(vault), amount); + vault.lockTokens(amount, duration); + + vm.warp(block.timestamp + duration + 1); + + vault.withdrawTokens(0); + assertEq(token.balanceOf(address(this)), amount); + } + + function testFuzz_LockMultipleEntries(uint256 a1, uint256 a2) public { + a1 = bound(a1, 1 ether, 50 ether); + a2 = bound(a2, 1 ether, 50 ether); + + token.mint(user, a1 + a2); + vm.prank(user); + token.approve(address(vault), a1 + a2); + + vm.startPrank(user); + vault.lockTokens(a1, 1 days); + vault.lockTokens(a2, 2 days); + vm.stopPrank(); + + TimeLockedVault.Lock[] memory locks = vault.getLocks(user); + assertEq(locks.length, 2); + assertEq(locks[0].amount, a1); + assertEq(locks[1].amount, a2); + } +}