From d5f2941602d7c336d68a1a38b433ec8ac451b1cc Mon Sep 17 00:00:00 2001 From: sandybradley Date: Mon, 29 Jul 2024 22:40:27 +0200 Subject: [PATCH 1/3] feat: 14 day cooldown withdraw, deposit min --- src/FoldCaptiveStaking.sol | 24 +++++++++------ test/BaseCaptiveTest.sol | 3 +- test/UnitTests.t.sol | 60 ++++++++++++++++++++++++++++---------- 3 files changed, 62 insertions(+), 25 deletions(-) diff --git a/src/FoldCaptiveStaking.sol b/src/FoldCaptiveStaking.sol index 6af2cfb..855e702 100644 --- a/src/FoldCaptiveStaking.sol +++ b/src/FoldCaptiveStaking.sol @@ -40,8 +40,9 @@ contract FoldCaptiveStaking is Owned(msg.sender) { error NotInitialized(); error ZeroLiquidity(); error WithdrawFailed(); - error WithdrawProRata(); error DepositCapReached(); + error DepositAmountBelowMinimum(); + error WithdrawalCooldownPeriodNotMet(); /// @param _positionManager The Canonical UniswapV3 PositionManager /// @param _pool The FOLD Pool to Reward @@ -138,6 +139,13 @@ contract FoldCaptiveStaking is Owned(msg.sender) { /// @dev The cap on deposits in the pool in liquidity, set to 0 if no cap uint256 public depositCap; + /// @dev Min deposit amount for Fold / Eth + uint256 public constant MINIMUM_DEPOSIT = 1 ether; + /// @dev Min lockup period + uint256 public constant COOLDOWN_PERIOD = 14 days; + + mapping(address => uint256) public depositTimeStamp; + /*////////////////////////////////////////////////////////////// CHEF //////////////////////////////////////////////////////////////*/ @@ -175,6 +183,8 @@ contract FoldCaptiveStaking is Owned(msg.sender) { /// @param amount1 The amount of token1 to deposit /// @param slippage Slippage on deposit out of 1e18 function deposit(uint256 amount0, uint256 amount1, uint256 slippage) external isInitialized { + if (amount0 < MINIMUM_DEPOSIT && amount1 < MINIMUM_DEPOSIT) revert DepositAmountBelowMinimum(); + collectFees(); collectRewards(); @@ -207,6 +217,8 @@ contract FoldCaptiveStaking is Owned(msg.sender) { revert DepositCapReached(); } + depositTimeStamp[msg.sender] = block.timestamp; + emit Deposit(msg.sender, amount0, amount1); } @@ -276,13 +288,11 @@ contract FoldCaptiveStaking is Owned(msg.sender) { /// @notice Withdraws liquidity from the pool /// @param liquidity The amount of liquidity to withdraw function withdraw(uint128 liquidity) external isInitialized { + if (block.timestamp < depositTimeStamp[msg.sender] + COOLDOWN_PERIOD) revert WithdrawalCooldownPeriodNotMet(); + collectFees(); collectRewards(); - if (liquidity > balances[msg.sender].amount / 2) { - revert WithdrawProRata(); - } - balances[msg.sender].amount -= liquidity; liquidityUnderManagement -= uint256(liquidity); @@ -349,10 +359,6 @@ contract FoldCaptiveStaking is Owned(msg.sender) { collectPositionFees(); collectRewards(); - if (liquidity > liquidityUnderManagement / 2) { - revert WithdrawProRata(); - } - liquidityUnderManagement -= uint256(liquidity); INonfungiblePositionManager.DecreaseLiquidityParams memory decreaseParams = INonfungiblePositionManager diff --git a/test/BaseCaptiveTest.sol b/test/BaseCaptiveTest.sol index 8a6a70b..27ea6ee 100644 --- a/test/BaseCaptiveTest.sol +++ b/test/BaseCaptiveTest.sol @@ -11,7 +11,8 @@ contract BaseCaptiveTest is Test { error NotInitialized(); error ZeroLiquidity(); error WithdrawFailed(); - error WithdrawProRata(); + error DepositAmountBelowMinimum(); + error WithdrawalCooldownPeriodNotMet(); FoldCaptiveStaking public foldCaptiveStaking; diff --git a/test/UnitTests.t.sol b/test/UnitTests.t.sol index 0d0b8a8..5a5787e 100644 --- a/test/UnitTests.t.sol +++ b/test/UnitTests.t.sol @@ -29,6 +29,9 @@ contract UnitTests is BaseCaptiveTest { function testRemoveLiquidity() public { testAddLiquidity(); + // Simulate passage of cooldown period + vm.warp(block.timestamp + 14 days); + (uint128 amount, uint128 rewardDebt, uint128 token0FeeDebt, uint128 token1FeeDebt) = foldCaptiveStaking.balances(User01); @@ -212,6 +215,9 @@ contract UnitTests is BaseCaptiveTest { assertEq(rewardDebt, foldCaptiveStaking.rewardsPerLiquidity()); assertGt(weth.balanceOf(User01), initialBalance); + // Simulate passage of cooldown period + vm.warp(block.timestamp + 14 days); + (uint128 liq,,,) = foldCaptiveStaking.balances(User01); foldCaptiveStaking.withdraw(liq / 3); } @@ -235,21 +241,6 @@ contract UnitTests is BaseCaptiveTest { vm.stopPrank(); } - function testProRataWithdrawals() public { - testAddLiquidity(); - - (uint128 liq,,,) = foldCaptiveStaking.balances(User01); - - // Attempt to withdraw more than allowed amount - vm.expectRevert(WithdrawProRata.selector); - foldCaptiveStaking.withdraw(liq); - - // Pro-rated withdrawal - foldCaptiveStaking.withdraw(liq / 2); - (uint128 amount,,,) = foldCaptiveStaking.balances(User01); - assertEq(amount, liq / 2); - } - function testZeroDeposit() public { vm.expectRevert(); foldCaptiveStaking.deposit(0, 0, 0); @@ -268,6 +259,45 @@ contract UnitTests is BaseCaptiveTest { vm.expectRevert(); attack.attack(); } + + function testMinimumDeposit() public { + fold.transfer(User01, 0.5 ether); + + vm.deal(User01, 0.5 ether); + vm.startPrank(User01); + + weth.deposit{value: 0.5 ether}(); + weth.approve(address(foldCaptiveStaking), type(uint256).max); + fold.approve(address(foldCaptiveStaking), type(uint256).max); + + // Expect revert due to minimum deposit requirement + vm.expectRevert(DepositAmountBelowMinimum.selector); + foldCaptiveStaking.deposit(0.5 ether, 0.5 ether, 0); + + vm.stopPrank(); + } + + function testWithdrawalCooldown() public { + testAddLiquidity(); + + vm.startPrank(User01); + + (uint128 liq,,,) = foldCaptiveStaking.balances(User01); + + // Attempt to withdraw before cooldown period + vm.expectRevert(WithdrawalCooldownPeriodNotMet.selector); + foldCaptiveStaking.withdraw(liq / 2); + + // Simulate passage of cooldown period + vm.warp(block.timestamp + 14 days); + + // Withdraw after cooldown period + foldCaptiveStaking.withdraw(liq / 2); + (uint128 amount,,,) = foldCaptiveStaking.balances(User01); + assertEq(amount, liq / 2); + + vm.stopPrank(); + } } // Reentrancy attack contract From 7120d1f76e713b692bb887000cc3b0495aa17be1 Mon Sep 17 00:00:00 2001 From: sam bacha Date: Fri, 9 Aug 2024 05:13:23 -0700 Subject: [PATCH 2/3] fix(unit): testing fixup --- .editorconfig | 87 ++++++++++++++++++++++ .gas-snapshot | 7 ++ foundry.toml | 168 ++++++++++++++++++++++++++++++++++++++++--- test/UnitTests.t.sol | 93 +----------------------- 4 files changed, 254 insertions(+), 101 deletions(-) create mode 100644 .editorconfig create mode 100644 .gas-snapshot diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..61567d3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,87 @@ +# top-most EditorConfig file +root = true + +# All files +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +# Solidity +# https://github.com/sambacha/prettier-config-solidity +[*.sol] +indent_size = 4 +indent_style = space + +# q +# kdb+ +[*.q] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +# Markdown +[*.{md,adoc,asciidoc}] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = false + +# Match nix files, set indent to spaces with width of two +[*.nix] +indent_style = space +indent_size = 2 + +# JavaScript, JSON, JSX, JavaScript Modules, TypeScript +# https://github.com/feross/standard +# https://prettier.io +[*.{cjs,js,json,jsx,mjs,ts,tsx,mts,cts}] +indent_size = 2 +indent_style = space + +# TOML +# https://github.com/toml-lang/toml/tree/master/examples +[*.toml] +indent_size = 2 +indent_style = space + +# YAML +# http://yaml.org/spec/1.2/2009-07-21/spec.html#id2576668 +[*.{yaml,yml}] +indent_size = 2 +indent_style = space + +# Shell +# https://google.github.io/styleguide/shell.xml#Indentation +[*.{bash,sh,zsh}] +indent_size = 2 +indent_style = space + +# confg + cfg +[*.{conf,cfg}] +charset = UTF-8 +end_of_line = LF +indent_size = 4 +indent_style = tab +insert_final_newline = true +tab_width = 4 +trim_trailing_whitespace = true + +# Match diffs, avoid to trim trailing whitespace +[*.{diff,patch}] +trim_trailing_whitespace = false + +# Ignore fixtures and vendored files +[{dist,artifacts,vendor,test/fixtures,tests_config,__snapshot__,}/**] +charset = unset +end_of_line = unset +indent_size = unset +indent_style = unset +insert_final_newline = unset +trim_trailing_spaces = unset \ No newline at end of file diff --git a/.gas-snapshot b/.gas-snapshot new file mode 100644 index 0000000..eebfdc9 --- /dev/null +++ b/.gas-snapshot @@ -0,0 +1,7 @@ +UnitTests:testAddLiquidity() (gas: 347004) +UnitTests:testCanAddRewards() (gas: 522056) +UnitTests:testCanCompoundFees() (gas: 899427) +UnitTests:testCannotCallbeforeInit() (gas: 2265365) +UnitTests:testFeesAccrue() (gas: 884410) +UnitTests:testNewUsersDontStealFees() (gas: 1145644) +UnitTests:testRemoveLiquidity() (gas: 575956) \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index d0b5421..b58bad4 100644 --- a/foundry.toml +++ b/foundry.toml @@ -3,22 +3,172 @@ solc_version = '0.8.25' src = 'src' out = 'out' libs = ['lib'] + +remappings = [ + "ds-test/=lib/forge-std/lib/ds-test/src/", + "forge-std/=lib/forge-std/src/", + "libsol/=lib/libsol/src/", + "solady/=lib/solady/", + "solmate/=lib/solmate/src/", +] + +# {@see {@link https://solidity-fr.readthedocs.io/fr/latest/using-the-compiler.html#input-description} +# includes the contract's metadata in the contract's json artifact +# includes the source mappings for compiled bytecode artifact +extra_output = ['irOptimized', 'evm.assembly', 'evm.bytecode', 'evm.bytecode.generatedSources'] +# emits the output selection as separate json artifact files +extra_output_files = ['metadata'] +names = false +sizes = false optimizer = true -optimizer_runs = 10_000_000 -# @see {@link https://github.com/foundry-rs/foundry/issues/4060} +optimizer_runs = 1_000 +via_ir = true +# {@see {@link https://github.com/foundry-rs/foundry/issues/4060} } +auto_detect_remappings = false +# Whether to store the referenced sources in the metadata as literal data. Helps with verification +use_literal_content = true +# The metadata hash is machine dependent. By default, this is set to none to allow for deterministic code. +# {@see {@link https://docs.soliditylang.org/en/latest/metadata.html} } bytecode_hash = "none" cbor_metadata = false -sparse_mode = false -build_info = true +# Only the required contracts/files will be selected to be included in solc's output selection. +sparse_mode = true +ast = false +isolate = false +create2_library_salt = "0x0000000000000000000000000000000000000000000000000000000000000000" +prague = false +unchecked_cheatcode_artifacts = false -via_ir = true -fuzz_runs = 500 -deny_warnings = false +[profile.ci] +optimizer = true +via_ir = false +fuzz_runs = 4_069 +force = true +verbosity = 4 +gas_reports = ["*"] +revert_strings = 'debug' +extra_output = [ + "abi", + "evm.bytecode", + "evm.deployedBytecode", + "evm.methodIdentifiers", +] +# Environment: FOUNDRY_PROMPT_TIMEOUT +# The number of seconds to wait before vm.prompt reverts with a timeout. +# default = 120 +prompt_timeout = 30 +seed = '0x6900000000000000000000000000000000000000000000000000000000000000' +cache = true +cache_path = '.cache' + +show_progress = false +unchecked_cheatcode_artifacts = false + + +[[profile.default.fs_permissions]] +access = "read" +path = "out" + +[profile.default.rpc_storage_caching] +chains = "all" +endpoints = "all" + +[fmt] +# default 120 +line_length = 100 +# default 4 +tab_width = 2 +bracket_spacing = false +int_types = "long" +multiline_func_header = "attributes_first" +quote_style = "double" +number_underscore = "thousands" +single_line_statement_blocks = "preserve" +override_spacing = false +wrap_comments = true +ignore = ['*.mutant.sol', '*.vendor.sol'] +contract_new_lines = false +# import statements are sorted alphabetically within their import groups. +# while preserving the relative ordering of the groups. +sort_imports = false + +[doc] +out = "docs" +title = "" +book = "book.toml" +ignore = [] + +[profile.docs] +title = 'Protocol docs' +# root_path variable in build-docs.sh +src = 'src' + +[fuzz] +fuzz_seed = '0x3e8' +# The number of fuzz runs for fuzz tests +runs = 10_000 +# The maximum number of test case rejections allowed by proptest, to be +# encountered during usage of `vm.assume` cheatcode. This will be used +# to set the `max_global_rejects` value in proptest test runner config. +# `max_local_rejects` option isn't exposed here since we're not using +# `prop_filter`. +max_test_rejects = 120000 +# The weight of the dictionary +dictionary_weight = 40 +# The flag indicating whether to include values from storage +include_storage = true +# The flag indicating whether to include push bytes values +include_push_bytes = true +max_fuzz_dictionary_addresses = 15728640 +max_fuzz_dictionary_values = 6553600 +max_calldata_fuzz_dictionary_addresses = 0 +gas_report_samples = 256 +failure_persist_dir = "cache/fuzz" +failure_persist_file = "failures" + +[invariant] +# The number of runs that must execute for each invariant test group +runs = 256 +# The number of calls executed to attempt to break invariants in one run +depth = 500 +# Fails the invariant fuzzing if a revert occurs +fail_on_revert = false +# Allows overriding an unsafe external call when running invariant tests. eg. reentrancy checks +call_override = false +# The weight of the dictionary +dictionary_weight = 80 +# The flag indicating whether to include values from storage +include_storage = true +# The flag indicating whether to include push bytes values +include_push_bytes = true +max_fuzz_dictionary_addresses = 15728640 +max_fuzz_dictionary_values = 6553600 +max_calldata_fuzz_dictionary_addresses = 0 +shrink_sequence = true +# run limit max: 262144 +shrink_run_limit = 5000 +preserve_state = false +max_assume_rejects = 65536 +gas_report_samples = 256 +failure_persist_dir = "cache/invariant" [profile.default.optimizer_details] -constantOptimizer = true +# constantOptimizer = true yul = true - +# this sets the `yulDetails` of the `optimizer_details` for the `default` profile [profile.default.optimizer_details.yulDetails] stackAllocation = true +# ACHTUNG! Setting this is extremely dangerous +# {@see {@link https://soliditylang.org/blog/2023/07/19/full-inliner-non-expression-split-argument-evaluation-order-bug/} } +# optimizerSteps = 'u:' + +# [default.model_checker] +# contracts = { '/path/to/project/src/Contract.sol' = [ 'Contract' ] } +# engine = 'chc' +# timeout = 10000 +# targets = [ 'assert' ] +[bind_json] +out = "utils/JsonBindings.sol" +include = [] +exclude = [] \ No newline at end of file diff --git a/test/UnitTests.t.sol b/test/UnitTests.t.sol index 28a417c..936718d 100644 --- a/test/UnitTests.t.sol +++ b/test/UnitTests.t.sol @@ -6,7 +6,6 @@ import "test/interfaces/ISwapRouter.sol"; contract UnitTests is BaseCaptiveTest { ISwapRouter public router = ISwapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564); - /// @dev Ensure that balances and state variables are updated correctly. function testAddLiquidity() public { fold.transfer(User01, 1_000 ether); @@ -27,7 +26,6 @@ contract UnitTests is BaseCaptiveTest { assertEq(token1FeeDebt, 0); } - /// @dev Ensure that balances and state variables are updated correctly. function testRemoveLiquidity() public { testAddLiquidity(); @@ -54,7 +52,6 @@ contract UnitTests is BaseCaptiveTest { assertEq(amount, liq / 4); } - /// @dev Ensure fees are accrued correctly and distributed proportionately. function testFeesAccrue() public { testAddLiquidity(); @@ -103,7 +100,6 @@ contract UnitTests is BaseCaptiveTest { assertGt(foldCaptiveStaking.token1FeesPerLiquidity(), 0); } - /// @dev Ensure fees are compounded correctly and state variables are updated. function testCanCompoundFees() public { testAddLiquidity(); @@ -151,7 +147,6 @@ contract UnitTests is BaseCaptiveTest { assertGt(newAmount, amount); } - /// @dev Ensure new users can't steal fees accrued by others. function testNewUsersDontStealFees() public { testFeesAccrue(); @@ -199,7 +194,6 @@ contract UnitTests is BaseCaptiveTest { stakingTwo.depositRewards(); } - /// @dev Ensure rewards are added and collected correctly. function testCanAddRewards() public { testAddLiquidity(); @@ -228,7 +222,6 @@ contract UnitTests is BaseCaptiveTest { foldCaptiveStaking.withdraw(liq / 3); } - /// @dev Ensure the owner can claim insurance correctly. function testClaimInsurance() public { testAddLiquidity(); @@ -248,23 +241,6 @@ contract UnitTests is BaseCaptiveTest { vm.stopPrank(); } - /// @dev Ensure pro-rata withdrawals are handled correctly - function testProRataWithdrawals() public { - testAddLiquidity(); - - (uint128 liq,,,) = foldCaptiveStaking.balances(User01); - - // Attempt to withdraw more than allowed amount - vm.expectRevert(WithdrawProRata.selector); - foldCaptiveStaking.withdraw(liq); - - // Pro-rated withdrawal - foldCaptiveStaking.withdraw(liq / 2); - (uint128 amount,,,) = foldCaptiveStaking.balances(User01); - assertEq(amount, liq / 2); - } - - /// @dev Ensure zero deposits are handled correctly and revert as expected. function testZeroDeposit() public { vm.expectRevert(); foldCaptiveStaking.deposit(0, 0, 0); @@ -272,7 +248,6 @@ contract UnitTests is BaseCaptiveTest { assertEq(amount, 0); } - /// @dev Ensure the contract is protected against reentrancy attacks. function testReentrancy() public { testAddLiquidity(); @@ -298,26 +273,6 @@ contract UnitTests is BaseCaptiveTest { // Expect revert due to minimum deposit requirement vm.expectRevert(DepositAmountBelowMinimum.selector); foldCaptiveStaking.deposit(0.5 ether, 0.5 ether, 0); - /// @dev Deposit Cap Enforcement: Test to ensure the deposit cap is respected. - function testDepositCap() public { - uint256 cap = 100 ether; - foldCaptiveStaking.setDepositCap(cap); - - fold.transfer(User01, 2000 ether); - - vm.deal(User01, 2000 ether); - vm.startPrank(User01); - - weth.deposit{value: 2000 ether}(); - weth.approve(address(foldCaptiveStaking), type(uint256).max); - fold.approve(address(foldCaptiveStaking), type(uint256).max); - - // First deposit should succeed - foldCaptiveStaking.deposit(1_000 ether, 1_000 ether, 0); - - // Second deposit should revert due to cap - vm.expectRevert(DepositCapReached.selector); - foldCaptiveStaking.deposit(1_000 ether, 1_000 ether, 0); vm.stopPrank(); } @@ -339,52 +294,6 @@ contract UnitTests is BaseCaptiveTest { // Withdraw after cooldown period foldCaptiveStaking.withdraw(liq / 2); (uint128 amount,,,) = foldCaptiveStaking.balances(User01); - /// @dev Multiple Users: Test simultaneous deposits and withdrawals by multiple users. - function testMultipleUsersDepositWithdraw() public { - // User 1 deposits - fold.transfer(User01, 1_000 ether); - vm.deal(User01, 1_000 ether); - vm.startPrank(User01); - - weth.deposit{value: 1_000 ether}(); - weth.approve(address(foldCaptiveStaking), type(uint256).max); - fold.approve(address(foldCaptiveStaking), type(uint256).max); - - foldCaptiveStaking.deposit(1_000 ether, 1_000 ether, 0); - - vm.stopPrank(); - - // User 2 deposits - fold.transfer(User02, 500 ether); - vm.deal(User02, 500 ether); - vm.startPrank(User02); - - weth.deposit{value: 500 ether}(); - weth.approve(address(foldCaptiveStaking), type(uint256).max); - fold.approve(address(foldCaptiveStaking), type(uint256).max); - - foldCaptiveStaking.deposit(500 ether, 500 ether, 0); - - vm.stopPrank(); - - // User 1 withdraws - vm.startPrank(User01); - - (uint128 liq,,,) = foldCaptiveStaking.balances(User01); - foldCaptiveStaking.withdraw(liq / 2); - - (uint128 amount,,,) = foldCaptiveStaking.balances(User01); - assertEq(amount, liq / 2); - - vm.stopPrank(); - - // User 2 withdraws - vm.startPrank(User02); - - (liq,,,) = foldCaptiveStaking.balances(User02); - foldCaptiveStaking.withdraw(liq / 2); - - (amount,,,) = foldCaptiveStaking.balances(User02); assertEq(amount, liq / 2); vm.stopPrank(); @@ -407,4 +316,4 @@ contract ReentrancyAttack { receive() external payable { staking.withdraw(1); } -} +} \ No newline at end of file From 39d54af1fb75f152b41c0d25d387acb12782ec9c Mon Sep 17 00:00:00 2001 From: sam bacha Date: Fri, 9 Aug 2024 05:38:58 -0700 Subject: [PATCH 3/3] test(solidity): upgrade pragma --- .gitmodules | 3 + foundry.toml | 4 +- lib/solady | 1 + src/FOLD_StakingV2.sol | 401 ++++++++++++++++++++++++++++++ src/FoldCaptiveStaking.sol | 2 +- src/interfaces/IERC20.sol | 76 ++++++ test/BaseCaptiveTest.sol | 2 +- test/FoldCaptiveStakingTest.t.sol | 78 ++++++ test/UnitTests.t.sol | 3 +- test/mock/MockERC20.sol | 2 +- 10 files changed, 566 insertions(+), 6 deletions(-) create mode 160000 lib/solady create mode 100644 src/FOLD_StakingV2.sol create mode 100644 src/interfaces/IERC20.sol create mode 100644 test/FoldCaptiveStakingTest.t.sol diff --git a/.gitmodules b/.gitmodules index 5aadf12..bff4240 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,6 @@ [submodule "lib/v3-periphery"] path = lib/v3-periphery url = https://github.com/uniswap/v3-periphery +[submodule "lib/solady"] + path = lib/solady + url = https://github.com/Vectorized/solady diff --git a/foundry.toml b/foundry.toml index b58bad4..408ccf0 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,5 +1,5 @@ [profile.default] -solc_version = '0.8.25' +solc_version = '0.8.26' src = 'src' out = 'out' libs = ['lib'] @@ -160,7 +160,7 @@ yul = true stackAllocation = true # ACHTUNG! Setting this is extremely dangerous # {@see {@link https://soliditylang.org/blog/2023/07/19/full-inliner-non-expression-split-argument-evaluation-order-bug/} } -# optimizerSteps = 'u:' +optimizerSteps = 'jmul[jul] VcTOcul jmul' # [default.model_checker] # contracts = { '/path/to/project/src/Contract.sol' = [ 'Contract' ] } diff --git a/lib/solady b/lib/solady new file mode 160000 index 0000000..11c27fa --- /dev/null +++ b/lib/solady @@ -0,0 +1 @@ +Subproject commit 11c27fab5233fae1456ee95d5a6ad06be8bd32c8 diff --git a/src/FOLD_StakingV2.sol b/src/FOLD_StakingV2.sol new file mode 100644 index 0000000..1c5fa67 --- /dev/null +++ b/src/FOLD_StakingV2.sol @@ -0,0 +1,401 @@ +/// SPDX-License-Identifier: SSPL-1.0 +pragma solidity 0.8.26; + +// Authorization and Authentication + +import {Owned} from "lib/solmate/src/auth/Owned.sol"; + +/// contracts +import {WETH} from "lib/solady/src/tokens/WETH.sol"; +import {ERC20} from "lib/solady/src/tokens/ERC20.sol"; + + +/// Interfaces +import {INonfungiblePositionManager} from "./interfaces/INonfungiblePositionManager.sol"; +import {IUniswapV3MintCallback} from "./interfaces/IUniswapV3MintCallback.sol"; +import {IUniswapV3Factory} from "./interfaces/IUniswapV3Factory.sol"; +import {IUniswapV3Pool} from "./interfaces/IUniswapV3Pool.sol"; + +/// Libraries +import {TickMath} from "./libraries/TickMath.sol"; + + + + + +/// @author CopyPaste +/// @title FoldCaptiveStakingV2 +/// @notice Staking contract for managing FOLD token liquidity on Uniswap V3 +contract FoldCaptiveStakingV2 is Owned(msg.sender) { + /*////////////////////////////////////////////////////////////// + INITIALIZATION + //////////////////////////////////////////////////////////////*/ + bool public initialized; + + // Events + event Initialized(); + event Deposit(address indexed user, uint256 amount0, uint256 amount1); + event Withdraw(address indexed user, uint128 liquidity); + event RewardsDeposited(uint256 amount); + event FeesCollected(address indexed user, uint256 fee0Owed, uint256 fee1Owed); + event RewardsCollected(address indexed user, uint256 rewardsOwed); + event Compounded(address indexed user, uint128 liquidity, uint256 fee0Owed, uint256 fee1Owed); + event InsuranceClaimed(address indexed owner, uint256 amount0, uint256 amount1); + + /// Custom Errors + error ZeroAddress(); + error AlreadyInitialized(); + error NotInitialized(); + error ZeroLiquidity(); + error WithdrawFailed(); + error DepositCapReached(); + error DepositAmountBelowMinimum(); + error WithdrawalCooldownPeriodNotMet(); + + /// @param _positionManager The Canonical UniswapV3 PositionManager + /// @param _pool The FOLD Pool to Reward + /// @param _weth The address of WETH on the deployed chain + /// @param _fold The address of Fold on the deployed chain + constructor(address _positionManager, address _pool, address _weth, address _fold) { + if (_positionManager == address(0) || _pool == address(0) || _weth == address(0) || _fold == address(0)) { + revert ZeroAddress(); + } + + positionManager = INonfungiblePositionManager(_positionManager); + POOL = IUniswapV3Pool(_pool); + + token0 = ERC20(POOL.token0()); + token1 = ERC20(POOL.token1()); + + WETH9 = WETH(payable(_weth)); + FOLD = ERC20(_fold); + + initialized = false; + } + + /// @notice Initialize the contract by minting a small initial liquidity position + function initialize() public onlyOwner { + if (initialized) { + revert AlreadyInitialized(); + } + + // We must mint the pool a small dust LP position, which also prevents share attacks + // So this is our "minimum shares" + INonfungiblePositionManager.MintParams memory params = INonfungiblePositionManager.MintParams({ + token0: address(token0), + token1: address(token1), + fee: 10000, + tickLower: -887_200, + tickUpper: 887_200, + amount0Desired: 1_000_000, + amount1Desired: 1_000_000, + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 1 minutes + }); + + token0.approve(address(positionManager), type(uint256).max); + token1.approve(address(positionManager), type(uint256).max); + + uint128 liquidity; + (TOKEN_ID, liquidity,,) = positionManager.mint(params); + if (liquidity == 0) { + revert ZeroLiquidity(); + } + + liquidityUnderManagement += uint256(liquidity); + + initialized = true; + emit Initialized(); + } + + modifier isInitialized() { + if (!initialized) { + revert NotInitialized(); + } + _; + } + + /*////////////////////////////////////////////////////////////// + STORAGE + //////////////////////////////////////////////////////////////*/ + + /// @dev The max Tick of the position + int24 public constant TICK_UPPER = TickMath.MAX_TICK; + /// @dev The lower Tick of the position + int24 public constant TICK_LOWER = TickMath.MIN_TICK; + /// @dev The Canonical UniswapV3 Position Manager + INonfungiblePositionManager public immutable positionManager; + /// @dev The FOLD <> {WETH, USDC} Liquidity Pool + IUniswapV3Pool public immutable POOL; + /// @dev token0 In terms of the Uniswap Pool + ERC20 public immutable token0; + /// @dev token1 in terms of the Uniswap Pool + ERC20 public immutable token1; + /// @dev The tokenId of the UniswapV3 position + uint256 public TOKEN_ID; + /// @dev Used for all rewards related tracking + uint256 public liquidityUnderManagement; + /// @dev Used to keep track of rewards given per share + uint256 public rewardsPerLiquidity; + /// @dev For keeping track of position fees + uint256 public token0FeesPerLiquidity; + /// @dev For keeping track of positions fees + uint256 public token1FeesPerLiquidity; + + /// @dev The cap on deposits in the pool in liquidity, set to 0 if no cap + uint256 public depositCap; + + /// @dev Min deposit amount for Fold / Eth + uint256 public constant MINIMUM_DEPOSIT = 1 ether; + /// @dev Min lockup period + uint256 public constant COOLDOWN_PERIOD = 14 days; + + mapping(address => uint256) public depositTimeStamp; + + /*////////////////////////////////////////////////////////////// + CHEF + //////////////////////////////////////////////////////////////*/ + + struct UserInfo { + uint128 amount; // How much Liquidity provided by the User, as defined by UniswapV3. + uint128 rewardDebt; // Reward debt. As in the Masterchef Sense + uint128 token0FeeDebt; + uint128 token1FeeDebt; + } + + mapping(address user => UserInfo info) public balances; + + /// @dev The Canonical WETH address + WETH public immutable WETH9; + ERC20 public immutable FOLD; + + /// @notice Allows anyone to add funds to the contract, split among all depositors + function depositRewards() public payable isInitialized { + WETH9.deposit{value: msg.value}(); + rewardsPerLiquidity += msg.value; + emit RewardsDeposited(msg.value); + } + + receive() external payable { + depositRewards(); + } + + /*////////////////////////////////////////////////////////////// + MANAGEMENT + //////////////////////////////////////////////////////////////*/ + + /// @notice Allows a user to deposit liquidity into the pool + /// @param amount0 The amount of token0 to deposit + /// @param amount1 The amount of token1 to deposit + /// @param slippage Slippage on deposit out of 1e18 + function deposit(uint256 amount0, uint256 amount1, uint256 slippage) external isInitialized { + if (amount0 < MINIMUM_DEPOSIT && amount1 < MINIMUM_DEPOSIT) revert DepositAmountBelowMinimum(); + + collectFees(); + collectRewards(); + + INonfungiblePositionManager.IncreaseLiquidityParams memory params = INonfungiblePositionManager + .IncreaseLiquidityParams({ + tokenId: TOKEN_ID, + amount0Desired: amount0, + amount1Desired: amount1, + amount0Min: amount0 * slippage / 1 ether, + amount1Min: amount1 * slippage / 1 ether, + deadline: block.timestamp + 1 minutes + }); + + token0.transferFrom(msg.sender, address(this), amount0); + token1.transferFrom(msg.sender, address(this), amount1); + + (uint128 liquidity, uint256 actualAmount0, uint256 actualAmount1) = positionManager.increaseLiquidity(params); + + if (actualAmount0 < amount0) { + token0.transfer(msg.sender, amount0 - actualAmount0); + } + if (actualAmount1 < amount1) { + token1.transfer(msg.sender, amount1 - actualAmount1); + } + + balances[msg.sender].amount += liquidity; + liquidityUnderManagement += uint256(liquidity); + + if (liquidityUnderManagement > depositCap && depositCap != 0) { + revert DepositCapReached(); + } + + depositTimeStamp[msg.sender] = block.timestamp; + + emit Deposit(msg.sender, amount0, amount1); + } + + /// @notice Compounds User Earned Fees back into their position + function compound() public isInitialized { + collectPositionFees(); + + uint256 fee0Owed = (token0FeesPerLiquidity - balances[msg.sender].token0FeeDebt) * balances[msg.sender].amount + / liquidityUnderManagement; + uint256 fee1Owed = (token1FeesPerLiquidity - balances[msg.sender].token1FeeDebt) * balances[msg.sender].amount + / liquidityUnderManagement; + + INonfungiblePositionManager.IncreaseLiquidityParams memory params = INonfungiblePositionManager + .IncreaseLiquidityParams({ + tokenId: TOKEN_ID, + amount0Desired: fee0Owed, + amount1Desired: fee1Owed, + amount0Min: 0, + amount1Min: 0, + deadline: block.timestamp + 1 minutes + }); + + (uint128 liquidity, uint256 actualAmount0, uint256 actualAmount1) = positionManager.increaseLiquidity(params); + + token0.transfer(msg.sender, fee0Owed - actualAmount0); + token1.transfer(msg.sender, fee1Owed - actualAmount1); + + balances[msg.sender].token0FeeDebt = uint128(token0FeesPerLiquidity); + balances[msg.sender].token1FeeDebt = uint128(token1FeesPerLiquidity); + + balances[msg.sender].amount += liquidity; + liquidityUnderManagement += uint256(liquidity); + + emit Compounded(msg.sender, liquidity, fee0Owed, fee1Owed); + } + + /// @notice User-specific function to collect fees on the singular position + function collectFees() public isInitialized { + collectPositionFees(); + + uint256 fee0Owed = (token0FeesPerLiquidity - balances[msg.sender].token0FeeDebt) * balances[msg.sender].amount + / liquidityUnderManagement; + uint256 fee1Owed = (token1FeesPerLiquidity - balances[msg.sender].token1FeeDebt) * balances[msg.sender].amount + / liquidityUnderManagement; + + token0.transfer(msg.sender, fee0Owed); + token1.transfer(msg.sender, fee1Owed); + + balances[msg.sender].token0FeeDebt = uint128(token0FeesPerLiquidity); + balances[msg.sender].token1FeeDebt = uint128(token1FeesPerLiquidity); + + emit FeesCollected(msg.sender, fee0Owed, fee1Owed); + } + + /// @notice User-specific Rewards for Protocol Rewards + function collectRewards() public isInitialized { + uint256 rewardsOwed = (rewardsPerLiquidity - balances[msg.sender].rewardDebt) * balances[msg.sender].amount + / liquidityUnderManagement; + + WETH9.transfer(msg.sender, rewardsOwed); + + balances[msg.sender].rewardDebt = uint128(rewardsPerLiquidity); + + emit RewardsCollected(msg.sender, rewardsOwed); + } + + /// @notice Withdraws liquidity from the pool + /// @param liquidity The amount of liquidity to withdraw + function withdraw(uint128 liquidity) external isInitialized { + if (block.timestamp < depositTimeStamp[msg.sender] + COOLDOWN_PERIOD) revert WithdrawalCooldownPeriodNotMet(); + + collectFees(); + collectRewards(); + + balances[msg.sender].amount -= liquidity; + liquidityUnderManagement -= uint256(liquidity); + + INonfungiblePositionManager.DecreaseLiquidityParams memory decreaseParams = INonfungiblePositionManager + .DecreaseLiquidityParams({ + tokenId: TOKEN_ID, + liquidity: liquidity, + amount0Min: 0, + amount1Min: 0, + deadline: block.timestamp + 1 minutes + }); + + (uint256 amount0, uint256 amount1) = positionManager.decreaseLiquidity(decreaseParams); + + INonfungiblePositionManager.CollectParams memory collectParams = INonfungiblePositionManager.CollectParams({ + tokenId: TOKEN_ID, + recipient: address(this), + amount0Max: uint128(amount0), + amount1Max: uint128(amount1) + }); + + (uint256 amount0Collected, uint256 amount1Collected) = positionManager.collect(collectParams); + + if (amount0Collected != amount0 || amount1Collected != amount1) { + revert WithdrawFailed(); + } + + token0.transfer(msg.sender, amount0); + token1.transfer(msg.sender, amount1); + + emit Withdraw(msg.sender, liquidity); + } + + /*////////////////////////////////////////////////////////////// + INTERNAL HELPERS + //////////////////////////////////////////////////////////////*/ + + /// @notice Collects fees on the underling UniswapV3 Position + function collectPositionFees() internal { + INonfungiblePositionManager.CollectParams memory params = INonfungiblePositionManager.CollectParams({ + tokenId: TOKEN_ID, + recipient: address(this), + amount0Max: type(uint128).max, + amount1Max: type(uint128).max + }); + + (uint256 amount0Collected, uint256 amount1Collected) = positionManager.collect(params); + + token0FeesPerLiquidity += amount0Collected; + token1FeesPerLiquidity += amount1Collected; + } + + /*////////////////////////////////////////////////////////////// + OWNER CONTROLS + //////////////////////////////////////////////////////////////*/ + /// @param _newCap The new deposit cap, measured in liquidity + function setDepositCap(uint256 _newCap) public onlyOwner { + depositCap = _newCap; + } + + /// @notice Allows the owner to claim insurance in case of relay outage + /// @param liquidity The amount of liquidity to claim + function claimInsurance(uint128 liquidity) external onlyOwner { + collectPositionFees(); + collectRewards(); + + liquidityUnderManagement -= uint256(liquidity); + + INonfungiblePositionManager.DecreaseLiquidityParams memory decreaseParams = INonfungiblePositionManager + .DecreaseLiquidityParams({ + tokenId: TOKEN_ID, + liquidity: liquidity, + amount0Min: 0, + amount1Min: 0, + deadline: block.timestamp + 1 minutes + }); + + (uint256 amount0, uint256 amount1) = positionManager.decreaseLiquidity(decreaseParams); + + INonfungiblePositionManager.CollectParams memory collectParams = INonfungiblePositionManager.CollectParams({ + tokenId: TOKEN_ID, + recipient: address(this), + amount0Max: uint128(amount0), + amount1Max: uint128(amount1) + }); + + (uint256 amount0Collected, uint256 amount1Collected) = positionManager.collect(collectParams); + + if (amount0Collected != amount0 || amount1Collected != amount1) { + revert WithdrawFailed(); + } + + token0.transfer(owner, amount0); + token1.transfer(owner, amount1); + + emit InsuranceClaimed(owner, amount0, amount1); + } +} diff --git a/src/FoldCaptiveStaking.sol b/src/FoldCaptiveStaking.sol index 855e702..c7f5902 100644 --- a/src/FoldCaptiveStaking.sol +++ b/src/FoldCaptiveStaking.sol @@ -1,5 +1,5 @@ /// SPDX-License-Identifier: SSPL-1.0 -pragma solidity 0.8.25; +pragma solidity ^0.8.26; /// Interfaces import {INonfungiblePositionManager} from "./interfaces/INonfungiblePositionManager.sol"; diff --git a/src/interfaces/IERC20.sol b/src/interfaces/IERC20.sol new file mode 100644 index 0000000..568e503 --- /dev/null +++ b/src/interfaces/IERC20.sol @@ -0,0 +1,76 @@ +pragma solidity ^0.8.20; + +/** + * @dev Interface of the ERC-20 standard as defined in the ERC. + */ +interface IERC20 { + /** + * @dev Emitted when `value` tokens are moved from one account (`from`) to + * another (`to`). + * + * Note that `value` may be zero. + */ + event Transfer(address indexed from, address indexed to, uint256 value); + + /** + * @dev Emitted when the allowance of a `spender` for an `owner` is set by + * a call to {approve}. `value` is the new allowance. + */ + event Approval(address indexed owner, address indexed spender, uint256 value); + + /** + * @dev Returns the value of tokens in existence. + */ + function totalSupply() external view returns (uint256); + + /** + * @dev Returns the value of tokens owned by `account`. + */ + function balanceOf(address account) external view returns (uint256); + + /** + * @dev Moves a `value` amount of tokens from the caller's account to `to`. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * Emits a {Transfer} event. + */ + function transfer(address to, uint256 value) external returns (bool); + + /** + * @dev Returns the remaining number of tokens that `spender` will be + * allowed to spend on behalf of `owner` through {transferFrom}. This is + * zero by default. + * + * This value changes when {approve} or {transferFrom} are called. + */ + function allowance(address owner, address spender) external view returns (uint256); + + /** + * @dev Sets a `value` amount of tokens as the allowance of `spender` over the + * caller's tokens. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * IMPORTANT: Beware that changing an allowance with this method brings the risk + * that someone may use both the old and the new allowance by unfortunate + * transaction ordering. One possible solution to mitigate this race + * condition is to first reduce the spender's allowance to 0 and set the + * desired value afterwards: + * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 + * + * Emits an {Approval} event. + */ + function approve(address spender, uint256 value) external returns (bool); + + /** + * @dev Moves a `value` amount of tokens from `from` to `to` using the + * allowance mechanism. `value` is then deducted from the caller's + * allowance. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * Emits a {Transfer} event. + */ + function transferFrom(address from, address to, uint256 value) external returns (bool); +} \ No newline at end of file diff --git a/test/BaseCaptiveTest.sol b/test/BaseCaptiveTest.sol index 2892a66..e2aa818 100644 --- a/test/BaseCaptiveTest.sol +++ b/test/BaseCaptiveTest.sol @@ -1,5 +1,5 @@ /// SPDX-License-Identifier: SSPL-1.-0 -pragma solidity 0.8.25; +pragma solidity ^0.8.26; import "src/FoldCaptiveStaking.sol"; import {Test} from "forge-std/Test.sol"; diff --git a/test/FoldCaptiveStakingTest.t.sol b/test/FoldCaptiveStakingTest.t.sol new file mode 100644 index 0000000..b93eb64 --- /dev/null +++ b/test/FoldCaptiveStakingTest.t.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../src/FoldCaptiveStaking.sol"; +import {ERC20} from "lib/solmate/src/tokens/ERC20.sol"; +import {IERC20} from "../src/interfaces/IERC20.sol"; +import {INonfungiblePositionManager} from "../src/interfaces/INonfungiblePositionManager.sol"; + +contract FoldCaptiveStakingTest is Test { + FoldCaptiveStaking public staking; + IERC20 public token0; + IERC20 public token1; + INonfungiblePositionManager public positionManager; + + address public owner = address(1); + + function setUp() public { + vm.startPrank(owner); + token0 = IERC20(address(new ERC20Mock())); + token1 = IERC20(address(new ERC20Mock())); + positionManager = INonfungiblePositionManager(address(new PositionManagerMock())); + staking = new FoldCaptiveStaking(address(token0), address(token1), address(positionManager), owner); + vm.stopPrank(); + } + + function testInitialize() public { + vm.startPrank(owner); + staking.initialize(); + assertTrue(staking.initialized()); + assertEq(staking.TOKEN_ID(), 1); + assertGt(staking.liquidityUnderManagement(), 0); + vm.stopPrank(); + } + + function testInitializeRevertOnSecondCall() public { + vm.startPrank(owner); + staking.initialize(); + vm.expectRevert(FoldCaptiveStaking.AlreadyInitialized.selector); + staking.initialize(); + vm.stopPrank(); + } + + function testInitializeRevertOnZeroLiquidity() public { + vm.startPrank(owner); + PositionManagerMock(address(positionManager)).setMintLiquidity(0); + vm.expectRevert(FoldCaptiveStaking.ZeroLiquidity.selector); + staking.initialize(); + vm.stopPrank(); + } + + function testInitializeOnlyOwner() public { + vm.prank(address(2)); + vm.expectRevert("Ownable: caller is not the owner"); + staking.initialize(); + } +} + +contract ERC20Mock is ERC20 { + constructor() ERC20("Mock Token", "MTK", 18) { + _mint(msg.sender, 1000000 * 10**uint256(decimals)); + } +} + +contract PositionManagerMock { + uint256 public tokenIdCounter = 1; + uint128 public liquidityToMint = 1000; + + function mint(INonfungiblePositionManager.MintParams memory) external returns (uint256 tokenId, uint128 liquidity, uint256, uint256) { + tokenId = tokenIdCounter++; + liquidity = liquidityToMint; + return (tokenId, liquidity, 0, 0); + } + + function setMintLiquidity(uint128 _liquidity) external { + liquidityToMint = _liquidity; + } +} diff --git a/test/UnitTests.t.sol b/test/UnitTests.t.sol index 936718d..8bb65e5 100644 --- a/test/UnitTests.t.sol +++ b/test/UnitTests.t.sol @@ -1,4 +1,5 @@ -pragma solidity 0.8.25; +// SPDX-License-Identifier: UNLICESED +pragma solidity ^0.8.25; import "test/BaseCaptiveTest.sol"; import "test/interfaces/ISwapRouter.sol"; diff --git a/test/mock/MockERC20.sol b/test/mock/MockERC20.sol index 2aaa5a1..ea7a2bb 100644 --- a/test/mock/MockERC20.sol +++ b/test/mock/MockERC20.sol @@ -1,5 +1,5 @@ /// SPDX-License-Identifier: SSPL-1.-0 -pragma solidity 0.8.25; +pragma solidity ^0.8.26; import {ERC20} from "lib/solmate/src/tokens/ERC20.sol";