From f2fff15c2746c0ae421a1c6bb1577051f81746b4 Mon Sep 17 00:00:00 2001 From: Nick Addison Date: Tue, 21 Jan 2025 09:30:34 +1100 Subject: [PATCH] Fix setFee when changing from a zero performance fee (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix setFee when changing from a zero performance fee * :boom: Refactor invariant setup. (#55) * Track the Lido withdrawal request amounts * Fixed fork tests with new cross price * Fix config of gas reports * Format code * :boom: Refactor invariant setup. * :sparkles: Add swap invariants. * :sparkles: Add extra invariant. * :sparkles: Add `afterInvariant` logic. * :bug: Fix not up-only shares. * :alembic: Push PoC. * :fire: Remove old code. * :zap: Adjust invariant error tolerance. * :green_heart: Fix CI. --------- Co-authored-by: Nicholas Addison --------- Co-authored-by: Clément <55331875+clement-ux@users.noreply.github.com> --- foundry.toml | 3 +- src/contracts/AbstractARM.sol | 8 +- .../LidoFixedPriceMultiLpARM/Deposit.t.sol | 165 +++++++ test/invariants/BaseInvariants.sol | 454 ------------------ test/invariants/BasicInvariants.sol | 144 ------ test/invariants/FuzzerFoundry.sol | 153 ++++++ test/invariants/Properties.sol | 189 ++++++++ .../{shared/Shared.sol => Setup.sol} | 18 +- test/invariants/TargetFunction.sol | 385 +++++++++++++++ test/invariants/Unit.sol | 18 + test/invariants/Utils.sol | 28 ++ test/invariants/handlers/BaseHandler.sol | 98 ---- .../handlers/DistributionHandler.sol | 67 --- test/invariants/handlers/DonationHandler.sol | 74 --- test/invariants/handlers/LLMHandler.sol | 158 ------ test/invariants/handlers/LpHandler.sol | 262 ---------- test/invariants/handlers/OwnerHandler.sol | 169 ------- test/invariants/handlers/SwapHandler.sol | 273 ----------- 18 files changed, 959 insertions(+), 1707 deletions(-) delete mode 100644 test/invariants/BaseInvariants.sol delete mode 100644 test/invariants/BasicInvariants.sol create mode 100644 test/invariants/FuzzerFoundry.sol create mode 100644 test/invariants/Properties.sol rename test/invariants/{shared/Shared.sol => Setup.sol} (91%) create mode 100644 test/invariants/TargetFunction.sol create mode 100644 test/invariants/Unit.sol create mode 100644 test/invariants/Utils.sol delete mode 100644 test/invariants/handlers/BaseHandler.sol delete mode 100644 test/invariants/handlers/DistributionHandler.sol delete mode 100644 test/invariants/handlers/DonationHandler.sol delete mode 100644 test/invariants/handlers/LLMHandler.sol delete mode 100644 test/invariants/handlers/LpHandler.sol delete mode 100644 test/invariants/handlers/OwnerHandler.sol delete mode 100644 test/invariants/handlers/SwapHandler.sol diff --git a/foundry.toml b/foundry.toml index 0ccc5e3..bec16bc 100644 --- a/foundry.toml +++ b/foundry.toml @@ -27,8 +27,9 @@ runs = 1_000 [invariant] runs = 256 depth = 500 -fail_on_revert = true shrink_run_limit = 5_000 +show_metrics = true +fail_on_revert = false [dependencies] "@openzeppelin-contracts" = "5.0.2" diff --git a/src/contracts/AbstractARM.sol b/src/contracts/AbstractARM.sol index 039c521..4109e61 100644 --- a/src/contracts/AbstractARM.sol +++ b/src/contracts/AbstractARM.sol @@ -689,6 +689,11 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable { // Accrue any performance fees up to this point (fees, newAvailableAssets) = _feesAccrued(); + // Save the new available assets back to storage less the collected fees. + // This needs to be done before the fees == 0 check to cover the scenario where the performance fee is zero + // and there has been an increase in assets since the last time fees were collected. + lastAvailableAssets = SafeCast.toInt128(SafeCast.toInt256(newAvailableAssets) - SafeCast.toInt256(fees)); + if (fees == 0) return 0; // Check there is enough liquidity assets (WETH) that are not reserved for the withdrawal queue @@ -700,9 +705,6 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable { // a failed WETH transfer so we spend the extra gas to check and give a meaningful error message. require(fees <= IERC20(liquidityAsset).balanceOf(address(this)), "ARM: insufficient liquidity"); - // Save the new available assets back to storage less the collected fees. - lastAvailableAssets = SafeCast.toInt128(SafeCast.toInt256(newAvailableAssets) - SafeCast.toInt256(fees)); - IERC20(liquidityAsset).transfer(feeCollector, fees); emit FeeCollected(feeCollector, fees); diff --git a/test/fork/LidoFixedPriceMultiLpARM/Deposit.t.sol b/test/fork/LidoFixedPriceMultiLpARM/Deposit.t.sol index ef0f77e..525e804 100644 --- a/test/fork/LidoFixedPriceMultiLpARM/Deposit.t.sol +++ b/test/fork/LidoFixedPriceMultiLpARM/Deposit.t.sol @@ -622,4 +622,169 @@ contract Fork_Concrete_LidoARM_Deposit_Test_ is Fork_Shared_Test_ { if (ac) assertEq(capManager.liquidityProviderCaps(address(this)), 0, "user cap"); // All the caps are used assertEqQueueMetadata(receivedAssets, 0, 1); } + + /// @notice Test the following scenario: + /// 1. ARM gain assets in WETH after small initial deposit + /// 2. User deposit liquidity + /// 3. Operator collects the performance fees + /// Check depositor hasn't lost value + function test_Deposit_WithAssetGain() + public + deal_(address(weth), address(lidoARM), 2 * MIN_TOTAL_SUPPLY) + disableCaps + { + // Assertions Before + uint256 expectedTotalSupplyBeforeDeposit = MIN_TOTAL_SUPPLY; + uint256 expectTotalAssetsBeforeDeposit = MIN_TOTAL_SUPPLY + (MIN_TOTAL_SUPPLY * 80 / 100); + uint256 assetsPerShareBefore = expectTotalAssetsBeforeDeposit * 1e18 / expectedTotalSupplyBeforeDeposit; + assertEq(lidoARM.totalSupply(), expectedTotalSupplyBeforeDeposit, "total supply before deposit"); + assertEq(lidoARM.totalAssets(), expectTotalAssetsBeforeDeposit, "total assets before deposit"); + assertEq(lidoARM.feesAccrued(), MIN_TOTAL_SUPPLY * 20 / 100, "fees accrued before deposit"); + + // shares = assets * total supply / total assets + uint256 expectShares = DEFAULT_AMOUNT * expectedTotalSupplyBeforeDeposit / expectTotalAssetsBeforeDeposit; + + // Expected events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(this), address(lidoARM), DEFAULT_AMOUNT); + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(0), address(this), expectShares); + + // Main calls + // 2. User mint shares + uint256 shares = lidoARM.deposit(DEFAULT_AMOUNT); + + assertEq(shares, expectShares, "shares after deposit"); + assertEq(lidoARM.totalAssets(), expectTotalAssetsBeforeDeposit + DEFAULT_AMOUNT, "total assets after deposit"); + assertEq(lidoARM.totalSupply(), expectedTotalSupplyBeforeDeposit + shares, "total supply after deposit"); + assertEq(lidoARM.feesAccrued(), MIN_TOTAL_SUPPLY * 20 / 100, "fees accrued after deposit"); + assertEq( + lidoARM.lastAvailableAssets(), + int256(MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT), + "last available assets after deposit" + ); + assertGe( + lidoARM.totalAssets() * 1e18 / lidoARM.totalSupply(), assetsPerShareBefore, "assets per share after deposit" + ); + assertGe(lidoARM.convertToAssets(shares), DEFAULT_AMOUNT - 1, "depositor has not lost value after deposit"); + + // 3. collect fees + lidoARM.collectFees(); + + // Assertions after collect fees + assertEq(lidoARM.totalSupply(), expectedTotalSupplyBeforeDeposit + shares, "total supply after collect fees"); + assertApproxEqRel( + lidoARM.totalAssets(), + expectTotalAssetsBeforeDeposit + DEFAULT_AMOUNT, + 1e6, + "total assets after collect fees" + ); + assertEq(lidoARM.feesAccrued(), 0, "fees accrued after collect fees"); + assertEq( + lidoARM.lastAvailableAssets(), + int256(expectTotalAssetsBeforeDeposit + DEFAULT_AMOUNT), + "last available assets after collect fees" + ); + assertGe( + lidoARM.convertToAssets(shares), DEFAULT_AMOUNT - 1, "depositor has not lost value after collected fees" + ); + } + + /// @notice Test the following scenario: + /// 1. Alice deposits 800 WETH + /// 2. Set fee to zero + /// 3. Swap 500 stETH for WETH + /// 4. Bob deposits 600 WETH + /// 5. Owner sets fee to 33% + /// Check depositor hasn't lost value + function test_Deposit_AfterSwapWithZeroFees() + public + disableCaps + /// Give 500 stETH to tester for swapping + deal_(address(steth), address(this), 600e18) + /// 1. Alice deposits 800 WETH + depositInLidoARM(address(alice), 800e18) + /// 2. Set buy, cross and sell prices + setPrices(0.998e36, 0.999e36, 1e36) + { + // Assertions Before + uint256 aliceDeposit = 800e18; + uint256 expectedTotalSupplyBeforeSwap = MIN_TOTAL_SUPPLY + aliceDeposit; + uint256 expectTotalAssetsBeforeSwap = MIN_TOTAL_SUPPLY + aliceDeposit; + uint256 assetsPerShareBeforeSwap = expectedTotalSupplyBeforeSwap * 1e18 / expectedTotalSupplyBeforeSwap; + assertEq(lidoARM.totalSupply(), expectedTotalSupplyBeforeSwap, "total supply before swap"); + assertEq(lidoARM.totalAssets(), expectTotalAssetsBeforeSwap, "total assets before swap"); + assertEq(lidoARM.feesAccrued(), 0, "fees accrued before swap"); + + // Main calls + // 2. Owner sets the fee + lidoARM.setFee(0); + + // 3. Swap 500 stETH for WETH + uint256 swapInAmount = 500e18; + lidoARM.swapExactTokensForTokens( + steth, // inToken + weth, // outToken + swapInAmount, + 0, + address(this) // to + ); + + uint256 expectedTotalSupplyBeforeDeposit = expectTotalAssetsBeforeSwap; + uint256 expectTotalAssetsBeforeDeposit = expectTotalAssetsBeforeSwap - 1 + // steth in discounted to the cross price + + ((swapInAmount * 0.999e36) / 1e36) + // weth out discounted by the buy price + - ((swapInAmount * 0.998e36) / 1e36); + assertEq(lidoARM.totalSupply(), expectedTotalSupplyBeforeDeposit, "total supply before deposit"); + assertEq(lidoARM.totalAssets(), expectTotalAssetsBeforeDeposit, "total assets before deposit"); + assertEq(lidoARM.feesAccrued(), 0, "fees accrued before swap"); + + /// 4. Bob deposits 600 WETH + uint256 bobDeposit = 600e18; + // shares = assets * total supply / total assets + uint256 expectShares = bobDeposit * expectedTotalSupplyBeforeDeposit / expectTotalAssetsBeforeDeposit; + + // Expected events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(this), address(lidoARM), bobDeposit); + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(0), address(this), expectShares); + + uint256 bobShares = lidoARM.deposit(bobDeposit); + + assertEq(bobShares, expectShares, "shares after deposit"); + assertEq(lidoARM.totalAssets(), expectTotalAssetsBeforeDeposit + bobDeposit, "total assets after deposit"); + assertEq(lidoARM.totalSupply(), expectedTotalSupplyBeforeDeposit + bobShares, "total supply after deposit"); + assertEq(lidoARM.feesAccrued(), 0, "fees accrued after deposit"); + assertEq( + lidoARM.lastAvailableAssets(), + int256(expectTotalAssetsBeforeSwap + bobDeposit), + "last available assets after deposit" + ); + assertGe( + lidoARM.totalAssets() * 1e18 / lidoARM.totalSupply(), + assetsPerShareBeforeSwap, + "assets per share after deposit" + ); + assertGe(lidoARM.convertToAssets(bobShares), bobDeposit - 1, "depositor has not lost value after deposit"); + + // 5. Owner sets fee to 33% + lidoARM.setFee(3300); + + // Assertions after collect fees + assertEq(lidoARM.totalSupply(), expectedTotalSupplyBeforeDeposit + bobShares, "total supply after collect fees"); + assertApproxEqRel( + lidoARM.totalAssets(), expectTotalAssetsBeforeDeposit + bobDeposit, 1e6, "total assets after collect fees" + ); + assertEq(lidoARM.feesAccrued(), 0, "fees accrued after collect fees"); + assertEq( + lidoARM.lastAvailableAssets(), + int256(expectTotalAssetsBeforeDeposit + bobDeposit), + "last available assets after collect fees" + ); + assertGe( + lidoARM.convertToAssets(bobShares), bobDeposit - 1, "depositor has not lost value after collected fees" + ); + } } diff --git a/test/invariants/BaseInvariants.sol b/test/invariants/BaseInvariants.sol deleted file mode 100644 index ddc377c..0000000 --- a/test/invariants/BaseInvariants.sol +++ /dev/null @@ -1,454 +0,0 @@ -// 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 deleted file mode 100644 index fbdaa46..0000000 --- a/test/invariants/BasicInvariants.sol +++ /dev/null @@ -1,144 +0,0 @@ -// 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 = 1e16; // 1% - - ////////////////////////////////////////////////////// - /// --- 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), address(lidoWithdraw)); - 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/FuzzerFoundry.sol b/test/invariants/FuzzerFoundry.sol new file mode 100644 index 0000000..a78fc92 --- /dev/null +++ b/test/invariants/FuzzerFoundry.sol @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {TargetFunction} from "test/invariants/TargetFunction.sol"; + +contract FuzzerFoundry is TargetFunction { + uint256 private constant NUM_LPS = 4; + uint256 private constant NUM_SWAPS = 3; + uint256 private constant MAX_WETH_PER_USERS = 1_000_000 ether; + uint256 private constant MAX_STETH_PER_USERS = 1_000_000 ether; + + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public 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); + + // Approve ARM for wETH + vm.prank(user); + weth.approve(address(lidoARM), type(uint256).max); + } + 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); + + // Approve ARM for stETH and wETH + vm.startPrank(user); + steth.approve(address(lidoARM), type(uint256).max); + weth.approve(address(lidoARM), type(uint256).max); + vm.stopPrank(); + } + + // --- 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); + + // --- Setup Fuzzer target --- + // Setup target + targetContract(address(this)); + + // Add selectors + bytes4[] memory selectors = new bytes4[](12); + selectors[0] = this.handler_swapExactTokensForTokens.selector; + selectors[1] = this.handler_swapTokensForExactTokens.selector; + selectors[2] = this.handler_deposit.selector; + selectors[3] = this.handler_requestRedeem.selector; + selectors[4] = this.handler_claimRedeem.selector; + selectors[5] = this.handler_requestLidoWithdrawals.selector; + selectors[6] = this.handler_claimLidoWithdrawals.selector; + selectors[7] = this.handler_setPrices.selector; + selectors[8] = this.handler_setCrossPrice.selector; + selectors[9] = this.handler_setFee.selector; + selectors[10] = this.handler_collectFees.selector; + selectors[11] = this.handler_donate.selector; + + // Target selectors + targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); + } + + ////////////////////////////////////////////////////// + /// --- INVARIANTS + ////////////////////////////////////////////////////// + function invariant_swap_A() public view { + assertTrue(property_swap_A()); + } + + function invariant_swap_B() public view { + assertTrue(property_swap_B()); + } + + function invariant_swap_C_D() public view { + assertTrue(property_swap_C()); + assertTrue(property_swap_D()); + } + + function invariant_lp_A() public view { + assertTrue(property_lp_A()); + } + + function invariant_lp_B() public view { + assertTrue(property_lp_B()); + } + + function invariant_lp_C() public view { + assertTrue(property_lp_C()); + } + + function invariant_lp_D_E() public view { + assertTrue(property_lp_D()); + assertTrue(property_lp_E()); + } + + function invariant_lp_F() public view { + assertTrue(property_lp_F()); + } + + function invariant_lp_G() public view { + assertTrue(property_lp_G()); + } + + function invariant_lp_H() public view { + assertTrue(property_lp_H()); + } + + function invariant_lp_I() public view { + assertTrue(property_lp_I()); + } + + function invariant_lp_J() public view { + assertTrue(property_lp_invariant_J()); + } + + function invariant_lp_K() public view { + assertTrue(property_lp_invariant_K()); + } + + function invariant_lp_M() public view { + assertTrue(property_lp_invariant_M()); + } + + function invariant_llm_A() public view { + assertTrue(property_llm_A()); + } + + function afterInvariant() public { + finalizeLidoClaims(); + sweepAllStETH(); + finalizeUserClaims(); + assertTrue(ensureSharesAreUpOnly(MAX_WETH_PER_USERS), "Shares are not up only"); + } +} diff --git a/test/invariants/Properties.sol b/test/invariants/Properties.sol new file mode 100644 index 0000000..20f6bbf --- /dev/null +++ b/test/invariants/Properties.sol @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +// Test imports +import {Utils} from "./Utils.sol"; +import {Setup} from "./Setup.sol"; + +abstract contract Properties is Setup, Utils { + //////////////////////////////////////////////////// + /// --- GHOSTS + //////////////////////////////////////////////////// + uint256 sum_weth_fees; + uint256 sum_weth_swap_in; + uint256 sum_weth_swap_out; + uint256 sum_weth_deposit; + uint256 sum_weth_request; + uint256 sum_weth_withdraw; + uint256 sum_weth_donated; + uint256 sum_weth_lido_redeem; + uint256 sum_steth_lido_requested; + uint256 sum_steth_swap_out; + uint256 sum_steth_swap_in; + uint256 sum_steth_donated; + uint256 ghost_requestCounter; + bool ghost_swap_C = true; + bool ghost_swap_D = true; + bool ghost_lp_D = true; + bool ghost_lp_E = true; + bool ghost_lp_K = true; + + //////////////////////////////////////////////////// + /// --- PROPERTIES + //////////////////////////////////////////////////// + + // --- Swap properties --- + // Invariant A: weth balance == ∑deposit + ∑wethIn + ∑wethRedeem + ∑wethDonated - ∑withdraw - ∑wethOut - ∑feesCollected + // Invariant B: steth balance >= ∑stethIn + ∑stethDonated - ∑stethOut - ∑stethRedeem + // Invariant C: when swap => AmountIn == amounts[0] + // Invariant D: when swap => AmountOut == amounts[1] + + // --- Liquidity Provider properties --- + // 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) + // Invariant E: previewDeposit(amount) == uint256 shares = previewDeposit(amount) + // 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 + // Invariant M: ∑feesCollected == feeCollector.balance + + // --- Lido Liquidity Management properties --- + // Invariant A: lidoWithdrawalQueueAmount == ∑lidoRequestRedeem.assets + // Invariant B: address(arm).balance == 0 + + //////////////////////////////////////////////////// + /// --- SWAPS + //////////////////////////////////////////////////// + function property_swap_A() public view returns (bool) { + uint256 inflows = sum_weth_deposit + sum_weth_swap_in + sum_weth_lido_redeem + sum_weth_donated; + uint256 outflows = sum_weth_swap_out + sum_weth_withdraw + sum_weth_fees; + + return eq(weth.balanceOf(address(lidoARM)), MIN_TOTAL_SUPPLY + inflows - outflows); + } + + function property_swap_B() public view returns (bool) { + uint256 inflows = sum_steth_donated + sum_steth_swap_in; + uint256 outflows = sum_steth_swap_out + sum_steth_lido_requested; + + return eq(steth.balanceOf(address(lidoARM)), inflows - outflows); + } + + function property_swap_C() public view returns (bool) { + return ghost_swap_C; + } + + function property_swap_D() public view returns (bool) { + return ghost_swap_D; + } + + //////////////////////////////////////////////////// + /// --- LIQUIDITY PROVIDERS + //////////////////////////////////////////////////// + function property_lp_A() public view returns (bool) { + return gt(lidoARM.totalSupply(), 0); + } + + function property_lp_B() public view returns (bool) { + return eq(lidoARM.totalSupply(), sumOfUserShares()); + } + + function property_lp_C() public view returns (bool) { + return eq(lidoARM.previewRedeem(sumOfUserShares()), lidoARM.totalAssets()); + } + + function property_lp_D() public view returns (bool) { + return ghost_lp_D; + } + + function property_lp_E() public view returns (bool) { + return ghost_lp_E; + } + + function property_lp_F() public view returns (bool) { + return eq(lidoARM.nextWithdrawalIndex(), ghost_requestCounter); + } + + function property_lp_G() public view returns (bool) { + return eq(lidoARM.withdrawsQueued(), sum_weth_request); + } + + function property_lp_H() public view returns (bool) { + return gte(lidoARM.withdrawsQueued(), lidoARM.withdrawsClaimed()); + } + + function property_lp_I() public view returns (bool) { + uint256 sum; + uint256 nextWithdrawalIndex = lidoARM.nextWithdrawalIndex(); + for (uint256 i; i < nextWithdrawalIndex; i++) { + (,,, uint128 assets,) = lidoARM.withdrawalRequests(i); + sum += assets; + } + + return eq(lidoARM.withdrawsQueued(), sum); + } + + function property_lp_invariant_J() public view returns (bool) { + return eq(lidoARM.withdrawsClaimed(), sum_weth_withdraw); + } + + function property_lp_invariant_K() public view returns (bool) { + uint256 nextWithdrawalIndex = lidoARM.nextWithdrawalIndex(); + for (uint256 i; i < nextWithdrawalIndex; i++) { + (,,, uint128 assets, uint128 queued) = lidoARM.withdrawalRequests(i); + if (queued < assets) return false; + } + + return true; + } + + function property_lp_invariant_M() public view returns (bool) { + address feeCollector = lidoARM.feeCollector(); + return eq(weth.balanceOf(feeCollector), sum_weth_fees); + } + + //////////////////////////////////////////////////// + /// --- LIDO LIQUIDITY MANAGMENT + //////////////////////////////////////////////////// + function property_llm_A() public view returns (bool) { + return eq(lidoARM.lidoWithdrawalQueueAmount(), sum_steth_lido_requested - sum_weth_lido_redeem); + } + + function property_llm_B() public view returns (bool) { + return eq(address(lidoARM).balance, 0); + } + + //////////////////////////////////////////////////// + /// --- HELPERS + //////////////////////////////////////////////////// + function estimateAmountIn(IERC20 tokenOut, uint256 amountOut) public view returns (uint256) { + return (amountOut * lidoARM.PRICE_SCALE()) / price(tokenOut == weth ? steth : weth) + 3; + } + + function estimateAmountOut(IERC20 tokenIn, uint256 amountIn) public view returns (uint256) { + return (amountIn * price(tokenIn)) / lidoARM.PRICE_SCALE(); + } + + function price(IERC20 token) public view returns (uint256) { + return token == lidoARM.token0() ? lidoARM.traderate0() : lidoARM.traderate1(); + } + + function sumOfUserShares() public view returns (uint256) { + uint256 sum_shares; + + for (uint256 i; i < lps.length; i++) { + sum_shares += lidoARM.balanceOf(lps[i]); + } + + sum_shares += lidoARM.balanceOf(address(0xdEaD)); + + return sum_shares; + } +} diff --git a/test/invariants/shared/Shared.sol b/test/invariants/Setup.sol similarity index 91% rename from test/invariants/shared/Shared.sol rename to test/invariants/Setup.sol index 53a4463..437e53c 100644 --- a/test/invariants/shared/Shared.sol +++ b/test/invariants/Setup.sol @@ -11,20 +11,22 @@ 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"; +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_ { +abstract contract Setup is Base_Test_ { address[] public users; + address[] public lps; + address[] public swaps; + ////////////////////////////////////////////////////// /// --- SETUP ////////////////////////////////////////////////////// - function setUp() public virtual override { super.setUp(); @@ -150,4 +152,12 @@ abstract contract Invariant_Shared_Test_ is Base_Test_ { // Set the Proxy as the LidoARM. lidoARM = LidoARM(payable(address(lidoProxy))); } + + function min(uint256 a, uint256 b) public pure returns (uint256) { + return a < b ? a : b; + } + + function max(uint256 a, uint256 b) public pure returns (uint256) { + return a > b ? a : b; + } } diff --git a/test/invariants/TargetFunction.sol b/test/invariants/TargetFunction.sol new file mode 100644 index 0000000..ddea2c4 --- /dev/null +++ b/test/invariants/TargetFunction.sol @@ -0,0 +1,385 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +// Test imports +import {Properties} from "test/invariants/Properties.sol"; + +abstract contract TargetFunction is Properties { + //////////////////////////////////////////////////// + /// --- SWAPS + //////////////////////////////////////////////////// + function handler_swapExactTokensForTokens(uint8 account, bool stETHForWETH, uint80 amount) public { + address[] memory path = new address[](2); + path[0] = stETHForWETH ? address(steth) : address(weth); + path[1] = stETHForWETH ? address(weth) : address(steth); + + // Select a random user + address user = swaps[account % swaps.length]; + + // Cache estimated amount out + uint256 estimatedAmountOut = estimateAmountOut(IERC20(path[0]), amount); + + // Prank the user + vm.prank(user); + uint256[] memory amounts = lidoARM.swapExactTokensForTokens({ + amountIn: amount, + amountOutMin: 0, + path: path, + to: address(user), + deadline: block.timestamp + }); + + // Update ghost + ghost_swap_C = amounts[0] == amount; + ghost_swap_D = amounts[1] == estimatedAmountOut; + stETHForWETH ? sum_steth_swap_in += amounts[0] : sum_weth_swap_in += amounts[0]; + stETHForWETH ? sum_weth_swap_out += amounts[1] : sum_steth_swap_out += amounts[1]; + } + + function handler_swapTokensForExactTokens(uint8 account, bool stETHForWETH, uint80 amount) public { + address[] memory path = new address[](2); + path[0] = stETHForWETH ? address(steth) : address(weth); + path[1] = stETHForWETH ? address(weth) : address(steth); + + // Select a random user + address user = swaps[account % swaps.length]; + + // Cache estimated amount in + uint256 estimatedAmountIn = estimateAmountIn(IERC20(path[1]), amount); + + // Prank the user + vm.prank(user); + uint256[] memory amounts = lidoARM.swapTokensForExactTokens({ + amountOut: amount, + amountInMax: type(uint256).max, + path: path, + to: address(user), + deadline: block.timestamp + }); + + // Update ghost + ghost_swap_C = amounts[0] == estimatedAmountIn; + ghost_swap_D = amounts[1] == amount; + stETHForWETH ? sum_steth_swap_in += amounts[0] : sum_weth_swap_in += amounts[0]; + stETHForWETH ? sum_weth_swap_out += amounts[1] : sum_steth_swap_out += amounts[1]; + } + + //////////////////////////////////////////////////// + /// --- LIQUIDITY PROVIDERS + //////////////////////////////////////////////////// + mapping(address => uint256[]) public requests; + + function handler_deposit(uint8 account, uint80 amount) public { + // Select a random user + address user = lps[account % lps.length]; + + // Cache preview deposit + uint256 expectedShares = lidoARM.previewDeposit(amount); + + // Prank the user + vm.prank(user); + uint256 shares = lidoARM.deposit(amount); + + // Update ghost + sum_weth_deposit += amount; + ghost_lp_D = shares == expectedShares; + } + + function handler_requestRedeem(uint8 account, uint80 shares) public { + address user; + uint256 len = lps.length; + // Select a random user with non-zero shares + for (uint256 i = account; i < account + len; i++) { + address user_ = lps[i % len]; + if (lidoARM.balanceOf(user_) > 0) { + user = user_; + break; + } + } + + if (user == address(0)) { + return; + } + + // Cache preview redeem + uint256 expectedAmount = lidoARM.previewRedeem(shares); + + // Prank the user + vm.prank(user); + + // Request redeem + (uint256 id, uint256 amount) = lidoARM.requestRedeem(shares); + + // Update state + requests[user].push(id); + sum_weth_request += amount; + ghost_lp_E = amount == expectedAmount; + ghost_requestCounter++; + } + + function handler_claimRedeem(uint8 account, uint256 id) public { + address user; + uint256 requestId; + uint256 len = lps.length; + // Select a random user with a request + for (uint256 i = account; i < account + len; i++) { + address user_ = lps[i % len]; + uint256 requestCount = requests[user_].length; + if (requestCount > 0) { + user = user_; + requestId = id % requestCount; + break; + } + } + + // Timejump to request deadline + skip(lidoARM.claimDelay()); + + // Prank the user + vm.prank(user); + + // Claim redeem + uint256 amount = lidoARM.claimRedeem(requestId); + + // Jump back to current time, to avoid issues with other tests + rewind(lidoARM.claimDelay()); + + // Update state + for (uint256 i = 0; i < requests[user].length; i++) { + // Get position of the requestId in the array + if (requests[user][i] == requestId) { + // Remove it from the list + requests[user][i] = requests[user][requests[user].length - 1]; + requests[user].pop(); + break; + } + } + + // Update ghost + sum_weth_withdraw += amount; + } + + //////////////////////////////////////////////////// + /// --- LIDO LIQUIDITY MANAGMENT + //////////////////////////////////////////////////// + uint256 constant MAX_BATCH_SIZE = 1_000 ether; + uint256[] public lidoWithdrawRequests; + + function handler_requestLidoWithdrawals(uint80 amount) public { + // Split the amount into 1k chunks + uint256 batch = (amount + MAX_BATCH_SIZE - 1) / MAX_BATCH_SIZE; // Rounded up + uint256[] memory amounts = new uint256[](batch); + uint256 totalAmount = amount; + for (uint256 i = 0; i < batch; i++) { + if (totalAmount > MAX_BATCH_SIZE) { + amounts[i] = MAX_BATCH_SIZE; + totalAmount -= MAX_BATCH_SIZE; + } else { + amounts[i] = totalAmount; + totalAmount = 0; + } + } + + // Prank Owner + vm.prank(lidoARM.owner()); + uint256[] memory newLidoWithdrawRequests = lidoARM.requestLidoWithdrawals(amounts); + + // Update state + for (uint256 i = 0; i < newLidoWithdrawRequests.length; i++) { + lidoWithdrawRequests.push(newLidoWithdrawRequests[i]); + } + + // Update ghost + sum_steth_lido_requested += amount; + } + + function handler_claimLidoWithdrawals(uint256 requestToClaimCount) public { + uint256 len = lidoWithdrawRequests.length; + requestToClaimCount = requestToClaimCount % len; + + // Select lidoWithdrawRequests + uint256[] memory requestToClaim = new uint256[](requestToClaimCount); + for (uint256 i; i < requestToClaimCount; i++) { + requestToClaim[i] = lidoWithdrawRequests[i]; + } + + // As `claimLidoWithdrawals` doesn't send back the amount, we need to calculate it + uint256 outstandingBefore = lidoARM.lidoWithdrawalQueueAmount(); + + // Prank Owner + vm.prank(lidoARM.owner()); + lidoARM.claimLidoWithdrawals(requestToClaim, new uint256[](0)); + + uint256 outstandingAfter = lidoARM.lidoWithdrawalQueueAmount(); + uint256 diff = outstandingBefore - outstandingAfter; + + // Remove it from the list + uint256[] memory newLidoWithdrawRequests = new uint256[](len - requestToClaimCount); + for (uint256 i = requestToClaimCount; i < len; i++) { + newLidoWithdrawRequests[i - requestToClaimCount] = lidoWithdrawRequests[i]; + } + lidoWithdrawRequests = newLidoWithdrawRequests; + + // Update ghost + sum_weth_lido_redeem += diff; + } + + //////////////////////////////////////////////////// + /// --- PRICES AND FEES MANAGEMENT + //////////////////////////////////////////////////// + uint256 constant MIN_FEES = 0; + uint256 constant MAX_FEES = 5000; + uint256 constant MIN_BUY_T1 = 0.98 * 1e36; + uint256 constant MAX_SELL_T1 = 1.02 * 1e36; + + function handler_setPrices(uint256 buyT1, uint256 sellT1) public { + uint256 crossPrice = lidoARM.crossPrice(); + + // Bound prices + buyT1 = _bound(buyT1, MIN_BUY_T1, crossPrice - 1); + sellT1 = _bound(sellT1, crossPrice, MAX_SELL_T1); + + // Prank owner + vm.prank(lidoARM.owner()); + + // Set prices + lidoARM.setPrices(buyT1, sellT1); + } + + function handler_setCrossPrice(uint256 newCrossPrice) public { + uint256 priceScale = lidoARM.PRICE_SCALE(); + + // Bound new cross price + uint256 sell = priceScale ** 2 / lidoARM.traderate0(); + uint256 buy = lidoARM.traderate1(); + newCrossPrice = + _bound(newCrossPrice, max(priceScale - lidoARM.MAX_CROSS_PRICE_DEVIATION(), buy) + 1, min(priceScale, sell)); + + // Prank owner + vm.prank(lidoARM.owner()); + + // Set cross price + lidoARM.setCrossPrice(newCrossPrice); + } + + function handler_setFee(uint256 performanceFee) public { + performanceFee = _bound(performanceFee, MIN_FEES, MAX_FEES); + + // Cache accrued fees before setting new fee + uint256 accumulatedFees = lidoARM.feesAccrued(); + + // Prank owner + vm.prank(lidoARM.owner()); + + // Set fees + lidoARM.setFee(performanceFee); + + // Update ghost + sum_weth_fees += accumulatedFees; + } + + function handler_collectFees() public { + // Prank owner + vm.prank(lidoARM.owner()); + + // Collect fees + uint256 collectedFees = lidoARM.collectFees(); + + // Update ghost + sum_weth_fees += collectedFees; + } + + function handler_totalAsset() public view returns (uint256) { + return lidoARM.totalAssets(); + } + + function handler_feeAccrued() public view returns (uint256) { + return lidoARM.feesAccrued(); + } + + function handler_lastAvailableAsset() public view returns (int128) { + return lidoARM.lastAvailableAssets(); + } + + //////////////////////////////////////////////////// + /// --- DONATION + //////////////////////////////////////////////////// + uint256 constant DONATION_PROBABILITY = 10; + uint256 constant DONATION_THRESHOLD = 1e20; + + function handler_donate(bool stETH, uint64 amount, uint256 probability) public { + // Reduce probability to 10% + vm.assume(probability % DONATION_PROBABILITY == 0 && lidoARM.totalSupply() > DONATION_THRESHOLD); + + IERC20 token = stETH ? IERC20(address(steth)) : IERC20(address(weth)); + + deal(address(token), address(this), amount); + + token.transfer(address(lidoARM), amount); + + // Update ghost + stETH ? sum_steth_donated += amount : sum_weth_donated += amount; + } + + //////////////////////////////////////////////////// + /// --- HELPERS + //////////////////////////////////////////////////// + uint256 constant SHARES_UP_ONLY__ERROR_TOLERANCE = 1e4; + + function finalizeLidoClaims() public { + if (lidoWithdrawRequests.length == 0) return; + + // Prank Owner + vm.prank(lidoARM.owner()); + lidoARM.claimLidoWithdrawals(lidoWithdrawRequests, new uint256[](0)); + + require(lidoARM.lidoWithdrawalQueueAmount() == 0, "FINALIZE_FAILED"); + } + + function sweepAllStETH() public { + deal(address(weth), address(this), 1e30); + weth.approve(address(lidoARM), type(uint256).max); + lidoARM.swapTokensForExactTokens( + weth, steth, steth.balanceOf(address(lidoARM)), type(uint256).max, address(this) + ); + require(steth.balanceOf(address(lidoARM)) == 0, "SWEEP_FAILED"); + } + + function finalizeUserClaims() public { + // Timejump to request deadline + skip(lidoARM.claimDelay()); + + // Loop on all LPs + for (uint256 i; i < lps.length; i++) { + address user = lps[i]; + uint256 requestCount = requests[user].length; + + // Prank LP + vm.startPrank(user); + + // Loop on all requests && Claim redeem + for (uint256 j; j < requestCount; j++) { + lidoARM.claimRedeem(requests[user][j]); + } + + vm.stopPrank(); + } + + // No need to jump back to current time, as we are done with the test + } + + function ensureSharesAreUpOnly(uint256 initialBalance) public view returns (bool) { + for (uint256 i; i < lps.length; i++) { + address user = lps[i]; + uint256 shares = lidoARM.balanceOf(user); + uint256 sum = weth.balanceOf(user) + lidoARM.previewRedeem(shares); + if (!gte(sum * (1e18 + SHARES_UP_ONLY__ERROR_TOLERANCE) / 1e18, initialBalance)) { + return false; + } + } + return true; + } +} diff --git a/test/invariants/Unit.sol b/test/invariants/Unit.sol new file mode 100644 index 0000000..27a7e15 --- /dev/null +++ b/test/invariants/Unit.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {Test} from "forge-std/Test.sol"; +import {FuzzerFoundry} from "test/invariants/FuzzerFoundry.sol"; + +contract Unit is Test { + FuzzerFoundry f; + + function setUp() public { + f = new FuzzerFoundry(); + f.setUp(); + } + + function test_unit() public { + // Use this template to replicate failing scenarios from invariant. + } +} diff --git a/test/invariants/Utils.sol b/test/invariants/Utils.sol new file mode 100644 index 0000000..93a3c6f --- /dev/null +++ b/test/invariants/Utils.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +abstract contract Utils { + function eq(uint256 a, uint256 b) internal pure returns (bool) { + return a == b; + } + + function gt(uint256 a, uint256 b) internal pure returns (bool) { + return a > b; + } + + function gte(uint256 a, uint256 b) internal pure returns (bool) { + return a >= b; + } + + function lt(uint256 a, uint256 b) internal pure returns (bool) { + return a < b; + } + + function lte(uint256 a, uint256 b) internal pure returns (bool) { + return a <= b; + } + + function approxEqAbs(uint256 a, uint256 b, uint256 epsilon) internal pure returns (bool) { + return a > b ? a - b <= epsilon : b - a <= epsilon; + } +} diff --git a/test/invariants/handlers/BaseHandler.sol b/test/invariants/handlers/BaseHandler.sol deleted file mode 100644 index 8714990..0000000 --- a/test/invariants/handlers/BaseHandler.sol +++ /dev/null @@ -1,98 +0,0 @@ -// 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 deleted file mode 100644 index ded559c..0000000 --- a/test/invariants/handlers/DistributionHandler.sol +++ /dev/null @@ -1,67 +0,0 @@ -// 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 deleted file mode 100644 index f689412..0000000 --- a/test/invariants/handlers/DonationHandler.sol +++ /dev/null @@ -1,74 +0,0 @@ -// 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 deleted file mode 100644 index 536d16f..0000000 --- a/test/invariants/handlers/LLMHandler.sol +++ /dev/null @@ -1,158 +0,0 @@ -// 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"; -import {IStETHWithdrawal} from "contracts/Interfaces.sol"; - -/// @notice LidoLiquidityManager Handler contract -/// @dev This contract is used to handle all functionalities 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; - IStETHWithdrawal public stETHWithdrawal; - 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, address _lidoWithdrawalQueue) { - arm = LidoARM(payable(_arm)); - owner = arm.owner(); - stETHWithdrawal = IStETHWithdrawal(_lidoWithdrawalQueue); - 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) / MAX_AMOUNT); - 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 - uint256 lastIndex = stETHWithdrawal.getLastCheckpointIndex(); - uint256[] memory hintIds = stETHWithdrawal.findCheckpointHints(requestIds_, 1, lastIndex); - arm.claimLidoWithdrawals(requestIds_, hintIds); - - // 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 - uint256 lastIndex = stETHWithdrawal.getLastCheckpointIndex(); - uint256[] memory hintIds = stETHWithdrawal.findCheckpointHints(requestIds, 1, lastIndex); - arm.claimLidoWithdrawals(requestIds, hintIds); - - // 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 deleted file mode 100644 index 095cfb4..0000000 --- a/test/invariants/handlers/LpHandler.sol +++ /dev/null @@ -1,262 +0,0 @@ -// 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 deleted file mode 100644 index d392dcd..0000000 --- a/test/invariants/handlers/OwnerHandler.sol +++ /dev/null @@ -1,169 +0,0 @@ -// 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 deleted file mode 100644 index 5987b99..0000000 --- a/test/invariants/handlers/SwapHandler.sol +++ /dev/null @@ -1,273 +0,0 @@ -// 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(); - } -}