From a40256f880d4a913f68cd4e7f9d2551c2a03c206 Mon Sep 17 00:00:00 2001 From: JTKaduma Date: Mon, 2 Mar 2026 11:59:47 +0100 Subject: [PATCH 01/10] feat: add testing with foundry assignment --- .gitignore | 3 +++ assignments/testing-with-foundry | 1 + 2 files changed, 4 insertions(+) create mode 160000 assignments/testing-with-foundry diff --git a/.gitignore b/.gitignore index 60f73e90..77ba2a7f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ Thumbs.db .idea/ *.swp *.swo + + +lib/ \ No newline at end of file diff --git a/assignments/testing-with-foundry b/assignments/testing-with-foundry new file mode 160000 index 00000000..e886372f --- /dev/null +++ b/assignments/testing-with-foundry @@ -0,0 +1 @@ +Subproject commit e886372fa32412df2f8ca225d07eabcf9d044348 From 3172e2fa67983cf2d3297470da1a39933b8d4d56 Mon Sep 17 00:00:00 2001 From: JTKaduma Date: Mon, 2 Mar 2026 12:41:34 +0100 Subject: [PATCH 02/10] fix: fix submodule problem --- .../.github/workflows/test.yml | 38 +++ .../foundry-test-assignment/.gitignore | 17 ++ .../foundry-test-assignment/.gitmodules | 3 + assignments/foundry-test-assignment/README.md | 66 ++++++ .../foundry-test-assignment/foundry.lock | 8 + .../foundry-test-assignment/foundry.toml | 6 + .../foundry-test-assignment/src/CounterV3.sol | 60 +++++ .../foundry-test-assignment/src/Vault.sol | 152 ++++++++++++ .../test/CounterV3.t.sol | 124 ++++++++++ .../foundry-test-assignment/test/Vault.t.sol | 221 ++++++++++++++++++ assignments/testing-with-foundry | 1 - 11 files changed, 695 insertions(+), 1 deletion(-) create mode 100644 assignments/foundry-test-assignment/.github/workflows/test.yml create mode 100644 assignments/foundry-test-assignment/.gitignore create mode 100644 assignments/foundry-test-assignment/.gitmodules create mode 100644 assignments/foundry-test-assignment/README.md create mode 100644 assignments/foundry-test-assignment/foundry.lock create mode 100644 assignments/foundry-test-assignment/foundry.toml create mode 100644 assignments/foundry-test-assignment/src/CounterV3.sol create mode 100644 assignments/foundry-test-assignment/src/Vault.sol create mode 100644 assignments/foundry-test-assignment/test/CounterV3.t.sol create mode 100644 assignments/foundry-test-assignment/test/Vault.t.sol delete mode 160000 assignments/testing-with-foundry diff --git a/assignments/foundry-test-assignment/.github/workflows/test.yml b/assignments/foundry-test-assignment/.github/workflows/test.yml new file mode 100644 index 00000000..b79c8d4f --- /dev/null +++ b/assignments/foundry-test-assignment/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: CI + +permissions: {} + +on: + push: + pull_request: + workflow_dispatch: + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + name: Foundry project + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Show Forge version + run: forge --version + + - name: Run Forge fmt + run: forge fmt --check + + - name: Run Forge build + run: forge build --sizes + + - name: Run Forge tests + run: forge test -vvv diff --git a/assignments/foundry-test-assignment/.gitignore b/assignments/foundry-test-assignment/.gitignore new file mode 100644 index 00000000..18d49a69 --- /dev/null +++ b/assignments/foundry-test-assignment/.gitignore @@ -0,0 +1,17 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env + + +lib/ \ No newline at end of file diff --git a/assignments/foundry-test-assignment/.gitmodules b/assignments/foundry-test-assignment/.gitmodules new file mode 100644 index 00000000..888d42dc --- /dev/null +++ b/assignments/foundry-test-assignment/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/assignments/foundry-test-assignment/README.md b/assignments/foundry-test-assignment/README.md new file mode 100644 index 00000000..8817d6ab --- /dev/null +++ b/assignments/foundry-test-assignment/README.md @@ -0,0 +1,66 @@ +## Foundry + +**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** + +Foundry consists of: + +- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). +- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. +- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. +- **Chisel**: Fast, utilitarian, and verbose solidity REPL. + +## Documentation + +https://book.getfoundry.sh/ + +## Usage + +### Build + +```shell +$ forge build +``` + +### Test + +```shell +$ forge test +``` + +### Format + +```shell +$ forge fmt +``` + +### Gas Snapshots + +```shell +$ forge snapshot +``` + +### Anvil + +```shell +$ anvil +``` + +### Deploy + +```shell +$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key +``` + +### Cast + +```shell +$ cast +``` + +### Help + +```shell +$ forge --help +$ anvil --help +$ cast --help +``` diff --git a/assignments/foundry-test-assignment/foundry.lock b/assignments/foundry-test-assignment/foundry.lock new file mode 100644 index 00000000..bc06b89b --- /dev/null +++ b/assignments/foundry-test-assignment/foundry.lock @@ -0,0 +1,8 @@ +{ + "lib/forge-std": { + "tag": { + "name": "v1.15.0", + "rev": "0844d7e1fc5e60d77b68e469bff60265f236c398" + } + } +} \ No newline at end of file diff --git a/assignments/foundry-test-assignment/foundry.toml b/assignments/foundry-test-assignment/foundry.toml new file mode 100644 index 00000000..25b918f9 --- /dev/null +++ b/assignments/foundry-test-assignment/foundry.toml @@ -0,0 +1,6 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/assignments/foundry-test-assignment/src/CounterV3.sol b/assignments/foundry-test-assignment/src/CounterV3.sol new file mode 100644 index 00000000..269dfb0c --- /dev/null +++ b/assignments/foundry-test-assignment/src/CounterV3.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +contract CounterV3 { + uint256 public number; + address public owner; + + mapping(address => bool) public approvedCallers; + mapping(address => bool) public pendingApproval; + + event ApprovalRequested(address indexed caller); + event ApprovalGranted(address indexed caller); + event ApprovalRevoked(address indexed caller); + + constructor() { + owner = msg.sender; + } + + modifier onlyOwner() { + require(msg.sender == owner, "Not owner"); + _; + } + + modifier onlyAuthorized() { + if (msg.sender != owner) { + require(approvedCallers[msg.sender], "Owner consent required"); + } + _; + } + + function requestPrivilege() external { + require(msg.sender != owner, "Owner does not need consent"); + + pendingApproval[msg.sender] = true; + emit ApprovalRequested(msg.sender); + } + + function grantPrivilege(address caller) external onlyOwner { + require(pendingApproval[caller], "No pending request"); + + approvedCallers[caller] = true; + pendingApproval[caller] = false; + emit ApprovalGranted(caller); + } + + function revokePrivilege(address caller) external onlyOwner { + require(approvedCallers[caller], "Caller not approved"); + + approvedCallers[caller] = false; + emit ApprovalRevoked(caller); + } + + function setNumber(uint256 newNumber) public onlyAuthorized { + number = newNumber; + } + + function increment() public onlyAuthorized { + number++; + } +} diff --git a/assignments/foundry-test-assignment/src/Vault.sol b/assignments/foundry-test-assignment/src/Vault.sol new file mode 100644 index 00000000..34ccb235 --- /dev/null +++ b/assignments/foundry-test-assignment/src/Vault.sol @@ -0,0 +1,152 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +contract TimeLock { + struct Vault { + uint balance; + uint unlockTime; + bool active; + } + + + mapping(address => Vault[]) private vaults; + + event Deposited(address indexed user, uint vaultId, uint amount, uint unlockTime); + event Withdrawn(address indexed user, uint vaultId, uint amount); + + function deposit(uint _unlockTime) external payable returns (uint) { + require(msg.value > 0, "Deposit must be greater than zero"); + require(_unlockTime > block.timestamp, "Unlock time must be in the future"); + + // Create new vault + vaults[msg.sender].push(Vault({ + balance: msg.value, + unlockTime: _unlockTime, + active: true + })); + + uint vaultId = vaults[msg.sender].length - 1; + emit Deposited(msg.sender, vaultId, msg.value, _unlockTime); + + return vaultId; + } +function withdraw(uint _vaultId) external { + require(_vaultId < vaults[msg.sender].length, "Invalid vault ID"); + + Vault storage userVault = vaults[msg.sender][_vaultId]; + require(userVault.active, "Vault is not active"); + require(userVault.balance > 0, "Vault has zero balance"); + require(block.timestamp >= userVault.unlockTime, "Funds are still locked"); + + uint amount = userVault.balance; + + // Mark vault as inactive and clear balance + userVault.balance = 0; + userVault.active = false; + + (bool success, ) = payable(msg.sender).call{value: amount}(""); + require(success, "Transfer failed"); + + emit Withdrawn(msg.sender, _vaultId, amount); + } + function withdrawAll() external returns (uint) { + uint totalWithdrawn = 0; + Vault[] storage userVaults = vaults[msg.sender]; + + for (uint i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && + userVaults[i].balance > 0 && + block.timestamp >= userVaults[i].unlockTime) { + + uint amount = userVaults[i].balance; + userVaults[i].balance = 0; + userVaults[i].active = false; + + totalWithdrawn += amount; + emit Withdrawn(msg.sender, i, amount); + } + } + + require(totalWithdrawn > 0, "No unlocked funds available"); + + (bool success, ) = payable(msg.sender).call{value: totalWithdrawn}(""); + require(success, "Transfer failed"); + + return totalWithdrawn; + } + function getVaultCount(address _user) external view returns (uint) { + return vaults[_user].length; + } + function getVault(address _user, uint _vaultId) external view returns ( + uint balance, + uint unlockTime, + bool active, + bool isUnlocked + ) { + require(_vaultId < vaults[_user].length, "Invalid vault ID"); + + Vault storage vault = vaults[_user][_vaultId]; + return ( + vault.balance, + vault.unlockTime, + vault.active, + block.timestamp >= vault.unlockTime + ); + } + function getAllVaults(address _user) external view returns (Vault[] memory) { + return vaults[_user]; + } + function getActiveVaults(address _user) external view returns ( + uint[] memory activeVaults, + uint[] memory balances, + uint[] memory unlockTimes + ) { + Vault[] storage userVaults = vaults[_user]; + + // Count active vaults + uint activeCount = 0; + for (uint i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0) { + activeCount++; + } + } + + // Create arrays + activeVaults = new uint[](activeCount); + balances = new uint[](activeCount); + unlockTimes = new uint[](activeCount); + + // Populate arrays + uint index = 0; + for (uint i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0) { + activeVaults[index] = i; + balances[index] = userVaults[i].balance; + unlockTimes[index] = userVaults[i].unlockTime; + index++; + } + } + + return (activeVaults, balances, unlockTimes); + } + function getTotalBalance(address _user) external view returns (uint total) { + Vault[] storage userVaults = vaults[_user]; + for (uint i = 0; i < userVaults.length; i++) { + if (userVaults[i].active) { + total += userVaults[i].balance; + } + } + return total; + } + function getUnlockedBalance(address _user) external view returns (uint unlocked) { + Vault[] storage userVaults = vaults[_user]; + for (uint i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && + userVaults[i].balance > 0 && + block.timestamp >= userVaults[i].unlockTime) { + unlocked += userVaults[i].balance; + } + } + return unlocked; + } +} diff --git a/assignments/foundry-test-assignment/test/CounterV3.t.sol b/assignments/foundry-test-assignment/test/CounterV3.t.sol new file mode 100644 index 00000000..e3384801 --- /dev/null +++ b/assignments/foundry-test-assignment/test/CounterV3.t.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {CounterV3} from "../src/CounterV3.sol"; + +contract CounterV3Test is Test { + CounterV3 public counterv3; + address public addr1; + address public addr2; + + function setUp() public { + counterv3 = new CounterV3(); + addr1 = makeAddr("addr1"); + addr2 = makeAddr("addr2"); + } + + function test_setNumber() public { + counterv3.setNumber(3); + assertEq(counterv3.number(), 3); + } + + function test_increment() public { + counterv3.increment(); + assertEq(counterv3.number(), 1); + } + + function test_revert_increment_withoutPrivilege() public { + vm.prank(addr1); + vm.expectRevert("Owner consent required"); + counterv3.increment(); + + assertEq(counterv3.number(), 0); + } + + function test_revert_setNumber_withoutPrivilege() public { + vm.prank(addr1); + vm.expectRevert("Owner consent required"); + counterv3.setNumber(7); + + assertEq(counterv3.number(), 0); + } + + function test_grantPrivilege() public { + vm.prank(addr1); + counterv3.requestPrivilege(); + + assertTrue(counterv3.pendingApproval(addr1)); + + counterv3.grantPrivilege(addr1); + + assertTrue(counterv3.approvedCallers(addr1)); + assertFalse(counterv3.pendingApproval(addr1)); + } + + function test_approvedCaller_increment() public { + vm.prank(addr1); + counterv3.requestPrivilege(); + + counterv3.grantPrivilege(addr1); + + vm.prank(addr1); + counterv3.increment(); + + assertEq(counterv3.number(), 1); + } + + function test_revert_grantPrivilege_withoutRequest() public { + vm.expectRevert("No pending request"); + counterv3.grantPrivilege(addr1); + } + + function test_revokePrivilege() public { + vm.prank(addr1); + counterv3.requestPrivilege(); + + counterv3.grantPrivilege(addr1); + counterv3.revokePrivilege(addr1); + + assertFalse(counterv3.approvedCallers(addr1)); + } + + function test_revert_revokedCaller_setNumber() public { + vm.prank(addr1); + counterv3.requestPrivilege(); + + counterv3.grantPrivilege(addr1); + counterv3.revokePrivilege(addr1); + + vm.prank(addr1); + vm.expectRevert("Owner consent required"); + counterv3.setNumber(7); + } + + function test_revert_grantPrivilege_notOwner() public { + vm.prank(addr1); + counterv3.requestPrivilege(); + + vm.prank(addr2); + vm.expectRevert("Not owner"); + counterv3.grantPrivilege(addr1); + } + + function test_revert_revokePrivilege_notOwner() public { + vm.prank(addr1); + counterv3.requestPrivilege(); + + counterv3.grantPrivilege(addr1); + + vm.prank(addr2); + vm.expectRevert("Not owner"); + counterv3.revokePrivilege(addr1); + } + + function test_revert_revokePrivilege_withoutApproval() public { + vm.expectRevert("Caller not approved"); + counterv3.revokePrivilege(addr1); + } + + function test_revert_requestPrivilege_owner() public { + vm.expectRevert("Owner does not need consent"); + counterv3.requestPrivilege(); + } +} diff --git a/assignments/foundry-test-assignment/test/Vault.t.sol b/assignments/foundry-test-assignment/test/Vault.t.sol new file mode 100644 index 00000000..480a0872 --- /dev/null +++ b/assignments/foundry-test-assignment/test/Vault.t.sol @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {TimeLock} from "../src/Vault.sol"; + +contract VaultTest is Test { + TimeLock public vault; + address public user; + address public attacker; + + function setUp() public { + vault = new TimeLock(); + user = makeAddr("user"); + attacker = makeAddr("attacker"); + + assertEq(address(vault).balance, 0); + + vm.deal(user, 10 ether); + vm.deal(attacker, 10 ether); + } + + function test_deposit() public { + uint256 unlockTime = block.timestamp + 1 days; + uint256 userBalanceBefore = user.balance; + uint256 vaultBalanceBefore = address(vault).balance; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + assertEq(user.balance, userBalanceBefore - 1 ether); + assertEq(address(vault).balance, vaultBalanceBefore + 1 ether); + assertEq(vault.getVaultCount(user), 1); + + (uint256 balance, uint256 savedUnlockTime, bool active, bool isUnlocked) = vault.getVault(user, 0); + assertEq(balance, 1 ether); + assertEq(savedUnlockTime, unlockTime); + assertTrue(active); + assertFalse(isUnlocked); + } + + function test_revert_deposit_withZeroValue() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vm.expectRevert("Deposit must be greater than zero"); + vault.deposit{value: 0}(unlockTime); + } + + function test_revert_deposit_withNonFutureUnlockTime() public { + vm.prank(user); + vm.expectRevert("Unlock time must be in the future"); + vault.deposit{value: 1 ether}(block.timestamp); + } + + function test_revert_getVault_withInvalidVaultId() public { + vm.expectRevert("Invalid vault ID"); + vault.getVault(user, 0); + } + + function test_getAllVaults() public { + uint256 firstUnlockTime = block.timestamp + 1 days; + uint256 secondUnlockTime = block.timestamp + 2 days; + + vm.startPrank(user); + vault.deposit{value: 1 ether}(firstUnlockTime); + vault.deposit{value: 2 ether}(secondUnlockTime); + vm.stopPrank(); + + TimeLock.Vault[] memory vaults = vault.getAllVaults(user); + + assertEq(vaults.length, 2); + assertEq(vaults[0].balance, 1 ether); + assertEq(vaults[0].unlockTime, firstUnlockTime); + assertTrue(vaults[0].active); + assertEq(vaults[1].balance, 2 ether); + assertEq(vaults[1].unlockTime, secondUnlockTime); + assertTrue(vaults[1].active); + } + + function test_getActiveVaults() public { + uint256 firstUnlockTime = block.timestamp + 1 days; + uint256 secondUnlockTime = block.timestamp + 2 days; + + vm.startPrank(user); + vault.deposit{value: 1 ether}(firstUnlockTime); + vault.deposit{value: 2 ether}(secondUnlockTime); + vm.stopPrank(); + + vm.warp(firstUnlockTime); + + vm.prank(user); + vault.withdraw(0); + + (uint256[] memory activeVaults, uint256[] memory balances, uint256[] memory unlockTimes) = + vault.getActiveVaults(user); + + assertEq(activeVaults.length, 1); + assertEq(balances.length, 1); + assertEq(unlockTimes.length, 1); + assertEq(activeVaults[0], 1); + assertEq(balances[0], 2 ether); + assertEq(unlockTimes[0], secondUnlockTime); + } + + function test_getTotalBalance() public { + uint256 firstUnlockTime = block.timestamp + 1 days; + uint256 secondUnlockTime = block.timestamp + 2 days; + + vm.startPrank(user); + vault.deposit{value: 1 ether}(firstUnlockTime); + vault.deposit{value: 2 ether}(secondUnlockTime); + vm.stopPrank(); + + assertEq(vault.getTotalBalance(user), 3 ether); + + vm.warp(firstUnlockTime); + + vm.prank(user); + vault.withdraw(0); + + assertEq(vault.getTotalBalance(user), 2 ether); + } + + function test_getUnlockedBalance() public { + uint256 firstUnlockTime = block.timestamp + 1 days; + uint256 secondUnlockTime = block.timestamp + 2 days; + + vm.startPrank(user); + vault.deposit{value: 1 ether}(firstUnlockTime); + vault.deposit{value: 2 ether}(secondUnlockTime); + vm.stopPrank(); + + assertEq(vault.getUnlockedBalance(user), 0); + + vm.warp(firstUnlockTime); + assertEq(vault.getUnlockedBalance(user), 1 ether); + + vm.warp(secondUnlockTime); + assertEq(vault.getUnlockedBalance(user), 3 ether); + } + + function test_withdraw() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.warp(unlockTime); + + uint256 userBalanceBefore = user.balance; + uint256 vaultBalanceBefore = address(vault).balance; + + vm.prank(user); + vault.withdraw(0); + + assertEq(user.balance, userBalanceBefore + 1 ether); + assertEq(address(vault).balance, vaultBalanceBefore - 1 ether); + + (uint256 balance, , bool active, bool isUnlocked) = vault.getVault(user, 0); + assertEq(balance, 0); + assertFalse(active); + assertTrue(isUnlocked); + } + + function test_revert_withdraw_whenStillLocked() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(user); + vm.expectRevert("Funds are still locked"); + vault.withdraw(0); + } + + function test_revert_withdraw_fromAnotherUsersVault() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(attacker); + vm.expectRevert("Invalid vault ID"); + vault.withdraw(0); + } + + function test_withdrawAll() public { + uint256 firstUnlockTime = block.timestamp + 1 days; + uint256 secondUnlockTime = block.timestamp + 2 days; + + vm.startPrank(user); + vault.deposit{value: 1 ether}(firstUnlockTime); + vault.deposit{value: 2 ether}(secondUnlockTime); + vm.stopPrank(); + + vm.warp(secondUnlockTime); + + uint256 userBalanceBefore = user.balance; + uint256 vaultBalanceBefore = address(vault).balance; + + vm.prank(user); + uint256 amount = vault.withdrawAll(); + + assertEq(amount, 3 ether); + assertEq(user.balance, userBalanceBefore + 3 ether); + assertEq(address(vault).balance, vaultBalanceBefore - 3 ether); + assertEq(vault.getTotalBalance(user), 0); + } + + function test_revert_withdrawAll_fromUserWithoutUnlockedFunds() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(attacker); + vm.expectRevert("No unlocked funds available"); + vault.withdrawAll(); + } +} diff --git a/assignments/testing-with-foundry b/assignments/testing-with-foundry deleted file mode 160000 index e886372f..00000000 --- a/assignments/testing-with-foundry +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e886372fa32412df2f8ca225d07eabcf9d044348 From d274ce06c46851aed99a6f769292003c2e9f9252 Mon Sep 17 00:00:00 2001 From: JTKaduma Date: Wed, 4 Mar 2026 11:24:20 +0100 Subject: [PATCH 03/10] Add OpenZeppelin Contracts as a submodule and update foundry.lock --- .gitmodules | 3 + .../foundry-test-assignment/foundry.lock | 6 + .../lib/openzeppelin-contracts | 1 + .../foundry-test-assignment/remappings.txt | 5 + .../foundry-test-assignment/src/Token.sol | 20 ++ .../foundry-test-assignment/src/VaultV2.sol | 267 +++++++++++++++++ .../foundry-test-assignment/src/VaultV3.sol | 2 + .../src/interfaces/IERC20.sol | 23 ++ .../test/VaultV2.t.sol | 274 ++++++++++++++++++ 9 files changed, 601 insertions(+) create mode 100644 .gitmodules create mode 160000 assignments/foundry-test-assignment/lib/openzeppelin-contracts create mode 100644 assignments/foundry-test-assignment/remappings.txt create mode 100644 assignments/foundry-test-assignment/src/Token.sol create mode 100644 assignments/foundry-test-assignment/src/VaultV2.sol create mode 100644 assignments/foundry-test-assignment/src/VaultV3.sol create mode 100644 assignments/foundry-test-assignment/src/interfaces/IERC20.sol create mode 100644 assignments/foundry-test-assignment/test/VaultV2.t.sol diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..feb451eb --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "assignments/foundry-test-assignment/lib/openzeppelin-contracts"] + path = assignments/foundry-test-assignment/lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/assignments/foundry-test-assignment/foundry.lock b/assignments/foundry-test-assignment/foundry.lock index bc06b89b..ab2c6d82 100644 --- a/assignments/foundry-test-assignment/foundry.lock +++ b/assignments/foundry-test-assignment/foundry.lock @@ -4,5 +4,11 @@ "name": "v1.15.0", "rev": "0844d7e1fc5e60d77b68e469bff60265f236c398" } + }, + "lib/openzeppelin-contracts": { + "tag": { + "name": "v5.6.1", + "rev": "5fd1781b1454fd1ef8e722282f86f9293cacf256" + } } } \ No newline at end of file diff --git a/assignments/foundry-test-assignment/lib/openzeppelin-contracts b/assignments/foundry-test-assignment/lib/openzeppelin-contracts new file mode 160000 index 00000000..5fd1781b --- /dev/null +++ b/assignments/foundry-test-assignment/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit 5fd1781b1454fd1ef8e722282f86f9293cacf256 diff --git a/assignments/foundry-test-assignment/remappings.txt b/assignments/foundry-test-assignment/remappings.txt new file mode 100644 index 00000000..918ed315 --- /dev/null +++ b/assignments/foundry-test-assignment/remappings.txt @@ -0,0 +1,5 @@ +@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ +erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/ +forge-std/=lib/forge-std/src/ +halmos-cheatcodes/=lib/openzeppelin-contracts/lib/halmos-cheatcodes/src/ +openzeppelin-contracts/=lib/openzeppelin-contracts/ diff --git a/assignments/foundry-test-assignment/src/Token.sol b/assignments/foundry-test-assignment/src/Token.sol new file mode 100644 index 00000000..1b737504 --- /dev/null +++ b/assignments/foundry-test-assignment/src/Token.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +// Compatible with OpenZeppelin Contracts ^5.6.0 +pragma solidity ^0.8.27; + +import {Ownable} from '@openzeppelin/contracts/access/Ownable.sol'; +import {ERC20} from '@openzeppelin/contracts/token/ERC20/ERC20.sol'; +import {ERC20Burnable} from '@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol'; + +contract JasonToken is ERC20, ERC20Burnable, Ownable { + constructor( + address recipient, + address initialOwner + ) ERC20('JasonToken', 'JKB') Ownable(initialOwner) { + _mint(recipient, 1000 * 10 ** decimals()); + } + + function mint(address to, uint256 amount) public onlyOwner { + _mint(to, amount); + } +} diff --git a/assignments/foundry-test-assignment/src/VaultV2.sol b/assignments/foundry-test-assignment/src/VaultV2.sol new file mode 100644 index 00000000..b8187847 --- /dev/null +++ b/assignments/foundry-test-assignment/src/VaultV2.sol @@ -0,0 +1,267 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; +import {IErc20} from './interfaces/IERC20.sol'; + +contract TimeLockV2 { + IErc20 public immutable token; + address public owner; + address public pendingOwner; + + event OwnershipTransferStarted( + address indexed previousOwner, + address indexed newOwner + ); + event OwnershipTransferred( + address indexed previousOwner, + address indexed newOwner + ); + + struct Vault { + uint256 balance; + uint tokenBalance; + uint unlockTime; + bool active; + } + + constructor(address _token) { + token = IErc20(_token); + owner = msg.sender; + } + + modifier onlyOwner() { + require(msg.sender == owner, 'Not owner'); + _; + } + + function transferOwnership(address newOwner) external onlyOwner { + require(newOwner != address(0), 'New owner is zero address'); + require(newOwner != owner, 'New owner is current owner'); + pendingOwner = newOwner; + emit OwnershipTransferStarted(owner, newOwner); + } + + function acceptOwnership() external { + require(msg.sender == pendingOwner, 'Not pending owner'); + address previousOwner = owner; + owner = msg.sender; + pendingOwner = address(0); + emit OwnershipTransferred(previousOwner, msg.sender); + } + + mapping(address => Vault[]) private vaults; + + event Deposited( + address indexed user, + uint256 vaultId, + uint256 amount, + uint256 unlockTime + ); + event Withdrawn(address indexed user, uint256 vaultId, uint256 amount); + event EmergencyWithdrawn(address indexed owner, uint256 amount); + + //INTERNAL FUNCTIONS + function _depositRatio( + uint _totalDeposit + ) internal pure returns (uint _tokenAmount) { + _tokenAmount = _totalDeposit * 10; // Token Ratio + } + + function deposit(uint256 _unlockTime) external payable returns (uint256) { + require(msg.value > 0, 'Deposit must be greater than zero'); + require(_unlockTime > block.timestamp, 'Unlock time must be in the future'); + + uint tokenBal = _depositRatio(msg.value); + require(token.transfer(msg.sender, tokenBal), 'Token transfer failed'); + + // Create new vault + vaults[msg.sender].push( + Vault({ + balance: msg.value, + unlockTime: _unlockTime, + tokenBalance: tokenBal, + active: true + }) + ); + + uint256 vaultId = vaults[msg.sender].length - 1; + emit Deposited(msg.sender, vaultId, msg.value, _unlockTime); + + return vaultId; + } + + function withdraw(uint256 _vaultId) external { + require(_vaultId < vaults[msg.sender].length, 'Invalid vault ID'); + + Vault storage userVault = vaults[msg.sender][_vaultId]; + require(userVault.active, 'Vault is not active'); + require(userVault.balance > 0, 'Vault has zero balance'); + require(block.timestamp >= userVault.unlockTime, 'Funds are still locked'); + + uint256 amount = userVault.balance; + uint256 tokenAmount = userVault.tokenBalance; + + // Mark vault as inactive and clear balance + userVault.balance = 0; + userVault.tokenBalance = 0; + userVault.active = false; + + require( + token.transferFrom(msg.sender, address(this), tokenAmount), + 'Token transfer failed' + ); + + (bool success, ) = payable(msg.sender).call{value: amount}(''); + require(success, 'Transfer failed'); + + emit Withdrawn(msg.sender, _vaultId, amount); + } + + function withdrawAll() external returns (uint256) { + uint256 totalWithdrawn = 0; + uint256 totalTokens = 0; + Vault[] storage userVaults = vaults[msg.sender]; + + for (uint256 i = 0; i < userVaults.length; i++) { + if ( + userVaults[i].active && + userVaults[i].balance > 0 && + block.timestamp >= userVaults[i].unlockTime + ) { + uint256 amount = userVaults[i].balance; + uint256 tokenAmount = userVaults[i].tokenBalance; + + userVaults[i].balance = 0; + userVaults[i].tokenBalance = 0; + userVaults[i].active = false; + + totalWithdrawn += amount; + totalTokens += tokenAmount; + emit Withdrawn(msg.sender, i, amount); + } + } + + require(totalWithdrawn > 0, 'No unlocked funds available'); + + require(token.transfer(msg.sender, totalTokens), 'Token transfer failed'); + + (bool success, ) = payable(msg.sender).call{value: totalWithdrawn}(''); + require(success, 'Transfer failed'); + + return totalWithdrawn; + } + + function emergencyWithdraw() external onlyOwner returns (uint256 amount) { + amount = address(this).balance; + require(amount > 0, 'No funds available'); + + (bool success, ) = payable(owner).call{value: amount}(''); + if(!success) revert(); + + emit EmergencyWithdrawn(owner, amount); + } + + function getVaultCount(address _user) external view returns (uint256) { + return vaults[_user].length; + } + + function getVault( + address _user, + uint256 _vaultId + ) + external + view + returns ( + uint256 balance, + uint256 tokenBalance, + uint256 unlockTime, + bool active, + bool isUnlocked + ) + { + require(_vaultId < vaults[_user].length, 'Invalid vault ID'); + + Vault storage vault = vaults[_user][_vaultId]; + return ( + vault.balance, + vault.tokenBalance, + vault.unlockTime, + vault.active, + block.timestamp >= vault.unlockTime + ); + } + + function getAllVaults(address _user) external view returns (Vault[] memory) { + return vaults[_user]; + } + + function getActiveVaults( + address _user + ) + external + view + returns ( + uint256[] memory activeVaults, + uint256[] memory balances, + uint256[] memory tokenBalances, + uint256[] memory unlockTimes + ) + { + Vault[] storage userVaults = vaults[_user]; + + // Count active vaults + uint256 activeCount = 0; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0) { + activeCount++; + } + } + + // Create arrays + activeVaults = new uint256[](activeCount); + balances = new uint256[](activeCount); + tokenBalances = new uint256[](activeCount); + unlockTimes = new uint256[](activeCount); + + // Populate arrays + uint256 index = 0; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0) { + activeVaults[index] = i; + balances[index] = userVaults[i].balance; + tokenBalances[index] = userVaults[i].tokenBalance; + unlockTimes[index] = userVaults[i].unlockTime; + index++; + } + } + + return (activeVaults, balances, tokenBalances, unlockTimes); + } + + function getTotalBalance( + address _user + ) external view returns (uint256 total) { + Vault[] storage userVaults = vaults[_user]; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active) { + total += userVaults[i].balance; + } + } + return total; + } + + function getUnlockedBalance( + address _user + ) external view returns (uint256 unlocked) { + Vault[] storage userVaults = vaults[_user]; + for (uint256 i = 0; i < userVaults.length; i++) { + if ( + userVaults[i].active && + userVaults[i].balance > 0 && + block.timestamp >= userVaults[i].unlockTime + ) { + unlocked += userVaults[i].balance; + } + } + return unlocked; + } +} diff --git a/assignments/foundry-test-assignment/src/VaultV3.sol b/assignments/foundry-test-assignment/src/VaultV3.sol new file mode 100644 index 00000000..6d46ea4f --- /dev/null +++ b/assignments/foundry-test-assignment/src/VaultV3.sol @@ -0,0 +1,2 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.28; diff --git a/assignments/foundry-test-assignment/src/interfaces/IERC20.sol b/assignments/foundry-test-assignment/src/interfaces/IERC20.sol new file mode 100644 index 00000000..3b788b73 --- /dev/null +++ b/assignments/foundry-test-assignment/src/interfaces/IERC20.sol @@ -0,0 +1,23 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; +import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; + +interface IErc20 is IERC20 { + function decimals() external view returns (uint8); + + function totalSupply() external view returns (uint256); + + function balanceOf(address _owner) external view returns (uint256 balance); + + function transferFrom( + address _from, + address _to, + uint256 _value + ) external returns (bool success); + + function mint(address to, uint256 amount) external; + + function burn(uint256 amount) external; + + function burnFrom(address account, uint256 amount) external; +} diff --git a/assignments/foundry-test-assignment/test/VaultV2.t.sol b/assignments/foundry-test-assignment/test/VaultV2.t.sol new file mode 100644 index 00000000..99effcfb --- /dev/null +++ b/assignments/foundry-test-assignment/test/VaultV2.t.sol @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.28; + +import {Test} from 'forge-std/Test.sol'; +import {console} from 'forge-std/console.sol'; +import {TimeLockV2} from '../src/VaultV2.sol'; +import {JasonToken} from '../src/Token.sol'; + +contract VaultV2Test is Test { + TimeLockV2 public vault; + JasonToken public token; + address public owner; + address public user; + address public attacker; + address public newOwner; + + uint oneDay = 86400; + + function setUp() public { + owner = makeAddr('owner'); + vm.startPrank(owner); + token = new JasonToken(address(this), address(this)); + vault = new TimeLockV2(address(token)); + vm.stopPrank(); + user = makeAddr('user'); + attacker = makeAddr('attacker'); + newOwner = makeAddr('newOwner'); + + token.transfer(address(vault), 500 * 10 ** token.decimals()); + + vm.deal(user, 10 ether); + vm.deal(attacker, 10 ether); + } + + function test_ownerIsSetOnDeployment() public view { + assertEq(vault.owner(), owner); + console.log('Owner: ', vault.owner()); + console.log('This: ', owner); + } + + function test_emergencyWithdraw() public { + uint256 unlockTime = block.timestamp + 1 days; + uint256 ownerBalanceBefore = owner.balance; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(owner); + uint256 withdrawnAmount = vault.emergencyWithdraw(); + + assertEq(withdrawnAmount, 1 ether); + assertEq(address(vault).balance, 0); + assertEq(owner.balance, ownerBalanceBefore + 1 ether); + } + + function test_revert_emergencyWithdraw_notOwner() public { + uint256 unlockTime = block.timestamp + oneDay; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(attacker); + vm.expectRevert(); + vault.emergencyWithdraw(); + } + + function test_transferOwnership_and_acceptOwnership() public { + vm.prank(owner); + vault.transferOwnership(newOwner); + + assertEq(vault.pendingOwner(), newOwner); + + vm.prank(newOwner); + vault.acceptOwnership(); + + assertEq(vault.owner(), newOwner); + assertEq(vault.pendingOwner(), address(0)); + } + + function test_revert_transferOwnership_notOwner() public { + vm.prank(attacker); + vm.expectRevert('Not owner'); + vault.transferOwnership(newOwner); + } + + function test_revert_acceptOwnership_notPendingOwner() public { + vm.prank(owner); + vault.transferOwnership(newOwner); + + vm.prank(attacker); + vm.expectRevert('Not pending owner'); + vault.acceptOwnership(); + } + + function test_emergencyWithdraw_accessAfterOwnershipTransfer() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(owner); + vault.transferOwnership(newOwner); + + vm.prank(newOwner); + vault.acceptOwnership(); + + vm.prank(owner); + vm.expectRevert('Not owner'); + vault.emergencyWithdraw(); + + uint256 newOwnerBalanceBefore = newOwner.balance; + vm.prank(newOwner); + uint256 withdrawnAmount = vault.emergencyWithdraw(); + + assertEq(withdrawnAmount, 1 ether); + assertEq(address(vault).balance, 0); + assertEq(newOwner.balance, newOwnerBalanceBefore + 1 ether); + } + + function test_deposit() public { + uint256 unlockTime = block.timestamp + 1 days; + uint256 userEthBefore = user.balance; + uint256 vaultEthBefore = address(vault).balance; + uint256 userTokenBefore = token.balanceOf(user); + uint256 vaultTokenBefore = token.balanceOf(address(vault)); + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + assertEq(user.balance, userEthBefore - 1 ether); + assertEq(address(vault).balance, vaultEthBefore + 1 ether); + assertEq(token.balanceOf(user), userTokenBefore + 10 ether); + assertEq(token.balanceOf(address(vault)), vaultTokenBefore - 10 ether); + assertEq(vault.getVaultCount(user), 1); + + ( + uint256 balance, + uint256 tokenBalance, + uint256 savedUnlockTime, + bool active, + bool isUnlocked + ) = vault.getVault(user, 0); + + assertEq(balance, 1 ether); + assertEq(tokenBalance, 10 ether); + assertEq(savedUnlockTime, unlockTime); + assertTrue(active); + assertFalse(isUnlocked); + } + + function test_revert_deposit_withZeroValue() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vm.expectRevert('Deposit must be greater than zero'); + vault.deposit{value: 0}(unlockTime); + } + + function test_getActiveVaults() public { + uint256 firstUnlockTime = block.timestamp + 1 days; + uint256 secondUnlockTime = block.timestamp + 2 days; + + vm.startPrank(user); + vault.deposit{value: 1 ether}(firstUnlockTime); + vault.deposit{value: 2 ether}(secondUnlockTime); + token.approve(address(vault), 10 ether); + vm.stopPrank(); + + vm.warp(firstUnlockTime); + + vm.prank(user); + vault.withdraw(0); + + ( + uint256[] memory activeVaults, + uint256[] memory balances, + uint256[] memory tokenBalances, + uint256[] memory unlockTimes + ) = vault.getActiveVaults(user); + + assertEq(activeVaults.length, 1); + assertEq(balances.length, 1); + assertEq(tokenBalances.length, 1); + assertEq(unlockTimes.length, 1); + assertEq(activeVaults[0], 1); + assertEq(balances[0], 2 ether); + assertEq(tokenBalances[0], 20 ether); + assertEq(unlockTimes[0], secondUnlockTime); + } + + function test_withdraw() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(user); + token.approve(address(vault), 10 ether); + + vm.warp(unlockTime); + + uint256 userEthBefore = user.balance; + uint256 vaultEthBefore = address(vault).balance; + uint256 userTokenBefore = token.balanceOf(user); + uint256 vaultTokenBefore = token.balanceOf(address(vault)); + + vm.prank(user); + vault.withdraw(0); + + assertEq(user.balance, userEthBefore + 1 ether); + assertEq(address(vault).balance, vaultEthBefore - 1 ether); + assertEq(token.balanceOf(user), userTokenBefore - 10 ether); + assertEq(token.balanceOf(address(vault)), vaultTokenBefore + 10 ether); + + ( + uint256 balance, + uint256 tokenBalance, + , + bool active, + bool isUnlocked + ) = vault.getVault(user, 0); + assertEq(balance, 0); + assertEq(tokenBalance, 0); + assertFalse(active); + assertTrue(isUnlocked); + } + + function test_revert_withdraw_withoutApproval() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.warp(unlockTime); + + vm.prank(user); + vm.expectRevert(); + vault.withdraw(0); + } + + function test_withdrawAll() public { + uint256 firstUnlockTime = block.timestamp + 1 days; + uint256 secondUnlockTime = block.timestamp + 2 days; + + vm.startPrank(user); + vault.deposit{value: 1 ether}(firstUnlockTime); + vault.deposit{value: 2 ether}(secondUnlockTime); + vm.stopPrank(); + + vm.warp(secondUnlockTime); + + uint256 userEthBefore = user.balance; + uint256 vaultEthBefore = address(vault).balance; + + vm.prank(user); + uint256 amount = vault.withdrawAll(); + + assertEq(amount, 3 ether); + assertEq(user.balance, userEthBefore + 3 ether); + assertEq(address(vault).balance, vaultEthBefore - 3 ether); + assertEq(vault.getTotalBalance(user), 0); + } + + function test_revert_withdraw_fromAnotherUsersVault() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(attacker); + vm.expectRevert('Invalid vault ID'); + vault.withdraw(0); + } +} From eb60c52bddf1495b69a5b3e292791876e68e4bdc Mon Sep 17 00:00:00 2001 From: JTKaduma Date: Wed, 4 Mar 2026 12:12:35 +0100 Subject: [PATCH 04/10] feat: add Vault V2 --- assignments/foundry-test-assignment/src/VaultV3.sol | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 assignments/foundry-test-assignment/src/VaultV3.sol diff --git a/assignments/foundry-test-assignment/src/VaultV3.sol b/assignments/foundry-test-assignment/src/VaultV3.sol deleted file mode 100644 index 6d46ea4f..00000000 --- a/assignments/foundry-test-assignment/src/VaultV3.sol +++ /dev/null @@ -1,2 +0,0 @@ -//SPDX-License-Identifier: MIT -pragma solidity 0.8.28; From 1f35c3540b497e998cd19b6c55a88654ac342e9d Mon Sep 17 00:00:00 2001 From: JTKaduma Date: Wed, 4 Mar 2026 13:00:58 +0100 Subject: [PATCH 05/10] chore: format code --- .../foundry-test-assignment/src/Token.sol | 21 +- .../foundry-test-assignment/src/Vault.sol | 136 +++-- .../foundry-test-assignment/src/VaultV2.sol | 458 +++++++-------- .../src/interfaces/IERC20.sol | 20 +- .../foundry-test-assignment/test/Vault.t.sol | 2 +- .../test/VaultV2.t.sol | 523 +++++++++--------- 6 files changed, 542 insertions(+), 618 deletions(-) diff --git a/assignments/foundry-test-assignment/src/Token.sol b/assignments/foundry-test-assignment/src/Token.sol index 1b737504..50f223e5 100644 --- a/assignments/foundry-test-assignment/src/Token.sol +++ b/assignments/foundry-test-assignment/src/Token.sol @@ -2,19 +2,16 @@ // Compatible with OpenZeppelin Contracts ^5.6.0 pragma solidity ^0.8.27; -import {Ownable} from '@openzeppelin/contracts/access/Ownable.sol'; -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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; contract JasonToken is ERC20, ERC20Burnable, Ownable { - constructor( - address recipient, - address initialOwner - ) ERC20('JasonToken', 'JKB') Ownable(initialOwner) { - _mint(recipient, 1000 * 10 ** decimals()); - } + constructor(address recipient, address initialOwner) ERC20("JasonToken", "JKB") Ownable(initialOwner) { + _mint(recipient, 1000 * 10 ** decimals()); + } - function mint(address to, uint256 amount) public onlyOwner { - _mint(to, amount); - } + function mint(address to, uint256 amount) public onlyOwner { + _mint(to, amount); + } } diff --git a/assignments/foundry-test-assignment/src/Vault.sol b/assignments/foundry-test-assignment/src/Vault.sol index 34ccb235..4500dd36 100644 --- a/assignments/foundry-test-assignment/src/Vault.sol +++ b/assignments/foundry-test-assignment/src/Vault.sol @@ -3,122 +3,114 @@ pragma solidity 0.8.28; contract TimeLock { struct Vault { - uint balance; - uint unlockTime; + uint256 balance; + uint256 unlockTime; bool active; } - - + mapping(address => Vault[]) private vaults; - event Deposited(address indexed user, uint vaultId, uint amount, uint unlockTime); - event Withdrawn(address indexed user, uint vaultId, uint amount); + event Deposited(address indexed user, uint256 vaultId, uint256 amount, uint256 unlockTime); + event Withdrawn(address indexed user, uint256 vaultId, uint256 amount); - function deposit(uint _unlockTime) external payable returns (uint) { + function deposit(uint256 _unlockTime) external payable returns (uint256) { require(msg.value > 0, "Deposit must be greater than zero"); require(_unlockTime > block.timestamp, "Unlock time must be in the future"); // Create new vault - vaults[msg.sender].push(Vault({ - balance: msg.value, - unlockTime: _unlockTime, - active: true - })); + vaults[msg.sender].push(Vault({balance: msg.value, unlockTime: _unlockTime, active: true})); - uint vaultId = vaults[msg.sender].length - 1; + uint256 vaultId = vaults[msg.sender].length - 1; emit Deposited(msg.sender, vaultId, msg.value, _unlockTime); - + return vaultId; } -function withdraw(uint _vaultId) external { + + function withdraw(uint256 _vaultId) external { require(_vaultId < vaults[msg.sender].length, "Invalid vault ID"); - + Vault storage userVault = vaults[msg.sender][_vaultId]; require(userVault.active, "Vault is not active"); require(userVault.balance > 0, "Vault has zero balance"); require(block.timestamp >= userVault.unlockTime, "Funds are still locked"); - uint amount = userVault.balance; - + uint256 amount = userVault.balance; + // Mark vault as inactive and clear balance userVault.balance = 0; userVault.active = false; - (bool success, ) = payable(msg.sender).call{value: amount}(""); + (bool success,) = payable(msg.sender).call{value: amount}(""); require(success, "Transfer failed"); - + emit Withdrawn(msg.sender, _vaultId, amount); } - function withdrawAll() external returns (uint) { - uint totalWithdrawn = 0; + + function withdrawAll() external returns (uint256) { + uint256 totalWithdrawn = 0; Vault[] storage userVaults = vaults[msg.sender]; - - for (uint i = 0; i < userVaults.length; i++) { - if (userVaults[i].active && - userVaults[i].balance > 0 && - block.timestamp >= userVaults[i].unlockTime) { - - uint amount = userVaults[i].balance; + + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0 && block.timestamp >= userVaults[i].unlockTime) { + uint256 amount = userVaults[i].balance; userVaults[i].balance = 0; userVaults[i].active = false; - + totalWithdrawn += amount; emit Withdrawn(msg.sender, i, amount); } } - + require(totalWithdrawn > 0, "No unlocked funds available"); - - (bool success, ) = payable(msg.sender).call{value: totalWithdrawn}(""); + + (bool success,) = payable(msg.sender).call{value: totalWithdrawn}(""); require(success, "Transfer failed"); - + return totalWithdrawn; } - function getVaultCount(address _user) external view returns (uint) { + + function getVaultCount(address _user) external view returns (uint256) { return vaults[_user].length; } - function getVault(address _user, uint _vaultId) external view returns ( - uint balance, - uint unlockTime, - bool active, - bool isUnlocked - ) { + + function getVault(address _user, uint256 _vaultId) + external + view + returns (uint256 balance, uint256 unlockTime, bool active, bool isUnlocked) + { require(_vaultId < vaults[_user].length, "Invalid vault ID"); - + Vault storage vault = vaults[_user][_vaultId]; - return ( - vault.balance, - vault.unlockTime, - vault.active, - block.timestamp >= vault.unlockTime - ); + return (vault.balance, vault.unlockTime, vault.active, block.timestamp >= vault.unlockTime); } - function getAllVaults(address _user) external view returns (Vault[] memory) { + + function getAllVaults(address _user) external view returns (Vault[] memory) { return vaults[_user]; } - function getActiveVaults(address _user) external view returns ( - uint[] memory activeVaults, - uint[] memory balances, - uint[] memory unlockTimes - ) { + + function getActiveVaults(address _user) + external + view + returns (uint256[] memory activeVaults, uint256[] memory balances, uint256[] memory unlockTimes) + { Vault[] storage userVaults = vaults[_user]; - + // Count active vaults - uint activeCount = 0; - for (uint i = 0; i < userVaults.length; i++) { + uint256 activeCount = 0; + for (uint256 i = 0; i < userVaults.length; i++) { if (userVaults[i].active && userVaults[i].balance > 0) { activeCount++; } } - + // Create arrays - activeVaults = new uint[](activeCount); - balances = new uint[](activeCount); - unlockTimes = new uint[](activeCount); - + activeVaults = new uint256[](activeCount); + balances = new uint256[](activeCount); + unlockTimes = new uint256[](activeCount); + // Populate arrays - uint index = 0; - for (uint i = 0; i < userVaults.length; i++) { + uint256 index = 0; + for (uint256 i = 0; i < userVaults.length; i++) { if (userVaults[i].active && userVaults[i].balance > 0) { activeVaults[index] = i; balances[index] = userVaults[i].balance; @@ -126,24 +118,24 @@ function withdraw(uint _vaultId) external { index++; } } - + return (activeVaults, balances, unlockTimes); } - function getTotalBalance(address _user) external view returns (uint total) { + + function getTotalBalance(address _user) external view returns (uint256 total) { Vault[] storage userVaults = vaults[_user]; - for (uint i = 0; i < userVaults.length; i++) { + for (uint256 i = 0; i < userVaults.length; i++) { if (userVaults[i].active) { total += userVaults[i].balance; } } return total; } - function getUnlockedBalance(address _user) external view returns (uint unlocked) { + + function getUnlockedBalance(address _user) external view returns (uint256 unlocked) { Vault[] storage userVaults = vaults[_user]; - for (uint i = 0; i < userVaults.length; i++) { - if (userVaults[i].active && - userVaults[i].balance > 0 && - block.timestamp >= userVaults[i].unlockTime) { + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0 && block.timestamp >= userVaults[i].unlockTime) { unlocked += userVaults[i].balance; } } diff --git a/assignments/foundry-test-assignment/src/VaultV2.sol b/assignments/foundry-test-assignment/src/VaultV2.sol index b8187847..41a6c350 100644 --- a/assignments/foundry-test-assignment/src/VaultV2.sol +++ b/assignments/foundry-test-assignment/src/VaultV2.sol @@ -1,267 +1,217 @@ //SPDX-License-Identifier: MIT pragma solidity ^0.8.28; -import {IErc20} from './interfaces/IERC20.sol'; +import {IErc20} from "./interfaces/IERC20.sol"; contract TimeLockV2 { - IErc20 public immutable token; - address public owner; - address public pendingOwner; - - event OwnershipTransferStarted( - address indexed previousOwner, - address indexed newOwner - ); - event OwnershipTransferred( - address indexed previousOwner, - address indexed newOwner - ); - - struct Vault { - uint256 balance; - uint tokenBalance; - uint unlockTime; - bool active; - } - - constructor(address _token) { - token = IErc20(_token); - owner = msg.sender; - } - - modifier onlyOwner() { - require(msg.sender == owner, 'Not owner'); - _; - } - - function transferOwnership(address newOwner) external onlyOwner { - require(newOwner != address(0), 'New owner is zero address'); - require(newOwner != owner, 'New owner is current owner'); - pendingOwner = newOwner; - emit OwnershipTransferStarted(owner, newOwner); - } - - function acceptOwnership() external { - require(msg.sender == pendingOwner, 'Not pending owner'); - address previousOwner = owner; - owner = msg.sender; - pendingOwner = address(0); - emit OwnershipTransferred(previousOwner, msg.sender); - } - - mapping(address => Vault[]) private vaults; - - event Deposited( - address indexed user, - uint256 vaultId, - uint256 amount, - uint256 unlockTime - ); - event Withdrawn(address indexed user, uint256 vaultId, uint256 amount); - event EmergencyWithdrawn(address indexed owner, uint256 amount); - - //INTERNAL FUNCTIONS - function _depositRatio( - uint _totalDeposit - ) internal pure returns (uint _tokenAmount) { - _tokenAmount = _totalDeposit * 10; // Token Ratio - } - - function deposit(uint256 _unlockTime) external payable returns (uint256) { - require(msg.value > 0, 'Deposit must be greater than zero'); - require(_unlockTime > block.timestamp, 'Unlock time must be in the future'); - - uint tokenBal = _depositRatio(msg.value); - require(token.transfer(msg.sender, tokenBal), 'Token transfer failed'); - - // Create new vault - vaults[msg.sender].push( - Vault({ - balance: msg.value, - unlockTime: _unlockTime, - tokenBalance: tokenBal, - active: true - }) - ); - - uint256 vaultId = vaults[msg.sender].length - 1; - emit Deposited(msg.sender, vaultId, msg.value, _unlockTime); - - return vaultId; - } - - function withdraw(uint256 _vaultId) external { - require(_vaultId < vaults[msg.sender].length, 'Invalid vault ID'); - - Vault storage userVault = vaults[msg.sender][_vaultId]; - require(userVault.active, 'Vault is not active'); - require(userVault.balance > 0, 'Vault has zero balance'); - require(block.timestamp >= userVault.unlockTime, 'Funds are still locked'); - - uint256 amount = userVault.balance; - uint256 tokenAmount = userVault.tokenBalance; - - // Mark vault as inactive and clear balance - userVault.balance = 0; - userVault.tokenBalance = 0; - userVault.active = false; - - require( - token.transferFrom(msg.sender, address(this), tokenAmount), - 'Token transfer failed' - ); - - (bool success, ) = payable(msg.sender).call{value: amount}(''); - require(success, 'Transfer failed'); - - emit Withdrawn(msg.sender, _vaultId, amount); - } - - function withdrawAll() external returns (uint256) { - uint256 totalWithdrawn = 0; - uint256 totalTokens = 0; - Vault[] storage userVaults = vaults[msg.sender]; - - for (uint256 i = 0; i < userVaults.length; i++) { - if ( - userVaults[i].active && - userVaults[i].balance > 0 && - block.timestamp >= userVaults[i].unlockTime - ) { - uint256 amount = userVaults[i].balance; - uint256 tokenAmount = userVaults[i].tokenBalance; - - userVaults[i].balance = 0; - userVaults[i].tokenBalance = 0; - userVaults[i].active = false; - - totalWithdrawn += amount; - totalTokens += tokenAmount; - emit Withdrawn(msg.sender, i, amount); - } + IErc20 public immutable token; + address public owner; + address public pendingOwner; + + event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + struct Vault { + uint256 balance; + uint256 tokenBalance; + uint256 unlockTime; + bool active; } - require(totalWithdrawn > 0, 'No unlocked funds available'); - - require(token.transfer(msg.sender, totalTokens), 'Token transfer failed'); - - (bool success, ) = payable(msg.sender).call{value: totalWithdrawn}(''); - require(success, 'Transfer failed'); - - return totalWithdrawn; - } - - function emergencyWithdraw() external onlyOwner returns (uint256 amount) { - amount = address(this).balance; - require(amount > 0, 'No funds available'); - - (bool success, ) = payable(owner).call{value: amount}(''); - if(!success) revert(); - - emit EmergencyWithdrawn(owner, amount); - } - - function getVaultCount(address _user) external view returns (uint256) { - return vaults[_user].length; - } - - function getVault( - address _user, - uint256 _vaultId - ) - external - view - returns ( - uint256 balance, - uint256 tokenBalance, - uint256 unlockTime, - bool active, - bool isUnlocked - ) - { - require(_vaultId < vaults[_user].length, 'Invalid vault ID'); - - Vault storage vault = vaults[_user][_vaultId]; - return ( - vault.balance, - vault.tokenBalance, - vault.unlockTime, - vault.active, - block.timestamp >= vault.unlockTime - ); - } - - function getAllVaults(address _user) external view returns (Vault[] memory) { - return vaults[_user]; - } - - function getActiveVaults( - address _user - ) - external - view - returns ( - uint256[] memory activeVaults, - uint256[] memory balances, - uint256[] memory tokenBalances, - uint256[] memory unlockTimes - ) - { - Vault[] storage userVaults = vaults[_user]; - - // Count active vaults - uint256 activeCount = 0; - for (uint256 i = 0; i < userVaults.length; i++) { - if (userVaults[i].active && userVaults[i].balance > 0) { - activeCount++; - } + constructor(address _token) { + token = IErc20(_token); + owner = msg.sender; } - // Create arrays - activeVaults = new uint256[](activeCount); - balances = new uint256[](activeCount); - tokenBalances = new uint256[](activeCount); - unlockTimes = new uint256[](activeCount); - - // Populate arrays - uint256 index = 0; - for (uint256 i = 0; i < userVaults.length; i++) { - if (userVaults[i].active && userVaults[i].balance > 0) { - activeVaults[index] = i; - balances[index] = userVaults[i].balance; - tokenBalances[index] = userVaults[i].tokenBalance; - unlockTimes[index] = userVaults[i].unlockTime; - index++; - } + modifier onlyOwner() { + require(msg.sender == owner, "Not owner"); + _; } - return (activeVaults, balances, tokenBalances, unlockTimes); - } - - function getTotalBalance( - address _user - ) external view returns (uint256 total) { - Vault[] storage userVaults = vaults[_user]; - for (uint256 i = 0; i < userVaults.length; i++) { - if (userVaults[i].active) { - total += userVaults[i].balance; - } + function transferOwnership(address newOwner) external onlyOwner { + require(newOwner != address(0), "New owner is zero address"); + require(newOwner != owner, "New owner is current owner"); + pendingOwner = newOwner; + emit OwnershipTransferStarted(owner, newOwner); } - return total; - } - - function getUnlockedBalance( - address _user - ) external view returns (uint256 unlocked) { - Vault[] storage userVaults = vaults[_user]; - for (uint256 i = 0; i < userVaults.length; i++) { - if ( - userVaults[i].active && - userVaults[i].balance > 0 && - block.timestamp >= userVaults[i].unlockTime - ) { - unlocked += userVaults[i].balance; - } + + function acceptOwnership() external { + require(msg.sender == pendingOwner, "Not pending owner"); + address previousOwner = owner; + owner = msg.sender; + pendingOwner = address(0); + emit OwnershipTransferred(previousOwner, msg.sender); + } + + mapping(address => Vault[]) private vaults; + + event Deposited(address indexed user, uint256 vaultId, uint256 amount, uint256 unlockTime); + event Withdrawn(address indexed user, uint256 vaultId, uint256 amount); + event EmergencyWithdrawn(address indexed owner, uint256 amount); + + //INTERNAL FUNCTIONS + function _depositRatio(uint256 _totalDeposit) internal pure returns (uint256 _tokenAmount) { + _tokenAmount = _totalDeposit * 10; // Token Ratio + } + + function deposit(uint256 _unlockTime) external payable returns (uint256) { + require(msg.value > 0, "Deposit must be greater than zero"); + require(_unlockTime > block.timestamp, "Unlock time must be in the future"); + + uint256 tokenBal = _depositRatio(msg.value); + require(token.transfer(msg.sender, tokenBal), "Token transfer failed"); + + // Create new vault + vaults[msg.sender].push( + Vault({balance: msg.value, unlockTime: _unlockTime, tokenBalance: tokenBal, active: true}) + ); + + uint256 vaultId = vaults[msg.sender].length - 1; + emit Deposited(msg.sender, vaultId, msg.value, _unlockTime); + + return vaultId; + } + + function withdraw(uint256 _vaultId) external { + require(_vaultId < vaults[msg.sender].length, "Invalid vault ID"); + + Vault storage userVault = vaults[msg.sender][_vaultId]; + require(userVault.active, "Vault is not active"); + require(userVault.balance > 0, "Vault has zero balance"); + require(block.timestamp >= userVault.unlockTime, "Funds are still locked"); + + uint256 amount = userVault.balance; + uint256 tokenAmount = userVault.tokenBalance; + + // Mark vault as inactive and clear balance + userVault.balance = 0; + userVault.tokenBalance = 0; + userVault.active = false; + + require(token.transferFrom(msg.sender, address(this), tokenAmount), "Token transfer failed"); + + (bool success,) = payable(msg.sender).call{value: amount}(""); + require(success, "Transfer failed"); + + emit Withdrawn(msg.sender, _vaultId, amount); + } + + function withdrawAll() external returns (uint256) { + uint256 totalWithdrawn = 0; + uint256 totalTokens = 0; + Vault[] storage userVaults = vaults[msg.sender]; + + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0 && block.timestamp >= userVaults[i].unlockTime) { + uint256 amount = userVaults[i].balance; + uint256 tokenAmount = userVaults[i].tokenBalance; + + userVaults[i].balance = 0; + userVaults[i].tokenBalance = 0; + userVaults[i].active = false; + + totalWithdrawn += amount; + totalTokens += tokenAmount; + emit Withdrawn(msg.sender, i, amount); + } + } + + require(totalWithdrawn > 0, "No unlocked funds available"); + + require(token.transfer(msg.sender, totalTokens), "Token transfer failed"); + + (bool success,) = payable(msg.sender).call{value: totalWithdrawn}(""); + require(success, "Transfer failed"); + + return totalWithdrawn; + } + + function emergencyWithdraw() external onlyOwner returns (uint256 amount) { + amount = address(this).balance; + require(amount > 0, "No funds available"); + + (bool success,) = payable(owner).call{value: amount}(""); + if (!success) revert(); + + emit EmergencyWithdrawn(owner, amount); + } + + function getVaultCount(address _user) external view returns (uint256) { + return vaults[_user].length; + } + + function getVault(address _user, uint256 _vaultId) + external + view + returns (uint256 balance, uint256 tokenBalance, uint256 unlockTime, bool active, bool isUnlocked) + { + require(_vaultId < vaults[_user].length, "Invalid vault ID"); + + Vault storage vault = vaults[_user][_vaultId]; + return (vault.balance, vault.tokenBalance, vault.unlockTime, vault.active, block.timestamp >= vault.unlockTime); + } + + function getAllVaults(address _user) external view returns (Vault[] memory) { + return vaults[_user]; + } + + function getActiveVaults(address _user) + external + view + returns ( + uint256[] memory activeVaults, + uint256[] memory balances, + uint256[] memory tokenBalances, + uint256[] memory unlockTimes + ) + { + Vault[] storage userVaults = vaults[_user]; + + // Count active vaults + uint256 activeCount = 0; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0) { + activeCount++; + } + } + + // Create arrays + activeVaults = new uint256[](activeCount); + balances = new uint256[](activeCount); + tokenBalances = new uint256[](activeCount); + unlockTimes = new uint256[](activeCount); + + // Populate arrays + uint256 index = 0; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0) { + activeVaults[index] = i; + balances[index] = userVaults[i].balance; + tokenBalances[index] = userVaults[i].tokenBalance; + unlockTimes[index] = userVaults[i].unlockTime; + index++; + } + } + + return (activeVaults, balances, tokenBalances, unlockTimes); + } + + function getTotalBalance(address _user) external view returns (uint256 total) { + Vault[] storage userVaults = vaults[_user]; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active) { + total += userVaults[i].balance; + } + } + return total; + } + + function getUnlockedBalance(address _user) external view returns (uint256 unlocked) { + Vault[] storage userVaults = vaults[_user]; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0 && block.timestamp >= userVaults[i].unlockTime) { + unlocked += userVaults[i].balance; + } + } + return unlocked; } - return unlocked; - } } diff --git a/assignments/foundry-test-assignment/src/interfaces/IERC20.sol b/assignments/foundry-test-assignment/src/interfaces/IERC20.sol index 3b788b73..92e32621 100644 --- a/assignments/foundry-test-assignment/src/interfaces/IERC20.sol +++ b/assignments/foundry-test-assignment/src/interfaces/IERC20.sol @@ -1,23 +1,19 @@ //SPDX-License-Identifier: MIT pragma solidity ^0.8.28; -import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; interface IErc20 is IERC20 { - function decimals() external view returns (uint8); + function decimals() external view returns (uint8); - function totalSupply() external view returns (uint256); + function totalSupply() external view returns (uint256); - function balanceOf(address _owner) external view returns (uint256 balance); + function balanceOf(address _owner) external view returns (uint256 balance); - function transferFrom( - address _from, - address _to, - uint256 _value - ) external returns (bool success); + function transferFrom(address _from, address _to, uint256 _value) external returns (bool success); - function mint(address to, uint256 amount) external; + function mint(address to, uint256 amount) external; - function burn(uint256 amount) external; + function burn(uint256 amount) external; - function burnFrom(address account, uint256 amount) external; + function burnFrom(address account, uint256 amount) external; } diff --git a/assignments/foundry-test-assignment/test/Vault.t.sol b/assignments/foundry-test-assignment/test/Vault.t.sol index 480a0872..bdf2655b 100644 --- a/assignments/foundry-test-assignment/test/Vault.t.sol +++ b/assignments/foundry-test-assignment/test/Vault.t.sol @@ -157,7 +157,7 @@ contract VaultTest is Test { assertEq(user.balance, userBalanceBefore + 1 ether); assertEq(address(vault).balance, vaultBalanceBefore - 1 ether); - (uint256 balance, , bool active, bool isUnlocked) = vault.getVault(user, 0); + (uint256 balance,, bool active, bool isUnlocked) = vault.getVault(user, 0); assertEq(balance, 0); assertFalse(active); assertTrue(isUnlocked); diff --git a/assignments/foundry-test-assignment/test/VaultV2.t.sol b/assignments/foundry-test-assignment/test/VaultV2.t.sol index 99effcfb..16f36564 100644 --- a/assignments/foundry-test-assignment/test/VaultV2.t.sol +++ b/assignments/foundry-test-assignment/test/VaultV2.t.sol @@ -1,274 +1,263 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.28; -import {Test} from 'forge-std/Test.sol'; -import {console} from 'forge-std/console.sol'; -import {TimeLockV2} from '../src/VaultV2.sol'; -import {JasonToken} from '../src/Token.sol'; +import {Test} from "forge-std/Test.sol"; +import {console} from "forge-std/console.sol"; +import {TimeLockV2} from "../src/VaultV2.sol"; +import {JasonToken} from "../src/Token.sol"; contract VaultV2Test is Test { - TimeLockV2 public vault; - JasonToken public token; - address public owner; - address public user; - address public attacker; - address public newOwner; + TimeLockV2 public vault; + JasonToken public token; + address public owner; + address public user; + address public attacker; + address public newOwner; - uint oneDay = 86400; - - function setUp() public { - owner = makeAddr('owner'); - vm.startPrank(owner); - token = new JasonToken(address(this), address(this)); - vault = new TimeLockV2(address(token)); - vm.stopPrank(); - user = makeAddr('user'); - attacker = makeAddr('attacker'); - newOwner = makeAddr('newOwner'); - - token.transfer(address(vault), 500 * 10 ** token.decimals()); - - vm.deal(user, 10 ether); - vm.deal(attacker, 10 ether); - } - - function test_ownerIsSetOnDeployment() public view { - assertEq(vault.owner(), owner); - console.log('Owner: ', vault.owner()); - console.log('This: ', owner); - } - - function test_emergencyWithdraw() public { - uint256 unlockTime = block.timestamp + 1 days; - uint256 ownerBalanceBefore = owner.balance; - - vm.prank(user); - vault.deposit{value: 1 ether}(unlockTime); - - vm.prank(owner); - uint256 withdrawnAmount = vault.emergencyWithdraw(); - - assertEq(withdrawnAmount, 1 ether); - assertEq(address(vault).balance, 0); - assertEq(owner.balance, ownerBalanceBefore + 1 ether); - } - - function test_revert_emergencyWithdraw_notOwner() public { - uint256 unlockTime = block.timestamp + oneDay; - - vm.prank(user); - vault.deposit{value: 1 ether}(unlockTime); - - vm.prank(attacker); - vm.expectRevert(); - vault.emergencyWithdraw(); - } - - function test_transferOwnership_and_acceptOwnership() public { - vm.prank(owner); - vault.transferOwnership(newOwner); - - assertEq(vault.pendingOwner(), newOwner); - - vm.prank(newOwner); - vault.acceptOwnership(); - - assertEq(vault.owner(), newOwner); - assertEq(vault.pendingOwner(), address(0)); - } - - function test_revert_transferOwnership_notOwner() public { - vm.prank(attacker); - vm.expectRevert('Not owner'); - vault.transferOwnership(newOwner); - } - - function test_revert_acceptOwnership_notPendingOwner() public { - vm.prank(owner); - vault.transferOwnership(newOwner); - - vm.prank(attacker); - vm.expectRevert('Not pending owner'); - vault.acceptOwnership(); - } - - function test_emergencyWithdraw_accessAfterOwnershipTransfer() public { - uint256 unlockTime = block.timestamp + 1 days; - - vm.prank(user); - vault.deposit{value: 1 ether}(unlockTime); - - vm.prank(owner); - vault.transferOwnership(newOwner); - - vm.prank(newOwner); - vault.acceptOwnership(); - - vm.prank(owner); - vm.expectRevert('Not owner'); - vault.emergencyWithdraw(); - - uint256 newOwnerBalanceBefore = newOwner.balance; - vm.prank(newOwner); - uint256 withdrawnAmount = vault.emergencyWithdraw(); - - assertEq(withdrawnAmount, 1 ether); - assertEq(address(vault).balance, 0); - assertEq(newOwner.balance, newOwnerBalanceBefore + 1 ether); - } - - function test_deposit() public { - uint256 unlockTime = block.timestamp + 1 days; - uint256 userEthBefore = user.balance; - uint256 vaultEthBefore = address(vault).balance; - uint256 userTokenBefore = token.balanceOf(user); - uint256 vaultTokenBefore = token.balanceOf(address(vault)); - - vm.prank(user); - vault.deposit{value: 1 ether}(unlockTime); - - assertEq(user.balance, userEthBefore - 1 ether); - assertEq(address(vault).balance, vaultEthBefore + 1 ether); - assertEq(token.balanceOf(user), userTokenBefore + 10 ether); - assertEq(token.balanceOf(address(vault)), vaultTokenBefore - 10 ether); - assertEq(vault.getVaultCount(user), 1); - - ( - uint256 balance, - uint256 tokenBalance, - uint256 savedUnlockTime, - bool active, - bool isUnlocked - ) = vault.getVault(user, 0); - - assertEq(balance, 1 ether); - assertEq(tokenBalance, 10 ether); - assertEq(savedUnlockTime, unlockTime); - assertTrue(active); - assertFalse(isUnlocked); - } - - function test_revert_deposit_withZeroValue() public { - uint256 unlockTime = block.timestamp + 1 days; - - vm.prank(user); - vm.expectRevert('Deposit must be greater than zero'); - vault.deposit{value: 0}(unlockTime); - } - - function test_getActiveVaults() public { - uint256 firstUnlockTime = block.timestamp + 1 days; - uint256 secondUnlockTime = block.timestamp + 2 days; - - vm.startPrank(user); - vault.deposit{value: 1 ether}(firstUnlockTime); - vault.deposit{value: 2 ether}(secondUnlockTime); - token.approve(address(vault), 10 ether); - vm.stopPrank(); - - vm.warp(firstUnlockTime); - - vm.prank(user); - vault.withdraw(0); - - ( - uint256[] memory activeVaults, - uint256[] memory balances, - uint256[] memory tokenBalances, - uint256[] memory unlockTimes - ) = vault.getActiveVaults(user); - - assertEq(activeVaults.length, 1); - assertEq(balances.length, 1); - assertEq(tokenBalances.length, 1); - assertEq(unlockTimes.length, 1); - assertEq(activeVaults[0], 1); - assertEq(balances[0], 2 ether); - assertEq(tokenBalances[0], 20 ether); - assertEq(unlockTimes[0], secondUnlockTime); - } - - function test_withdraw() public { - uint256 unlockTime = block.timestamp + 1 days; - - vm.prank(user); - vault.deposit{value: 1 ether}(unlockTime); - - vm.prank(user); - token.approve(address(vault), 10 ether); - - vm.warp(unlockTime); - - uint256 userEthBefore = user.balance; - uint256 vaultEthBefore = address(vault).balance; - uint256 userTokenBefore = token.balanceOf(user); - uint256 vaultTokenBefore = token.balanceOf(address(vault)); - - vm.prank(user); - vault.withdraw(0); - - assertEq(user.balance, userEthBefore + 1 ether); - assertEq(address(vault).balance, vaultEthBefore - 1 ether); - assertEq(token.balanceOf(user), userTokenBefore - 10 ether); - assertEq(token.balanceOf(address(vault)), vaultTokenBefore + 10 ether); - - ( - uint256 balance, - uint256 tokenBalance, - , - bool active, - bool isUnlocked - ) = vault.getVault(user, 0); - assertEq(balance, 0); - assertEq(tokenBalance, 0); - assertFalse(active); - assertTrue(isUnlocked); - } - - function test_revert_withdraw_withoutApproval() public { - uint256 unlockTime = block.timestamp + 1 days; - - vm.prank(user); - vault.deposit{value: 1 ether}(unlockTime); - - vm.warp(unlockTime); - - vm.prank(user); - vm.expectRevert(); - vault.withdraw(0); - } - - function test_withdrawAll() public { - uint256 firstUnlockTime = block.timestamp + 1 days; - uint256 secondUnlockTime = block.timestamp + 2 days; - - vm.startPrank(user); - vault.deposit{value: 1 ether}(firstUnlockTime); - vault.deposit{value: 2 ether}(secondUnlockTime); - vm.stopPrank(); - - vm.warp(secondUnlockTime); - - uint256 userEthBefore = user.balance; - uint256 vaultEthBefore = address(vault).balance; - - vm.prank(user); - uint256 amount = vault.withdrawAll(); - - assertEq(amount, 3 ether); - assertEq(user.balance, userEthBefore + 3 ether); - assertEq(address(vault).balance, vaultEthBefore - 3 ether); - assertEq(vault.getTotalBalance(user), 0); - } - - function test_revert_withdraw_fromAnotherUsersVault() public { - uint256 unlockTime = block.timestamp + 1 days; - - vm.prank(user); - vault.deposit{value: 1 ether}(unlockTime); - - vm.prank(attacker); - vm.expectRevert('Invalid vault ID'); - vault.withdraw(0); - } + uint256 oneDay = 86400; + + function setUp() public { + owner = makeAddr("owner"); + vm.startPrank(owner); + token = new JasonToken(address(this), address(this)); + vault = new TimeLockV2(address(token)); + vm.stopPrank(); + user = makeAddr("user"); + attacker = makeAddr("attacker"); + newOwner = makeAddr("newOwner"); + + token.transfer(address(vault), 500 * 10 ** token.decimals()); + + vm.deal(user, 10 ether); + vm.deal(attacker, 10 ether); + } + + function test_ownerIsSetOnDeployment() public view { + assertEq(vault.owner(), owner); + console.log("Owner: ", vault.owner()); + console.log("This: ", owner); + } + + function test_emergencyWithdraw() public { + uint256 unlockTime = block.timestamp + 1 days; + uint256 ownerBalanceBefore = owner.balance; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(owner); + uint256 withdrawnAmount = vault.emergencyWithdraw(); + + assertEq(withdrawnAmount, 1 ether); + assertEq(address(vault).balance, 0); + assertEq(owner.balance, ownerBalanceBefore + 1 ether); + } + + function test_revert_emergencyWithdraw_notOwner() public { + uint256 unlockTime = block.timestamp + oneDay; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(attacker); + vm.expectRevert(); + vault.emergencyWithdraw(); + } + + function test_transferOwnership_and_acceptOwnership() public { + vm.prank(owner); + vault.transferOwnership(newOwner); + + assertEq(vault.pendingOwner(), newOwner); + + vm.prank(newOwner); + vault.acceptOwnership(); + + assertEq(vault.owner(), newOwner); + assertEq(vault.pendingOwner(), address(0)); + } + + function test_revert_transferOwnership_notOwner() public { + vm.prank(attacker); + vm.expectRevert("Not owner"); + vault.transferOwnership(newOwner); + } + + function test_revert_acceptOwnership_notPendingOwner() public { + vm.prank(owner); + vault.transferOwnership(newOwner); + + vm.prank(attacker); + vm.expectRevert("Not pending owner"); + vault.acceptOwnership(); + } + + function test_emergencyWithdraw_accessAfterOwnershipTransfer() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(owner); + vault.transferOwnership(newOwner); + + vm.prank(newOwner); + vault.acceptOwnership(); + + vm.prank(owner); + vm.expectRevert("Not owner"); + vault.emergencyWithdraw(); + + uint256 newOwnerBalanceBefore = newOwner.balance; + vm.prank(newOwner); + uint256 withdrawnAmount = vault.emergencyWithdraw(); + + assertEq(withdrawnAmount, 1 ether); + assertEq(address(vault).balance, 0); + assertEq(newOwner.balance, newOwnerBalanceBefore + 1 ether); + } + + function test_deposit() public { + uint256 unlockTime = block.timestamp + 1 days; + uint256 userEthBefore = user.balance; + uint256 vaultEthBefore = address(vault).balance; + uint256 userTokenBefore = token.balanceOf(user); + uint256 vaultTokenBefore = token.balanceOf(address(vault)); + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + assertEq(user.balance, userEthBefore - 1 ether); + assertEq(address(vault).balance, vaultEthBefore + 1 ether); + assertEq(token.balanceOf(user), userTokenBefore + 10 ether); + assertEq(token.balanceOf(address(vault)), vaultTokenBefore - 10 ether); + assertEq(vault.getVaultCount(user), 1); + + (uint256 balance, uint256 tokenBalance, uint256 savedUnlockTime, bool active, bool isUnlocked) = + vault.getVault(user, 0); + + assertEq(balance, 1 ether); + assertEq(tokenBalance, 10 ether); + assertEq(savedUnlockTime, unlockTime); + assertTrue(active); + assertFalse(isUnlocked); + } + + function test_revert_deposit_withZeroValue() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vm.expectRevert("Deposit must be greater than zero"); + vault.deposit{value: 0}(unlockTime); + } + + function test_getActiveVaults() public { + uint256 firstUnlockTime = block.timestamp + 1 days; + uint256 secondUnlockTime = block.timestamp + 2 days; + + vm.startPrank(user); + vault.deposit{value: 1 ether}(firstUnlockTime); + vault.deposit{value: 2 ether}(secondUnlockTime); + token.approve(address(vault), 10 ether); + vm.stopPrank(); + + vm.warp(firstUnlockTime); + + vm.prank(user); + vault.withdraw(0); + + ( + uint256[] memory activeVaults, + uint256[] memory balances, + uint256[] memory tokenBalances, + uint256[] memory unlockTimes + ) = vault.getActiveVaults(user); + + assertEq(activeVaults.length, 1); + assertEq(balances.length, 1); + assertEq(tokenBalances.length, 1); + assertEq(unlockTimes.length, 1); + assertEq(activeVaults[0], 1); + assertEq(balances[0], 2 ether); + assertEq(tokenBalances[0], 20 ether); + assertEq(unlockTimes[0], secondUnlockTime); + } + + function test_withdraw() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(user); + token.approve(address(vault), 10 ether); + + vm.warp(unlockTime); + + uint256 userEthBefore = user.balance; + uint256 vaultEthBefore = address(vault).balance; + uint256 userTokenBefore = token.balanceOf(user); + uint256 vaultTokenBefore = token.balanceOf(address(vault)); + + vm.prank(user); + vault.withdraw(0); + + assertEq(user.balance, userEthBefore + 1 ether); + assertEq(address(vault).balance, vaultEthBefore - 1 ether); + assertEq(token.balanceOf(user), userTokenBefore - 10 ether); + assertEq(token.balanceOf(address(vault)), vaultTokenBefore + 10 ether); + + (uint256 balance, uint256 tokenBalance,, bool active, bool isUnlocked) = vault.getVault(user, 0); + assertEq(balance, 0); + assertEq(tokenBalance, 0); + assertFalse(active); + assertTrue(isUnlocked); + } + + function test_revert_withdraw_withoutApproval() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.warp(unlockTime); + + vm.prank(user); + vm.expectRevert(); + vault.withdraw(0); + } + + function test_withdrawAll() public { + uint256 firstUnlockTime = block.timestamp + 1 days; + uint256 secondUnlockTime = block.timestamp + 2 days; + + vm.startPrank(user); + vault.deposit{value: 1 ether}(firstUnlockTime); + vault.deposit{value: 2 ether}(secondUnlockTime); + vm.stopPrank(); + + vm.warp(secondUnlockTime); + + uint256 userEthBefore = user.balance; + uint256 vaultEthBefore = address(vault).balance; + + vm.prank(user); + uint256 amount = vault.withdrawAll(); + + assertEq(amount, 3 ether); + assertEq(user.balance, userEthBefore + 3 ether); + assertEq(address(vault).balance, vaultEthBefore - 3 ether); + assertEq(vault.getTotalBalance(user), 0); + } + + function test_revert_withdraw_fromAnotherUsersVault() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(attacker); + vm.expectRevert("Invalid vault ID"); + vault.withdraw(0); + } } From 27b97110e7eebbffb8bc8bb2f7b17a0de525c514 Mon Sep 17 00:00:00 2001 From: sprtd Date: Wed, 4 Mar 2026 13:09:16 +0100 Subject: [PATCH 06/10] chore: setup timelock test env --- sessions/timelock/.gitignore | 14 ++ sessions/timelock/.gitmodules | 3 + sessions/timelock/README.md | 66 ++++++ sessions/timelock/foundry.lock | 8 + sessions/timelock/foundry.toml | 6 + sessions/timelock/script/Counter.s.sol | 19 ++ sessions/timelock/src/CounterV3.sol | 60 ++++++ sessions/timelock/src/Token.sol | 17 ++ sessions/timelock/src/Vault.sol | 144 +++++++++++++ sessions/timelock/src/VaultV2.sol | 217 ++++++++++++++++++++ sessions/timelock/src/interfaces/IERC20.sol | 19 ++ sessions/timelock/test/TimeLock.ts | 152 ++++++++++++++ 12 files changed, 725 insertions(+) create mode 100644 sessions/timelock/.gitignore create mode 100644 sessions/timelock/.gitmodules create mode 100644 sessions/timelock/README.md create mode 100644 sessions/timelock/foundry.lock create mode 100644 sessions/timelock/foundry.toml create mode 100644 sessions/timelock/script/Counter.s.sol create mode 100644 sessions/timelock/src/CounterV3.sol create mode 100644 sessions/timelock/src/Token.sol create mode 100644 sessions/timelock/src/Vault.sol create mode 100644 sessions/timelock/src/VaultV2.sol create mode 100644 sessions/timelock/src/interfaces/IERC20.sol create mode 100644 sessions/timelock/test/TimeLock.ts diff --git a/sessions/timelock/.gitignore b/sessions/timelock/.gitignore new file mode 100644 index 00000000..85198aaa --- /dev/null +++ b/sessions/timelock/.gitignore @@ -0,0 +1,14 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env diff --git a/sessions/timelock/.gitmodules b/sessions/timelock/.gitmodules new file mode 100644 index 00000000..888d42dc --- /dev/null +++ b/sessions/timelock/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/sessions/timelock/README.md b/sessions/timelock/README.md new file mode 100644 index 00000000..8817d6ab --- /dev/null +++ b/sessions/timelock/README.md @@ -0,0 +1,66 @@ +## Foundry + +**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** + +Foundry consists of: + +- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). +- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. +- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. +- **Chisel**: Fast, utilitarian, and verbose solidity REPL. + +## Documentation + +https://book.getfoundry.sh/ + +## Usage + +### Build + +```shell +$ forge build +``` + +### Test + +```shell +$ forge test +``` + +### Format + +```shell +$ forge fmt +``` + +### Gas Snapshots + +```shell +$ forge snapshot +``` + +### Anvil + +```shell +$ anvil +``` + +### Deploy + +```shell +$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key +``` + +### Cast + +```shell +$ cast +``` + +### Help + +```shell +$ forge --help +$ anvil --help +$ cast --help +``` diff --git a/sessions/timelock/foundry.lock b/sessions/timelock/foundry.lock new file mode 100644 index 00000000..bc06b89b --- /dev/null +++ b/sessions/timelock/foundry.lock @@ -0,0 +1,8 @@ +{ + "lib/forge-std": { + "tag": { + "name": "v1.15.0", + "rev": "0844d7e1fc5e60d77b68e469bff60265f236c398" + } + } +} \ No newline at end of file diff --git a/sessions/timelock/foundry.toml b/sessions/timelock/foundry.toml new file mode 100644 index 00000000..25b918f9 --- /dev/null +++ b/sessions/timelock/foundry.toml @@ -0,0 +1,6 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/sessions/timelock/script/Counter.s.sol b/sessions/timelock/script/Counter.s.sol new file mode 100644 index 00000000..f01d69c3 --- /dev/null +++ b/sessions/timelock/script/Counter.s.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Script} from "forge-std/Script.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterScript is Script { + Counter public counter; + + function setUp() public {} + + function run() public { + vm.startBroadcast(); + + counter = new Counter(); + + vm.stopBroadcast(); + } +} diff --git a/sessions/timelock/src/CounterV3.sol b/sessions/timelock/src/CounterV3.sol new file mode 100644 index 00000000..269dfb0c --- /dev/null +++ b/sessions/timelock/src/CounterV3.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +contract CounterV3 { + uint256 public number; + address public owner; + + mapping(address => bool) public approvedCallers; + mapping(address => bool) public pendingApproval; + + event ApprovalRequested(address indexed caller); + event ApprovalGranted(address indexed caller); + event ApprovalRevoked(address indexed caller); + + constructor() { + owner = msg.sender; + } + + modifier onlyOwner() { + require(msg.sender == owner, "Not owner"); + _; + } + + modifier onlyAuthorized() { + if (msg.sender != owner) { + require(approvedCallers[msg.sender], "Owner consent required"); + } + _; + } + + function requestPrivilege() external { + require(msg.sender != owner, "Owner does not need consent"); + + pendingApproval[msg.sender] = true; + emit ApprovalRequested(msg.sender); + } + + function grantPrivilege(address caller) external onlyOwner { + require(pendingApproval[caller], "No pending request"); + + approvedCallers[caller] = true; + pendingApproval[caller] = false; + emit ApprovalGranted(caller); + } + + function revokePrivilege(address caller) external onlyOwner { + require(approvedCallers[caller], "Caller not approved"); + + approvedCallers[caller] = false; + emit ApprovalRevoked(caller); + } + + function setNumber(uint256 newNumber) public onlyAuthorized { + number = newNumber; + } + + function increment() public onlyAuthorized { + number++; + } +} diff --git a/sessions/timelock/src/Token.sol b/sessions/timelock/src/Token.sol new file mode 100644 index 00000000..50f223e5 --- /dev/null +++ b/sessions/timelock/src/Token.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +// Compatible with OpenZeppelin Contracts ^5.6.0 +pragma solidity ^0.8.27; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; + +contract JasonToken is ERC20, ERC20Burnable, Ownable { + constructor(address recipient, address initialOwner) ERC20("JasonToken", "JKB") Ownable(initialOwner) { + _mint(recipient, 1000 * 10 ** decimals()); + } + + function mint(address to, uint256 amount) public onlyOwner { + _mint(to, amount); + } +} diff --git a/sessions/timelock/src/Vault.sol b/sessions/timelock/src/Vault.sol new file mode 100644 index 00000000..4500dd36 --- /dev/null +++ b/sessions/timelock/src/Vault.sol @@ -0,0 +1,144 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +contract TimeLock { + struct Vault { + uint256 balance; + uint256 unlockTime; + bool active; + } + + mapping(address => Vault[]) private vaults; + + event Deposited(address indexed user, uint256 vaultId, uint256 amount, uint256 unlockTime); + event Withdrawn(address indexed user, uint256 vaultId, uint256 amount); + + function deposit(uint256 _unlockTime) external payable returns (uint256) { + require(msg.value > 0, "Deposit must be greater than zero"); + require(_unlockTime > block.timestamp, "Unlock time must be in the future"); + + // Create new vault + vaults[msg.sender].push(Vault({balance: msg.value, unlockTime: _unlockTime, active: true})); + + uint256 vaultId = vaults[msg.sender].length - 1; + emit Deposited(msg.sender, vaultId, msg.value, _unlockTime); + + return vaultId; + } + + function withdraw(uint256 _vaultId) external { + require(_vaultId < vaults[msg.sender].length, "Invalid vault ID"); + + Vault storage userVault = vaults[msg.sender][_vaultId]; + require(userVault.active, "Vault is not active"); + require(userVault.balance > 0, "Vault has zero balance"); + require(block.timestamp >= userVault.unlockTime, "Funds are still locked"); + + uint256 amount = userVault.balance; + + // Mark vault as inactive and clear balance + userVault.balance = 0; + userVault.active = false; + + (bool success,) = payable(msg.sender).call{value: amount}(""); + require(success, "Transfer failed"); + + emit Withdrawn(msg.sender, _vaultId, amount); + } + + function withdrawAll() external returns (uint256) { + uint256 totalWithdrawn = 0; + Vault[] storage userVaults = vaults[msg.sender]; + + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0 && block.timestamp >= userVaults[i].unlockTime) { + uint256 amount = userVaults[i].balance; + userVaults[i].balance = 0; + userVaults[i].active = false; + + totalWithdrawn += amount; + emit Withdrawn(msg.sender, i, amount); + } + } + + require(totalWithdrawn > 0, "No unlocked funds available"); + + (bool success,) = payable(msg.sender).call{value: totalWithdrawn}(""); + require(success, "Transfer failed"); + + return totalWithdrawn; + } + + function getVaultCount(address _user) external view returns (uint256) { + return vaults[_user].length; + } + + function getVault(address _user, uint256 _vaultId) + external + view + returns (uint256 balance, uint256 unlockTime, bool active, bool isUnlocked) + { + require(_vaultId < vaults[_user].length, "Invalid vault ID"); + + Vault storage vault = vaults[_user][_vaultId]; + return (vault.balance, vault.unlockTime, vault.active, block.timestamp >= vault.unlockTime); + } + + function getAllVaults(address _user) external view returns (Vault[] memory) { + return vaults[_user]; + } + + function getActiveVaults(address _user) + external + view + returns (uint256[] memory activeVaults, uint256[] memory balances, uint256[] memory unlockTimes) + { + Vault[] storage userVaults = vaults[_user]; + + // Count active vaults + uint256 activeCount = 0; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0) { + activeCount++; + } + } + + // Create arrays + activeVaults = new uint256[](activeCount); + balances = new uint256[](activeCount); + unlockTimes = new uint256[](activeCount); + + // Populate arrays + uint256 index = 0; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0) { + activeVaults[index] = i; + balances[index] = userVaults[i].balance; + unlockTimes[index] = userVaults[i].unlockTime; + index++; + } + } + + return (activeVaults, balances, unlockTimes); + } + + function getTotalBalance(address _user) external view returns (uint256 total) { + Vault[] storage userVaults = vaults[_user]; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active) { + total += userVaults[i].balance; + } + } + return total; + } + + function getUnlockedBalance(address _user) external view returns (uint256 unlocked) { + Vault[] storage userVaults = vaults[_user]; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0 && block.timestamp >= userVaults[i].unlockTime) { + unlocked += userVaults[i].balance; + } + } + return unlocked; + } +} diff --git a/sessions/timelock/src/VaultV2.sol b/sessions/timelock/src/VaultV2.sol new file mode 100644 index 00000000..41a6c350 --- /dev/null +++ b/sessions/timelock/src/VaultV2.sol @@ -0,0 +1,217 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; +import {IErc20} from "./interfaces/IERC20.sol"; + +contract TimeLockV2 { + IErc20 public immutable token; + address public owner; + address public pendingOwner; + + event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + struct Vault { + uint256 balance; + uint256 tokenBalance; + uint256 unlockTime; + bool active; + } + + constructor(address _token) { + token = IErc20(_token); + owner = msg.sender; + } + + modifier onlyOwner() { + require(msg.sender == owner, "Not owner"); + _; + } + + function transferOwnership(address newOwner) external onlyOwner { + require(newOwner != address(0), "New owner is zero address"); + require(newOwner != owner, "New owner is current owner"); + pendingOwner = newOwner; + emit OwnershipTransferStarted(owner, newOwner); + } + + function acceptOwnership() external { + require(msg.sender == pendingOwner, "Not pending owner"); + address previousOwner = owner; + owner = msg.sender; + pendingOwner = address(0); + emit OwnershipTransferred(previousOwner, msg.sender); + } + + mapping(address => Vault[]) private vaults; + + event Deposited(address indexed user, uint256 vaultId, uint256 amount, uint256 unlockTime); + event Withdrawn(address indexed user, uint256 vaultId, uint256 amount); + event EmergencyWithdrawn(address indexed owner, uint256 amount); + + //INTERNAL FUNCTIONS + function _depositRatio(uint256 _totalDeposit) internal pure returns (uint256 _tokenAmount) { + _tokenAmount = _totalDeposit * 10; // Token Ratio + } + + function deposit(uint256 _unlockTime) external payable returns (uint256) { + require(msg.value > 0, "Deposit must be greater than zero"); + require(_unlockTime > block.timestamp, "Unlock time must be in the future"); + + uint256 tokenBal = _depositRatio(msg.value); + require(token.transfer(msg.sender, tokenBal), "Token transfer failed"); + + // Create new vault + vaults[msg.sender].push( + Vault({balance: msg.value, unlockTime: _unlockTime, tokenBalance: tokenBal, active: true}) + ); + + uint256 vaultId = vaults[msg.sender].length - 1; + emit Deposited(msg.sender, vaultId, msg.value, _unlockTime); + + return vaultId; + } + + function withdraw(uint256 _vaultId) external { + require(_vaultId < vaults[msg.sender].length, "Invalid vault ID"); + + Vault storage userVault = vaults[msg.sender][_vaultId]; + require(userVault.active, "Vault is not active"); + require(userVault.balance > 0, "Vault has zero balance"); + require(block.timestamp >= userVault.unlockTime, "Funds are still locked"); + + uint256 amount = userVault.balance; + uint256 tokenAmount = userVault.tokenBalance; + + // Mark vault as inactive and clear balance + userVault.balance = 0; + userVault.tokenBalance = 0; + userVault.active = false; + + require(token.transferFrom(msg.sender, address(this), tokenAmount), "Token transfer failed"); + + (bool success,) = payable(msg.sender).call{value: amount}(""); + require(success, "Transfer failed"); + + emit Withdrawn(msg.sender, _vaultId, amount); + } + + function withdrawAll() external returns (uint256) { + uint256 totalWithdrawn = 0; + uint256 totalTokens = 0; + Vault[] storage userVaults = vaults[msg.sender]; + + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0 && block.timestamp >= userVaults[i].unlockTime) { + uint256 amount = userVaults[i].balance; + uint256 tokenAmount = userVaults[i].tokenBalance; + + userVaults[i].balance = 0; + userVaults[i].tokenBalance = 0; + userVaults[i].active = false; + + totalWithdrawn += amount; + totalTokens += tokenAmount; + emit Withdrawn(msg.sender, i, amount); + } + } + + require(totalWithdrawn > 0, "No unlocked funds available"); + + require(token.transfer(msg.sender, totalTokens), "Token transfer failed"); + + (bool success,) = payable(msg.sender).call{value: totalWithdrawn}(""); + require(success, "Transfer failed"); + + return totalWithdrawn; + } + + function emergencyWithdraw() external onlyOwner returns (uint256 amount) { + amount = address(this).balance; + require(amount > 0, "No funds available"); + + (bool success,) = payable(owner).call{value: amount}(""); + if (!success) revert(); + + emit EmergencyWithdrawn(owner, amount); + } + + function getVaultCount(address _user) external view returns (uint256) { + return vaults[_user].length; + } + + function getVault(address _user, uint256 _vaultId) + external + view + returns (uint256 balance, uint256 tokenBalance, uint256 unlockTime, bool active, bool isUnlocked) + { + require(_vaultId < vaults[_user].length, "Invalid vault ID"); + + Vault storage vault = vaults[_user][_vaultId]; + return (vault.balance, vault.tokenBalance, vault.unlockTime, vault.active, block.timestamp >= vault.unlockTime); + } + + function getAllVaults(address _user) external view returns (Vault[] memory) { + return vaults[_user]; + } + + function getActiveVaults(address _user) + external + view + returns ( + uint256[] memory activeVaults, + uint256[] memory balances, + uint256[] memory tokenBalances, + uint256[] memory unlockTimes + ) + { + Vault[] storage userVaults = vaults[_user]; + + // Count active vaults + uint256 activeCount = 0; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0) { + activeCount++; + } + } + + // Create arrays + activeVaults = new uint256[](activeCount); + balances = new uint256[](activeCount); + tokenBalances = new uint256[](activeCount); + unlockTimes = new uint256[](activeCount); + + // Populate arrays + uint256 index = 0; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0) { + activeVaults[index] = i; + balances[index] = userVaults[i].balance; + tokenBalances[index] = userVaults[i].tokenBalance; + unlockTimes[index] = userVaults[i].unlockTime; + index++; + } + } + + return (activeVaults, balances, tokenBalances, unlockTimes); + } + + function getTotalBalance(address _user) external view returns (uint256 total) { + Vault[] storage userVaults = vaults[_user]; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active) { + total += userVaults[i].balance; + } + } + return total; + } + + function getUnlockedBalance(address _user) external view returns (uint256 unlocked) { + Vault[] storage userVaults = vaults[_user]; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0 && block.timestamp >= userVaults[i].unlockTime) { + unlocked += userVaults[i].balance; + } + } + return unlocked; + } +} diff --git a/sessions/timelock/src/interfaces/IERC20.sol b/sessions/timelock/src/interfaces/IERC20.sol new file mode 100644 index 00000000..92e32621 --- /dev/null +++ b/sessions/timelock/src/interfaces/IERC20.sol @@ -0,0 +1,19 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IErc20 is IERC20 { + function decimals() external view returns (uint8); + + function totalSupply() external view returns (uint256); + + function balanceOf(address _owner) external view returns (uint256 balance); + + function transferFrom(address _from, address _to, uint256 _value) external returns (bool success); + + function mint(address to, uint256 amount) external; + + function burn(uint256 amount) external; + + function burnFrom(address account, uint256 amount) external; +} diff --git a/sessions/timelock/test/TimeLock.ts b/sessions/timelock/test/TimeLock.ts new file mode 100644 index 00000000..1f9992f7 --- /dev/null +++ b/sessions/timelock/test/TimeLock.ts @@ -0,0 +1,152 @@ +import { expect } from "chai"; +import { network } from "hardhat"; + +const { ethers, networkHelpers } = await network.connect(); + +const { time, loadFixture } = networkHelpers; + +describe("TimeLock", function () { + const ONE_DAY_IN_SECS = 24 * 60 * 60; + const DEPOSIT_AMOUNT = ethers.parseEther("1.0"); + + async function deployTimeLockFixture() { + const [owner, otherAccount] = await ethers.getSigners(); + const TimeLock = await ethers.getContractFactory("TimeLock"); + const timelock = await TimeLock.deploy(); + + return { timelock, owner, otherAccount }; + } + + describe("Deposits", function () { + it("Should create a vault with correct balance and unlock time", async function () { + const { timelock, owner } = await loadFixture(deployTimeLockFixture); + const unlockTime = (await time.latest()) + ONE_DAY_IN_SECS; + + await expect(timelock.deposit(unlockTime, { value: DEPOSIT_AMOUNT })) + .to.emit(timelock, "Deposited") + .withArgs(owner.address, 0, DEPOSIT_AMOUNT, unlockTime); + + const vault = await timelock.getVault(owner.address, 0); + expect(vault.balance).to.equal(DEPOSIT_AMOUNT); + expect(vault.unlockTime).to.equal(unlockTime); + expect(vault.active).to.be.true; + }); + + it("Should revert if deposit is 0", async function () { + const { timelock } = await loadFixture(deployTimeLockFixture); + const unlockTime = (await time.latest()) + ONE_DAY_IN_SECS; + + await expect(timelock.deposit(unlockTime, { value: 0 })) + .to.be.revertedWith("Deposit must be greater than zero"); + }); + + it("Should revert if unlock time is in the past", async function () { + const { timelock } = await loadFixture(deployTimeLockFixture); + const pastTime = (await time.latest()) - 100; + + await expect(timelock.deposit(pastTime, { value: DEPOSIT_AMOUNT })) + .to.be.revertedWith("Unlock time must be in the future"); + }); + }); + + describe.only("Withdrawals", function () { + it("Should fail if funds are still locked", async function () { + const { timelock } = await loadFixture(deployTimeLockFixture); + const unlockTime = (await time.latest()) + ONE_DAY_IN_SECS; + + await timelock.deposit(unlockTime, { value: DEPOSIT_AMOUNT }); + await expect(timelock.withdraw(0)).to.be.revertedWith("Funds are still locked"); + }); + + it("Should succeed if unlock time has passed", async function () { + const { timelock, owner } = await loadFixture(deployTimeLockFixture); + const unlockTime = (await time.latest()) + ONE_DAY_IN_SECS; + + await timelock.deposit(unlockTime, { value: DEPOSIT_AMOUNT }); + + // Move time forward + await time.increaseTo(unlockTime); + + await expect(timelock.withdraw(0)) + .to.emit(timelock, "Withdrawn") + .withArgs(owner.address, 0, DEPOSIT_AMOUNT); + + const vault = await timelock.getVault(owner.address, 0); + expect(vault.active).to.be.false; + expect(vault.balance).to.equal(0); + }); + + it("Should fail if trying to withdraw from an inactive vault", async function () { + const { timelock } = await loadFixture(deployTimeLockFixture); + const unlockTime = (await time.latest()) + ONE_DAY_IN_SECS; + + await timelock.deposit(unlockTime, { value: DEPOSIT_AMOUNT }); + await time.increaseTo(unlockTime); + await timelock.withdraw(0); + + await expect(timelock.withdraw(0)).to.be.revertedWith("Vault is not active"); + }); + }); + + describe("WithdrawAll", function () { + it("Should withdraw from multiple unlocked vaults at once", async function () { + const { timelock, owner } = await loadFixture(deployTimeLockFixture); + const now = await time.latest(); + + // Deposit into 3 vaults with different times + await timelock.deposit(now + ONE_DAY_IN_SECS, { value: DEPOSIT_AMOUNT }); // Vault 0 + await timelock.deposit(now + ONE_DAY_IN_SECS * 2, { value: DEPOSIT_AMOUNT }); // Vault 1 + await timelock.deposit(now + ONE_DAY_IN_SECS * 10, { value: DEPOSIT_AMOUNT }); // Vault 2 (remains locked) + + // Move time to unlock first two vaults + await time.increase(ONE_DAY_IN_SECS * 3); + + const expectedTransfer = DEPOSIT_AMOUNT * BigInt(2); + + await expect(timelock.withdrawAll()) + .to.changeEtherBalance(ethers, owner, expectedTransfer); + + expect(await timelock.getTotalBalance(owner.address)).to.equal(DEPOSIT_AMOUNT); // Only Vault 2 remains + }); + + it("Should revert if no vaults are ready for withdrawal", async function () { + const { timelock } = await loadFixture(deployTimeLockFixture); + const unlockTime = (await time.latest()) + ONE_DAY_IN_SECS; + + await timelock.deposit(unlockTime, { value: DEPOSIT_AMOUNT }); + await expect(timelock.withdrawAll()).to.be.revertedWith("No unlocked funds available"); + }); + }); + + describe("View Functions", function () { + it("Should correctly track total and unlocked balances", async function () { + const { timelock, owner } = await loadFixture(deployTimeLockFixture); + const now = await time.latest(); + + await timelock.deposit(now + 100, { value: ethers.parseEther("1") }); + await timelock.deposit(now + 1000, { value: ethers.parseEther("2") }); + + expect(await timelock.getTotalBalance(owner.address)).to.equal(ethers.parseEther("3")); + expect(await timelock.getUnlockedBalance(owner.address)).to.equal(0); + + await time.increase(200); + expect(await timelock.getUnlockedBalance(owner.address)).to.equal(ethers.parseEther("1")); + }); + + it("Should return only active vaults data", async function () { + const { timelock, owner } = await loadFixture(deployTimeLockFixture); + const now = await time.latest(); + + await timelock.deposit(now + 100, { value: DEPOSIT_AMOUNT }); + await timelock.deposit(now + 200, { value: DEPOSIT_AMOUNT }); + + // Withdraw the first one + await time.increase(150); + await timelock.withdraw(0); + + const activeData = await timelock.getActiveVaults(owner.address); + expect(activeData.activeVaults.length).to.equal(1); + expect(activeData.activeVaults[0]).to.equal(1); // Index of the second vault + }); + }); +}); From 566ab4ab58ab63913eee9c52808058bd7925abcc Mon Sep 17 00:00:00 2001 From: sprtd Date: Wed, 4 Mar 2026 13:41:34 +0100 Subject: [PATCH 07/10] chore: setup timelockv2 --- sessions/timelock/foundry.toml | 5 + sessions/timelock/mappings.txt | 1 + sessions/timelock/src/TimelockV3.sol | 216 ++++++++++++++++++++ sessions/timelock/test/CounterV3.t.sol | 124 ++++++++++++ sessions/timelock/test/TimeLock.ts | 152 -------------- sessions/timelock/test/Vault.t.sol | 221 +++++++++++++++++++++ sessions/timelock/test/VaultV2.t.sol | 263 +++++++++++++++++++++++++ 7 files changed, 830 insertions(+), 152 deletions(-) create mode 100644 sessions/timelock/mappings.txt create mode 100644 sessions/timelock/src/TimelockV3.sol create mode 100644 sessions/timelock/test/CounterV3.t.sol delete mode 100644 sessions/timelock/test/TimeLock.ts create mode 100644 sessions/timelock/test/Vault.t.sol create mode 100644 sessions/timelock/test/VaultV2.t.sol diff --git a/sessions/timelock/foundry.toml b/sessions/timelock/foundry.toml index 25b918f9..46a0ce98 100644 --- a/sessions/timelock/foundry.toml +++ b/sessions/timelock/foundry.toml @@ -4,3 +4,8 @@ out = "out" libs = ["lib"] # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options +# [profile.default] +remappings = [ + "@openzeppelin/=lib/openzeppelin-contracts/", + "@chainlink/contracts/=lib/chainlink-brownie-contracts/contracts/" +] \ No newline at end of file diff --git a/sessions/timelock/mappings.txt b/sessions/timelock/mappings.txt new file mode 100644 index 00000000..feaba2dd --- /dev/null +++ b/sessions/timelock/mappings.txt @@ -0,0 +1 @@ +forge-std/=lib/forge-std/src/ diff --git a/sessions/timelock/src/TimelockV3.sol b/sessions/timelock/src/TimelockV3.sol new file mode 100644 index 00000000..52065cbf --- /dev/null +++ b/sessions/timelock/src/TimelockV3.sol @@ -0,0 +1,216 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; +import {IErc20} from "./interfaces/IERC20.sol"; + +contract TimeLockV3 { + IErc20 public immutable token; + address public owner; + address public pendingOwner; + + event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + struct Vault { + uint256 balance; + uint256 tokenBalance; + uint256 unlockTime; + bool active; + } + + constructor(address _token) { + token = IErc20(_token); + owner = msg.sender; + } + + modifier onlyOwner() { + require(msg.sender == owner, "Not owner"); + _; + } + + mapping(address => Vault[]) private vaults; + + event Deposited(address indexed user, uint256 vaultId, uint256 amount, uint256 unlockTime); + event Withdrawn(address indexed user, uint256 vaultId, uint256 amount); + event EmergencyWithdrawn(address indexed owner, uint256 amount); + + //INTERNAL FUNCTIONS + function _depositRatio(uint256 _totalDeposit) internal pure returns (uint256 _tokenAmount) { + _tokenAmount = _totalDeposit * 10; // Token Ratio + } + + function deposit(uint256 _unlockTime) external payable returns (uint256) { + require(msg.value > 0, "Deposit must be greater than zero"); + require(_unlockTime > block.timestamp, "Unlock time must be in the future"); + + uint256 tokenBal = _depositRatio(msg.value); + require(token.transfer(msg.sender, tokenBal), "Token transfer failed"); + + // Create new vault + vaults[msg.sender].push( + Vault({balance: msg.value, unlockTime: _unlockTime, tokenBalance: tokenBal, active: true}) + ); + + uint256 vaultId = vaults[msg.sender].length - 1; + emit Deposited(msg.sender, vaultId, msg.value, _unlockTime); + + return vaultId; + } + + function withdraw(uint256 _vaultId) external { + require(_vaultId < vaults[msg.sender].length, "Invalid vault ID"); + + Vault storage userVault = vaults[msg.sender][_vaultId]; + require(userVault.active, "Vault is not active"); + require(userVault.balance > 0, "Vault has zero balance"); + require(block.timestamp >= userVault.unlockTime, "Funds are still locked"); + + uint256 amount = userVault.balance; + uint256 tokenAmount = userVault.tokenBalance; + + // Mark vault as inactive and clear balance + userVault.balance = 0; + userVault.tokenBalance = 0; + userVault.active = false; + + require(token.transferFrom(msg.sender, address(this), tokenAmount), "Token transfer failed"); + + (bool success,) = payable(msg.sender).call{value: amount}(""); + require(success, "Transfer failed"); + + emit Withdrawn(msg.sender, _vaultId, amount); + } + + function withdrawAll() external returns (uint256) { + uint256 totalWithdrawn = 0; + uint256 totalTokens = 0; + Vault[] storage userVaults = vaults[msg.sender]; + + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0 && block.timestamp >= userVaults[i].unlockTime) { + uint256 amount = userVaults[i].balance; + uint256 tokenAmount = userVaults[i].tokenBalance; + + userVaults[i].balance = 0; + userVaults[i].tokenBalance = 0; + userVaults[i].active = false; + + totalWithdrawn += amount; + totalTokens += tokenAmount; + emit Withdrawn(msg.sender, i, amount); + } + } + + require(totalWithdrawn > 0, "No unlocked funds available"); + + require(token.transfer(msg.sender, totalTokens), "Token transfer failed"); + (bool success,) = payable(msg.sender).call{value: totalWithdrawn}(""); + require(success, "Transfer failed"); + + return totalWithdrawn; + } + + function emergencyWithdraw() external onlyOwner returns (uint256 amount) { + amount = address(this).balance; + require(amount > 0, "No funds available"); + + (bool success,) = payable(owner).call{value: amount}(""); + if (!success) revert(); + + emit EmergencyWithdrawn(owner, amount); + } + + function getVaultCount(address _user) external view returns (uint256) { + return vaults[_user].length; + } + + function getVault(address _user, uint256 _vaultId) + external + view + returns (uint256 balance, uint256 tokenBalance, uint256 unlockTime, bool active, bool isUnlocked) + { + require(_vaultId < vaults[_user].length, "Invalid vault ID"); + + Vault storage vault = vaults[_user][_vaultId]; + return (vault.balance, vault.tokenBalance, vault.unlockTime, vault.active, block.timestamp >= vault.unlockTime); + } + + function getAllVaults(address _user) external view returns (Vault[] memory) { + return vaults[_user]; + } + + function getActiveVaults(address _user) + external + view + returns ( + uint256[] memory activeVaults, + uint256[] memory balances, + uint256[] memory tokenBalances, + uint256[] memory unlockTimes + ) + { + Vault[] storage userVaults = vaults[_user]; + + // Count active vaults + uint256 activeCount = 0; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0) { + activeCount++; + } + } + + // Create arrays + activeVaults = new uint256[](activeCount); + balances = new uint256[](activeCount); + tokenBalances = new uint256[](activeCount); + unlockTimes = new uint256[](activeCount); + + // Populate arrays + uint256 index = 0; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0) { + activeVaults[index] = i; + balances[index] = userVaults[i].balance; + tokenBalances[index] = userVaults[i].tokenBalance; + unlockTimes[index] = userVaults[i].unlockTime; + index++; + } + } + + return (activeVaults, balances, tokenBalances, unlockTimes); + } + + function getTotalBalance(address _user) external view returns (uint256 total) { + Vault[] storage userVaults = vaults[_user]; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active) { + total += userVaults[i].balance; + } + } + return total; + } + + function getUnlockedBalance(address _user) external view returns (uint256 unlocked) { + Vault[] storage userVaults = vaults[_user]; + for (uint256 i = 0; i < userVaults.length; i++) { + if (userVaults[i].active && userVaults[i].balance > 0 && block.timestamp >= userVaults[i].unlockTime) { + unlocked += userVaults[i].balance; + } + } + return unlocked; + } + + function transferOwnership(address newOwner) external onlyOwner { + require(newOwner != address(0), "New owner is zero address"); + require(newOwner != owner, "New owner is current owner"); + pendingOwner = newOwner; + emit OwnershipTransferStarted(owner, newOwner); + } + + function acceptOwnership() external { + require(msg.sender == pendingOwner, "Not pending owner"); + address previousOwner = owner; + owner = msg.sender; + pendingOwner = address(0); + emit OwnershipTransferred(previousOwner, msg.sender); + } +} diff --git a/sessions/timelock/test/CounterV3.t.sol b/sessions/timelock/test/CounterV3.t.sol new file mode 100644 index 00000000..e3384801 --- /dev/null +++ b/sessions/timelock/test/CounterV3.t.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {CounterV3} from "../src/CounterV3.sol"; + +contract CounterV3Test is Test { + CounterV3 public counterv3; + address public addr1; + address public addr2; + + function setUp() public { + counterv3 = new CounterV3(); + addr1 = makeAddr("addr1"); + addr2 = makeAddr("addr2"); + } + + function test_setNumber() public { + counterv3.setNumber(3); + assertEq(counterv3.number(), 3); + } + + function test_increment() public { + counterv3.increment(); + assertEq(counterv3.number(), 1); + } + + function test_revert_increment_withoutPrivilege() public { + vm.prank(addr1); + vm.expectRevert("Owner consent required"); + counterv3.increment(); + + assertEq(counterv3.number(), 0); + } + + function test_revert_setNumber_withoutPrivilege() public { + vm.prank(addr1); + vm.expectRevert("Owner consent required"); + counterv3.setNumber(7); + + assertEq(counterv3.number(), 0); + } + + function test_grantPrivilege() public { + vm.prank(addr1); + counterv3.requestPrivilege(); + + assertTrue(counterv3.pendingApproval(addr1)); + + counterv3.grantPrivilege(addr1); + + assertTrue(counterv3.approvedCallers(addr1)); + assertFalse(counterv3.pendingApproval(addr1)); + } + + function test_approvedCaller_increment() public { + vm.prank(addr1); + counterv3.requestPrivilege(); + + counterv3.grantPrivilege(addr1); + + vm.prank(addr1); + counterv3.increment(); + + assertEq(counterv3.number(), 1); + } + + function test_revert_grantPrivilege_withoutRequest() public { + vm.expectRevert("No pending request"); + counterv3.grantPrivilege(addr1); + } + + function test_revokePrivilege() public { + vm.prank(addr1); + counterv3.requestPrivilege(); + + counterv3.grantPrivilege(addr1); + counterv3.revokePrivilege(addr1); + + assertFalse(counterv3.approvedCallers(addr1)); + } + + function test_revert_revokedCaller_setNumber() public { + vm.prank(addr1); + counterv3.requestPrivilege(); + + counterv3.grantPrivilege(addr1); + counterv3.revokePrivilege(addr1); + + vm.prank(addr1); + vm.expectRevert("Owner consent required"); + counterv3.setNumber(7); + } + + function test_revert_grantPrivilege_notOwner() public { + vm.prank(addr1); + counterv3.requestPrivilege(); + + vm.prank(addr2); + vm.expectRevert("Not owner"); + counterv3.grantPrivilege(addr1); + } + + function test_revert_revokePrivilege_notOwner() public { + vm.prank(addr1); + counterv3.requestPrivilege(); + + counterv3.grantPrivilege(addr1); + + vm.prank(addr2); + vm.expectRevert("Not owner"); + counterv3.revokePrivilege(addr1); + } + + function test_revert_revokePrivilege_withoutApproval() public { + vm.expectRevert("Caller not approved"); + counterv3.revokePrivilege(addr1); + } + + function test_revert_requestPrivilege_owner() public { + vm.expectRevert("Owner does not need consent"); + counterv3.requestPrivilege(); + } +} diff --git a/sessions/timelock/test/TimeLock.ts b/sessions/timelock/test/TimeLock.ts deleted file mode 100644 index 1f9992f7..00000000 --- a/sessions/timelock/test/TimeLock.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { expect } from "chai"; -import { network } from "hardhat"; - -const { ethers, networkHelpers } = await network.connect(); - -const { time, loadFixture } = networkHelpers; - -describe("TimeLock", function () { - const ONE_DAY_IN_SECS = 24 * 60 * 60; - const DEPOSIT_AMOUNT = ethers.parseEther("1.0"); - - async function deployTimeLockFixture() { - const [owner, otherAccount] = await ethers.getSigners(); - const TimeLock = await ethers.getContractFactory("TimeLock"); - const timelock = await TimeLock.deploy(); - - return { timelock, owner, otherAccount }; - } - - describe("Deposits", function () { - it("Should create a vault with correct balance and unlock time", async function () { - const { timelock, owner } = await loadFixture(deployTimeLockFixture); - const unlockTime = (await time.latest()) + ONE_DAY_IN_SECS; - - await expect(timelock.deposit(unlockTime, { value: DEPOSIT_AMOUNT })) - .to.emit(timelock, "Deposited") - .withArgs(owner.address, 0, DEPOSIT_AMOUNT, unlockTime); - - const vault = await timelock.getVault(owner.address, 0); - expect(vault.balance).to.equal(DEPOSIT_AMOUNT); - expect(vault.unlockTime).to.equal(unlockTime); - expect(vault.active).to.be.true; - }); - - it("Should revert if deposit is 0", async function () { - const { timelock } = await loadFixture(deployTimeLockFixture); - const unlockTime = (await time.latest()) + ONE_DAY_IN_SECS; - - await expect(timelock.deposit(unlockTime, { value: 0 })) - .to.be.revertedWith("Deposit must be greater than zero"); - }); - - it("Should revert if unlock time is in the past", async function () { - const { timelock } = await loadFixture(deployTimeLockFixture); - const pastTime = (await time.latest()) - 100; - - await expect(timelock.deposit(pastTime, { value: DEPOSIT_AMOUNT })) - .to.be.revertedWith("Unlock time must be in the future"); - }); - }); - - describe.only("Withdrawals", function () { - it("Should fail if funds are still locked", async function () { - const { timelock } = await loadFixture(deployTimeLockFixture); - const unlockTime = (await time.latest()) + ONE_DAY_IN_SECS; - - await timelock.deposit(unlockTime, { value: DEPOSIT_AMOUNT }); - await expect(timelock.withdraw(0)).to.be.revertedWith("Funds are still locked"); - }); - - it("Should succeed if unlock time has passed", async function () { - const { timelock, owner } = await loadFixture(deployTimeLockFixture); - const unlockTime = (await time.latest()) + ONE_DAY_IN_SECS; - - await timelock.deposit(unlockTime, { value: DEPOSIT_AMOUNT }); - - // Move time forward - await time.increaseTo(unlockTime); - - await expect(timelock.withdraw(0)) - .to.emit(timelock, "Withdrawn") - .withArgs(owner.address, 0, DEPOSIT_AMOUNT); - - const vault = await timelock.getVault(owner.address, 0); - expect(vault.active).to.be.false; - expect(vault.balance).to.equal(0); - }); - - it("Should fail if trying to withdraw from an inactive vault", async function () { - const { timelock } = await loadFixture(deployTimeLockFixture); - const unlockTime = (await time.latest()) + ONE_DAY_IN_SECS; - - await timelock.deposit(unlockTime, { value: DEPOSIT_AMOUNT }); - await time.increaseTo(unlockTime); - await timelock.withdraw(0); - - await expect(timelock.withdraw(0)).to.be.revertedWith("Vault is not active"); - }); - }); - - describe("WithdrawAll", function () { - it("Should withdraw from multiple unlocked vaults at once", async function () { - const { timelock, owner } = await loadFixture(deployTimeLockFixture); - const now = await time.latest(); - - // Deposit into 3 vaults with different times - await timelock.deposit(now + ONE_DAY_IN_SECS, { value: DEPOSIT_AMOUNT }); // Vault 0 - await timelock.deposit(now + ONE_DAY_IN_SECS * 2, { value: DEPOSIT_AMOUNT }); // Vault 1 - await timelock.deposit(now + ONE_DAY_IN_SECS * 10, { value: DEPOSIT_AMOUNT }); // Vault 2 (remains locked) - - // Move time to unlock first two vaults - await time.increase(ONE_DAY_IN_SECS * 3); - - const expectedTransfer = DEPOSIT_AMOUNT * BigInt(2); - - await expect(timelock.withdrawAll()) - .to.changeEtherBalance(ethers, owner, expectedTransfer); - - expect(await timelock.getTotalBalance(owner.address)).to.equal(DEPOSIT_AMOUNT); // Only Vault 2 remains - }); - - it("Should revert if no vaults are ready for withdrawal", async function () { - const { timelock } = await loadFixture(deployTimeLockFixture); - const unlockTime = (await time.latest()) + ONE_DAY_IN_SECS; - - await timelock.deposit(unlockTime, { value: DEPOSIT_AMOUNT }); - await expect(timelock.withdrawAll()).to.be.revertedWith("No unlocked funds available"); - }); - }); - - describe("View Functions", function () { - it("Should correctly track total and unlocked balances", async function () { - const { timelock, owner } = await loadFixture(deployTimeLockFixture); - const now = await time.latest(); - - await timelock.deposit(now + 100, { value: ethers.parseEther("1") }); - await timelock.deposit(now + 1000, { value: ethers.parseEther("2") }); - - expect(await timelock.getTotalBalance(owner.address)).to.equal(ethers.parseEther("3")); - expect(await timelock.getUnlockedBalance(owner.address)).to.equal(0); - - await time.increase(200); - expect(await timelock.getUnlockedBalance(owner.address)).to.equal(ethers.parseEther("1")); - }); - - it("Should return only active vaults data", async function () { - const { timelock, owner } = await loadFixture(deployTimeLockFixture); - const now = await time.latest(); - - await timelock.deposit(now + 100, { value: DEPOSIT_AMOUNT }); - await timelock.deposit(now + 200, { value: DEPOSIT_AMOUNT }); - - // Withdraw the first one - await time.increase(150); - await timelock.withdraw(0); - - const activeData = await timelock.getActiveVaults(owner.address); - expect(activeData.activeVaults.length).to.equal(1); - expect(activeData.activeVaults[0]).to.equal(1); // Index of the second vault - }); - }); -}); diff --git a/sessions/timelock/test/Vault.t.sol b/sessions/timelock/test/Vault.t.sol new file mode 100644 index 00000000..bdf2655b --- /dev/null +++ b/sessions/timelock/test/Vault.t.sol @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {TimeLock} from "../src/Vault.sol"; + +contract VaultTest is Test { + TimeLock public vault; + address public user; + address public attacker; + + function setUp() public { + vault = new TimeLock(); + user = makeAddr("user"); + attacker = makeAddr("attacker"); + + assertEq(address(vault).balance, 0); + + vm.deal(user, 10 ether); + vm.deal(attacker, 10 ether); + } + + function test_deposit() public { + uint256 unlockTime = block.timestamp + 1 days; + uint256 userBalanceBefore = user.balance; + uint256 vaultBalanceBefore = address(vault).balance; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + assertEq(user.balance, userBalanceBefore - 1 ether); + assertEq(address(vault).balance, vaultBalanceBefore + 1 ether); + assertEq(vault.getVaultCount(user), 1); + + (uint256 balance, uint256 savedUnlockTime, bool active, bool isUnlocked) = vault.getVault(user, 0); + assertEq(balance, 1 ether); + assertEq(savedUnlockTime, unlockTime); + assertTrue(active); + assertFalse(isUnlocked); + } + + function test_revert_deposit_withZeroValue() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vm.expectRevert("Deposit must be greater than zero"); + vault.deposit{value: 0}(unlockTime); + } + + function test_revert_deposit_withNonFutureUnlockTime() public { + vm.prank(user); + vm.expectRevert("Unlock time must be in the future"); + vault.deposit{value: 1 ether}(block.timestamp); + } + + function test_revert_getVault_withInvalidVaultId() public { + vm.expectRevert("Invalid vault ID"); + vault.getVault(user, 0); + } + + function test_getAllVaults() public { + uint256 firstUnlockTime = block.timestamp + 1 days; + uint256 secondUnlockTime = block.timestamp + 2 days; + + vm.startPrank(user); + vault.deposit{value: 1 ether}(firstUnlockTime); + vault.deposit{value: 2 ether}(secondUnlockTime); + vm.stopPrank(); + + TimeLock.Vault[] memory vaults = vault.getAllVaults(user); + + assertEq(vaults.length, 2); + assertEq(vaults[0].balance, 1 ether); + assertEq(vaults[0].unlockTime, firstUnlockTime); + assertTrue(vaults[0].active); + assertEq(vaults[1].balance, 2 ether); + assertEq(vaults[1].unlockTime, secondUnlockTime); + assertTrue(vaults[1].active); + } + + function test_getActiveVaults() public { + uint256 firstUnlockTime = block.timestamp + 1 days; + uint256 secondUnlockTime = block.timestamp + 2 days; + + vm.startPrank(user); + vault.deposit{value: 1 ether}(firstUnlockTime); + vault.deposit{value: 2 ether}(secondUnlockTime); + vm.stopPrank(); + + vm.warp(firstUnlockTime); + + vm.prank(user); + vault.withdraw(0); + + (uint256[] memory activeVaults, uint256[] memory balances, uint256[] memory unlockTimes) = + vault.getActiveVaults(user); + + assertEq(activeVaults.length, 1); + assertEq(balances.length, 1); + assertEq(unlockTimes.length, 1); + assertEq(activeVaults[0], 1); + assertEq(balances[0], 2 ether); + assertEq(unlockTimes[0], secondUnlockTime); + } + + function test_getTotalBalance() public { + uint256 firstUnlockTime = block.timestamp + 1 days; + uint256 secondUnlockTime = block.timestamp + 2 days; + + vm.startPrank(user); + vault.deposit{value: 1 ether}(firstUnlockTime); + vault.deposit{value: 2 ether}(secondUnlockTime); + vm.stopPrank(); + + assertEq(vault.getTotalBalance(user), 3 ether); + + vm.warp(firstUnlockTime); + + vm.prank(user); + vault.withdraw(0); + + assertEq(vault.getTotalBalance(user), 2 ether); + } + + function test_getUnlockedBalance() public { + uint256 firstUnlockTime = block.timestamp + 1 days; + uint256 secondUnlockTime = block.timestamp + 2 days; + + vm.startPrank(user); + vault.deposit{value: 1 ether}(firstUnlockTime); + vault.deposit{value: 2 ether}(secondUnlockTime); + vm.stopPrank(); + + assertEq(vault.getUnlockedBalance(user), 0); + + vm.warp(firstUnlockTime); + assertEq(vault.getUnlockedBalance(user), 1 ether); + + vm.warp(secondUnlockTime); + assertEq(vault.getUnlockedBalance(user), 3 ether); + } + + function test_withdraw() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.warp(unlockTime); + + uint256 userBalanceBefore = user.balance; + uint256 vaultBalanceBefore = address(vault).balance; + + vm.prank(user); + vault.withdraw(0); + + assertEq(user.balance, userBalanceBefore + 1 ether); + assertEq(address(vault).balance, vaultBalanceBefore - 1 ether); + + (uint256 balance,, bool active, bool isUnlocked) = vault.getVault(user, 0); + assertEq(balance, 0); + assertFalse(active); + assertTrue(isUnlocked); + } + + function test_revert_withdraw_whenStillLocked() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(user); + vm.expectRevert("Funds are still locked"); + vault.withdraw(0); + } + + function test_revert_withdraw_fromAnotherUsersVault() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(attacker); + vm.expectRevert("Invalid vault ID"); + vault.withdraw(0); + } + + function test_withdrawAll() public { + uint256 firstUnlockTime = block.timestamp + 1 days; + uint256 secondUnlockTime = block.timestamp + 2 days; + + vm.startPrank(user); + vault.deposit{value: 1 ether}(firstUnlockTime); + vault.deposit{value: 2 ether}(secondUnlockTime); + vm.stopPrank(); + + vm.warp(secondUnlockTime); + + uint256 userBalanceBefore = user.balance; + uint256 vaultBalanceBefore = address(vault).balance; + + vm.prank(user); + uint256 amount = vault.withdrawAll(); + + assertEq(amount, 3 ether); + assertEq(user.balance, userBalanceBefore + 3 ether); + assertEq(address(vault).balance, vaultBalanceBefore - 3 ether); + assertEq(vault.getTotalBalance(user), 0); + } + + function test_revert_withdrawAll_fromUserWithoutUnlockedFunds() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(attacker); + vm.expectRevert("No unlocked funds available"); + vault.withdrawAll(); + } +} diff --git a/sessions/timelock/test/VaultV2.t.sol b/sessions/timelock/test/VaultV2.t.sol new file mode 100644 index 00000000..16f36564 --- /dev/null +++ b/sessions/timelock/test/VaultV2.t.sol @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {console} from "forge-std/console.sol"; +import {TimeLockV2} from "../src/VaultV2.sol"; +import {JasonToken} from "../src/Token.sol"; + +contract VaultV2Test is Test { + TimeLockV2 public vault; + JasonToken public token; + address public owner; + address public user; + address public attacker; + address public newOwner; + + uint256 oneDay = 86400; + + function setUp() public { + owner = makeAddr("owner"); + vm.startPrank(owner); + token = new JasonToken(address(this), address(this)); + vault = new TimeLockV2(address(token)); + vm.stopPrank(); + user = makeAddr("user"); + attacker = makeAddr("attacker"); + newOwner = makeAddr("newOwner"); + + token.transfer(address(vault), 500 * 10 ** token.decimals()); + + vm.deal(user, 10 ether); + vm.deal(attacker, 10 ether); + } + + function test_ownerIsSetOnDeployment() public view { + assertEq(vault.owner(), owner); + console.log("Owner: ", vault.owner()); + console.log("This: ", owner); + } + + function test_emergencyWithdraw() public { + uint256 unlockTime = block.timestamp + 1 days; + uint256 ownerBalanceBefore = owner.balance; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(owner); + uint256 withdrawnAmount = vault.emergencyWithdraw(); + + assertEq(withdrawnAmount, 1 ether); + assertEq(address(vault).balance, 0); + assertEq(owner.balance, ownerBalanceBefore + 1 ether); + } + + function test_revert_emergencyWithdraw_notOwner() public { + uint256 unlockTime = block.timestamp + oneDay; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(attacker); + vm.expectRevert(); + vault.emergencyWithdraw(); + } + + function test_transferOwnership_and_acceptOwnership() public { + vm.prank(owner); + vault.transferOwnership(newOwner); + + assertEq(vault.pendingOwner(), newOwner); + + vm.prank(newOwner); + vault.acceptOwnership(); + + assertEq(vault.owner(), newOwner); + assertEq(vault.pendingOwner(), address(0)); + } + + function test_revert_transferOwnership_notOwner() public { + vm.prank(attacker); + vm.expectRevert("Not owner"); + vault.transferOwnership(newOwner); + } + + function test_revert_acceptOwnership_notPendingOwner() public { + vm.prank(owner); + vault.transferOwnership(newOwner); + + vm.prank(attacker); + vm.expectRevert("Not pending owner"); + vault.acceptOwnership(); + } + + function test_emergencyWithdraw_accessAfterOwnershipTransfer() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(owner); + vault.transferOwnership(newOwner); + + vm.prank(newOwner); + vault.acceptOwnership(); + + vm.prank(owner); + vm.expectRevert("Not owner"); + vault.emergencyWithdraw(); + + uint256 newOwnerBalanceBefore = newOwner.balance; + vm.prank(newOwner); + uint256 withdrawnAmount = vault.emergencyWithdraw(); + + assertEq(withdrawnAmount, 1 ether); + assertEq(address(vault).balance, 0); + assertEq(newOwner.balance, newOwnerBalanceBefore + 1 ether); + } + + function test_deposit() public { + uint256 unlockTime = block.timestamp + 1 days; + uint256 userEthBefore = user.balance; + uint256 vaultEthBefore = address(vault).balance; + uint256 userTokenBefore = token.balanceOf(user); + uint256 vaultTokenBefore = token.balanceOf(address(vault)); + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + assertEq(user.balance, userEthBefore - 1 ether); + assertEq(address(vault).balance, vaultEthBefore + 1 ether); + assertEq(token.balanceOf(user), userTokenBefore + 10 ether); + assertEq(token.balanceOf(address(vault)), vaultTokenBefore - 10 ether); + assertEq(vault.getVaultCount(user), 1); + + (uint256 balance, uint256 tokenBalance, uint256 savedUnlockTime, bool active, bool isUnlocked) = + vault.getVault(user, 0); + + assertEq(balance, 1 ether); + assertEq(tokenBalance, 10 ether); + assertEq(savedUnlockTime, unlockTime); + assertTrue(active); + assertFalse(isUnlocked); + } + + function test_revert_deposit_withZeroValue() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vm.expectRevert("Deposit must be greater than zero"); + vault.deposit{value: 0}(unlockTime); + } + + function test_getActiveVaults() public { + uint256 firstUnlockTime = block.timestamp + 1 days; + uint256 secondUnlockTime = block.timestamp + 2 days; + + vm.startPrank(user); + vault.deposit{value: 1 ether}(firstUnlockTime); + vault.deposit{value: 2 ether}(secondUnlockTime); + token.approve(address(vault), 10 ether); + vm.stopPrank(); + + vm.warp(firstUnlockTime); + + vm.prank(user); + vault.withdraw(0); + + ( + uint256[] memory activeVaults, + uint256[] memory balances, + uint256[] memory tokenBalances, + uint256[] memory unlockTimes + ) = vault.getActiveVaults(user); + + assertEq(activeVaults.length, 1); + assertEq(balances.length, 1); + assertEq(tokenBalances.length, 1); + assertEq(unlockTimes.length, 1); + assertEq(activeVaults[0], 1); + assertEq(balances[0], 2 ether); + assertEq(tokenBalances[0], 20 ether); + assertEq(unlockTimes[0], secondUnlockTime); + } + + function test_withdraw() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(user); + token.approve(address(vault), 10 ether); + + vm.warp(unlockTime); + + uint256 userEthBefore = user.balance; + uint256 vaultEthBefore = address(vault).balance; + uint256 userTokenBefore = token.balanceOf(user); + uint256 vaultTokenBefore = token.balanceOf(address(vault)); + + vm.prank(user); + vault.withdraw(0); + + assertEq(user.balance, userEthBefore + 1 ether); + assertEq(address(vault).balance, vaultEthBefore - 1 ether); + assertEq(token.balanceOf(user), userTokenBefore - 10 ether); + assertEq(token.balanceOf(address(vault)), vaultTokenBefore + 10 ether); + + (uint256 balance, uint256 tokenBalance,, bool active, bool isUnlocked) = vault.getVault(user, 0); + assertEq(balance, 0); + assertEq(tokenBalance, 0); + assertFalse(active); + assertTrue(isUnlocked); + } + + function test_revert_withdraw_withoutApproval() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.warp(unlockTime); + + vm.prank(user); + vm.expectRevert(); + vault.withdraw(0); + } + + function test_withdrawAll() public { + uint256 firstUnlockTime = block.timestamp + 1 days; + uint256 secondUnlockTime = block.timestamp + 2 days; + + vm.startPrank(user); + vault.deposit{value: 1 ether}(firstUnlockTime); + vault.deposit{value: 2 ether}(secondUnlockTime); + vm.stopPrank(); + + vm.warp(secondUnlockTime); + + uint256 userEthBefore = user.balance; + uint256 vaultEthBefore = address(vault).balance; + + vm.prank(user); + uint256 amount = vault.withdrawAll(); + + assertEq(amount, 3 ether); + assertEq(user.balance, userEthBefore + 3 ether); + assertEq(address(vault).balance, vaultEthBefore - 3 ether); + assertEq(vault.getTotalBalance(user), 0); + } + + function test_revert_withdraw_fromAnotherUsersVault() public { + uint256 unlockTime = block.timestamp + 1 days; + + vm.prank(user); + vault.deposit{value: 1 ether}(unlockTime); + + vm.prank(attacker); + vm.expectRevert("Invalid vault ID"); + vault.withdraw(0); + } +} From 68f44db77f0d33449748b2df79fa87c4898c339b Mon Sep 17 00:00:00 2001 From: sprtd Date: Wed, 4 Mar 2026 16:20:57 +0100 Subject: [PATCH 08/10] test: deposit with priceFeed --- sessions/timelock/foundry.toml | 11 ++-- sessions/timelock/script/Counter.s.sol | 19 ------- sessions/timelock/src/TimelockV3.sol | 47 +++++++++++----- sessions/timelock/test/TimelockV3.t.sol | 72 +++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 34 deletions(-) delete mode 100644 sessions/timelock/script/Counter.s.sol create mode 100644 sessions/timelock/test/TimelockV3.t.sol diff --git a/sessions/timelock/foundry.toml b/sessions/timelock/foundry.toml index 46a0ce98..7b6b3f60 100644 --- a/sessions/timelock/foundry.toml +++ b/sessions/timelock/foundry.toml @@ -4,8 +4,13 @@ out = "out" libs = ["lib"] # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options -# [profile.default] remappings = [ "@openzeppelin/=lib/openzeppelin-contracts/", - "@chainlink/contracts/=lib/chainlink-brownie-contracts/contracts/" -] \ No newline at end of file + "@chainlink/contracts/=lib/chainlink-brownie-contracts/contracts/", +] + +eth_rpc_url = "https://ethereum-sepolia-rpc.publicnode.com" +# fork-block-number = 10382697 10382882 +# fork-block-number = 10382882 +# fork-block-number = 10279235 +fork-block-number = 10383134 diff --git a/sessions/timelock/script/Counter.s.sol b/sessions/timelock/script/Counter.s.sol deleted file mode 100644 index f01d69c3..00000000 --- a/sessions/timelock/script/Counter.s.sol +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Script} from "forge-std/Script.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterScript is Script { - Counter public counter; - - function setUp() public {} - - function run() public { - vm.startBroadcast(); - - counter = new Counter(); - - vm.stopBroadcast(); - } -} diff --git a/sessions/timelock/src/TimelockV3.sol b/sessions/timelock/src/TimelockV3.sol index 52065cbf..655c8cb6 100644 --- a/sessions/timelock/src/TimelockV3.sol +++ b/sessions/timelock/src/TimelockV3.sol @@ -1,11 +1,14 @@ //SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import {IErc20} from "./interfaces/IERC20.sol"; +import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol"; +import {console} from "forge-std/console.sol"; contract TimeLockV3 { IErc20 public immutable token; - address public owner; + address public owner = 0xCe4F29b6A3955Fa50b46DFdFAe2F6352F16A77BB; address public pendingOwner; + AggregatorV3Interface internal priceFeed; event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner); event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); @@ -20,6 +23,7 @@ contract TimeLockV3 { constructor(address _token) { token = IErc20(_token); owner = msg.sender; + priceFeed = AggregatorV3Interface(0x694AA1769357215DE4FAC081bf1f309aDC325306); // ETH-USD sepolia } modifier onlyOwner() { @@ -38,22 +42,15 @@ contract TimeLockV3 { _tokenAmount = _totalDeposit * 10; // Token Ratio } - function deposit(uint256 _unlockTime) external payable returns (uint256) { - require(msg.value > 0, "Deposit must be greater than zero"); + function deposit(uint256 _unlockTime) external payable { + uint256 amount = ethToUSD(msg.value); + require(amount >= 100, "you must deposit 100 USD worth of ETH"); require(_unlockTime > block.timestamp, "Unlock time must be in the future"); - - uint256 tokenBal = _depositRatio(msg.value); - require(token.transfer(msg.sender, tokenBal), "Token transfer failed"); - // Create new vault - vaults[msg.sender].push( - Vault({balance: msg.value, unlockTime: _unlockTime, tokenBalance: tokenBal, active: true}) - ); + vaults[msg.sender].push(Vault({balance: msg.value, unlockTime: _unlockTime, tokenBalance: 1, active: true})); uint256 vaultId = vaults[msg.sender].length - 1; emit Deposited(msg.sender, vaultId, msg.value, _unlockTime); - - return vaultId; } function withdraw(uint256 _vaultId) external { @@ -213,4 +210,30 @@ contract TimeLockV3 { pendingOwner = address(0); emit OwnershipTransferred(previousOwner, msg.sender); } + + /** + * Returns the latest answer. + */ + function getETHUSDPrice() public view returns (int256) { + (, + /* uint80 roundId */ + int256 answer,, + /*uint256 startedAt*/ + , + /*uint256 updatedAt*/ + /*uint80 answeredInRound*/ + ) = priceFeed.latestRoundData(); + uint8 decimals = getDecimals(); + int256 result = answer / int256(10 ** decimals); + return result; + } + + function getDecimals() public view returns (uint8) { + uint8 decimals = priceFeed.decimals(); + return decimals; + } + + function ethToUSD(uint256 amount) public view returns (uint256 returnedValue) { + + } } diff --git a/sessions/timelock/test/TimelockV3.t.sol b/sessions/timelock/test/TimelockV3.t.sol new file mode 100644 index 00000000..23ac0dfa --- /dev/null +++ b/sessions/timelock/test/TimelockV3.t.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {TimeLockV3} from "../src/TimelockV3.sol"; +import {console} from "forge-std/console.sol"; +import {IErc20} from "../src/interfaces/IERC20.sol"; + +contract TimelockV3Test is Test { + IErc20 public token; + TimeLockV3 public timelockV3; + address public owner = 0xCe4F29b6A3955Fa50b46DFdFAe2F6352F16A77BB; + address public addr1; + address public addr2; + + function setUp() public { + vm.startPrank(owner); + timelockV3 = new TimeLockV3(0x6434193a115151156d038fB5B61747Cc5b07511F); + addr1 = makeAddr("addr1"); + addr2 = makeAddr("addr2"); + token = IErc20(0x6434193a115151156d038fB5B61747Cc5b07511F); + vm.stopPrank(); + } + + function fromWei(uint256 amount) public pure returns (uint256) { + return (amount / 10 ** 9); + } + + function test_deployment() public view { + assertEq(timelockV3.owner(), owner); + int256 price = timelockV3.getETHUSDPrice(); + console.log("ETH price here", price); + + uint8 decimalResult = timelockV3.getDecimals(); + console.log("decimals here", decimalResult); + + uint256 ownerEthBefore = token.balanceOf(owner); + console.log("token decimals here____", token.decimals()); + assertEq(ownerEthBefore, (2000 * 10 ** 6)); + } + + function test_deposit() public { + vm.startPrank(owner); + uint256 unlockTime = block.timestamp + 1 days; + // uint256 ownerEthBal1 = address(owner).balance; + uint256 ownerEthBal1 = owner.balance; + // console.log("owner balance 1___-", fromWei(ownerEthBal1)); + timelockV3.deposit{value: 0.01 ether}(unlockTime); + + // uint256 vaultEthBefore = address(vault).balance; + // uint256 userTokenBefore = token.balanceOf(user); + // uint256 vaultTokenBefore = token.balanceOf(address(vault)); + + // vm.prank(user); + // vault.deposit{value: 1 ether}(unlockTime); + + // assertEq(user.balance, userEthBefore - 1 ether); + // assertEq(address(vault).balance, vaultEthBefore + 1 ether); + // assertEq(token.balanceOf(user), userTokenBefore + 10 ether); + // assertEq(token.balanceOf(address(vault)), vaultTokenBefore - 10 ether); + // assertEq(vault.getVaultCount(user), 1); + + // (uint256 balance, uint256 tokenBalance, uint256 savedUnlockTime, bool active, bool isUnlocked) = + // vault.getVault(user, 0); + + // assertEq(balance, 1 ether); + // assertEq(tokenBalance, 10 ether); + // assertEq(savedUnlockTime, unlockTime); + // assertTrue(active); + // assertFalse(isUnlocked); + } +} From 7fe04353e1808bdcaef710d3c6791a3e6b976941 Mon Sep 17 00:00:00 2001 From: Olorunshogo Moses BAMTEFA Date: Thu, 2 Apr 2026 15:46:30 +0100 Subject: [PATCH 09/10] chore: submitting upgradeable contract assignments --- assignments/Olorunshogo.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 assignments/Olorunshogo.md diff --git a/assignments/Olorunshogo.md b/assignments/Olorunshogo.md new file mode 100644 index 00000000..f8ad6b5b --- /dev/null +++ b/assignments/Olorunshogo.md @@ -0,0 +1,5 @@ + +1. [Link to the upgradeable contracts using both open zeppelin and the diamond](https://github.com/Olorunshogo/UpgradeableContract) + + +2. [Link to the article](https://medium.com/@shownzy001/upgradeable-contracts-b4d8c5c3d61e) \ No newline at end of file From 879831192ddbfa10fd1156948b820649806e2f1d Mon Sep 17 00:00:00 2001 From: Olorunshogo Moses BAMTEFA Date: Mon, 13 Apr 2026 12:21:18 +0100 Subject: [PATCH 10/10] feat: my submission for assessment 1 --- 13-04-26-test/assessment1/README.md | 66 +++++++++++ 13-04-26-test/assessment1/foundry.toml | 6 + .../assessment1/src/AssessmentContract.sol | 73 ++++++++++++ .../assessment1/test/Assessment.t.sol | 106 ++++++++++++++++++ 4 files changed, 251 insertions(+) create mode 100644 13-04-26-test/assessment1/README.md create mode 100644 13-04-26-test/assessment1/foundry.toml create mode 100644 13-04-26-test/assessment1/src/AssessmentContract.sol create mode 100644 13-04-26-test/assessment1/test/Assessment.t.sol diff --git a/13-04-26-test/assessment1/README.md b/13-04-26-test/assessment1/README.md new file mode 100644 index 00000000..8817d6ab --- /dev/null +++ b/13-04-26-test/assessment1/README.md @@ -0,0 +1,66 @@ +## Foundry + +**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** + +Foundry consists of: + +- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). +- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. +- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. +- **Chisel**: Fast, utilitarian, and verbose solidity REPL. + +## Documentation + +https://book.getfoundry.sh/ + +## Usage + +### Build + +```shell +$ forge build +``` + +### Test + +```shell +$ forge test +``` + +### Format + +```shell +$ forge fmt +``` + +### Gas Snapshots + +```shell +$ forge snapshot +``` + +### Anvil + +```shell +$ anvil +``` + +### Deploy + +```shell +$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key +``` + +### Cast + +```shell +$ cast +``` + +### Help + +```shell +$ forge --help +$ anvil --help +$ cast --help +``` diff --git a/13-04-26-test/assessment1/foundry.toml b/13-04-26-test/assessment1/foundry.toml new file mode 100644 index 00000000..25b918f9 --- /dev/null +++ b/13-04-26-test/assessment1/foundry.toml @@ -0,0 +1,6 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/13-04-26-test/assessment1/src/AssessmentContract.sol b/13-04-26-test/assessment1/src/AssessmentContract.sol new file mode 100644 index 00000000..fd4b0ac2 --- /dev/null +++ b/13-04-26-test/assessment1/src/AssessmentContract.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.33; + +// === Vulnerable Contract +contract VulnerableContract { + mapping(address => uint256) public balances; + + function deposit() public payable { + balances[msg.sender] += msg.value; + } + + function withdraw(uint256 amount) public { + require(balances[msg.sender] >= amount, 'Insufficient balance'); + + (bool success, ) = msg.sender.call{value: amount}(''); + require(success, 'Transfer failed'); + + balances[msg.sender] -= amount; + } +} + +// === Attacker Contract +contract AttackerContract { + VulnerableContract public vulnerableContract; + + constructor(address _vulnerableContractAddress) { + vulnerableContract = VulnerableContract(_vulnerableContractAddress); + } + + // === This is called anytime the contract receives ether + fallback() external payable { + if (address(vulnerableContract).balance >= 1 ether) { + vulnerableContract.withdraw(1 ether); + } + } + + function exploit() external payable { + require(msg.value >= 1 ether); + vulnerableContract.deposit{value: 1 ether}(); + vulnerableContract.withdraw(1 ether); + } + + // receive() external payable; +} + +contract ReentrancyGuard { + bool internal _notInteracted = true; + + modifier nonReentrant() { + require(_notInteracted, "ReentrancyGuard: reentrant call"); + _notInteracted = false; + _; + _notInteracted = true; + } + +} + +contract FixedContract is ReentrancyGuard { + mapping(address => uint256) public balances; + + function deposit() external payable nonReentrant { + balances[msg.sender] += msg.value; + } + + function withdraw(uint256 amount) external nonReentrant { + require(balances[msg.sender] >= amount, 'Insufficient balance'); + + balances[msg.sender] -= amount; + + (bool success, ) = msg.sender.call{value: amount}(''); + require(success, 'Transfer failed'); + } +} diff --git a/13-04-26-test/assessment1/test/Assessment.t.sol b/13-04-26-test/assessment1/test/Assessment.t.sol new file mode 100644 index 00000000..f2c05603 --- /dev/null +++ b/13-04-26-test/assessment1/test/Assessment.t.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import {Test} from 'forge-std/Test.sol'; +import '../src/AssessmentContract.sol'; + +contract AssessmentTest is Test { + VulnerableContract public vulnerableContract; + AttackerContract public attackerContract; + FixedContract public fixedContract; + + address owner = makeAddr('owner'); + address attacker = makeAddr('attacker'); + address user = makeAddr('user'); + + function setUp() public { + vulnerableContract = new VulnerableContract(); + fixedContract = new FixedContract(); + attackerContract = new AttackerContract(address(vulnerableContract)); + + vm.deal(owner, 10 ether); + vm.deal(attacker, 2 ether); + vm.deal(user, 2 ether); + } + + // === Basic Deployment Tests + function test_if_deployment_initialBalance_isZero() public view { + // uint256 contractBalance = vulnerableContract.balances; + assertEq(vulnerableContract.balances(owner), 0); + } + + function test_deposit_will_updateBalance() public { + vm.prank(owner); + vulnerableContract.deposit{value: 1 ether}(); + assertEq(vulnerableContract.balances(owner), 1 ether); + } + + function test_withdraw_will_reduceBalance() public { + vm.prank(owner); + vulnerableContract.deposit{value: 1 ether}(); + + vm.prank(owner); + vulnerableContract.withdraw(1 ether); + + assertEq(vulnerableContract.balances(owner), 0); + } + + function test_withdraw_revertsIfInsufficientBalance() public { + vm.prank(owner); + vm.expectRevert('Insufficient balance'); + vulnerableContract.withdraw(1 ether); + } + + // === Reentrancy attack + function test_reentrancyAttack_to_drain_vulnerable_contract() public { + // I'm depositing into the contract + vm.prank(owner); + vulnerableContract.deposit{value: 5 ether}(); + + // Initial balances + uint256 contractInitialBalance = address(vulnerableContract).balance; + uint256 attackerInitialBalance = address(attackerContract).balance; + + vm.prank(attacker); + attackerContract.exploit{value: 1 ether}(); + + Attacker may now drain more than they put in + assertGt( + address(attackerContract).balance, + attackerBalanceBefore + 1 ether, + "Attacker should have drained extra ETH" + ); + assertLt( + address(vulnerableContract).balance, + contractBalanceBefore, + "Vulnerable contract should have lost ETH" + ); + } + + // === Fixed Contract Tests + function test_if_fixed_deployment_initialBalance_isZero() public view { + assertEq(fixedContract.balances(user), 0); + } + + function test_fixed_deposit_will_updateBalance() public { + vm.prank(user); + fixedContract.deposit{value: 1 ether}(); + assertEq(fixedContract.balances(user), 1 ether); + } + + function test_fixed_withdraw_will_reduceBalance() public { + vm.prank(user); + fixedContract.deposit{value: 1 ether}(); + + vm.prank(user); + fixedContract.withdraw(1 ether); + + assertEq(vulnerableContract.balances(user), 0); + } + + function test_fixed_withdraw_revertsIfInsufficientBalance() public { + vm.prank(user); + vm.expectRevert('Insufficient balance'); + fixedContract.withdraw(1 ether); + } +}