diff --git a/src/contracts/Interfaces.sol b/src/contracts/Interfaces.sol index d013642c..13c75b7a 100644 --- a/src/contracts/Interfaces.sol +++ b/src/contracts/Interfaces.sol @@ -357,4 +357,10 @@ interface IStakedUSDe is IERC4626 { function unstake(address receiver) external; function cooldowns(address receiver) external view returns (UserCooldown memory); + + function getUnvestedAmount() external view returns (uint256); + + function lastDistributionTimestamp() external view returns (uint256); + + function transferInRewards(uint256 amount) external; } diff --git a/test/invariants/EthenaARM/Base.sol b/test/invariants/EthenaARM/Base.sol new file mode 100644 index 00000000..3612dc39 --- /dev/null +++ b/test/invariants/EthenaARM/Base.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Contracts +import {Proxy} from "contracts/Proxy.sol"; +import {EthenaARM} from "contracts/EthenaARM.sol"; +import {MockMorpho} from "test/invariants/EthenaARM/mocks/MockMorpho.sol"; +import {MorphoMarket} from "src/contracts/markets/MorphoMarket.sol"; +import {EthenaUnstaker} from "contracts/EthenaUnstaker.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; +import {IStakedUSDe} from "contracts/Interfaces.sol"; + +// Tests +import {Vm} from "./helpers/Vm.sol"; + +/// @notice This contract should be the common parent for all test contracts. +/// It should be used to define common variables and that will be +/// used across all test contracts. This pattern is used to allow different +/// test contracts to share common variables, and ensure a consistent setup. +/// @dev This contract should be inherited by "Shared" contracts. +/// @dev This contract should only be used as storage for common variables. +/// @dev Helpers and other functions should be defined in a separate contract. +abstract contract Base_Test_ { + ////////////////////////////////////////////////////// + /// --- CONTRACTS + ////////////////////////////////////////////////////// + // --- Main contracts --- + Proxy public armProxy; + Proxy public morphoMarketProxy; + EthenaARM public arm; + MockMorpho public morpho; + MorphoMarket public market; + EthenaUnstaker[] public unstakers; + uint256[] public unstakerIndices; + + // --- Tokens --- + IERC20 public usde; + IStakedUSDe public susde; + + // --- Utils --- + Vm public vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + ////////////////////////////////////////////////////// + /// --- USERS + ////////////////////////////////////////////////////// + // --- Users with roles --- + address public deployer; + address public governor; + address public operator; + address public treasury; + + // --- Regular users --- + address public alice; + address public bobby; + address public carol; + address public david; + address public elise; + address public frank; + address public grace; + address public harry; + address public dead; + + // --- Group of users --- + address[] public makers; + address[] public traders; + mapping(address => uint256[]) public pendingRequests; + + ////////////////////////////////////////////////////// + /// --- DEFAULT VALUES + ////////////////////////////////////////////////////// + uint256 public constant MAKERS_COUNT = 3; + uint256 public constant TRADERS_COUNT = 3; + uint256 public constant UNSTAKERS_COUNT = 42; + uint256 public constant DEFAULT_CLAIM_DELAY = 10 minutes; + uint256 public constant DEFAULT_MIN_TOTAL_SUPPLY = 1e12; + uint256 public constant DEFAULT_ALLOCATE_THRESHOLD = 1e18; + uint256 public constant DEFAULT_MIN_SHARES_TO_REDEEM = 1e7; + + /// @notice Indicates if labels have been set in the Vm. + function isLabelAvailable() external view virtual returns (bool); + function isAssumeAvailable() external view virtual returns (bool); + function isConsoleAvailable() external view virtual returns (bool); + + ////////////////////////////////////////////////////// + /// --- GHOST VALUES + ////////////////////////////////////////////////////// + // --- USDe values --- + uint256 public sumUSDeSwapIn; + uint256 public sumUSDeSwapOut; + uint256 public sumUSDeUserDeposit; + uint256 public sumUSDeUserRedeem; + uint256 public sumUSDeUserRequest; + uint256 public sumUSDeBaseRedeem; + uint256 public sumUSDeFeesCollected; + uint256 public sumUSDeMarketDeposit; + uint256 public sumUSDeMarketWithdraw; + // --- sUSDe values --- + uint256 public sumSUSDeSwapIn; + uint256 public sumSUSDeSwapOut; + uint256 public sumSUSDeBaseRedeem; +} + diff --git a/test/invariants/EthenaARM/FoundryFuzzer.sol b/test/invariants/EthenaARM/FoundryFuzzer.sol new file mode 100644 index 00000000..b9968794 --- /dev/null +++ b/test/invariants/EthenaARM/FoundryFuzzer.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {Properties} from "./Properties.sol"; +import {StdInvariant} from "forge-std/StdInvariant.sol"; +import {StdAssertions} from "forge-std/StdAssertions.sol"; + +/// @title FuzzerFoundry +/// @notice Concrete fuzzing contract implementing Foundry's invariant testing framework. +/// @dev This contract configures and executes property-based testing: +/// - Inherits from Properties to access handler functions and properties +/// - Configures fuzzer targeting (contracts, selectors, senders) +/// - Implements invariant test functions that call property validators +/// - Each invariant function represents a critical system property to maintain +/// - Fuzzer will call targeted handlers randomly and check invariants after each call +contract FuzzerFoundry_EthenaARM is Properties, StdInvariant, StdAssertions { + bool public constant override isLabelAvailable = true; + bool public constant override isAssumeAvailable = true; + bool public constant override isConsoleAvailable = true; + + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + + // --- Setup Fuzzer target --- + // Setup target + targetContract(address(this)); + + // Add selectors + bytes4[] memory selectors = new bytes4[](22); + // --- sUSDe --- + selectors[0] = this.targetSUSDeDeposit.selector; + selectors[1] = this.targetSUSDeCooldownShares.selector; + selectors[2] = this.targetSUSDeUnstake.selector; + selectors[3] = this.targetSUSDeTransferInRewards.selector; + // --- Morpho --- + selectors[4] = this.targetMorphoDeposit.selector; + selectors[5] = this.targetMorphoWithdraw.selector; + selectors[6] = this.targetMorphoTransferInRewards.selector; + selectors[7] = this.targetMorphoSetUtilizationRate.selector; + // --- ARM --- + selectors[8] = this.targetARMDeposit.selector; + selectors[9] = this.targetARMRequestRedeem.selector; + selectors[10] = this.targetARMClaimRedeem.selector; + selectors[11] = this.targetARMSetARMBuffer.selector; + selectors[12] = this.targetARMSetActiveMarket.selector; + selectors[13] = this.targetARMAllocate.selector; + selectors[14] = this.targetARMSetPrices.selector; + selectors[15] = this.targetARMSetCrossPrice.selector; + selectors[16] = this.targetARMSwapExactTokensForTokens.selector; + selectors[17] = this.targetARMSwapTokensForExactTokens.selector; + selectors[18] = this.targetARMCollectFees.selector; + selectors[19] = this.targetARMSetFees.selector; + selectors[20] = this.targetARMRequestBaseWithdrawal.selector; + selectors[21] = this.targetARMClaimBaseWithdrawals.selector; + // Target selectors + targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); + } + + ////////////////////////////////////////////////////// + /// --- INVARIANTS + ////////////////////////////////////////////////////// + function invariantA() public view { + assertTrue(propertyA(), "Property A failed"); + assertTrue(propertyB(), "Property B failed"); + assertTrue(propertyC(), "Property C failed"); + assertTrue(propertyD(), "Property D failed"); + assertTrue(propertyE(), "Property E failed"); + assertTrue(propertyF(), "Property F failed"); + assertTrue(propertyG(), "Property G failed"); + assertTrue(propertyH(), "Property H failed"); + assertTrue(propertyI(), "Property I failed"); + } +} diff --git a/test/invariants/EthenaARM/Properties.sol b/test/invariants/EthenaARM/Properties.sol new file mode 100644 index 00000000..89f4eea9 --- /dev/null +++ b/test/invariants/EthenaARM/Properties.sol @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {console} from "forge-std/console.sol"; + +// Test imports +import {TargetFunctions} from "./TargetFunctions.sol"; + +// Helpers +import {Math} from "./helpers/Math.sol"; + +/// @title Properties +/// @notice Abstract contract defining invariant properties for formal verification and fuzzing. +/// @dev This contract contains pure property functions that express system invariants: +/// - Properties must be implemented as view/pure functions returning bool +/// - Each property should represent a mathematical invariant of the system +/// - Properties should be stateless and deterministic +/// - Property names should clearly indicate what invariant they check +/// Usage: Properties are called by fuzzing contracts to validate system state +abstract contract Properties is TargetFunctions { + // ╔══════════════════════════════════════════════════════════════════════════════╗ + // ║ ✦✦✦ SWAP PROPERTIES ✦✦✦ ║ + // ╚══════════════════════════════════════════════════════════════════════════════╝ + // [ ] Invariant A: USDe balance == ∑swapIn - ∑swapOut + // + ∑userDeposit - ∑userWithdraw + // + ∑marketWithdraw - ∑marketDeposit + // + ∑baseRedeem - ∑feesCollected + // [ ] Invariant B: sUSDe balance == (∑swapIn - ∑swapOut) - ∑baseRedeem + // + // ╔══════════════════════════════════════════════════════════════════════════════╗ + // ║ ✦✦✦ LP PROPERTIES ✦✦✦ ║ + // ╚══════════════════════════════════════════════════════════════════════════════╝ + // [x] Invariant C: ∑shares > 0 due to initial deposit + // [x] Invariant D: totalShares == ∑userShares + deadShares + // [x] Invariant E: previewRedeem(∑shares) == totalAssets + // [x] Invariant F: withdrawsQueued == ∑requestRedeem.amount + // [x] Invariant G: withdrawsQueued > withdrawsClaimed + // [x] Invariant H: withdrawsQueued == ∑request.assets + // [x] Invariant I: withdrawsClaimed == ∑claimRedeem.amount + // [x] Invariant J: ∀ requestId, request.queued >= request.assets + // [x] Invariant K: ∑feesCollected == feeCollector.balance + + function propertyA() public view returns (bool) { + uint256 usdeBalance = usde.balanceOf(address(arm)); + uint256 inflow = 1e12 + sumUSDeSwapIn + sumUSDeUserDeposit + sumUSDeMarketWithdraw + sumUSDeBaseRedeem; + uint256 outflow = sumUSDeSwapOut + sumUSDeUserRedeem + sumUSDeMarketDeposit + sumUSDeFeesCollected; + // console.log(">>> Property A:"); + // console.log(" - USDe balance: %18e", usdeBalance); + // console.log(" - Inflow breakdown:"); + // console.log(" o Initial buffer: %18e", uint256(1e12)); + // console.log(" o Swap In: %18e", sumUSDeSwapIn); + // console.log(" o User Deposit: %18e", sumUSDeUserDeposit); + // console.log(" o Market Withdraw: %18e", sumUSDeMarketWithdraw); + // console.log(" o Base Redeem: %18e", sumUSDeBaseRedeem); + // console.log(" - USDe inflow sum: %18e", inflow); + // console.log(" - Outflow breakdown:"); + // console.log(" o Swap Out: %18e", sumUSDeSwapOut); + // console.log(" o User Redeem: %18e", sumUSDeUserRedeem); + // console.log(" o Market Deposit: %18e", sumUSDeMarketDeposit); + // console.log(" o Fees Collected: %18e", sumUSDeFeesCollected); + // console.log(" - USDe outflow sum: %18e", outflow); + // console.log(" - Diff: %18e", Math.absDiff(inflow, outflow)); + return Math.eq(usdeBalance, Math.absDiff(inflow, outflow)); + } + + function propertyB() public view returns (bool) { + uint256 susdeBalance = susde.balanceOf(address(arm)); + uint256 inflow = sumSUSDeSwapIn; + uint256 outflow = sumSUSDeSwapOut + sumSUSDeBaseRedeem; + // console.log(">>> Property B:"); + // console.log(" - sUSDe balance: %18e", susdeBalance); + // console.log(" - Inflow breakdown:"); + // console.log(" o Swap In: %18e", sumSUSDeSwapIn); + // console.log(" - sUSDe inflow sum: %18e", inflow); + // console.log(" - Outflow breakdown:"); + // console.log(" o Swap Out: %18e", sumSUSDeSwapOut); + // console.log(" o Base Redeem: %18e", sumSUSDeBaseRedeem); + // console.log(" - sUSDe outflow sum: %18e", outflow); + // console.log(" - Diff: %18e", Math.absDiff(inflow, outflow)); + return Math.eq(susdeBalance, Math.absDiff(inflow, outflow)); + } + + function propertyC() public view returns (bool) { + return Math.gt(arm.totalSupply(), 0); + } + + function propertyD() public view returns (bool) { + uint256 totalUserShares = 0; + for (uint256 i = 0; i < MAKERS_COUNT; i++) { + totalUserShares += arm.balanceOf(makers[i]); + } + uint256 deadShares = 1e12; + return Math.eq(arm.totalSupply(), totalUserShares + deadShares); + } + + function propertyE() public view returns (bool) { + return Math.eq(arm.previewRedeem(arm.totalSupply()), arm.totalAssets()); + } + + function propertyF() public view returns (bool) { + return Math.eq(arm.withdrawsQueued(), sumUSDeUserRequest); + } + + function propertyG() public view returns (bool) { + return Math.gte(arm.withdrawsQueued(), arm.withdrawsClaimed()); + } + + function propertyH() public view returns (bool) { + uint256 sum = 0; + uint256 len = arm.nextWithdrawalIndex(); + for (uint256 i; i < len; i++) { + (,,, uint128 amount,) = arm.withdrawalRequests(i); + sum += amount; + } + return Math.eq(arm.withdrawsQueued(), sum); + } + + function propertyI() public view returns (bool) { + return Math.eq(arm.withdrawsClaimed(), sumUSDeUserRedeem); + } + + function propertyJ() public view returns (bool) { + uint256 len = arm.nextWithdrawalIndex(); + for (uint256 i; i < len; i++) { + (,,, uint128 amount, uint128 queued) = arm.withdrawalRequests(i); + if (queued < amount) { + return false; + } + } + return true; + } + + function propertyK() public view returns (bool) { + uint256 feeCollectorBalance = usde.balanceOf(treasury); + return Math.eq(sumUSDeFeesCollected, feeCollectorBalance); + } +} diff --git a/test/invariants/EthenaARM/Setup.sol b/test/invariants/EthenaARM/Setup.sol new file mode 100644 index 00000000..584fffa3 --- /dev/null +++ b/test/invariants/EthenaARM/Setup.sol @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {Base_Test_} from "./Base.sol"; + +// Contracts +import {Proxy} from "contracts/Proxy.sol"; +import {EthenaARM} from "contracts/EthenaARM.sol"; +import {MorphoMarket} from "src/contracts/markets/MorphoMarket.sol"; +import {EthenaUnstaker} from "contracts/EthenaUnstaker.sol"; +import {Abstract4626MarketWrapper} from "contracts/markets/Abstract4626MarketWrapper.sol"; + +// Mocks +import {MockERC20} from "@solmate/test/utils/mocks/MockERC20.sol"; +import {MockSUSDE} from "test/invariants/EthenaARM/mocks/MockSUSDE.sol"; +import {MockMorpho} from "test/invariants/EthenaARM/mocks/MockMorpho.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; +import {IStakedUSDe} 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 Setup is Base_Test_ { + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public virtual { + // 1. Setup a realistic test environnement. + _setUpRealisticEnvironnement(); + + // 2. Create user. + _createUsers(); + + // 3. Deploy mocks. + _deployMocks(); + + // 4. Deploy contracts. + _deployContracts(); + + // 5. Label addresses + _labelAll(); + + // 6. Ignite contracts + _ignite(); + } + + function _setUpRealisticEnvironnement() internal virtual { + vm.warp(1_800_000_000); // Warp to a future timestamp + vm.roll(24_000_000); // Warp to a future block number + } + + function _createUsers() internal virtual { + // --- Users with roles --- + deployer = generateAddr("deployer"); + governor = generateAddr("governor"); + operator = generateAddr("operator"); + treasury = generateAddr("treasury"); + + // --- Regular users --- + alice = generateAddr("alice"); + bobby = generateAddr("bobby"); + carol = generateAddr("carol"); + david = generateAddr("david"); + elise = generateAddr("elise"); + frank = generateAddr("frank"); + grace = generateAddr("grace"); + harry = generateAddr("harry"); + dead = generateAddr("dead"); + + // --- Group of users --- + makers = new address[](MAKERS_COUNT); + makers[0] = alice; + makers[1] = bobby; + makers[2] = carol; + + traders = new address[](TRADERS_COUNT); + traders[0] = david; + traders[1] = elise; + traders[2] = frank; + } + + function _deployMocks() internal virtual { + // Deploy mock USDe. + usde = IERC20(address(new MockERC20("USDe", "USDe", 18))); + + // Deploy mock sUSDe. + susde = IStakedUSDe(address(new MockSUSDE(address(usde), governor))); + + // Deploy mock Morpho Market. + morpho = new MockMorpho(address(usde)); + } + + function _deployContracts() internal virtual { + vm.startPrank(deployer); + + // --- Ethena ARM --- + // Deploy Ethena ARM proxy. + armProxy = new Proxy(); + + // Deploy Ethena ARM implementation. + arm = new EthenaARM({ + _usde: address(usde), + _susde: address(susde), + _claimDelay: DEFAULT_CLAIM_DELAY, + _minSharesToRedeem: DEFAULT_MIN_SHARES_TO_REDEEM, + _allocateThreshold: int256(DEFAULT_ALLOCATE_THRESHOLD) + }); + + // Initialization requires to transfer some USDe to the proxy from the deployer. + MockERC20(address(usde)).mint(deployer, DEFAULT_MIN_TOTAL_SUPPLY); + usde.approve(address(armProxy), DEFAULT_MIN_TOTAL_SUPPLY); + + // Initialize Ethena ARM proxy. + bytes memory data = abi.encodeWithSelector( + EthenaARM.initialize.selector, + "Ethena ARM", + "ARM-USDe-sUSDe", + operator, + 2000, // 20% performance fee + treasury, + address(0) // CapManager address + ); + armProxy.initialize(address(arm), deployer, data); + + // Cast proxy address to EthenaARM type for easier interaction. + arm = EthenaARM(address(armProxy)); + + // --- Ethena Unstakers --- + // Deploy 42 Ethena Unstaker contracts + address[UNSTAKERS_COUNT] memory _unstakers; + for (uint256 i; i < UNSTAKERS_COUNT; i++) { + unstakers.push(new EthenaUnstaker(address(arm), susde)); + _unstakers[i] = address(unstakers[i]); + } + // Set unstakers in the ARM + arm.setUnstakers(_unstakers); + + // Transfer ownership of the ARM to the governor. + arm.setOwner(governor); + + // --- Morpho Market --- + // Deploy Morpho Market Proxy. + morphoMarketProxy = new Proxy(); + + // Deploy Morpho Market implementation. + market = new MorphoMarket(address(arm), address(morpho)); + + // Initialize Morpho Market proxy. + data = abi.encodeWithSelector(Abstract4626MarketWrapper.initialize.selector, address(0x1), address(0x1)); + morphoMarketProxy.initialize(address(market), governor, data); + + // Cast proxy address to MorphoMarket type for easier interaction. + market = MorphoMarket(address(morphoMarketProxy)); + + vm.stopPrank(); + } + + function _labelAll() internal virtual { + // This only works with Foundry's Vm.label feature. + if (!this.isLabelAvailable()) return; + + // --- Proxies --- + vm.label(address(armProxy), "Proxy EthenaARM"); + vm.label(address(morphoMarketProxy), "Proxy MorphoMarket"); + + // --- Implementations --- + vm.label(address(arm), "Ethena ARM"); + vm.label(address(market), "Morpho Market"); + vm.label(address(morpho), "Morpho Blue"); + vm.label(address(unstakers[0]), "Ethena Unstaker 0"); + vm.label(address(unstakers[1]), "Ethena Unstaker 1"); + vm.label(address(unstakers[2]), "Ethena Unstaker 2"); + vm.label(address(unstakers[3]), "Ethena Unstaker 3"); + vm.label(address(unstakers[4]), "Ethena Unstaker 4"); + vm.label(address(unstakers[5]), "Ethena Unstaker 5"); + vm.label(address(unstakers[6]), "Ethena Unstaker 6"); + vm.label(address(unstakers[7]), "Ethena Unstaker 7"); + vm.label(address(unstakers[8]), "Ethena Unstaker 8"); + vm.label(address(unstakers[9]), "Ethena Unstaker 9"); + vm.label(address(unstakers[10]), "Ethena Unstaker 10"); + vm.label(address(unstakers[11]), "Ethena Unstaker 11"); + vm.label(address(unstakers[12]), "Ethena Unstaker 12"); + vm.label(address(unstakers[13]), "Ethena Unstaker 13"); + vm.label(address(unstakers[14]), "Ethena Unstaker 14"); + vm.label(address(unstakers[15]), "Ethena Unstaker 15"); + vm.label(address(unstakers[16]), "Ethena Unstaker 16"); + vm.label(address(unstakers[17]), "Ethena Unstaker 17"); + vm.label(address(unstakers[18]), "Ethena Unstaker 18"); + vm.label(address(unstakers[19]), "Ethena Unstaker 19"); + vm.label(address(unstakers[20]), "Ethena Unstaker 20"); + vm.label(address(unstakers[21]), "Ethena Unstaker 21"); + vm.label(address(unstakers[22]), "Ethena Unstaker 22"); + vm.label(address(unstakers[23]), "Ethena Unstaker 23"); + vm.label(address(unstakers[24]), "Ethena Unstaker 24"); + vm.label(address(unstakers[25]), "Ethena Unstaker 25"); + vm.label(address(unstakers[26]), "Ethena Unstaker 26"); + vm.label(address(unstakers[27]), "Ethena Unstaker 27"); + vm.label(address(unstakers[28]), "Ethena Unstaker 28"); + vm.label(address(unstakers[29]), "Ethena Unstaker 29"); + vm.label(address(unstakers[30]), "Ethena Unstaker 30"); + vm.label(address(unstakers[31]), "Ethena Unstaker 31"); + vm.label(address(unstakers[32]), "Ethena Unstaker 32"); + vm.label(address(unstakers[33]), "Ethena Unstaker 33"); + vm.label(address(unstakers[34]), "Ethena Unstaker 34"); + vm.label(address(unstakers[35]), "Ethena Unstaker 35"); + vm.label(address(unstakers[36]), "Ethena Unstaker 36"); + vm.label(address(unstakers[37]), "Ethena Unstaker 37"); + vm.label(address(unstakers[38]), "Ethena Unstaker 38"); + vm.label(address(unstakers[39]), "Ethena Unstaker 39"); + vm.label(address(unstakers[40]), "Ethena Unstaker 40"); + vm.label(address(unstakers[41]), "Ethena Unstaker 41"); + // Using a loop here would be cleaner, but Vm.label doesn't support dynamic strings. + + // --- Tokens --- + vm.label(address(usde), "USDe"); + vm.label(address(susde), "sUSDe"); + + // --- Users with roles --- + vm.label(deployer, "Deployer"); + vm.label(governor, "Governor"); + vm.label(operator, "Operator"); + vm.label(treasury, "Treasury"); + + // --- Regular users --- + vm.label(alice, "Alice"); + vm.label(bobby, "Bobby"); + vm.label(carol, "Carol"); + vm.label(david, "David"); + vm.label(elise, "Elise"); + vm.label(frank, "Frank"); + vm.label(grace, "Grace"); + vm.label(harry, "Harry"); + vm.label(dead, "Dead"); + } + + function _ignite() internal virtual { + // As sUSDe is an ERC4626, we want to avoid small total supply issues. + // So we mint some sUSDe to the dead address, to replicate a realistic scenario. + MockERC20(address(usde)).mint(address(dead), 2_000_000 ether); + + vm.startPrank(dead); + usde.approve(address(susde), 1_000_000 ether); + susde.deposit(1_000_000 ether, dead); + + // Same for morpho contract. + usde.approve(address(morpho), 1_000_000 ether); + morpho.deposit(1_000_000 ether, dead); + vm.stopPrank(); + + // Set initial prices in the ARM. + vm.prank(governor); + arm.setCrossPrice(0.9998e36); + vm.prank(operator); + arm.setPrices(0.9992e36, 0.9999e36); + address[] memory markets = new address[](1); + markets[0] = address(market); + vm.prank(governor); + arm.addMarkets(markets); + + // Grace will only deposit/withdraw USDe from/to sUSDe. + vm.prank(grace); + usde.approve(address(susde), type(uint256).max); + + // Harry will only deposit/withdraw USDe from/to Morpho. + vm.prank(harry); + usde.approve(address(morpho), type(uint256).max); + + // Governor will deposit usde rewards into sUSDe. + vm.prank(governor); + usde.approve(address(susde), type(uint256).max); + + // Makers and traders approve ARM to spend their USDe. + for (uint256 i; i < MAKERS_COUNT; i++) { + vm.prank(makers[i]); + usde.approve(address(arm), type(uint256).max); + } + + for (uint256 i; i < TRADERS_COUNT; i++) { + vm.startPrank(traders[i]); + usde.approve(address(arm), type(uint256).max); + usde.approve(address(susde), type(uint256).max); + susde.approve(address(arm), type(uint256).max); + vm.stopPrank(); + } + } + + function generateAddr(string memory name) internal returns (address) { + return vm.addr(uint256(keccak256(abi.encodePacked(name)))); + } + + function assume(bool condition) internal returns (bool returnEarly) { + if (!condition) { + if (this.isAssumeAvailable()) vm.assume(false); + else returnEarly = true; + } + } + + function abs(int256 x) internal pure returns (uint256) { + return uint256(x >= 0 ? x : -x); + } + + function max(uint256 a, uint256 b) internal pure returns (uint256) { + return a >= b ? a : b; + } + + function min(uint256 a, uint256 b) internal pure returns (uint256) { + return a <= b ? a : b; + } + + modifier ensureTimeIncrease() { + uint256 oldTimestamp = block.timestamp; + _; + require(block.timestamp >= oldTimestamp, "TIME_DECREASED"); + } +} diff --git a/test/invariants/EthenaARM/TargetFunctions.sol b/test/invariants/EthenaARM/TargetFunctions.sol new file mode 100644 index 00000000..cd725b27 --- /dev/null +++ b/test/invariants/EthenaARM/TargetFunctions.sol @@ -0,0 +1,805 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test imports +import {Setup} from "./Setup.sol"; +import {console} from "forge-std/console.sol"; +import {StdUtils} from "forge-std/StdUtils.sol"; +import {StdStyle} from "forge-std/StdStyle.sol"; + +// Solmate +import {MockERC20} from "@solmate/test/utils/mocks/MockERC20.sol"; + +// Contracts +import {IERC20} from "contracts/Interfaces.sol"; +import {IERC4626} from "contracts/Interfaces.sol"; +import {UserCooldown} from "contracts/Interfaces.sol"; + +// Helpers +import {Find} from "./helpers/Find.sol"; + +/// @title TargetFunctions +/// @notice TargetFunctions contract for tests, containing the target functions that should be tested. +/// This is the entry point with the contract we are testing. Ideally, it should never revert. +abstract contract TargetFunctions is Setup, StdUtils { + // ╔══════════════════════════════════════════════════════════════════════════════╗ + // ║ ✦✦✦ ETHENA ARM ✦✦✦ ║ + // ╚══════════════════════════════════════════════════════════════════════════════╝ + // [x] SwapExactTokensForTokens + // [x] SwapTokensForExactTokens + // [x] Deposit + // [x] Allocate + // [x] CollectFees + // [x] RequestRedeem + // [x] ClaimRedeem + // [x] RequestBaseWithdrawal + // [x] ClaimBaseWithdrawals + // --- Admin functions + // [x] SetPrices + // [x] SetCrossPrice + // [x] SetFee + // [x] SetActiveMarket + // [x] SetARMBuffer + // + // ╔══════════════════════════════════════════════════════════════════════════════╗ + // ║ ✦✦✦ SUSDE ✦✦✦ ║ + // ╚══════════════════════════════════════════════════════════════════════════════╝ + // [x] Deposit + // [x] CoolDownShares + // [x] Unstake + // --- Admin functions + // [x] TransferInRewards + // + // ╔══════════════════════════════════════════════════════════════════════════════╗ + // ║ ✦✦✦ MORPHO ✦✦✦ ║ + // ╚══════════════════════════════════════════════════════════════════════════════╝ + // [x] Deposit + // [x] Withdraw + // [x] TransferInRewards + // [x] SetUtilizationRate + // + // ╔══════════════════════════════════════════════════════════════════════════════╗ + // ║ ✦✦✦ ✦✦✦ ║ + // ╚══════════════════════════════════════════════════════════════════════════════╝ + + // ╔══════════════════════════════════════════════════════════════════════════════╗ + // ║ ✦✦✦ ETHENA ARM ✦✦✦ ║ + // ╚══════════════════════════════════════════════════════════════════════════════╝ + function targetARMDeposit(uint88 amount, uint256 randomAddressIndex) external { + // Select a random user from makers + address user = makers[randomAddressIndex % MAKERS_COUNT]; + + uint256 totalSupply = arm.totalSupply(); + uint256 totalAssets = arm.totalAssets(); + // Min amount to avoid 0 shares minting + uint256 minAmount = totalAssets / totalSupply + 1; + amount = uint88(_bound(amount, minAmount, type(uint88).max)); + + // Mint amount to user + MockERC20(address(usde)).mint(user, amount); + // Deposit as user + vm.prank(user); + uint256 shares = arm.deposit(amount, user); + + if (this.isConsoleAvailable()) { + console.log( + ">>> ARM Deposit:\t %s deposited %18e USDe\t and received %18e ARM shares", + vm.getLabel(user), + amount, + shares + ); + } + + sumUSDeUserDeposit += amount; + } + + function targetARMRequestRedeem(uint88 shareAmount, uint248 randomAddressIndex) external { + address user; + uint256 balance; + // Todo: mirgate it to Find library + for (uint256 i; i < MAKERS_COUNT; i++) { + address _user = makers[(randomAddressIndex + i) % MAKERS_COUNT]; + uint256 _balance = arm.balanceOf(_user); + // Found a user with non-zero balance + if (_balance > 1) { + (user, balance) = (_user, _balance); + break; + } + } + if (assume(user != address(0))) return; + // Bound shareAmount to [1, balance] + shareAmount = uint88(_bound(shareAmount, 1, balance)); + + // Request redeem as user + vm.prank(user); + (uint256 requestId, uint256 amount) = arm.requestRedeem(shareAmount); + pendingRequests[user].push(requestId); + + if (this.isConsoleAvailable()) { + console.log( + string( + abi.encodePacked( + ">>> ARM Request:\t ", + vm.getLabel(user), + " requested redeem of %18e ARM shares\t for %18e USDe underlying\t Request ID: %d" + ) + ), + shareAmount, + amount, + requestId + ); + } + + sumUSDeUserRequest += amount; + } + + function targetARMClaimRedeem(uint248 randomAddressIndex, uint248 randomArrayIndex) external ensureTimeIncrease { + address user; + uint256 requestId; + uint256 claimTimestamp; + uint256 claimable = arm.claimable(); + uint256 availableLiquidity = usde.balanceOf(address(arm)); + address market = arm.activeMarket(); + if (market != address(0)) { + availableLiquidity += IERC4626(market).maxWithdraw(address(arm)); + } + if (assume(claimable != 0)) return; + // Find a user with a pending request, where the amount is <= claimable + { + (user, requestId, claimTimestamp) = Find.getUserRequestWithAmount( + Find.GetUserRequestWithAmountStruct({ + arm: address(arm), + randomAddressIndex: randomAddressIndex, + randomArrayIndex: randomArrayIndex, + users: makers, + claimable: uint128(claimable), + availableLiquidity: uint128(availableLiquidity) + }), + pendingRequests + ); + if (assume(user != address(0))) return; + } + + // Fast forward time if needed + if (block.timestamp < claimTimestamp) { + if (this.isConsoleAvailable()) { + console.log( + StdStyle.yellow( + string( + abi.encodePacked( + ">>> Time jump:\t Fast forwarded to: ", + vm.toString(claimTimestamp), + " (+ ", + vm.toString(claimTimestamp - block.timestamp), + "s)" + ) + ) + ) + ); + } + vm.warp(claimTimestamp); + } + + // Claim redeem as user + uint256 balanceBefore = usde.balanceOf(address(arm)); + vm.prank(user); + uint256 amount = arm.claimRedeem(requestId); + + if (this.isConsoleAvailable()) { + console.log( + string( + abi.encodePacked( + ">>> ARM Claim:\t ", + vm.getLabel(user), + " claimed redeem request ID %d\t and received %18e USDe underlying" + ) + ), + requestId, + amount + ); + } + + sumUSDeUserRedeem += amount; + if (balanceBefore < amount) { + // This means we had to withdraw from market + sumUSDeMarketWithdraw += amount - balanceBefore; + } + } + + function targetARMSetARMBuffer(uint256 pct) external { + pct = _bound(pct, 0, 100); + + vm.prank(operator); + arm.setARMBuffer(pct * 1e16); + + if (this.isConsoleAvailable()) { + console.log(">>> ARM Buffer:\t Governor set ARM buffer to %s%", pct); + } + } + + function targetARMSetActiveMarket(bool isActive) external { + // If isActive is true it will `setActiveMarket` with MorphoMarket + // else it will set it to address(0) + address currentMarket = arm.activeMarket(); + address targetMarket = isActive ? address(market) : address(0); + + // If the current market is the morpho market and we want to deactivate it + // ensure the is enough liquidity in Morpho to cover the ARM's assets withdrawals + if (currentMarket == address(market) && !isActive) { + uint256 shares = market.balanceOf(address(arm)); + uint256 assets = market.convertToAssets(shares); + uint256 availableLiquidity = morpho.availableLiquidity(); + if (assume(assets < availableLiquidity)) return; + } + + uint256 balanceBefore = usde.balanceOf(address(arm)); + vm.prank(operator); + arm.setActiveMarket(targetMarket); + uint256 balanceAfter = usde.balanceOf(address(arm)); + + if (this.isConsoleAvailable()) { + console.log( + ">>> ARM SetMarket:\t Governor set active market to %s", isActive ? "Morpho Market" : "No active market" + ); + } + + int256 diff = int256(balanceAfter) - int256(balanceBefore); + if (diff > 0) { + sumUSDeMarketWithdraw += uint256(diff); + } else { + sumUSDeMarketDeposit += uint256(-diff); + } + } + + function targetARMAllocate() external { + address currentMarket = arm.activeMarket(); + if (assume(currentMarket != address(0))) return; + + (int256 targetLiquidityDelta, int256 actualLiquidityDelta) = arm.allocate(); + + if (this.isConsoleAvailable()) { + console.log( + string( + abi.encodePacked( + ">>> ARM Allocate:\t ARM allocated liquidity to active market. Target delta: ", + targetLiquidityDelta < 0 ? "-" : "", + "%18e USDe\t Actual delta: ", + actualLiquidityDelta < 0 ? "-" : "", + "%18e USDe" + ) + ), + abs(targetLiquidityDelta), + abs(actualLiquidityDelta) + ); + } + + if (actualLiquidityDelta > 0) { + sumUSDeMarketDeposit += uint256(actualLiquidityDelta); + } else { + sumUSDeMarketWithdraw += uint256(-actualLiquidityDelta); + } + } + + function targetARMSetPrices(uint256 buyPrice, uint256 sellPrice) external { + uint256 crossPrice = arm.crossPrice(); + // Bound sellPrice + sellPrice = uint120(_bound(sellPrice, crossPrice, (1e37 - 1) / 9)); // -> min traderate0 -> 0.9e36 + // Bound buyPrice + buyPrice = uint120(_bound(buyPrice, 0.9e36, crossPrice - 1)); // -> min traderate1 -> 0.9e36 + + vm.prank(operator); + arm.setPrices(buyPrice, sellPrice); + + if (this.isConsoleAvailable()) { + console.log( + ">>> ARM SetPrices:\t Governor set buy price to %36e\t sell price to %36e\t cross price to %36e", + buyPrice, + 1e72 / sellPrice, + arm.crossPrice() + ); + } + } + + function targetARMSetCrossPrice(uint256 crossPrice) external { + uint256 maxCrossPrice = 1e36; + uint256 minCrossPrice = 1e36 - 20e32; + uint256 sellT1 = 1e72 / (arm.traderate0()); + uint256 buyT1 = arm.traderate1() + 1; + minCrossPrice = max(minCrossPrice, buyT1); + maxCrossPrice = min(maxCrossPrice, sellT1); + if (assume(maxCrossPrice >= minCrossPrice)) return; + crossPrice = _bound(crossPrice, minCrossPrice, maxCrossPrice); + + if (arm.crossPrice() > crossPrice) { + if (assume(susde.balanceOf(address(arm)) < 1e12)) return; + } + + vm.prank(governor); + arm.setCrossPrice(crossPrice); + + if (this.isConsoleAvailable()) { + console.log(">>> ARM SetCPrice:\t Governor set cross price to %36e", crossPrice); + } + } + + function targetARMSwapExactTokensForTokens(bool token0ForToken1, uint88 amountIn, uint256 randomAddressIndex) + external + { + (IERC20 tokenIn, IERC20 tokenOut) = token0ForToken1 + ? (IERC20(address(usde)), IERC20(address(susde))) + : (IERC20(address(susde)), IERC20(address(usde))); + + // What's the maximum amountOut we can obtain? + uint256 maxAmountOut; + if (address(tokenOut) == address(usde)) { + uint256 balance = usde.balanceOf(address(arm)); + uint256 outstandingWithdrawals = arm.withdrawsQueued() - arm.withdrawsClaimed(); + maxAmountOut = outstandingWithdrawals >= balance ? 0 : balance - outstandingWithdrawals; + } else { + maxAmountOut = susde.balanceOf(address(arm)); + } + // Ensure there is liquidity available in ARM + if (assume(maxAmountOut > 1)) return; + + // What's the maximum amountIn we can provide to not exceed maxAmountOut? + uint256 maxAmountIn = token0ForToken1 + ? (maxAmountOut * 1e36 / arm.traderate0()) * susde.totalAssets() / susde.totalSupply() + : (maxAmountOut * 1e36 / arm.traderate1()) * susde.totalSupply() / susde.totalAssets(); + if (assume(maxAmountIn > 0)) return; + + // Bound amountIn + amountIn = uint88(_bound(amountIn, 1, maxAmountIn)); + // Select a random user from makers + address user = traders[randomAddressIndex % TRADERS_COUNT]; + + vm.startPrank(user); + // Mint amountIn to user + if (token0ForToken1) { + MockERC20(address(usde)).mint(user, amountIn); + } else { + // Mint too much USDe to user to be able to mint enough sUSDe + MockERC20(address(usde)).mint(user, uint256(amountIn) * 10); + // Mint sUSDe to user + susde.mint(amountIn, user); + // Burn excess USDe + MockERC20(address(usde)).burn(user, usde.balanceOf(user)); + } + // Perform swap + uint256[] memory obtained = arm.swapExactTokensForTokens(tokenIn, tokenOut, amountIn, 0, user); + vm.stopPrank(); + + if (this.isConsoleAvailable()) { + console.log( + string( + abi.encodePacked( + ">>> ARM SwapEF:\t ", + vm.getLabel(user), + " swapped %18e ", + token0ForToken1 ? "USDe" : "sUSDe", + "\t for %18e ", + token0ForToken1 ? "sUSDe" : "USDe" + ) + ), + amountIn, + obtained[1] + ); + } + + require(obtained[0] == amountIn, "Amount in mismatch"); + if (token0ForToken1) { + sumUSDeSwapIn += obtained[0]; + sumSUSDeSwapOut += obtained[1]; + } else { + sumSUSDeSwapIn += obtained[0]; + sumUSDeSwapOut += obtained[1]; + } + } + + function targetARMSwapTokensForExactTokens(bool token0ForToken1, uint88 amountOut, uint256 randomAddressIndex) + external + { + (IERC20 tokenIn, IERC20 tokenOut) = token0ForToken1 + ? (IERC20(address(usde)), IERC20(address(susde))) + : (IERC20(address(susde)), IERC20(address(usde))); + + // What's the maximum amountOut we can obtain? + uint256 maxAmountOut; + if (address(tokenOut) == address(usde)) { + uint256 balance = usde.balanceOf(address(arm)); + uint256 outstandingWithdrawals = arm.withdrawsQueued() - arm.withdrawsClaimed(); + maxAmountOut = outstandingWithdrawals >= balance ? 0 : balance - outstandingWithdrawals; + } else { + maxAmountOut = susde.balanceOf(address(arm)); + } + // Ensure there is liquidity available in ARM + if (assume(maxAmountOut > 1)) return; + + amountOut = uint88(_bound(amountOut, 1, maxAmountOut)); + + // What's the maximum amountIn we can provide to not exceed maxAmountOut? + uint256 convertedAmountOut; + if (token0ForToken1) { + convertedAmountOut = (amountOut * susde.totalAssets()) / susde.totalSupply(); + } else { + convertedAmountOut = (amountOut * susde.totalSupply()) / susde.totalAssets(); + } + uint256 price = token0ForToken1 ? arm.traderate0() : arm.traderate1(); + uint256 amountIn = ((uint256(convertedAmountOut) * 1e36) / price) + 3 + 10; // slippage + rounding buffer + + // Select a random user from makers + address user = traders[randomAddressIndex % TRADERS_COUNT]; + vm.startPrank(user); + // Mint amountIn to user + if (token0ForToken1) { + MockERC20(address(usde)).mint(user, amountIn); + } else { + // Mint too much USDe to user to be able to mint enough sUSDe + MockERC20(address(usde)).mint(user, amountIn * 2); + // Mint sUSDe to user + susde.mint(amountIn, user); + // Burn excess USDe + MockERC20(address(usde)).burn(user, usde.balanceOf(user)); + } + // Perform swap + uint256[] memory obtained = arm.swapTokensForExactTokens(tokenIn, tokenOut, amountOut, type(uint256).max, user); + vm.stopPrank(); + + if (this.isConsoleAvailable()) { + console.log( + string( + abi.encodePacked( + ">>> ARM SwapFT:\t ", + vm.getLabel(user), + " swapped %18e ", + token0ForToken1 ? "USDe" : "sUSDe", + "\t for %18e ", + token0ForToken1 ? "sUSDe" : "USDe" + ) + ), + obtained[0], + amountOut + ); + } + + require(obtained[1] == amountOut, "Amount out mismatch"); + if (token0ForToken1) { + sumUSDeSwapIn += obtained[0]; + sumSUSDeSwapOut += obtained[1]; + } else { + sumSUSDeSwapIn += obtained[0]; + sumUSDeSwapOut += obtained[1]; + } + } + + function targetARMCollectFees() external { + uint256 feesAccrued = arm.feesAccrued(); + uint256 balance = usde.balanceOf(address(arm)); + uint256 outstandingWithdrawals = arm.withdrawsQueued() - arm.withdrawsClaimed(); + if (assume(balance >= feesAccrued + outstandingWithdrawals)) return; + + uint256 feesCollected = arm.collectFees(); + + if (this.isConsoleAvailable()) { + console.log(">>> ARM Collect:\t Governor collected %18e USDe in fees", feesCollected); + } + require(feesCollected == feesAccrued, "Fees collected mismatch"); + + sumUSDeFeesCollected += feesCollected; + } + + function targetARMSetFees(uint256 fee) external { + // Ensure current fee can be collected + uint256 feesAccrued = arm.feesAccrued(); + if (feesAccrued != 0) { + uint256 balance = usde.balanceOf(address(arm)); + uint256 outstandingWithdrawals = arm.withdrawsQueued() - arm.withdrawsClaimed(); + if (assume(balance >= feesAccrued + outstandingWithdrawals)) return; + } + + uint256 oldFee = arm.fee(); + // Bound fee to [0, 50%] + fee = _bound(fee, 0, 50); + vm.prank(governor); + arm.setFee(fee * 100); + + if (this.isConsoleAvailable()) { + console.log(">>> ARM SetFees:\t Governor set ARM fee from %s% to %s%", oldFee / 100, fee); + } + + sumUSDeFeesCollected += feesAccrued; + } + + function targetARMRequestBaseWithdrawal(uint88 amount) external { + uint256 balance = susde.balanceOf(address(arm)); + if (assume(balance > 1)) return; + amount = uint88(_bound(amount, 1, balance)); + + // Ensure there is an unstaker available + uint256 nextIndex = arm.nextUnstakerIndex(); + address unstaker = arm.unstakers(nextIndex); + UserCooldown memory cooldown = susde.cooldowns(unstaker); + // If next unstaker has an active cooldown, this means all unstakers are in cooldown + // -> no unstaker available + if (assume(cooldown.underlyingAmount == 0)) return; + + // Ensure time delay has passed + uint32 lastRequestTimestamp = arm.lastRequestTimestamp(); + if (block.timestamp < lastRequestTimestamp + 3 hours) { + if (this.isConsoleAvailable()) { + console.log( + StdStyle.yellow( + string( + abi.encodePacked( + ">>> Time jump:\t Fast forwarded to: ", + vm.toString(lastRequestTimestamp + 3 hours), + " (+ ", + vm.toString((lastRequestTimestamp + 3 hours) - block.timestamp), + "s)" + ) + ) + ) + ); + } + vm.warp(lastRequestTimestamp + 3 hours); + } + + vm.prank(operator); + arm.requestBaseWithdrawal(amount); + + unstakerIndices.push(nextIndex); + + if (this.isConsoleAvailable()) { + console.log( + ">>> ARM ReqBaseW:\t Operator requested base withdrawal of %18e sUSDe underlying, using unstakers #%s", + amount, + nextIndex + ); + } + + sumSUSDeBaseRedeem += amount; + } + + function targetARMClaimBaseWithdrawals(uint256 randomAddressIndex) external ensureTimeIncrease { + if (assume(unstakerIndices.length != 0)) return; + // Select a random unstaker index from used unstakers + uint256 selectedIndex = unstakerIndices[randomAddressIndex % unstakerIndices.length]; + address unstaker = arm.unstakers(uint8(selectedIndex)); + UserCooldown memory cooldown = susde.cooldowns(address(unstaker)); + uint256 endTimestamp = cooldown.cooldownEnd; + + // Fast forward time if needed + if (block.timestamp < endTimestamp) { + if (this.isConsoleAvailable()) { + console.log( + StdStyle.yellow( + string( + abi.encodePacked( + ">>> Time jump:\t Fast forwarded to: ", + vm.toString(endTimestamp), + " (+ ", + vm.toString(endTimestamp - block.timestamp), + "s)" + ) + ) + ) + ); + } + vm.warp(endTimestamp); + } + + vm.prank(operator); + arm.claimBaseWithdrawals(unstaker); + + // Remove selectedIndex from unstakerIndices, without preserving order + unstakerIndices[randomAddressIndex % unstakerIndices.length] = unstakerIndices[unstakerIndices.length - 1]; + unstakerIndices.pop(); + + if (this.isConsoleAvailable()) { + console.log( + string( + abi.encodePacked( + ">>> ARM ClaimBaseW:\t Operator claimed base withdrawals using %s\t ", "who unstaked %18e USDe" + ) + ), + vm.getLabel(unstaker), + cooldown.underlyingAmount + ); + } + + sumUSDeBaseRedeem += cooldown.underlyingAmount; + } + + // ╔══════════════════════════════════════════════════════════════════════════════╗ + // ║ ✦✦✦ SUSDE ✦✦✦ ║ + // ╚══════════════════════════════════════════════════════════════════════════════╝ + function targetSUSDeDeposit(uint88 amount) external { + // Ensure we don't mint 0 shares. + uint256 totalAssets = susde.totalAssets(); + uint256 totalSupply = susde.totalSupply(); + uint256 minAmount = totalAssets / totalSupply + 1; + // Prevent zero deposits + amount = uint88(_bound(amount, minAmount, type(uint88).max)); + + // Mint amount to grace + MockERC20(address(usde)).mint(grace, amount); + + // Deposit as grace + vm.prank(grace); + uint256 shares = susde.deposit(amount, grace); + + if (this.isConsoleAvailable()) { + console.log( + ">>> sUSDe Deposit:\t Grace deposited %18e USDe\t and received %18e sUSDe shares", amount, shares + ); + } + } + + function targetSUSDeCooldownShares(uint88 shareAmount) external { + // Cache balance + uint256 balance = susde.balanceOf(grace); + + // Assume balance not zero + if (assume(balance > 1)) return; + + // Bound shareAmount to [1, balance] + shareAmount = uint88(_bound(shareAmount, 1, balance)); + + // Cooldown shares as grace + vm.prank(grace); + uint256 amount = susde.cooldownShares(shareAmount); + if (this.isConsoleAvailable()) { + console.log( + ">>> sUSDe Cooldown:\t Grace cooled down %18e sUSDe shares\t for %18e USDe underlying", + shareAmount, + amount + ); + } + } + + function targetSUSDeUnstake() external ensureTimeIncrease { + // Check grace's cooldown + UserCooldown memory cooldown = susde.cooldowns(grace); + + // Ensure grace has a valid cooldown + if (assume(cooldown.cooldownEnd != 0)) return; + + // Fast forward to after cooldown end if needed + if (block.timestamp < cooldown.cooldownEnd) { + if (this.isConsoleAvailable()) { + console.log( + StdStyle.yellow( + string( + abi.encodePacked( + ">>> Time jump:\t Fast forwarded to: ", + vm.toString(cooldown.cooldownEnd), + " (+ ", + vm.toString(cooldown.cooldownEnd - block.timestamp), + "s)" + ) + ) + ) + ); + } + vm.warp(cooldown.cooldownEnd); + } + + // Unstake as grace + vm.prank(grace); + susde.unstake(grace); + + if (this.isConsoleAvailable()) { + console.log( + ">>> sUSDe Unstake:\t Grace unstaked %18e USDe underlying after cooldown", cooldown.underlyingAmount + ); + } + MockERC20(address(usde)).burn(grace, cooldown.underlyingAmount); + } + + function targetSUSDeTransferInRewards(uint8 bps) external ensureTimeIncrease { + // Ensure enough time has passed since last distribution + uint256 lastDistribution = susde.lastDistributionTimestamp(); + if (block.timestamp < 8 hours + lastDistribution) { + // Fast forward time to allow rewards distribution + if (this.isConsoleAvailable()) { + console.log( + StdStyle.yellow( + string( + abi.encodePacked( + ">>> Time jump:\t Fast forwarded to: ", + vm.toString(lastDistribution + 8 hours), + " (+ ", + vm.toString((lastDistribution + 8 hours) - block.timestamp), + "s)" + ) + ) + ) + ); + vm.warp(lastDistribution + 8 hours); + } + } + + uint256 balance = usde.balanceOf(address(susde)); + // Rewards can be distributed 3/days max. 1bps at every distribution -> 10 APR. + bps = uint8(_bound(bps, 1, 10)); + uint256 rewards = (balance * bps) / 10_000; + MockERC20(address(usde)).mint(governor, rewards); + vm.prank(governor); + susde.transferInRewards(rewards); + + if (this.isConsoleAvailable()) { + console.log(">>> sUSDe Rewards:\t Governor transferred in %18e USDe as rewards, bps: %d", rewards, bps); + } + } + + // ╔══════════════════════════════════════════════════════════════════════════════╗ + // ║ ✦✦✦ MORPHO ✦✦✦ ║ + // ╚══════════════════════════════════════════════════════════════════════════════╝ + function targetMorphoDeposit(uint88 amount) external { + // Ensure we don't mint 0 shares. + uint256 totalAssets = morpho.totalAssets(); + uint256 totalSupply = morpho.totalSupply(); + uint256 minAmount = totalAssets / totalSupply + 1; + // Prevent zero deposits + amount = uint88(_bound(amount, minAmount, type(uint88).max)); + + // Mint amount to harry + MockERC20(address(usde)).mint(harry, amount); + + // Deposit as harry + vm.prank(harry); + uint256 shares = morpho.deposit(amount, harry); + + if (this.isConsoleAvailable()) { + console.log( + ">>> Morpho Deposit:\t Harry deposited %18e USDe\t and received %18e Morpho shares", amount, shares + ); + } + } + + function targetMorphoWithdraw(uint88 amount) external { + // Check harry's balance + uint256 balance = morpho.balanceOf(harry); + + // Assume balance not zero + if (assume(balance > 1)) return; + + // Bound shareAmount to [1, balance] + amount = uint88(_bound(amount, 1, balance)); + + // Ensure there is enough liquidity to withdraw the amount + uint256 maxWithdrawable = morpho.maxWithdraw(harry); + if (assume(amount <= maxWithdrawable)) return; + + // Withdraw as harry + vm.prank(harry); + uint256 shares = morpho.withdraw(amount, harry, harry); + if (this.isConsoleAvailable()) { + console.log( + ">>> Morpho Withdraw:\t Harry withdrew %18e Morpho shares\t for %18e USDe underlying", shares, amount + ); + } + + MockERC20(address(usde)).burn(harry, amount); + } + + function targetMorphoTransferInRewards(uint8 bps) external { + uint256 balance = usde.balanceOf(address(morpho)); + bps = uint8(_bound(bps, 1, 10)); + uint256 rewards = (balance * bps) / 10_000; + MockERC20(address(usde)).mint(address(morpho), rewards); + + if (this.isConsoleAvailable()) { + console.log(">>> Morpho Rewards:\t Transferred in %18e USDe as rewards, bps: %d", rewards, bps); + } + } + + function targetMorphoSetUtilizationRate(uint256 pct) external { + pct = _bound(pct, 0, 100); + + morpho.setUtilizationRate(pct * 1e16); + + if (this.isConsoleAvailable()) { + console.log(">>> Morpho UseRate:\t Governor set utilization rate to %s%", pct); + } + } +} diff --git a/test/invariants/EthenaARM/helpers/Find.sol b/test/invariants/EthenaARM/helpers/Find.sol new file mode 100644 index 00000000..385b2bdc --- /dev/null +++ b/test/invariants/EthenaARM/helpers/Find.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {AbstractARM} from "contracts/AbstractARM.sol"; + +/// @notice Library used to find specific data in storage for testing purposes. +/// Most of the time to find a specific user/request that meets certain criteria. +library Find { + struct GetUserRequestWithAmountStruct { + address arm; + uint248 randomAddressIndex; + uint248 randomArrayIndex; + address[] users; + uint128 claimable; + uint128 availableLiquidity; + } + + function getUserRequestWithAmount( + GetUserRequestWithAmountStruct memory $, + mapping(address => uint256[]) storage pendingRequests + ) internal returns (address user, uint256 requestId, uint40 claimTimestamp) { + for (uint256 i; i < $.users.length; i++) { + // Take a random user + address _user = $.users[($.randomAddressIndex + i) % $.users.length]; + // Find a request that can be claimed + for (uint256 j; j < pendingRequests[_user].length; j++) { + // Take a random request from that user + uint256 _requestId = pendingRequests[_user][($.randomArrayIndex + j) % pendingRequests[_user].length]; + // Check request data + (,, uint40 _claimTimestamp, uint128 _amount, uint128 _queued) = + AbstractARM($.arm).withdrawalRequests(_requestId); + // Check if this is claimable + if (_queued <= $.claimable && _amount <= $.availableLiquidity) { + (user, requestId, claimTimestamp) = (_user, _requestId, _claimTimestamp); + // Remove pendingRequests + pendingRequests[_user][($.randomArrayIndex + j) % pendingRequests[_user].length] = + pendingRequests[_user][pendingRequests[_user].length - 1]; + pendingRequests[_user].pop(); + break; + } + } + } + } +} diff --git a/test/invariants/EthenaARM/helpers/Math.sol b/test/invariants/EthenaARM/helpers/Math.sol new file mode 100644 index 00000000..18c1676d --- /dev/null +++ b/test/invariants/EthenaARM/helpers/Math.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +library Math { + ////////////////////////////////////////////////////// + /// --- ABS + ////////////////////////////////////////////////////// + /// @notice Returns the absolute value of an int256 as uint256 + /// @param a The integer to get the absolute value of + /// @return The absolute value as uint256 + function abs(int256 a) internal pure returns (uint256) { + return uint256(a >= 0 ? a : -a); + } + + /// @notice Returns the absolute difference between two uint256 values + /// @param a The first value + /// @param b The second value + /// @return The absolute difference + function absDiff(uint256 a, uint256 b) internal pure returns (uint256) { + return a >= b ? a - b : b - a; + } + + ////////////////////////////////////////////////////// + /// --- MIN & MAX + ////////////////////////////////////////////////////// + /// @notice Returns the maximum of two uint256 values + /// @param a The first value + /// @param b The second value + /// @return The maximum value + function max(uint256 a, uint256 b) internal pure returns (uint256) { + return a >= b ? a : b; + } + + /// @notice Returns the minimum of two uint256 values + /// @param a The first value + /// @param b The second value + /// @return The minimum value + function min(uint256 a, uint256 b) internal pure returns (uint256) { + return a <= b ? a : b; + } + + ////////////////////////////////////////////////////// + /// --- EQUALITY STRICT AND APPROXIMATE + ////////////////////////////////////////////////////// + /// @notice Checks if two uint256 values are equal + /// @param a The first value + /// @param b The second value + /// @return True if equal, false otherwise + function eq(uint256 a, uint256 b) internal pure returns (bool) { + return a == b; + } + + /// @notice Checks if two uint256 values are approximately equal within a maximum absolute difference + /// @param a The first value + /// @param b The second value + /// @param maxDelta The maximum allowed absolute difference + /// @return True if approximately equal, false otherwise + function approxEqAbs(uint256 a, uint256 b, uint256 maxDelta) internal pure returns (bool) { + if (a >= b) { + return (a - b) <= maxDelta; + } else { + return (b - a) <= maxDelta; + } + } + + /// @notice Checks if two uint256 values are approximately equal within a maximum relative difference (in WAD) + /// @param a The first value + /// @param b The second value + /// @param maxRelDeltaWAD The maximum allowed relative difference in WAD (1e18 = 100%) + /// @return True if approximately equal, false otherwise + function approxEqRel(uint256 a, uint256 b, uint256 maxRelDeltaWAD) internal pure returns (bool) { + if (a == b) { + return true; + } + uint256 _absDiff = a >= b ? a - b : b - a; + uint256 relDiffWAD = (_absDiff * 1 ether) / Math.max(a, b); + return relDiffWAD <= maxRelDeltaWAD; + } + + ////////////////////////////////////////////////////// + /// --- GREATER THAN + ////////////////////////////////////////////////////// + /// @notice Checks if a is greater than b + /// @param a The first value + /// @param b The second value + /// @return True if a > b, false otherwise + function gt(uint256 a, uint256 b) internal pure returns (bool) { + return a > b; + } + + /// @notice Checks if a is greater than or equal to b + function gte(uint256 a, uint256 b) internal pure returns (bool) { + return a >= b; + } + + /// @notice Checks if a is approximately greater than or equal to b within a maximum absolute difference + /// @param a The first value + /// @param b The second value + /// @param maxDelta The maximum allowed absolute difference + /// @return True if a is approximately greater than or equal to b, false otherwise + function approxGteAbs(uint256 a, uint256 b, uint256 maxDelta) internal pure returns (bool) { + if (a >= b) { + return true; + } else { + return (b - a) <= maxDelta; + } + } + + ////////////////////////////////////////////////////// + /// --- LESS THAN + ////////////////////////////////////////////////////// + /// @notice Checks if a is less than b + /// @param a The first value + /// @param b The second value + /// @return True if a < b, false otherwise + function lt(uint256 a, uint256 b) internal pure returns (bool) { + return a < b; + } + + /// @notice Checks if a is less than or equal to b + /// @param a The first value + /// @param b The second value + /// @return True if a <= b, false otherwise + function lte(uint256 a, uint256 b) internal pure returns (bool) { + return a <= b; + } +} diff --git a/test/invariants/EthenaARM/helpers/Vm.sol b/test/invariants/EthenaARM/helpers/Vm.sol new file mode 100644 index 00000000..ba1ddc99 --- /dev/null +++ b/test/invariants/EthenaARM/helpers/Vm.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.23; + +/// @notice Medusa StdCheats interface +interface Vm { + // Set block.timestamp + function warp(uint256) external; + + // Set block.number + function roll(uint256) external; + + // Set block.basefee + function fee(uint256) external; + + // Set block.difficulty (deprecated in `medusa`) + function difficulty(uint256) external; + + // Set block.prevrandao + function prevrandao(bytes32) external; + + // Set block.chainid + function chainId(uint256) external; + + // Sets the block.coinbase + function coinbase(address) external; + + // Loads a storage slot from an address + function load(address account, bytes32 slot) external returns (bytes32); + + // Stores a value to an address' storage slot + function store(address account, bytes32 slot, bytes32 value) external; + + // Sets the *next* call's msg.sender to be the input address + function prank(address) external; + + // Sets all subsequent call's msg.sender (until stopPrank is called) to be the input address + function startPrank(address) external; + + // Stops a previously called startPrank + function stopPrank() external; + + // Set msg.sender to the input address until the current call exits + function prankHere(address) external; + + // Sets an address' balance + function deal(address who, uint256 newBalance) external; + + // Sets an address' code + function etch(address who, bytes calldata code) external; + + // Signs data + function sign(uint256 privateKey, bytes32 digest) external returns (uint8 v, bytes32 r, bytes32 s); + + // Computes address for a given private key + function addr(uint256 privateKey) external returns (address); + + // Gets the creation bytecode of a contract + function getCode(string calldata) external returns (bytes memory); + + // Gets the nonce of an account + function getNonce(address account) external returns (uint64); + + // Sets the nonce of an account + // The new nonce must be higher than the current nonce of the account + function setNonce(address account, uint64 nonce) external; + + // Performs a foreign function call via terminal + function ffi(string[] calldata) external returns (bytes memory); + + // Take a snapshot of the current state of the EVM + function snapshot() external returns (uint256); + + // Revert state back to a snapshot + function revertTo(uint256) external returns (bool); + + // Convert Solidity types to strings + function toString(address) external returns (string memory); + function toString(bytes calldata) external returns (string memory); + function toString(bytes32) external returns (string memory); + function toString(bool) external returns (string memory); + function toString(uint256) external returns (string memory); + function toString(int256) external returns (string memory); + + // Convert strings into Solidity types + function parseBytes(string memory) external returns (bytes memory); + function parseBytes32(string memory) external returns (bytes32); + function parseAddress(string memory) external returns (address); + function parseUint(string memory) external returns (uint256); + function parseInt(string memory) external returns (int256); + function parseBool(string memory) external returns (bool); + + // Only works with Foundry + function label(address account, string calldata newLabel) external; + function getLabel(address account) external returns (string memory); + function assume(bool condition) external; +} diff --git a/test/invariants/EthenaARM/mocks/MockMorpho.sol b/test/invariants/EthenaARM/mocks/MockMorpho.sol new file mode 100644 index 00000000..32a5dc17 --- /dev/null +++ b/test/invariants/EthenaARM/mocks/MockMorpho.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Solmate +import {ERC20} from "@solmate/tokens/ERC20.sol"; +import {ERC4626} from "@solmate/mixins/ERC4626.sol"; + +contract MockMorpho is ERC4626 { + ////////////////////////////////////////////////////// + /// --- STATE VARIABLES + ////////////////////////////////////////////////////// + uint256 public utilizationRate; + + ////////////////////////////////////////////////////// + /// --- EVENTS + ////////////////////////////////////////////////////// + event UtilizationRateChanged(uint256 oldUtilizationRate, uint256 newUtilizationRate); + + ////////////////////////////////////////////////////// + /// --- CONSTRUCTOR + ////////////////////////////////////////////////////// + constructor(address _underlying) ERC4626(ERC20(_underlying), "Mock Morpho Blue", "Mock Morpho Blue") {} + + ////////////////////////////////////////////////////// + /// --- VIEW FUNCTIONS + ////////////////////////////////////////////////////// + function totalAssets() public view override returns (uint256) { + return asset.balanceOf(address(this)); + } + + function maxWithdraw(address owner) public view override returns (uint256) { + uint256 remainingLiquidity = availableLiquidity(); + uint256 userLiquidity = convertToAssets(balanceOf[owner]); + return userLiquidity > remainingLiquidity ? remainingLiquidity : userLiquidity; + } + + function maxRedeem(address owner) public view override returns (uint256) { + uint256 maxRedeemableShares = convertToShares(availableLiquidity()); + uint256 userShares = balanceOf[owner]; + return userShares > maxRedeemableShares ? maxRedeemableShares : userShares; + } + + function beforeWithdraw(uint256 assets, uint256) internal view override { + require(assets <= availableLiquidity(), "INSUFFICIENT_LIQUIDITY"); + } + + function availableLiquidity() public view returns (uint256) { + return totalAssets() * (1e18 - utilizationRate) / 1e18; + } + + ////////////////////////////////////////////////////// + /// --- MUTATIVE FUNCTIONS + ////////////////////////////////////////////////////// + function setUtilizationRate(uint256 _utilizationRate) external { + emit UtilizationRateChanged(utilizationRate, _utilizationRate); + utilizationRate = _utilizationRate; + } +} diff --git a/test/invariants/EthenaARM/mocks/MockSUSDE.sol b/test/invariants/EthenaARM/mocks/MockSUSDE.sol new file mode 100644 index 00000000..b71cd1f5 --- /dev/null +++ b/test/invariants/EthenaARM/mocks/MockSUSDE.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Solmate +import {Owned} from "@solmate/auth/Owned.sol"; +import {ERC20} from "@solmate/tokens/ERC20.sol"; +import {ERC4626} from "@solmate/mixins/ERC4626.sol"; + +// Interfaces +import {UserCooldown} from "contracts/Interfaces.sol"; + +contract MockSUSDE is ERC4626, Owned { + ////////////////////////////////////////////////////// + /// --- CONSTANTS & IMMUTABLES + ////////////////////////////////////////////////////// + address public immutable SILO; + uint256 public immutable VESTING_DURATION; + uint256 public immutable COOLDOWN_DURATION; + + ////////////////////////////////////////////////////// + /// --- STATE VARIABLES + ////////////////////////////////////////////////////// + uint256 public vestingAmount; + uint256 public lastDistributionTimestamp; + mapping(address => UserCooldown) public cooldowns; + + ////////////////////////////////////////////////////// + /// --- EVENTS + ////////////////////////////////////////////////////// + event CooldownSet(uint256 oldDuration, uint256 newDuration); + event RewardReceived(uint256 amount); + + ////////////////////////////////////////////////////// + /// --- CONSTRUCTOR + ////////////////////////////////////////////////////// + constructor(address _underlying, address _governor) + ERC4626(ERC20(_underlying), "Staked USDe", "sUSDe") + Owned(_governor) + { + SILO = address(new MockSilo(asset)); + VESTING_DURATION = 8 hours; + COOLDOWN_DURATION = 7 days; + } + + ////////////////////////////////////////////////////// + /// --- VIEWS + ////////////////////////////////////////////////////// + function totalAssets() public view override returns (uint256) { + return asset.balanceOf(address(this)) - getUnvestedAmount(); + } + + function getUnvestedAmount() public view returns (uint256) { + uint256 timeSinceLastDistribution = block.timestamp - lastDistributionTimestamp; + + if (timeSinceLastDistribution >= VESTING_DURATION) { + return 0; + } + + uint256 deltaT; + unchecked { + deltaT = VESTING_DURATION - timeSinceLastDistribution; + } + return (vestingAmount * deltaT) / VESTING_DURATION; + } + + ////////////////////////////////////////////////////// + /// --- MUTATIVE FUNCTIONS + ////////////////////////////////////////////////////// + function unstake(address receiver) external { + UserCooldown storage cooldown = cooldowns[msg.sender]; + uint256 assets = cooldown.underlyingAmount; + + if (block.timestamp >= cooldown.cooldownEnd) { + delete cooldowns[msg.sender]; + + MockSilo(SILO).withdraw(receiver, assets); + } else { + revert("SUSDE: Invalid cooldown"); + } + } + + function cooldownAssets(uint256 assets) external returns (uint256 shares) { + if (assets > maxWithdraw(msg.sender)) revert("SUSDE: Excessive withdraw amount"); + + shares = previewWithdraw(assets); + + cooldowns[msg.sender].cooldownEnd = uint104(block.timestamp + COOLDOWN_DURATION); + cooldowns[msg.sender].underlyingAmount += uint152(assets); + + super.withdraw(assets, SILO, msg.sender); + } + + function cooldownShares(uint256 shares) external returns (uint256 assets) { + if (shares > maxRedeem(msg.sender)) revert("SUSDE: Excessive redeem amount"); + + assets = previewRedeem(shares); + + cooldowns[msg.sender].cooldownEnd = uint104(block.timestamp + COOLDOWN_DURATION); + cooldowns[msg.sender].underlyingAmount += uint152(assets); + + super.withdraw(assets, SILO, msg.sender); + } + + function withdraw(uint256, address, address) public pure override returns (uint256) { + revert("SUSDE: Use cooldown functions"); + } + + function redeem(uint256, address, address) public pure override returns (uint256) { + revert("SUSDE: Use cooldown functions"); + } + + ////////////////////////////////////////////////////// + /// --- ADMIN FUNCTIONS + ////////////////////////////////////////////////////// + function transferInRewards(uint256 amount) external onlyOwner { + require(amount != 0, "SUSDE: amount zero"); + + // Ensure previous vesting period is complete before starting a new one + // _updateVestingAmount(amount) in original contract + require(getUnvestedAmount() == 0, "SUSDE: previous vesting not complete"); + vestingAmount = amount; + lastDistributionTimestamp = block.timestamp; + + asset.transferFrom(msg.sender, address(this), amount); + emit RewardReceived(amount); + } +} + +contract MockSilo is Owned { + ////////////////////////////////////////////////////// + /// --- IMMUTABLES + ////////////////////////////////////////////////////// + ERC20 public immutable _USDE; + + ///////////////////////////////////////////////////// + /// --- CONSTRUCTOR + ////////////////////////////////////////////////////// + constructor(ERC20 _usde) Owned(msg.sender) { + _USDE = _usde; + } + + ////////////////////////////////////////////////////// + /// --- MUTATIVE FUNCTIONS + ////////////////////////////////////////////////////// + function withdraw(address to, uint256 amount) external onlyOwner { + _USDE.transfer(to, amount); + } +}