From 3223bf624a413cf8ada30d619f2d331bd1e89dd0 Mon Sep 17 00:00:00 2001 From: eugenioclrc Date: Tue, 16 Aug 2022 18:11:10 -0300 Subject: [PATCH] demo challenge --- .gitignore | 9 ++ .gitmodules | 6 ++ README.md | 43 ++++++++ foundry.toml | 24 +++++ lib/forge-std | 1 + lib/solmate | 1 + remappings.txt | 3 + script/Deploy.s.sol | 19 ++++ src/ETHPool.sol | 251 ++++++++++++++++++++++++++++++++++++++++++++ src/IWETH.sol | 20 ++++ src/mock/WETH.sol | 21 ++++ test/ETHPool.t.sol | 123 ++++++++++++++++++++++ 12 files changed, 521 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 foundry.toml create mode 160000 lib/forge-std create mode 160000 lib/solmate create mode 100644 remappings.txt create mode 100644 script/Deploy.s.sol create mode 100644 src/ETHPool.sol create mode 100644 src/IWETH.sol create mode 100644 src/mock/WETH.sol create mode 100644 test/ETHPool.t.sol diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..0583a1f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/* +/broadcast/*/31337/ +/.env diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..558b49a6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "lib/solmate"] + path = lib/solmate + url = https://github.com/rari-capital/solmate diff --git a/README.md b/README.md index 3adf821c..2cbc9d3e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,46 @@ + +### Comments + +Build in foundry. + +Some domain assumptions; +- each week is a different pool with different rewards +- week number is defined by current timestamp / (7*24*60*60) +- if you deposit 10 ether in week 1, and withdraw 10 ether in week 2, your rewards will be 0% for week 1 and 2 +- if you deposit 10 ether in week 1, and withdraw 5 ether in week 2, and on week 3 you withdraw 5 ether your rewards will be 0% for week 1, (5 ether / total deposits week 2)% of rewards for week 2, and 0 for week 3 +- Use WETH but send ether on withdraw and claims, it would be easier just use WETH +- didnt fully tested, it may be really buggy + +### Deployed and verify on Goerli +[0x729da725cfdb6c0f240aa6c473066c76fcdec57f](https://goerli.etherscan.io/address/0x729da725cfdb6c0f240aa6c473066c76fcdec57f) + +### Test coverage + +100% test coverge, however it may need mor test cases + +```bash +(base) ➜ exactly-solidity-challenge git:(main) ✗ forge coverage +[⠊] Compiling... +[⠒] Compiling 15 files with 0.8.4 +[⠆] Solc 0.8.4 finished in 1.07s +Compiler run successful +Analysing contracts... +Running tests... ++---------------------+-----------------+-----------------+-----------------+-----------------+ +| File | % Lines | % Statements | % Branches | % Funcs | ++=============================================================================================+ +| script/Deploy.s.sol | 0.00% (0/4) | 0.00% (0/4) | 100.00% (0/0) | 0.00% (0/2) | +|---------------------+-----------------+-----------------+-----------------+-----------------| +| src/ETHPool.sol | 100.00% (87/87) | 100.00% (96/96) | 100.00% (36/36) | 100.00% (13/13) | +|---------------------+-----------------+-----------------+-----------------+-----------------| +| src/mock/WETH.sol | 100.00% (3/3) | 100.00% (3/3) | 100.00% (0/0) | 100.00% (2/2) | +|---------------------+-----------------+-----------------+-----------------+-----------------| +| Total | 95.74% (90/94) | 96.12% (99/103) | 100.00% (36/36) | 88.24% (15/17) | ++---------------------+-----------------+-----------------+-----------------+-----------------+ +``` + +--- + # Smart Contract Challenge ## A) Challenge diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 00000000..d8168bc3 --- /dev/null +++ b/foundry.toml @@ -0,0 +1,24 @@ +[profile.default] +solc = '0.8.4' +src = 'src' +out = 'out' +libs = ['lib'] +fuzz_runs = 1000 +optimizer_runs = 10_000 + +[profile.optimized] +via_ir = true +out = 'out-via-ir' +fuzz_runs = 5000 + +[profile.test] +via_ir = true +out = 'out-via-ir' +fuzz_runs = 5000 +src = 'test' + +[rpc_endpoints] +goerli = "https://eth-goerli.alchemyapi.io/v2/${GOERLI_API_KEY}" + +# See more config options https://github.com/foundry-rs/foundry/tree/master/config + diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 00000000..8d93b527 --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 8d93b5273ca94b1c50b055ffc0e1b8b0a3c03d78 diff --git a/lib/solmate b/lib/solmate new file mode 160000 index 00000000..9cf14282 --- /dev/null +++ b/lib/solmate @@ -0,0 +1 @@ +Subproject commit 9cf1428245074e39090dceacb0c28b1f684f584c diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 00000000..f5b99c45 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,3 @@ +ds-test/=lib/solmate/lib/ds-test/src/ +forge-std/=lib/forge-std/src/ +solmate/=lib/solmate/src/ diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol new file mode 100644 index 00000000..a9d3fcf3 --- /dev/null +++ b/script/Deploy.s.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "forge-std/Script.sol"; +import "src/ETHPool.sol"; + +contract DeployScript is Script { + function setUp() public {} + + function run() public { + // goerli weth address + // https://goerli.etherscan.io/address/0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6 + address WETH = 0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6; + + vm.startBroadcast(); + new ETHPool(WETH); + vm.stopBroadcast(); + } +} diff --git a/src/ETHPool.sol b/src/ETHPool.sol new file mode 100644 index 00000000..1740cbb9 --- /dev/null +++ b/src/ETHPool.sol @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.4; + +// for testing porpourses +// import "forge-std/console2.sol"; + +// Challenge, just for fun +// https://github.com/eugenioclrc/solidity-challenge + +// assumption, each week is a different pool with different rewards +// a week number is defined by current timestamp / (7*24*60*60) +// if you deposit 10 ether in week 1, and withdraw 10 ether in +// week 2, your rewards will be 0 for week 1 and 2 +// if you deposit 10 ether in week 1, and withdraw 5 ether in +// week 2, and on week 3 you withdraw 5 ether your rewards will +// be 0 for week 1, 100% of rewards for week 1, and 0 for week 3 + +// test coverage is 100%, try to run al edge cases that i could +// imagine, how ever might not be enough, also + +// i use WETH but send ether on withdraw and claims, it would be +// easier just use WETH + +import {ReentrancyGuard} from "solmate/utils/ReentrancyGuard.sol"; +import {SafeTransferLib} from "solmate/utils/SafeTransferLib.sol"; +import {Owned} from "solmate/auth/Owned.sol"; +import {ERC20} from "solmate/tokens/ERC20.sol"; + +import {IWETH} from "./IWETH.sol"; + +contract ETHPool is Owned(msg.sender), ReentrancyGuard { + uint256 constant WEEK = 60 * 60 * 24 * 7; + + // contract week start number + uint32 immutable GENESIS; + // weth contract + IWETH immutable WETH; + + address team; + + // total deposits + uint256 public totalDeposits; + // total deposits per week + mapping(uint32 => uint256) public totalDepositsWeek; + + // rewards per week + mapping(uint32 => uint256) public weekRewards; + + // user balances + mapping(address => uint256) balances; + // user balances per week + mapping(address => mapping(uint32 => uint256)) public weekBalance; + + mapping(address => uint32) lastUserClaim; + mapping(address => uint32) lastUserUpdate; + uint32 _lastUpdate; + + event Withdraw(address indexed user, uint32 indexed week, uint256 amount); + event Deposit(address indexed user, uint32 indexed week, uint256 amount); + event Claim(address indexed user, uint256 amount); + + event SetTeamWallet(address teamWallet); + event AddReward(uint32 week, uint256 amount); + event RescueReward(uint32 week, uint256 amount); + + constructor(address _WETH) { + WETH = IWETH(_WETH); + GENESIS = currentWeek(); + _lastUpdate = currentWeek(); + } + + // private functions + + function batchCalcsPredeposit(uint32 _currentWeek) private { + if (_currentWeek > _lastUpdate) { + for (uint32 i = _lastUpdate; i < _currentWeek;) { + unchecked { + totalDepositsWeek[i + 1] = totalDepositsWeek[i]; + i++; + } + } + } + + _lastUpdate = _currentWeek; + } + + function min(uint256 a, uint256 b) private pure returns (uint256) { + return a < b ? a : b; + } + + // tricky batch calculations + function batchCalculations(address user, uint32 _currentWeek) private { + // user week calcs + if (lastUserUpdate[user] == 0) { + lastUserUpdate[user] = _currentWeek; + return; + } + + if (_currentWeek > lastUserUpdate[user]) { + for (uint32 i = lastUserUpdate[user]; i < _currentWeek;) { + unchecked { + weekBalance[user][i + 1] = weekBalance[user][i]; + i++; + } + } + } + + lastUserUpdate[user] = _currentWeek; + } + + // owner functions + function setTeam(address _team) external onlyOwner { + team = _team; + emit SetTeamWallet(_team); + } + + // team functions + function addRewards() external payable { + addRewards(currentWeek() + 1); + } + + function addRewards(uint32 week) public payable { + require(msg.sender == team, "only team can add rewards"); + require(msg.value > 0, "send some ETH!"); + require(week > currentWeek(), "week must => current week"); + + weekRewards[week] += msg.value; + WETH.deposit{value: msg.value}(); + emit AddReward(week, msg.value); + } + + function withdrawStuckRewards(uint32 week) public payable { + require(_lastUpdate > week, "week must > last update"); + require(msg.sender == team, "only team can add rewards"); + require(week < currentWeek(), "week must < current week"); + + require(min(totalDepositsWeek[week], totalDepositsWeek[week - 1]) == 0, "rewards arent stuck"); + + uint256 _rewards = weekRewards[week]; + weekRewards[week] = 0; + WETH.withdraw(_rewards); + SafeTransferLib.safeTransferETH(team, _rewards); + + emit RescueReward(week, _rewards); + } + + // public-external functions + + function deposit() external payable { + require(msg.value > 0, "deposit must be greater than 0"); + deposit(0); + } + + function deposit(uint256 amount) public payable nonReentrant { + uint32 _currentWeek = currentWeek(); + batchCalcsPredeposit(_currentWeek); + batchCalculations(msg.sender, _currentWeek); + claim(); + + if (msg.value > 0) { + WETH.deposit{value: msg.value}(); + amount = msg.value; + } else if (amount > 0) { + require(msg.value == 0, "Using WETH, dont send ether"); + SafeTransferLib.safeTransferFrom(ERC20(address(WETH)), msg.sender, address(this), amount); + } + + totalDeposits += amount; + balances[msg.sender] += amount; + + totalDepositsWeek[_currentWeek] = totalDeposits; + weekBalance[msg.sender][_currentWeek] = balances[msg.sender]; + + emit Deposit(msg.sender, _currentWeek, amount); + } + + function withdraw(uint256 amount) external nonReentrant { + uint32 _currentWeek = currentWeek(); + batchCalcsPredeposit(_currentWeek); + batchCalculations(msg.sender, _currentWeek); + claim(); + + balances[msg.sender] -= amount; + totalDeposits -= amount; + + totalDepositsWeek[_currentWeek] = totalDeposits; + weekBalance[msg.sender][_currentWeek] = balances[msg.sender]; + + WETH.withdraw(amount); + SafeTransferLib.safeTransferETH(msg.sender, amount); + + emit Withdraw(msg.sender, _currentWeek, amount); + } + + function claim() public { + uint32 _currentWeek = currentWeek(); + + totalDepositsWeek[_currentWeek] = totalDeposits; + weekBalance[msg.sender][_currentWeek] = balances[msg.sender]; + + uint256 _earn = pendingRewards(msg.sender); + + lastUserClaim[msg.sender] = _currentWeek; + + if (_earn > 0) { + WETH.withdraw(_earn); + SafeTransferLib.safeTransferETH(msg.sender, _earn); + emit Claim(msg.sender, _earn); + } + } + + function pendingRewards(address user) public /*view*/ returns (uint256 _earn) { + uint32 _currentWeek = currentWeek(); + totalDepositsWeek[_currentWeek] = totalDeposits; + weekBalance[msg.sender][_currentWeek] = balances[msg.sender]; + + batchCalcsPredeposit(_currentWeek); + batchCalculations(user, _currentWeek); + + uint32 start = lastUserClaim[user]; + if (start == 0) { + start = GENESIS; + } + + for (uint32 i = start; i < _currentWeek;) { + uint256 _rewards = weekRewards[i]; + if (_rewards == 0) { + unchecked { + i++; + } + continue; + } + uint256 denominator = min(totalDepositsWeek[i], totalDepositsWeek[i - 1]); + if (totalDepositsWeek[i] > 0) { + _earn += (_rewards * min(weekBalance[user][i], weekBalance[user][i - 1])) / denominator; + } + unchecked { + i++; + } + } + } + + function currentWeek() public view returns (uint32) { + return uint32(block.timestamp / WEEK); + } + + // we can receive ether from the WETH contract + // but if someone sends ether to the contract, it will get stuck + // it would be best just use WETH + receive() external payable {} +} diff --git a/src/IWETH.sol b/src/IWETH.sol new file mode 100644 index 00000000..63f0a00a --- /dev/null +++ b/src/IWETH.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +interface IWETH { + function totalSupply() external view returns (uint256); + + function balanceOf(address account) external view returns (uint256); + + function transfer(address recipient, uint256 amount) external returns (bool); + + function allowance(address owner, address spender) external view returns (uint256); + + function approve(address spender, uint256 amount) external returns (bool); + + function transferFrom(address src, address dst, uint256 wad) external returns (bool); + + function deposit() external payable; + + function withdraw(uint256 wad) external; +} diff --git a/src/mock/WETH.sol b/src/mock/WETH.sol new file mode 100644 index 00000000..3644efe9 --- /dev/null +++ b/src/mock/WETH.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {ERC20} from "solmate/tokens/ERC20.sol"; + +contract WETH is ERC20 { + constructor() ERC20("WETH Mock", "WETH", 18) {} + + function deposit() public payable { + _mint(msg.sender, msg.value); + } + + function withdraw(uint256 wad) public { + _burn(msg.sender, wad); + payable(msg.sender).transfer(wad); + } + + receive() external payable { + deposit(); + } +} diff --git a/test/ETHPool.t.sol b/test/ETHPool.t.sol new file mode 100644 index 00000000..99f24328 --- /dev/null +++ b/test/ETHPool.t.sol @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "forge-std/console2.sol"; +// import {ETHPool} from "src/ETHPool.sol"; +import {ETHPool} from "src/ETHPool.sol"; +import {WETH as WETHMock} from "src/mock/WETH.sol"; + +contract ContractTest is Test { + WETHMock public weth; + // ETHPool public ethPool; + ETHPool ethPool; + + address immutable alice; + address immutable bob; + address immutable team; + + uint32 constant DAY = 60 * 60 * 24; + uint32 constant WEEK = 60 * 60 * 24 * 7; + + constructor() { + alice = makeAddr("alice"); + bob = makeAddr("bob"); + team = makeAddr("team"); + } + + function setUp() public { + // week number will always be greater than 0 + vm.warp(WEEK * 2); + + weth = new WETHMock(); + ethPool = new ETHPool(address(weth)); + + vm.label(address(ethPool), "ethPool"); + vm.label(address(weth), "weth"); + vm.deal(alice, 10_000 ether); + vm.deal(bob, 10_000 ether); + vm.deal(team, 10_000 ether); + + ethPool.setTeam(team); + } + + function testJustDepositNWithdraw() public { + vm.startPrank(alice); + assertEq(ethPool.weekBalance(alice, 0), 0); + assertEq(ethPool.totalDepositsWeek(0), 0); + + console.log(block.timestamp / WEEK); + ethPool.deposit{value: 10}(); + assertEq(ethPool.weekBalance(alice, 0), 0); + assertEq(ethPool.totalDepositsWeek(0), 0); + assertEq(ethPool.weekBalance(alice, 2), 10); + assertEq(ethPool.totalDepositsWeek(2), 10); + + skip(2 * WEEK); + + ethPool.deposit{value: 50}(); + // console.log(block.timestamp / WEEK); + assertEq(ethPool.totalDepositsWeek(3), 10, "wrong weekly balance"); + assertEq(ethPool.totalDepositsWeek(4), 60, "wrong weekly balance"); + assertEq(ethPool.weekBalance(alice, 3), 10); + + assertEq(ethPool.weekBalance(alice, 4), 60); + assertEq(ethPool.totalDepositsWeek(4), 60, "wrong weekly balance"); + ethPool.withdraw(10); + assertEq(ethPool.weekBalance(alice, 4), 50); + assertEq(ethPool.totalDepositsWeek(4), 50, "wrong weekly balance"); + } + + function testSipleDeposit() public { + vm.startPrank(bob); + ethPool.deposit{value: 300 ether}(); + weth.approve(address(ethPool), 300 ether); + weth.deposit{value: 100 ether}(); + ethPool.deposit(100 ether); + vm.stopPrank(); + } + + function testWeeks() public { + vm.warp(0); // next week + for (uint256 i; i < 20; i++) { + assertEq(ethPool.currentWeek(), i); + skip(WEEK); + } + } + + function testFull() public { + vm.prank(alice); + ethPool.deposit{value: 100 ether}(); + + vm.startPrank(bob); + weth.deposit{value: 300 ether}(); + weth.approve(address(ethPool), 300 ether); + ethPool.deposit(100 ether); + ethPool.deposit(200 ether); + vm.stopPrank(); + + // lets set the rewards to next week, otherwise, ether will get stuck + vm.startPrank(team); + ethPool.addRewards{value: 200 ether}(ethPool.currentWeek() + 1); + vm.stopPrank(); + + // A deposits 100, and B deposits 300 for a total of 400 in the pool. + assertEq(ethPool.totalDeposits(), 400 ether, "totalDeposit should be 400 ETHER"); + assertEq(ethPool.pendingRewards(alice), 0, "alice rewards should be 0 ETH"); + assertEq(ethPool.pendingRewards(bob), 0, "bob rewards should be 0 ETH"); + + skip(WEEK * 3); // next week + + assertEq(ethPool.pendingRewards(alice), 50 ether, "wrong alice rewards"); + assertEq(ethPool.weekBalance(alice, 2), 100 ether, "alice week deposit should be 100 ETH"); + assertEq(ethPool.pendingRewards(bob), uint256(200 ether * 3 / 4), "wrong bob rewards"); + assertEq(ethPool.weekBalance(bob, 2), 300 ether, "bob week deposit should be 300 ETH"); + + skip(WEEK * 3); // next week + + vm.startPrank(alice); + uint256 balanceBefore = alice.balance; + ethPool.withdraw(30 ether); + assertEq(alice.balance - balanceBefore, 30 ether + 50 ether, "wrong alice withdraw"); + } +}