diff --git a/Makefile b/Makefile index cdcaa82..d125703 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ snapshot: # Tests test: - @forge test --summary + @forge test --summary --fail-fast --show-progress test-f-%: @FOUNDRY_MATCH_TEST=$* make test diff --git a/foundry.toml b/foundry.toml index e755671..04bad5f 100644 --- a/foundry.toml +++ b/foundry.toml @@ -9,6 +9,7 @@ auto_detect_remappings = false gas_reports = ["OEthARM", "Proxy"] fs_permissions = [{ access = "read-write", path = "./build" }] extra_output_files = ["metadata"] +ignored_warnings_from = ["src/contracts/Proxy.sol"] remappings = [ "contracts/=./src/contracts", "script/=./script", @@ -17,15 +18,23 @@ remappings = [ "forge-std/=dependencies/forge-std-1.9.2/src/", "@openzeppelin/contracts/=dependencies/@openzeppelin-contracts-5.0.2/", "@openzeppelin/contracts-upgradeable/=dependencies/@openzeppelin-contracts-upgradeable-5.0.2/", + "@solmate/=dependencies/solmate-6.7.0/src/", ] [fuzz] runs = 1_000 +[invariant] +runs = 256 +depth = 500 +fail_on_revert = true +shrink_run_limit = 5_000 + [dependencies] "@openzeppelin-contracts" = "5.0.2" "@openzeppelin-contracts-upgradeable" = "5.0.2" forge-std = { version = "1.9.2", git = "https://github.com/foundry-rs/forge-std.git", rev = "5a802d7c10abb4bbfb3e7214c75052ef9e6a06f8" } +solmate = "6.7.0" [soldeer] recursive_deps = false diff --git a/test/Base.sol b/test/Base.sol index 32ac957..e120471 100644 --- a/test/Base.sol +++ b/test/Base.sol @@ -34,7 +34,6 @@ abstract contract Base_Test_ is Test { Proxy public proxy; Proxy public lpcProxy; Proxy public lidoProxy; - Proxy public lidoOwnerProxy; OethARM public oethARM; LidoARM public lidoARM; CapManager public capManager; @@ -51,11 +50,20 @@ abstract contract Base_Test_ is Test { /// --- Governance, multisigs and EOAs ////////////////////////////////////////////////////// address public alice; + address public bob; + address public charlie; + address public dave; + address public eve; + address public frank; + address public george; + address public harry; + address public deployer; address public governor; address public operator; address public oethWhale; address public feeCollector; + address public lidoWithdraw; ////////////////////////////////////////////////////// /// --- DEFAULT VALUES @@ -70,4 +78,43 @@ abstract contract Base_Test_ is Test { function setUp() public virtual { resolver = new AddressResolver(); } + + /// @notice Better if called once all contract have been depoyed. + function labelAll() public virtual { + // Contracts + _labelNotNull(address(proxy), "DEFAULT PROXY"); + _labelNotNull(address(lpcProxy), "LPC PROXY"); + _labelNotNull(address(lidoProxy), "LIDO ARM PROXY"); + _labelNotNull(address(oethARM), "OETH ARM"); + _labelNotNull(address(lidoARM), "LIDO ARM"); + _labelNotNull(address(capManager), "CAP MANAGER"); + + _labelNotNull(address(oeth), "OETH"); + _labelNotNull(address(weth), "WETH"); + _labelNotNull(address(steth), "STETH"); + _labelNotNull(address(wsteth), " WRAPPED STETH"); + _labelNotNull(address(badToken), "BAD TOKEN"); + _labelNotNull(address(vault), "OETH VAULT"); + + // Governance, multisig and EOAs + _labelNotNull(alice, "Alice"); + _labelNotNull(bob, "Bob"); + _labelNotNull(charlie, "Charlie"); + _labelNotNull(dave, "Dave"); + _labelNotNull(eve, "Eve"); + _labelNotNull(frank, "Frank"); + _labelNotNull(george, "George"); + _labelNotNull(harry, "Harry"); + + _labelNotNull(deployer, "Deployer"); + _labelNotNull(governor, "Governor"); + _labelNotNull(operator, "Operator"); + _labelNotNull(oethWhale, "OETH Whale"); + _labelNotNull(feeCollector, "Fee Collector"); + _labelNotNull(lidoWithdraw, "Lido Withdraw"); + } + + function _labelNotNull(address _address, string memory _name) internal { + if (_address != address(0)) vm.label(_address, _name); + } } diff --git a/test/fork/shared/Shared.sol b/test/fork/shared/Shared.sol index bb6073d..93a259d 100644 --- a/test/fork/shared/Shared.sol +++ b/test/fork/shared/Shared.sol @@ -109,7 +109,6 @@ abstract contract Fork_Shared_Test_ is Modifiers { proxy = new Proxy(); lpcProxy = new Proxy(); lidoProxy = new Proxy(); - lidoOwnerProxy = new Proxy(); // --- Deploy OethARM implementation --- // Deploy OethARM implementation. diff --git a/test/invariants/BaseInvariants.sol b/test/invariants/BaseInvariants.sol new file mode 100644 index 0000000..ddc377c --- /dev/null +++ b/test/invariants/BaseInvariants.sol @@ -0,0 +1,454 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {console} from "forge-std/console.sol"; + +// Test imports +import {Invariant_Shared_Test_} from "./shared/Shared.sol"; + +// Handlers +import {LpHandler} from "./handlers/LpHandler.sol"; +import {LLMHandler} from "./handlers/LLMHandler.sol"; +import {SwapHandler} from "./handlers/SwapHandler.sol"; +import {OwnerHandler} from "./handlers/OwnerHandler.sol"; +import {DonationHandler} from "./handlers/DonationHandler.sol"; + +// Mocks +import {MockSTETH} from "./mocks/MockSTETH.sol"; + +/// @notice Base invariant test contract +/// @dev This contract should be used as a base contract that hold all +/// invariants properties independently from deployment context. +abstract contract Invariant_Base_Test_ is Invariant_Shared_Test_ { + ////////////////////////////////////////////////////// + /// --- VARIABLES + ////////////////////////////////////////////////////// + address[] public lps; // Users that provide liquidity + address[] public swaps; // Users that perform swap + + LpHandler public lpHandler; + LLMHandler public llmHandler; + SwapHandler public swapHandler; + OwnerHandler public ownerHandler; + DonationHandler public donationHandler; + + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public virtual override { + super.setUp(); + } + + ////////////////////////////////////////////////////// + /// --- INVARIANTS + ////////////////////////////////////////////////////// + /* + * Swap functionnalities (swap) + * Invariant A: weth balance == ∑deposit + ∑wethIn + ∑wethRedeem + ∑wethDonated - ∑withdraw - ∑wethOut - ∑feesCollected + * Invariant B: steth balance >= ∑stethIn + ∑stethDonated - ∑stethOut - ∑stethRedeem + + * Liquidity provider functionnalities (lp) + * Shares: + * Invariant A: ∑shares > 0 due to initial deposit + * Invariant B: totalShares == ∑userShares + deadShares + * Invariant C: previewRedeem(∑shares) == totalAssets + * Invariant D: previewRedeem(shares) == (, uint256 assets) = previewRedeem(shares) Not really invariant, but tested on handler + * Invariant E: previewDeposit(amount) == uint256 shares = previewDeposit(amount) Not really invariant, but tested on handler + * Invariant L: ∀ user, user.weth + previewRedeem(user.shares) >=~ initialBalance , approxGe, to handle rounding error on deposit. + + * Withdraw Queue: + * Invariant F: nextWithdrawalIndex == requestRedeem call count + * Invariant G: withdrawsQueued == ∑requestRedeem.amount + * Invariant H: withdrawsQueued > withdrawsClaimed + * Invariant I: withdrawsQueued == ∑request.assets + * Invariant J: withdrawsClaimed == ∑claimRedeem.amount + * Invariant K: ∀ requestId, request.queued >= request.assets + + * Fees: + * Invariant M: ∑feesCollected == feeCollector.balance + + * Lido Liquidity Manager functionnalities + * Invariant A: lidoWithdrawalQueueAmount == ∑lidoRequestRedeem.assets + * Invariant B: address(arm).balance == 0 + * Invariant C: All slot allow for gap are empty + + * After invariants: + * All user can withdraw their funds + * Log stats + + + */ + + ////////////////////////////////////////////////////// + /// --- SWAP ASSERTIONS + ////////////////////////////////////////////////////// + function assert_swap_invariant_A() public view { + uint256 inflows = lpHandler.sum_of_deposits() + swapHandler.sum_of_weth_in() + + llmHandler.sum_of_redeemed_ether() + donationHandler.sum_of_weth_donated() + MIN_TOTAL_SUPPLY; + uint256 outflows = lpHandler.sum_of_withdraws() + swapHandler.sum_of_weth_out() + ownerHandler.sum_of_fees(); + assertEq(weth.balanceOf(address(lidoARM)), inflows - outflows, "swapHandler.invariant_A"); + } + + function assert_swap_invariant_B() public view { + uint256 inflows = swapHandler.sum_of_steth_in() + donationHandler.sum_of_steth_donated(); + uint256 outflows = swapHandler.sum_of_steth_out() + llmHandler.sum_of_requested_ether(); + uint256 sum_of_errors = MockSTETH(address(steth)).sum_of_errors(); + assertApproxEqAbs( + steth.balanceOf(address(lidoARM)), absDiff(inflows, outflows), sum_of_errors, "swapHandler.invariant_B" + ); + } + + ////////////////////////////////////////////////////// + /// --- LIQUIDITY PROVIDER ASSERTIONS + ////////////////////////////////////////////////////// + function assert_lp_invariant_A() public view { + assertGt(lidoARM.totalSupply(), 0, "lpHandler.invariant_A"); + } + + function assert_lp_invariant_B() public view { + uint256 sumOfUserShares; + for (uint256 i; i < lps.length; i++) { + address user = lps[i]; + sumOfUserShares += lidoARM.balanceOf(user); + } + assertEq(lidoARM.totalSupply(), _sumOfUserShares(), "lpHandler.invariant_B"); + } + + function assert_lp_invariant_C() public view { + assertEq(lidoARM.previewRedeem(_sumOfUserShares()), lidoARM.totalAssets(), "lpHandler.invariant_C"); + } + + function assert_lp_invariant_D() public view { + // Not really an invariant, but tested on handler + } + + function assert_lp_invariant_E() public view { + // Not really an invariant, but tested on handler + } + + function assert_lp_invariant_F() public view { + assertEq( + lidoARM.nextWithdrawalIndex(), lpHandler.numberOfCalls("lpHandler.requestRedeem"), "lpHandler.invariant_F" + ); + } + + function assert_lp_invariant_G() public view { + assertEq(lidoARM.withdrawsQueued(), lpHandler.sum_of_requests(), "lpHandler.invariant_G"); + } + + function assert_lp_invariant_H() public view { + assertGe(lidoARM.withdrawsQueued(), lidoARM.withdrawsClaimed(), "lpHandler.invariant_H"); + } + + function assert_lp_invariant_I() public view { + uint256 sum; + uint256 nextWithdrawalIndex = lidoARM.nextWithdrawalIndex(); + for (uint256 i; i < nextWithdrawalIndex; i++) { + (,,, uint128 assets,) = lidoARM.withdrawalRequests(i); + sum += assets; + } + + assertEq(lidoARM.withdrawsQueued(), sum, "lpHandler.invariant_I"); + } + + function assert_lp_invariant_J() public view { + assertEq(lidoARM.withdrawsClaimed(), lpHandler.sum_of_withdraws(), "lpHandler.invariant_J"); + } + + function assert_lp_invariant_K() public view { + uint256 nextWithdrawalIndex = lidoARM.nextWithdrawalIndex(); + for (uint256 i; i < nextWithdrawalIndex; i++) { + (,,, uint128 assets, uint128 queued) = lidoARM.withdrawalRequests(i); + assertGe(queued, assets, "lpHandler.invariant_L"); + } + } + + function assert_lp_invariant_L(uint256 initialBalance, uint256 maxError) public { + // As we will manipulate state here, we will snapshot the state and revert it after + uint256 snapshotId = vm.snapshot(); + + // 1. Finalize all claims on Lido + llmHandler.finalizeAllClaims(); + + // 2. Swap all stETH to WETH + _sweepAllStETH(); + + // 3. Finalize all claim redeem on ARM. + lpHandler.finalizeAllClaims(); + + for (uint256 i; i < lps.length; i++) { + address user = lps[i]; + uint256 userShares = lidoARM.balanceOf(user); + uint256 assets = lidoARM.previewRedeem(userShares); + uint256 sum = assets + weth.balanceOf(user); + + if (sum < initialBalance) { + // In this situation user have lost a bit of asset, ensure this is not too much + assertApproxEqRel(sum, initialBalance, maxError, "lpHandler.invariant_L_a"); + } else { + // In this case user have gained asset. + assertGe(sum, initialBalance, "lpHandler.invariant_L_b"); + } + } + + vm.revertToAndDelete(snapshotId); + } + + function assert_lp_invariant_M() public view { + address feeCollector = lidoARM.feeCollector(); + assertEq(weth.balanceOf(feeCollector), ownerHandler.sum_of_fees(), "lpHandler.invariant_M"); + } + + ////////////////////////////////////////////////////// + /// --- LIDO LIQUIDITY MANAGER ASSERTIONS + ////////////////////////////////////////////////////// + function assert_llm_invariant_A() public view { + assertEq( + lidoARM.lidoWithdrawalQueueAmount(), + llmHandler.sum_of_requested_ether() - llmHandler.sum_of_redeemed_ether(), + "llmHandler.invariant_A" + ); + } + + function assert_llm_invariant_B() public view { + assertEq(address(lidoARM).balance, 0, "llmHandler.invariant_B"); + } + + function assert_llm_invariant_C() public view { + uint256 slotGap1 = 1; + uint256 slotGap2 = 59; + uint256 gap1Length = 49; + uint256 gap2Length = 41; + + for (uint256 i = slotGap1; i < slotGap1 + gap1Length; i++) { + assertEq(readStorageSlotOnARM(i), 0, "lpHandler.invariant_C.gap1"); + } + + for (uint256 i = slotGap2; i < slotGap2 + gap2Length; i++) { + assertEq(readStorageSlotOnARM(i), 0, "lpHandler.invariant_C.gap2"); + } + } + + ////////////////////////////////////////////////////// + /// --- HELPERS + ////////////////////////////////////////////////////// + /// @notice Sum of users shares, including dead shares + function _sumOfUserShares() internal view returns (uint256) { + uint256 sumOfUserShares; + for (uint256 i; i < lps.length; i++) { + address user = lps[i]; + sumOfUserShares += lidoARM.balanceOf(user); + } + return sumOfUserShares + lidoARM.balanceOf(address(0xdEaD)); + } + + /// @notice Swap all stETH to WETH at the current price + function _sweepAllStETH() internal { + uint256 stETHBalance = steth.balanceOf(address(lidoARM)); + deal(address(weth), address(this), 1_000_000_000 ether); + weth.approve(address(lidoARM), type(uint256).max); + lidoARM.swapTokensForExactTokens(weth, steth, stETHBalance, type(uint256).max, address(this)); + assertApproxEqAbs(steth.balanceOf(address(lidoARM)), 0, 1, "SwepAllStETH"); + } + + /// @notice Empties the ARM + /// @dev Finalize all claims on lido, swap all stETH to WETH, finalize all + /// claim redeem on ARM and withdraw all user funds. + function emptiesARM() internal { + // 1. Finalize all claims on Lido + llmHandler.finalizeAllClaims(); + + // 2. Swap all stETH to WETH + _sweepAllStETH(); + + // 3. Finalize all claim redeem on ARM. + lpHandler.finalizeAllClaims(); + + // 4. Withdraw all user funds + lpHandler.withdrawAllUserFunds(); + } + + /// @notice Absolute difference between two numbers + function absDiff(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? a - b : b - a; + } + + function readStorageSlotOnARM(uint256 slotNumber) internal view returns (uint256 value) { + value = uint256(vm.load(address(lidoARM), bytes32(slotNumber))); + } + + function logStats() public view { + // Don't trace this function as it's only for logging data. + vm.pauseTracing(); + // Get data + _LPHandler memory lpHandlerStats = _LPHandler({ + deposit: lpHandler.numberOfCalls("lpHandler.deposit"), + deposit_skip: lpHandler.numberOfCalls("lpHandler.deposit.skip"), + requestRedeem: lpHandler.numberOfCalls("lpHandler.requestRedeem"), + requestRedeem_skip: lpHandler.numberOfCalls("lpHandler.requestRedeem.skip"), + claimRedeem: lpHandler.numberOfCalls("lpHandler.claimRedeem"), + claimRedeem_skip: lpHandler.numberOfCalls("lpHandler.claimRedeem.skip") + }); + + _SwapHandler memory swapHandlerStats = _SwapHandler({ + swapExact: swapHandler.numberOfCalls("swapHandler.swapExact"), + swapExact_skip: swapHandler.numberOfCalls("swapHandler.swapExact.skip"), + swapTokens: swapHandler.numberOfCalls("swapHandler.swapTokens"), + swapTokens_skip: swapHandler.numberOfCalls("swapHandler.swapTokens.skip") + }); + + _OwnerHandler memory ownerHandlerStats = _OwnerHandler({ + setPrices: ownerHandler.numberOfCalls("ownerHandler.setPrices"), + setPrices_skip: ownerHandler.numberOfCalls("ownerHandler.setPrices.skip"), + setCrossPrice: ownerHandler.numberOfCalls("ownerHandler.setCrossPrice"), + setCrossPrice_skip: ownerHandler.numberOfCalls("ownerHandler.setCrossPrice.skip"), + collectFees: ownerHandler.numberOfCalls("ownerHandler.collectFees"), + collectFees_skip: ownerHandler.numberOfCalls("ownerHandler.collectFees.skip"), + setFees: ownerHandler.numberOfCalls("ownerHandler.setFees"), + setFees_skip: ownerHandler.numberOfCalls("ownerHandler.setFees.skip") + }); + + _LLMHandler memory llmHandlerStats = _LLMHandler({ + requestStETHWithdraw: llmHandler.numberOfCalls("llmHandler.requestStETHWithdraw"), + claimStETHWithdraw: llmHandler.numberOfCalls("llmHandler.claimStETHWithdraw") + }); + + _DonationHandler memory donationHandlerStats = _DonationHandler({ + donateStETH: donationHandler.numberOfCalls("donationHandler.donateStETH"), + donateWETH: donationHandler.numberOfCalls("donationHandler.donateWETH") + }); + + // Log data + console.log(""); + console.log(""); + console.log(""); + console.log("--- Stats ---"); + + // --- LP Handler --- + console.log(""); + console.log("# LP Handler # "); + console.log("Number of Call: Deposit %d (skipped: %d)", lpHandlerStats.deposit, lpHandlerStats.deposit_skip); + console.log( + "Number of Call: RequestRedeem %d (skipped: %d)", + lpHandlerStats.requestRedeem, + lpHandlerStats.requestRedeem_skip + ); + console.log( + "Number of Call: ClaimRedeem %d (skipped: %d)", lpHandlerStats.claimRedeem, lpHandlerStats.claimRedeem_skip + ); + + // --- Swap Handler --- + console.log(""); + console.log("# Swap Handler #"); + console.log( + "Number of Call: SwapExactTokensForTokens %d (skipped: %d)", + swapHandlerStats.swapExact, + swapHandlerStats.swapExact_skip + ); + console.log( + "Number of Call: SwapTokensForExactTokens %d (skipped: %d)", + swapHandlerStats.swapTokens, + swapHandlerStats.swapTokens_skip + ); + + // --- Owner Handler --- + console.log(""); + console.log("# Owner Handler #"); + console.log( + "Number of Call: SetPrices %d (skipped: %d)", ownerHandlerStats.setPrices, ownerHandlerStats.setPrices_skip + ); + console.log( + "Number of Call: SetCrossPrice %d (skipped: %d)", + ownerHandlerStats.setCrossPrice, + ownerHandlerStats.setCrossPrice_skip + ); + console.log( + "Number of Call: CollectFees %d (skipped: %d)", + ownerHandlerStats.collectFees, + ownerHandlerStats.collectFees_skip + ); + console.log( + "Number of Call: SetFees %d (skipped: %d)", ownerHandlerStats.setFees, ownerHandlerStats.setFees_skip + ); + + // --- LLM Handler --- + console.log(""); + console.log("# LLM Handler #"); + console.log( + "Number of Call: RequestStETHWithdrawalForETH %d (skipped: %d)", llmHandlerStats.requestStETHWithdraw, 0 + ); + console.log( + "Number of Call: ClaimStETHWithdrawalForWETH %d (skipped: %d)", llmHandlerStats.claimStETHWithdraw, 0 + ); + + // --- Donation Handler --- + console.log(""); + console.log("# Donation Handler #"); + console.log("Number of Call: DonateStETH %d (skipped: %d)", donationHandlerStats.donateStETH, 0); + console.log("Number of Call: DonateWETH %d (skipped: %d)", donationHandlerStats.donateWETH, 0); + + // --- Global --- + console.log(""); + console.log("# Global Data #"); + uint256 sumOfCall = donationHandlerStats.donateStETH + donationHandlerStats.donateWETH + + llmHandlerStats.requestStETHWithdraw + llmHandlerStats.claimStETHWithdraw + ownerHandlerStats.setPrices + + ownerHandlerStats.setCrossPrice + ownerHandlerStats.collectFees + ownerHandlerStats.setFees + + swapHandlerStats.swapExact + swapHandlerStats.swapTokens + lpHandlerStats.deposit + + lpHandlerStats.requestRedeem + lpHandlerStats.claimRedeem; + uint256 sumOfCall_skip = ownerHandlerStats.setPrices_skip + ownerHandlerStats.setCrossPrice_skip + + ownerHandlerStats.collectFees_skip + ownerHandlerStats.setFees_skip + swapHandlerStats.swapExact_skip + + swapHandlerStats.swapTokens_skip + lpHandlerStats.deposit_skip + lpHandlerStats.requestRedeem_skip + + lpHandlerStats.claimRedeem_skip; + + uint256 skipPct = (sumOfCall_skip * 10_000) / max(sumOfCall, 1); + console.log("Total call: %d (skipped: %d) -> %2e%", sumOfCall, sumOfCall_skip, skipPct); + console.log(""); + console.log("-------------"); + console.log(""); + console.log(""); + console.log(""); + vm.resumeTracing(); + } + + function max(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? a : b; + } + + struct _LPHandler { + uint256 deposit; + uint256 deposit_skip; + uint256 requestRedeem; + uint256 requestRedeem_skip; + uint256 claimRedeem; + uint256 claimRedeem_skip; + } + + struct _SwapHandler { + uint256 swapExact; + uint256 swapExact_skip; + uint256 swapTokens; + uint256 swapTokens_skip; + } + + struct _OwnerHandler { + uint256 setPrices; + uint256 setPrices_skip; + uint256 setCrossPrice; + uint256 setCrossPrice_skip; + uint256 collectFees; + uint256 collectFees_skip; + uint256 setFees; + uint256 setFees_skip; + } + + struct _LLMHandler { + uint256 requestStETHWithdraw; + uint256 claimStETHWithdraw; + } + + struct _DonationHandler { + uint256 donateStETH; + uint256 donateWETH; + } +} diff --git a/test/invariants/BasicInvariants.sol b/test/invariants/BasicInvariants.sol new file mode 100644 index 0000000..551d5f2 --- /dev/null +++ b/test/invariants/BasicInvariants.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {Invariant_Base_Test_} from "./BaseInvariants.sol"; + +// Handlers +import {LpHandler} from "./handlers/LpHandler.sol"; +import {LLMHandler} from "./handlers/LLMHandler.sol"; +import {SwapHandler} from "./handlers/SwapHandler.sol"; +import {OwnerHandler} from "./handlers/OwnerHandler.sol"; +import {DonationHandler} from "./handlers/DonationHandler.sol"; +import {DistributionHandler} from "./handlers/DistributionHandler.sol"; + +/// @notice Basic invariant test contract +/// @dev This contract holds all the configuration needed for the basic invariant tests, +/// like call distribution %, user configuration, max values etc. +/// @dev This is where all the invariant are checked. +contract Invariant_Basic_Test_ is Invariant_Base_Test_ { + ////////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + ////////////////////////////////////////////////////// + uint256 private constant NUM_LPS = 4; + uint256 private constant NUM_SWAPS = 3; + uint256 private constant MAX_FEES = 5_000; // 50% + uint256 private constant MIN_BUY_T1 = 0.98 * 1e36; // We could have use 0, but this is non-sense + uint256 private constant MAX_SELL_T1 = 1.02 * 1e36; // We could have use type(uint256).max, but this is non-sense + uint256 private constant MAX_WETH_PER_USERS = 10_000 ether; // 10M + uint256 private constant MAX_STETH_PER_USERS = 10_000 ether; // 10M, actual total supply + uint256 private constant MAX_LOSS_IN_PCT = 1e13; // 0.001% + + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public virtual override { + super.setUp(); + + // --- Create Users --- + // In this configuration, an user is either a LP or a Swap, but not both. + require(NUM_LPS + NUM_SWAPS <= users.length, "IBT: NOT_ENOUGH_USERS"); + for (uint256 i; i < NUM_LPS; i++) { + address user = users[i]; + require(user != address(0), "IBT: INVALID_USER"); + lps.push(user); + + // Give them a lot of wETH + deal(address(weth), user, MAX_WETH_PER_USERS); + } + for (uint256 i = NUM_LPS; i < NUM_LPS + NUM_SWAPS; i++) { + address user = users[i]; + require(user != address(0), "IBT: INVALID_USER"); + swaps.push(user); + + // Give them a lot of wETH and stETH + deal(address(weth), user, MAX_WETH_PER_USERS); + deal(address(steth), user, MAX_STETH_PER_USERS); + } + + // --- Setup ARM --- + // Max caps on the total asset that can be deposited + vm.prank(capManager.owner()); + capManager.setTotalAssetsCap(type(uint248).max); + + // Set prices, start with almost 1:1 + vm.prank(lidoARM.owner()); + lidoARM.setPrices(1e36 - 1, 1e36); + + // --- Handlers --- + lpHandler = new LpHandler(address(lidoARM), address(weth), lps); + swapHandler = new SwapHandler(address(lidoARM), address(weth), address(steth), swaps); + ownerHandler = + new OwnerHandler(address(lidoARM), address(weth), address(steth), MIN_BUY_T1, MAX_SELL_T1, MAX_FEES); + llmHandler = new LLMHandler(address(lidoARM), address(steth)); + donationHandler = new DonationHandler(address(lidoARM), address(weth), address(steth)); + + lpHandler.setSelectorWeight(lpHandler.deposit.selector, 5_000); // 50% + lpHandler.setSelectorWeight(lpHandler.requestRedeem.selector, 2_500); // 25% + lpHandler.setSelectorWeight(lpHandler.claimRedeem.selector, 2_500); // 25% + swapHandler.setSelectorWeight(swapHandler.swapExactTokensForTokens.selector, 5_000); // 50% + swapHandler.setSelectorWeight(swapHandler.swapTokensForExactTokens.selector, 5_000); // 50% + ownerHandler.setSelectorWeight(ownerHandler.setPrices.selector, 5_000); // 50% + ownerHandler.setSelectorWeight(ownerHandler.setCrossPrice.selector, 2_000); // 20% + ownerHandler.setSelectorWeight(ownerHandler.collectFees.selector, 2_000); // 20% + ownerHandler.setSelectorWeight(ownerHandler.setFees.selector, 1_000); // 10% + llmHandler.setSelectorWeight(llmHandler.requestLidoWithdrawals.selector, 5_000); // 50% + llmHandler.setSelectorWeight(llmHandler.claimLidoWithdrawals.selector, 5_000); // 50% + donationHandler.setSelectorWeight(donationHandler.donateStETH.selector, 5_000); // 50% + donationHandler.setSelectorWeight(donationHandler.donateWETH.selector, 5_000); // 50% + + address[] memory targetContracts = new address[](5); + targetContracts[0] = address(lpHandler); + targetContracts[1] = address(swapHandler); + targetContracts[2] = address(ownerHandler); + targetContracts[3] = address(llmHandler); + targetContracts[4] = address(donationHandler); + + uint256[] memory weightsDistributorHandler = new uint256[](5); + weightsDistributorHandler[0] = 4_000; // 40% + weightsDistributorHandler[1] = 4_000; // 40% + weightsDistributorHandler[2] = 1_000; // 10% + weightsDistributorHandler[3] = 700; // 7% + weightsDistributorHandler[4] = 300; // 3% + + address distributionHandler = address(new DistributionHandler(targetContracts, weightsDistributorHandler)); + + // All call will be done through the distributor, so we set it as the target contract + targetContract(distributionHandler); + } + + ////////////////////////////////////////////////////// + /// --- INVARIANTS + ////////////////////////////////////////////////////// + function invariant_lp() external { + assert_lp_invariant_A(); + assert_lp_invariant_B(); + assert_lp_invariant_C(); + assert_lp_invariant_D(); + assert_lp_invariant_E(); + assert_lp_invariant_F(); + assert_lp_invariant_G(); + assert_lp_invariant_H(); + assert_lp_invariant_I(); + assert_lp_invariant_J(); + assert_lp_invariant_K(); + assert_lp_invariant_L(MAX_WETH_PER_USERS, MAX_LOSS_IN_PCT); + assert_lp_invariant_M(); + } + + function invariant_swap() external view { + assert_swap_invariant_A(); + assert_swap_invariant_B(); + } + + function invariant_llm() external view { + assert_llm_invariant_A(); + assert_llm_invariant_B(); + assert_llm_invariant_C(); + } + + function afterInvariant() external { + logStats(); + emptiesARM(); + } +} diff --git a/test/invariants/handlers/BaseHandler.sol b/test/invariants/handlers/BaseHandler.sol new file mode 100644 index 0000000..8714990 --- /dev/null +++ b/test/invariants/handlers/BaseHandler.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {Vm} from "forge-std/Vm.sol"; +import {StdUtils} from "forge-std/StdUtils.sol"; +import {StdCheats} from "forge-std/StdCheats.sol"; + +/// @notice Base handler contract +/// @dev This contract should be used as a base contract for all handlers +/// as this it holds the sole and exclusive callable function `entryPoint`. +/// @dev Highly inspired from Maple-Core-V2 repo: https://github.com/maple-labs/maple-core-v2 +abstract contract BaseHandler is StdUtils, StdCheats { + ////////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + ////////////////////////////////////////////////////// + Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + uint256 internal constant WEIGHTS_RANGE = 10_000; + + ////////////////////////////////////////////////////// + /// --- VARIABLES + ////////////////////////////////////////////////////// + uint256 public numCalls; + uint256 public totalWeight; + + bytes4[] public selectors; + + mapping(address => string) public names; + mapping(bytes4 => uint256) public weights; + mapping(bytes32 => uint256) public numberOfCalls; + + constructor() { + // Default names + names[makeAddr("Alice")] = "Alice"; + names[makeAddr("Bob")] = "Bob"; + names[makeAddr("Charlie")] = "Charlie"; + names[makeAddr("Dave")] = "Dave"; + names[makeAddr("Eve")] = "Eve"; + names[makeAddr("Frank")] = "Frank"; + names[makeAddr("George")] = "George"; + names[makeAddr("Harry")] = "Harry"; + } + + ////////////////////////////////////////////////////// + /// --- FUNCTIONS + ////////////////////////////////////////////////////// + function setSelectorWeight(bytes4 funcSelector, uint256 weight_) external { + // Set Selector weight + weights[funcSelector] = weight_; + + // Add selector to the selector list + selectors.push(funcSelector); + + // Increase totalWeight + totalWeight += weight_; + } + + function entryPoint(uint256 seed_) external { + require(totalWeight == WEIGHTS_RANGE, "HB:INVALID_WEIGHTS"); + + numCalls++; + + uint256 range_; + + uint256 value_ = uint256(keccak256(abi.encodePacked(seed_, numCalls))) % WEIGHTS_RANGE + 1; // 1 - 100 + + for (uint256 i = 0; i < selectors.length; i++) { + uint256 weight_ = weights[selectors[i]]; + + range_ += weight_; + if (value_ <= range_ && weight_ != 0) { + (bool success,) = address(this).call(abi.encodeWithSelector(selectors[i], seed_)); + + // TODO: Parse error from low-level call and revert with it + require(success, "HB:CALL_FAILED"); + break; + } + } + } + + ////////////////////////////////////////////////////// + /// --- HELPERS + ////////////////////////////////////////////////////// + function _randomize(uint256 seed, string memory salt) internal pure returns (uint256) { + return uint256(keccak256(abi.encodePacked(seed, salt))); + } + + /// @notice Return the minimum between two uint256 + function min(uint256 a, uint256 b) internal pure returns (uint256) { + return a < b ? a : b; + } + + /// @notice Return the maximum between two uint256 + function max(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? a : b; + } +} diff --git a/test/invariants/handlers/DistributionHandler.sol b/test/invariants/handlers/DistributionHandler.sol new file mode 100644 index 0000000..ded559c --- /dev/null +++ b/test/invariants/handlers/DistributionHandler.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Contract +import {BaseHandler} from "./BaseHandler.sol"; + +/// @title Distribution Handler contract +/// @dev This contract should be the only callable contract from test and will distribute calls to other contracts +/// @dev Highly inspired from Maple-Core-V2 repo: https://github.com/maple-labs/maple-core-v2 +contract DistributionHandler { + ////////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + ////////////////////////////////////////////////////// + uint256 internal constant WEIGHTS_RANGE = 10_000; + + ////////////////////////////////////////////////////// + /// --- VARIABLES + ////////////////////////////////////////////////////// + uint256 public numOfCallsTotal; + + address[] public targetContracts; + + uint256[] public weights; + + mapping(address => uint256) public numOfCalls; + + ////////////////////////////////////////////////////// + /// --- CONSTRUCTOR + ////////////////////////////////////////////////////// + constructor(address[] memory targetContracts_, uint256[] memory weights_) { + // NOTE: Order of arrays must match + require(targetContracts_.length == weights_.length, "DH:INVALID_LENGTHS"); + + uint256 weightsTotal; + + for (uint256 i; i < weights_.length; ++i) { + weightsTotal += weights_[i]; + } + + require(weightsTotal == WEIGHTS_RANGE, "DH:INVALID_WEIGHTS"); + + targetContracts = targetContracts_; + weights = weights_; + } + + ////////////////////////////////////////////////////// + /// --- FUNCTIONS + ////////////////////////////////////////////////////// + function distributorEntryPoint(uint256 seed_) external { + numOfCallsTotal++; + + uint256 range_; + + uint256 value_ = uint256(keccak256(abi.encodePacked(seed_, numOfCallsTotal))) % WEIGHTS_RANGE + 1; // 1 - 100 + + for (uint256 i = 0; i < targetContracts.length; i++) { + uint256 weight_ = weights[i]; + + range_ += weight_; + if (value_ <= range_ && weight_ != 0) { + numOfCalls[targetContracts[i]]++; + BaseHandler(targetContracts[i]).entryPoint(seed_); + break; + } + } + } +} diff --git a/test/invariants/handlers/DonationHandler.sol b/test/invariants/handlers/DonationHandler.sol new file mode 100644 index 0000000..f689412 --- /dev/null +++ b/test/invariants/handlers/DonationHandler.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {console} from "forge-std/console.sol"; + +// Handlers +import {BaseHandler} from "./BaseHandler.sol"; + +// Contracts +import {IERC20} from "contracts/Interfaces.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; + +/// @notice DonaitonHandler contract +/// @dev This contract is used to simulate donation of stETH or wETH to the ARM. +contract DonationHandler is BaseHandler { + //////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + //////////////////////////////////////////////////// + IERC20 public immutable weth; + IERC20 public immutable steth; + LidoARM public immutable arm; + + //////////////////////////////////////////////////// + /// --- VARIABLES + //////////////////////////////////////////////////// + + //////////////////////////////////////////////////// + /// --- VARIABLES FOR INVARIANT ASSERTIONS + //////////////////////////////////////////////////// + uint256 public sum_of_weth_donated; + uint256 public sum_of_steth_donated; + + //////////////////////////////////////////////////// + /// --- CONSTRUCTOR + //////////////////////////////////////////////////// + constructor(address _arm, address _weth, address _steth) { + arm = LidoARM(payable(_arm)); + weth = IERC20(_weth); + steth = IERC20(_steth); + + names[address(weth)] = "WETH"; + names[address(steth)] = "STETH"; + } + + //////////////////////////////////////////////////// + /// --- ACTIONS + //////////////////////////////////////////////////// + function donateStETH(uint256 _seed) external { + numberOfCalls["donationHandler.donateStETH"]++; + + uint256 amount = _bound(_seed, 1, 1 ether); + console.log("DonationHandler.donateStETH(%18e)", amount); + + deal(address(steth), address(this), amount); + + steth.transfer(address(arm), amount); + + sum_of_steth_donated += amount; + } + + function donateWETH(uint256 _seed) external { + numberOfCalls["donationHandler.donateWETH"]++; + + uint256 amount = _bound(_seed, 1, 1 ether); + console.log("DonationHandler.donateWETH(%18e)", amount); + + deal(address(weth), address(this), amount); + + weth.transfer(address(arm), amount); + + sum_of_weth_donated += amount; + } +} diff --git a/test/invariants/handlers/LLMHandler.sol b/test/invariants/handlers/LLMHandler.sol new file mode 100644 index 0000000..731a332 --- /dev/null +++ b/test/invariants/handlers/LLMHandler.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {console} from "forge-std/console.sol"; + +// Handlers +import {BaseHandler} from "./BaseHandler.sol"; + +// Contracts +import {IERC20} from "contracts/Interfaces.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; + +/// @notice LidoLiquidityManager Handler contract +/// @dev This contract is used to handle all functionnalities that are related to the Lido Liquidity Manager. +contract LLMHandler is BaseHandler { + //////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + //////////////////////////////////////////////////// + IERC20 public immutable steth; + LidoARM public immutable arm; + address public immutable owner; + uint256 public constant MAX_AMOUNT = 1_000 ether; + + //////////////////////////////////////////////////// + /// --- VARIABLES + //////////////////////////////////////////////////// + uint256[] public requestIds; + + //////////////////////////////////////////////////// + /// --- VARIABLES FOR INVARIANT ASSERTIONS + //////////////////////////////////////////////////// + uint256 public sum_of_requested_ether; + uint256 public sum_of_redeemed_ether; + + //////////////////////////////////////////////////// + /// --- CONSTRUCTOR + //////////////////////////////////////////////////// + constructor(address _arm, address _steth) { + arm = LidoARM(payable(_arm)); + owner = arm.owner(); + steth = IERC20(_steth); + } + + //////////////////////////////////////////////////// + /// --- ACTIONS + //////////////////////////////////////////////////// + function requestLidoWithdrawals(uint256 _seed) external { + numberOfCalls["llmHandler.requestStETHWithdraw"]++; + + // Select a random amount + uint256 totalAmount = _bound(_seed, 0, min(MAX_AMOUNT * 3, steth.balanceOf(address(arm)))); + + // We can only request only 1k amount at a time + uint256 batch = (totalAmount / MAX_AMOUNT) + 1; + uint256[] memory amounts = new uint256[](batch); + uint256 totalAmount_ = totalAmount; + for (uint256 i = 0; i < batch; i++) { + if (totalAmount_ >= MAX_AMOUNT) { + amounts[i] = MAX_AMOUNT; + totalAmount_ -= MAX_AMOUNT; + } else { + amounts[i] = totalAmount_; + totalAmount_ = 0; + } + } + require(totalAmount_ == 0, "LLMHandler: Invalid total amount"); + + console.log("LLMHandler.requestLidoWithdrawals(%18e)", totalAmount); + + // Prank Owner + vm.startPrank(owner); + + // Request stETH withdrawal for ETH + uint256[] memory requestId = arm.requestLidoWithdrawals(amounts); + + // Stop Prank + vm.stopPrank(); + + // Update state + for (uint256 i = 0; i < requestId.length; i++) { + requestIds.push(requestId[i]); + } + + // Update sum of requested ether + sum_of_requested_ether += totalAmount; + } + + function claimLidoWithdrawals(uint256 _seed) external { + numberOfCalls["llmHandler.claimStETHWithdraw"]++; + + // Select multiple requestIds + uint256 len = requestIds.length; + uint256 requestCount = _bound(_seed, 0, len); + uint256[] memory requestIds_ = new uint256[](requestCount); + for (uint256 i = 0; i < requestCount; i++) { + requestIds_[i] = requestIds[i]; + } + + // Remove requestIds from list + uint256[] memory newRequestIds = new uint256[](len - requestCount); + for (uint256 i = requestCount; i < len; i++) { + newRequestIds[i - requestCount] = requestIds[i]; + } + requestIds = newRequestIds; + + // As `claimLidoWithdrawals` doesn't send back the amount, we need to calculate it + uint256 outstandingBefore = arm.lidoWithdrawalQueueAmount(); + + // Prank Owner + vm.startPrank(owner); + + // Claim stETH withdrawal for WETH + arm.claimLidoWithdrawals(requestIds_); + + // Stop Prank + vm.stopPrank(); + + uint256 outstandingAfter = arm.lidoWithdrawalQueueAmount(); + uint256 diff = outstandingBefore - outstandingAfter; + + console.log("LLMHandler.claimLidoWithdrawals(%18e -- count: %d)", diff, requestCount); + + // Update sum of redeemed ether + sum_of_redeemed_ether += diff; + } + + //////////////////////////////////////////////////// + /// --- HELPERS + //////////////////////////////////////////////////// + /// @notice Claim all the remaining requested withdrawals + function finalizeAllClaims() external { + // As `claimLidoWithdrawals` doesn't send back the amount, we need to calculate it + uint256 outstandingBefore = arm.lidoWithdrawalQueueAmount(); + + // Prank Owner + vm.startPrank(owner); + + // Claim stETH withdrawal for WETH + arm.claimLidoWithdrawals(requestIds); + + // Stop Prank + vm.stopPrank(); + + uint256 outstandingAfter = arm.lidoWithdrawalQueueAmount(); + uint256 diff = outstandingBefore - outstandingAfter; + + // Update sum of redeemed ether + sum_of_redeemed_ether += diff; + } +} diff --git a/test/invariants/handlers/LpHandler.sol b/test/invariants/handlers/LpHandler.sol new file mode 100644 index 0000000..095cfb4 --- /dev/null +++ b/test/invariants/handlers/LpHandler.sol @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {console} from "forge-std/console.sol"; + +// Handlers +import {BaseHandler} from "./BaseHandler.sol"; + +// Contracts +import {IERC20} from "contracts/Interfaces.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; + +/// @notice LpHandler contract +/// @dev This contract is used to handle all functionnalities related to providing liquidity in the ARM. +contract LpHandler is BaseHandler { + //////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + //////////////////////////////////////////////////// + IERC20 public immutable weth; + LidoARM public immutable arm; + + //////////////////////////////////////////////////// + /// --- VARIABLES + //////////////////////////////////////////////////// + address[] public lps; // Users that provide liquidity + mapping(address user => uint256[] ids) public requests; + + //////////////////////////////////////////////////// + /// --- VARIABLES FOR INVARIANT ASSERTIONS + //////////////////////////////////////////////////// + uint256 public sum_of_deposits; + uint256 public sum_of_requests; + uint256 public sum_of_withdraws; + + //////////////////////////////////////////////////// + /// --- CONSTRUCTOR + //////////////////////////////////////////////////// + constructor(address _arm, address _weth, address[] memory _lps) { + arm = LidoARM(payable(_arm)); + weth = IERC20(_weth); + + require(_lps.length > 0, "LH: EMPTY_LPS"); + lps = _lps; + } + + //////////////////////////////////////////////////// + /// --- ACTIONS + //////////////////////////////////////////////////// + /// @notice Provide liquidity to the ARM with a given amount of WETH + /// @dev This assumes that lps have unlimited capacity to provide liquidity on LPC contracts. + function deposit(uint256 _seed) external { + numberOfCalls["lpHandler.deposit"]++; + + // Get a user + address user = lps[_seed % lps.length]; + + // Amount of WETH to deposit should be between 0 and total WETH balance + uint256 amount = _bound(_seed, 0, weth.balanceOf(user)); + console.log("LpHandler.deposit(%18e), %s", amount, names[user]); + + // Prank user + vm.startPrank(user); + + // Approve WETH to ARM + weth.approve(address(arm), amount); + + // Deposit WETH + uint256 expectedShares = arm.previewDeposit(amount); + uint256 shares = arm.deposit(amount); + + // This is an invariant check. The shares should be equal to the expected shares + require(shares == expectedShares, "LH: DEPOSIT - INVALID_SHARES"); + + // End prank + vm.stopPrank(); + + // Update sum of deposits + sum_of_deposits += amount; + } + + /// @notice Request to redeem a given amount of shares from the ARM + /// @dev This is allowed to redeem 0 shares. + function requestRedeem(uint256 _seed) external { + numberOfCalls["lpHandler.requestRedeem"]++; + + // Try to get a user that have shares, i.e. that have deposited and not redeemed all + // If there is not such user, get a random user and 0redeem + address user; + uint256 len = lps.length; + uint256 __seed = _bound(_seed, 0, type(uint256).max - len); + for (uint256 i; i < len; i++) { + user = lps[(__seed + i) % len]; + if (arm.balanceOf(user) > 0) break; + } + require(user != address(0), "LH: REDEEM_REQUEST - NO_USER"); // Should not happen, but just in case + + // Amount of shares to redeem should be between 0 and user total shares balance + uint256 shares = _bound(_seed, 0, arm.balanceOf(user)); + console.log("LpHandler.requestRedeem(%18e -- id: %d), %s", shares, arm.nextWithdrawalIndex(), names[user]); + + // Prank user + vm.startPrank(user); + + // Redeem shares + uint256 expectedAmount = arm.previewRedeem(shares); + (uint256 id, uint256 amount) = arm.requestRedeem(shares); + + // This is an invariant check. The amount should be equal to the expected amount + require(amount == expectedAmount, "LH: REDEEM_REQUEST - INVALID_AMOUNT"); + + // End prank + vm.stopPrank(); + + // Add request to user + requests[user].push(id); + + // Update sum of requests + sum_of_requests += amount; + } + + event UserFound(address user, uint256 requestId, uint256 requestIndex); + + /// @notice Claim redeem request for a user on the ARM + /// @dev This call will be skipped if there is no request to claim at all. However, claiming zero is allowed. + /// @dev A jump in time is done to the request deadline, but the time is rewinded back to the current time. + function claimRedeem(uint256 _seed) external { + numberOfCalls["lpHandler.claimRedeem"]++; + + // Get a user that have a request to claim + // If no user have a request, skip this call + address user; + uint256 requestId; // on the ARM + uint256 requestIndex; // local + uint256 requestAmount; + uint256 len = lps.length; + uint256 __seed = _bound(_seed, 0, type(uint256).max - len); + uint256 withdrawsClaimed = arm.withdrawsClaimed(); + + // 1. Loop to find a user with a request + for (uint256 i; i < len; i++) { + // Take a random user + address user_ = lps[(__seed + i) % len]; + // Check if user have a request + if (requests[user_].length > 0) { + // Cache user requests length + uint256 requestLen = requests[user_].length; + + // 2. Loop to find a request that can be claimed + for (uint256 j; j < requestLen; j++) { + uint256 ___seed = _bound(_seed, 0, type(uint256).max - requestLen); + // Take a random request among user requests + uint256 requestIndex_ = (___seed + j) % requestLen; + + // Get data about the request (in ARM contract) + (,,, uint128 amount_, uint128 queued) = arm.withdrawalRequests(requests[user_][requestIndex_]); + + // 3. Check if the request can be claimed + if (queued < withdrawsClaimed + weth.balanceOf(address(arm))) { + user = user_; + requestId = requests[user_][requestIndex_]; + requestIndex = requestIndex_; + requestAmount = amount_; + emit UserFound(user, requestId, requestIndex); + break; + } + } + } + + // If we found a user with a request, break the loop + if (user != address(0)) break; + } + + // If no user have a request, skip this call + if (user == address(0)) { + console.log("LpHandler.claimRedeem - No user have a request"); + numberOfCalls["lpHandler.claimRedeem.skip"]++; + return; + } + + console.log("LpHandler.claimRedeem(%18e -- id: %d), %s", requestAmount, requestId, names[user]); + + // Timejump to request deadline + skip(arm.claimDelay()); + + // Prank user + vm.startPrank(user); + + // Claim redeem + (uint256 amount) = arm.claimRedeem(requestId); + require(amount == requestAmount, "LH: CLAIM_REDEEM - INVALID_AMOUNT"); + + // End prank + vm.stopPrank(); + + // Jump back to current time, to avoid issues with other tests + rewind(arm.claimDelay()); + + // Remove request + uint256[] storage userRequests = requests[user]; + userRequests[requestIndex] = userRequests[userRequests.length - 1]; + userRequests.pop(); + + // Update sum of withdraws + sum_of_withdraws += amount; + } + + //////////////////////////////////////////////////// + /// --- HELPERS + //////////////////////////////////////////////////// + /// @notice Finalize all user claim request for all users + function finalizeAllClaims() external { + // Timejump to request deadline + skip(arm.claimDelay()); + + for (uint256 i; i < lps.length; i++) { + address user = lps[i]; + + vm.startPrank(user); + uint256[] memory userRequests = requests[user]; + for (uint256 j; j < userRequests.length; j++) { + uint256 amount = arm.claimRedeem(userRequests[j]); + sum_of_withdraws += amount; + } + // Delete all requests + delete requests[user]; + + vm.stopPrank(); + } + + // Jump back to current time, to avoid issues with other tests + rewind(arm.claimDelay()); + } + + /// @notice Withdraw all user funds + /// @dev This function assumes that all pending request on lido have been finalized, + /// all stETH have been swapped to WETH and all claim redeem requests have been finalized. + function withdrawAllUserFunds() external { + for (uint256 i; i < lps.length; i++) { + address user = lps[i]; + vm.startPrank(user); + + // Request Claim + (uint256 requestId,) = arm.requestRedeem(arm.balanceOf(user)); + + // Timejump to request deadline + skip(arm.claimDelay()); + + // Claim request + arm.claimRedeem(requestId); + + // Jump back to current time, to avoid issues with other tests + rewind(arm.claimDelay()); + vm.stopPrank(); + } + } + + /// @notice Get all requests for a user + function getRequests(address user) external view returns (uint256[] memory) { + return requests[user]; + } +} diff --git a/test/invariants/handlers/OwnerHandler.sol b/test/invariants/handlers/OwnerHandler.sol new file mode 100644 index 0000000..d392dcd --- /dev/null +++ b/test/invariants/handlers/OwnerHandler.sol @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {console} from "forge-std/console.sol"; + +// Handlers +import {BaseHandler} from "./BaseHandler.sol"; + +// Contracts +import {IERC20} from "contracts/Interfaces.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; + +/// @notice OwnerHandler contract +/// @dev This contract is used to handle all functionnalities restricted to the owner of the ARM. +contract OwnerHandler is BaseHandler { + //////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + //////////////////////////////////////////////////// + IERC20 public immutable weth; + IERC20 public immutable steth; + LidoARM public immutable arm; + address public immutable owner; + uint256 public immutable maxFees; + address public immutable operator; + uint256 public immutable minBuyT1; + uint256 public immutable maxSellT1; + uint256 public immutable priceScale; + uint256 public constant MIN_TOTAL_SUPPLY = 1e12; + + //////////////////////////////////////////////////// + /// --- VARIABLES FOR INVARIANT ASSERTIONS + //////////////////////////////////////////////////// + uint256 public sum_of_fees; + + //////////////////////////////////////////////////// + /// --- CONSTRUCTOR + //////////////////////////////////////////////////// + constructor(address _arm, address _weth, address _steth, uint256 _minBuyT1, uint256 _maxSellT1, uint256 _maxFees) { + arm = LidoARM(payable(_arm)); + weth = IERC20(_weth); + steth = IERC20(_steth); + maxFees = _maxFees; + minBuyT1 = _minBuyT1; + maxSellT1 = _maxSellT1; + owner = arm.owner(); + operator = arm.operator(); + priceScale = arm.PRICE_SCALE(); + } + + //////////////////////////////////////////////////// + /// --- ACTIONS + //////////////////////////////////////////////////// + /// @notice Set prices for the ARM + function setPrices(uint256 _seed) external { + numberOfCalls["ownerHandler.setPrices"]++; + + // Bound prices + uint256 crossPrice = arm.crossPrice(); + uint256 buyT1 = _bound(_randomize(_seed, "buy"), minBuyT1, crossPrice - 1); + uint256 sellT1 = _bound(_randomize(_seed, "sell"), crossPrice, maxSellT1); + + console.log("OwnerHandler.setPrices(%36e,%36e)", buyT1, sellT1); + + // Prank owner instead of operator to bypass price check + vm.startPrank(owner); + + // Set prices + arm.setPrices(buyT1, sellT1); + + // Stop prank + vm.stopPrank(); + } + + /// @notice Set cross price for the ARM + function setCrossPrice(uint256 _seed) external { + numberOfCalls["ownerHandler.setCrossPrice"]++; + + // Bound prices + uint256 currentPrice = arm.crossPrice(); + // Condition 1: 1e36 - 20e32 <= newCrossPrice <= 1e36 + // Condition 2: buyPrice < newCrossPrice <= sellPrice + // <=> + // max(buyPrice, 1e36 - 20e32) < newCrossPrice <= min(sellPrice, 1e36) + uint256 sellPrice = priceScale * priceScale / arm.traderate0(); + uint256 buyPrice = arm.traderate1(); + uint256 newCrossPrice = + _bound(_seed, max(priceScale - arm.MAX_CROSS_PRICE_DEVIATION(), buyPrice) + 1, min(priceScale, sellPrice)); + + if (newCrossPrice < currentPrice && steth.balanceOf(address(arm)) >= MIN_TOTAL_SUPPLY) { + console.log("OwnerHandler.setCrossPrice() - Skipping price decrease"); + numberOfCalls["ownerHandler.setCrossPrice.skip"]++; + return; + } + + console.log("OwnerHandler.setCrossPrice(%36e)", newCrossPrice); + + // Prank owner instead of operator to bypass price check + vm.startPrank(owner); + + // Set prices + arm.setCrossPrice(newCrossPrice); + + // Stop prank + vm.stopPrank(); + } + + /// @notice Set fees for the ARM + function setFees(uint256 _seed) external { + numberOfCalls["ownerHandler.setFees"]++; + + uint256 feeAccrued = arm.feesAccrued(); + if (!enoughLiquidityAvailable(feeAccrued) || feeAccrued > weth.balanceOf(address(arm))) { + console.log("OwnerHandler.setFees() - Not enough liquidity to collect fees"); + numberOfCalls["ownerHandler.setFees.skip"]++; + return; + } + + uint256 fee = _bound(_seed, 0, maxFees); + console.log("OwnerHandler.setFees(%2e)", fee); + + // Prank owner + vm.startPrank(owner); + + // Set fees + arm.setFee(fee); + + // Stop prank + vm.stopPrank(); + + // Update sum of fees + sum_of_fees += feeAccrued; + } + + /// @notice Collect fees from the ARM + /// @dev skipped if there is not enough liquidity to collect fees + function collectFees(uint256) external { + numberOfCalls["ownerHandler.collectFees"]++; + + uint256 feeAccrued = arm.feesAccrued(); + if (!enoughLiquidityAvailable(feeAccrued) || feeAccrued > weth.balanceOf(address(arm))) { + console.log("OwnerHandler.collectFees() - Not enough liquidity to collect fees"); + numberOfCalls["ownerHandler.collectFees.skip"]++; + return; + } + + console.log("OwnerHandler.collectFees(%18e)", feeAccrued); + + // Collect fees + uint256 fees = arm.collectFees(); + require(feeAccrued == fees, "OwnerHandler.collectFees() - Fees collected do not match fees accrued"); + + // Update sum of fees + sum_of_fees += fees; + } + + //////////////////////////////////////////////////// + /// --- ACTIONS + //////////////////////////////////////////////////// + function enoughLiquidityAvailable(uint256 amount) public view returns (bool) { + // The amount of liquidity assets (WETH) that is still to be claimed in the withdrawal queue + uint256 outstandingWithdrawals = arm.withdrawsQueued() - arm.withdrawsClaimed(); + + // Save gas on an external balanceOf call if there are no outstanding withdrawals + if (outstandingWithdrawals == 0) return true; + + return amount + outstandingWithdrawals <= weth.balanceOf(address(arm)); + } +} diff --git a/test/invariants/handlers/SwapHandler.sol b/test/invariants/handlers/SwapHandler.sol new file mode 100644 index 0000000..5987b99 --- /dev/null +++ b/test/invariants/handlers/SwapHandler.sol @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {console} from "forge-std/console.sol"; + +// Handlers +import {BaseHandler} from "./BaseHandler.sol"; + +// Contracts +import {IERC20} from "contracts/Interfaces.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; + +/// @notice SwapHandler contract +/// @dev This contract is used to handle all functionnalities related to the swap in the ARM. +contract SwapHandler is BaseHandler { + //////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + //////////////////////////////////////////////////// + IERC20 public immutable weth; + IERC20 public immutable steth; + LidoARM public immutable arm; + + //////////////////////////////////////////////////// + /// --- VARIABLES + //////////////////////////////////////////////////// + address[] public swaps; // Users that perform swap + + //////////////////////////////////////////////////// + /// --- VARIABLES FOR INVARIANT ASSERTIONS + //////////////////////////////////////////////////// + uint256 public sum_of_weth_in; + uint256 public sum_of_weth_out; + uint256 public sum_of_steth_in; + uint256 public sum_of_steth_out; + + //////////////////////////////////////////////////// + /// --- EVENTS + //////////////////////////////////////////////////// + event GetAmountInMax(uint256 amount); + event GetAmountOutMax(uint256 amount); + event EstimateAmountIn(uint256 amount); + event EstimateAmountOut(uint256 amount); + + //////////////////////////////////////////////////// + /// --- CONSTRUCTOR + //////////////////////////////////////////////////// + constructor(address _arm, address _weth, address _steth, address[] memory _swaps) { + arm = LidoARM(payable(_arm)); + weth = IERC20(_weth); + steth = IERC20(_steth); + + require(_swaps.length > 0, "SH: EMPTY_SWAPS"); + swaps = _swaps; + + names[address(weth)] = "WETH"; + names[address(steth)] = "STETH"; + } + + //////////////////////////////////////////////////// + /// --- ACTIONS + //////////////////////////////////////////////////// + function swapExactTokensForTokens(uint256 _seed) external { + numberOfCalls["swapHandler.swapExact"]++; + + // Select an input token and build path + IERC20 inputToken = _seed % 2 == 0 ? weth : steth; + IERC20 outputToken = inputToken == weth ? steth : weth; + address[] memory path = new address[](2); + path[0] = address(inputToken); + path[1] = address(outputToken); + + // Select a random user thah have the input token. If no one, it will be skipped after. + address user; + uint256 len = swaps.length; + uint256 __seed = _bound(_seed, 0, type(uint256).max - len); + for (uint256 i; i < len; i++) { + user = swaps[(__seed + i) % len]; + if (inputToken.balanceOf(user) > 0) break; + } + + // Select a random amount, maximum is the minimum between the balance of the user and the liquidity available + uint256 amountIn = _bound(_seed, 0, min(inputToken.balanceOf(user), getAmountInMax(inputToken))); + uint256 estimatedAmountOut = estimateAmountOut(inputToken, amountIn); + + // Even this is possible in some case, there is not interest to swap 0 amount, so we skip it. + if (amountIn == 0) { + numberOfCalls["swapHandler.swapExact.skip"]++; + console.log("SwapHandler.swapExactTokensForTokens - Swapping 0 amount"); + return; + } + + console.log( + "SwapHandler.swapExactTokensForTokens(%18e), %s, %s", amountIn, names[user], names[address(inputToken)] + ); + + // Prank user + vm.startPrank(user); + + // Approve the ARM to spend the input token + inputToken.approve(address(arm), amountIn); + + // Swap + // Note: this implementation is prefered as it returns the amountIn of output tokens + uint256[] memory amounts = arm.swapExactTokensForTokens({ + amountIn: amountIn, + amountOutMin: estimatedAmountOut, + path: path, + to: address(user), + deadline: block.timestamp + 1 + }); + + // End prank + vm.stopPrank(); + + // Update sum of swaps + if (inputToken == weth) { + sum_of_weth_in += amounts[0]; + sum_of_steth_out += amounts[1]; + } else { + sum_of_steth_in += amounts[0]; + sum_of_weth_out += amounts[1]; + } + + require(amountIn == amounts[0], "SH: SWAP - INVALID_AMOUNT_IN"); + require(estimatedAmountOut == amounts[1], "SH: SWAP - INVALID_AMOUNT_OUT"); + } + + function swapTokensForExactTokens(uint256 _seed) external { + numberOfCalls["swapHandler.swapTokens"]++; + + // Select an input token and build path + IERC20 inputToken = _seed % 2 == 0 ? weth : steth; + IERC20 outputToken = inputToken == weth ? steth : weth; + address[] memory path = new address[](2); + path[0] = address(inputToken); + path[1] = address(outputToken); + + // Select a random user thah have the input token. If no one, it will be skipped after. + address user; + uint256 len = swaps.length; + uint256 __seed = _bound(_seed, 0, type(uint256).max - len); + for (uint256 i; i < len; i++) { + user = swaps[(__seed + i) % len]; + if (inputToken.balanceOf(user) > 0) break; + } + + // Select a random amount, maximum is the minimum between the balance of the user and the liquidity available + uint256 amountOut = _bound(_seed, 0, min(liquidityAvailable(outputToken), getAmountOutMax(outputToken, user))); + + // Even this is possible in some case, there is not interest to swap 0 amount, so we skip it. + // It could have been interesting to check it, to see what's happen if someone swap 0 and thus send 1 wei to the contract, + // but this will be tested with Donation Handler. So we skip it. + if (amountOut == 0) { + numberOfCalls["swapHandler.swapTokens.skip"]++; + console.log("SwapHandler.swapTokensForExactTokens - Swapping 0 amount"); + return; + } + + uint256 estimatedAmountIn = estimateAmountIn(outputToken, amountOut); + console.log( + "SwapHandler.swapTokensForExactTokens(%18e), %s, %s", + estimatedAmountIn, + names[user], + names[address(inputToken)] + ); + + // Prank user + vm.startPrank(user); + + // Approve the ARM to spend the input token + // Approve max, to avoid calculating the exact amount + inputToken.approve(address(arm), type(uint256).max); + + // Swap + // Note: this implementation is prefered as it returns the amountIn of output tokens + uint256[] memory amounts = arm.swapTokensForExactTokens({ + amountOut: amountOut, + amountInMax: type(uint256).max, + path: path, + to: address(user), + deadline: block.timestamp + 1 + }); + + // End prank + vm.stopPrank(); + + // Update sum of swaps + if (inputToken == weth) { + sum_of_weth_in += amounts[0]; + sum_of_steth_out += amounts[1]; + } else { + sum_of_steth_in += amounts[0]; + sum_of_weth_out += amounts[1]; + } + + require(estimatedAmountIn == amounts[0], "SH: SWAP - INVALID_AMOUNT_IN"); + require(amountOut == amounts[1], "SH: SWAP - INVALID_AMOUNT_OUT"); + } + + //////////////////////////////////////////////////// + /// --- HELPERS + //////////////////////////////////////////////////// + /// @notice Helpers to calcul the maximum amountIn of token that we can use as input in swapExactTokensForTokens. + /// @dev Depends on the reserve of the output token in ARM and the price of the input token. + function getAmountInMax(IERC20 tokenIn) public returns (uint256) { + IERC20 tokenOut = tokenIn == weth ? steth : weth; + + uint256 reserveOut = liquidityAvailable(tokenOut); + + uint256 amount = (reserveOut * arm.PRICE_SCALE()) / price(tokenIn); + + // Emit event to see it directly in logs + emit GetAmountInMax(amount); + + return amount; + } + + /// @notice Helpers to calcul the maximum amountOut of token that we can use as input in swapTokensForExactTokens. + /// @dev Depends on the reserve of the input token of user and the price of the output token. + function getAmountOutMax(IERC20 tokenOut, address user) public returns (uint256) { + IERC20 tokenIn = tokenOut == weth ? steth : weth; + + uint256 reserveUser = tokenIn.balanceOf(user); + if (reserveUser < 3) return 0; + + uint256 amount = ((reserveUser - 3) * price(tokenIn)) / arm.PRICE_SCALE(); + + // Emit event to see it directly in logs + emit GetAmountOutMax(amount); + + return amount; + } + + /// @notice Helpers to calcul the expected amountIn of tokenIn used in swapTokensForExactTokens. + function estimateAmountIn(IERC20 tokenOut, uint256 amountOut) public returns (uint256) { + IERC20 tokenIn = tokenOut == weth ? steth : weth; + + uint256 amountIn = (amountOut * arm.PRICE_SCALE()) / price(tokenIn) + 3; + + // Emit event to see it directly in logs + emit EstimateAmountIn(amountIn); + + return amountIn; + } + + /// @notice Helpers to calcul the expected amountOut of tokenOut used in swapExactTokensForTokens. + function estimateAmountOut(IERC20 tokenIn, uint256 amountIn) public returns (uint256) { + uint256 amountOut = (amountIn * price(tokenIn)) / arm.PRICE_SCALE(); + + // Emit event to see it directly in logs + emit EstimateAmountOut(amountOut); + + return amountOut; + } + + /// @notice Helpers to calcul the liquidity available for a token, especially for WETH and withdraw queue. + function liquidityAvailable(IERC20 token) public view returns (uint256 liquidity) { + if (token == weth) { + uint256 outstandingWithdrawals = arm.withdrawsQueued() - arm.withdrawsClaimed(); + uint256 reserve = weth.balanceOf(address(arm)); + if (outstandingWithdrawals > reserve) return 0; + return reserve - outstandingWithdrawals; + } else if (token == steth) { + return steth.balanceOf(address(arm)); + } + } + + /// @notice Helpers to get the price of a token in the ARM. + function price(IERC20 token) public view returns (uint256) { + return token == arm.token0() ? arm.traderate0() : arm.traderate1(); + } +} diff --git a/test/invariants/mocks/MockLidoWithdraw.sol b/test/invariants/mocks/MockLidoWithdraw.sol new file mode 100644 index 0000000..b69cfe2 --- /dev/null +++ b/test/invariants/mocks/MockLidoWithdraw.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {Vm} from "forge-std/Vm.sol"; + +// Solmate +import {ERC20} from "@solmate/tokens/ERC20.sol"; + +contract MockLidoWithdraw { + ////////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + ////////////////////////////////////////////////////// + Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + ////////////////////////////////////////////////////// + /// --- STRUCTS & ENUMS + ////////////////////////////////////////////////////// + struct Request { + bool claimed; + address owner; + uint256 amount; + } + + ////////////////////////////////////////////////////// + /// --- VARIABLES + ////////////////////////////////////////////////////// + ERC20 public steth; + + uint256 public counter; + + // Request Id -> Request struct + mapping(uint256 => Request) public requests; + + ////////////////////////////////////////////////////// + /// --- CONSTRUCTOR + ////////////////////////////////////////////////////// + constructor(address _steth) { + steth = ERC20(_steth); + } + + ////////////////////////////////////////////////////// + /// --- FUNCTIONS + ////////////////////////////////////////////////////// + function requestWithdrawals(uint256[] memory amounts, address owner) external returns (uint256[] memory) { + uint256 len = amounts.length; + uint256[] memory userRequests = new uint256[](len); + + for (uint256 i; i < len; i++) { + require(amounts[i] <= 1_000 ether, "Mock LW: Withdraw amount too big"); + + // Due to rounding error issue, we need to check balance before and after. + uint256 balBefore = steth.balanceOf(address(this)); + steth.transferFrom(msg.sender, address(this), amounts[i]); + uint256 amount = steth.balanceOf(address(this)) - balBefore; + + // Update request mapping + requests[counter] = Request({claimed: false, owner: owner, amount: amount}); + userRequests[i] = counter; + // Increase request count + counter++; + } + + return userRequests; + } + + function claimWithdrawals(uint256[] memory requestId, uint256[] memory) external { + uint256 sum; + uint256 len = requestId.length; + for (uint256 i; i < len; i++) { + // Cache id + uint256 id = requestId[i]; + + // Ensure msg.sender is the owner + require(requests[id].owner == msg.sender, "Mock LW: Not owner"); + requests[id].claimed = true; + sum += requests[id].amount; + } + + // Send sum of eth + vm.deal(address(msg.sender), address(msg.sender).balance + sum); + } + + function getLastCheckpointIndex() external returns (uint256) {} + + function findCheckpointHints(uint256[] memory, uint256, uint256) external returns (uint256[] memory) {} +} diff --git a/test/invariants/mocks/MockSTETH.sol b/test/invariants/mocks/MockSTETH.sol new file mode 100644 index 0000000..ca5f3e1 --- /dev/null +++ b/test/invariants/mocks/MockSTETH.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {Vm} from "forge-std/Vm.sol"; + +import {ERC20} from "@solmate/tokens/ERC20.sol"; + +contract MockSTETH is ERC20 { + ////////////////////////////////////////////////////// + /// --- CONSTANTS & IMMUTABLES + ////////////////////////////////////////////////////// + Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + ////////////////////////////////////////////////////// + /// --- VARIABLES + ////////////////////////////////////////////////////// + uint256 public sum_of_errors; + + ////////////////////////////////////////////////////// + /// --- CONSTRUCTOR + ////////////////////////////////////////////////////// + constructor() ERC20("Liquid staked Ether 2.0", "stETH", 18) {} + + ////////////////////////////////////////////////////// + /// --- FUNCTIONS + ////////////////////////////////////////////////////// + function transfer(address to, uint256 amount) public override returns (bool) { + return super.transfer(to, brutalizeAmount(amount)); + } + + function transferFrom(address from, address to, uint256 amount) public override returns (bool) { + return super.transferFrom(from, to, brutalizeAmount(amount)); + } + + function brutalizeAmount(uint256 amount) public returns (uint256) { + // Only brutalize the sender doesn't sent all of their balance + if (balanceOf[msg.sender] != amount && amount > 0) { + // Get a random number between 0 and 1 + uint256 randomUint = vm.randomUint(0, 1); + // If the amount is greater than the random number, subtract the random number from the amount + if (amount > randomUint) { + amount -= randomUint; + sum_of_errors += randomUint; + } + } + return amount; + } +} diff --git a/test/invariants/shared/Shared.sol b/test/invariants/shared/Shared.sol new file mode 100644 index 0000000..53a4463 --- /dev/null +++ b/test/invariants/shared/Shared.sol @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {Base_Test_} from "test/Base.sol"; + +// Contracts +import {Proxy} from "contracts/Proxy.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; +import {CapManager} from "contracts/CapManager.sol"; +import {WETH} from "@solmate/tokens/WETH.sol"; + +// Mocks +import {MockSTETH} from "../mocks/MockSTETH.sol"; +import {MockLidoWithdraw} from "../mocks/MockLidoWithdraw.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +/// @notice Shared invariant test contract +/// @dev This contract should be used for deploying all contracts and mocks needed for the test. +abstract contract Invariant_Shared_Test_ is Base_Test_ { + address[] public users; + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + + function setUp() public virtual override { + super.setUp(); + + // 1. Setup a realistic test environnement, not needed as not time related. + // _setUpRealisticEnvironnement() + + // 2. Create user + _createUsers(); + + // To increase performance, we will not use fork., mocking contract instead. + // 3. Deploy mocks. + _deployMocks(); + + // 4. Deploy contracts. + _deployContracts(); + + // 5. Label addresses + labelAll(); + } + + function _setUpRealisticEnvironnement() private { + vm.warp(1000); + vm.roll(1000); + } + + function _createUsers() private { + // Users with role + deployer = makeAddr("Deployer"); + governor = makeAddr("Governor"); + operator = makeAddr("Operator"); + feeCollector = makeAddr("Fee Collector"); + + // Random users + alice = makeAddr("Alice"); + bob = makeAddr("Bob"); + charlie = makeAddr("Charlie"); + dave = makeAddr("Dave"); + eve = makeAddr("Eve"); + frank = makeAddr("Frank"); + george = makeAddr("George"); + harry = makeAddr("Harry"); + + // Add users to the list + users.push(alice); + users.push(bob); + users.push(charlie); + users.push(dave); + users.push(eve); + users.push(frank); + users.push(george); + users.push(harry); + } + + ////////////////////////////////////////////////////// + /// --- MOCKS + ////////////////////////////////////////////////////// + function _deployMocks() private { + // WETH + weth = IERC20(address(new WETH())); + + // STETH + steth = IERC20(address(new MockSTETH())); + + // Lido Withdraw + lidoWithdraw = address(new MockLidoWithdraw(address(steth))); + } + + ////////////////////////////////////////////////////// + /// --- CONTRACTS + ////////////////////////////////////////////////////// + function _deployContracts() private { + vm.startPrank(deployer); + + // 1. Deploy all proxies. + _deployProxies(); + + // 2. Deploy Liquidity Provider Controller. + _deployLPC(); + + // 3. Deploy Lido ARM. + _deployLidoARM(); + + vm.stopPrank(); + } + + function _deployProxies() private { + lpcProxy = new Proxy(); + lidoProxy = new Proxy(); + } + + function _deployLPC() private { + // Deploy CapManager implementation. + CapManager lpcImpl = new CapManager(address(lidoProxy)); + + // Initialize Proxy with CapManager implementation. + bytes memory data = abi.encodeWithSignature("initialize(address)", operator); + lpcProxy.initialize(address(lpcImpl), address(this), data); + + // Set the Proxy as the CapManager. + capManager = CapManager(payable(address(lpcProxy))); + } + + function _deployLidoARM() private { + // Deploy LidoARM implementation. + LidoARM lidoImpl = new LidoARM(address(steth), address(weth), lidoWithdraw, 10 minutes); + + // Deployer will need WETH to initialize the ARM. + deal(address(weth), address(deployer), MIN_TOTAL_SUPPLY); + weth.approve(address(lidoProxy), MIN_TOTAL_SUPPLY); + + // Initialize Proxy with LidoARM implementation. + bytes memory data = abi.encodeWithSignature( + "initialize(string,string,address,uint256,address,address)", + "Lido ARM", + "ARM-ST", + operator, + 2000, // 20% performance fee + feeCollector, + address(lpcProxy) + ); + lidoProxy.initialize(address(lidoImpl), address(this), data); + + // Set the Proxy as the LidoARM. + lidoARM = LidoARM(payable(address(lidoProxy))); + } +}