From af2748504d8a001a6d4fd14070eef8f595f8e6c0 Mon Sep 17 00:00:00 2001 From: xhad Date: Sat, 23 Mar 2024 17:23:50 -0400 Subject: [PATCH] Adds ynLSD scneario test with invariants for rebasing balance transfer bug --- test/foundry/scenarios/Invariants.sol | 36 +++ test/foundry/scenarios/ynETH-Scenarios.md | 108 +++++++ test/foundry/scenarios/ynETH.spec.sol | 342 ++++++++++++++++++++++ test/foundry/scenarios/ynLSD.spec.sol | 85 ++++++ 4 files changed, 571 insertions(+) create mode 100644 test/foundry/scenarios/Invariants.sol create mode 100644 test/foundry/scenarios/ynETH-Scenarios.md create mode 100644 test/foundry/scenarios/ynETH.spec.sol create mode 100644 test/foundry/scenarios/ynLSD.spec.sol diff --git a/test/foundry/scenarios/Invariants.sol b/test/foundry/scenarios/Invariants.sol new file mode 100644 index 000000000..db162c0ee --- /dev/null +++ b/test/foundry/scenarios/Invariants.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: BSD 3-Clause License +pragma solidity ^0.8.24; + +library Invariants { + + /// Share and Assets Invariants + function shareMintIntegrity (uint256 totalSupply, uint256 previousTotal, uint256 newShares) public pure { + require(totalSupply == previousTotal + newShares, + "Invariant: Total supply should be equal to previous total plus new shares" + ); + } + + function totalDepositIntegrity (uint256 totalDeposited, uint256 previousTotal, uint256 newDeposited) public pure { + require(totalDeposited == previousTotal + newDeposited, + "Invariant: Total deposited should be equal to previous total plus new deposited" + ); + } + + function userSharesIntegrity (uint256 userShares, uint256 previousShares, uint256 newShares) public pure { + require(userShares == previousShares + newShares, + "Invariant: User shares should be equal to previous shares plus new shares" + ); + } + + function totalAssetsIntegrity (uint256 totalAssets, uint256 previousAssets, uint256 newAssets) public pure { + require(totalAssets == previousAssets + newAssets, + "Invariant: Total assets should be equal to previous assets plus new assets" + ); + } + + function totalBalanceIntegrity (uint256 balance, uint256 previousBalance, uint256 newBalance) public pure { + require(balance == previousBalance + newBalance, + "Invariant: Total balance should be equal to previous balance plus new balance" + ); + } +} \ No newline at end of file diff --git a/test/foundry/scenarios/ynETH-Scenarios.md b/test/foundry/scenarios/ynETH-Scenarios.md new file mode 100644 index 000000000..ece79c016 --- /dev/null +++ b/test/foundry/scenarios/ynETH-Scenarios.md @@ -0,0 +1,108 @@ + +## Usage Scenario Tests + +These tests are designed to verify the correct behavior of the ynETH contract in various usage scenarios. + +**Scenario 1:** Successful ETH Deposit and Share Minting + +Objective: Test that a user can deposit ETH and receive the correct amount of shares in return. + +**Scenario 2:** Deposit Paused + +Objective: Ensure that deposits are correctly paused and resumed, preventing or allowing ETH deposits accordingly. + +**Scenario 3:** Deposit and Withdraw ETH to Staking Nodes Manager + +Objective: Test the end-to-end flow of depositing ETH to an eigenpod, and withdrawing ETH to the staking nodes manager. + +**Scenario 4:** Share Accouting and Yield Accrual + +Objective: Verify that the share price correctly increases after the contract earns yield from consensus and execution rewards. + +**Scenario 5:** Emergency Withdrawal of ETH + +Objective: Test ability to withdraw all assets from eigenpods. + +**Scenario 6:** Validator and Staking Node Administration + +Objective: Test the ynETH's ability to update the address of the Staking Nodes Manager. + +**Scenario 7:** Accrual and Distribution of Fees + +Objective: Ensure that ynETH correctly accrues and distributes fees from yield earnings from execution and consensus rewards. + +**Scenario 8:** Staking Rewards Distribution + +Objective: Test the distribution of staking rewards to a multisig. + +**Scenario 9:** EigenLayer Accounting and Distribution + +Objective: Verify that ynETH correctly accounts for fund balances and withdrawals from EigenLayer. + +## Invariant Scenarios + +The following invariant scenarios are designed to verify the correct behavior of the ynETH contract in various usage scenarios. These scenarios should never fail, and if they do, it indicates there is an implementation issue somewhere in the protocol. + +**Total Assets Consistency** + +```solidity +assert(totalDepositedInPool + totalDepositedInValidators() == totalAssets()); +``` + +**Exchange Rate Integrity** + +```solidity +assert(exchangeAdjustmentRate >= 0 && exchangeAdjustmentRate <= BASIS_POINTS_DENOMINATOR); +``` +**Share Minting Consistency** + +```solidity +assert(totalSupply() == previousTotalSupply + mintedShares) +``` + +**User Shares Integrity** + +```solidity +assert(balanceOf(user) == previousUserSharesBalance + newUserSharesBalance); +``` + +**Total Deposited Integrity** + +```solidity +assert(totalDepositedInValidators() == previousTotalDeposited + newDeposit); +``` + +**Total Assets Integrity** + +```solidity +assert(totalAssets() == previousTotalAssets + newDeposit); +``` + +**Total Balance Integrity** + +```solidity +assert(address(yneth).balance() == previousBalance + newBalance); +``` + +**Deposit and Withdrawal Symmetry** + +```solidity +uint256 sharesMinted = depositETH(amount); +assert(sharesMinted == previewDeposit(amount)); +``` + +**Rewards Increase Total Assets** + +```solidity +uint256 previousTotalAssets = totalAssets(); +// Simulate receiving rewards +receiveRewards{value: rewardAmount}(); +assert(totalAssets() == previousTotalAssets + rewardAmount); +``` + +**Authorized Access Control** + +```solidity +// For any role-restricted operation +assert(msg.sender == authorizedRoleAddress); +``` diff --git a/test/foundry/scenarios/ynETH.spec.sol b/test/foundry/scenarios/ynETH.spec.sol new file mode 100644 index 000000000..749fab4cc --- /dev/null +++ b/test/foundry/scenarios/ynETH.spec.sol @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.24; + +import { IntegrationBaseTest } from "test/foundry/integration/IntegrationBaseTest.sol"; +import { Invariants } from "test/foundry/scenarios/Invariants.sol"; +import { IStakingNodesManager } from "src/interfaces/IStakingNodesManager.sol"; +import { IStakingNode } from "src/interfaces/IStakingNode.sol"; +import { BeaconChainProofs } from "src/external/eigenlayer/v0.1.0/BeaconChainProofs.sol"; +import { IEigenPod } from "src/external/eigenlayer/v0.1.0/interfaces/IEigenPod.sol"; +import { IEigenPodManager } from "src/external/eigenlayer/v0.1.0/interfaces/IEigenPodManager.sol"; +import { IDelayedWithdrawalRouter } from "src/external/eigenlayer/v0.1.0/interfaces/IDelayedWithdrawalRouter.sol"; +import { IRewardsDistributor } from "src/interfaces/IRewardsDistributor.sol"; + +contract YnETHScenarioTest1 is IntegrationBaseTest { + + /** + Scenario 1: Successful ETH Deposit and Share Minting + Objective: Test that a user can deposit ETH and receive + the correct amount of shares in return. + */ + + address user1 = address(0x01); + address user2 = address(0x02); + address user3 = address(0x03); + + function test_ynETH_Scenario_1_Fuzz(uint256 random1, uint256 random2, uint256 random3) public { + + /** + Users deposit random amounts + - Check the total assets of ynETH + - Check the share balance of each user + - Check the total deposited in the pool + - Check total supply of ynETH + */ + + vm.assume(random1 > 0 && random1 < 100_000_000 ether); + vm.assume(random2 > 0 && random2 < 100_000_000 ether); + vm.assume(random3 > 0 && random3 < 100_000_000 ether); + + uint256 previousTotalDeposited; + uint256 previousTotalShares; + + // user 1 deposits random1 + uint256 user1Amount = random1; + vm.deal(user1, user1Amount); + + uint256 user1Shares = yneth.previewDeposit(user1Amount); + yneth.depositETH{value: user1Amount}(user1); + + previousTotalDeposited = 0; + previousTotalShares = 0; + + runInvariants(user1, previousTotalDeposited, previousTotalShares, user1Amount, user1Shares); + + // user 2 deposits random2 + uint256 user2Amount = random2; + vm.deal(user2, user2Amount); + + uint256 user2Shares = yneth.previewDeposit(user2Amount); + yneth.depositETH{value: user2Amount}(user2); + + previousTotalDeposited += user1Amount; + previousTotalShares += user1Shares; + + runInvariants(user2, previousTotalDeposited, previousTotalShares, user2Amount, user2Shares); + + // user 3 deposits random3 + uint256 user3Amount = random3; + vm.deal(user3, user3Amount); + + uint256 user3Shares = yneth.previewDeposit(user3Amount); + yneth.depositETH{value: user3Amount}(user3); + + previousTotalDeposited += user2Amount; + previousTotalShares += user2Shares; + + runInvariants(user3, previousTotalDeposited, previousTotalShares, user3Amount, user3Shares); + } + + function runInvariants(address user, uint256 previousTotalDeposited, uint256 previousTotalShares, uint256 userAmount, uint256 userShares) public view { + Invariants.totalDepositIntegrity(yneth.totalDepositedInPool(), previousTotalDeposited, userAmount); + Invariants.totalAssetsIntegrity(yneth.totalAssets(), previousTotalDeposited, userAmount); + Invariants.shareMintIntegrity(yneth.totalSupply(), previousTotalShares, userShares); + Invariants.userSharesIntegrity(yneth.balanceOf(user), 0, userShares); + } +} + +contract YnETHScenarioTest2 is IntegrationBaseTest { + + /** + Scenario 2: Deposit Paused + Objective: Ensure that deposits are correctly + paused and resumed, preventing or allowing ETH + deposits accordingly. + */ + + address user1 = address(0x01); + address user2 = address(0x02); + + // pause ynETH and try to deposit fail + function test_ynETH_Scenario_2_Pause() public { + + vm.prank(actors.PAUSE_ADMIN); + yneth.updateDepositsPaused(true); + + vm.deal(user1, 1 ether); + vm.expectRevert(bytes4(keccak256(abi.encodePacked("Paused()")))); + yneth.depositETH{value: 1 ether}(user1); + } + + function test_ynETH_Scenario_2_Unpause() public { + + vm.startPrank(actors.PAUSE_ADMIN); + yneth.updateDepositsPaused(true); + assertTrue(yneth.depositsPaused()); + yneth.updateDepositsPaused(false); + assertFalse(yneth.depositsPaused()); + vm.stopPrank(); + + vm.deal(user1, 1 ether); + vm.prank(user1); + yneth.depositETH{value: 1 ether}(user1); + assertEq(yneth.balanceOf(user1), 1 ether); + } + + function test_ynETH_Scenario_2_Pause_Transfer(uint256 random1) public { + + vm.assume(random1 > 0 && random1 < 100_000_000 ether); + + uint256 amount = random1; + vm.deal(user1, amount); + vm.startPrank(user1); + yneth.depositETH{value: amount}(user1); + assertEq(yneth.balanceOf(user1), amount); + + // should fail when not on the pause whitelist + yneth.approve(user2, amount); + vm.expectRevert(bytes4(keccak256(abi.encodePacked("TransfersPaused()")))); + yneth.transfer(user2, amount); + vm.stopPrank(); + + // should pass when on the pause whitelist + vm.startPrank(actors.TRANSFER_ENABLED_EOA); + vm.deal(actors.TRANSFER_ENABLED_EOA, amount); + yneth.depositETH{value: amount}(actors.TRANSFER_ENABLED_EOA); + + uint256 transferEnabledEOABalance = yneth.balanceOf(actors.TRANSFER_ENABLED_EOA); + yneth.transfer(user2, transferEnabledEOABalance); + assertEq(yneth.balanceOf(user2), transferEnabledEOABalance); + } + +} + +contract YnETHScenarioTest3 is IntegrationBaseTest { + + /** + Scenario 3: Deposit and Withdraw ETH to Staking Nodes Manager + Objective: Test that only the Staking Nodes Manager + can withdraw ETH from the contract. + */ + + address user1 = address(0x01); + + function test_ynETH_Scenario_3_Deposit_Withdraw() public { + + // Deposit 32 ETH to ynETH and create a Staking Node with a Validator + depositEth_and_createValidator(); + + // Verify withdraw credentials + // verifyEigenWithdrawCredentials(stakingNode); + } + + function depositEth_and_createValidator() public returns (IStakingNode stakingNode, IStakingNodesManager.ValidatorData[] memory validatorData) { + // 1. Create Validator and deposit 32 ETH + + // Deposit 32 ETH to ynETH + uint256 depositAmount = 32 ether; + vm.deal(user1, depositAmount); + vm.prank(user1); + yneth.depositETH{value: depositAmount}(user1); + + // Staking Node Creator Role creates the staking nodes + vm.prank(actors.STAKING_NODE_CREATOR); + stakingNode = stakingNodesManager.createStakingNode(); + + // Create a new Validator Data object + validatorData = new IStakingNodesManager.ValidatorData[](1); + validatorData[0] = IStakingNodesManager.ValidatorData({ + publicKey: ONE_PUBLIC_KEY, + signature: ZERO_SIGNATURE, + nodeId: 0, + depositDataRoot: bytes32(0) + }); + + // Generate the deposit data root with withdrawal credentials + bytes memory withdrawalCredentials = stakingNodesManager.getWithdrawalCredentials(validatorData[0].nodeId); + validatorData[0].depositDataRoot = stakingNodesManager.generateDepositRoot( + validatorData[0].publicKey, + validatorData[0].signature, + withdrawalCredentials, + depositAmount + ); + + // checks the deposit Validator Data + stakingNodesManager.validateDepositDataAllocation(validatorData); + + // get a deposit root from the ethereum deposit contract + bytes32 depositRoot = depositContractEth2.get_deposit_root(); + + // Validator Manager Role registers the validators + vm.prank(actors.VALIDATOR_MANAGER); + stakingNodesManager.registerValidators(depositRoot, validatorData); + + assertEq(address(yneth).balance, 0); + + return (stakingNode, validatorData); + } + + function verifyEigenWithdrawCredentials(IStakingNode stakingNode) public { + // EigenLayer must not be paused: + address pauser = 0x369e6F597e22EaB55fFb173C6d9cD234BD699111; + IEigenPodManager eigenPodManager = IEigenPodManager(chainAddresses.eigenlayer.EIGENPOD_MANAGER_ADDRESS); + vm.prank(pauser); + eigenPodManager.unpause(0); + + + // eigenPod.verifyWithdrawalCredentials + // @param oracleBlockNumber is the Beacon Chain blockNumber whose state root the `proof` will be proven against. + uint64[] memory oracleBlockNumbers = new uint64[](1); + oracleBlockNumbers[0] = uint32(block.number); + + // @param validatorIndex is the index of the validator being proven, refer to consensus specs + uint40[] memory validatorIndexes = new uint40[](1); + validatorIndexes[0] = 1234567; // Validator index + + // @param proofs is the bytes that prove the ETH validator's balance and withdrawal credentials against a beacon chain state root + BeaconChainProofs.ValidatorFieldsAndBalanceProofs[] memory proofs = new BeaconChainProofs.ValidatorFieldsAndBalanceProofs[](1); + proofs[0] = BeaconChainProofs.ValidatorFieldsAndBalanceProofs({ + validatorFieldsProof: new bytes(3), + validatorBalanceProof: new bytes(0), + balanceRoot: bytes32(0) + }); + + // @param validatorFields are the fields of the "Validator Container", refer to consensus specs + // https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#validator + // https://github.com/Layr-Labs/eigenpod-proofs-generation/blob/m1-mainet-frozen/generate_validator_proof.go + bytes32[][] memory validatorFields = new bytes32[][](1); + validatorFields[0] = new bytes32[](0); + + vm.prank(actors.STAKING_NODES_ADMIN); + stakingNode.verifyWithdrawalCredentials(oracleBlockNumbers, validatorIndexes, proofs, validatorFields); + + IEigenPod eigenPod = IEigenPod(stakingNode.eigenPod()); + eigenPod.validatorStatus(0); + } +} + +contract YnETHScenarioTest8 is IntegrationBaseTest, YnETHScenarioTest3 { + + /** + Scenario 8: Staking Rewards Distribution + Objective: Test the distribution of staking rewards to a multisig. + */ + + event Log(string message, uint256 value); + event LogAddress(string message, address value); + + function test_ynETH_Scenario_8_Rewards_Distribution(uint256 randomAmount) public { + vm.assume(randomAmount > 1_000 && randomAmount < 100_000_000 ether); + + // Deposit 32 ETH to ynETH and create a Staking Node with a Validator + (IStakingNode stakingNode,) = depositEth_and_createValidator(); + + // send concensus rewards to eigen pod + uint256 amount = 32 ether + 1 wei; + IEigenPod eigenPod = IEigenPod(stakingNode.eigenPod()); + uint256 initialPodBalance = address(eigenPod).balance; + vm.deal(address(eigenPod), amount); + assertEq(address(eigenPod).balance, initialPodBalance + amount); + + // To withdraw, create a DelayedWithdrawal on the DelayedWithdrawalRouter + vm.prank(actors.STAKING_NODES_ADMIN); + stakingNode.withdrawBeforeRestaking(); + assertEq(address(eigenPod).balance, initialPodBalance); + + // There should be a delayedWithdraw on the DelayedWithdrawalRouter + IDelayedWithdrawalRouter withdrawalRouter = IDelayedWithdrawalRouter(chainAddresses.eigenlayer.DELAYED_WITHDRAWAL_ROUTER_ADDRESS); + IDelayedWithdrawalRouter.DelayedWithdrawal[] memory delayedWithdrawals = withdrawalRouter.getUserDelayedWithdrawals(address(stakingNode)); + assertEq(delayedWithdrawals.length, 1); + assertEq(delayedWithdrawals[0].amount, amount); + + // Because of the delay, the delayedWithdrawal should not be claimable yet + IDelayedWithdrawalRouter.DelayedWithdrawal[] memory claimableDelayedWithdrawals = withdrawalRouter.getClaimableUserDelayedWithdrawals(address(stakingNode)); + assertEq(claimableDelayedWithdrawals.length, 0); + + // Move ahead in time to make the delayedWithdrawal claimable + vm.roll(block.number + withdrawalRouter.withdrawalDelayBlocks() + 1); + IDelayedWithdrawalRouter.DelayedWithdrawal[] memory claimableDelayedWithdrawalsWarp = withdrawalRouter.getClaimableUserDelayedWithdrawals(address(stakingNode)); + assertEq(claimableDelayedWithdrawalsWarp.length, 1); + assertEq(claimableDelayedWithdrawalsWarp[0].amount, amount, "claimableDelayedWithdrawalsWarp[0].amount != 3 ether"); + + // We can now claim the delayedWithdrawal + uint256 withdrawnValidatorPrincipal = stakingNode.getETHBalance(); + vm.prank(address(actors.STAKING_NODES_ADMIN)); + + // Divided the withdrawnValidatorPrincipal by 2 to simulate the rewards distribution + stakingNode.claimDelayedWithdrawals(1, withdrawnValidatorPrincipal / 2); + + // Get the rewards receiver addresses from the rewards distributor + IRewardsDistributor rewardsDistributor = IRewardsDistributor(stakingNodesManager.rewardsDistributor()); + address consensusLayerReceiver = address(rewardsDistributor.consensusLayerReceiver()); + address executionLayerReceiver = address(rewardsDistributor.executionLayerReceiver()); + + uint256 concensusRewards = consensusLayerReceiver.balance; + + // Mock execution rewards coming from a node operator service (eg. figment) + vm.deal(executionLayerReceiver, 1 ether); + + uint256 concensusRewardsExpected = withdrawnValidatorPrincipal / 2; + assertEq( + compareWithThreshold(concensusRewards, concensusRewardsExpected, 1), + true, + "concensusRewards != concensusRewardsExpected" + ); + assertEq( + compareWithThreshold(address(yneth).balance, withdrawnValidatorPrincipal / 2, 1), + true, + "yneth.balance != concensusRewardsExpected" + ); + + // finally, process rewards from the rewards distributor + rewardsDistributor.processRewards(); + + // uint256 fees = Math.mulDiv(feesBasisPoints, totalRewards, _BASIS_POINTS_DENOMINATOR); + + + } + +} + + + diff --git a/test/foundry/scenarios/ynLSD.spec.sol b/test/foundry/scenarios/ynLSD.spec.sol new file mode 100644 index 000000000..9b28bd23e --- /dev/null +++ b/test/foundry/scenarios/ynLSD.spec.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.24; + +import { IntegrationBaseTest } from "test/foundry/integration/IntegrationBaseTest.sol"; +import { Invariants } from "test/foundry/scenarios/Invariants.sol"; +import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; + +contract YnETHScenarioTest1 is IntegrationBaseTest { + + /** + Scenario 1: Successful LSD Deposit and Share Minting + Objective: Test that a user can deposit stETH and receive + the correct amount of shares in return. + */ + + address user1 = address(0x01); + address user2 = address(0x02); + address user3 = address(0x03); + + function test_ynLSD_Scenario_1_Fuzz(uint256 random1, uint256 random2, uint256 random3) public { + + /** + Users deposit random amounts + - Check the total assets of ynLSD + - Check the share balance of each user + - Check the total deposited in the pool + - Check total supply of ynLSD + */ + + vm.assume(random1 > 0 && random1 < 100_000_000 ether); + vm.assume(random2 > 0 && random2 < 100_000_000 ether); + vm.assume(random3 > 0 && random3 < 100_000_000 ether); + + // get stETH + (bool success, ) = chainAddresses.lsd.STETH_ADDRESS.call{ value: random1 }(""); + require(success, "ETH transfer failed"); + IERC20 steth = IERC20(chainAddresses.lsd.STETH_ADDRESS); + + + uint256 previousTotalDeposited; + uint256 previousTotalShares; + + uint256 user1Amount = random1; + uint256 user1Shares = ynlsd.previewDeposit(steth, user1Amount); + steth.approve(address(ynlsd), random1); + ynlsd.deposit(steth, random1, user1); + + + previousTotalDeposited = 0; + previousTotalShares = 0; + + runInvariants(user1, previousTotalDeposited, previousTotalShares, user1Amount, user1Shares); + + // // user 2 deposits random2 + // uint256 user2Amount = random2; + // vm.deal(user2, user2Amount); + + // // uint256 user2Shares = yneth.previewDeposit(user2Amount); + // // yneth.depositETH{value: user2Amount}(user2); + + // previousTotalDeposited += user1Amount; + // previousTotalShares += user1Shares; + + // runInvariants(user2, previousTotalDeposited, previousTotalShares, user2Amount, user2Shares); + + // // user 3 deposits random3 + // uint256 user3Amount = random3; + // vm.deal(user3, user3Amount); + + // // uint256 user3Shares = yneth.previewDeposit(user3Amount); + // // yneth.depositETH{value: user3Amount}(user3); + + // previousTotalDeposited += user2Amount; + // previousTotalShares += user2Shares; + + // runInvariants(user3, previousTotalDeposited, previousTotalShares, user3Amount, user3Shares); + } + + function runInvariants(address user, uint256 previousTotalDeposited, uint256 previousTotalShares, uint256 userAmount, uint256 userShares) public view { + Invariants.totalDepositIntegrity(yneth.totalDepositedInPool(), previousTotalDeposited, userAmount); + Invariants.totalAssetsIntegrity(yneth.totalAssets(), previousTotalDeposited, userAmount); + Invariants.shareMintIntegrity(yneth.totalSupply(), previousTotalShares, userShares); + Invariants.userSharesIntegrity(yneth.balanceOf(user), 0, userShares); + } +} \ No newline at end of file