diff --git a/contracts/AggregatorFeeSharingWithUniswapV3.sol b/contracts/AggregatorFeeSharingWithUniswapV3.sol index d7a2aac..053376c 100644 --- a/contracts/AggregatorFeeSharingWithUniswapV3.sol +++ b/contracts/AggregatorFeeSharingWithUniswapV3.sol @@ -49,8 +49,8 @@ contract AggregatorFeeSharingWithUniswapV3 is Ownable, Pausable, ReentrancyGuard // Last user action block uint256 public lastHarvestBlock; - // Minimum price of LOOKS (in WETH) multiplied 1e18 (e.g., 0.0004 ETH --> 4e14) - uint256 public minPriceLOOKSInWETH; + // Maximum price of LOOKS (in WETH) multiplied 1e18 (e.g., 0.0004 ETH --> 4e14) + uint256 public maxPriceLOOKSInWETH; // Threshold amount (in rewardToken) uint256 public thresholdAmount; @@ -67,7 +67,7 @@ contract AggregatorFeeSharingWithUniswapV3 is Ownable, Pausable, ReentrancyGuard event HarvestStart(); event HarvestStop(); event NewHarvestBufferBlocks(uint256 harvestBufferBlocks); - event NewMinimumPriceOfLOOKSInWETH(uint256 minPriceLOOKSInWETH); + event NewMaximumPriceLOOKSInWETH(uint256 maxPriceLOOKSInWETH); event NewThresholdAmount(uint256 thresholdAmount); event NewTradingFeeUniswapV3(uint24 tradingFeeUniswapV3); event Withdraw(address indexed user, uint256 amount); @@ -210,14 +210,14 @@ contract AggregatorFeeSharingWithUniswapV3 is Ownable, Pausable, ReentrancyGuard } /** - * @notice Update minimum price of LOOKS in WETH - * @param _newMinPriceLOOKSInWETH new minimum price of LOOKS in ETH times 1e18 + * @notice Update maximum price of LOOKS in WETH + * @param _newMaxPriceLOOKSInWETH new maximum price of LOOKS in WETH times 1e18 * @dev Only callable by owner */ - function updateMinPriceOfLOOKSInWETH(uint256 _newMinPriceLOOKSInWETH) external onlyOwner { - minPriceLOOKSInWETH = _newMinPriceLOOKSInWETH; + function updateMaxPriceOfLOOKSInWETH(uint256 _newMaxPriceLOOKSInWETH) external onlyOwner { + maxPriceLOOKSInWETH = _newMaxPriceLOOKSInWETH; - emit NewMinimumPriceOfLOOKSInWETH(_newMinPriceLOOKSInWETH); + emit NewMaximumPriceLOOKSInWETH(_newMaxPriceLOOKSInWETH); } /** @@ -328,7 +328,7 @@ contract AggregatorFeeSharingWithUniswapV3 is Ownable, Pausable, ReentrancyGuard * @return whether the transaction went through */ function _sellRewardTokenToLOOKS(uint256 _amount) internal returns (bool) { - uint256 amountOutMinimum = minPriceLOOKSInWETH != 0 ? (_amount * minPriceLOOKSInWETH) / 1e18 : 0; + uint256 amountOutMinimum = maxPriceLOOKSInWETH != 0 ? (_amount * 1e18) / maxPriceLOOKSInWETH : 0; // Set the order parameters ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams( diff --git a/contracts/test/AggregatorFeeSharingWithUniswapV3.t.sol b/contracts/test/AggregatorFeeSharingWithUniswapV3.t.sol index 72ec582..4c9c1a1 100644 --- a/contracts/test/AggregatorFeeSharingWithUniswapV3.t.sol +++ b/contracts/test/AggregatorFeeSharingWithUniswapV3.t.sol @@ -1,11 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.7; -import {DSTest} from "../../lib/ds-test/src/test.sol"; - import {LooksRareToken} from "../LooksRareToken.sol"; import {TokenDistributor} from "../TokenDistributor.sol"; import {FeeSharingSystem} from "../FeeSharingSystem.sol"; +import {FeeSharingSetter} from "../FeeSharingSetter.sol"; + import {AggregatorFeeSharingWithUniswapV3} from "../AggregatorFeeSharingWithUniswapV3.sol"; import {MockERC20} from "./utils/MockERC20.sol"; import {MockUniswapV3Router} from "./utils/MockUniswapV3Router.sol"; @@ -20,19 +20,20 @@ abstract contract TestParameters { uint256 internal _START_BLOCK; } -contract AggregatorTest is DSTest, TestParameters, TestHelpers { +contract AggregatorTest is TestParameters, TestHelpers { LooksRareToken public looksRareToken; TokenDistributor public tokenDistributor; FeeSharingSystem public feeSharingSystem; + FeeSharingSetter public feeSharingSetter; AggregatorFeeSharingWithUniswapV3 public aggregatorFeeSharingWithUniswapV3; MockUniswapV3Router public uniswapRouter; MockERC20 public rewardToken; function setUp() public { - // 0. Mock WETH + // 0. Mock WETH deployment rewardToken = new MockERC20("WETH", "Wrapped Ether"); - // 1. Mock Uniswap v3 Router + // 1. Mock UniswapV3Router deployment uniswapRouter = new MockUniswapV3Router(); // 2. LooksRareToken deployment @@ -79,18 +80,19 @@ contract AggregatorTest is DSTest, TestParameters, TestHelpers { address(tokenDistributor) ); - // 6. Aggregator deployment + // 6. FeeSharingSetter deployment (w/ distribution period is set at 100 blocks) + feeSharingSetter = new FeeSharingSetter(address(feeSharingSystem), 30, 1000, 100); + feeSharingSetter.grantRole(feeSharingSetter.OPERATOR_ROLE(), feeSharingSystem.owner()); + feeSharingSystem.transferOwnership(address(feeSharingSetter)); + + // 7. Aggregator deployment aggregatorFeeSharingWithUniswapV3 = new AggregatorFeeSharingWithUniswapV3( address(feeSharingSystem), address(uniswapRouter) ); - aggregatorFeeSharingWithUniswapV3.startHarvest(); - aggregatorFeeSharingWithUniswapV3.updateThresholdAmount(_parseEtherWithFloating(5, 1)); - aggregatorFeeSharingWithUniswapV3.updateHarvestBufferBlocks(10); - - // 7. Distribute LOOKS to user accounts (from the premint) - address[4] memory users = [address(1), address(2), address(3), address(4)]; + // 8. Distribute LOOKS (from the premint) to user accounts + address[4] memory users = [user1, user2, user3, user4]; for (uint256 i = 0; i < users.length; i++) { cheats.prank(_PREMINT_RECEIVER); @@ -114,18 +116,27 @@ contract AggregatorTest is DSTest, TestParameters, TestHelpers { uint256 currentBalanceUser1 = looksRareToken.balanceOf(user1); assertEq(aggregatorFeeSharingWithUniswapV3.userInfo(user1), _parseEther(100)); assertEq(aggregatorFeeSharingWithUniswapV3.calculateSharesValueInLOOKS(user1), _parseEther(100)); + assertEq(aggregatorFeeSharingWithUniswapV3.calculateSharePriceInPrimeShare(), 1e18); // Time travel by 1 block cheats.roll(_START_BLOCK + 1); assertEq(aggregatorFeeSharingWithUniswapV3.calculateSharesValueInLOOKS(user1), _parseEther(130)); + assertEq(aggregatorFeeSharingWithUniswapV3.calculateSharePriceInLOOKS(), _parseEtherWithFloating(13, 1)); aggregatorFeeSharingWithUniswapV3.withdrawAll(); // 200 LOOKS + 130 LOOKS = 330 LOOKS assertEq(looksRareToken.balanceOf(user1), _parseEther(130) + currentBalanceUser1); assertEq(aggregatorFeeSharingWithUniswapV3.calculateSharesValueInLOOKS(user1), _parseEther(0)); + + aggregatorFeeSharingWithUniswapV3.deposit(_parseEther(100)); + aggregatorFeeSharingWithUniswapV3.deposit(_parseEther(100)); + + cheats.roll(_START_BLOCK + 50); + assertEq(aggregatorFeeSharingWithUniswapV3.userInfo(user1), _parseEther(200)); + assertEq(aggregatorFeeSharingWithUniswapV3.calculateSharesValueInLOOKS(user1), _parseEther(200 + 49 * 30)); } - function testDepositAndWithdrawSameBlock(uint8 x, uint16 numberBlocks) public asPrankedUser(user1) { + function testSameBlockDepositAndWithdraw(uint8 x, uint16 numberBlocks) public asPrankedUser(user1) { uint256 amountDeposit = _parseEther(x); cheats.assume(amountDeposit >= aggregatorFeeSharingWithUniswapV3.MINIMUM_DEPOSIT_LOOKS()); cheats.roll(_START_BLOCK + uint256(numberBlocks)); @@ -135,4 +146,244 @@ contract AggregatorTest is DSTest, TestParameters, TestHelpers { aggregatorFeeSharingWithUniswapV3.withdrawAll(); assertEq(looksRareToken.balanceOf(user1), currentBalanceUser1 + amountDeposit); } + + function testSameBlockMultipleDeposits(uint8 x, uint16 numberBlocks) public { + uint256 amountDeposit = _parseEther(x); + cheats.assume(amountDeposit >= aggregatorFeeSharingWithUniswapV3.MINIMUM_DEPOSIT_LOOKS()); + cheats.roll(_START_BLOCK + uint256(numberBlocks)); + + cheats.prank(user1); + aggregatorFeeSharingWithUniswapV3.deposit(amountDeposit); + + cheats.prank(user2); + aggregatorFeeSharingWithUniswapV3.deposit(amountDeposit); + assertEq(aggregatorFeeSharingWithUniswapV3.userInfo(user1), aggregatorFeeSharingWithUniswapV3.userInfo(user2)); + } + + function testScenarioWithoutRouter() public { + uint256 amountDeposit = _parseEther(100); + cheats.roll(_START_BLOCK + 5); + + /** 1. Initial deposits at startBlock + 5 + */ + address[4] memory users = [user1, user2, user3, user4]; + for (uint256 i = 0; i < users.length; i++) { + cheats.prank(users[i]); + aggregatorFeeSharingWithUniswapV3.deposit(amountDeposit); + } + assertEq(aggregatorFeeSharingWithUniswapV3.userInfo(user1), aggregatorFeeSharingWithUniswapV3.userInfo(user2)); + + /** 2. Time travel to startBlock + 20 (15 blocks later) + * User1 withdraws funds + */ + cheats.roll(_START_BLOCK + 20); + + uint256 currentBalanceUser = looksRareToken.balanceOf(user1); + cheats.prank(user1); + aggregatorFeeSharingWithUniswapV3.withdrawAll(); + + // 15 blocks at 30 LOOKS/block = 450 LOOKS + // 450 / 4 = 112.5 LOOKS for user + assertEq( + looksRareToken.balanceOf(user1), + currentBalanceUser + _parseEtherWithFloating(1125, 1) + amountDeposit + ); + + /** 3. Time travel to startBlock + 100 (80 blocks later) + * User2 checks the value of her shares + */ + cheats.roll(_START_BLOCK + 100); + + // 80 blocks at 30 LOOKS/blocks = 2400 LOOKS + // 800 LOOKS for user + // Total value of the shares = 800 LOOKS + 112.5 LOOKS + 100 LOOKS = 1012.5 LOOKS + // @dev To deal with minor precision losses due to division, we look at the boundaries + assertQuasiEq( + aggregatorFeeSharingWithUniswapV3.calculateSharesValueInLOOKS(user2), + _parseEtherWithFloating(10125, 1) + ); + + /** 4. Time travel to startBlock + 170 (70 blocks later) + * User2 withdraws all + */ + cheats.roll(_START_BLOCK + 170); + currentBalanceUser = looksRareToken.balanceOf(user2); + cheats.prank(user2); + aggregatorFeeSharingWithUniswapV3.withdrawAll(); + + // Previous value of shares of user2 = 1012.5 LOOKS (see above) + // 70 blocks at 15 LOOKS/block = 1050 LOOKS + // 1050 LOOKS / 3 = 350 LOOKS for user + // Total = 1362.5 LOOKS + assertQuasiEq(looksRareToken.balanceOf(user2), currentBalanceUser + _parseEtherWithFloating(13625, 1)); + + /** 5. Time travel to startBlock + 400 (230 blocks later) + * User3 withdraws all + */ + cheats.roll(_START_BLOCK + 400); + currentBalanceUser = looksRareToken.balanceOf(user3); + cheats.prank(user3); + aggregatorFeeSharingWithUniswapV3.withdrawAll(); + + // Previous value of shares of user2 = 1362.5 LOOKS (see above) + // 30 blocks at 15 LOOKS/block = 450 LOOKS + // 100 blocks at 7.5 LOOKS/block = 750 LOOKS + // 100 blocks at 3.75 LOOKS/block = 375 LOOKS + // 1575 LOOKS / 2 = 787.5 LOOKS + // Total = 2150 LOOKS + assertQuasiEq(looksRareToken.balanceOf(user3), currentBalanceUser + _parseEther(2150)); + + /** 6. Time travel to startBlock + 400 (230 blocks later) + * User4 withdraws all + */ + cheats.roll(_START_BLOCK + 450); + currentBalanceUser = looksRareToken.balanceOf(user4); + cheats.prank(user4); + aggregatorFeeSharingWithUniswapV3.withdrawAll(); + + // Should be same as user3 since LOOKS distribution is stopped + assertQuasiEq(looksRareToken.balanceOf(user4), currentBalanceUser + _parseEther(2150)); + + // Verify the final total supply is equal to the cap - supply not minted (for first 5 blocks) + assertEq(looksRareToken.totalSupply(), _parseEther(_CAP) - _parseEther(500)); + } + + function testScenarioWithRouter() public { + /** 0. Initial set up + */ + cheats.roll(_START_BLOCK); + + // Add 1000 WETH for distribution for next 100 blocks (10 WETH per block) + rewardToken.mint(address(feeSharingSetter), _parseEther(1000)); + feeSharingSetter.updateRewards(); + feeSharingSetter.setNewRewardDurationInBlocks(300); // This will be adjusted for the second period + assertEq(feeSharingSystem.currentRewardPerBlock(), _parseEther(10)); + + aggregatorFeeSharingWithUniswapV3.startHarvest(); + aggregatorFeeSharingWithUniswapV3.updateThresholdAmount(_parseEtherWithFloating(5, 1)); // 1 WETH + aggregatorFeeSharingWithUniswapV3.updateHarvestBufferBlocks(20); + uniswapRouter.setMultiplier(15000); // 1 WETH = 1.5 LOOKS + aggregatorFeeSharingWithUniswapV3.updateMaxPriceOfLOOKSInWETH(_parseEtherWithFloating(667, 3)); // 1 LOOKS = 0.667 WETH + + // Transfer 4000 LOOKS to mock router + cheats.prank(_PREMINT_RECEIVER); + looksRareToken.transfer(address(uniswapRouter), _parseEther(4000)); + + uint256 amountDeposit = _parseEther(100); + cheats.roll(_START_BLOCK + 5); + + /** 1. Initial deposits at startBlock + 5 + */ + address[4] memory users = [user1, user2, user3, user4]; + for (uint256 i = 0; i < users.length; i++) { + cheats.prank(users[i]); + aggregatorFeeSharingWithUniswapV3.deposit(amountDeposit); + } + + /** 2. Time travel to startBlock + 20 (15 blocks later) + * User1 withdraws funds + */ + cheats.roll(_START_BLOCK + 20); + + uint256 currentBalanceUser = looksRareToken.balanceOf(user1); + cheats.prank(user1); + aggregatorFeeSharingWithUniswapV3.withdrawAll(); + + // 15 blocks at 30 LOOKS/block = 450 LOOKS + // + 150 WETH sold at 1 WETH = 1.5 LOOKS --> 225 LOOKS + // 675 / 4 = 168.75 LOOKS for user + // @dev 50 WETH are lost to the fee sharing system contract since no user was staking for the first 5 blocks + assertEq( + looksRareToken.balanceOf(user1), + currentBalanceUser + _parseEtherWithFloating(16875, 2) + amountDeposit + ); + + // 400 prime shares --> (450 + 225 + 4 * 100) = 1075 + // 1 prime share is worth 1075 / 400 = 2.6875 + assertEq(aggregatorFeeSharingWithUniswapV3.calculateSharePriceInLOOKS(), _parseEtherWithFloating(26875, 4)); + + // 400 shares --> (450 + 4 * 100) = 850 + // 1 share is worth 850 / 400 = 2.125 + assertEq(feeSharingSystem.calculateSharePriceInLOOKS(), _parseEtherWithFloating(2125, 3)); + + // 1 prime share is worth ~ 1.264 shares + assertEq( + aggregatorFeeSharingWithUniswapV3.calculateSharePriceInPrimeShare(), + (aggregatorFeeSharingWithUniswapV3.calculateSharePriceInLOOKS() * 1e18) / + feeSharingSystem.calculateSharePriceInLOOKS() + ); + + // User1 decides to re-deposit the exact same amount as the one earned + cheats.prank(user1); + aggregatorFeeSharingWithUniswapV3.deposit(_parseEtherWithFloating(16875, 2) + amountDeposit); + + assertEq( + aggregatorFeeSharingWithUniswapV3.calculateSharesValueInLOOKS(user1), + aggregatorFeeSharingWithUniswapV3.calculateSharesValueInLOOKS(user2) + ); + + /** 3. Time travel to startBlock + 100 (80 blocks later) + * User1 withdraws + */ + cheats.roll(_START_BLOCK + 100); + assertEq(feeSharingSystem.periodEndBlock(), _START_BLOCK + 100); + + currentBalanceUser = looksRareToken.balanceOf(user1); + cheats.prank(user1); + aggregatorFeeSharingWithUniswapV3.withdrawAll(); + + // Previous value of shares of user1 = 168.75 LOOKS (see above) + // 80 blocks at 30 LOOKS/block = 2400 LOOKS + // + 800 WETH sold at 1 WETH = 1.5 LOOKS --> 1200 LOOKS + // 3600 / 4 = 900 LOOKS for user + assertQuasiEq( + looksRareToken.balanceOf(user1), + currentBalanceUser + _parseEtherWithFloating(106875, 2) + amountDeposit + ); + + assertEq(feeSharingSystem.lastRewardBlock(), _START_BLOCK + 100); + + /** 4. Start of new reward period over 300 blocks + */ + + // Add 1500 WETH for distribution for next 300 blocks (5 WETH per block) + cheats.roll(_START_BLOCK + 101); + rewardToken.mint(address(feeSharingSetter), _parseEther(1500)); + feeSharingSetter.updateRewards(); + assertEq(feeSharingSystem.currentRewardPerBlock(), _parseEther(5)); + + /** 5. Time travel to the end of the LOOKS staking/fee-sharing period + * All 3 users withdraw their funds + */ + cheats.roll(_START_BLOCK + 401); + + // @dev currentBalanceUser is same for user2/user3/user4 + currentBalanceUser = looksRareToken.balanceOf(user2); + + // All users (except user1) withdraw + for (uint256 i = 1; i < users.length; i++) { + cheats.prank(users[i]); + aggregatorFeeSharingWithUniswapV3.withdrawAll(); + } + + // Previous value of shares of user1 = 1068.75 LOOKS (see above) + // 100 blocks at 15 LOOKS/block = 1500 LOOKS + // 100 blocks at 7.5 LOOKS/block = 750 LOOKS + // 100 blocks at 3.75 LOOKS/block = 375 LOOKS + // 2625 LOOKS + // 1500 WETH sold for 1 WETH = 1.5 LOOKS --> 2250 LOOKS + // Total = 4875 LOOKS --> 1625 LOOKS per user + assertQuasiEq( + looksRareToken.balanceOf(user2), + _parseEther(1625) + _parseEtherWithFloating(106875, 2) + amountDeposit + currentBalanceUser + ); + assertQuasiEq(looksRareToken.balanceOf(user2), looksRareToken.balanceOf(user3)); + assertQuasiEq(looksRareToken.balanceOf(user3), looksRareToken.balanceOf(user4)); + + // There should be around 50 WETH left in the fee sharing contract (for the first 5 blocks without user staking) + assertQuasiEq(rewardToken.balanceOf(address(feeSharingSystem)), _parseEther(50)); + + // Verify the final total supply is equal to the cap - supply not minted (for first 5 blocks) + assertEq(looksRareToken.totalSupply(), _parseEther(_CAP) - _parseEther(500)); + } } diff --git a/contracts/test/TestHelpers.sol b/contracts/test/TestHelpers.sol index b62c8d4..d8bf103 100644 --- a/contracts/test/TestHelpers.sol +++ b/contracts/test/TestHelpers.sol @@ -2,8 +2,9 @@ pragma solidity ^0.8.0; import {ICheatCodes} from "./ICheatCodes.sol"; +import {DSTest} from "../../lib/ds-test/src/test.sol"; -abstract contract TestHelpers { +abstract contract TestHelpers is DSTest { ICheatCodes public cheats = ICheatCodes(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); address public user1 = address(1); @@ -22,6 +23,23 @@ abstract contract TestHelpers { cheats.stopPrank(); } + function assertQuasiEq(uint256 a, uint256 b) public { + require(a >= 1e18 || b >= 1e18, "Error: a & b must be > 1e18"); + + // 0.000001 % precision tolerance + uint256 PRECISION_LOSS = 1e9; + + if (a == b) { + assertEq(a, b); + } else if (a > b) { + assertGt(a, b); + assertLt(a - PRECISION_LOSS, b); + } else if (a < b) { + assertGt(a, b - PRECISION_LOSS); + assertLt(a, b); + } + } + function _parseEther(uint256 value) internal pure returns (uint256) { return value * 1e18; } diff --git a/contracts/test/utils/MockUniswapV3Router.sol b/contracts/test/utils/MockUniswapV3Router.sol index 73c9f3c..81e5288 100644 --- a/contracts/test/utils/MockUniswapV3Router.sol +++ b/contracts/test/utils/MockUniswapV3Router.sol @@ -14,6 +14,8 @@ contract MockUniswapV3Router is ISwapRouter { uint256 public multiplier; + event SlippageError(); + constructor() { // Useless logic not to use an abstract contract DEPLOYER = msg.sender; @@ -29,9 +31,15 @@ contract MockUniswapV3Router is ISwapRouter { override returns (uint256 amountOut) { - IERC20(params.tokenIn).safeTransferFrom(msg.sender, address(this), params.amountIn); amountOut = (params.amountIn * multiplier) / PRECISION_MULTIPLIER; - IERC20(params.tokenOut).transfer(msg.sender, amountOut); + + if (amountOut < params.amountOutMinimum) { + emit SlippageError(); + amountOut = 0; + } else { + IERC20(params.tokenIn).safeTransferFrom(msg.sender, address(this), params.amountIn); + IERC20(params.tokenOut).transfer(msg.sender, amountOut); + } return amountOut; } diff --git a/foundry.toml b/foundry.toml index 44e516f..ce0ac69 100644 --- a/foundry.toml +++ b/foundry.toml @@ -14,7 +14,7 @@ ffi = false force = false fuzz_max_global_rejects = 65536 fuzz_max_local_rejects = 1024 -fuzz_runs = 256 +fuzz_runs = 1000 gas_limit = 9223372036854775807 gas_price = 0 gas_reports = ['*'] diff --git a/test/aggregatorFeeSharingWithUniswapV3.test.ts b/test/aggregatorFeeSharingWithUniswapV3.test.ts index f6d427b..95a2477 100644 --- a/test/aggregatorFeeSharingWithUniswapV3.test.ts +++ b/test/aggregatorFeeSharingWithUniswapV3.test.ts @@ -2,9 +2,8 @@ import { assert, expect } from "chai"; import { ethers } from "hardhat"; import { BigNumber, constants, Contract, utils } from "ethers"; import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; -import * as blockTraveller from "./helpers/block-traveller"; +import { advanceBlock, advanceBlockTo, pauseAutomine, resumeAutomine } from "./helpers/block-traveller"; -const { advanceBlockTo } = blockTraveller; const { parseEther } = utils; async function setupUsers( @@ -17,9 +16,7 @@ async function setupUsers( for (const user of users) { await looksRareToken.connect(admin).transfer(user.address, parseEther("200")); await looksRareToken.connect(user).approve(feeSharingSystem.address, constants.MaxUint256); - await looksRareToken.connect(user).approve(aggregator.address, constants.MaxUint256); - await aggregator.connect(user).deposit(parseEther("100")); } } @@ -41,11 +38,10 @@ describe("AggregatorFeeSharing", () => { beforeEach(async () => { accounts = await ethers.getSigners(); - admin = accounts[0]; const tokenSplitter = accounts[19]; - const premintReceiver = admin; + const premintReceiver = admin; const premintAmount = parseEther("6250"); const cap = parseEther("25000"); // 25,000 tokens @@ -95,7 +91,6 @@ describe("AggregatorFeeSharing", () => { rewardToken.address, tokenDistributor.address ); - await feeSharingSystem.deployed(); const minRewardDurationInBlocks = "30"; @@ -115,6 +110,7 @@ describe("AggregatorFeeSharing", () => { const MockUniswapV3Router = await ethers.getContractFactory("MockUniswapV3Router"); uniswapRouter = await MockUniswapV3Router.deploy(); + // 1 WETH is sold for 2 LOOKS await uniswapRouter.connect(admin).setMultiplier("20000"); // Transfer 2,250 LOOKS to mock router @@ -122,6 +118,7 @@ describe("AggregatorFeeSharing", () => { const AggregatorFeeSharingWithUniswapV3 = await ethers.getContractFactory("AggregatorFeeSharingWithUniswapV3"); aggregator = await AggregatorFeeSharingWithUniswapV3.deploy(feeSharingSystem.address, uniswapRouter.address); + await aggregator.deployed(); }); describe("#1 - Regular user/admin interactions", async () => { @@ -222,10 +219,10 @@ describe("AggregatorFeeSharing", () => { // Advanced to the end of the first fee-sharing await advanceBlockTo(BigNumber.from(await tokenDistributor.START_BLOCK()).add("49")); - // Withdraw all + // User1 withdraws all let tx = await aggregator.connect(user1).withdrawAll(); - // 50 WETH sold for 100 LOOKS + // 50 WETH is sold for 100 LOOKS await expect(tx) .to.emit(aggregator, "ConversionToLOOKS") .withArgs(parseEther("49.99999999999999980"), parseEther("99.9999999999999996")); @@ -291,18 +288,45 @@ describe("AggregatorFeeSharing", () => { it("Harvest doesn't trigger reinvesting if amount received is less than 1 LOOKS", async () => { const [user1, user2, user3] = [accounts[1], accounts[2], accounts[3]]; await setupUsers(feeSharingSystem, looksRareToken, aggregator, admin, [user1, user2, user3]); - // 1 WETH --> 1 LOOKS + // 1 WETH is sold for 1 LOOKS await uniswapRouter.setMultiplier("10000"); await aggregator.connect(admin).updateThresholdAmount(parseEther("0.999")); await rewardToken.connect(admin).transfer(aggregator.address, parseEther("0.999")); const tx = await aggregator.connect(admin).harvestAndSellAndCompound(); - // Amount is equal to the threshold to trigger the selling await expect(tx).to.emit(aggregator, "ConversionToLOOKS").withArgs(parseEther("0.999"), parseEther("0.999")); // Amount is lower than threshold to trigger the deposit await expect(tx).not.to.emit(feeSharingSystem, "Deposit"); }); + + it("Slippage protections work as expected", async () => { + const [user1, user2, user3] = [accounts[1], accounts[2], accounts[3]]; + await setupUsers(feeSharingSystem, looksRareToken, aggregator, admin, [user1, user2, user3]); + + // 1 WETH must be sold at least for 100 LOOKS + await aggregator.connect(admin).updateMaxPriceOfLOOKSInWETH(parseEther("0.01")); + + // 1 WETH is sold for 100 LOOKS + await uniswapRouter.setMultiplier("1000000"); + + // Transfer 1 LOOKS + await rewardToken.connect(admin).transfer(aggregator.address, parseEther("1")); + let tx = await aggregator.connect(admin).harvestAndSellAndCompound(); + + // Amount is same as threshold... it doesn't trigger the error + await expect(tx).not.to.emit(uniswapRouter, "SlippageError"); + + // 1 WETH is now sold for 99.999 LOOKS + await uniswapRouter.setMultiplier("999999"); + + // Transfer 1 LOOKS + await rewardToken.connect(admin).transfer(aggregator.address, parseEther("1")); + tx = await aggregator.connect(admin).harvestAndSellAndCompound(); + + // Amount is lower than threshold to trigger the deposit + await expect(tx).to.emit(uniswapRouter, "SlippageError"); + }); }); describe("#2 - Revertions work as expected", async () => { @@ -341,6 +365,20 @@ describe("AggregatorFeeSharing", () => { await expect(aggregator.connect(admin).harvestAndSellAndCompound()).to.be.revertedWith("Harvest: No share"); }); + it("Cannot harvest if already harvested in same block", async () => { + const [user1, user2, user3] = [accounts[1], accounts[2], accounts[3]]; + await setupUsers(feeSharingSystem, looksRareToken, aggregator, admin, [user1, user2, user3]); + await pauseAutomine(); + await aggregator.connect(admin).harvestAndSellAndCompound(); + const tx = await aggregator.connect(admin).harvestAndSellAndCompound(); + const pendingBlock = await ethers.provider.send("eth_getBlockByNumber", ["pending", false]); + // Verify there are 2+ txs in the mempool + assert.isAtLeast(pendingBlock.transactions.length, 2); + await advanceBlock(); + await resumeAutomine(); + await expect(tx.wait()).to.be.reverted; + }); + it("Faulty router doesn't throw revertion on harvesting operations if it fails to sell", async () => { const MockFaultyUniswapV3Router = await ethers.getContractFactory("MockFaultyUniswapV3Router"); const faultyUniswapRouter = await MockFaultyUniswapV3Router.deploy(); @@ -379,19 +417,35 @@ describe("AggregatorFeeSharing", () => { await expect(tx).to.emit(aggregator, "HarvestStop"); }); - it("Owner can update threshold", async () => { + it("Owner can update threshold amount", async () => { const tx = await aggregator.connect(admin).updateThresholdAmount(parseEther("5")); await expect(tx).to.emit(aggregator, "NewThresholdAmount").withArgs(parseEther("5")); }); + it("Owner can adjust buffer block only within limits", async () => { + const MAXIMUM_HARVEST_BUFFER_BLOCKS = await aggregator.MAXIMUM_HARVEST_BUFFER_BLOCKS(); + const tx = await aggregator.connect(admin).updateHarvestBufferBlocks(MAXIMUM_HARVEST_BUFFER_BLOCKS); + await expect(tx).to.emit(aggregator, "NewHarvestBufferBlocks").withArgs(MAXIMUM_HARVEST_BUFFER_BLOCKS); + + await expect( + aggregator.connect(admin).updateHarvestBufferBlocks(MAXIMUM_HARVEST_BUFFER_BLOCKS.add("1")) + ).to.be.revertedWith("Owner: Must be below MAXIMUM_HARVEST_BUFFER_BLOCKS"); + }); + it("Owner can reset maximum allowance for LOOKS token", async () => { const tx = await aggregator.connect(admin).checkAndAdjustLOOKSTokenAllowanceIfRequired(); - await expect(tx) .to.emit(looksRareToken, "Approval") .withArgs(aggregator.address, feeSharingSystem.address, constants.MaxUint256); }); + it("Owner can reset maximum allowance for reward token", async () => { + const tx = await aggregator.connect(admin).checkAndAdjustRewardTokenAllowanceIfRequired(); + await expect(tx) + .to.emit(rewardToken, "Approval") + .withArgs(aggregator.address, uniswapRouter.address, constants.MaxUint256); + }); + it("Owner can pause/unpause", async () => { let tx = await aggregator.connect(admin).pause(); await expect(tx).to.emit(aggregator, "Paused"); @@ -413,6 +467,14 @@ describe("AggregatorFeeSharing", () => { ); }); + it("Owner can update minPriceLOOKSInWETH", async () => { + // 1 LOOKS is at most equal to 0.01 WETH + // 1 WETH is at least equal to 100 LOOKS + const tx = await aggregator.connect(admin).updateMaxPriceOfLOOKSInWETH(parseEther("0.01")); + await expect(tx).to.emit(aggregator, "NewMaximumPriceLOOKSInWETH").withArgs(parseEther("0.01")); + assert.deepEqual(await aggregator.maxPriceLOOKSInWETH(), parseEther("0.01")); + }); + it("Only owner can call functions for onlyOwner", async () => { const [user1, user2, user3] = [accounts[1], accounts[2], accounts[3]]; await setupUsers(feeSharingSystem, looksRareToken, aggregator, admin, [user1, user2, user3]); @@ -426,6 +488,9 @@ describe("AggregatorFeeSharing", () => { await expect(aggregator.connect(user1).updateThresholdAmount("10")).to.be.revertedWith( "Ownable: caller is not the owner" ); + await expect(aggregator.connect(user1).updateMaxPriceOfLOOKSInWETH(parseEther("0.01"))).to.be.revertedWith( + "Ownable: caller is not the owner" + ); await expect(aggregator.connect(user1).pause()).to.be.revertedWith("Ownable: caller is not the owner"); await expect(aggregator.connect(user1).unpause()).to.be.revertedWith("Ownable: caller is not the owner"); await expect(aggregator.connect(user1).startHarvest()).to.be.revertedWith("Ownable: caller is not the owner"); @@ -433,7 +498,9 @@ describe("AggregatorFeeSharing", () => { await expect(aggregator.connect(user1).checkAndAdjustLOOKSTokenAllowanceIfRequired()).to.be.revertedWith( "Ownable: caller is not the owner" ); - + await expect(aggregator.connect(user1).checkAndAdjustRewardTokenAllowanceIfRequired()).to.be.revertedWith( + "Ownable: caller is not the owner" + ); await expect(aggregator.connect(user1).stopHarvest()).to.be.revertedWith("Ownable: caller is not the owner"); }); }); diff --git a/test/feeSharingSystem.test.ts b/test/feeSharingSystem.test.ts index 7e09180..f60d465 100644 --- a/test/feeSharingSystem.test.ts +++ b/test/feeSharingSystem.test.ts @@ -3,10 +3,9 @@ import { ethers } from "hardhat"; import { BigNumber, constants, Contract, utils } from "ethers"; import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; import { defaultAbiCoder } from "ethers/lib/utils"; -import * as blockTraveller from "./helpers/block-traveller"; +import { advanceBlockTo } from "./helpers/block-traveller"; const { parseEther } = utils; -const { advanceBlockTo } = blockTraveller; describe("FeeSharingSystem", () => { let feeSharingSetter: Contract; diff --git a/test/helpers/block-traveller.ts b/test/helpers/block-traveller.ts index 7438497..1eff4f1 100644 --- a/test/helpers/block-traveller.ts +++ b/test/helpers/block-traveller.ts @@ -57,3 +57,17 @@ export async function increaseTo(targetTime: BigNumber): Promise { export async function latest(): Promise { return (await ethers.provider.getBlock(await ethers.provider.getBlockNumber())).timestamp; } + +/** + * Start automine + */ +export async function pauseAutomine(): Promise { + await network.provider.send("evm_setAutomine", [false]); +} + +/** + * Resume automine + */ +export async function resumeAutomine(): Promise { + await network.provider.send("evm_setAutomine", [true]); +} diff --git a/test/looksRareToken.test.ts b/test/looksRareToken.test.ts index 53a3612..a1b2865 100644 --- a/test/looksRareToken.test.ts +++ b/test/looksRareToken.test.ts @@ -1,4 +1,4 @@ -import { expect } from "chai"; +import { assert, expect } from "chai"; import { BigNumber, constants, Contract, utils } from "ethers"; import { ethers } from "hardhat"; import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; @@ -19,18 +19,37 @@ describe("LooksRareToken", () => { cap = parseEther("1000000000"); const LooksRareToken = await ethers.getContractFactory("LooksRareToken"); - looksRareToken = await LooksRareToken.deploy(admin.address, premintAmount, cap); await looksRareToken.deployed(); }); describe("#1 - Regular user/owner interactions", async () => { + it("Post-deployment values are correct", async () => { + assert.deepEqual(await looksRareToken.SUPPLY_CAP(), cap); + assert.deepEqual(await looksRareToken.totalSupply(), premintAmount); + }); + it("Owner can mint", async () => { const valueToMint = parseEther("100000"); + await expect(looksRareToken.connect(admin).mint(admin.address, valueToMint)) + .to.emit(looksRareToken, "Transfer") + .withArgs(constants.AddressZero, admin.address, valueToMint); + }); + it("Owner cannot mint more than cap", async () => { + let valueToMint = cap.sub(premintAmount); await expect(looksRareToken.connect(admin).mint(admin.address, valueToMint)) .to.emit(looksRareToken, "Transfer") .withArgs(constants.AddressZero, admin.address, valueToMint); + + assert.deepEqual(await looksRareToken.totalSupply(), cap); + + valueToMint = BigNumber.from("1"); + await expect(looksRareToken.connect(admin).mint(admin.address, valueToMint)).not.to.emit( + looksRareToken, + "Transfer" + ); + assert.deepEqual(await looksRareToken.totalSupply(), cap); }); }); @@ -43,9 +62,7 @@ describe("LooksRareToken", () => { it("Cannot deploy if cap is greater than premint amount", async () => { const wrongCap = BigNumber.from("0"); - const LooksRareToken = await ethers.getContractFactory("LooksRareToken"); - await expect(LooksRareToken.deploy(admin.address, premintAmount, wrongCap)).to.be.revertedWith( "LOOKS: Premint amount is greater than cap" ); diff --git a/test/privateSaleWithFeeSharing.test.ts b/test/privateSaleWithFeeSharing.test.ts index 3e9c266..8524769 100644 --- a/test/privateSaleWithFeeSharing.test.ts +++ b/test/privateSaleWithFeeSharing.test.ts @@ -307,7 +307,7 @@ describe("PrivateSaleWithFeeSharing", () => { .to.emit(privateSale, "Withdraw") .withArgs(user.address, BigNumber.from("1"), amountLOOKSForTier1); - if (user != firstTierUser1 && user != firstTierUser2) { + if (user !== firstTierUser1 && user !== firstTierUser2) { await expect(tx).to.emit(privateSale, "Harvest").withArgs(user.address, expectedHarvestAmountForTier1Step2); } else { await expect(tx).to.not.emit(privateSale, "Harvest"); @@ -320,7 +320,7 @@ describe("PrivateSaleWithFeeSharing", () => { .to.emit(privateSale, "Withdraw") .withArgs(user.address, BigNumber.from("2"), amountLOOKSForTier2); - if (user != secondTierUser1 && user != secondTierUser2) { + if (user !== secondTierUser1 && user !== secondTierUser2) { await expect(tx).to.emit(privateSale, "Harvest").withArgs(user.address, expectedHarvestAmountForTier2Step2); } else { await expect(tx).to.not.emit(privateSale, "Harvest"); @@ -333,7 +333,7 @@ describe("PrivateSaleWithFeeSharing", () => { .to.emit(privateSale, "Withdraw") .withArgs(user.address, BigNumber.from("3"), amountLOOKSForTier3); - if (user != thirdTierUser1 && user != thirdTierUser2) { + if (user !== thirdTierUser1 && user !== thirdTierUser2) { await expect(tx).to.emit(privateSale, "Harvest").withArgs(user.address, expectedHarvestAmountForTier3Step2); } else { await expect(tx).to.not.emit(privateSale, "Harvest"); diff --git a/test/tokenDistributor.test.ts b/test/tokenDistributor.test.ts index f13a7fa..8de2dd5 100644 --- a/test/tokenDistributor.test.ts +++ b/test/tokenDistributor.test.ts @@ -2,10 +2,9 @@ import { assert, expect } from "chai"; import { ethers } from "hardhat"; import { BigNumber, constants, Contract, utils } from "ethers"; import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; -import * as blockTraveller from "./helpers/block-traveller"; +import { advanceBlockTo } from "./helpers/block-traveller"; const { parseEther } = utils; -const { advanceBlockTo } = blockTraveller; describe("TokenDistributor", () => { let looksRareToken: Contract; diff --git a/test/tokenSplitter.test.ts b/test/tokenSplitter.test.ts index bf0fbd5..3af9f9a 100644 --- a/test/tokenSplitter.test.ts +++ b/test/tokenSplitter.test.ts @@ -1,6 +1,6 @@ import { assert, expect } from "chai"; import { ethers } from "hardhat"; -import { BigNumber, constants, Contract, utils } from "ethers"; +import { constants, Contract, utils } from "ethers"; import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; const { parseEther } = utils;