diff --git a/script/coverage.sh b/script/coverage.sh index f9bed604..581f6de4 100755 --- a/script/coverage.sh +++ b/script/coverage.sh @@ -7,17 +7,106 @@ cd .. folder_path="coverage" if [ ! -d "$folder_path" ]; then - # If not, create the folder mkdir -p "$folder_path" echo "Folder created at: $folder_path" else echo "Folder already exists at: $folder_path" fi +# Configuration: Define test files for different EVM versions +declare -a SHANGHAI_TESTS=( + # "test/helpers/LiquidStaking.t.sol" + test/helpers/PooledStaking.t.sol + # Add more shanghai tests here in the future + # "test/helpers/AnotherShanghaiTest.t.sol" +) +declare -a CANCUN_TESTS=( + # Add cancun tests here when needed + # "test/helpers/CancunTest.t.sol" +) -# Generates lcov.info -forge coverage --report lcov --skip scripts --report-file "$folder_path/lcov.info" +# Function to build match patterns for forge coverage +build_match_patterns() { + local tests=("$@") + local patterns="" + + for test in "${tests[@]}"; do + if [[ -n "$patterns" ]]; then + patterns="$patterns --match-path *$(basename "$test")" + else + patterns="--match-path *$(basename "$test")" + fi + done + + echo "$patterns" +} + +# Function to build no-match patterns for forge coverage +build_no_match_patterns() { + local tests=("$@") + local patterns="" + + for test in "${tests[@]}"; do + if [[ -n "$patterns" ]]; then + patterns="$patterns --no-match-path *$(basename "$test")" + else + patterns="--no-match-path *$(basename "$test")" + fi + done + + echo "$patterns" +} + +echo "Running coverage with inline EVM version flags..." +echo "-----------------------------------------------" + +# Build list of all special EVM tests to exclude from default London run +ALL_SPECIAL_EVM_TESTS=("${SHANGHAI_TESTS[@]}" "${CANCUN_TESTS[@]}") +LONDON_NO_MATCH_PATTERNS=$(build_no_match_patterns "${ALL_SPECIAL_EVM_TESTS[@]}") + +# Generate coverage for London EVM (default) - exclude special EVM tests +if [[ -n "$LONDON_NO_MATCH_PATTERNS" ]]; then + echo "Running coverage for London EVM..." + echo "Excluding: ${ALL_SPECIAL_EVM_TESTS[*]}" + forge coverage --evm-version london --report lcov --skip scripts $LONDON_NO_MATCH_PATTERNS --report-file "$folder_path/lcov-london.info" +else + echo "Running coverage for London EVM - no exclusions..." + forge coverage --evm-version london --report lcov --skip scripts --report-file "$folder_path/lcov-london.info" +fi + +# Generate coverage for Shanghai EVM tests if any exist +if [ ${#SHANGHAI_TESTS[@]} -gt 0 ]; then + echo "Running coverage for Shanghai EVM..." + echo "Including: ${SHANGHAI_TESTS[*]}" + SHANGHAI_MATCH_PATTERNS=$(build_match_patterns "${SHANGHAI_TESTS[@]}") + forge coverage --evm-version shanghai --report lcov --skip scripts $SHANGHAI_MATCH_PATTERNS --report-file "$folder_path/lcov-shanghai.info" +fi + +# Generate coverage for Cancun EVM tests if any exist +if [ ${#CANCUN_TESTS[@]} -gt 0 ]; then + echo "Running coverage for Cancun EVM..." + echo "Including: ${CANCUN_TESTS[*]}" + CANCUN_MATCH_PATTERNS=$(build_match_patterns "${CANCUN_TESTS[@]}") + forge coverage --evm-version cancun --report lcov --skip scripts $CANCUN_MATCH_PATTERNS --report-file "$folder_path/lcov-cancun.info" +fi + +# Build the list of coverage files to merge +COVERAGE_FILES=("$folder_path/lcov-london.info") +if [ ${#SHANGHAI_TESTS[@]} -gt 0 ]; then + COVERAGE_FILES+=("$folder_path/lcov-shanghai.info") +fi +if [ ${#CANCUN_TESTS[@]} -gt 0 ]; then + COVERAGE_FILES+=("$folder_path/lcov-cancun.info") +fi + +# Merge the lcov files +echo "Merging coverage reports..." +echo "Files to merge: ${COVERAGE_FILES[*]}" +lcov \ + --rc branch_coverage=1 \ + $(printf -- "--add-tracefile %s " "${COVERAGE_FILES[@]}") \ + --output-file "$folder_path/lcov.info" # Filter out test, mock, and script files lcov \ @@ -39,4 +128,4 @@ then --output-directory "$folder_path" \ "$folder_path/filtered-lcov.info" open "$folder_path/index.html" -fi \ No newline at end of file +fi \ No newline at end of file diff --git a/test/helpers/PooledStaking.t.sol b/test/helpers/PooledStaking.t.sol new file mode 100644 index 00000000..11298dbf --- /dev/null +++ b/test/helpers/PooledStaking.t.sol @@ -0,0 +1,440 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { IERC1271 } from "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +import { BaseTest } from "../utils/BaseTest.t.sol"; +import { EncoderLib } from "../../src/libraries/EncoderLib.sol"; +import { Implementation, SignatureType } from "../utils/Types.t.sol"; +import { Execution, Delegation, Caveat, ModeCode } from "../../src/utils/Types.sol"; +import { AllowedTargetsEnforcer } from "../../src/enforcers/AllowedTargetsEnforcer.sol"; +import { AllowedMethodsEnforcer } from "../../src/enforcers/AllowedMethodsEnforcer.sol"; +import { ValueLteEnforcer } from "../../src/enforcers/ValueLteEnforcer.sol"; +import { ExactCalldataEnforcer } from "../../src/enforcers/ExactCalldataEnforcer.sol"; +import { LogicalOrWrapperEnforcer } from "../../src/enforcers/LogicalOrWrapperEnforcer.sol"; +import { IEthFoxVault, IVaultEthStaking, IVaultEnterExit } from "../helpers/interfaces/IEthFoxVault.sol"; +import { EIP7702StatelessDeleGator } from "../../src/EIP7702/EIP7702StatelessDeleGator.sol"; +import { ERC1271Lib } from "../../src/libraries/ERC1271Lib.sol"; + +// @dev Do not remove this comment below +/// forge-config: default.evm_version = "shanghai" + +/** + * @title PooledStaking Forked Test + * @notice Tests delegation-based interactions with MetaMask PooledStaking (EthFoxVault) on Ethereum mainnet + * + * @dev This test suite validates delegation patterns for staking operations using mainnet forks at specific block numbers. + * It includes both direct protocol interactions (without delegations) to verify baseline functionality, and delegation-based + * interactions using a single LogicalOrWrapperEnforcer that supports three operation types: deposits, entering the exit queue, + * and claiming exited assets. + * + * @dev Amount restrictions are intentionally omitted (e.g., NativeTokenTransferAmountEnforcer) since all tokens in the + * DeleGator account are intended for investment purposes. The deposit operation uses exact calldata matching to ensure tokens + * can only be sent to the root delegator. Exit queue and claim operations automatically send outputs to msg.sender + * (the root delegator) and have flexible parameters due to trust in the investment system. + * + * @dev These tests intentionally deploy fresh delegation framework contracts rather than using existing deployments to detect + * compatibility issues when contracts are modified. Regular maintenance of this test file is expected as the + * delegation framework evolves. + * + * @dev Exit queue and claim tests use vm.prank with real mainnet addresses at specific blocks where these users + * historically had the required permissions, simplifying test setup compared to replicating the full multi-step + * staking workflow. + */ +contract PooledStakingTest is BaseTest { + ////////////////////// State ////////////////////// + + // MetaMask PooledStaking contract (EthFoxVault) on Ethereum mainnet + IEthFoxVault public constant VAULT = IEthFoxVault(0x4FEF9D741011476750A243aC70b9789a63dd47Df); + + // Enforcers for delegation restrictions + AllowedTargetsEnforcer public allowedTargetsEnforcer; + AllowedMethodsEnforcer public allowedMethodsEnforcer; + ValueLteEnforcer public valueLteEnforcer; + ExactCalldataEnforcer public exactCalldataEnforcer; + LogicalOrWrapperEnforcer public logicalOrWrapperEnforcer; + + // Real mainnet addresses that have interacted with the vault + address private constant EXIT_REQUESTER_ADDRESS = 0x600c080F3E0ce390Ee4699c70c1628fEF150eda4; + address private constant CLAIMER_ADDRESS = 0xFbFFd0bBe31400567C18421D39D040ff3C7EdF42; + + // Test constants + uint256 public constant MAINNET_FORK_BLOCK = 22734910; + + // Group indices for different vault operations + uint256 private constant DEPOSIT_GROUP = 0; + uint256 private constant EXIT_QUEUE_GROUP = 1; + uint256 private constant CLAIM_ASSETS_GROUP = 2; + + ////////////////////// Setup ////////////////////// + + function setUpContracts() public { + IMPLEMENTATION = Implementation.Hybrid; + SIGNATURE_TYPE = SignatureType.RawP256; + super.setUp(); + + allowedTargetsEnforcer = new AllowedTargetsEnforcer(); + allowedMethodsEnforcer = new AllowedMethodsEnforcer(); + valueLteEnforcer = new ValueLteEnforcer(); + exactCalldataEnforcer = new ExactCalldataEnforcer(); + logicalOrWrapperEnforcer = new LogicalOrWrapperEnforcer(delegationManager); + + vm.label(address(allowedTargetsEnforcer), "AllowedTargetsEnforcer"); + vm.label(address(allowedMethodsEnforcer), "AllowedMethodsEnforcer"); + vm.label(address(valueLteEnforcer), "ValueLteEnforcer"); + vm.label(address(exactCalldataEnforcer), "ExactCalldataEnforcer"); + vm.label(address(logicalOrWrapperEnforcer), "LogicalOrWrapperEnforcer"); + vm.label(address(VAULT), "MetaMask PooledStaking"); + vm.label(EXIT_REQUESTER_ADDRESS, "MainnetExitRequester"); + vm.label(CLAIMER_ADDRESS, "MainnetClaimer"); + } + + ////////////////////// Tests ////////////////////// + + /** + * @notice Test direct deposit functionality + */ + function test_deposit_direct() public { + vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), MAINNET_FORK_BLOCK); + setUpContracts(); + + uint256 initialShares_ = VAULT.getShares(address(users.alice.deleGator)); + uint256 depositAmount_ = 1 ether; + + vm.prank(address(users.alice.deleGator)); + VAULT.deposit{ value: depositAmount_ }(address(users.alice.deleGator), address(0)); + + uint256 finalShares_ = VAULT.getShares(address(users.alice.deleGator)); + assertGt(finalShares_, initialShares_, "Shares should have increased after deposit"); + } + + /** + * @notice Test deposit via delegation from Alice to Bob + * @dev Uses exact calldata matching to ensure tokens can only be deposited to the root delegator's address, + * preventing the redeemer from redirecting funds elsewhere + */ + function test_deposit_viaDelegation() public { + vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), MAINNET_FORK_BLOCK); + setUpContracts(); + + uint256 initialShares_ = VAULT.getShares(address(users.alice.deleGator)); + + // Create caveat groups for deposit operations + LogicalOrWrapperEnforcer.CaveatGroup[] memory groups_ = + _createVaultCaveatGroups(DEPOSIT_GROUP, address(users.alice.deleGator)); + + // Create selected group for deposit operations + bytes[] memory caveatArgs_ = new bytes[](2); + caveatArgs_[0] = hex""; // No args for allowedTargetsEnforcer + caveatArgs_[1] = hex""; // No args for exactCalldataEnforcer + LogicalOrWrapperEnforcer.SelectedGroup memory selectedGroup_ = + LogicalOrWrapperEnforcer.SelectedGroup({ groupIndex: DEPOSIT_GROUP, caveatArgs: caveatArgs_ }); + + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = + Caveat({ args: abi.encode(selectedGroup_), enforcer: address(logicalOrWrapperEnforcer), terms: abi.encode(groups_) }); + + Delegation memory delegation_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: address(users.alice.deleGator), + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 0, + signature: hex"" + }); + + delegation_ = signDelegation(users.alice, delegation_); + + uint256 depositAmount_ = 1 ether; + Execution memory execution_ = Execution({ + target: address(VAULT), + value: depositAmount_, + callData: abi.encodeWithSelector(IVaultEthStaking.deposit.selector, address(users.alice.deleGator), address(0)) + }); + + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + + invokeDelegation_UserOp(users.bob, delegations_, execution_); + + uint256 finalShares_ = VAULT.getShares(address(users.alice.deleGator)); + assertGt(finalShares_, initialShares_, "Shares should have increased after deposit"); + } + + /** + * @notice Test direct exit queue entry using real mainnet address + */ + function test_exitQueue_direct() public { + vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 22769140); + setUpContracts(); + + uint256 initialShares_ = VAULT.getShares(EXIT_REQUESTER_ADDRESS); + uint256 initialQueuedShares_ = VAULT.queuedShares(); + uint256 initialEthBalance_ = EXIT_REQUESTER_ADDRESS.balance; + + assertGt(initialShares_, 0, "Exit requester must have shares to enter exit queue"); + + vm.prank(EXIT_REQUESTER_ADDRESS); + VAULT.enterExitQueue(initialShares_, EXIT_REQUESTER_ADDRESS); + + uint256 finalShares_ = VAULT.getShares(EXIT_REQUESTER_ADDRESS); + uint256 finalQueuedShares_ = VAULT.queuedShares(); + uint256 finalEthBalance_ = EXIT_REQUESTER_ADDRESS.balance; + + assertEq(finalShares_, 0, "User shares should be zero after entering exit queue"); + assertGt(finalQueuedShares_, initialQueuedShares_, "Queued shares should have increased"); + assertEq(finalQueuedShares_, initialQueuedShares_ + initialShares_, "All user shares should be in queue"); + assertEq(finalEthBalance_, initialEthBalance_, "ETH balance should remain unchanged"); + } + + /** + * @notice Test exit queue entry via delegation using real mainnet address + * @dev The vault automatically handles token flows to msg.sender (root delegator). Function parameters are + * unrestricted to allow flexibility within the trusted investment system. Uses vm.prank with a real address + * that historically had shares at this block number. + */ + function test_exitQueue_viaDelegation() public { + vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 22769140); + setUpContracts(); + + _assignImplementationAndVerify(EXIT_REQUESTER_ADDRESS); + + uint256 initialShares_ = VAULT.getShares(EXIT_REQUESTER_ADDRESS); + uint256 initialQueuedShares_ = VAULT.queuedShares(); + uint256 initialEthBalance_ = EXIT_REQUESTER_ADDRESS.balance; + + assertGt(initialShares_, 0, "Exit requester must have shares to enter exit queue"); + + // Create caveat groups for exit queue operations + LogicalOrWrapperEnforcer.CaveatGroup[] memory groups_ = _createVaultCaveatGroups(EXIT_QUEUE_GROUP, EXIT_REQUESTER_ADDRESS); + + // Create selected group for exit queue operations + bytes[] memory caveatArgs_ = new bytes[](3); + caveatArgs_[0] = hex""; // No args for allowedTargetsEnforcer + caveatArgs_[1] = hex""; // No args for valueLteEnforcer + caveatArgs_[2] = hex""; // No args for allowedMethodsEnforcer + LogicalOrWrapperEnforcer.SelectedGroup memory selectedGroup_ = + LogicalOrWrapperEnforcer.SelectedGroup({ groupIndex: EXIT_QUEUE_GROUP, caveatArgs: caveatArgs_ }); + + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = + Caveat({ args: abi.encode(selectedGroup_), enforcer: address(logicalOrWrapperEnforcer), terms: abi.encode(groups_) }); + + Delegation memory delegation_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: EXIT_REQUESTER_ADDRESS, + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 0, + signature: hex"" + }); + + delegation_ = _mockSignDelegation(delegation_); + + Execution memory execution_ = Execution({ + target: address(VAULT), + value: 0, + callData: abi.encodeWithSelector(IVaultEnterExit.enterExitQueue.selector, initialShares_, EXIT_REQUESTER_ADDRESS) + }); + + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + + invokeDelegation_UserOp(users.bob, delegations_, execution_); + + uint256 finalShares_ = VAULT.getShares(EXIT_REQUESTER_ADDRESS); + uint256 finalQueuedShares_ = VAULT.queuedShares(); + uint256 finalEthBalance_ = EXIT_REQUESTER_ADDRESS.balance; + + assertEq(finalShares_, 0, "User shares should be zero after entering exit queue"); + assertGt(finalQueuedShares_, initialQueuedShares_, "Queued shares should have increased"); + assertEq(finalQueuedShares_, initialQueuedShares_ + initialShares_, "All user shares should be in queue"); + assertEq(finalEthBalance_, initialEthBalance_, "ETH balance should remain unchanged"); + } + + /** + * @notice Test direct claiming of exited assets using real mainnet address + */ + function test_claimExitedAssets_direct() public { + vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 22769133); + setUpContracts(); + + uint256 initialEthBalance_ = CLAIMER_ADDRESS.balance; + uint256 positionTicket_ = 31305208530820384526455; + uint256 claimTimestamp_ = 1750591523; + uint256 exitQueueIndex_ = 749; + + (,, uint256 claimedAssets_) = + VAULT.calculateExitedAssets(CLAIMER_ADDRESS, positionTicket_, claimTimestamp_, exitQueueIndex_); + + vm.prank(CLAIMER_ADDRESS); + VAULT.claimExitedAssets(positionTicket_, claimTimestamp_, exitQueueIndex_); + + uint256 ethObtained_ = CLAIMER_ADDRESS.balance - initialEthBalance_; + assertEq(ethObtained_, claimedAssets_, "ETH balance should equal claimed assets"); + } + + /** + * @notice Test claiming exited assets via delegation using real mainnet address + * @dev The vault automatically sends claimed ETH to msg.sender (root delegator). Function parameters are + * unrestricted to allow flexibility within the trusted investment system. Uses vm.prank with a real address + * that historically had claimable assets at this block number. + */ + function test_claimExitedAssets_viaDelegation() public { + vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 22769133); + setUpContracts(); + + _assignImplementationAndVerify(CLAIMER_ADDRESS); + + uint256 initialEthBalance_ = CLAIMER_ADDRESS.balance; + uint256 initialShares_ = VAULT.getShares(CLAIMER_ADDRESS); + + // Create caveat groups for claim operations + LogicalOrWrapperEnforcer.CaveatGroup[] memory groups_ = _createVaultCaveatGroups(CLAIM_ASSETS_GROUP, CLAIMER_ADDRESS); + + // Create selected group for claim operations + // caveatArgs = No args for allowedTargetsEnforcer, No args for valueLteEnforcer, No args for allowedMethodsEnforcer + LogicalOrWrapperEnforcer.SelectedGroup memory selectedGroup_ = + LogicalOrWrapperEnforcer.SelectedGroup({ groupIndex: CLAIM_ASSETS_GROUP, caveatArgs: new bytes[](3) }); + + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = + Caveat({ args: abi.encode(selectedGroup_), enforcer: address(logicalOrWrapperEnforcer), terms: abi.encode(groups_) }); + + Delegation memory delegation_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: CLAIMER_ADDRESS, + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 0, + signature: hex"" + }); + + delegation_ = _mockSignDelegation(delegation_); + + uint256 positionTicket_ = 31305208530820384526455; + uint256 claimTimestamp_ = 1750591523; + uint256 exitQueueIndex_ = 749; + + (,, uint256 expectedClaimedAssets_) = + VAULT.calculateExitedAssets(CLAIMER_ADDRESS, positionTicket_, claimTimestamp_, exitQueueIndex_); + + Execution memory execution_ = Execution({ + target: address(VAULT), + value: 0, + callData: abi.encodeWithSelector( + IVaultEnterExit.claimExitedAssets.selector, positionTicket_, claimTimestamp_, exitQueueIndex_ + ) + }); + + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + + invokeDelegation_UserOp(users.bob, delegations_, execution_); + + uint256 ethObtained_ = CLAIMER_ADDRESS.balance - initialEthBalance_; + assertEq(ethObtained_, expectedClaimedAssets_, "ETH obtained should equal expected claimed assets"); + assertEq(VAULT.getShares(CLAIMER_ADDRESS), initialShares_, "Shares should remain unchanged after claiming assets"); + } + + ////////////////////// Helper Functions ////////////////////// + + /** + * @notice Assigns EIP-7702 implementation to an address and verifies NAME() function + */ + function _assignImplementationAndVerify(address _account) internal { + vm.etch(_account, bytes.concat(hex"ef0100", abi.encodePacked(address(eip7702StatelessDeleGatorImpl)))); + + string memory name_ = EIP7702StatelessDeleGator(payable(_account)).NAME(); + assertEq(name_, "EIP7702StatelessDeleGator", "NAME() should return correct implementation name"); + } + + /** + * @notice Mocks signature validation for delegation testing + * @dev Required because it's not possible to produce real signatures from pranked addresses + */ + function _mockSignDelegation(Delegation memory _delegation) internal returns (Delegation memory delegation_) { + bytes32 delegationHash_ = EncoderLib._getDelegationHash(_delegation); + bytes32 domainHash_ = delegationManager.getDomainHash(); + bytes32 typedDataHash_ = MessageHashUtils.toTypedDataHash(domainHash_, delegationHash_); + + bytes memory dummySignature_ = + hex"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1b"; + + vm.mockCall( + address(_delegation.delegator), + abi.encodeWithSelector(IERC1271.isValidSignature.selector, typedDataHash_, dummySignature_), + abi.encode(ERC1271Lib.EIP1271_MAGIC_VALUE) + ); + + delegation_ = Delegation({ + delegate: _delegation.delegate, + delegator: _delegation.delegator, + authority: _delegation.authority, + caveats: _delegation.caveats, + salt: _delegation.salt, + signature: dummySignature_ + }); + } + + /** + * @notice Creates caveat groups for different vault operations + * @param _groupIndex The group index (0=deposit, 1=enterExitQueue, 2=claimExitedAssets) + * @param _delegator The delegator address for deposit operations + * @return groups Array of caveat groups + */ + function _createVaultCaveatGroups( + uint256 _groupIndex, + address _delegator + ) + internal + view + returns (LogicalOrWrapperEnforcer.CaveatGroup[] memory groups) + { + require(_groupIndex <= 2, "Invalid group index"); + + groups = new LogicalOrWrapperEnforcer.CaveatGroup[](3); + + // Group 0: Deposit operations + { + Caveat[] memory depositCaveats_ = new Caveat[](2); + depositCaveats_[0] = + Caveat({ args: hex"", enforcer: address(allowedTargetsEnforcer), terms: abi.encodePacked(address(VAULT)) }); + depositCaveats_[1] = Caveat({ + args: hex"", + enforcer: address(exactCalldataEnforcer), + terms: abi.encodeWithSelector(IVaultEthStaking.deposit.selector, _delegator, address(0)) + }); + groups[0] = LogicalOrWrapperEnforcer.CaveatGroup({ caveats: depositCaveats_ }); + } + + // Group 1: Enter exit queue operations + { + Caveat[] memory exitQueueCaveats_ = new Caveat[](3); + exitQueueCaveats_[0] = + Caveat({ args: hex"", enforcer: address(allowedTargetsEnforcer), terms: abi.encodePacked(address(VAULT)) }); + exitQueueCaveats_[1] = Caveat({ args: hex"", enforcer: address(valueLteEnforcer), terms: abi.encode(uint256(0)) }); + exitQueueCaveats_[2] = Caveat({ + args: hex"", + enforcer: address(allowedMethodsEnforcer), + terms: abi.encodePacked(IVaultEnterExit.enterExitQueue.selector) + }); + groups[1] = LogicalOrWrapperEnforcer.CaveatGroup({ caveats: exitQueueCaveats_ }); + } + + // Group 2: Claim exited assets operations + { + Caveat[] memory claimCaveats_ = new Caveat[](3); + claimCaveats_[0] = + Caveat({ args: hex"", enforcer: address(allowedTargetsEnforcer), terms: abi.encodePacked(address(VAULT)) }); + claimCaveats_[1] = Caveat({ args: hex"", enforcer: address(valueLteEnforcer), terms: abi.encode(uint256(0)) }); + claimCaveats_[2] = Caveat({ + args: hex"", + enforcer: address(allowedMethodsEnforcer), + terms: abi.encodePacked(IVaultEnterExit.claimExitedAssets.selector) + }); + groups[2] = LogicalOrWrapperEnforcer.CaveatGroup({ caveats: claimCaveats_ }); + } + } +} diff --git a/test/helpers/interfaces/IEthFoxVault.sol b/test/helpers/interfaces/IEthFoxVault.sol new file mode 100644 index 00000000..8c0fe962 --- /dev/null +++ b/test/helpers/interfaces/IEthFoxVault.sol @@ -0,0 +1,541 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import { IERC5267 } from "@openzeppelin/contracts/interfaces/IERC5267.sol"; +import { IERC173 } from "../../../src/interfaces/IERC173.sol"; + +/** + * @title IVaultEthStaking + * @author StakeWise + * @notice Defines the interface for the VaultEthStaking contract + */ +interface IVaultEthStaking { + /** + * @notice Deposit ETH to the Vault + * @param receiver The address that will receive Vault's shares + * @param referrer The address of the referrer. Set to zero address if not used. + * @return shares The number of shares minted + */ + function deposit(address receiver, address referrer) external payable returns (uint256 shares); +} + +/** + * @title IVaultEnterExit + * @author StakeWise + * @notice Defines the interface for the VaultEnterExit contract + */ +interface IVaultEnterExit { + /** + * @notice Locks shares to the exit queue. The shares continue earning rewards until they will be burned by the Vault. + * @param shares The number of shares to lock + * @param receiver The address that will receive assets upon withdrawal + * @return positionTicket The position ticket of the exit queue + */ + function enterExitQueue(uint256 shares, address receiver) external returns (uint256 positionTicket); + + /** + * @notice Get the exit queue index to claim exited assets from + * @param positionTicket The exit queue position ticket to get the index for + * @return The exit queue index that should be used to claim exited assets. + * Returns -1 in case such index does not exist. + */ + function getExitQueueIndex(uint256 positionTicket) external view returns (int256); + + /** + * @notice Calculates the number of shares and assets that can be claimed from the exit queue. + * @param receiver The address that will receive assets upon withdrawal + * @param positionTicket The exit queue ticket received after the `enterExitQueue` call + * @param timestamp The timestamp when the shares entered the exit queue + * @param exitQueueIndex The exit queue index at which the shares were burned. It can be looked up by calling + * `getExitQueueIndex`. + * @return leftShares The number of shares that are still in the queue + * @return claimedShares The number of claimed shares + * @return claimedAssets The number of claimed assets + */ + function calculateExitedAssets( + address receiver, + uint256 positionTicket, + uint256 timestamp, + uint256 exitQueueIndex + ) + external + view + returns (uint256 leftShares, uint256 claimedShares, uint256 claimedAssets); + + /** + * @notice Claims assets that were withdrawn by the Vault. It can be called only after the `enterExitQueue` call by the + * `receiver`. + * @param positionTicket The exit queue ticket received after the `enterExitQueue` call + * @param timestamp The timestamp when the shares entered the exit queue + * @param exitQueueIndex The exit queue index at which the shares were burned. It can be looked up by calling + * `getExitQueueIndex`. + * @return newPositionTicket The new exit queue ticket in case not all the shares were burned. Otherwise 0. + * @return claimedShares The number of shares claimed + * @return claimedAssets The number of assets claimed + */ + function claimExitedAssets( + uint256 positionTicket, + uint256 timestamp, + uint256 exitQueueIndex + ) + external + returns (uint256 newPositionTicket, uint256 claimedShares, uint256 claimedAssets); + + /** + * @notice Redeems assets from the Vault by utilising what has not been staked yet. Can only be called when vault is not + * collateralized. + * @param shares The number of shares to burn + * @param receiver The address that will receive assets + * @return assets The number of assets withdrawn + */ + function redeem(uint256 shares, address receiver) external returns (uint256 assets); +} + +/** + * @title IKeeperRewards + * @author StakeWise + * @notice Defines the interface for the Keeper contract rewards + */ +interface IKeeperRewards { + /** + * @notice Event emitted on rewards update + * @param caller The address of the function caller + * @param rewardsRoot The new rewards merkle tree root + * @param avgRewardPerSecond The new average reward per second + * @param updateTimestamp The update timestamp used for rewards calculation + * @param nonce The nonce used for verifying signatures + * @param rewardsIpfsHash The new rewards IPFS hash + */ + event RewardsUpdated( + address indexed caller, + bytes32 indexed rewardsRoot, + uint256 avgRewardPerSecond, + uint64 updateTimestamp, + uint64 nonce, + string rewardsIpfsHash + ); + + /** + * @notice Event emitted on Vault harvest + * @param vault The address of the Vault + * @param rewardsRoot The rewards merkle tree root + * @param totalAssetsDelta The Vault total assets delta since last sync. Can be negative in case of penalty/slashing. + * @param unlockedMevDelta The Vault execution reward that can be withdrawn from shared MEV escrow. Only used by shared MEV + * Vaults. + */ + event Harvested(address indexed vault, bytes32 indexed rewardsRoot, int256 totalAssetsDelta, uint256 unlockedMevDelta); + + /** + * @notice Event emitted on rewards min oracles number update + * @param oracles The new minimum number of oracles required to update rewards + */ + event RewardsMinOraclesUpdated(uint256 oracles); + + /** + * @notice A struct containing the last synced Vault's cumulative reward + * @param assets The Vault cumulative reward earned since the start. Can be negative in case of penalty/slashing. + * @param nonce The nonce of the last sync + */ + struct Reward { + int192 assets; + uint64 nonce; + } + + /** + * @notice A struct containing the last unlocked Vault's cumulative execution reward that can be withdrawn from shared MEV + * escrow. Only used by shared MEV Vaults. + * @param assets The shared MEV Vault's cumulative execution reward that can be withdrawn + * @param nonce The nonce of the last sync + */ + struct UnlockedMevReward { + uint192 assets; + uint64 nonce; + } + + /** + * @notice A struct containing parameters for rewards update + * @param rewardsRoot The new rewards merkle root + * @param avgRewardPerSecond The new average reward per second + * @param updateTimestamp The update timestamp used for rewards calculation + * @param rewardsIpfsHash The new IPFS hash with all the Vaults' rewards for the new root + * @param signatures The concatenation of the Oracles' signatures + */ + struct RewardsUpdateParams { + bytes32 rewardsRoot; + uint256 avgRewardPerSecond; + uint64 updateTimestamp; + string rewardsIpfsHash; + bytes signatures; + } + + /** + * @notice A struct containing parameters for harvesting rewards. Can only be called by Vault. + * @param rewardsRoot The rewards merkle root + * @param reward The Vault cumulative reward earned since the start. Can be negative in case of penalty/slashing. + * @param unlockedMevReward The Vault cumulative execution reward that can be withdrawn from shared MEV escrow. Only used by + * shared MEV Vaults. + * @param proof The proof to verify that Vault's reward is correct + */ + struct HarvestParams { + bytes32 rewardsRoot; + int160 reward; + uint160 unlockedMevReward; + bytes32[] proof; + } + + /** + * @notice Previous Rewards Root + * @return The previous merkle tree root of the rewards accumulated by the Vaults + */ + function prevRewardsRoot() external view returns (bytes32); + + /** + * @notice Rewards Root + * @return The latest merkle tree root of the rewards accumulated by the Vaults + */ + function rewardsRoot() external view returns (bytes32); + + /** + * @notice Rewards Nonce + * @return The nonce used for updating rewards merkle tree root + */ + function rewardsNonce() external view returns (uint64); + + /** + * @notice The last rewards update + * @return The timestamp of the last rewards update + */ + function lastRewardsTimestamp() external view returns (uint64); + + /** + * @notice The minimum number of oracles required to update rewards + * @return The minimum number of oracles + */ + function rewardsMinOracles() external view returns (uint256); + + /** + * @notice The rewards delay + * @return The delay in seconds between rewards updates + */ + function rewardsDelay() external view returns (uint256); + + /** + * @notice Get last synced Vault cumulative reward + * @param vault The address of the Vault + * @return assets The last synced reward assets + * @return nonce The last synced reward nonce + */ + function rewards(address vault) external view returns (int192 assets, uint64 nonce); + + /** + * @notice Get last unlocked shared MEV Vault cumulative reward + * @param vault The address of the Vault + * @return assets The last synced reward assets + * @return nonce The last synced reward nonce + */ + function unlockedMevRewards(address vault) external view returns (uint192 assets, uint64 nonce); + + /** + * @notice Checks whether Vault must be harvested + * @param vault The address of the Vault + * @return `true` if the Vault requires harvesting, `false` otherwise + */ + function isHarvestRequired(address vault) external view returns (bool); + + /** + * @notice Checks whether the Vault can be harvested + * @param vault The address of the Vault + * @return `true` if Vault can be harvested, `false` otherwise + */ + function canHarvest(address vault) external view returns (bool); + + /** + * @notice Checks whether rewards can be updated + * @return `true` if rewards can be updated, `false` otherwise + */ + function canUpdateRewards() external view returns (bool); + + /** + * @notice Checks whether the Vault has registered validators + * @param vault The address of the Vault + * @return `true` if Vault is collateralized, `false` otherwise + */ + function isCollateralized(address vault) external view returns (bool); + + /** + * @notice Update rewards data + * @param params The struct containing rewards update parameters + */ + function updateRewards(RewardsUpdateParams calldata params) external; + + /** + * @notice Harvest rewards. Can be called only by Vault. + * @param params The struct containing rewards harvesting parameters + * @return totalAssetsDelta The total reward/penalty accumulated by the Vault since the last sync + * @return unlockedMevDelta The Vault execution reward that can be withdrawn from shared MEV escrow. Only used by shared MEV + * Vaults. + * @return harvested `true` when the rewards were harvested, `false` otherwise + */ + function harvest(HarvestParams calldata params) + external + returns (int256 totalAssetsDelta, uint256 unlockedMevDelta, bool harvested); + + /** + * @notice Set min number of oracles for confirming rewards update. Can only be called by the owner. + * @param _rewardsMinOracles The new min number of oracles for confirming rewards update + */ + function setRewardsMinOracles(uint256 _rewardsMinOracles) external; +} + +/** + * @title IKeeperOracles + * @author StakeWise + * @notice Defines the interface for the KeeperOracles contract + */ +interface IKeeperOracles is IERC5267 { + /** + * @notice Event emitted on the oracle addition + * @param oracle The address of the added oracle + */ + event OracleAdded(address indexed oracle); + + /** + * @notice Event emitted on the oracle removal + * @param oracle The address of the removed oracle + */ + event OracleRemoved(address indexed oracle); + + /** + * @notice Event emitted on oracles config update + * @param configIpfsHash The IPFS hash of the new config + */ + event ConfigUpdated(string configIpfsHash); + + /** + * @notice Function for verifying whether oracle is registered or not + * @param oracle The address of the oracle to check + * @return `true` for the registered oracle, `false` otherwise + */ + function isOracle(address oracle) external view returns (bool); + + /** + * @notice Total Oracles + * @return The total number of oracles registered + */ + function totalOracles() external view returns (uint256); + + /** + * @notice Function for adding oracle to the set + * @param oracle The address of the oracle to add + */ + function addOracle(address oracle) external; + + /** + * @notice Function for removing oracle from the set + * @param oracle The address of the oracle to remove + */ + function removeOracle(address oracle) external; + + /** + * @notice Function for updating the config IPFS hash + * @param configIpfsHash The new config IPFS hash + */ + function updateConfig(string calldata configIpfsHash) external; +} + +/** + * @title IKeeperValidators + * @author StakeWise + * @notice Defines the interface for the Keeper validators + */ +interface IKeeperValidators is IKeeperOracles, IKeeperRewards { + /** + * @notice Event emitted on validators approval + * @param vault The address of the Vault + * @param exitSignaturesIpfsHash The IPFS hash with the validators' exit signatures + */ + event ValidatorsApproval(address indexed vault, string exitSignaturesIpfsHash); + + /** + * @notice Event emitted on exit signatures update + * @param caller The address of the function caller + * @param vault The address of the Vault + * @param nonce The nonce used for verifying Oracles' signatures + * @param exitSignaturesIpfsHash The IPFS hash with the validators' exit signatures + */ + event ExitSignaturesUpdated(address indexed caller, address indexed vault, uint256 nonce, string exitSignaturesIpfsHash); + + /** + * @notice Event emitted on validators min oracles number update + * @param oracles The new minimum number of oracles required to approve validators + */ + event ValidatorsMinOraclesUpdated(uint256 oracles); + + /** + * @notice Get nonce for the next vault exit signatures update + * @param vault The address of the Vault to get the nonce for + * @return The nonce of the Vault for updating signatures + */ + function exitSignaturesNonces(address vault) external view returns (uint256); + + /** + * @notice Struct for approving registration of one or more validators + * @param validatorsRegistryRoot The deposit data root used to verify that oracles approved validators + * @param deadline The deadline for submitting the approval + * @param validators The concatenation of the validators' public key, signature and deposit data root + * @param signatures The concatenation of Oracles' signatures + * @param exitSignaturesIpfsHash The IPFS hash with the validators' exit signatures + */ + struct ApprovalParams { + bytes32 validatorsRegistryRoot; + uint256 deadline; + bytes validators; + bytes signatures; + string exitSignaturesIpfsHash; + } + + /** + * @notice The minimum number of oracles required to update validators + * @return The minimum number of oracles + */ + function validatorsMinOracles() external view returns (uint256); + + /** + * @notice Function for approving validators registration + * @param params The parameters for approving validators registration + */ + function approveValidators(ApprovalParams calldata params) external; + + /** + * @notice Function for updating exit signatures for every hard fork + * @param vault The address of the Vault to update signatures for + * @param deadline The deadline for submitting signatures update + * @param exitSignaturesIpfsHash The IPFS hash with the validators' exit signatures + * @param oraclesSignatures The concatenation of Oracles' signatures + */ + function updateExitSignatures( + address vault, + uint256 deadline, + string calldata exitSignaturesIpfsHash, + bytes calldata oraclesSignatures + ) + external; + + /** + * @notice Function for updating validators min oracles number + * @param _validatorsMinOracles The new minimum number of oracles required to approve validators + */ + function setValidatorsMinOracles(uint256 _validatorsMinOracles) external; +} + +/** + * @title IKeeper + * @author StakeWise + * @notice Defines the interface for the Keeper contract + */ +interface IKeeper is IKeeperOracles, IKeeperRewards, IKeeperValidators, IERC173 { + /** + * @notice Initializes the Keeper contract. Can only be called once. + * @param _owner The address of the owner + */ + function initialize(address _owner) external; +} + +/** + * @title IVaultState + * @author StakeWise + * @notice Defines the interface for the VaultState contract + */ +interface IVaultState { + /** + * @notice Event emitted on checkpoint creation + * @param shares The number of burned shares + * @param assets The amount of exited assets + */ + event CheckpointCreated(uint256 shares, uint256 assets); + + /** + * @notice Event emitted on minting fee recipient shares + * @param receiver The address of the fee recipient + * @param shares The number of minted shares + * @param assets The amount of minted assets + */ + event FeeSharesMinted(address receiver, uint256 shares, uint256 assets); + + /** + * @notice Total assets in the Vault + * @return The total amount of the underlying asset that is "managed" by Vault + */ + function totalAssets() external view returns (uint256); + + /** + * @notice Function for retrieving total shares + * @return The amount of shares in existence + */ + function totalShares() external view returns (uint256); + + /** + * @notice The Vault's capacity + * @return The amount after which the Vault stops accepting deposits + */ + function capacity() external view returns (uint256); + + /** + * @notice Total assets available in the Vault. They can be staked or withdrawn. + * @return The total amount of withdrawable assets + */ + function withdrawableAssets() external view returns (uint256); + + /** + * @notice Queued Shares + * @return The total number of shares queued for exit + */ + function queuedShares() external view returns (uint128); + + /** + * @notice Returns the number of shares held by an account + * @param account The account for which to look up the number of shares it has, i.e. its balance + * @return The number of shares held by the account + */ + function getShares(address account) external view returns (uint256); + + /** + * @notice Converts shares to assets + * @param assets The amount of assets to convert to shares + * @return shares The amount of shares that the Vault would exchange for the amount of assets provided + */ + function convertToShares(uint256 assets) external view returns (uint256 shares); + + /** + * @notice Converts assets to shares + * @param shares The amount of shares to convert to assets + * @return assets The amount of assets that the Vault would exchange for the amount of shares provided + */ + function convertToAssets(uint256 shares) external view returns (uint256 assets); + + /** + * @notice Check whether state update is required + * @return `true` if state update is required, `false` otherwise + */ + function isStateUpdateRequired() external view returns (bool); + + /** + * @notice Updates the total amount of assets in the Vault and its exit queue + * @param harvestParams The parameters for harvesting Keeper rewards + */ + function updateState(IKeeperRewards.HarvestParams calldata harvestParams) external; +} + +/** + * @title IEthFoxVault + * @author StakeWise + * @notice Defines the interface for the EthFoxVault contract + */ +interface IEthFoxVault is IVaultEthStaking, IVaultEnterExit, IVaultState { + /** + * @notice Ejects user from the Vault. Can only be called by the blocklist manager. + * The ejected user will be added to the blocklist and all his shares will be sent to the exit queue. + * @param user The address of the user to eject + */ + function ejectUser(address user) external; +}