From a17fb120fb004ab350df09f55203363e298bd0f0 Mon Sep 17 00:00:00 2001 From: fsle <148141389+fsle@users.noreply.github.com> Date: Tue, 19 Aug 2025 11:42:29 +0200 Subject: [PATCH 01/16] refactor + add of multiple invariants --- .../StakingVaultConstants.sol | 30 + .../StakingVaultsFuzzing.t.sol | 375 ++++++++++ .../StakingVaultsHandler.t.sol | 340 +++++++++ .../invariant-fuzzing/mocks/CommonMocks.sol | 461 ++++++++++++ .../mocks/LazyOracleMock.sol | 276 +++++++ .../mocks/OperatorGridMock.sol | 699 ++++++++++++++++++ 6 files changed, 2181 insertions(+) create mode 100644 test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol create mode 100644 test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol create mode 100644 test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol create mode 100644 test/0.8.25/invariant-fuzzing/mocks/CommonMocks.sol create mode 100644 test/0.8.25/invariant-fuzzing/mocks/LazyOracleMock.sol create mode 100644 test/0.8.25/invariant-fuzzing/mocks/OperatorGridMock.sol diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol b/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol new file mode 100644 index 0000000000..995e7b4dc1 --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity ^0.8.0; + +library Constants { + //OperatorGrid params + //retrieved from default settings in deploy scripts + uint256 public constant SHARE_LIMIT = 1000; + uint256 public constant RESERVE_RATIO_BP = 2000; + uint256 public constant FORCED_REBALANCE_THRESHOLD_BP = 1800; + uint256 public constant INFRA_FEE_BP = 500; + uint256 public constant LIQUIDITY_FEE_BP = 400; + uint256 public constant RESERVATION_FEE_BP = 100; + + //VaultHub params + uint256 public constant RELATIVE_SHARE_LIMIT = 1000; + uint256 public constant UNSETTLED_THRESHOLD = 1 ether; + uint256 public constant TOTAL_BASIS_POINTS = 10000; + + //LidoMock params + uint256 public constant TOTAL_SHARES_MAINNET = 7810237 ether; + uint256 public constant TOTAL_POOLED_ETHER_MAINNET = 9365361 ether; + uint256 public constant EXTERNAL_SHARES_MAINNET = 0; + + uint256 public constant CONNECT_DEPOSIT = 1 ether; + + //LazyOracle params + uint64 public constant QUARANTINE_PERIOD = 3 days; + uint16 public constant MAX_REWARD_RATIO_BP = 350; //3.5% +} diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol new file mode 100644 index 0000000000..14628caea5 --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol @@ -0,0 +1,375 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity 0.8.25; + +import {Test} from "forge-std/Test.sol"; +import "forge-std/console2.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts-v5.2/proxy/ERC1967/ERC1967Proxy.sol"; +import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; +import {StakingVault} from "contracts/0.8.25/vaults/StakingVault.sol"; +import {TierParams, OperatorGrid} from "contracts/0.8.25/vaults/OperatorGrid.sol"; + +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; +import {IHashConsensus} from "contracts/0.8.25/vaults/interfaces/IHashConsensus.sol"; +import {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; + +import {StakingVaultsHandler} from "./StakingVaultsHandler.t.sol"; +import {Constants} from "./StakingVaultConstants.sol"; + +import {LazyOracleMock} from "./mocks/LazyOracleMock.sol"; + +import {Math256} from "contracts/common/lib/Math256.sol"; + +contract StakingVaultsTest is Test { + VaultHub vaultHubProxy; + StakingVault stakingVaultProxy; + + StakingVaultsHandler svHandler; + + address private rootAccount = makeAddr("rootAccount"); + address private userAccount = makeAddr("userAccount"); + + address private treasury_addr = makeAddr("treasury"); + address private depositor = makeAddr("depositor"); + address private nodeOperator = makeAddr("nodeOperator"); + + //contracts addresses + address private pdg_addr = makeAddr("predepositGuarantee"); + address private accounting_addr = makeAddr("accounting"); + address private lazyOracle_addr = makeAddr("lazyoracle"); + address private operatorGrid_addr = makeAddr("operatorGrid"); + address private vaultHub_addr = makeAddr("vaultHub"); + address private lidoLocator_addr = makeAddr("lidoLocator"); + address private lido_addr = makeAddr("lido"); + address private consensusContract_addr = makeAddr("consensusContract"); + + function deployMockContracts() internal { + //Deploy LidoMock + deployCodeTo( + "CommonMocks.sol:LidoMock", + abi.encode( + Constants.TOTAL_SHARES_MAINNET, + Constants.TOTAL_POOLED_ETHER_MAINNET, + Constants.EXTERNAL_SHARES_MAINNET + ), + lido_addr + ); + + //Deploy LazyOracleMock + deployCodeTo( + "CommonMocks.sol:LazyOracleMock", + abi.encode( + lidoLocator_addr, + consensusContract_addr, + Constants.QUARANTINE_PERIOD, + Constants.MAX_REWARD_RATIO_BP + ), + lazyOracle_addr + ); + + //Deploy ConsensusContractMock + deployCodeTo("CommonMocks.sol:ConsensusContractMock", abi.encode(1, 0), consensusContract_addr); + + //Deploy LidoLocatorMock + deployCodeTo( + "CommonMocks.sol:LidoLocatorMock", + abi.encode( + lido_addr, + pdg_addr, + accounting_addr, + treasury_addr, + operatorGrid_addr, + lazyOracle_addr, + vaultHub_addr, + consensusContract_addr + ), + lidoLocator_addr + ); + } + + function deployOperatorGrid() internal { + TierParams memory defaultTierParams = TierParams({ + shareLimit: Constants.SHARE_LIMIT, + reserveRatioBP: Constants.RESERVE_RATIO_BP, + forcedRebalanceThresholdBP: Constants.FORCED_REBALANCE_THRESHOLD_BP, + infraFeeBP: Constants.INFRA_FEE_BP, + liquidityFeeBP: Constants.LIQUIDITY_FEE_BP, + reservationFeeBP: Constants.RESERVATION_FEE_BP + }); + + OperatorGrid operatorGrid = new OperatorGrid(ILidoLocator(address(lidoLocator_addr))); + + vm.prank(rootAccount); + deployCodeTo( + "ERC1967Proxy", + abi.encode( + operatorGrid, + abi.encodeWithSelector(OperatorGrid.initialize.selector, rootAccount, defaultTierParams) + ), + operatorGrid_addr + ); + } + + function deployVaultHub() internal { + VaultHub vaultHub = new VaultHub( + ILidoLocator(address(lidoLocator_addr)), + ILido(address(lido_addr)), + IHashConsensus(address(consensusContract_addr)), + Constants.RELATIVE_SHARE_LIMIT + ); + + vm.prank(rootAccount); + deployCodeTo( + "ERC1967Proxy", + abi.encode(vaultHub, abi.encodeWithSelector(VaultHub.initialize.selector, rootAccount)), + vaultHub_addr + ); + + vaultHubProxy = VaultHub(payable(vaultHub_addr)); + + bytes32 vaultMasterRole = vaultHubProxy.VAULT_MASTER_ROLE(); + vm.prank(rootAccount); + vaultHubProxy.grantRole(vaultMasterRole, rootAccount); + + bytes32 vaultCodehashSetRole = vaultHubProxy.VAULT_CODEHASH_SET_ROLE(); + vm.prank(rootAccount); + vaultHubProxy.grantRole(vaultCodehashSetRole, rootAccount); + } + + function deployStakingVault() internal { + //Create StakingVault contract + StakingVault stakingVault = new StakingVault(address(0x22)); + ERC1967Proxy proxy = new ERC1967Proxy( + address(stakingVault), + abi.encodeWithSelector(StakingVault.initialize.selector, userAccount, nodeOperator, pdg_addr, "0x") + ); + stakingVaultProxy = StakingVault(payable(address(proxy))); + + vm.prank(rootAccount); + //Allow the stakingVault contract to be connected + vaultHubProxy.setAllowedCodehash(address(stakingVaultProxy).codehash, true); + } + + function setUp() public { + //LidoMock + //LidoLocatorMock + //LazyOracleMock + //ConsensusContractMock + deployMockContracts(); + + //VaultHub + deployVaultHub(); + + //OperatorGrid + deployOperatorGrid(); + + //StakingVault + deployStakingVault(); + + //Handler + svHandler = new StakingVaultsHandler(lidoLocator_addr, address(stakingVaultProxy), rootAccount, userAccount); + + //We advance time to avoid a freshly connected vault to be able to mint shares + //That would be possible because record.reportTimestamp (0 at connection) would be too close to block.timestamp (0 aswell) and considered fresh + vm.warp(block.timestamp + 3 days); + + //First connect StakingVault to VaultHub + svHandler.connectVault(); + + // Configure fuzzing targets + bytes4[] memory svSelectors = new bytes4[](13); + svSelectors[0] = svHandler.fund.selector; + svSelectors[1] = svHandler.VHwithdraw.selector; + svSelectors[2] = svHandler.forceRebalance.selector; + svSelectors[3] = svHandler.forceValidatorExit.selector; + svSelectors[4] = svHandler.mintShares.selector; + svSelectors[5] = svHandler.burnShares.selector; + svSelectors[6] = svHandler.transferAndBurnShares.selector; + svSelectors[7] = svHandler.voluntaryDisconnect.selector; + svSelectors[8] = svHandler.sv_otcDeposit.selector; + svSelectors[9] = svHandler.vh_otcDeposit.selector; + svSelectors[10] = svHandler.updateVaultData.selector; + svSelectors[11] = svHandler.SVwithdraw.selector; + svSelectors[12] = svHandler.connectVault.selector; + + targetContract(address(svHandler)); + targetSelector(FuzzSelector({addr: address(svHandler), selectors: svSelectors})); + } + + ////////// INVARIANTS ////////// + + //With current deployed environement (no slashing, no stETH rebase) + //the staking Vault should never go below the rebalance threshold + //Meaning having less locked collateral than the threshold ratio limit (in regards to the liabilityShares converted in ETH) + //This is computd by rebalanceShortfall function + function invariant_liabilityShares_not_above_collateral() external { + uint256 rebalanceShares = vaultHubProxy.rebalanceShortfall(address(stakingVaultProxy)); + assertEq(rebalanceShares, 0, "Staking Vault should never go below the rebalance threshold"); + } + + //This invariant checks that the dynamic (accounting for deltas) totalValue of the vault is not underflowed + function invariant_dynamic_totalValue_should_not_underflow() external { + int256 inOutDelta; + uint256 totalValue = vaultHubProxy.totalValue(address(stakingVaultProxy)); + VaultHub.VaultRecord memory record = vaultHubProxy.vaultRecord(address(stakingVaultProxy)); + int256 curInOutDelta = record.inOutDelta.value; + (uint256 refSlot, ) = IHashConsensus(consensusContract_addr).getCurrentFrame(); + if (record.inOutDelta.refSlot == refSlot) { + inOutDelta = record.inOutDelta.refSlotValue; + } else { + inOutDelta = curInOutDelta; + } + assertGe(int256(totalValue) + curInOutDelta - inOutDelta, 0, "Dynamic total value should not underflow"); //@audit this should revert with high totalValue + } + + //forceRebalance and forceValidatorExit should notrevert when the vault is unhealthy + function invariant_forceRebalance_should_not_revert_when_unhealthy() external { + bool forceRebalanceReverted = svHandler.didForceRebalanceReverted(); + assertFalse(forceRebalanceReverted, "forceRebalance should not revert when unhealthy"); + } + + function invariant_forceValidatorExit_should_not_revert_when_unhealthy_and_vault_balance_too_low() external { + bool forceValidatorExitReverted = svHandler.didForceValidatorExitReverted(); + assertFalse( + forceValidatorExitReverted, + "forceValidatorExit should not revert when unhealthy and vault balance is not sufficient" + ); + } + + function invariant_applied_tv_should_not_be_greater_than_reported_tv() external { + //This invariant checks that the applied total value is not greater than the reported total value + + uint256 appliedTotalValue = svHandler.getAppliedTotalValue(); + uint256 reportedTotalValue = svHandler.getReportedTotalValue(); + + assertLe( + appliedTotalValue, + reportedTotalValue, + "Applied total value should not be greater than reported total value" + ); + } + + function invariant_liabilityshares_should_never_be_greater_than_connection_sharelimit() external { + //Get the share limit from the vault + uint256 liabilityShares = vaultHubProxy.liabilityShares(address(stakingVaultProxy)); + + //Get the connection share limit from the vault + VaultHub.VaultConnection memory connection = vaultHubProxy.vaultConnection(address(stakingVaultProxy)); + uint96 shareLimit = connection.shareLimit; + assertLe(liabilityShares, shareLimit, "liability shares should never be greater than connection share limit"); + } + + modifier vaultMustBeConnected() { + if (vaultHubProxy.vaultConnection(address(stakingVaultProxy)).vaultIndex == 0) { + return; + } + _; + } + + modifier vaultNotPendingDisconnect() { + if (vaultHubProxy.vaultConnection(address(stakingVaultProxy)).pendingDisconnect) { + return; + } + _; + } + + //Locked amount cannot be less than max (slashing reserve, 1 ETH, liability * reserverAtio) + //Also safety buffer should be enforced (based on liability) (threshold should not be broken) + function invariant_locked_cannot_be_less_than_slashing_connectdep_reserve() + external + vaultMustBeConnected + vaultNotPendingDisconnect + { + //slashing reserve + VaultHub.VaultRecord memory record = vaultHubProxy.vaultRecord(address(stakingVaultProxy)); + VaultHub.VaultConnection memory connection = vaultHubProxy.vaultConnection(address(stakingVaultProxy)); + uint256 forcedRebalanceThresholdBP = connection.forcedRebalanceThresholdBP; + + uint128 lockedAmount = record.locked; + uint256 liabilityStETH = ILido(address(lido_addr)).getPooledEthBySharesRoundUp(record.liabilityShares); + + uint256 minium_safety_buffer = (liabilityStETH * Constants.TOTAL_BASIS_POINTS) / + (Constants.TOTAL_BASIS_POINTS - forcedRebalanceThresholdBP); + + assertGe( + lockedAmount, + Math256.max(Constants.CONNECT_DEPOSIT, minium_safety_buffer), + "Locked amount should be greater than or equal to max(connect deposit, slashing reserve, reserve ratio)" + ); + } + + // function invariant_totalValue_should_be_greater_than_locked() vaultMustBeConnected vaultNotPendingDisconnect external { + // //Get the total value of the vault + // uint256 totalValue = vaultHubProxy.totalValue(address(stakingVaultProxy)); + // if (totalValue == 0) { + // // If totalValue is 0, we cannot check the invariant + // //That's probably because the vault has just been created and no report has not been applied yet + // return; + // } + + // //Get the locked amount + // VaultHub.VaultRecord memory record = vaultHubProxy.vaultRecord(address(stakingVaultProxy)); + // uint128 lockedAmount = record.locked; + + // //VaultHub.VaultObligations memory vaultObligations = vaultHubProxy.vaultObligations(address(stakingVaultProxy)); + // //uint256 unsettledObligations = vaultObligations.unsettledLidoFees + vaultObligations.redemptions; + + // //Check that total value is greater than or equal to locked amount and unsettled obligations + // assertGe(totalValue, lockedAmount , "Total value should be greater than or equal to locked amount"); + // } + + function invariant_withdrawableValue_should_be_less_than_or_equal_to_totalValue_minus_locked_and_obligations() + external + { + //Get the withdrawable value of the vault + uint256 withdrawableValue = vaultHubProxy.withdrawableValue(address(stakingVaultProxy)); + + //Get the total value of the vault + uint256 totalValue = vaultHubProxy.totalValue(address(stakingVaultProxy)); + + //Get the locked amount and unsettled obligations + VaultHub.VaultRecord memory record = vaultHubProxy.vaultRecord(address(stakingVaultProxy)); + uint128 lockedAmount = record.locked; + VaultHub.VaultObligations memory vaultObligations = vaultHubProxy.vaultObligations(address(stakingVaultProxy)); + uint256 unsettled_plus_locked = vaultObligations.unsettledLidoFees + + vaultObligations.redemptions + + lockedAmount; + uint256 tv_minus_locked_and_obligations = totalValue > unsettled_plus_locked + ? totalValue - unsettled_plus_locked + : 0; + + //Check that withdrawable value is less than or equal to total value minus locked amount and unsettled obligations + assertLe( + withdrawableValue, + tv_minus_locked_and_obligations, + "Withdrawable value should be less than or equal to total value minus locked amount and unsettled obligations" + ); + } + + //The totalValue should be equal or above the real totalValue (EL+CL balance) + //totalValue = report.totalValue + current ioDelta - reported ioDelta + //This invariant catches the crit vulnerability that exploits + //- replay of same report + //- uncleared quarantine upon disconnect + //call path is pretty long but is: + //1. connectVault + //2. sv_otcDeposit + //3. updateVaultData -> triggers quarantine + //4. initializeDisconnect + //5. updateVaultData -> finalize disconnection + //6. connectVault + //7. updateVaultData -> generate a fresh report with TV + //8. SVwithdraw + //9. connectVault + //10. updateVaultData -> reuses previous report; quarantine is expired; TV is kept as is (special branch if the new quarantine delta is lower than the expired one). + // function invariant_check_totalValue() external { + // assertLe(svHandler.getVaultTotalValue(), svHandler.getEffectiveVaultTotalValue()); + // } + + /* + //for testing purposes only (guiding the fuzzing) + function invariant_state() external { + assertEq(svHandler.actionIndex() != 11, true, "callpath reached"); + } +*/ +} diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol new file mode 100644 index 0000000000..7521af15e4 --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol @@ -0,0 +1,340 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity ^0.8.25; + +import {CommonBase} from "forge-std/Base.sol"; +import {StdCheats} from "forge-std/StdCheats.sol"; +import {StdUtils} from "forge-std/StdUtils.sol"; + +import {StdAssertions} from "forge-std/StdAssertions.sol"; +import {Vm} from "forge-std/Vm.sol"; + +import {StakingVault} from "contracts/0.8.25/vaults/StakingVault.sol"; +import {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; +import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; +import {Math256} from "contracts/common/lib/Math256.sol"; +import {LidoLocatorMock, ConsensusContractMock} from "./mocks/CommonMocks.sol"; + +import {LazyOracleMock} from "./mocks/LazyOracleMock.sol"; +import {Constants} from "./StakingVaultConstants.sol"; +import "forge-std/console2.sol"; + +/** +TODO: + - function triggerValidatorWithdrawals() + - PDG funcs + - proveUnknownValidatorToPDG + - compensateDisprovenPredepositFromPDG +**/ + +contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions { + // Protocol contracts + ILido public lidoContract; + LidoLocatorMock public lidoLocator; + VaultHub public vaultHub; + address public dashboard; + StakingVault public stakingVault; + LazyOracleMock public lazyOracle; + ConsensusContractMock public consensusContract; + VaultReport public lastReport; + + struct VaultReport { + uint256 totalValue; + uint256 cumulativeLidoFees; + uint256 liabilityShares; + uint64 reportTimestamp; + } + + // Account addresses + address public userAccount; + address public rootAccount; + + uint256 public cl_balance = 0; //aka deposited on beacon chain + + uint256 constant MIN_SHARES = 1; + uint256 constant MAX_SHARES = 100; + + uint256 public sv_otcDeposited = 0; + uint256 public vh_otcDeposited = 0; + + bool public forceRebalanceReverted = false; + bool public forceValidatorExitReverted = false; + + uint256 public appliedTotalValue = 0; + uint256 public reportedTotalValue = 0; + + enum VaultAction { + CONNECT, + VOLUNTARY_DISCONNECT, + UPDATE_VAULT_DATA, + SV_OTC_DEPOSIT, + VH_OTC_DEPOSIT, + FUND, + VH_WITHDRAW, + SV_WITHDRAW + } + VaultAction[] public actionPath; + uint256 public actionIndex = 0; + + constructor(address _lidoLocator, address _stakingVault, address _rootAccount, address _userAccount) { + lidoLocator = LidoLocatorMock(_lidoLocator); + lidoContract = ILido(lidoLocator.lido()); + vaultHub = VaultHub(payable(lidoLocator.vaultHub())); + stakingVault = StakingVault(payable(_stakingVault)); + lazyOracle = LazyOracleMock(lidoLocator.lazyOracle()); + consensusContract = ConsensusContractMock(lidoLocator.consensusContract()); + rootAccount = _rootAccount; + userAccount = _userAccount; + actionPath = [ + VaultAction.CONNECT, //connect + VaultAction.SV_OTC_DEPOSIT, //otc funds + VaultAction.UPDATE_VAULT_DATA, //trigger quarantine + VaultAction.VOLUNTARY_DISCONNECT, //pendingDisconnect + VaultAction.UPDATE_VAULT_DATA, //disconnected + //quarantine expires (3days) + VaultAction.CONNECT, //reconnect with same TV + wait for fresh report + VaultAction.VOLUNTARY_DISCONNECT, //pendingDisconnect + VaultAction.UPDATE_VAULT_DATA, //disconnected (2nd time) (Report2) + VaultAction.SV_WITHDRAW, //withdraw from vault + VaultAction.CONNECT, //reconnect with CONNECT_DEPOSIT + VaultAction.UPDATE_VAULT_DATA // apply report2 -> QUARANTINE tirggered, and lower than the expired one -> expired quarantine considered as accounted + ]; + } + + modifier actionIndexUpdate(VaultAction action) { + if (actionPath[actionIndex] == action) { + actionIndex++; + } else { + revert("not the good sequence"); + } + _; + } + + ////////// GETTERS FOR SV FUZZING INVARIANTS ////////// + + function getAppliedTotalValue() public returns (uint256) { + return appliedTotalValue; + } + + function getReportedTotalValue() public returns (uint256) { + return reportedTotalValue; + } + + function didForceRebalanceReverted() public returns (bool) { + return forceRebalanceReverted; + } + + function didForceValidatorExitReverted() public returns (bool) { + return forceValidatorExitReverted; + } + ////////// VAULTHUB INTERACTIONS ////////// + function connectVault() public actionIndexUpdate(VaultAction.CONNECT) { + //check if the vault is already connected + VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVault)); + + //do nothing if already connected + if (vc.vaultIndex != 0) return; + + if (address(stakingVault).balance < Constants.CONNECT_DEPOSIT) { + deal(address(userAccount), Constants.CONNECT_DEPOSIT); + vm.prank(userAccount); + stakingVault.fund{value: Constants.CONNECT_DEPOSIT}(); + } + + vm.prank(userAccount); + stakingVault.transferOwnership(address(vaultHub)); + vm.prank(userAccount); + vaultHub.connectVault(address(stakingVault)); + } + + function voluntaryDisconnect() public actionIndexUpdate(VaultAction.VOLUNTARY_DISCONNECT) { + VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVault)); + + //do nothing if disconnected or already disconnecting + if (vc.vaultIndex == 0 || vc.pendingDisconnect == true) return; + + //decrease liabilities + uint256 shares = vaultHub.liabilityShares(address(stakingVault)); + if (shares != 0) { + vaultHub.burnShares(address(stakingVault), shares); + } + + vm.prank(userAccount); + vaultHub.voluntaryDisconnect(address(stakingVault)); + } + + function fund(uint256 amount) public actionIndexUpdate(VaultAction.FUND) { + amount = bound(amount, 1, 1 ether); + deal(address(userAccount), amount); + vm.prank(userAccount); + vaultHub.fund{value: amount}(address(stakingVault)); + } + + function VHwithdraw(uint256 amount) public actionIndexUpdate(VaultAction.VH_WITHDRAW) { + amount = bound(amount, 0, vaultHub.withdrawableValue(address(stakingVault))); + + if (amount == 0) { + return; + } + vm.prank(userAccount); + vaultHub.withdraw(address(stakingVault), userAccount, amount); + } + + function forceRebalance() public { + //Avoid revert when vault is healthy + if (vaultHub.isVaultHealthy(address(stakingVault))) { + return; //no need to rebalance + } + + vm.prank(userAccount); + try vaultHub.forceRebalance(address(stakingVault)) {} catch { + forceRebalanceReverted = true; + } + } + + function forceValidatorExit() public { + uint256 redemptions = vaultHub.vaultObligations(address(stakingVault)).redemptions; + //Avoid revert when vault is healthy or has no redemption over the threshold + if ( + vaultHub.isVaultHealthy(address(stakingVault)) && + redemptions < Math256.max(Constants.UNSETTLED_THRESHOLD, address(stakingVault).balance) + ) { + return; //no need to force exit + } + bytes memory pubkeys = new bytes(0); + vm.prank(rootAccount); //privileged account can force exit + try vaultHub.forceValidatorExit(address(stakingVault), pubkeys, userAccount) { + // If the call succeeds, we do nothing + } catch { + forceValidatorExitReverted = true; + } + } + + function mintShares(uint256 shares) public { + shares = bound(shares, MIN_SHARES, MAX_SHARES); + vm.prank(userAccount); + vaultHub.mintShares(address(stakingVault), userAccount, shares); + } + + function burnShares(uint256 shares) public { + shares = bound(shares, MIN_SHARES, MAX_SHARES); + uint256 currShares = vaultHub.liabilityShares(address(stakingVault)); + uint256 sharesToBurn = Math256.min(currShares, shares); + if (sharesToBurn == 0) { + return; // nothing to burn + } + vm.prank(userAccount); + vaultHub.burnShares(address(stakingVault), sharesToBurn); + } + + function transferAndBurnShares(uint256 shares) public { + shares = bound(shares, MIN_SHARES, MAX_SHARES); + uint256 currShares = vaultHub.liabilityShares(address(stakingVault)); + uint256 sharesToBurn = Math256.min(currShares, shares); + if (sharesToBurn == 0) { + return; // nothing to burn + } + vm.prank(userAccount); + vaultHub.transferAndBurnShares(address(stakingVault), shares); + } + + function pauseBeaconChainDeposits() public { + vaultHub.pauseBeaconChainDeposits(address(stakingVault)); + } + + function resumeBeaconChainDeposits() public { + vaultHub.resumeBeaconChainDeposits(address(stakingVault)); + } + + function getEffectiveVaultTotalValue() public returns (uint256) { + return address(stakingVault).balance + cl_balance; + } + + function getVaultTotalValue() public returns (uint256) { + //gets reported TV + current ioDelta - reported ioDelta + return vaultHub.totalValue(address(stakingVault)); + } + + function sv_otcDeposit(uint256 amount) public actionIndexUpdate(VaultAction.SV_OTC_DEPOSIT) { + amount = bound(amount, 1 ether, 10 ether); + sv_otcDeposited += amount; + deal(address(address(stakingVault)), amount); + } + + function vh_otcDeposit(uint256 amount) public actionIndexUpdate(VaultAction.VH_OTC_DEPOSIT) { + amount = bound(amount, 1 ether, 10 ether); + vh_otcDeposited += amount; + deal(address(address(vaultHub)), amount); + } + + ////////// LazyOracle INTERACTIONS ////////// + + function updateVaultData(uint256 daysShift) public actionIndexUpdate(VaultAction.UPDATE_VAULT_DATA) { + daysShift = bound(daysShift, 0, 1); + daysShift *= 3; //0 or 3 days for quarantine period expiration + console2.log("DaysShift = %d", daysShift); + + if (daysShift > 0) { + vm.warp(block.timestamp + daysShift * 1 days); + lazyOracle.setVaultDataTimestamp(uint64(block.timestamp)); + VaultHub.VaultObligations memory obligations = vaultHub.vaultObligations(address(stakingVault)); + + lastReport = VaultReport({ + totalValue: vaultHub.totalValue(address(stakingVault)) + sv_otcDeposited + cl_balance, + //totalValue: random_tv, + cumulativeLidoFees: obligations.settledLidoFees + obligations.unsettledLidoFees + 1, + liabilityShares: vaultHub.liabilityShares(address(stakingVault)), + reportTimestamp: uint64(block.timestamp) + }); + + //reset otc deposit value + sv_otcDeposited = 0; + } + + //path to trigger to get quarantine back in TV + //reportTs - q.startTimestamp < $.quarantinePeriod + + //simulate next ref slot + (uint256 refSlot, ) = consensusContract.getCurrentFrame(); + if (daysShift > 0) { + refSlot += daysShift; + consensusContract.setCurrentFrame(refSlot); + } + + //we update the reported total Value + reportedTotalValue = lastReport.totalValue; + + //update the vault data + lazyOracle.updateVaultData( + address(stakingVault), + lastReport.totalValue, + lastReport.cumulativeLidoFees, + lastReport.liabilityShares, + uint64(block.timestamp) + ); + + //we update the applied total value (TV should go through sanity checks, quarantine, etc.) + appliedTotalValue = vaultHub.vaultRecord(address(stakingVault)).report.totalValue; + + //Handle if disconnect was successfull + if (stakingVault.pendingOwner() == userAccount) { + vm.prank(userAccount); + stakingVault.acceptOwnership(); + } + } + + ////////// STAKING VAULT INTERACTIONS ////////// + + function SVwithdraw(uint256 amount) public actionIndexUpdate(VaultAction.SV_WITHDRAW) { + if (stakingVault.owner() != userAccount) { + return; //we are managed by the VaultHub + } + + amount = bound(amount, 0, address(stakingVault).balance); + if (amount == 0) { + return; // nothing to withdraw + } + vm.prank(userAccount); + stakingVault.withdraw(userAccount, amount); + } +} diff --git a/test/0.8.25/invariant-fuzzing/mocks/CommonMocks.sol b/test/0.8.25/invariant-fuzzing/mocks/CommonMocks.sol new file mode 100644 index 0000000000..16a7a7f21a --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/mocks/CommonMocks.sol @@ -0,0 +1,461 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.25; + +import {IHashConsensus} from "contracts/0.8.25/vaults/interfaces/IHashConsensus.sol"; +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; +import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; +import {OperatorGrid} from "contracts/0.8.25/vaults/OperatorGrid.sol"; +import {SafeCast} from "@openzeppelin/contracts-v5.2/utils/math/SafeCast.sol"; +import {Math256} from "contracts/common/lib/Math256.sol"; +import {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; + +contract ConsensusContractMock is IHashConsensus { + uint256 public refSlot; + uint256 public reportProcessingDeadlineSlot; + + constructor(uint256 _refSlot, uint256 _reportProcessingDeadlineSlot) { + refSlot = _refSlot; + reportProcessingDeadlineSlot = _reportProcessingDeadlineSlot; + } + + function getCurrentFrame() external view returns (uint256, uint256) { + return (refSlot, reportProcessingDeadlineSlot); + } + + function setCurrentFrame(uint256 newRefSlot) external { + refSlot = newRefSlot; + } + + function getIsMember(address) external view returns (bool) { + return true; + } + + function getChainConfig() + external + view + returns (uint256 slotsPerEpoch, uint256 secondsPerSlot, uint256 genesisTime) + { + return (0, 0, 0); + } + + function getFrameConfig() external view returns (uint256 initialEpoch, uint256 epochsPerFrame) { + return (0, 0); + } + + function getInitialRefSlot() external view returns (uint256) { + return 0; + } +} + +contract LidoLocatorMock { + address public lido_; + address public predepositGuarantee_; + address public accounting_; + address public treasury_; + address public operatorGrid_; + address public lazyOracle_; + address public vaultHub_; + address public consensusContract_; + + constructor( + address _lido, + address _predepositGuarantee, + address _accounting, + address _treasury, + address _operatorGrid, + address _lazyOracle, + address _vaultHub, + address _consensusContract + ) { + lido_ = _lido; + predepositGuarantee_ = _predepositGuarantee; + accounting_ = _accounting; + treasury_ = _treasury; + operatorGrid_ = _operatorGrid; + lazyOracle_ = _lazyOracle; + vaultHub_ = _vaultHub; + consensusContract_ = _consensusContract; + } + + function lido() external view returns (address) { + return lido_; + } + function operatorGrid() external view returns (address) { + return operatorGrid_; + } + + function predepositGuarantee() external view returns (address) { + return predepositGuarantee_; + } + + function accounting() external view returns (address) { + return accounting_; + } + + function treasury() external view returns (address) { + return treasury_; + } + + function lazyOracle() external view returns (address) { + return lazyOracle_; + } + + function vaultHub() external view returns (address) { + return vaultHub_; + } + + function consensusContract() external view returns (address) { + return consensusContract_; + } +} + +contract LazyOracleMock { + struct Storage { + /// @notice root of the vaults data tree + bytes32 vaultsDataTreeRoot; + /// @notice CID of the vaults data tree + string vaultsDataReportCid; + /// @notice timestamp of the vaults data + uint64 vaultsDataTimestamp; + /// @notice total value increase quarantine period + uint64 quarantinePeriod; + /// @notice max reward ratio for refSlot-observed total value, basis points + uint16 maxRewardRatioBP; + /// @notice deposit quarantines for each vault + mapping(address vault => Quarantine) vaultQuarantines; + } + + struct Quarantine { + uint128 pendingTotalValueIncrease; + uint64 startTimestamp; + } + + struct VaultInfo { + address vault; + uint96 vaultIndex; + uint256 balance; + bytes32 withdrawalCredentials; + uint256 liabilityShares; + uint256 mintableStETH; + uint96 shareLimit; + uint16 reserveRatioBP; + uint16 forcedRebalanceThresholdBP; + uint16 infraFeeBP; + uint16 liquidityFeeBP; + uint16 reservationFeeBP; + bool pendingDisconnect; + } + + struct QuarantineInfo { + bool isActive; + uint256 pendingTotalValueIncrease; + uint256 startTimestamp; + uint256 endTimestamp; + } + + // keccak256(abi.encode(uint256(keccak256("LazyOracle")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant LAZY_ORACLE_STORAGE_LOCATION = + 0xe5459f2b48ec5df2407caac4ec464a5cb0f7f31a1f22f649728a9579b25c1d00; + + bytes32 public constant UPDATE_SANITY_PARAMS_ROLE = keccak256("UPDATE_SANITY_PARAMS_ROLE"); + + // total basis points = 100% + uint256 internal constant TOTAL_BP = 100_00; + + ILidoLocator public immutable LIDO_LOCATOR; + IHashConsensus public immutable HASH_CONSENSUS; + + /// @dev basis points base + uint256 private constant TOTAL_BASIS_POINTS = 100_00; + + constructor(address _lidoLocator, address _hashConsensus, uint64 _quarantinePeriod, uint16 _maxRewardRatioBP) { + LIDO_LOCATOR = ILidoLocator(payable(_lidoLocator)); + HASH_CONSENSUS = IHashConsensus(_hashConsensus); + _updateSanityParams(_quarantinePeriod, _maxRewardRatioBP); + } + + /// @notice returns the latest report timestamp + function latestReportTimestamp() external view returns (uint64) { + return _storage().vaultsDataTimestamp; + } + + /// @notice returns the quarantine period + function quarantinePeriod() external view returns (uint64) { + return _storage().quarantinePeriod; + } + + /// @notice returns the max reward ratio for refSlot total value, basis points + function maxRewardRatioBP() external view returns (uint16) { + return _storage().maxRewardRatioBP; + } + + /// @notice returns the quarantine info for the vault + /// @param _vault the address of the vault + // @dev returns zeroed structure if there is no active quarantine + function vaultQuarantine(address _vault) external view returns (QuarantineInfo memory) { + Quarantine storage q = _storage().vaultQuarantines[_vault]; + if (q.pendingTotalValueIncrease == 0) { + return QuarantineInfo(false, 0, 0, 0); + } + + return + QuarantineInfo( + true, + q.pendingTotalValueIncrease, + q.startTimestamp, + q.startTimestamp + _storage().quarantinePeriod + ); + } + + /// @notice update the sanity parameters + /// @param _quarantinePeriod the quarantine period + /// @param _maxRewardRatioBP the max EL CL rewards + function updateSanityParams(uint64 _quarantinePeriod, uint16 _maxRewardRatioBP) external { + _updateSanityParams(_quarantinePeriod, _maxRewardRatioBP); + } + + function setVaultDataTimestamp(uint64 _vaultsDataTimestamp) external { + Storage storage $ = _storage(); + $.vaultsDataTimestamp = uint64(_vaultsDataTimestamp); + } + + /// @notice Permissionless update of the vault data + /// @param _vault the address of the vault + /// @param _totalValue the total value of the vault + /// @param _cumulativeLidoFees the cumulative Lido fees accrued on the vault (nominated in ether) + /// @param _liabilityShares the liabilityShares of the vault + function updateVaultData( + address _vault, + uint256 _totalValue, + uint256 _cumulativeLidoFees, + uint256 _liabilityShares, + uint64 _vaultsDataTimestamp + ) external { + // bytes32 leaf = keccak256( + // bytes.concat(keccak256(abi.encode(_vault, _totalValue, _cumulativeLidoFees, _liabilityShares))) + // ); + //if (!MerkleProof.verify(_proof, _storage().vaultsDataTreeRoot, leaf)) revert InvalidProof(); + + int256 inOutDelta; + (_totalValue, inOutDelta) = _handleSanityChecks(_vault, _totalValue); + + _vaultHub().applyVaultReport( + _vault, + _vaultsDataTimestamp, + _totalValue, + inOutDelta, + _cumulativeLidoFees, + _liabilityShares + ); + } + + /// @notice handle sanity checks for the vault lazy report data + /// @param _vault the address of the vault + /// @param _totalValue the total value of the vault in refSlot + /// @return totalValue the smoothed total value of the vault after sanity checks + /// @return inOutDelta the inOutDelta in the refSlot + function _handleSanityChecks( + address _vault, + uint256 _totalValue + ) public returns (uint256 totalValue, int256 inOutDelta) { + VaultHub vaultHub = _vaultHub(); + VaultHub.VaultRecord memory record = vaultHub.vaultRecord(_vault); + + // 1. Calculate inOutDelta in the refSlot + int256 curInOutDelta = record.inOutDelta.value; + (uint256 refSlot, ) = HASH_CONSENSUS.getCurrentFrame(); + if (record.inOutDelta.refSlot == refSlot) { + inOutDelta = record.inOutDelta.refSlotValue; + } else { + inOutDelta = curInOutDelta; + } + + // 2. Sanity check for total value increase + totalValue = _processTotalValue(_vault, _totalValue, inOutDelta, record); + + // 3. Sanity check for dynamic total value underflow + if (int256(totalValue) + curInOutDelta - inOutDelta < 0) revert UnderflowInTotalValueCalculation(); + + return (totalValue, inOutDelta); + } + + function _processTotalValue( + address _vault, + uint256 _totalValue, + int256 _inOutDelta, + VaultHub.VaultRecord memory record + ) internal returns (uint256) { + Storage storage $ = _storage(); + + uint256 refSlotTotalValue = uint256( + int256(uint256(record.report.totalValue)) + _inOutDelta - record.report.inOutDelta + ); + // some percentage of funds hasn't passed through the vault's balance is allowed for the EL and CL rewards handling + uint256 limit = (refSlotTotalValue * (TOTAL_BP + $.maxRewardRatioBP)) / TOTAL_BP; + + if (_totalValue > limit) { + Quarantine storage q = $.vaultQuarantines[_vault]; + uint64 reportTs = $.vaultsDataTimestamp; + uint128 quarDelta = q.pendingTotalValueIncrease; + uint128 delta = SafeCast.toUint128(_totalValue - refSlotTotalValue); + + if (quarDelta == 0) { + // first overlimit report + _totalValue = refSlotTotalValue; + q.pendingTotalValueIncrease = delta; + q.startTimestamp = reportTs; + emit QuarantinedDeposit(_vault, delta); + } else if (reportTs - q.startTimestamp < $.quarantinePeriod) { + // quarantine not expired + _totalValue = refSlotTotalValue; + } else if (delta <= quarDelta + (refSlotTotalValue * $.maxRewardRatioBP) / TOTAL_BP) { + // quarantine expired + q.pendingTotalValueIncrease = 0; + emit QuarantineExpired(_vault, delta); + } else { + // start new quarantine + _totalValue = refSlotTotalValue + quarDelta; + q.pendingTotalValueIncrease = delta - quarDelta; + q.startTimestamp = reportTs; + emit QuarantinedDeposit(_vault, delta - quarDelta); + } + } + + return _totalValue; + } + + function _updateSanityParams(uint64 _quarantinePeriod, uint16 _maxRewardRatioBP) internal { + Storage storage $ = _storage(); + $.quarantinePeriod = _quarantinePeriod; + $.maxRewardRatioBP = _maxRewardRatioBP; + emit SanityParamsUpdated(_quarantinePeriod, _maxRewardRatioBP); + } + + function _mintableStETH(address _vault) internal view returns (uint256) { + VaultHub vaultHub = _vaultHub(); + uint256 maxLockableValue = vaultHub.maxLockableValue(_vault); + uint256 reserveRatioBP = vaultHub.vaultConnection(_vault).reserveRatioBP; + uint256 mintableStETHByRR = (maxLockableValue * (TOTAL_BASIS_POINTS - reserveRatioBP)) / TOTAL_BASIS_POINTS; + + uint256 effectiveShareLimit = _operatorGrid().effectiveShareLimit(_vault); + uint256 mintableStEthByShareLimit = ILido(LIDO_LOCATOR.lido()).getPooledEthBySharesRoundUp(effectiveShareLimit); + + return Math256.min(mintableStETHByRR, mintableStEthByShareLimit); + } + + function _storage() internal pure returns (Storage storage $) { + assembly { + $.slot := LAZY_ORACLE_STORAGE_LOCATION + } + } + + function _vaultHub() internal view returns (VaultHub) { + return VaultHub(payable(LIDO_LOCATOR.vaultHub())); + } + + function _operatorGrid() internal view returns (OperatorGrid) { + return OperatorGrid(LIDO_LOCATOR.operatorGrid()); + } + + event VaultsReportDataUpdated(uint256 indexed timestamp, bytes32 indexed root, string cid); + event QuarantinedDeposit(address indexed vault, uint128 delta); + event SanityParamsUpdated(uint64 quarantinePeriod, uint16 maxRewardRatioBP); + event QuarantineExpired(address indexed vault, uint128 delta); + error AdminCannotBeZero(); + error NotAuthorized(); + error InvalidProof(); + error UnderflowInTotalValueCalculation(); +} + +contract LidoMock { + uint256 public totalShares; + uint256 public externalShares; + uint256 public totalPooledEther; + uint256 public bufferedEther; + + constructor(uint256 _totalShares, uint256 _totalPooledEther, uint256 _externalShares) { + if (_totalShares == 0) revert("totalShares cannot be 0"); + if (_totalPooledEther == 0) revert("totalPooledEther cannot be 0"); + + totalShares = _totalShares; + totalPooledEther = _totalPooledEther; + externalShares = _externalShares; + } + + function getSharesByPooledEth(uint256 _ethAmount) public view returns (uint256) { + return (_ethAmount * totalShares) / totalPooledEther; + } + + function getPooledEthByShares(uint256 _sharesAmount) public view returns (uint256) { + return (_sharesAmount * totalPooledEther) / totalShares; + } + + function getTotalShares() external view returns (uint256) { + return totalShares; + } + + function getExternalShares() external view returns (uint256) { + return externalShares; + } + + function mintExternalShares(address, uint256 _amountOfShares) external { + totalShares += _amountOfShares; + externalShares += _amountOfShares; + } + + function burnExternalShares(uint256 _amountOfShares) external { + totalShares -= _amountOfShares; + externalShares -= _amountOfShares; + } + + function stake() external payable { + uint256 sharesAmount = getSharesByPooledEth(msg.value); + totalShares += sharesAmount; + totalPooledEther += msg.value; + } + + function receiveRewards(uint256 _rewards) external { + totalPooledEther += _rewards; + } + + function getExternalEther() external view returns (uint256) { + return _getExternalEther(totalPooledEther); + } + + function _getExternalEther(uint256 _internalEther) internal view returns (uint256) { + return (externalShares * _internalEther) / (totalShares - externalShares); + } + + function rebalanceExternalEtherToInternal() external payable { + uint256 shares = getSharesByPooledEth(msg.value); + if (shares > externalShares) revert("not enough external shares"); + externalShares -= shares; + totalPooledEther += msg.value; + } + + function getPooledEthBySharesRoundUp(uint256 _sharesAmount) external view returns (uint256) { + uint256 etherAmount = (_sharesAmount * totalPooledEther) / totalShares; + if (_sharesAmount * totalPooledEther != etherAmount * totalShares) { + ++etherAmount; + } + return etherAmount; + } + + function transferSharesFrom(address, address, uint256) external pure returns (uint256) { + return 0; + } + + function getTotalPooledEther() external view returns (uint256) { + return totalPooledEther; + } + + function mintShares(address, uint256 _sharesAmount) external { + totalShares += _sharesAmount; + } + + function burnShares(uint256 _amountOfShares) external { + totalShares -= _amountOfShares; + } +} diff --git a/test/0.8.25/invariant-fuzzing/mocks/LazyOracleMock.sol b/test/0.8.25/invariant-fuzzing/mocks/LazyOracleMock.sol new file mode 100644 index 0000000000..8a4096bbe8 --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/mocks/LazyOracleMock.sol @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity 0.8.25; + +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; +import {IHashConsensus} from "contracts/0.8.25/vaults/interfaces/IHashConsensus.sol"; + +import {SafeCast} from "@openzeppelin/contracts-v5.2/utils/math/SafeCast.sol"; + +//import {AccessControlEnumerableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; + +import {Math256} from "contracts/common/lib/Math256.sol"; + +import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; +import {OperatorGrid} from "contracts/0.8.25/vaults/OperatorGrid.sol"; +//import {StakingVault} from "contracts/0.8.25/vaults/StakingVault.sol"; + +import {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; + +contract LazyOracleMock { + struct Storage { + /// @notice root of the vaults data tree + bytes32 vaultsDataTreeRoot; + /// @notice CID of the vaults data tree + string vaultsDataReportCid; + /// @notice timestamp of the vaults data + uint64 vaultsDataTimestamp; + /// @notice total value increase quarantine period + uint64 quarantinePeriod; + /// @notice max reward ratio for refSlot-observed total value, basis points + uint16 maxRewardRatioBP; + /// @notice deposit quarantines for each vault + mapping(address vault => Quarantine) vaultQuarantines; + } + + struct Quarantine { + uint128 pendingTotalValueIncrease; + uint64 startTimestamp; + } + + struct VaultInfo { + address vault; + uint96 vaultIndex; + uint256 balance; + bytes32 withdrawalCredentials; + uint256 liabilityShares; + uint256 mintableStETH; + uint96 shareLimit; + uint16 reserveRatioBP; + uint16 forcedRebalanceThresholdBP; + uint16 infraFeeBP; + uint16 liquidityFeeBP; + uint16 reservationFeeBP; + bool pendingDisconnect; + } + + struct QuarantineInfo { + bool isActive; + uint256 pendingTotalValueIncrease; + uint256 startTimestamp; + uint256 endTimestamp; + } + + // keccak256(abi.encode(uint256(keccak256("LazyOracle")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant LAZY_ORACLE_STORAGE_LOCATION = + 0xe5459f2b48ec5df2407caac4ec464a5cb0f7f31a1f22f649728a9579b25c1d00; + + bytes32 public constant UPDATE_SANITY_PARAMS_ROLE = keccak256("UPDATE_SANITY_PARAMS_ROLE"); + + // total basis points = 100% + uint256 internal constant TOTAL_BP = 100_00; + + ILidoLocator public immutable LIDO_LOCATOR; + IHashConsensus public immutable HASH_CONSENSUS; + + /// @dev basis points base + uint256 private constant TOTAL_BASIS_POINTS = 100_00; + + constructor(address _lidoLocator, address _hashConsensus, uint64 _quarantinePeriod, uint16 _maxRewardRatioBP) { + LIDO_LOCATOR = ILidoLocator(payable(_lidoLocator)); + HASH_CONSENSUS = IHashConsensus(_hashConsensus); + _updateSanityParams(_quarantinePeriod, _maxRewardRatioBP); + } + + /// @notice returns the latest report timestamp + function latestReportTimestamp() external view returns (uint64) { + return _storage().vaultsDataTimestamp; + } + + /// @notice returns the quarantine period + function quarantinePeriod() external view returns (uint64) { + return _storage().quarantinePeriod; + } + + /// @notice returns the max reward ratio for refSlot total value, basis points + function maxRewardRatioBP() external view returns (uint16) { + return _storage().maxRewardRatioBP; + } + + /// @notice returns the quarantine info for the vault + /// @param _vault the address of the vault + // @dev returns zeroed structure if there is no active quarantine + function vaultQuarantine(address _vault) external view returns (QuarantineInfo memory) { + Quarantine storage q = _storage().vaultQuarantines[_vault]; + if (q.pendingTotalValueIncrease == 0) { + return QuarantineInfo(false, 0, 0, 0); + } + + return + QuarantineInfo( + true, + q.pendingTotalValueIncrease, + q.startTimestamp, + q.startTimestamp + _storage().quarantinePeriod + ); + } + + /// @notice update the sanity parameters + /// @param _quarantinePeriod the quarantine period + /// @param _maxRewardRatioBP the max EL CL rewards + function updateSanityParams(uint64 _quarantinePeriod, uint16 _maxRewardRatioBP) external { + _updateSanityParams(_quarantinePeriod, _maxRewardRatioBP); + } + + function setVaultDataTimestamp(uint64 _vaultsDataTimestamp) external { + Storage storage $ = _storage(); + $.vaultsDataTimestamp = uint64(_vaultsDataTimestamp); + } + + /// @notice Permissionless update of the vault data + /// @param _vault the address of the vault + /// @param _totalValue the total value of the vault + /// @param _cumulativeLidoFees the cumulative Lido fees accrued on the vault (nominated in ether) + /// @param _liabilityShares the liabilityShares of the vault + function updateVaultData( + address _vault, + uint256 _totalValue, + uint256 _cumulativeLidoFees, + uint256 _liabilityShares, + uint64 _vaultsDataTimestamp + ) external { + // bytes32 leaf = keccak256( + // bytes.concat(keccak256(abi.encode(_vault, _totalValue, _cumulativeLidoFees, _liabilityShares))) + // ); + //if (!MerkleProof.verify(_proof, _storage().vaultsDataTreeRoot, leaf)) revert InvalidProof(); + + int256 inOutDelta; + (_totalValue, inOutDelta) = _handleSanityChecks(_vault, _totalValue); + + _vaultHub().applyVaultReport( + _vault, + _vaultsDataTimestamp, + _totalValue, + inOutDelta, + _cumulativeLidoFees, + _liabilityShares + ); + } + + /// @notice handle sanity checks for the vault lazy report data + /// @param _vault the address of the vault + /// @param _totalValue the total value of the vault in refSlot + /// @return totalValue the smoothed total value of the vault after sanity checks + /// @return inOutDelta the inOutDelta in the refSlot + function _handleSanityChecks( + address _vault, + uint256 _totalValue + ) public returns (uint256 totalValue, int256 inOutDelta) { + VaultHub vaultHub = _vaultHub(); + VaultHub.VaultRecord memory record = vaultHub.vaultRecord(_vault); + + // 1. Calculate inOutDelta in the refSlot + int256 curInOutDelta = record.inOutDelta.value; + (uint256 refSlot, ) = HASH_CONSENSUS.getCurrentFrame(); + if (record.inOutDelta.refSlot == refSlot) { + inOutDelta = record.inOutDelta.refSlotValue; + } else { + inOutDelta = curInOutDelta; + } + + // 2. Sanity check for total value increase + totalValue = _processTotalValue(_vault, _totalValue, inOutDelta, record); + + // 3. Sanity check for dynamic total value underflow + if (int256(totalValue) + curInOutDelta - inOutDelta < 0) revert UnderflowInTotalValueCalculation(); + + return (totalValue, inOutDelta); + } + + function _processTotalValue( + address _vault, + uint256 _totalValue, + int256 _inOutDelta, + VaultHub.VaultRecord memory record + ) internal returns (uint256) { + Storage storage $ = _storage(); + + uint256 refSlotTotalValue = uint256( + int256(uint256(record.report.totalValue)) + _inOutDelta - record.report.inOutDelta + ); + // some percentage of funds hasn't passed through the vault's balance is allowed for the EL and CL rewards handling + uint256 limit = (refSlotTotalValue * (TOTAL_BP + $.maxRewardRatioBP)) / TOTAL_BP; + + if (_totalValue > limit) { + Quarantine storage q = $.vaultQuarantines[_vault]; + uint64 reportTs = $.vaultsDataTimestamp; + uint128 quarDelta = q.pendingTotalValueIncrease; + uint128 delta = SafeCast.toUint128(_totalValue - refSlotTotalValue); + + if (quarDelta == 0) { + // first overlimit report + _totalValue = refSlotTotalValue; + q.pendingTotalValueIncrease = delta; + q.startTimestamp = reportTs; + emit QuarantinedDeposit(_vault, delta); + } else if (reportTs - q.startTimestamp < $.quarantinePeriod) { + // quarantine not expired + _totalValue = refSlotTotalValue; + } else if (delta <= quarDelta + (refSlotTotalValue * $.maxRewardRatioBP) / TOTAL_BP) { + // quarantine expired + q.pendingTotalValueIncrease = 0; + emit QuarantineExpired(_vault, delta); + } else { + // start new quarantine + _totalValue = refSlotTotalValue + quarDelta; + q.pendingTotalValueIncrease = delta - quarDelta; + q.startTimestamp = reportTs; + emit QuarantinedDeposit(_vault, delta - quarDelta); + } + } + + return _totalValue; + } + + function _updateSanityParams(uint64 _quarantinePeriod, uint16 _maxRewardRatioBP) internal { + Storage storage $ = _storage(); + $.quarantinePeriod = _quarantinePeriod; + $.maxRewardRatioBP = _maxRewardRatioBP; + emit SanityParamsUpdated(_quarantinePeriod, _maxRewardRatioBP); + } + + function _mintableStETH(address _vault) internal view returns (uint256) { + VaultHub vaultHub = _vaultHub(); + uint256 maxLockableValue = vaultHub.maxLockableValue(_vault); + uint256 reserveRatioBP = vaultHub.vaultConnection(_vault).reserveRatioBP; + uint256 mintableStETHByRR = (maxLockableValue * (TOTAL_BASIS_POINTS - reserveRatioBP)) / TOTAL_BASIS_POINTS; + + uint256 effectiveShareLimit = _operatorGrid().effectiveShareLimit(_vault); + uint256 mintableStEthByShareLimit = ILido(LIDO_LOCATOR.lido()).getPooledEthBySharesRoundUp(effectiveShareLimit); + + return Math256.min(mintableStETHByRR, mintableStEthByShareLimit); + } + + function _storage() internal pure returns (Storage storage $) { + assembly { + $.slot := LAZY_ORACLE_STORAGE_LOCATION + } + } + + function _vaultHub() internal view returns (VaultHub) { + return VaultHub(payable(LIDO_LOCATOR.vaultHub())); + } + + function _operatorGrid() internal view returns (OperatorGrid) { + return OperatorGrid(LIDO_LOCATOR.operatorGrid()); + } + + event VaultsReportDataUpdated(uint256 indexed timestamp, bytes32 indexed root, string cid); + event QuarantinedDeposit(address indexed vault, uint128 delta); + event SanityParamsUpdated(uint64 quarantinePeriod, uint16 maxRewardRatioBP); + event QuarantineExpired(address indexed vault, uint128 delta); + error AdminCannotBeZero(); + error NotAuthorized(); + error InvalidProof(); + error UnderflowInTotalValueCalculation(); +} diff --git a/test/0.8.25/invariant-fuzzing/mocks/OperatorGridMock.sol b/test/0.8.25/invariant-fuzzing/mocks/OperatorGridMock.sol new file mode 100644 index 0000000000..c0653880a6 --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/mocks/OperatorGridMock.sol @@ -0,0 +1,699 @@ +// SPDX-FileCopyrightText: 2025 Lido +// SPDX-License-Identifier: GPL-3.0 + +// See contracts/COMPILERS.md +pragma solidity 0.8.25; + +import { + AccessControlEnumerableUpgradeable +} from "contracts/openzeppelin/5.2/upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; + +import {Math256} from "contracts/common/lib/Math256.sol"; +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; + +//import {Confirmable2Addresses} from "../utils/Confirmable2Addresses.sol"; + +import {IStakingVault} from "contracts/0.8.25/vaults/interfaces/IStakingVault.sol"; +import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; + +struct TierParams { + uint256 shareLimit; + uint256 reserveRatioBP; + uint256 forcedRebalanceThresholdBP; + uint256 infraFeeBP; + uint256 liquidityFeeBP; + uint256 reservationFeeBP; +} + +/** + * @title OperatorGrid + * @author loga4 + * @notice + * OperatorGrid is a contract that manages mint parameters for vaults when they are connected to the VaultHub. + * These parameters include: + * - shareLimit: maximum amount of shares that can be minted + * - reserveRatioBP: reserve ratio in basis points + * - forcedRebalanceThresholdBP: forced rebalance threshold in basis points + * - infraFeeBP: infra fee in basis points + * - liquidityFeeBP: liquidity fee in basis points + * - reservationFeeBP: reservation fee in basis points + * + * These parameters are determined by the Tier in which the Vault is registered. + * + */ +//, Confirmable2Addresses { +contract OperatorGridMock is AccessControlEnumerableUpgradeable { + /* + Key concepts: + 1. Default Registration: + - All Vaults initially have default tier (DEFAULT_TIER_ID = 0) + - The default tier has no group + + DEFAULT_TIER_ID = 0 + ┌──────────────────────┐ + │ Tier 1 │ + │ tierShareLimit = z │ + │ Vault_1 ... Vault_m │ + └──────────────────────┘ + + 2. Tier Change Process: + - To predefine vaults tier or modify the existing vault's connection parameters to VaultHub, a tier change must be requested + - Both vault owner and node operator must confirm the change (doesn't matter who confirms first) + - The confirmation has an expiry time (default 1 hour) + + 3. Tier Reset: + - When a vault is disconnected from VaultHub, its tier is automatically reset to the default tier (DEFAULT_TIER_ID) + + 4. Tier Capacity: + - Tiers are not limited by the number of vaults + - Tiers are limited by the sum of vaults' liability shares + + ┌──────────────────────────────────────────────────────┐ + │ Group 1 = operator 1 │ + │ ┌────────────────────────────────────────────────┐ │ + │ │ groupShareLimit = 1kk │ │ + │ └────────────────────────────────────────────────┘ │ + │ ┌──────────────────────┐ ┌──────────────────────┐ │ + │ │ Tier 1 │ │ Tier 2 │ │ + │ │ tierShareLimit = x │ │ tierShareLimit = y │ │ + │ │ Vault_2 ... Vault_k │ │ │ │ + │ └──────────────────────┘ └──────────────────────┘ │ + └──────────────────────────────────────────────────────┘ + */ + + bytes32 public constant REGISTRY_ROLE = keccak256("vaults.OperatorsGrid.Registry"); + + /// @notice Lido Locator contract + ILidoLocator public immutable LIDO_LOCATOR; + + uint256 public constant DEFAULT_TIER_ID = 0; + + // Special address to denote that default tier is not linked to any real operator + address public constant DEFAULT_TIER_OPERATOR = address(uint160(type(uint160).max)); + + /// @dev basis points base + uint256 internal constant TOTAL_BASIS_POINTS = 100_00; + /// @dev max value for fees in basis points - it's about 650% + uint256 internal constant MAX_FEE_BP = type(uint16).max; + + // ----------------------------- + // STRUCTS + // ----------------------------- + struct Group { + address operator; + uint96 shareLimit; + uint96 liabilityShares; + uint256[] tierIds; + } + + struct Tier { + address operator; + uint96 shareLimit; + uint96 liabilityShares; + uint16 reserveRatioBP; + uint16 forcedRebalanceThresholdBP; + uint16 infraFeeBP; + uint16 liquidityFeeBP; + uint16 reservationFeeBP; + } + + /** + * @notice ERC-7201 storage namespace for the OperatorGrid + * @dev ERC-7201 namespace is used to prevent upgrade collisions + * @custom:storage-location erc7201:Lido.Vaults.OperatorGrid + * @custom:tiers Tiers + * @custom:vaultTier Vault tier + * @custom:groups Groups + * @custom:nodeOperators Node operators + */ + struct ERC7201Storage { + Tier[] tiers; + mapping(address vault => uint256 tierId) vaultTier; + mapping(address nodeOperator => Group) groups; + address[] nodeOperators; + } + + /** + * @notice Storage offset slot for ERC-7201 namespace + * The storage namespace is used to prevent upgrade collisions + * keccak256(abi.encode(uint256(keccak256("Lido.Vaults.OperatorGrid")) - 1)) & ~bytes32(uint256(0xff)) + */ + bytes32 private constant OPERATOR_GRID_STORAGE_LOCATION = + 0x6b64617c951381e2c1eff2be939fe368ab6d76b7d335df2e47ba2309eba1c700; + + /// @notice Initializes the contract with a LidoLocator + /// @param _locator LidoLocator contract + constructor(ILidoLocator _locator) { + LIDO_LOCATOR = _locator; + + _disableInitializers(); + } + + /// @notice Initializes the contract with an admin + /// @param _admin Address of the admin + /// @param _defaultTierParams Default tier params for the default tier + function initialize(address _admin, TierParams calldata _defaultTierParams) external initializer { + if (_admin == address(0)) revert ZeroArgument("_admin"); + + __AccessControlEnumerable_init(); + //__Confirmations_init(); + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + + ERC7201Storage storage $ = _getStorage(); + + //create default tier with default share limit + $.tiers.push( + Tier({ + operator: DEFAULT_TIER_OPERATOR, + shareLimit: uint96(_defaultTierParams.shareLimit), + reserveRatioBP: uint16(_defaultTierParams.reserveRatioBP), + forcedRebalanceThresholdBP: uint16(_defaultTierParams.forcedRebalanceThresholdBP), + infraFeeBP: uint16(_defaultTierParams.infraFeeBP), + liquidityFeeBP: uint16(_defaultTierParams.liquidityFeeBP), + reservationFeeBP: uint16(_defaultTierParams.reservationFeeBP), + liabilityShares: 0 + }) + ); + } + + /// @notice Registers a new group + /// @param _nodeOperator address of the node operator + /// @param _shareLimit Maximum share limit for the group + function registerGroup(address _nodeOperator, uint256 _shareLimit) external onlyRole(REGISTRY_ROLE) { + if (_nodeOperator == address(0)) revert ZeroArgument("_nodeOperator"); + + ERC7201Storage storage $ = _getStorage(); + if ($.groups[_nodeOperator].operator != address(0)) revert GroupExists(); + + $.groups[_nodeOperator] = Group({ + operator: _nodeOperator, + shareLimit: uint96(_shareLimit), + liabilityShares: 0, + tierIds: new uint256[](0) + }); + $.nodeOperators.push(_nodeOperator); + + emit GroupAdded(_nodeOperator, uint96(_shareLimit)); + } + + /// @notice Updates the share limit of a group + /// @param _nodeOperator address of the node operator + /// @param _shareLimit New share limit value + function updateGroupShareLimit(address _nodeOperator, uint256 _shareLimit) external onlyRole(REGISTRY_ROLE) { + if (_nodeOperator == address(0)) revert ZeroArgument("_nodeOperator"); + + ERC7201Storage storage $ = _getStorage(); + Group storage group_ = $.groups[_nodeOperator]; + if (group_.operator == address(0)) revert GroupNotExists(); + + group_.shareLimit = uint96(_shareLimit); + + emit GroupShareLimitUpdated(_nodeOperator, uint96(_shareLimit)); + } + + /// @notice Returns a group by node operator address + /// @param _nodeOperator address of the node operator + /// @return Group + function group(address _nodeOperator) external view returns (Group memory) { + return _getStorage().groups[_nodeOperator]; + } + + /// @notice Returns a node operator address by index + /// @param _index index of the node operator + /// @return Node operator address + function nodeOperatorAddress(uint256 _index) external view returns (address) { + ERC7201Storage storage $ = _getStorage(); + if (_index >= $.nodeOperators.length) revert NodeOperatorNotExists(); + return $.nodeOperators[_index]; + } + + /// @notice Returns a node operator count + /// @return Node operator count + function nodeOperatorCount() external view returns (uint256) { + return _getStorage().nodeOperators.length; + } + + /// @notice Registers a new tier + /// @param _nodeOperator address of the node operator + /// @param _tiers array of tiers to register + function registerTiers(address _nodeOperator, TierParams[] calldata _tiers) external onlyRole(REGISTRY_ROLE) { + if (_nodeOperator == address(0)) revert ZeroArgument("_nodeOperator"); + + ERC7201Storage storage $ = _getStorage(); + Group storage group_ = $.groups[_nodeOperator]; + if (group_.operator == address(0)) revert GroupNotExists(); + + uint256 tierId = $.tiers.length; + uint256 length = _tiers.length; + for (uint256 i = 0; i < length; i++) { + _validateParams( + tierId, + _tiers[i].reserveRatioBP, + _tiers[i].forcedRebalanceThresholdBP, + _tiers[i].infraFeeBP, + _tiers[i].liquidityFeeBP, + _tiers[i].reservationFeeBP + ); + + Tier memory tier_ = Tier({ + operator: _nodeOperator, + shareLimit: uint96(_tiers[i].shareLimit), + reserveRatioBP: uint16(_tiers[i].reserveRatioBP), + forcedRebalanceThresholdBP: uint16(_tiers[i].forcedRebalanceThresholdBP), + infraFeeBP: uint16(_tiers[i].infraFeeBP), + liquidityFeeBP: uint16(_tiers[i].liquidityFeeBP), + reservationFeeBP: uint16(_tiers[i].reservationFeeBP), + liabilityShares: 0 + }); + $.tiers.push(tier_); + group_.tierIds.push(tierId); + + emit TierAdded( + _nodeOperator, + tierId, + uint96(tier_.shareLimit), + uint16(tier_.reserveRatioBP), + uint16(tier_.forcedRebalanceThresholdBP), + uint16(tier_.infraFeeBP), + uint16(tier_.liquidityFeeBP), + uint16(tier_.reservationFeeBP) + ); + + tierId++; + } + } + + /// @notice Returns a tier by ID + /// @param _tierId id of the tier + /// @return Tier + function tier(uint256 _tierId) external view returns (Tier memory) { + ERC7201Storage storage $ = _getStorage(); + if (_tierId >= $.tiers.length) revert TierNotExists(); + return $.tiers[_tierId]; + } + + /// @notice Returns a tiers count + /// @return Tiers count + function tiersCount() external view returns (uint256) { + return _getStorage().tiers.length; + } + + /// @notice Alters multiple tiers + /// @dev We do not enforce to update old vaults with the new tier params, only new ones. + /// @param _tierIds array of tier ids to alter + /// @param _tierParams array of new tier params + function alterTiers( + uint256[] calldata _tierIds, + TierParams[] calldata _tierParams + ) external onlyRole(REGISTRY_ROLE) { + if (_tierIds.length != _tierParams.length) revert ArrayLengthMismatch(); + + ERC7201Storage storage $ = _getStorage(); + uint256 length = _tierIds.length; + uint256 tiersLength = $.tiers.length; + + for (uint256 i = 0; i < length; i++) { + if (_tierIds[i] >= tiersLength) revert TierNotExists(); + + _validateParams( + _tierIds[i], + _tierParams[i].reserveRatioBP, + _tierParams[i].forcedRebalanceThresholdBP, + _tierParams[i].infraFeeBP, + _tierParams[i].liquidityFeeBP, + _tierParams[i].reservationFeeBP + ); + + Tier storage tier_ = $.tiers[_tierIds[i]]; + + tier_.shareLimit = uint96(_tierParams[i].shareLimit); + tier_.reserveRatioBP = uint16(_tierParams[i].reserveRatioBP); + tier_.forcedRebalanceThresholdBP = uint16(_tierParams[i].forcedRebalanceThresholdBP); + tier_.infraFeeBP = uint16(_tierParams[i].infraFeeBP); + tier_.liquidityFeeBP = uint16(_tierParams[i].liquidityFeeBP); + tier_.reservationFeeBP = uint16(_tierParams[i].reservationFeeBP); + + emit TierUpdated( + _tierIds[i], + tier_.shareLimit, + tier_.reserveRatioBP, + tier_.forcedRebalanceThresholdBP, + tier_.infraFeeBP, + tier_.liquidityFeeBP, + tier_.reservationFeeBP + ); + } + } + + /// @notice Vault tier change with multi-role confirmation + /// @param _vault address of the vault + /// @param _requestedTierId id of the tier + /// @param _requestedShareLimit share limit to set + /// @return bool Whether the tier change was confirmed. + /* + + Legend: + V = Vault1.liabilityShares + LS = liabilityShares + + Scheme1 - transfer Vault from default tier to Tier2 + + ┌──────────────────────────────┐ + │ Group 1 │ + │ │ + ┌────────────────────┐ │ ┌─────────┐ ┌───────────┐ │ + │ Tier 1 (default) │ confirm │ │ Tier 2 │ │ Tier 3 │ │ + │ LS: -V │ ─────> │ │ LS:+V │ │ │ │ + └────────────────────┘ │ └─────────┘ └───────────┘ │ + │ │ + │ Group1.liabilityShares: +V │ + └──────────────────────────────┘ + + After confirmation: + - Tier 1.liabilityShares = -V + - Tier 2.liabilityShares = +V + - Group1.liabilityShares = +V + + -------------------------------------------------------------------------- + Scheme2 - transfer Vault from Tier2 to Tier3, no need to change group minted shares + + ┌────────────────────────────────┐ ┌────────────────────────────────┐ + │ Group 1 │ │ Group 2 │ + │ │ │ │ + │ ┌───────────┐ ┌───────────┐ │ │ ┌───────────┐ │ + │ │ Tier 2 │ │ Tier 3 │ │ │ │ Tier 4 │ │ + │ │ LS:-V │ │ LS:+V │ │ │ │ │ │ + │ └───────────┘ └───────────┘ │ │ └───────────┘ │ + │ operator1 │ │ operator2 │ + └────────────────────────────────┘ └────────────────────────────────┘ + + After confirmation: + - Tier 2.liabilityShares = -V + - Tier 3.liabilityShares = +V + + NB: Cannot change from Tier2 to Tier1, because Tier1 has no group + NB: Cannot change from Tier2 to Tier4, because Tier4 has different operator. + + */ + function changeTier( + address _vault, + uint256 _requestedTierId, + uint256 _requestedShareLimit + ) external returns (bool) { + if (_vault == address(0)) revert ZeroArgument("_vault"); + + ERC7201Storage storage $ = _getStorage(); + if (_requestedTierId >= $.tiers.length) revert TierNotExists(); + if (_requestedTierId == DEFAULT_TIER_ID) revert CannotChangeToDefaultTier(); + + VaultHub vaultHub = _vaultHub(); + bool isVaultConnected = vaultHub.isVaultConnected(_vault); + + address vaultOwner = isVaultConnected ? vaultHub.vaultConnection(_vault).owner : IStakingVault(_vault).owner(); + + address nodeOperator = IStakingVault(_vault).nodeOperator(); + + uint256 vaultTierId = $.vaultTier[_vault]; + if (vaultTierId == _requestedTierId) revert TierAlreadySet(); + + Tier storage requestedTier = $.tiers[_requestedTierId]; + if (nodeOperator != requestedTier.operator) revert TierNotInOperatorGroup(); + if (_requestedShareLimit > requestedTier.shareLimit) + revert RequestedShareLimitTooHigh(_requestedShareLimit, requestedTier.shareLimit); + + // store the caller's confirmation; only proceed if the required number of confirmations is met. + //if (!_collectAndCheckConfirmations(msg.data, vaultOwner, nodeOperator)) return false; + uint256 vaultLiabilityShares = vaultHub.liabilityShares(_vault); + + //check if tier limit is exceeded + if (requestedTier.liabilityShares + vaultLiabilityShares > requestedTier.shareLimit) revert TierLimitExceeded(); + + // if the vault was in the default tier: + // - that mean that the vault has no group, so we decrease only the minted shares of the default tier + // - but need to check requested group limit exceeded + if (vaultTierId == DEFAULT_TIER_ID) { + Group storage requestedGroup = $.groups[nodeOperator]; + if (requestedGroup.liabilityShares + vaultLiabilityShares > requestedGroup.shareLimit) { + revert GroupLimitExceeded(); + } + requestedGroup.liabilityShares += uint96(vaultLiabilityShares); + } + + Tier storage currentTier = $.tiers[vaultTierId]; + + currentTier.liabilityShares -= uint96(vaultLiabilityShares); + requestedTier.liabilityShares += uint96(vaultLiabilityShares); + + $.vaultTier[_vault] = _requestedTierId; + + // Vault may not be connected to VaultHub yet. + // There are two possible flows: + // 1. Vault is created and connected to VaultHub immediately with the default tier. + // In this case, `VaultConnection` is non-zero and updateConnection must be called. + // 2. Vault is created, its tier is changed before connecting to VaultHub. + // In this case, `VaultConnection` is still zero, and updateConnection must be skipped. + // Hence, we update the VaultHub connection only if the vault is already connected. + vaultHub.updateConnection( + _vault, + _requestedShareLimit, + requestedTier.reserveRatioBP, + requestedTier.forcedRebalanceThresholdBP, + requestedTier.infraFeeBP, + requestedTier.liquidityFeeBP, + requestedTier.reservationFeeBP + ); + + emit TierChanged(_vault, _requestedTierId, _requestedShareLimit); + + return true; + } + + /// @notice Reset vault's tier to default + /// @param _vault address of the vault + /// @dev Requires vault's liabilityShares to be zero before resetting the tier + function resetVaultTier(address _vault) external { + if (msg.sender != LIDO_LOCATOR.vaultHub()) revert NotAuthorized("resetVaultTier", msg.sender); + + ERC7201Storage storage $ = _getStorage(); + + if ($.vaultTier[_vault] != DEFAULT_TIER_ID) { + $.vaultTier[_vault] = DEFAULT_TIER_ID; + + emit TierChanged(_vault, DEFAULT_TIER_ID, $.tiers[DEFAULT_TIER_ID].shareLimit); + } + } + + // ----------------------------- + // MINT / BURN + // ----------------------------- + + /// @notice Mint shares limit check + /// @param _vault address of the vault + /// @param _amount amount of shares will be minted + function onMintedShares(address _vault, uint256 _amount) external { + if (msg.sender != LIDO_LOCATOR.vaultHub()) revert NotAuthorized("onMintedShares", msg.sender); + + ERC7201Storage storage $ = _getStorage(); + + uint256 tierId = $.vaultTier[_vault]; + Tier storage tier_ = $.tiers[tierId]; + + uint96 tierLiabilityShares = tier_.liabilityShares; + if (tierLiabilityShares + _amount > tier_.shareLimit) revert TierLimitExceeded(); + + tier_.liabilityShares = tierLiabilityShares + uint96(_amount); + + if (tierId != DEFAULT_TIER_ID) { + Group storage group_ = $.groups[tier_.operator]; + uint96 groupMintedShares = group_.liabilityShares; + if (groupMintedShares + _amount > group_.shareLimit) revert GroupLimitExceeded(); + + group_.liabilityShares = groupMintedShares + uint96(_amount); + } + } + + /// @notice Burn shares limit check + /// @param _vault address of the vault + /// @param _amount amount of shares to burn + function onBurnedShares(address _vault, uint256 _amount) external { + if (msg.sender != LIDO_LOCATOR.vaultHub()) revert NotAuthorized("burnShares", msg.sender); + + ERC7201Storage storage $ = _getStorage(); + + uint256 tierId = $.vaultTier[_vault]; + + Tier storage tier_ = $.tiers[tierId]; + + // we skip the check for minted shared underflow, because it's done in the VaultHub.burnShares() + + tier_.liabilityShares -= uint96(_amount); + + if (tierId != DEFAULT_TIER_ID) { + Group storage group_ = $.groups[tier_.operator]; + group_.liabilityShares -= uint96(_amount); + } + } + + /// @notice Get vault limits + /// @param _vault address of the vault + /// @return nodeOperator node operator of the vault + /// @return tierId tier id of the vault + /// @return shareLimit share limit of the vault + /// @return reserveRatioBP reserve ratio of the vault + /// @return forcedRebalanceThresholdBP forced rebalance threshold of the vault + /// @return infraFeeBP infra fee of the vault + /// @return liquidityFeeBP liquidity fee of the vault + /// @return reservationFeeBP reservation fee of the vault + function vaultInfo( + address _vault + ) + external + view + returns ( + address nodeOperator, + uint256 tierId, + uint256 shareLimit, + uint256 reserveRatioBP, + uint256 forcedRebalanceThresholdBP, + uint256 infraFeeBP, + uint256 liquidityFeeBP, + uint256 reservationFeeBP + ) + { + ERC7201Storage storage $ = _getStorage(); + + tierId = $.vaultTier[_vault]; + + Tier memory t = $.tiers[tierId]; + nodeOperator = t.operator; + + shareLimit = t.shareLimit; + reserveRatioBP = t.reserveRatioBP; + forcedRebalanceThresholdBP = t.forcedRebalanceThresholdBP; + infraFeeBP = t.infraFeeBP; + liquidityFeeBP = t.liquidityFeeBP; + reservationFeeBP = t.reservationFeeBP; + } + + /// @notice Returns the effective share limit of a vault according to the OperatorGrid and vault share limits + /// @param _vault address of the vault + /// @return shareLimit effective share limit of the vault + function effectiveShareLimit(address _vault) public view returns (uint256) { + VaultHub vaultHub = _vaultHub(); + uint256 shareLimit = vaultHub.vaultConnection(_vault).shareLimit; + uint256 liabilityShares = vaultHub.liabilityShares(_vault); + + uint256 gridShareLimit = _gridRemainingShareLimit(_vault) + liabilityShares; + return Math256.min(gridShareLimit, shareLimit); + } + + /// @notice Returns the remaining share limit in a given tier and group + /// @param _vault address of the vault + /// @return remaining share limit + /// @dev remaining share limit inherits the limits of the vault tier and group, + /// and accounts liabilities of other vaults belonging to the same tier and group + function _gridRemainingShareLimit(address _vault) internal view returns (uint256) { + ERC7201Storage storage $ = _getStorage(); + uint256 tierId = $.vaultTier[_vault]; + Tier storage t = $.tiers[tierId]; + + uint256 tierLimit = t.shareLimit; + uint256 tierRemaining = tierLimit > t.liabilityShares ? tierLimit - t.liabilityShares : 0; + + if (tierId == DEFAULT_TIER_ID) return tierRemaining; + + Group storage g = $.groups[t.operator]; + uint256 groupLimit = g.shareLimit; + uint256 groupRemaining = groupLimit > g.liabilityShares ? groupLimit - g.liabilityShares : 0; + return Math256.min(tierRemaining, groupRemaining); + } + + /// @notice Validates tier parameters + /// @param _reserveRatioBP Reserve ratio + /// @param _forcedRebalanceThresholdBP Forced rebalance threshold + /// @param _infraFeeBP Infra fee + /// @param _liquidityFeeBP Liquidity fee + /// @param _reservationFeeBP Reservation fee + function _validateParams( + uint256 _tierId, + uint256 _reserveRatioBP, + uint256 _forcedRebalanceThresholdBP, + uint256 _infraFeeBP, + uint256 _liquidityFeeBP, + uint256 _reservationFeeBP + ) internal pure { + if (_reserveRatioBP == 0) revert ZeroArgument("_reserveRatioBP"); + if (_reserveRatioBP > TOTAL_BASIS_POINTS) + revert ReserveRatioTooHigh(_tierId, _reserveRatioBP, TOTAL_BASIS_POINTS); + + if (_forcedRebalanceThresholdBP == 0) revert ZeroArgument("_forcedRebalanceThresholdBP"); + if (_forcedRebalanceThresholdBP > _reserveRatioBP) + revert ForcedRebalanceThresholdTooHigh(_tierId, _forcedRebalanceThresholdBP, _reserveRatioBP); + + if (_infraFeeBP > MAX_FEE_BP) revert InfraFeeTooHigh(_tierId, _infraFeeBP, MAX_FEE_BP); + + if (_liquidityFeeBP > MAX_FEE_BP) revert LiquidityFeeTooHigh(_tierId, _liquidityFeeBP, MAX_FEE_BP); + + if (_reservationFeeBP > MAX_FEE_BP) revert ReservationFeeTooHigh(_tierId, _reservationFeeBP, MAX_FEE_BP); + } + + function _vaultHub() internal view returns (VaultHub) { + return VaultHub(payable(LIDO_LOCATOR.vaultHub())); + } + + function _getStorage() private pure returns (ERC7201Storage storage $) { + assembly { + $.slot := OPERATOR_GRID_STORAGE_LOCATION + } + } + + // ----------------------------- + // EVENTS + // ----------------------------- + event GroupAdded(address indexed nodeOperator, uint256 shareLimit); + event GroupShareLimitUpdated(address indexed nodeOperator, uint256 shareLimit); + event TierAdded( + address indexed nodeOperator, + uint256 indexed tierId, + uint256 shareLimit, + uint256 reserveRatioBP, + uint256 forcedRebalanceThresholdBP, + uint256 infraFeeBP, + uint256 liquidityFeeBP, + uint256 reservationFeeBP + ); + event TierChanged(address indexed vault, uint256 indexed tierId, uint256 shareLimit); + event TierUpdated( + uint256 indexed tierId, + uint256 shareLimit, + uint256 reserveRatioBP, + uint256 forcedRebalanceThresholdBP, + uint256 infraFeeBP, + uint256 liquidityFeeBP, + uint256 reservationFeeBP + ); + + // ----------------------------- + // ERRORS + // ----------------------------- + error NotAuthorized(string operation, address sender); + error ZeroArgument(string argument); + error GroupExists(); + error GroupNotExists(); + error GroupLimitExceeded(); + error NodeOperatorNotExists(); + error TierLimitExceeded(); + + error TierNotExists(); + error TierAlreadySet(); + error TierNotInOperatorGroup(); + error CannotChangeToDefaultTier(); + + error ReserveRatioTooHigh(uint256 tierId, uint256 reserveRatioBP, uint256 maxReserveRatioBP); + error ForcedRebalanceThresholdTooHigh(uint256 tierId, uint256 forcedRebalanceThresholdBP, uint256 reserveRatioBP); + error InfraFeeTooHigh(uint256 tierId, uint256 infraFeeBP, uint256 maxInfraFeeBP); + error LiquidityFeeTooHigh(uint256 tierId, uint256 liquidityFeeBP, uint256 maxLiquidityFeeBP); + error ReservationFeeTooHigh(uint256 tierId, uint256 reservationFeeBP, uint256 maxReservationFeeBP); + error ArrayLengthMismatch(); + error RequestedShareLimitTooHigh(uint256 requestedShareLimit, uint256 tierShareLimit); +} From 632cecfa203e94940e1e8db47b5aa13ea482afd3 Mon Sep 17 00:00:00 2001 From: fsle <148141389+fsle@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:49:02 +0200 Subject: [PATCH 02/16] adding VALIDATOR_EXIT_ROLE to rootAccount and modifying totalValue underflow invariant --- .../StakingVaultsFuzzing.t.sol | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol index 14628caea5..fbdb26da88 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol @@ -134,6 +134,10 @@ contract StakingVaultsTest is Test { bytes32 vaultCodehashSetRole = vaultHubProxy.VAULT_CODEHASH_SET_ROLE(); vm.prank(rootAccount); vaultHubProxy.grantRole(vaultCodehashSetRole, rootAccount); + + bytes32 validatorExitRole = vaultHubProxy.VALIDATOR_EXIT_ROLE(); + vm.prank(rootAccount); + vaultHubProxy.grantRole(validatorExitRole, rootAccount); } function deployStakingVault() internal { @@ -209,17 +213,14 @@ contract StakingVaultsTest is Test { //This invariant checks that the dynamic (accounting for deltas) totalValue of the vault is not underflowed function invariant_dynamic_totalValue_should_not_underflow() external { - int256 inOutDelta; - uint256 totalValue = vaultHubProxy.totalValue(address(stakingVaultProxy)); VaultHub.VaultRecord memory record = vaultHubProxy.vaultRecord(address(stakingVaultProxy)); - int256 curInOutDelta = record.inOutDelta.value; - (uint256 refSlot, ) = IHashConsensus(consensusContract_addr).getCurrentFrame(); - if (record.inOutDelta.refSlot == refSlot) { - inOutDelta = record.inOutDelta.refSlotValue; - } else { - inOutDelta = curInOutDelta; - } - assertGe(int256(totalValue) + curInOutDelta - inOutDelta, 0, "Dynamic total value should not underflow"); //@audit this should revert with high totalValue + assertGe( + int256(uint256(record.report.totalValue)) + + int256(record.inOutDelta.value) - + int256(record.report.inOutDelta), + 0, + "Total value should not underflow" + ); } //forceRebalance and forceValidatorExit should notrevert when the vault is unhealthy From aa3091059a44adcbf30ba3739abc09100a75f44e Mon Sep 17 00:00:00 2001 From: fsle <148141389+fsle@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:51:45 +0200 Subject: [PATCH 03/16] removing path orientation\ modifing vhwithdraw\ fix deal amount\ check for vaults connected when applygin report --- .../StakingVaultsHandler.t.sol | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol index 7521af15e4..56c98393a9 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol @@ -105,7 +105,7 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions if (actionPath[actionIndex] == action) { actionIndex++; } else { - revert("not the good sequence"); + return; //not the good squence } _; } @@ -128,7 +128,7 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions return forceValidatorExitReverted; } ////////// VAULTHUB INTERACTIONS ////////// - function connectVault() public actionIndexUpdate(VaultAction.CONNECT) { + function connectVault() public { //check if the vault is already connected VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVault)); @@ -147,7 +147,7 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions vaultHub.connectVault(address(stakingVault)); } - function voluntaryDisconnect() public actionIndexUpdate(VaultAction.VOLUNTARY_DISCONNECT) { + function voluntaryDisconnect() public { VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVault)); //do nothing if disconnected or already disconnecting @@ -163,15 +163,20 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions vaultHub.voluntaryDisconnect(address(stakingVault)); } - function fund(uint256 amount) public actionIndexUpdate(VaultAction.FUND) { + function fund(uint256 amount) public { amount = bound(amount, 1, 1 ether); deal(address(userAccount), amount); vm.prank(userAccount); vaultHub.fund{value: amount}(address(stakingVault)); } - function VHwithdraw(uint256 amount) public actionIndexUpdate(VaultAction.VH_WITHDRAW) { - amount = bound(amount, 0, vaultHub.withdrawableValue(address(stakingVault))); + function VHwithdraw(uint256 amount) public { + amount = bound(amount, 1, vaultHub.withdrawableValue(address(stakingVault))); + + //check that stakingVault is connected + if (vaultHub.vaultConnection(address(stakingVault)).vaultIndex == 0) { + return; + } if (amount == 0) { return; @@ -255,25 +260,33 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions return vaultHub.totalValue(address(stakingVault)); } - function sv_otcDeposit(uint256 amount) public actionIndexUpdate(VaultAction.SV_OTC_DEPOSIT) { + function sv_otcDeposit(uint256 amount) public { amount = bound(amount, 1 ether, 10 ether); sv_otcDeposited += amount; - deal(address(address(stakingVault)), amount); + deal(address(stakingVault), address(stakingVault).balance + amount); + + console2.log("stakingVault balance =", address(stakingVault).balance); } - function vh_otcDeposit(uint256 amount) public actionIndexUpdate(VaultAction.VH_OTC_DEPOSIT) { + function vh_otcDeposit(uint256 amount) public { amount = bound(amount, 1 ether, 10 ether); vh_otcDeposited += amount; - deal(address(address(vaultHub)), amount); + deal(address(vaultHub), address(vaultHub).balance + amount); } ////////// LazyOracle INTERACTIONS ////////// - function updateVaultData(uint256 daysShift) public actionIndexUpdate(VaultAction.UPDATE_VAULT_DATA) { + function updateVaultData(uint256 daysShift) public { daysShift = bound(daysShift, 0, 1); daysShift *= 3; //0 or 3 days for quarantine period expiration console2.log("DaysShift = %d", daysShift); + VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVault)); + + //do nothing if disconnected + if (vc.vaultIndex == 0) + return; + if (daysShift > 0) { vm.warp(block.timestamp + daysShift * 1 days); lazyOracle.setVaultDataTimestamp(uint64(block.timestamp)); @@ -301,6 +314,10 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions consensusContract.setCurrentFrame(refSlot); } + //That means that there has no been any new refSLot meanning no new report since vault connection + if (lastReport.totalValue == 0 && lastReport.cumulativeLidoFees == 0) + return; + //we update the reported total Value reportedTotalValue = lastReport.totalValue; @@ -325,7 +342,7 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions ////////// STAKING VAULT INTERACTIONS ////////// - function SVwithdraw(uint256 amount) public actionIndexUpdate(VaultAction.SV_WITHDRAW) { + function SVwithdraw(uint256 amount) public { if (stakingVault.owner() != userAccount) { return; //we are managed by the VaultHub } From 0449d6ea4bcd34e6ecfa0a4cd6e793b80170f797 Mon Sep 17 00:00:00 2001 From: fsle <148141389+fsle@users.noreply.github.com> Date: Wed, 20 Aug 2025 17:02:15 +0200 Subject: [PATCH 04/16] must have some gas to trigger the exit --- .../invariant-fuzzing/StakingVaultsHandler.t.sol | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol index 56c98393a9..ff392429b8 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol @@ -172,7 +172,7 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions function VHwithdraw(uint256 amount) public { amount = bound(amount, 1, vaultHub.withdrawableValue(address(stakingVault))); - + //check that stakingVault is connected if (vaultHub.vaultConnection(address(stakingVault)).vaultIndex == 0) { return; @@ -208,7 +208,7 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions } bytes memory pubkeys = new bytes(0); vm.prank(rootAccount); //privileged account can force exit - try vaultHub.forceValidatorExit(address(stakingVault), pubkeys, userAccount) { + try vaultHub.forceValidatorExit{value: 3000}(address(stakingVault), pubkeys, userAccount) { // If the call succeeds, we do nothing } catch { forceValidatorExitReverted = true; @@ -284,8 +284,7 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVault)); //do nothing if disconnected - if (vc.vaultIndex == 0) - return; + if (vc.vaultIndex == 0) return; if (daysShift > 0) { vm.warp(block.timestamp + daysShift * 1 days); @@ -315,9 +314,8 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions } //That means that there has no been any new refSLot meanning no new report since vault connection - if (lastReport.totalValue == 0 && lastReport.cumulativeLidoFees == 0) - return; - + if (lastReport.totalValue == 0 && lastReport.cumulativeLidoFees == 0) return; + //we update the reported total Value reportedTotalValue = lastReport.totalValue; From 53f9bf30003b19e5048100a1e0c3a466ac21b660 Mon Sep 17 00:00:00 2001 From: fsle <148141389+fsle@users.noreply.github.com> Date: Wed, 20 Aug 2025 17:58:29 +0200 Subject: [PATCH 05/16] refactor connection test --- .../StakingVaultsHandler.t.sol | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol index ff392429b8..b9e0a6d3c3 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol @@ -129,11 +129,10 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions } ////////// VAULTHUB INTERACTIONS ////////// function connectVault() public { - //check if the vault is already connected - VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVault)); - - //do nothing if already connected - if (vc.vaultIndex != 0) return; + //check if the vault is connected + if (vaultHub.vaultConnection(address(stakingVault)).vaultIndex == 0) { + return; + } if (address(stakingVault).balance < Constants.CONNECT_DEPOSIT) { deal(address(userAccount), Constants.CONNECT_DEPOSIT); @@ -281,10 +280,10 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions daysShift *= 3; //0 or 3 days for quarantine period expiration console2.log("DaysShift = %d", daysShift); - VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVault)); - - //do nothing if disconnected - if (vc.vaultIndex == 0) return; + //Check if vault is connected before proceeding + if (vaultHub.vaultConnection(address(stakingVault)).vaultIndex == 0) { + return; + } if (daysShift > 0) { vm.warp(block.timestamp + daysShift * 1 days); From ce072cf2ad67b8582dd4ae285292b4e4fce56d34 Mon Sep 17 00:00:00 2001 From: fsle <148141389+fsle@users.noreply.github.com> Date: Wed, 20 Aug 2025 22:12:26 +0200 Subject: [PATCH 06/16] multi vault fuzzing --- .../MultiStakingVaultFuzzing.t.sol | 347 +++++++++++++++++ .../MultiStakingVaultHandler.t.sol | 363 ++++++++++++++++++ 2 files changed, 710 insertions(+) create mode 100644 test/0.8.25/invariant-fuzzing/MultiStakingVaultFuzzing.t.sol create mode 100644 test/0.8.25/invariant-fuzzing/MultiStakingVaultHandler.t.sol diff --git a/test/0.8.25/invariant-fuzzing/MultiStakingVaultFuzzing.t.sol b/test/0.8.25/invariant-fuzzing/MultiStakingVaultFuzzing.t.sol new file mode 100644 index 0000000000..4026d549db --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/MultiStakingVaultFuzzing.t.sol @@ -0,0 +1,347 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity 0.8.25; + +import {Test} from "forge-std/Test.sol"; +import "forge-std/console2.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts-v5.2/proxy/ERC1967/ERC1967Proxy.sol"; +import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; +import {StakingVault} from "contracts/0.8.25/vaults/StakingVault.sol"; + +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; +import {IHashConsensus} from "contracts/0.8.25/vaults/interfaces/IHashConsensus.sol"; +import {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; + +import {MultiStakingVaultHandler} from "./MultiStakingVaultHandler.t.sol"; +import {Constants} from "./StakingVaultConstants.sol"; + +import {LazyOracleMock} from "./mocks/LazyOracleMock.sol"; +import {OperatorGridMock, TierParams} from "./mocks/OperatorGridMock.sol"; + +import {Math256} from "contracts/common/lib/Math256.sol"; + +contract MultiStakingVaultsTest is Test { + VaultHub vaultHubProxy; + StakingVault[] stakingVaultProxies; + + OperatorGridMock operatorGridProxy; + + uint256[2] groupShareLimit = [1000 ether, 500 ether]; + MultiStakingVaultHandler msvHandler; + + address private rootAccount = makeAddr("rootAccount"); + address[2] private nodeOpAccount = [makeAddr("nodeOpAccount1"), makeAddr("nodeOpAccount2")]; + address[] private userAccount; + + uint256 private constant NB_VAULTS = 4; + + address private treasury_addr = makeAddr("treasury"); + address private depositor = makeAddr("depositor"); + address private nodeOperator = makeAddr("nodeOperator"); + + //contracts addresses + address private pdg_addr = makeAddr("predepositGuarantee"); + address private accounting_addr = makeAddr("accounting"); + address private lazyOracle_addr = makeAddr("lazyoracle"); + address private operatorGrid_addr = makeAddr("operatorGrid"); + address private vaultHub_addr = makeAddr("vaultHub"); + address private lidoLocator_addr = makeAddr("lidoLocator"); + address private lido_addr = makeAddr("lido"); + address private consensusContract_addr = makeAddr("consensusContract"); + + function deployMockContracts() internal { + //Deploy LidoMock + deployCodeTo( + "CommonMocks.sol:LidoMock", + abi.encode( + Constants.TOTAL_SHARES_MAINNET, + Constants.TOTAL_POOLED_ETHER_MAINNET, + Constants.EXTERNAL_SHARES_MAINNET + ), + lido_addr + ); + + //Deploy LazyOracleMock + deployCodeTo( + "CommonMocks.sol:LazyOracleMock", + abi.encode( + lidoLocator_addr, + consensusContract_addr, + Constants.QUARANTINE_PERIOD, + Constants.MAX_REWARD_RATIO_BP + ), + lazyOracle_addr + ); + + //Deploy ConsensusContractMock + deployCodeTo("CommonMocks.sol:ConsensusContractMock", abi.encode(1, 0), consensusContract_addr); + + //Deploy LidoLocatorMock + deployCodeTo( + "CommonMocks.sol:LidoLocatorMock", + abi.encode( + lido_addr, + pdg_addr, + accounting_addr, + treasury_addr, + operatorGrid_addr, + lazyOracle_addr, + vaultHub_addr, + consensusContract_addr + ), + lidoLocator_addr + ); + } + + function deployOperatorGrid() internal { + TierParams memory defaultTierParams = TierParams({ + shareLimit: Constants.SHARE_LIMIT, + reserveRatioBP: Constants.RESERVE_RATIO_BP, + forcedRebalanceThresholdBP: Constants.FORCED_REBALANCE_THRESHOLD_BP, + infraFeeBP: Constants.INFRA_FEE_BP, + liquidityFeeBP: Constants.LIQUIDITY_FEE_BP, + reservationFeeBP: Constants.RESERVATION_FEE_BP + }); + + //Deploy OperatorGridMock + OperatorGridMock operatorGrid = new OperatorGridMock(ILidoLocator(address(lidoLocator_addr))); + + vm.prank(rootAccount); + deployCodeTo( + "ERC1967Proxy", + abi.encode( + operatorGrid, + abi.encodeWithSelector(OperatorGridMock.initialize.selector, rootAccount, defaultTierParams) + ), + operatorGrid_addr + ); + + //register 2 Groups + operatorGridProxy = OperatorGridMock(payable(operatorGrid_addr)); + + //grantRole REGISTRY_ROLE + vm.startPrank(rootAccount); + bytes32 operatorGridRegistryRole = operatorGridProxy.REGISTRY_ROLE(); + operatorGridProxy.grantRole( + operatorGridRegistryRole, + rootAccount + ); + + operatorGridProxy.registerGroup( + nodeOpAccount[0], + groupShareLimit[0] + ); + operatorGridProxy.registerGroup( + nodeOpAccount[1], + groupShareLimit[1] + ); + + + TierParams[] memory tiersParamsGroup1 = new TierParams[](2); + TierParams[] memory tiersParamsGroup2 = new TierParams[](2); + + tiersParamsGroup1[0] = TierParams({ + shareLimit: Constants.SHARE_LIMIT, + reserveRatioBP: Constants.RESERVE_RATIO_BP, + forcedRebalanceThresholdBP: Constants.FORCED_REBALANCE_THRESHOLD_BP, + infraFeeBP: Constants.INFRA_FEE_BP, + liquidityFeeBP: Constants.LIQUIDITY_FEE_BP, + reservationFeeBP: Constants.RESERVATION_FEE_BP + }); + + tiersParamsGroup1[1] = TierParams({ + shareLimit: Constants.SHARE_LIMIT, + reserveRatioBP: Constants.RESERVE_RATIO_BP, + forcedRebalanceThresholdBP: Constants.FORCED_REBALANCE_THRESHOLD_BP, + infraFeeBP: Constants.INFRA_FEE_BP, + liquidityFeeBP: Constants.LIQUIDITY_FEE_BP, + reservationFeeBP: Constants.RESERVATION_FEE_BP + }); + + tiersParamsGroup2[0] = TierParams({ + shareLimit: Constants.SHARE_LIMIT, + reserveRatioBP: Constants.RESERVE_RATIO_BP, + forcedRebalanceThresholdBP: Constants.FORCED_REBALANCE_THRESHOLD_BP, + infraFeeBP: Constants.INFRA_FEE_BP, + liquidityFeeBP: Constants.LIQUIDITY_FEE_BP, + reservationFeeBP: Constants.RESERVATION_FEE_BP + }); + + tiersParamsGroup2[1] = TierParams({ + shareLimit: Constants.SHARE_LIMIT, + reserveRatioBP: Constants.RESERVE_RATIO_BP, + forcedRebalanceThresholdBP: Constants.FORCED_REBALANCE_THRESHOLD_BP, + infraFeeBP: Constants.INFRA_FEE_BP, + liquidityFeeBP: Constants.LIQUIDITY_FEE_BP, + reservationFeeBP: Constants.RESERVATION_FEE_BP + }); + + //register Tiers1,2 from Group1 and Tiers3,4 from Group2 + operatorGridProxy.registerTiers( + nodeOpAccount[0], + tiersParamsGroup1 + ); + + operatorGridProxy.registerTiers( + nodeOpAccount[1], + tiersParamsGroup2 + ); + vm.stopPrank(); + } + + function deployVaultHub() internal { + VaultHub vaultHub = new VaultHub( + ILidoLocator(address(lidoLocator_addr)), + ILido(address(lido_addr)), + IHashConsensus(address(consensusContract_addr)), + Constants.RELATIVE_SHARE_LIMIT + ); + + vm.prank(rootAccount); + deployCodeTo( + "ERC1967Proxy", + abi.encode(vaultHub, abi.encodeWithSelector(VaultHub.initialize.selector, rootAccount)), + vaultHub_addr + ); + + vaultHubProxy = VaultHub(payable(vaultHub_addr)); + + bytes32 vaultMasterRole = vaultHubProxy.VAULT_MASTER_ROLE(); + vm.prank(rootAccount); + vaultHubProxy.grantRole(vaultMasterRole, rootAccount); + + bytes32 vaultCodehashSetRole = vaultHubProxy.VAULT_CODEHASH_SET_ROLE(); + vm.prank(rootAccount); + vaultHubProxy.grantRole(vaultCodehashSetRole, rootAccount); + } + + function deployStakingVaults() internal { + for (uint256 i=0; i QUARANTINE tirggered, and lower than the expired one -> expired quarantine considered as accounted + ]; + } + + modifier actionIndexUpdate(VaultAction action) { + if (actionPath[actionIndex] == action) { + actionIndex++; + } else { + revert("not the good sequence"); + } + _; + } + + + ////////// GETTERS FOR INVARIANTS ////////// + function getGroupShareLimit(uint256 groupId) public view returns (uint256) { + return groupShareLimit[groupId]; + } + + function getTierShareLimit(uint256 tierId) public view returns (uint256) { + return tierShareLimit[tierId]; + } + + + ////////// VAULTHUB INTERACTIONS ////////// + function connectVault(uint256 id) public { + id = bound(id, 0, userAccount.length - 1); + + console2.log("connectVault id =", id); + //check if the vault is already connected + VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVaults[id])); + + //do nothing if already connected + if (vc.vaultIndex != 0) return; + + if (address(stakingVaults[id]).balance < Constants.CONNECT_DEPOSIT) { + deal(address(userAccount[id]), Constants.CONNECT_DEPOSIT); + vm.prank(userAccount[id]); + stakingVaults[id].fund{value: Constants.CONNECT_DEPOSIT}(); + } + vm.prank(userAccount[id]); + stakingVaults[id].transferOwnership(address(vaultHub)); + vm.prank(userAccount[id]); + vaultHub.connectVault(address(stakingVaults[id])); + } + + function voluntaryDisconnect(uint256 id) public { + id = bound(id, 0, userAccount.length - 1); + console2.log("voluntaryDisconnect id =", id); + VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVaults[id])); + + //do nothing if disconnected or already disconnecting + if (vc.vaultIndex == 0 || vc.pendingDisconnect == true) return; + + //decrease liabilities + uint256 shares = vaultHub.liabilityShares(address(stakingVaults[id])); + if (shares != 0) { + vm.prank(userAccount[id]); + vaultHub.burnShares(address(stakingVaults[id]), shares); + } + vm.prank(userAccount[id]); + vaultHub.voluntaryDisconnect(address(stakingVaults[id])); + } + + function fund(uint256 id, uint256 amount) public { + id = bound(id, 0, userAccount.length - 1); + console2.log("fund id =", id); + amount = bound(amount, 1, 1 ether); + deal(address(userAccount[id]), address(userAccount[id]).balance + amount); + vm.prank(userAccount[id]); + vaultHub.fund{value: amount}(address(stakingVaults[id])); + } + + function VHwithdraw(uint256 id, uint256 amount) public { + id = bound(id, 0, userAccount.length - 1); + amount = bound(amount, 0, vaultHub.withdrawableValue(address(stakingVaults[id]))); + if (vaultHub.vaultConnection(address(stakingVaults[id])).vaultIndex == 0) { + return; + } + if (amount == 0) { + return; + } + + vm.prank(userAccount[id]); + vaultHub.withdraw(address(stakingVaults[id]), userAccount[id], amount); + } + + function forceRebalance(uint256 id) public { + id = bound(id, 0, userAccount.length - 1); + + console2.log("forceRebalance id =", id); + //Avoid revert when vault is healthy + if (vaultHub.isVaultHealthy(address(stakingVaults[id]))) { + + return; //no need to rebalance + } + vm.prank(userAccount[id]); + try vaultHub.forceRebalance(address(stakingVaults[id])) { + } catch { + forceRebalanceReverted = true; + } + } + + function forceValidatorExit(uint256 id) public { + id = bound(id, 0, userAccount.length - 1); + + console2.log("forceValidatorExit id =", id); + uint256 redemptions = vaultHub.vaultObligations(address(stakingVaults[id])).redemptions; + //Avoid revert when vault is healthy or has no redemption over the threshold + if (vaultHub.isVaultHealthy(address(stakingVaults[id])) && redemptions < Math256.max(Constants.UNSETTLED_THRESHOLD, address(stakingVaults[id]).balance)) { + return; //no need to force exit + } + bytes memory pubkeys = new bytes(0); + vm.prank(rootAccount); //privileged account can force exit + try vaultHub.forceValidatorExit(address(stakingVaults[id]), pubkeys, userAccount[id]) { + // If the call succeeds, we do nothing + } catch { + forceValidatorExitReverted = true; + } + } + + function mintShares(uint256 id, uint256 shares) public { + id = bound(id, 0, userAccount.length - 1); + + console2.log("mintShares id =", id); + shares = bound(shares, MIN_SHARES, MAX_SHARES); + vm.prank(userAccount[id]); + vaultHub.mintShares(address(stakingVaults[id]), userAccount[id], shares); + } + + function burnShares(uint256 id, uint256 shares) public { + id = bound(id, 0, userAccount.length - 1); + + shares = bound(shares, MIN_SHARES, MAX_SHARES); + uint256 currShares = vaultHub.liabilityShares(address(stakingVaults[id])); + uint256 sharesToBurn = Math256.min(currShares, shares); + if (sharesToBurn == 0) { + return; // nothing to burn + } + vm.prank(userAccount[id]); + vaultHub.burnShares(address(stakingVaults[id]), sharesToBurn); + } + + function changeTier(uint256 id, uint256 _requestedTierId, uint256 _requestedShareLimit) public { + id = bound(id, 0, userAccount.length - 1); + + //check if the vault is already connected + if (vaultHub.vaultConnection(address(stakingVaults[id])).vaultIndex == 0) { + return; + } + + //get node operator of the staking vault + address nodeOperator = stakingVaults[id].nodeOperator(); + + //get all the tiers that are owned by the node operator + OperatorGridMock.Group memory nodeOperatorGroup = operatorGrid.group(nodeOperator); + + //randomly changeTier to a tier owner by this operator + _requestedTierId = bound(_requestedTierId, 1, nodeOperatorGroup.tierIds.length - 1); //we cannot change to default tier (0) + + (,uint256 vaultTierId,,,,,,) = operatorGrid.vaultInfo(address(stakingVaults[id])); + if (_requestedTierId == vaultTierId) + return; //requested Tier must be different + + uint256 requestedTierId = nodeOperatorGroup.tierIds[_requestedTierId]; + + uint256 requestedTierShareLimit = operatorGrid.tier(requestedTierId).shareLimit; + //_requestedShareLimit = bound(_requestedShareLimit, 1, requestedTierShareLimit); //avoid revert with too big share limite + + /////// AVOIDS INVARIANT VIOLATION /////////// + _requestedShareLimit = bound(_requestedShareLimit, vaultHub.liabilityShares(address(stakingVaults[id])), requestedTierShareLimit); + /////// AVOIDS INVARIANT VIOLATION /////////// + + vm.prank(userAccount[id]); + operatorGrid.changeTier(address(stakingVaults[id]), requestedTierId, _requestedShareLimit); + } + + + function sv_otcDeposit(uint256 id, uint256 amount) public { + id = bound(id, 0, userAccount.length-1); + amount = bound(amount, 1 ether, 10 ether); + sv_otcDeposited[id] += amount; + deal(address(stakingVaults[id]), address(stakingVaults[id]).balance + amount); + } + + function vh_otcDeposit(uint256 amount) public { + //console2.log("vh_otcDeposit"); + amount = bound(amount, 1 ether, 10 ether); + vh_otcDeposited += amount; + deal(address(address(vaultHub)), address(vaultHub).balance + amount); + } + + // ////////// LazyOracle INTERACTIONS ////////// + + function updateVaultData(uint256 id, uint256 daysShift) public { + id = bound(id, 0, userAccount.length - 1); + console2.log("updateVaultData id =", id); + + //check that stakingVault is connected + if (vaultHub.vaultConnection(address(stakingVaults[id])).vaultIndex == 0) { + return; + } + + daysShift = bound(daysShift, 0, 1); + daysShift *= 3; //0 or 3 days for quarantine period expiration + console2.log("DaysShift = %d", daysShift); + + if (daysShift > 0) { + vm.warp(block.timestamp + daysShift * 1 days); + lazyOracle.setVaultDataTimestamp(uint64(block.timestamp)); + VaultHub.VaultObligations memory obligations = vaultHub.vaultObligations(address(stakingVaults[id])); + + lastReport = VaultReport({ + totalValue: vaultHub.totalValue(address(stakingVaults[id])) + sv_otcDeposited[id] + cl_balance, + //totalValue: random_tv, + cumulativeLidoFees: obligations.settledLidoFees + obligations.unsettledLidoFees + 1, + liabilityShares: vaultHub.liabilityShares(address(stakingVaults[id])), + reportTimestamp: uint64(block.timestamp) + }); + + //reset otc deposit value + sv_otcDeposited[id] = 0; + } + + + //simulate next ref slot + (uint256 refSlot, ) = consensusContract.getCurrentFrame(); + if (daysShift > 0) { + refSlot += daysShift; + consensusContract.setCurrentFrame(refSlot); + } + + //update the vault data + lazyOracle.updateVaultData( + address(stakingVaults[id]), + lastReport.totalValue, + lastReport.cumulativeLidoFees, + lastReport.liabilityShares, + uint64(block.timestamp) + ); + + //Handle if disconnect was successfull + if (stakingVaults[id].pendingOwner() == userAccount[id]) { + vm.prank(userAccount[id]); + stakingVaults[id].acceptOwnership(); + } + } + + // ////////// STAKING VAULT INTERACTIONS ////////// + + function SVwithdraw(uint256 id, uint256 amount) public { + id = bound(id, 0, userAccount.length - 1); + console2.log("SVwithdraw id =", id); + + if (stakingVaults[id].owner() != userAccount[id]) { + return; //we are managed by the VaultHub + } + + amount = bound(amount, 0, address(stakingVaults[id]).balance); + if (amount == 0) { + return; // nothing to withdraw + } + vm.prank(userAccount[id]); + stakingVaults[id].withdraw(userAccount[id], amount); + } +} From 643035cac8fcdda053c476c7d740e8d29543b3ba Mon Sep 17 00:00:00 2001 From: fsle <148141389+fsle@users.noreply.github.com> Date: Thu, 21 Aug 2025 11:41:39 +0200 Subject: [PATCH 07/16] invariant formatting --- .../StakingVaultsFuzzing.t.sol | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol index fbdb26da88..452561d5f6 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol @@ -205,31 +205,35 @@ contract StakingVaultsTest is Test { //With current deployed environement (no slashing, no stETH rebase) //the staking Vault should never go below the rebalance threshold //Meaning having less locked collateral than the threshold ratio limit (in regards to the liabilityShares converted in ETH) - //This is computd by rebalanceShortfall function - function invariant_liabilityShares_not_above_collateral() external { + //This is computed by rebalanceShortfall function + + // Invariant 1: Staking vault should never go below the rebalance threshold (collateral always covers liability). + function invariant1_liabilityShares_not_above_collateral() external { uint256 rebalanceShares = vaultHubProxy.rebalanceShortfall(address(stakingVaultProxy)); assertEq(rebalanceShares, 0, "Staking Vault should never go below the rebalance threshold"); } - //This invariant checks that the dynamic (accounting for deltas) totalValue of the vault is not underflowed - function invariant_dynamic_totalValue_should_not_underflow() external { + + // Invariant 2: Dynamic total value (including deltas) should never underflow (must be >= 0). + function invariant2_dynamic_totalValue_should_not_underflow() external { VaultHub.VaultRecord memory record = vaultHubProxy.vaultRecord(address(stakingVaultProxy)); assertGe( int256(uint256(record.report.totalValue)) + int256(record.inOutDelta.value) - int256(record.report.inOutDelta), 0, - "Total value should not underflow" + "Dynamic total value should not underflow" ); } - //forceRebalance and forceValidatorExit should notrevert when the vault is unhealthy - function invariant_forceRebalance_should_not_revert_when_unhealthy() external { + // Invariant 3: forceRebalance should not revert when the vault is unhealthy. + function invariant3_forceRebalance_should_not_revert_when_unhealthy() external { bool forceRebalanceReverted = svHandler.didForceRebalanceReverted(); assertFalse(forceRebalanceReverted, "forceRebalance should not revert when unhealthy"); } - function invariant_forceValidatorExit_should_not_revert_when_unhealthy_and_vault_balance_too_low() external { + // Invariant 4: forceValidatorExit should not revert when unhealthy and vault balance is too low. + function invariant4_forceValidatorExit_should_not_revert_when_unhealthy_and_vault_balance_too_low() external { bool forceValidatorExitReverted = svHandler.didForceValidatorExitReverted(); assertFalse( forceValidatorExitReverted, @@ -237,9 +241,8 @@ contract StakingVaultsTest is Test { ); } - function invariant_applied_tv_should_not_be_greater_than_reported_tv() external { - //This invariant checks that the applied total value is not greater than the reported total value - + // Invariant 5: Applied total value should not be greater than reported total value. + function invariant5_applied_tv_should_not_be_greater_than_reported_tv() external { uint256 appliedTotalValue = svHandler.getAppliedTotalValue(); uint256 reportedTotalValue = svHandler.getReportedTotalValue(); @@ -250,7 +253,8 @@ contract StakingVaultsTest is Test { ); } - function invariant_liabilityshares_should_never_be_greater_than_connection_sharelimit() external { + // Invariant 6: Liability shares should never be greater than connection share limit. + function invariant6_liabilityshares_should_never_be_greater_than_connection_sharelimit() external { //Get the share limit from the vault uint256 liabilityShares = vaultHubProxy.liabilityShares(address(stakingVaultProxy)); @@ -274,9 +278,8 @@ contract StakingVaultsTest is Test { _; } - //Locked amount cannot be less than max (slashing reserve, 1 ETH, liability * reserverAtio) - //Also safety buffer should be enforced (based on liability) (threshold should not be broken) - function invariant_locked_cannot_be_less_than_slashing_connectdep_reserve() + // Invariant 7: Locked amount must be >= max(connect deposit, slashing reserve, reserve ratio). + function invariant7_locked_cannot_be_less_than_slashing_connectdep_reserve() external vaultMustBeConnected vaultNotPendingDisconnect @@ -319,7 +322,8 @@ contract StakingVaultsTest is Test { // assertGe(totalValue, lockedAmount , "Total value should be greater than or equal to locked amount"); // } - function invariant_withdrawableValue_should_be_less_than_or_equal_to_totalValue_minus_locked_and_obligations() + // Invariant 8: Withdrawable value must be <= total value minus locked amount and unsettled obligations. + function invariant8_withdrawableValue_should_be_less_than_or_equal_to_totalValue_minus_locked_and_obligations() external { //Get the withdrawable value of the vault @@ -339,7 +343,6 @@ contract StakingVaultsTest is Test { ? totalValue - unsettled_plus_locked : 0; - //Check that withdrawable value is less than or equal to total value minus locked amount and unsettled obligations assertLe( withdrawableValue, tv_minus_locked_and_obligations, @@ -363,9 +366,10 @@ contract StakingVaultsTest is Test { //8. SVwithdraw //9. connectVault //10. updateVaultData -> reuses previous report; quarantine is expired; TV is kept as is (special branch if the new quarantine delta is lower than the expired one). - // function invariant_check_totalValue() external { - // assertLe(svHandler.getVaultTotalValue(), svHandler.getEffectiveVaultTotalValue()); - // } + // Invariant 9: Computed totalValue must be <= effective (real) total value. + function invariant9_computed_totalValue_must_be_less_than_or_equal_to_effective_total_value() external { + assertLe(svHandler.getVaultTotalValue(), svHandler.getEffectiveVaultTotalValue()); + } /* //for testing purposes only (guiding the fuzzing) From 8f769b6bc92747f4b61c2713ccdabf9831234a5c Mon Sep 17 00:00:00 2001 From: fsle <148141389+fsle@users.noreply.github.com> Date: Thu, 21 Aug 2025 13:24:13 +0200 Subject: [PATCH 08/16] connection check fix for connectVault handler --- test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol index b9e0a6d3c3..2f302a0b7a 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol @@ -129,8 +129,8 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions } ////////// VAULTHUB INTERACTIONS ////////// function connectVault() public { - //check if the vault is connected - if (vaultHub.vaultConnection(address(stakingVault)).vaultIndex == 0) { + //check if the vault is already connected + if (vaultHub.vaultConnection(address(stakingVault)).vaultIndex != 0) { return; } From 0502d3dd65d9098e1cb2da09f0e5513023719753 Mon Sep 17 00:00:00 2001 From: fsle <148141389+fsle@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:26:30 +0200 Subject: [PATCH 09/16] refactoring --- .../StakingVaultsHandler.t.sol | 124 ++++++++---------- 1 file changed, 55 insertions(+), 69 deletions(-) diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol index 2f302a0b7a..4c67ad2a37 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol @@ -19,20 +19,17 @@ import {LazyOracleMock} from "./mocks/LazyOracleMock.sol"; import {Constants} from "./StakingVaultConstants.sol"; import "forge-std/console2.sol"; -/** -TODO: - - function triggerValidatorWithdrawals() - - PDG funcs - - proveUnknownValidatorToPDG - - compensateDisprovenPredepositFromPDG -**/ +/// @title StakingVaultsHandler +/// @notice Handler contract for invariant fuzzing of a single staking vault in the Lido protocol. +/// @dev Used by fuzzing contracts to simulate user and protocol actions, track state, and expose relevant variables for invariant checks. +/// The handler enables deep testing of vault logic, including deposits, withdrawals, connection/disconnection, ownership transfers, and time manipulation. +/// It is extensible and designed to help ensure critical invariants always hold, even under adversarial or randomized conditions. contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions { // Protocol contracts ILido public lidoContract; LidoLocatorMock public lidoLocator; VaultHub public vaultHub; - address public dashboard; StakingVault public stakingVault; LazyOracleMock public lazyOracle; ConsensusContractMock public consensusContract; @@ -49,7 +46,7 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions address public userAccount; address public rootAccount; - uint256 public cl_balance = 0; //aka deposited on beacon chain + uint256 public cl_balance = 0; // Amount deposited on beacon chain uint256 constant MIN_SHARES = 1; uint256 constant MAX_SHARES = 100; @@ -63,6 +60,7 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions uint256 public appliedTotalValue = 0; uint256 public reportedTotalValue = 0; + /// @notice Sequence of actions for guided fuzzing enum VaultAction { CONNECT, VOLUNTARY_DISCONNECT, @@ -86,66 +84,66 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions rootAccount = _rootAccount; userAccount = _userAccount; actionPath = [ - VaultAction.CONNECT, //connect - VaultAction.SV_OTC_DEPOSIT, //otc funds - VaultAction.UPDATE_VAULT_DATA, //trigger quarantine - VaultAction.VOLUNTARY_DISCONNECT, //pendingDisconnect - VaultAction.UPDATE_VAULT_DATA, //disconnected - //quarantine expires (3days) - VaultAction.CONNECT, //reconnect with same TV + wait for fresh report - VaultAction.VOLUNTARY_DISCONNECT, //pendingDisconnect - VaultAction.UPDATE_VAULT_DATA, //disconnected (2nd time) (Report2) - VaultAction.SV_WITHDRAW, //withdraw from vault - VaultAction.CONNECT, //reconnect with CONNECT_DEPOSIT - VaultAction.UPDATE_VAULT_DATA // apply report2 -> QUARANTINE tirggered, and lower than the expired one -> expired quarantine considered as accounted + VaultAction.CONNECT, // connect + VaultAction.SV_OTC_DEPOSIT, // OTC funds + VaultAction.UPDATE_VAULT_DATA, // trigger quarantine + VaultAction.VOLUNTARY_DISCONNECT, // pendingDisconnect + VaultAction.UPDATE_VAULT_DATA, // disconnected + VaultAction.CONNECT, // reconnect with same TV + wait for fresh report + VaultAction.VOLUNTARY_DISCONNECT, // pendingDisconnect + VaultAction.UPDATE_VAULT_DATA, // disconnected (2nd time) + VaultAction.SV_WITHDRAW, // withdraw from vault + VaultAction.CONNECT, // reconnect with CONNECT_DEPOSIT + VaultAction.UPDATE_VAULT_DATA // apply report2 -> quarantine triggered, and lower than the expired one -> expired quarantine considered as accounted ]; } + /// @notice Modifier to update action index for guided fuzzing modifier actionIndexUpdate(VaultAction action) { if (actionPath[actionIndex] == action) { actionIndex++; } else { - return; //not the good squence + return; // not the correct sequence } _; } - ////////// GETTERS FOR SV FUZZING INVARIANTS ////////// + // --- Getters for invariant checks --- - function getAppliedTotalValue() public returns (uint256) { + function getAppliedTotalValue() public view returns (uint256) { return appliedTotalValue; } - function getReportedTotalValue() public returns (uint256) { + function getReportedTotalValue() public view returns (uint256) { return reportedTotalValue; } - function didForceRebalanceReverted() public returns (bool) { + function didForceRebalanceReverted() public view returns (bool) { return forceRebalanceReverted; } - function didForceValidatorExitReverted() public returns (bool) { + function didForceValidatorExitReverted() public view returns (bool) { return forceValidatorExitReverted; } - ////////// VAULTHUB INTERACTIONS ////////// + // --- VaultHub interactions --- + /// @notice Connects the vault to the VaultHub, funding if needed function connectVault() public { //check if the vault is already connected if (vaultHub.vaultConnection(address(stakingVault)).vaultIndex != 0) { return; } - if (address(stakingVault).balance < Constants.CONNECT_DEPOSIT) { deal(address(userAccount), Constants.CONNECT_DEPOSIT); vm.prank(userAccount); stakingVault.fund{value: Constants.CONNECT_DEPOSIT}(); } - vm.prank(userAccount); stakingVault.transferOwnership(address(vaultHub)); vm.prank(userAccount); vaultHub.connectVault(address(stakingVault)); } + /// @notice Initiates voluntary disconnect for the vault function voluntaryDisconnect() public { VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVault)); @@ -162,6 +160,7 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions vaultHub.voluntaryDisconnect(address(stakingVault)); } + /// @notice Funds the vault via VaultHub function fund(uint256 amount) public { amount = bound(amount, 1, 1 ether); deal(address(userAccount), amount); @@ -169,6 +168,7 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions vaultHub.fund{value: amount}(address(stakingVault)); } + /// @notice Withdraws from the vault via VaultHub function VHwithdraw(uint256 amount) public { amount = bound(amount, 1, vaultHub.withdrawableValue(address(stakingVault))); @@ -176,34 +176,29 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions if (vaultHub.vaultConnection(address(stakingVault)).vaultIndex == 0) { return; } - - if (amount == 0) { - return; - } vm.prank(userAccount); vaultHub.withdraw(address(stakingVault), userAccount, amount); } + /// @notice Forces a rebalance if the vault is unhealthy function forceRebalance() public { - //Avoid revert when vault is healthy if (vaultHub.isVaultHealthy(address(stakingVault))) { - return; //no need to rebalance + return; } - vm.prank(userAccount); try vaultHub.forceRebalance(address(stakingVault)) {} catch { forceRebalanceReverted = true; } } + /// @notice Forces validator exit if vault is unhealthy or obligations exceed threshold function forceValidatorExit() public { uint256 redemptions = vaultHub.vaultObligations(address(stakingVault)).redemptions; - //Avoid revert when vault is healthy or has no redemption over the threshold if ( vaultHub.isVaultHealthy(address(stakingVault)) && redemptions < Math256.max(Constants.UNSETTLED_THRESHOLD, address(stakingVault).balance) ) { - return; //no need to force exit + return; } bytes memory pubkeys = new bytes(0); vm.prank(rootAccount); //privileged account can force exit @@ -214,70 +209,70 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions } } + /// @notice Mints shares for the vault function mintShares(uint256 shares) public { shares = bound(shares, MIN_SHARES, MAX_SHARES); vm.prank(userAccount); vaultHub.mintShares(address(stakingVault), userAccount, shares); } + /// @notice Burns shares from the vault function burnShares(uint256 shares) public { shares = bound(shares, MIN_SHARES, MAX_SHARES); uint256 currShares = vaultHub.liabilityShares(address(stakingVault)); uint256 sharesToBurn = Math256.min(currShares, shares); if (sharesToBurn == 0) { - return; // nothing to burn + return; } vm.prank(userAccount); vaultHub.burnShares(address(stakingVault), sharesToBurn); } + /// @notice Transfers and burns shares from the vault function transferAndBurnShares(uint256 shares) public { shares = bound(shares, MIN_SHARES, MAX_SHARES); uint256 currShares = vaultHub.liabilityShares(address(stakingVault)); uint256 sharesToBurn = Math256.min(currShares, shares); if (sharesToBurn == 0) { - return; // nothing to burn + return; } vm.prank(userAccount); vaultHub.transferAndBurnShares(address(stakingVault), shares); } - function pauseBeaconChainDeposits() public { - vaultHub.pauseBeaconChainDeposits(address(stakingVault)); - } - function resumeBeaconChainDeposits() public { - vaultHub.resumeBeaconChainDeposits(address(stakingVault)); - } - function getEffectiveVaultTotalValue() public returns (uint256) { + /// @notice Returns the effective total value of the vault (EL + CL balance) + function getEffectiveVaultTotalValue() public view returns (uint256) { return address(stakingVault).balance + cl_balance; } - function getVaultTotalValue() public returns (uint256) { - //gets reported TV + current ioDelta - reported ioDelta + /// @notice Returns the reported total value of the vault + function getVaultTotalValue() public view returns (uint256) { return vaultHub.totalValue(address(stakingVault)); } + /// @notice Simulates OTC deposit to the staking vault function sv_otcDeposit(uint256 amount) public { amount = bound(amount, 1 ether, 10 ether); sv_otcDeposited += amount; deal(address(stakingVault), address(stakingVault).balance + amount); - console2.log("stakingVault balance =", address(stakingVault).balance); } + /// @notice Simulates OTC deposit to the VaultHub function vh_otcDeposit(uint256 amount) public { amount = bound(amount, 1 ether, 10 ether); vh_otcDeposited += amount; deal(address(vaultHub), address(vaultHub).balance + amount); } - ////////// LazyOracle INTERACTIONS ////////// + // --- LazyOracle interactions --- + /// @notice Updates vault data, simulating time shifts and quarantine logic function updateVaultData(uint256 daysShift) public { daysShift = bound(daysShift, 0, 1); - daysShift *= 3; //0 or 3 days for quarantine period expiration + daysShift *= 3; // 0 or 3 days for quarantine period expiration console2.log("DaysShift = %d", daysShift); //Check if vault is connected before proceeding @@ -292,7 +287,6 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions lastReport = VaultReport({ totalValue: vaultHub.totalValue(address(stakingVault)) + sv_otcDeposited + cl_balance, - //totalValue: random_tv, cumulativeLidoFees: obligations.settledLidoFees + obligations.unsettledLidoFees + 1, liabilityShares: vaultHub.liabilityShares(address(stakingVault)), reportTimestamp: uint64(block.timestamp) @@ -301,18 +295,13 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions //reset otc deposit value sv_otcDeposited = 0; } - - //path to trigger to get quarantine back in TV - //reportTs - q.startTimestamp < $.quarantinePeriod - - //simulate next ref slot + // Simulate next ref slot (uint256 refSlot, ) = consensusContract.getCurrentFrame(); if (daysShift > 0) { refSlot += daysShift; consensusContract.setCurrentFrame(refSlot); } - - //That means that there has no been any new refSLot meanning no new report since vault connection + // If no new report since vault connection, skip if (lastReport.totalValue == 0 && lastReport.cumulativeLidoFees == 0) return; //we update the reported total Value @@ -329,25 +318,22 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions //we update the applied total value (TV should go through sanity checks, quarantine, etc.) appliedTotalValue = vaultHub.vaultRecord(address(stakingVault)).report.totalValue; - - //Handle if disconnect was successfull + // Accept ownership if disconnect was successful if (stakingVault.pendingOwner() == userAccount) { vm.prank(userAccount); stakingVault.acceptOwnership(); } } - ////////// STAKING VAULT INTERACTIONS ////////// + // --- StakingVault interactions --- + /// @notice Withdraws directly from the staking vault (when not managed by VaultHub) function SVwithdraw(uint256 amount) public { if (stakingVault.owner() != userAccount) { - return; //we are managed by the VaultHub + return; } + amount = bound(amount, 1, address(stakingVault).balance); - amount = bound(amount, 0, address(stakingVault).balance); - if (amount == 0) { - return; // nothing to withdraw - } vm.prank(userAccount); stakingVault.withdraw(userAccount, amount); } From 887c36007d89755568130f208feed25056e31419 Mon Sep 17 00:00:00 2001 From: fsle <148141389+fsle@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:53:13 +0200 Subject: [PATCH 10/16] refactoring --- .../MultiStakingVaultHandler.t.sol | 141 +++++++----------- 1 file changed, 53 insertions(+), 88 deletions(-) diff --git a/test/0.8.25/invariant-fuzzing/MultiStakingVaultHandler.t.sol b/test/0.8.25/invariant-fuzzing/MultiStakingVaultHandler.t.sol index 38e7c7f207..67fc335187 100644 --- a/test/0.8.25/invariant-fuzzing/MultiStakingVaultHandler.t.sol +++ b/test/0.8.25/invariant-fuzzing/MultiStakingVaultHandler.t.sol @@ -20,13 +20,16 @@ import {OperatorGridMock} from "./mocks/OperatorGridMock.sol"; import {Constants} from "./StakingVaultConstants.sol"; import "forge-std/console2.sol"; - -contract MultiStakingVaultHandler is CommonBase, StdCheats, StdUtils, StdAssertions{ +/// @title MultiStakingVaultHandler +/// @notice Handler contract for invariant fuzzing of multiple staking vaults, tiers, and groups in the Lido protocol. +/// @dev Used by fuzzing contracts to simulate user and protocol actions, track state, and expose relevant variables for invariant checks across multiple vaults. +/// The handler enables deep testing of vault logic, including deposits, withdrawals, connection/disconnection, tier changes, and time manipulation. +/// It is extensible and designed to help ensure critical invariants always hold, even under adversarial or randomized conditions. +contract MultiStakingVaultHandler is CommonBase, StdCheats, StdUtils, StdAssertions { // Protocol contracts ILido public lidoContract; LidoLocatorMock public lidoLocator; VaultHub public vaultHub; - address public dashboard; StakingVault[] public stakingVaults; LazyOracleMock public lazyOracle; OperatorGridMock public operatorGrid; @@ -47,7 +50,7 @@ contract MultiStakingVaultHandler is CommonBase, StdCheats, StdUtils, StdAsserti address[] public userAccount; address public rootAccount; - uint256 public cl_balance = 0; //aka deposited on beacon chain + uint256 public cl_balance = 0; // Amount deposited on beacon chain uint256 constant MIN_SHARES = 1; uint256 constant MAX_SHARES = 100; @@ -60,6 +63,7 @@ contract MultiStakingVaultHandler is CommonBase, StdCheats, StdUtils, StdAsserti + /// @notice Sequence of actions for guided fuzzing enum VaultAction { CONNECT, VOLUNTARY_DISCONNECT, @@ -84,32 +88,32 @@ contract MultiStakingVaultHandler is CommonBase, StdCheats, StdUtils, StdAsserti rootAccount = _rootAccount; userAccount = _userAccount; actionPath = [ - VaultAction.CONNECT, //connect - VaultAction.SV_OTC_DEPOSIT, //otc funds - VaultAction.UPDATE_VAULT_DATA, //trigger quarantine - VaultAction.VOLUNTARY_DISCONNECT, //pendingDisconnect - VaultAction.UPDATE_VAULT_DATA, //disconnected - //quarantine expires (3days) - VaultAction.CONNECT, //reconnect with same TV + wait for fresh report - VaultAction.VOLUNTARY_DISCONNECT, //pendingDisconnect - VaultAction.UPDATE_VAULT_DATA, //disconnected (2nd time) (Report2) - VaultAction.SV_WITHDRAW, //withdraw from vault - VaultAction.CONNECT, //reconnect with CONNECT_DEPOSIT - VaultAction.UPDATE_VAULT_DATA // apply report2 -> QUARANTINE tirggered, and lower than the expired one -> expired quarantine considered as accounted + VaultAction.CONNECT, // connect + VaultAction.SV_OTC_DEPOSIT, // OTC funds + VaultAction.UPDATE_VAULT_DATA, // trigger quarantine + VaultAction.VOLUNTARY_DISCONNECT, // pendingDisconnect + VaultAction.UPDATE_VAULT_DATA, // disconnected + VaultAction.CONNECT, // reconnect with same TV + wait for fresh report + VaultAction.VOLUNTARY_DISCONNECT, // pendingDisconnect + VaultAction.UPDATE_VAULT_DATA, // disconnected (2nd time) + VaultAction.SV_WITHDRAW, // withdraw from vault + VaultAction.CONNECT, // reconnect with CONNECT_DEPOSIT + VaultAction.UPDATE_VAULT_DATA // apply report2 -> quarantine triggered, and lower than the expired one -> expired quarantine considered as accounted ]; } + /// @notice Modifier to update action index for guided fuzzing modifier actionIndexUpdate(VaultAction action) { if (actionPath[actionIndex] == action) { actionIndex++; } else { - revert("not the good sequence"); + revert("not the correct sequence"); } _; } - ////////// GETTERS FOR INVARIANTS ////////// + // --- Getters for invariant checks --- function getGroupShareLimit(uint256 groupId) public view returns (uint256) { return groupShareLimit[groupId]; } @@ -119,17 +123,12 @@ contract MultiStakingVaultHandler is CommonBase, StdCheats, StdUtils, StdAsserti } - ////////// VAULTHUB INTERACTIONS ////////// + // --- VaultHub interactions --- + /// @notice Connects a vault to the VaultHub, funding if needed function connectVault(uint256 id) public { id = bound(id, 0, userAccount.length - 1); - - console2.log("connectVault id =", id); - //check if the vault is already connected VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVaults[id])); - - //do nothing if already connected if (vc.vaultIndex != 0) return; - if (address(stakingVaults[id]).balance < Constants.CONNECT_DEPOSIT) { deal(address(userAccount[id]), Constants.CONNECT_DEPOSIT); vm.prank(userAccount[id]); @@ -141,15 +140,11 @@ contract MultiStakingVaultHandler is CommonBase, StdCheats, StdUtils, StdAsserti vaultHub.connectVault(address(stakingVaults[id])); } + /// @notice Initiates voluntary disconnect for a vault function voluntaryDisconnect(uint256 id) public { id = bound(id, 0, userAccount.length - 1); - console2.log("voluntaryDisconnect id =", id); VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVaults[id])); - - //do nothing if disconnected or already disconnecting if (vc.vaultIndex == 0 || vc.pendingDisconnect == true) return; - - //decrease liabilities uint256 shares = vaultHub.liabilityShares(address(stakingVaults[id])); if (shares != 0) { vm.prank(userAccount[id]); @@ -157,17 +152,18 @@ contract MultiStakingVaultHandler is CommonBase, StdCheats, StdUtils, StdAsserti } vm.prank(userAccount[id]); vaultHub.voluntaryDisconnect(address(stakingVaults[id])); - } + } + /// @notice Funds a vault via VaultHub function fund(uint256 id, uint256 amount) public { id = bound(id, 0, userAccount.length - 1); - console2.log("fund id =", id); amount = bound(amount, 1, 1 ether); deal(address(userAccount[id]), address(userAccount[id]).balance + amount); vm.prank(userAccount[id]); vaultHub.fund{value: amount}(address(stakingVaults[id])); } + /// @notice Withdraws from a vault via VaultHub function VHwithdraw(uint256 id, uint256 amount) public { id = bound(id, 0, userAccount.length - 1); amount = bound(amount, 0, vaultHub.withdrawableValue(address(stakingVaults[id]))); @@ -177,19 +173,15 @@ contract MultiStakingVaultHandler is CommonBase, StdCheats, StdUtils, StdAsserti if (amount == 0) { return; } - vm.prank(userAccount[id]); vaultHub.withdraw(address(stakingVaults[id]), userAccount[id], amount); } + /// @notice Forces a rebalance if the vault is unhealthy function forceRebalance(uint256 id) public { id = bound(id, 0, userAccount.length - 1); - - console2.log("forceRebalance id =", id); - //Avoid revert when vault is healthy if (vaultHub.isVaultHealthy(address(stakingVaults[id]))) { - - return; //no need to rebalance + return; } vm.prank(userAccount[id]); try vaultHub.forceRebalance(address(stakingVaults[id])) { @@ -198,81 +190,67 @@ contract MultiStakingVaultHandler is CommonBase, StdCheats, StdUtils, StdAsserti } } + /// @notice Forces validator exit if vault is unhealthy or obligations exceed threshold function forceValidatorExit(uint256 id) public { id = bound(id, 0, userAccount.length - 1); - - console2.log("forceValidatorExit id =", id); uint256 redemptions = vaultHub.vaultObligations(address(stakingVaults[id])).redemptions; - //Avoid revert when vault is healthy or has no redemption over the threshold if (vaultHub.isVaultHealthy(address(stakingVaults[id])) && redemptions < Math256.max(Constants.UNSETTLED_THRESHOLD, address(stakingVaults[id]).balance)) { - return; //no need to force exit + return; } bytes memory pubkeys = new bytes(0); - vm.prank(rootAccount); //privileged account can force exit + vm.prank(rootAccount); try vaultHub.forceValidatorExit(address(stakingVaults[id]), pubkeys, userAccount[id]) { - // If the call succeeds, we do nothing } catch { forceValidatorExitReverted = true; } } + /// @notice Mints shares for a vault function mintShares(uint256 id, uint256 shares) public { id = bound(id, 0, userAccount.length - 1); - - console2.log("mintShares id =", id); shares = bound(shares, MIN_SHARES, MAX_SHARES); vm.prank(userAccount[id]); vaultHub.mintShares(address(stakingVaults[id]), userAccount[id], shares); } + /// @notice Burns shares from a vault function burnShares(uint256 id, uint256 shares) public { id = bound(id, 0, userAccount.length - 1); - shares = bound(shares, MIN_SHARES, MAX_SHARES); uint256 currShares = vaultHub.liabilityShares(address(stakingVaults[id])); uint256 sharesToBurn = Math256.min(currShares, shares); if (sharesToBurn == 0) { - return; // nothing to burn + return; } vm.prank(userAccount[id]); vaultHub.burnShares(address(stakingVaults[id]), sharesToBurn); } + /// @notice Changes the tier of a vault, respecting share limits function changeTier(uint256 id, uint256 _requestedTierId, uint256 _requestedShareLimit) public { id = bound(id, 0, userAccount.length - 1); - - //check if the vault is already connected if (vaultHub.vaultConnection(address(stakingVaults[id])).vaultIndex == 0) { return; } - - //get node operator of the staking vault address nodeOperator = stakingVaults[id].nodeOperator(); - - //get all the tiers that are owned by the node operator OperatorGridMock.Group memory nodeOperatorGroup = operatorGrid.group(nodeOperator); - - //randomly changeTier to a tier owner by this operator - _requestedTierId = bound(_requestedTierId, 1, nodeOperatorGroup.tierIds.length - 1); //we cannot change to default tier (0) - + _requestedTierId = bound(_requestedTierId, 1, nodeOperatorGroup.tierIds.length - 1); // cannot change to default tier (0) (,uint256 vaultTierId,,,,,,) = operatorGrid.vaultInfo(address(stakingVaults[id])); if (_requestedTierId == vaultTierId) - return; //requested Tier must be different - + return; uint256 requestedTierId = nodeOperatorGroup.tierIds[_requestedTierId]; - uint256 requestedTierShareLimit = operatorGrid.tier(requestedTierId).shareLimit; - //_requestedShareLimit = bound(_requestedShareLimit, 1, requestedTierShareLimit); //avoid revert with too big share limite /////// AVOIDS INVARIANT VIOLATION /////////// - _requestedShareLimit = bound(_requestedShareLimit, vaultHub.liabilityShares(address(stakingVaults[id])), requestedTierShareLimit); - /////// AVOIDS INVARIANT VIOLATION /////////// + _requestedShareLimit = bound(_requestedShareLimit, vaultHub.liabilityShares(address(stakingVaults[id])), requestedTierShareLimit); //this caught a finding with a minimum set to 1 + vm.prank(userAccount[id]); operatorGrid.changeTier(address(stakingVaults[id]), requestedTierId, _requestedShareLimit); } + /// @notice Simulates OTC deposit to a staking vault function sv_otcDeposit(uint256 id, uint256 amount) public { id = bound(id, 0, userAccount.length-1); amount = bound(amount, 1 ether, 10 ether); @@ -280,28 +258,23 @@ contract MultiStakingVaultHandler is CommonBase, StdCheats, StdUtils, StdAsserti deal(address(stakingVaults[id]), address(stakingVaults[id]).balance + amount); } + /// @notice Simulates OTC deposit to the VaultHub function vh_otcDeposit(uint256 amount) public { - //console2.log("vh_otcDeposit"); amount = bound(amount, 1 ether, 10 ether); vh_otcDeposited += amount; - deal(address(address(vaultHub)), address(vaultHub).balance + amount); + deal(address(vaultHub), address(vaultHub).balance + amount); } - // ////////// LazyOracle INTERACTIONS ////////// + // --- LazyOracle interactions --- + /// @notice Updates vault data, simulating time shifts and quarantine logic function updateVaultData(uint256 id, uint256 daysShift) public { id = bound(id, 0, userAccount.length - 1); - console2.log("updateVaultData id =", id); - - //check that stakingVault is connected if (vaultHub.vaultConnection(address(stakingVaults[id])).vaultIndex == 0) { return; } - daysShift = bound(daysShift, 0, 1); - daysShift *= 3; //0 or 3 days for quarantine period expiration - console2.log("DaysShift = %d", daysShift); - + daysShift *= 3; // 0 or 3 days for quarantine period expiration if (daysShift > 0) { vm.warp(block.timestamp + daysShift * 1 days); lazyOracle.setVaultDataTimestamp(uint64(block.timestamp)); @@ -309,7 +282,6 @@ contract MultiStakingVaultHandler is CommonBase, StdCheats, StdUtils, StdAsserti lastReport = VaultReport({ totalValue: vaultHub.totalValue(address(stakingVaults[id])) + sv_otcDeposited[id] + cl_balance, - //totalValue: random_tv, cumulativeLidoFees: obligations.settledLidoFees + obligations.unsettledLidoFees + 1, liabilityShares: vaultHub.liabilityShares(address(stakingVaults[id])), reportTimestamp: uint64(block.timestamp) @@ -318,9 +290,7 @@ contract MultiStakingVaultHandler is CommonBase, StdCheats, StdUtils, StdAsserti //reset otc deposit value sv_otcDeposited[id] = 0; } - - - //simulate next ref slot + // Simulate next ref slot (uint256 refSlot, ) = consensusContract.getCurrentFrame(); if (daysShift > 0) { refSlot += daysShift; @@ -335,28 +305,23 @@ contract MultiStakingVaultHandler is CommonBase, StdCheats, StdUtils, StdAsserti lastReport.liabilityShares, uint64(block.timestamp) ); - - //Handle if disconnect was successfull + // Accept ownership if disconnect was successful if (stakingVaults[id].pendingOwner() == userAccount[id]) { vm.prank(userAccount[id]); stakingVaults[id].acceptOwnership(); } } - // ////////// STAKING VAULT INTERACTIONS ////////// + // --- StakingVault interactions --- + /// @notice Withdraws directly from a staking vault (when not managed by VaultHub) function SVwithdraw(uint256 id, uint256 amount) public { id = bound(id, 0, userAccount.length - 1); - console2.log("SVwithdraw id =", id); - if (stakingVaults[id].owner() != userAccount[id]) { - return; //we are managed by the VaultHub + return; } + amount = bound(amount, 1, address(stakingVaults[id]).balance); - amount = bound(amount, 0, address(stakingVaults[id]).balance); - if (amount == 0) { - return; // nothing to withdraw - } vm.prank(userAccount[id]); stakingVaults[id].withdraw(userAccount[id], amount); } From e23f7bb6616ff39012495fd3bbf0908454228fae Mon Sep 17 00:00:00 2001 From: fsle <148141389+fsle@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:29:52 +0200 Subject: [PATCH 11/16] adding rebalance function --- .../invariant-fuzzing/StakingVaultsFuzzing.t.sol | 15 ++++++++------- .../invariant-fuzzing/StakingVaultsHandler.t.sol | 13 ++++++++++++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol index 452561d5f6..1c6c22d1a7 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol @@ -181,7 +181,7 @@ contract StakingVaultsTest is Test { svHandler.connectVault(); // Configure fuzzing targets - bytes4[] memory svSelectors = new bytes4[](13); + bytes4[] memory svSelectors = new bytes4[](14); svSelectors[0] = svHandler.fund.selector; svSelectors[1] = svHandler.VHwithdraw.selector; svSelectors[2] = svHandler.forceRebalance.selector; @@ -195,6 +195,7 @@ contract StakingVaultsTest is Test { svSelectors[10] = svHandler.updateVaultData.selector; svSelectors[11] = svHandler.SVwithdraw.selector; svSelectors[12] = svHandler.connectVault.selector; + svSelectors[13] = svHandler.rebalance.selector; targetContract(address(svHandler)); targetSelector(FuzzSelector({addr: address(svHandler), selectors: svSelectors})); @@ -371,10 +372,10 @@ contract StakingVaultsTest is Test { assertLe(svHandler.getVaultTotalValue(), svHandler.getEffectiveVaultTotalValue()); } - /* - //for testing purposes only (guiding the fuzzing) - function invariant_state() external { - assertEq(svHandler.actionIndex() != 11, true, "callpath reached"); - } -*/ + + // For testing purposes only (guiding the fuzzing) + // function invariant_state() external { + // assertEq(svHandler.actionIndex() != 11, true, "callpath reached"); + // } + } diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol index 4c67ad2a37..335d19dbba 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol @@ -49,7 +49,7 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions uint256 public cl_balance = 0; // Amount deposited on beacon chain uint256 constant MIN_SHARES = 1; - uint256 constant MAX_SHARES = 100; + uint256 constant MAX_SHARES = 1000; uint256 public sv_otcDeposited = 0; uint256 public vh_otcDeposited = 0; @@ -240,6 +240,17 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions vaultHub.transferAndBurnShares(address(stakingVault), shares); } + /// @notice Calls rebalance on the staking vault (via VaultHub) + function rebalance(uint256 amount) public { + VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVault)); + if (vc.vaultIndex == 0 || vc.pendingDisconnect == true) return; + + uint256 totalValue = vaultHub.totalValue(address(stakingVault)); + amount = bound(amount, 1, totalValue); + + vm.prank(userAccount); + vaultHub.rebalance(address(stakingVault), amount); + } /// @notice Returns the effective total value of the vault (EL + CL balance) From 6bacf4664f3843f09db79d40a91a10f428c7de9a Mon Sep 17 00:00:00 2001 From: fsle <148141389+fsle@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:31:45 +0200 Subject: [PATCH 12/16] adding new invariant --- .../MultiStakingVaultFuzzing.t.sol | 16 +++++++++++++--- .../invariant-fuzzing/StakingVaultConstants.sol | 3 ++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/test/0.8.25/invariant-fuzzing/MultiStakingVaultFuzzing.t.sol b/test/0.8.25/invariant-fuzzing/MultiStakingVaultFuzzing.t.sol index 4026d549db..1ff23d6d7f 100644 --- a/test/0.8.25/invariant-fuzzing/MultiStakingVaultFuzzing.t.sol +++ b/test/0.8.25/invariant-fuzzing/MultiStakingVaultFuzzing.t.sol @@ -26,14 +26,15 @@ contract MultiStakingVaultsTest is Test { OperatorGridMock operatorGridProxy; - uint256[2] groupShareLimit = [1000 ether, 500 ether]; + //uint256[2] groupShareLimit = [1000 ether, 500 ether]; + uint256[2] groupShareLimit = [1000, 500]; MultiStakingVaultHandler msvHandler; address private rootAccount = makeAddr("rootAccount"); address[2] private nodeOpAccount = [makeAddr("nodeOpAccount1"), makeAddr("nodeOpAccount2")]; address[] private userAccount; - uint256 private constant NB_VAULTS = 4; + uint256 private constant NB_VAULTS = 5; address private treasury_addr = makeAddr("treasury"); address private depositor = makeAddr("depositor"); @@ -342,6 +343,15 @@ contract MultiStakingVaultsTest is Test { } } - + // Invariant 4: Sum of vaults' liabilityShares in the default tier <= default tier shareLimit + function invariant4_default_tier_liability_consistency() external { + address[] memory vaults = get_all_vaults_in_tier(Constants.DEFAULT_TIER); + OperatorGridMock.Tier memory default_tier = operatorGridProxy.tier(Constants.DEFAULT_TIER); + uint256 sumVaultLiabilities = 0; + for (uint256 i = 0; i < vaults.length; i++) { + sumVaultLiabilities += vaultHubProxy.liabilityShares(vaults[i]); + } + assertLe(sumVaultLiabilities, default_tier.shareLimit, "Sum of vaults' liabilityShares in the default tier must be less than or equal to the default tier's shareLimit"); + } } diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol b/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol index 995e7b4dc1..855d15a058 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol @@ -5,7 +5,8 @@ pragma solidity ^0.8.0; library Constants { //OperatorGrid params //retrieved from default settings in deploy scripts - uint256 public constant SHARE_LIMIT = 1000; + uint256 public constant DEFAULT_TIER = 0; + uint256 public constant SHARE_LIMIT = 100; uint256 public constant RESERVE_RATIO_BP = 2000; uint256 public constant FORCED_REBALANCE_THRESHOLD_BP = 1800; uint256 public constant INFRA_FEE_BP = 500; From 6d7a4663b61a18bdb4a09f1864cd541e1580d249 Mon Sep 17 00:00:00 2001 From: fsle <148141389+fsle@users.noreply.github.com> Date: Fri, 22 Aug 2025 08:33:45 +0200 Subject: [PATCH 13/16] new invariant ensuring consistency between vault connection and operatorgrid vaultInfo --- .../MultiStakingVaultFuzzing.t.sol | 64 +++++++++++++------ 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/test/0.8.25/invariant-fuzzing/MultiStakingVaultFuzzing.t.sol b/test/0.8.25/invariant-fuzzing/MultiStakingVaultFuzzing.t.sol index 1ff23d6d7f..84a621dbb6 100644 --- a/test/0.8.25/invariant-fuzzing/MultiStakingVaultFuzzing.t.sol +++ b/test/0.8.25/invariant-fuzzing/MultiStakingVaultFuzzing.t.sol @@ -151,30 +151,30 @@ contract MultiStakingVaultsTest is Test { }); tiersParamsGroup1[1] = TierParams({ - shareLimit: Constants.SHARE_LIMIT, - reserveRatioBP: Constants.RESERVE_RATIO_BP, - forcedRebalanceThresholdBP: Constants.FORCED_REBALANCE_THRESHOLD_BP, - infraFeeBP: Constants.INFRA_FEE_BP, - liquidityFeeBP: Constants.LIQUIDITY_FEE_BP, - reservationFeeBP: Constants.RESERVATION_FEE_BP + shareLimit: Constants.SHARE_LIMIT + 1, + reserveRatioBP: Constants.RESERVE_RATIO_BP + 1, + forcedRebalanceThresholdBP: Constants.FORCED_REBALANCE_THRESHOLD_BP + 1, + infraFeeBP: Constants.INFRA_FEE_BP + 1, + liquidityFeeBP: Constants.LIQUIDITY_FEE_BP + 1, + reservationFeeBP: Constants.RESERVATION_FEE_BP + 1 }); tiersParamsGroup2[0] = TierParams({ - shareLimit: Constants.SHARE_LIMIT, - reserveRatioBP: Constants.RESERVE_RATIO_BP, - forcedRebalanceThresholdBP: Constants.FORCED_REBALANCE_THRESHOLD_BP, - infraFeeBP: Constants.INFRA_FEE_BP, - liquidityFeeBP: Constants.LIQUIDITY_FEE_BP, - reservationFeeBP: Constants.RESERVATION_FEE_BP + shareLimit: Constants.SHARE_LIMIT + 2, + reserveRatioBP: Constants.RESERVE_RATIO_BP + 2, + forcedRebalanceThresholdBP: Constants.FORCED_REBALANCE_THRESHOLD_BP + 2, + infraFeeBP: Constants.INFRA_FEE_BP + 2, + liquidityFeeBP: Constants.LIQUIDITY_FEE_BP + 2, + reservationFeeBP: Constants.RESERVATION_FEE_BP + 2 }); tiersParamsGroup2[1] = TierParams({ - shareLimit: Constants.SHARE_LIMIT, - reserveRatioBP: Constants.RESERVE_RATIO_BP, - forcedRebalanceThresholdBP: Constants.FORCED_REBALANCE_THRESHOLD_BP, - infraFeeBP: Constants.INFRA_FEE_BP, - liquidityFeeBP: Constants.LIQUIDITY_FEE_BP, - reservationFeeBP: Constants.RESERVATION_FEE_BP + shareLimit: Constants.SHARE_LIMIT + 3, + reserveRatioBP: Constants.RESERVE_RATIO_BP + 3, + forcedRebalanceThresholdBP: Constants.FORCED_REBALANCE_THRESHOLD_BP + 3, + infraFeeBP: Constants.INFRA_FEE_BP + 3, + liquidityFeeBP: Constants.LIQUIDITY_FEE_BP + 3, + reservationFeeBP: Constants.RESERVATION_FEE_BP + 3 }); //register Tiers1,2 from Group1 and Tiers3,4 from Group2 @@ -354,4 +354,32 @@ contract MultiStakingVaultsTest is Test { assertLe(sumVaultLiabilities, default_tier.shareLimit, "Sum of vaults' liabilityShares in the default tier must be less than or equal to the default tier's shareLimit"); } + + // Invariant 5: Vault's connection settings must match their current Tier info + function invariant5_vault_connection_info() external { + for (uint256 i = 0; i < stakingVaultProxies.length; i++) { + address vault = address(stakingVaultProxies[i]); + VaultHub.VaultConnection memory vc = vaultHubProxy.vaultConnection(vault); + + if (vc.vaultIndex == 0) return; + + ( + , + , + uint256 shareLimit, + uint256 reserveRatioBP, + uint256 forcedRebalanceThresholdBP, + uint256 infraFeeBP, + uint256 liquidityFeeBP, + uint256 reservationFeeBP + ) = operatorGridProxy.vaultInfo(vault); + assertEq(vc.shareLimit, shareLimit, "Vault's shareLimit in connection must match OperatorGrid registered VaultInfo"); + assertEq(vc.reserveRatioBP, reserveRatioBP, "Vault's reserveRatioBP in connection must match OperatorGrid registered VaultInfo"); + assertEq(vc.forcedRebalanceThresholdBP, forcedRebalanceThresholdBP, "Vault's forcedRebalanceThresholdBP in connection must match OperatorGrid registered VaultInfo"); + assertEq(vc.infraFeeBP, infraFeeBP, "Vault's infraFeeBP in connection must match OperatorGrid registered VaultInfo"); + assertEq(vc.liquidityFeeBP, liquidityFeeBP, "Vault's liquidityFeeBP in connection must match OperatorGrid registered VaultInfo"); + assertEq(vc.reservationFeeBP, reservationFeeBP, "Vault's reservationFeeBP in connection must match OperatorGrid registered VaultInfo"); + } + } + } From 0467652befcc25839da719585f2e3692dc170c65 Mon Sep 17 00:00:00 2001 From: Anton Kovalchuk Date: Mon, 3 Nov 2025 14:40:57 +0100 Subject: [PATCH 14/16] fix: fix fuzzing --- .../MultiStakingVaultFuzzing.t.sol | 155 +++++---- .../MultiStakingVaultHandler.t.sol | 87 ++--- .../StakingVaultConstants.sol | 1 + .../StakingVaultsFuzzing.t.sol | 148 +++++---- .../StakingVaultsHandler.t.sol | 89 +++-- .../invariant-fuzzing/mocks/CommonMocks.sol | 304 +++--------------- .../mocks/LazyOracleMock.sol | 276 ---------------- .../mocks/OperatorGridMock.sol | 41 +++ 8 files changed, 364 insertions(+), 737 deletions(-) delete mode 100644 test/0.8.25/invariant-fuzzing/mocks/LazyOracleMock.sol diff --git a/test/0.8.25/invariant-fuzzing/MultiStakingVaultFuzzing.t.sol b/test/0.8.25/invariant-fuzzing/MultiStakingVaultFuzzing.t.sol index 84a621dbb6..c94aa7912e 100644 --- a/test/0.8.25/invariant-fuzzing/MultiStakingVaultFuzzing.t.sol +++ b/test/0.8.25/invariant-fuzzing/MultiStakingVaultFuzzing.t.sol @@ -3,21 +3,20 @@ pragma solidity 0.8.25; import {Test} from "forge-std/Test.sol"; -import "forge-std/console2.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts-v5.2/proxy/ERC1967/ERC1967Proxy.sol"; import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; import {StakingVault} from "contracts/0.8.25/vaults/StakingVault.sol"; import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; -import {IHashConsensus} from "contracts/0.8.25/vaults/interfaces/IHashConsensus.sol"; -import {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; +import {IHashConsensus} from "contracts/common/interfaces/IHashConsensus.sol"; +import {ILido} from "contracts/common/interfaces/ILido.sol"; import {MultiStakingVaultHandler} from "./MultiStakingVaultHandler.t.sol"; import {Constants} from "./StakingVaultConstants.sol"; -import {LazyOracleMock} from "./mocks/LazyOracleMock.sol"; +import {LazyOracle} from "contracts/0.8.25/vaults/LazyOracle.sol"; import {OperatorGridMock, TierParams} from "./mocks/OperatorGridMock.sol"; - +import {PinnedBeaconProxyMock, VaultFactoryMock, PredepositGuaranteeMock} from "./mocks/CommonMocks.sol"; import {Math256} from "contracts/common/lib/Math256.sol"; contract MultiStakingVaultsTest is Test { @@ -49,6 +48,7 @@ contract MultiStakingVaultsTest is Test { address private lidoLocator_addr = makeAddr("lidoLocator"); address private lido_addr = makeAddr("lido"); address private consensusContract_addr = makeAddr("consensusContract"); + address private vaultFactory_addr = makeAddr("vaultFactory"); function deployMockContracts() internal { //Deploy LidoMock @@ -63,13 +63,19 @@ contract MultiStakingVaultsTest is Test { ); //Deploy LazyOracleMock + LazyOracle lazyOracle = new LazyOracle(lidoLocator_addr); + vm.prank(rootAccount); deployCodeTo( - "CommonMocks.sol:LazyOracleMock", + "ERC1967Proxy", abi.encode( - lidoLocator_addr, - consensusContract_addr, - Constants.QUARANTINE_PERIOD, - Constants.MAX_REWARD_RATIO_BP + lazyOracle, + abi.encodeWithSelector( + LazyOracle.initialize.selector, + rootAccount, + Constants.QUARANTINE_PERIOD, + Constants.MAX_REWARD_RATIO_BP, + Constants.MAX_LIDO_FEE_RATE_PER_SECOND + ) ), lazyOracle_addr ); @@ -77,6 +83,10 @@ contract MultiStakingVaultsTest is Test { //Deploy ConsensusContractMock deployCodeTo("CommonMocks.sol:ConsensusContractMock", abi.encode(1, 0), consensusContract_addr); + deployCodeTo("CommonMocks.sol:VaultFactoryMock", abi.encode(vaultFactory_addr), vaultFactory_addr); + + deployCodeTo("CommonMocks.sol:PredepositGuaranteeMock", abi.encode(pdg_addr), pdg_addr); + //Deploy LidoLocatorMock deployCodeTo( "CommonMocks.sol:LidoLocatorMock", @@ -88,7 +98,8 @@ contract MultiStakingVaultsTest is Test { operatorGrid_addr, lazyOracle_addr, vaultHub_addr, - consensusContract_addr + consensusContract_addr, + vaultFactory_addr ), lidoLocator_addr ); @@ -123,21 +134,11 @@ contract MultiStakingVaultsTest is Test { //grantRole REGISTRY_ROLE vm.startPrank(rootAccount); bytes32 operatorGridRegistryRole = operatorGridProxy.REGISTRY_ROLE(); - operatorGridProxy.grantRole( - operatorGridRegistryRole, - rootAccount - ); + operatorGridProxy.grantRole(operatorGridRegistryRole, rootAccount); - operatorGridProxy.registerGroup( - nodeOpAccount[0], - groupShareLimit[0] - ); - operatorGridProxy.registerGroup( - nodeOpAccount[1], - groupShareLimit[1] - ); + operatorGridProxy.registerGroup(nodeOpAccount[0], groupShareLimit[0]); + operatorGridProxy.registerGroup(nodeOpAccount[1], groupShareLimit[1]); - TierParams[] memory tiersParamsGroup1 = new TierParams[](2); TierParams[] memory tiersParamsGroup2 = new TierParams[](2); @@ -178,15 +179,9 @@ contract MultiStakingVaultsTest is Test { }); //register Tiers1,2 from Group1 and Tiers3,4 from Group2 - operatorGridProxy.registerTiers( - nodeOpAccount[0], - tiersParamsGroup1 - ); + operatorGridProxy.registerTiers(nodeOpAccount[0], tiersParamsGroup1); - operatorGridProxy.registerTiers( - nodeOpAccount[1], - tiersParamsGroup2 - ); + operatorGridProxy.registerTiers(nodeOpAccount[1], tiersParamsGroup2); vm.stopPrank(); } @@ -210,32 +205,32 @@ contract MultiStakingVaultsTest is Test { bytes32 vaultMasterRole = vaultHubProxy.VAULT_MASTER_ROLE(); vm.prank(rootAccount); vaultHubProxy.grantRole(vaultMasterRole, rootAccount); - - bytes32 vaultCodehashSetRole = vaultHubProxy.VAULT_CODEHASH_SET_ROLE(); - vm.prank(rootAccount); - vaultHubProxy.grantRole(vaultCodehashSetRole, rootAccount); } function deployStakingVaults() internal { - for (uint256 i=0; i 0) { vm.warp(block.timestamp + daysShift * 1 days); - lazyOracle.setVaultDataTimestamp(uint64(block.timestamp)); - VaultHub.VaultObligations memory obligations = vaultHub.vaultObligations(address(stakingVaults[id])); + //lazyOracle.setVaultDataTimestamp(uint64(block.timestamp)); + (uint256 refSlot1, ) = consensusContract.getCurrentFrame(); + lazyOracle.updateReportData(uint256(block.timestamp), refSlot1, bytes32(0), "test"); + VaultHub.VaultRecord memory vaultRecord = vaultHub.vaultRecord(address(stakingVaults[id])); lastReport = VaultReport({ totalValue: vaultHub.totalValue(address(stakingVaults[id])) + sv_otcDeposited[id] + cl_balance, - cumulativeLidoFees: obligations.settledLidoFees + obligations.unsettledLidoFees + 1, + cumulativeLidoFees: vaultRecord.cumulativeLidoFees + vaultRecord.settledLidoFees + 1, liabilityShares: vaultHub.liabilityShares(address(stakingVaults[id])), reportTimestamp: uint64(block.timestamp) }); @@ -297,13 +309,16 @@ contract MultiStakingVaultHandler is CommonBase, StdCheats, StdUtils, StdAsserti consensusContract.setCurrentFrame(refSlot); } + VaultHub.VaultRecord memory vaultRecord = vaultHub.vaultRecord(address(stakingVaults[id])); //update the vault data lazyOracle.updateVaultData( address(stakingVaults[id]), lastReport.totalValue, lastReport.cumulativeLidoFees, lastReport.liabilityShares, - uint64(block.timestamp) + vaultRecord.maxLiabilityShares, + 0, + new bytes32[](0) ); // Accept ownership if disconnect was successful if (stakingVaults[id].pendingOwner() == userAccount[id]) { diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol b/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol index 855d15a058..3e92f0411e 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol @@ -28,4 +28,5 @@ library Constants { //LazyOracle params uint64 public constant QUARANTINE_PERIOD = 3 days; uint16 public constant MAX_REWARD_RATIO_BP = 350; //3.5% + uint256 public constant MAX_LIDO_FEE_RATE_PER_SECOND = 1 ether; } diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol index 1c6c22d1a7..14082888de 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol @@ -3,24 +3,26 @@ pragma solidity 0.8.25; import {Test} from "forge-std/Test.sol"; -import "forge-std/console2.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts-v5.2/proxy/ERC1967/ERC1967Proxy.sol"; + +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; +import {IHashConsensus} from "contracts/common/interfaces/IHashConsensus.sol"; +import {ILido} from "contracts/common/interfaces/ILido.sol"; +import {Math256} from "contracts/common/lib/Math256.sol"; import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; import {StakingVault} from "contracts/0.8.25/vaults/StakingVault.sol"; import {TierParams, OperatorGrid} from "contracts/0.8.25/vaults/OperatorGrid.sol"; - -import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; -import {IHashConsensus} from "contracts/0.8.25/vaults/interfaces/IHashConsensus.sol"; -import {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; +import {LazyOracle} from "contracts/0.8.25/vaults/LazyOracle.sol"; +import {RefSlotCache, DoubleRefSlotCache, DOUBLE_CACHE_LENGTH} from "contracts/0.8.25/vaults/lib/RefSlotCache.sol"; import {StakingVaultsHandler} from "./StakingVaultsHandler.t.sol"; import {Constants} from "./StakingVaultConstants.sol"; - -import {LazyOracleMock} from "./mocks/LazyOracleMock.sol"; - -import {Math256} from "contracts/common/lib/Math256.sol"; +import {PinnedBeaconProxyMock} from "./mocks/CommonMocks.sol"; contract StakingVaultsTest is Test { + using RefSlotCache for RefSlotCache.Uint104WithCache; + using DoubleRefSlotCache for DoubleRefSlotCache.Int104WithCache[DOUBLE_CACHE_LENGTH]; + VaultHub vaultHubProxy; StakingVault stakingVaultProxy; @@ -42,6 +44,7 @@ contract StakingVaultsTest is Test { address private lidoLocator_addr = makeAddr("lidoLocator"); address private lido_addr = makeAddr("lido"); address private consensusContract_addr = makeAddr("consensusContract"); + address private vaultFactory_addr = makeAddr("vaultFactory"); function deployMockContracts() internal { //Deploy LidoMock @@ -56,13 +59,20 @@ contract StakingVaultsTest is Test { ); //Deploy LazyOracleMock + LazyOracle lazyOracle = new LazyOracle(lidoLocator_addr); + + vm.prank(rootAccount); deployCodeTo( - "CommonMocks.sol:LazyOracleMock", + "ERC1967Proxy", abi.encode( - lidoLocator_addr, - consensusContract_addr, - Constants.QUARANTINE_PERIOD, - Constants.MAX_REWARD_RATIO_BP + lazyOracle, + abi.encodeWithSelector( + LazyOracle.initialize.selector, + rootAccount, + Constants.QUARANTINE_PERIOD, + Constants.MAX_REWARD_RATIO_BP, + Constants.MAX_LIDO_FEE_RATE_PER_SECOND + ) ), lazyOracle_addr ); @@ -70,6 +80,12 @@ contract StakingVaultsTest is Test { //Deploy ConsensusContractMock deployCodeTo("CommonMocks.sol:ConsensusContractMock", abi.encode(1, 0), consensusContract_addr); + //Deploy VaultFactoryMock + deployCodeTo("CommonMocks.sol:VaultFactoryMock", abi.encode(vaultFactory_addr), vaultFactory_addr); + + //Deploy PredepositGuaranteeMock + deployCodeTo("CommonMocks.sol:PredepositGuaranteeMock", abi.encode(pdg_addr), pdg_addr); + //Deploy LidoLocatorMock deployCodeTo( "CommonMocks.sol:LidoLocatorMock", @@ -81,7 +97,8 @@ contract StakingVaultsTest is Test { operatorGrid_addr, lazyOracle_addr, vaultHub_addr, - consensusContract_addr + consensusContract_addr, + vaultFactory_addr ), lidoLocator_addr ); @@ -131,10 +148,6 @@ contract StakingVaultsTest is Test { vm.prank(rootAccount); vaultHubProxy.grantRole(vaultMasterRole, rootAccount); - bytes32 vaultCodehashSetRole = vaultHubProxy.VAULT_CODEHASH_SET_ROLE(); - vm.prank(rootAccount); - vaultHubProxy.grantRole(vaultCodehashSetRole, rootAccount); - bytes32 validatorExitRole = vaultHubProxy.VALIDATOR_EXIT_ROLE(); vm.prank(rootAccount); vaultHubProxy.grantRole(validatorExitRole, rootAccount); @@ -143,15 +156,12 @@ contract StakingVaultsTest is Test { function deployStakingVault() internal { //Create StakingVault contract StakingVault stakingVault = new StakingVault(address(0x22)); - ERC1967Proxy proxy = new ERC1967Proxy( + + PinnedBeaconProxyMock proxy = new PinnedBeaconProxyMock( address(stakingVault), abi.encodeWithSelector(StakingVault.initialize.selector, userAccount, nodeOperator, pdg_addr, "0x") ); stakingVaultProxy = StakingVault(payable(address(proxy))); - - vm.prank(rootAccount); - //Allow the stakingVault contract to be connected - vaultHubProxy.setAllowedCodehash(address(stakingVaultProxy).codehash, true); } function setUp() public { @@ -201,45 +211,50 @@ contract StakingVaultsTest is Test { targetSelector(FuzzSelector({addr: address(svHandler), selectors: svSelectors})); } + function test() public { + svHandler.fund(1 ether); + svHandler.updateVaultData(3); + svHandler.voluntaryDisconnect(); + } + ////////// INVARIANTS ////////// - //With current deployed environement (no slashing, no stETH rebase) + //With current deployed environment (no slashing, no stETH rebase) //the staking Vault should never go below the rebalance threshold //Meaning having less locked collateral than the threshold ratio limit (in regards to the liabilityShares converted in ETH) //This is computed by rebalanceShortfall function // Invariant 1: Staking vault should never go below the rebalance threshold (collateral always covers liability). function invariant1_liabilityShares_not_above_collateral() external { - uint256 rebalanceShares = vaultHubProxy.rebalanceShortfall(address(stakingVaultProxy)); + uint256 rebalanceShares = vaultHubProxy.healthShortfallShares(address(stakingVaultProxy)); assertEq(rebalanceShares, 0, "Staking Vault should never go below the rebalance threshold"); } - // Invariant 2: Dynamic total value (including deltas) should never underflow (must be >= 0). function invariant2_dynamic_totalValue_should_not_underflow() external { VaultHub.VaultRecord memory record = vaultHubProxy.vaultRecord(address(stakingVaultProxy)); assertGe( int256(uint256(record.report.totalValue)) + - int256(record.inOutDelta.value) - + int256(record.inOutDelta.currentValue()) - int256(record.report.inOutDelta), 0, "Dynamic total value should not underflow" ); } - // Invariant 3: forceRebalance should not revert when the vault is unhealthy. - function invariant3_forceRebalance_should_not_revert_when_unhealthy() external { + // Invariant 3: forceRebalance should not revert when the vault has available balance and obligations. + function invariant3_forceRebalance_should_not_revert_when_has_available_balance_and_obligations() external { bool forceRebalanceReverted = svHandler.didForceRebalanceReverted(); - assertFalse(forceRebalanceReverted, "forceRebalance should not revert when unhealthy"); + assertFalse( + forceRebalanceReverted, + "forceRebalance should not revert when has available balance and obligations" + ); } - // Invariant 4: forceValidatorExit should not revert when unhealthy and vault balance is too low. - function invariant4_forceValidatorExit_should_not_revert_when_unhealthy_and_vault_balance_too_low() external { + // Invariant 4: forceValidatorExit should not revert when has obligations shortfall. + function invariant4_forceValidatorExit_should_not_revert_when_has_obligations_shortfall() external { bool forceValidatorExitReverted = svHandler.didForceValidatorExitReverted(); - assertFalse( - forceValidatorExitReverted, - "forceValidatorExit should not revert when unhealthy and vault balance is not sufficient" - ); + assertFalse(forceValidatorExitReverted, "forceValidatorExit should not revert when has obligations shortfall"); } // Invariant 5: Applied total value should not be greater than reported total value. @@ -254,6 +269,7 @@ contract StakingVaultsTest is Test { ); } + // внимательно следить при каких операциях он держится (update connection, socilized bad dept) // Invariant 6: Liability shares should never be greater than connection share limit. function invariant6_liabilityshares_should_never_be_greater_than_connection_sharelimit() external { //Get the share limit from the vault @@ -266,21 +282,21 @@ contract StakingVaultsTest is Test { } modifier vaultMustBeConnected() { - if (vaultHubProxy.vaultConnection(address(stakingVaultProxy)).vaultIndex == 0) { + if (!vaultHubProxy.isVaultConnected(address(stakingVaultProxy))) { return; } _; } modifier vaultNotPendingDisconnect() { - if (vaultHubProxy.vaultConnection(address(stakingVaultProxy)).pendingDisconnect) { + if (vaultHubProxy.isPendingDisconnect(address(stakingVaultProxy))) { return; } _; } // Invariant 7: Locked amount must be >= max(connect deposit, slashing reserve, reserve ratio). - function invariant7_locked_cannot_be_less_than_slashing_connectdep_reserve() + function invariant7_locked_cannot_be_less_than_slashing_connected_reserve() external vaultMustBeConnected vaultNotPendingDisconnect @@ -290,7 +306,7 @@ contract StakingVaultsTest is Test { VaultHub.VaultConnection memory connection = vaultHubProxy.vaultConnection(address(stakingVaultProxy)); uint256 forcedRebalanceThresholdBP = connection.forcedRebalanceThresholdBP; - uint128 lockedAmount = record.locked; + uint256 lockedAmount = vaultHubProxy.locked(address(stakingVaultProxy)); uint256 liabilityStETH = ILido(address(lido_addr)).getPooledEthBySharesRoundUp(record.liabilityShares); uint256 minium_safety_buffer = (liabilityStETH * Constants.TOTAL_BASIS_POINTS) / @@ -303,25 +319,25 @@ contract StakingVaultsTest is Test { ); } - // function invariant_totalValue_should_be_greater_than_locked() vaultMustBeConnected vaultNotPendingDisconnect external { - // //Get the total value of the vault - // uint256 totalValue = vaultHubProxy.totalValue(address(stakingVaultProxy)); - // if (totalValue == 0) { - // // If totalValue is 0, we cannot check the invariant - // //That's probably because the vault has just been created and no report has not been applied yet - // return; - // } + // // function invariant_totalValue_should_be_greater_than_locked() vaultMustBeConnected vaultNotPendingDisconnect external { + // // //Get the total value of the vault + // // uint256 totalValue = vaultHubProxy.totalValue(address(stakingVaultProxy)); + // // if (totalValue == 0) { + // // // If totalValue is 0, we cannot check the invariant + // // //That's probably because the vault has just been created and no report has not been applied yet + // // return; + // // } - // //Get the locked amount - // VaultHub.VaultRecord memory record = vaultHubProxy.vaultRecord(address(stakingVaultProxy)); - // uint128 lockedAmount = record.locked; + // // //Get the locked amount + // // VaultHub.VaultRecord memory record = vaultHubProxy.vaultRecord(address(stakingVaultProxy)); + // // uint128 lockedAmount = record.locked; - // //VaultHub.VaultObligations memory vaultObligations = vaultHubProxy.vaultObligations(address(stakingVaultProxy)); - // //uint256 unsettledObligations = vaultObligations.unsettledLidoFees + vaultObligations.redemptions; + // // //VaultHub.VaultObligations memory vaultObligations = vaultHubProxy.vaultObligations(address(stakingVaultProxy)); + // // //uint256 unsettledObligations = vaultObligations.unsettledLidoFees + vaultObligations.redemptions; - // //Check that total value is greater than or equal to locked amount and unsettled obligations - // assertGe(totalValue, lockedAmount , "Total value should be greater than or equal to locked amount"); - // } + // // //Check that total value is greater than or equal to locked amount and unsettled obligations + // // assertGe(totalValue, lockedAmount , "Total value should be greater than or equal to locked amount"); + // // } // Invariant 8: Withdrawable value must be <= total value minus locked amount and unsettled obligations. function invariant8_withdrawableValue_should_be_less_than_or_equal_to_totalValue_minus_locked_and_obligations() @@ -334,12 +350,10 @@ contract StakingVaultsTest is Test { uint256 totalValue = vaultHubProxy.totalValue(address(stakingVaultProxy)); //Get the locked amount and unsettled obligations - VaultHub.VaultRecord memory record = vaultHubProxy.vaultRecord(address(stakingVaultProxy)); - uint128 lockedAmount = record.locked; - VaultHub.VaultObligations memory vaultObligations = vaultHubProxy.vaultObligations(address(stakingVaultProxy)); - uint256 unsettled_plus_locked = vaultObligations.unsettledLidoFees + - vaultObligations.redemptions + - lockedAmount; + uint256 lockedAmount = vaultHubProxy.locked(address(stakingVaultProxy)); + (uint256 obligationsShares, uint256 obligationsFees) = vaultHubProxy.obligations(address(stakingVaultProxy)); + + uint256 unsettled_plus_locked = obligationsFees + lockedAmount; uint256 tv_minus_locked_and_obligations = totalValue > unsettled_plus_locked ? totalValue - unsettled_plus_locked : 0; @@ -368,14 +382,12 @@ contract StakingVaultsTest is Test { //9. connectVault //10. updateVaultData -> reuses previous report; quarantine is expired; TV is kept as is (special branch if the new quarantine delta is lower than the expired one). // Invariant 9: Computed totalValue must be <= effective (real) total value. - function invariant9_computed_totalValue_must_be_less_than_or_equal_to_effective_total_value() external { - assertLe(svHandler.getVaultTotalValue(), svHandler.getEffectiveVaultTotalValue()); - } + // function invariant9_computed_totalValue_must_be_less_than_or_equal_to_effective_total_value() external { + // assertLe(svHandler.getVaultTotalValue(), svHandler.getEffectiveVaultTotalValue()); + // } - // For testing purposes only (guiding the fuzzing) // function invariant_state() external { // assertEq(svHandler.actionIndex() != 11, true, "callpath reached"); // } - } diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol index 335d19dbba..547797319d 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol @@ -2,23 +2,21 @@ pragma solidity ^0.8.25; +// External dependencies import {CommonBase} from "forge-std/Base.sol"; import {StdCheats} from "forge-std/StdCheats.sol"; import {StdUtils} from "forge-std/StdUtils.sol"; - import {StdAssertions} from "forge-std/StdAssertions.sol"; import {Vm} from "forge-std/Vm.sol"; +import {ILido} from "contracts/common/interfaces/ILido.sol"; +import {Math256} from "contracts/common/lib/Math256.sol"; import {StakingVault} from "contracts/0.8.25/vaults/StakingVault.sol"; -import {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; -import {Math256} from "contracts/common/lib/Math256.sol"; -import {LidoLocatorMock, ConsensusContractMock} from "./mocks/CommonMocks.sol"; +import {LazyOracle} from "contracts/0.8.25/vaults/LazyOracle.sol"; -import {LazyOracleMock} from "./mocks/LazyOracleMock.sol"; import {Constants} from "./StakingVaultConstants.sol"; -import "forge-std/console2.sol"; - +import {LidoLocatorMock, ConsensusContractMock} from "./mocks/CommonMocks.sol"; /// @title StakingVaultsHandler /// @notice Handler contract for invariant fuzzing of a single staking vault in the Lido protocol. @@ -31,9 +29,10 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions LidoLocatorMock public lidoLocator; VaultHub public vaultHub; StakingVault public stakingVault; - LazyOracleMock public lazyOracle; + LazyOracle public lazyOracle; ConsensusContractMock public consensusContract; VaultReport public lastReport; + address public accountingOracle; struct VaultReport { uint256 totalValue; @@ -76,10 +75,11 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions constructor(address _lidoLocator, address _stakingVault, address _rootAccount, address _userAccount) { lidoLocator = LidoLocatorMock(_lidoLocator); + accountingOracle = lidoLocator.accountingOracle(); lidoContract = ILido(lidoLocator.lido()); vaultHub = VaultHub(payable(lidoLocator.vaultHub())); stakingVault = StakingVault(payable(_stakingVault)); - lazyOracle = LazyOracleMock(lidoLocator.lazyOracle()); + lazyOracle = LazyOracle(lidoLocator.lazyOracle()); consensusContract = ConsensusContractMock(lidoLocator.consensusContract()); rootAccount = _rootAccount; userAccount = _userAccount; @@ -125,6 +125,7 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions function didForceValidatorExitReverted() public view returns (bool) { return forceValidatorExitReverted; } + // --- VaultHub interactions --- /// @notice Connects the vault to the VaultHub, funding if needed function connectVault() public { @@ -132,7 +133,8 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions if (vaultHub.vaultConnection(address(stakingVault)).vaultIndex != 0) { return; } - if (address(stakingVault).balance < Constants.CONNECT_DEPOSIT) { + // TODO: PDG.pendingActivations + if (stakingVault.availableBalance() < Constants.CONNECT_DEPOSIT) { deal(address(userAccount), Constants.CONNECT_DEPOSIT); vm.prank(userAccount); stakingVault.fund{value: Constants.CONNECT_DEPOSIT}(); @@ -145,10 +147,9 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions /// @notice Initiates voluntary disconnect for the vault function voluntaryDisconnect() public { - VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVault)); - //do nothing if disconnected or already disconnecting - if (vc.vaultIndex == 0 || vc.pendingDisconnect == true) return; + if (!vaultHub.isVaultConnected(address(stakingVault)) || vaultHub.isPendingDisconnect(address(stakingVault))) + return; //decrease liabilities uint256 shares = vaultHub.liabilityShares(address(stakingVault)); @@ -173,33 +174,49 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions amount = bound(amount, 1, vaultHub.withdrawableValue(address(stakingVault))); //check that stakingVault is connected - if (vaultHub.vaultConnection(address(stakingVault)).vaultIndex == 0) { + if (!vaultHub.isVaultConnected(address(stakingVault))) { return; } vm.prank(userAccount); vaultHub.withdraw(address(stakingVault), userAccount, amount); } - /// @notice Forces a rebalance if the vault is unhealthy + /// @notice Forces a rebalance if the vault has available balance and obligations function forceRebalance() public { - if (vaultHub.isVaultHealthy(address(stakingVault))) { + // if (vaultHub.isVaultHealthy(address(stakingVault))) { + // return; + // } + + uint256 availableBalance = Math256.min( + stakingVault.availableBalance(), + vaultHub.totalValue(address(stakingVault)) + ); + if (availableBalance == 0) { return; } + + (uint256 obligationsShares, ) = vaultHub.obligations(address(stakingVault)); + uint256 sharesToForceRebalance = Math256.min( + obligationsShares, + lidoContract.getSharesByPooledEth(availableBalance) + ); + if (sharesToForceRebalance == 0) { + return; + } + vm.prank(userAccount); try vaultHub.forceRebalance(address(stakingVault)) {} catch { forceRebalanceReverted = true; } } - /// @notice Forces validator exit if vault is unhealthy or obligations exceed threshold + /// @notice Forces validator exit if vault has obligations shortfall function forceValidatorExit() public { - uint256 redemptions = vaultHub.vaultObligations(address(stakingVault)).redemptions; - if ( - vaultHub.isVaultHealthy(address(stakingVault)) && - redemptions < Math256.max(Constants.UNSETTLED_THRESHOLD, address(stakingVault).balance) - ) { + uint256 obligationsShortfallValue = vaultHub.obligationsShortfallValue(address(stakingVault)); + if (obligationsShortfallValue == 0) { return; } + bytes memory pubkeys = new bytes(0); vm.prank(rootAccount); //privileged account can force exit try vaultHub.forceValidatorExit{value: 3000}(address(stakingVault), pubkeys, userAccount) { @@ -243,7 +260,8 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions /// @notice Calls rebalance on the staking vault (via VaultHub) function rebalance(uint256 amount) public { VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVault)); - if (vc.vaultIndex == 0 || vc.pendingDisconnect == true) return; + if (!vaultHub.isVaultConnected(address(stakingVault)) || vaultHub.isPendingDisconnect(address(stakingVault))) + return; uint256 totalValue = vaultHub.totalValue(address(stakingVault)); amount = bound(amount, 1, totalValue); @@ -252,7 +270,6 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions vaultHub.rebalance(address(stakingVault), amount); } - /// @notice Returns the effective total value of the vault (EL + CL balance) function getEffectiveVaultTotalValue() public view returns (uint256) { return address(stakingVault).balance + cl_balance; @@ -268,7 +285,6 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions amount = bound(amount, 1 ether, 10 ether); sv_otcDeposited += amount; deal(address(stakingVault), address(stakingVault).balance + amount); - console2.log("stakingVault balance =", address(stakingVault).balance); } /// @notice Simulates OTC deposit to the VaultHub @@ -284,21 +300,23 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions function updateVaultData(uint256 daysShift) public { daysShift = bound(daysShift, 0, 1); daysShift *= 3; // 0 or 3 days for quarantine period expiration - console2.log("DaysShift = %d", daysShift); //Check if vault is connected before proceeding - if (vaultHub.vaultConnection(address(stakingVault)).vaultIndex == 0) { + if (!vaultHub.isVaultConnected(address(stakingVault))) { return; } if (daysShift > 0) { vm.warp(block.timestamp + daysShift * 1 days); - lazyOracle.setVaultDataTimestamp(uint64(block.timestamp)); - VaultHub.VaultObligations memory obligations = vaultHub.vaultObligations(address(stakingVault)); + (uint256 refSlot1, ) = consensusContract.getCurrentFrame(); + vm.prank(accountingOracle); + lazyOracle.updateReportData(uint256(block.timestamp), refSlot1, bytes32(0), "test"); + + VaultHub.VaultRecord memory vaultRecord = vaultHub.vaultRecord(address(stakingVault)); lastReport = VaultReport({ - totalValue: vaultHub.totalValue(address(stakingVault)) + sv_otcDeposited + cl_balance, - cumulativeLidoFees: obligations.settledLidoFees + obligations.unsettledLidoFees + 1, + totalValue: stakingVault.availableBalance() + cl_balance + sv_otcDeposited, //*/ vaultHub.totalValue(address(stakingVault)),// + sv_otcDeposited + cl_balance, + cumulativeLidoFees: vaultRecord.cumulativeLidoFees + vaultRecord.settledLidoFees + 1, liabilityShares: vaultHub.liabilityShares(address(stakingVault)), reportTimestamp: uint64(block.timestamp) }); @@ -306,6 +324,7 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions //reset otc deposit value sv_otcDeposited = 0; } + // Simulate next ref slot (uint256 refSlot, ) = consensusContract.getCurrentFrame(); if (daysShift > 0) { @@ -318,15 +337,21 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions //we update the reported total Value reportedTotalValue = lastReport.totalValue; + uint256 maxLiabilityShares = vaultHub.vaultRecord(address(stakingVault)).maxLiabilityShares; + //update the vault data lazyOracle.updateVaultData( address(stakingVault), lastReport.totalValue, lastReport.cumulativeLidoFees, lastReport.liabilityShares, - uint64(block.timestamp) + maxLiabilityShares, + 0, + new bytes32[](0) ); + bool isReportFresh = vaultHub.isReportFresh(address(stakingVault)); + //we update the applied total value (TV should go through sanity checks, quarantine, etc.) appliedTotalValue = vaultHub.vaultRecord(address(stakingVault)).report.totalValue; // Accept ownership if disconnect was successful diff --git a/test/0.8.25/invariant-fuzzing/mocks/CommonMocks.sol b/test/0.8.25/invariant-fuzzing/mocks/CommonMocks.sol index 16a7a7f21a..96bc8db86f 100644 --- a/test/0.8.25/invariant-fuzzing/mocks/CommonMocks.sol +++ b/test/0.8.25/invariant-fuzzing/mocks/CommonMocks.sol @@ -3,13 +3,24 @@ pragma solidity 0.8.25; -import {IHashConsensus} from "contracts/0.8.25/vaults/interfaces/IHashConsensus.sol"; +import {IHashConsensus} from "contracts/common/interfaces/IHashConsensus.sol"; import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; import {OperatorGrid} from "contracts/0.8.25/vaults/OperatorGrid.sol"; import {SafeCast} from "@openzeppelin/contracts-v5.2/utils/math/SafeCast.sol"; import {Math256} from "contracts/common/lib/Math256.sol"; -import {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; +import {ILido} from "contracts/common/interfaces/ILido.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts-v5.2/proxy/ERC1967/ERC1967Proxy.sol"; +import {IPinnedBeaconProxy} from "contracts/0.8.25/vaults/interfaces/IPinnedBeaconProxy.sol"; +import {RefSlotCache, DoubleRefSlotCache, DOUBLE_CACHE_LENGTH} from "contracts/0.8.25/vaults/lib/RefSlotCache.sol"; +import {MerkleProof} from "@openzeppelin/contracts-v5.2/utils/cryptography/MerkleProof.sol"; +import {LazyOracle} from "contracts/0.8.25/vaults/LazyOracle.sol"; +import {ILazyOracle} from "contracts/common/interfaces/ILazyOracle.sol"; +import { + AccessControlEnumerableUpgradeable +} from "contracts/openzeppelin/5.2/upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; +import {IStakingVault} from "contracts/0.8.25/vaults/interfaces/IStakingVault.sol"; +import {IPredepositGuarantee} from "contracts/0.8.25/vaults/interfaces/IPredepositGuarantee.sol"; contract ConsensusContractMock is IHashConsensus { uint256 public refSlot; @@ -58,6 +69,7 @@ contract LidoLocatorMock { address public lazyOracle_; address public vaultHub_; address public consensusContract_; + address public vaultFactory_; constructor( address _lido, @@ -67,7 +79,8 @@ contract LidoLocatorMock { address _operatorGrid, address _lazyOracle, address _vaultHub, - address _consensusContract + address _consensusContract, + address _vaultFactory ) { lido_ = _lido; predepositGuarantee_ = _predepositGuarantee; @@ -77,6 +90,7 @@ contract LidoLocatorMock { lazyOracle_ = _lazyOracle; vaultHub_ = _vaultHub; consensusContract_ = _consensusContract; + vaultFactory_ = _vaultFactory; } function lido() external view returns (address) { @@ -90,7 +104,7 @@ contract LidoLocatorMock { return predepositGuarantee_; } - function accounting() external view returns (address) { + function accountingOracle() external view returns (address) { return accounting_; } @@ -109,264 +123,10 @@ contract LidoLocatorMock { function consensusContract() external view returns (address) { return consensusContract_; } -} - -contract LazyOracleMock { - struct Storage { - /// @notice root of the vaults data tree - bytes32 vaultsDataTreeRoot; - /// @notice CID of the vaults data tree - string vaultsDataReportCid; - /// @notice timestamp of the vaults data - uint64 vaultsDataTimestamp; - /// @notice total value increase quarantine period - uint64 quarantinePeriod; - /// @notice max reward ratio for refSlot-observed total value, basis points - uint16 maxRewardRatioBP; - /// @notice deposit quarantines for each vault - mapping(address vault => Quarantine) vaultQuarantines; - } - - struct Quarantine { - uint128 pendingTotalValueIncrease; - uint64 startTimestamp; - } - - struct VaultInfo { - address vault; - uint96 vaultIndex; - uint256 balance; - bytes32 withdrawalCredentials; - uint256 liabilityShares; - uint256 mintableStETH; - uint96 shareLimit; - uint16 reserveRatioBP; - uint16 forcedRebalanceThresholdBP; - uint16 infraFeeBP; - uint16 liquidityFeeBP; - uint16 reservationFeeBP; - bool pendingDisconnect; - } - struct QuarantineInfo { - bool isActive; - uint256 pendingTotalValueIncrease; - uint256 startTimestamp; - uint256 endTimestamp; + function vaultFactory() external view returns (address) { + return vaultFactory_; } - - // keccak256(abi.encode(uint256(keccak256("LazyOracle")) - 1)) & ~bytes32(uint256(0xff)) - bytes32 private constant LAZY_ORACLE_STORAGE_LOCATION = - 0xe5459f2b48ec5df2407caac4ec464a5cb0f7f31a1f22f649728a9579b25c1d00; - - bytes32 public constant UPDATE_SANITY_PARAMS_ROLE = keccak256("UPDATE_SANITY_PARAMS_ROLE"); - - // total basis points = 100% - uint256 internal constant TOTAL_BP = 100_00; - - ILidoLocator public immutable LIDO_LOCATOR; - IHashConsensus public immutable HASH_CONSENSUS; - - /// @dev basis points base - uint256 private constant TOTAL_BASIS_POINTS = 100_00; - - constructor(address _lidoLocator, address _hashConsensus, uint64 _quarantinePeriod, uint16 _maxRewardRatioBP) { - LIDO_LOCATOR = ILidoLocator(payable(_lidoLocator)); - HASH_CONSENSUS = IHashConsensus(_hashConsensus); - _updateSanityParams(_quarantinePeriod, _maxRewardRatioBP); - } - - /// @notice returns the latest report timestamp - function latestReportTimestamp() external view returns (uint64) { - return _storage().vaultsDataTimestamp; - } - - /// @notice returns the quarantine period - function quarantinePeriod() external view returns (uint64) { - return _storage().quarantinePeriod; - } - - /// @notice returns the max reward ratio for refSlot total value, basis points - function maxRewardRatioBP() external view returns (uint16) { - return _storage().maxRewardRatioBP; - } - - /// @notice returns the quarantine info for the vault - /// @param _vault the address of the vault - // @dev returns zeroed structure if there is no active quarantine - function vaultQuarantine(address _vault) external view returns (QuarantineInfo memory) { - Quarantine storage q = _storage().vaultQuarantines[_vault]; - if (q.pendingTotalValueIncrease == 0) { - return QuarantineInfo(false, 0, 0, 0); - } - - return - QuarantineInfo( - true, - q.pendingTotalValueIncrease, - q.startTimestamp, - q.startTimestamp + _storage().quarantinePeriod - ); - } - - /// @notice update the sanity parameters - /// @param _quarantinePeriod the quarantine period - /// @param _maxRewardRatioBP the max EL CL rewards - function updateSanityParams(uint64 _quarantinePeriod, uint16 _maxRewardRatioBP) external { - _updateSanityParams(_quarantinePeriod, _maxRewardRatioBP); - } - - function setVaultDataTimestamp(uint64 _vaultsDataTimestamp) external { - Storage storage $ = _storage(); - $.vaultsDataTimestamp = uint64(_vaultsDataTimestamp); - } - - /// @notice Permissionless update of the vault data - /// @param _vault the address of the vault - /// @param _totalValue the total value of the vault - /// @param _cumulativeLidoFees the cumulative Lido fees accrued on the vault (nominated in ether) - /// @param _liabilityShares the liabilityShares of the vault - function updateVaultData( - address _vault, - uint256 _totalValue, - uint256 _cumulativeLidoFees, - uint256 _liabilityShares, - uint64 _vaultsDataTimestamp - ) external { - // bytes32 leaf = keccak256( - // bytes.concat(keccak256(abi.encode(_vault, _totalValue, _cumulativeLidoFees, _liabilityShares))) - // ); - //if (!MerkleProof.verify(_proof, _storage().vaultsDataTreeRoot, leaf)) revert InvalidProof(); - - int256 inOutDelta; - (_totalValue, inOutDelta) = _handleSanityChecks(_vault, _totalValue); - - _vaultHub().applyVaultReport( - _vault, - _vaultsDataTimestamp, - _totalValue, - inOutDelta, - _cumulativeLidoFees, - _liabilityShares - ); - } - - /// @notice handle sanity checks for the vault lazy report data - /// @param _vault the address of the vault - /// @param _totalValue the total value of the vault in refSlot - /// @return totalValue the smoothed total value of the vault after sanity checks - /// @return inOutDelta the inOutDelta in the refSlot - function _handleSanityChecks( - address _vault, - uint256 _totalValue - ) public returns (uint256 totalValue, int256 inOutDelta) { - VaultHub vaultHub = _vaultHub(); - VaultHub.VaultRecord memory record = vaultHub.vaultRecord(_vault); - - // 1. Calculate inOutDelta in the refSlot - int256 curInOutDelta = record.inOutDelta.value; - (uint256 refSlot, ) = HASH_CONSENSUS.getCurrentFrame(); - if (record.inOutDelta.refSlot == refSlot) { - inOutDelta = record.inOutDelta.refSlotValue; - } else { - inOutDelta = curInOutDelta; - } - - // 2. Sanity check for total value increase - totalValue = _processTotalValue(_vault, _totalValue, inOutDelta, record); - - // 3. Sanity check for dynamic total value underflow - if (int256(totalValue) + curInOutDelta - inOutDelta < 0) revert UnderflowInTotalValueCalculation(); - - return (totalValue, inOutDelta); - } - - function _processTotalValue( - address _vault, - uint256 _totalValue, - int256 _inOutDelta, - VaultHub.VaultRecord memory record - ) internal returns (uint256) { - Storage storage $ = _storage(); - - uint256 refSlotTotalValue = uint256( - int256(uint256(record.report.totalValue)) + _inOutDelta - record.report.inOutDelta - ); - // some percentage of funds hasn't passed through the vault's balance is allowed for the EL and CL rewards handling - uint256 limit = (refSlotTotalValue * (TOTAL_BP + $.maxRewardRatioBP)) / TOTAL_BP; - - if (_totalValue > limit) { - Quarantine storage q = $.vaultQuarantines[_vault]; - uint64 reportTs = $.vaultsDataTimestamp; - uint128 quarDelta = q.pendingTotalValueIncrease; - uint128 delta = SafeCast.toUint128(_totalValue - refSlotTotalValue); - - if (quarDelta == 0) { - // first overlimit report - _totalValue = refSlotTotalValue; - q.pendingTotalValueIncrease = delta; - q.startTimestamp = reportTs; - emit QuarantinedDeposit(_vault, delta); - } else if (reportTs - q.startTimestamp < $.quarantinePeriod) { - // quarantine not expired - _totalValue = refSlotTotalValue; - } else if (delta <= quarDelta + (refSlotTotalValue * $.maxRewardRatioBP) / TOTAL_BP) { - // quarantine expired - q.pendingTotalValueIncrease = 0; - emit QuarantineExpired(_vault, delta); - } else { - // start new quarantine - _totalValue = refSlotTotalValue + quarDelta; - q.pendingTotalValueIncrease = delta - quarDelta; - q.startTimestamp = reportTs; - emit QuarantinedDeposit(_vault, delta - quarDelta); - } - } - - return _totalValue; - } - - function _updateSanityParams(uint64 _quarantinePeriod, uint16 _maxRewardRatioBP) internal { - Storage storage $ = _storage(); - $.quarantinePeriod = _quarantinePeriod; - $.maxRewardRatioBP = _maxRewardRatioBP; - emit SanityParamsUpdated(_quarantinePeriod, _maxRewardRatioBP); - } - - function _mintableStETH(address _vault) internal view returns (uint256) { - VaultHub vaultHub = _vaultHub(); - uint256 maxLockableValue = vaultHub.maxLockableValue(_vault); - uint256 reserveRatioBP = vaultHub.vaultConnection(_vault).reserveRatioBP; - uint256 mintableStETHByRR = (maxLockableValue * (TOTAL_BASIS_POINTS - reserveRatioBP)) / TOTAL_BASIS_POINTS; - - uint256 effectiveShareLimit = _operatorGrid().effectiveShareLimit(_vault); - uint256 mintableStEthByShareLimit = ILido(LIDO_LOCATOR.lido()).getPooledEthBySharesRoundUp(effectiveShareLimit); - - return Math256.min(mintableStETHByRR, mintableStEthByShareLimit); - } - - function _storage() internal pure returns (Storage storage $) { - assembly { - $.slot := LAZY_ORACLE_STORAGE_LOCATION - } - } - - function _vaultHub() internal view returns (VaultHub) { - return VaultHub(payable(LIDO_LOCATOR.vaultHub())); - } - - function _operatorGrid() internal view returns (OperatorGrid) { - return OperatorGrid(LIDO_LOCATOR.operatorGrid()); - } - - event VaultsReportDataUpdated(uint256 indexed timestamp, bytes32 indexed root, string cid); - event QuarantinedDeposit(address indexed vault, uint128 delta); - event SanityParamsUpdated(uint64 quarantinePeriod, uint16 maxRewardRatioBP); - event QuarantineExpired(address indexed vault, uint128 delta); - error AdminCannotBeZero(); - error NotAuthorized(); - error InvalidProof(); - error UnderflowInTotalValueCalculation(); } contract LidoMock { @@ -459,3 +219,27 @@ contract LidoMock { totalShares -= _amountOfShares; } } + +contract VaultFactoryMock { + function deployedVaults(address _vault) external view returns (bool) { + return true; + } +} + +contract PredepositGuaranteeMock { + function pendingPredeposits(address _vault) external view returns (uint256) { + return 0; + } + + function pendingActivations(address _vault) external view returns (uint256) { + return 0; + } +} + +contract PinnedBeaconProxyMock is ERC1967Proxy, IPinnedBeaconProxy { + constructor(address _impl, bytes memory _data) ERC1967Proxy(_impl, _data) {} + + function isOssified() external view returns (bool) { + return false; + } +} diff --git a/test/0.8.25/invariant-fuzzing/mocks/LazyOracleMock.sol b/test/0.8.25/invariant-fuzzing/mocks/LazyOracleMock.sol deleted file mode 100644 index 8a4096bbe8..0000000000 --- a/test/0.8.25/invariant-fuzzing/mocks/LazyOracleMock.sol +++ /dev/null @@ -1,276 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED - -pragma solidity 0.8.25; - -import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; -import {IHashConsensus} from "contracts/0.8.25/vaults/interfaces/IHashConsensus.sol"; - -import {SafeCast} from "@openzeppelin/contracts-v5.2/utils/math/SafeCast.sol"; - -//import {AccessControlEnumerableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; - -import {Math256} from "contracts/common/lib/Math256.sol"; - -import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; -import {OperatorGrid} from "contracts/0.8.25/vaults/OperatorGrid.sol"; -//import {StakingVault} from "contracts/0.8.25/vaults/StakingVault.sol"; - -import {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; - -contract LazyOracleMock { - struct Storage { - /// @notice root of the vaults data tree - bytes32 vaultsDataTreeRoot; - /// @notice CID of the vaults data tree - string vaultsDataReportCid; - /// @notice timestamp of the vaults data - uint64 vaultsDataTimestamp; - /// @notice total value increase quarantine period - uint64 quarantinePeriod; - /// @notice max reward ratio for refSlot-observed total value, basis points - uint16 maxRewardRatioBP; - /// @notice deposit quarantines for each vault - mapping(address vault => Quarantine) vaultQuarantines; - } - - struct Quarantine { - uint128 pendingTotalValueIncrease; - uint64 startTimestamp; - } - - struct VaultInfo { - address vault; - uint96 vaultIndex; - uint256 balance; - bytes32 withdrawalCredentials; - uint256 liabilityShares; - uint256 mintableStETH; - uint96 shareLimit; - uint16 reserveRatioBP; - uint16 forcedRebalanceThresholdBP; - uint16 infraFeeBP; - uint16 liquidityFeeBP; - uint16 reservationFeeBP; - bool pendingDisconnect; - } - - struct QuarantineInfo { - bool isActive; - uint256 pendingTotalValueIncrease; - uint256 startTimestamp; - uint256 endTimestamp; - } - - // keccak256(abi.encode(uint256(keccak256("LazyOracle")) - 1)) & ~bytes32(uint256(0xff)) - bytes32 private constant LAZY_ORACLE_STORAGE_LOCATION = - 0xe5459f2b48ec5df2407caac4ec464a5cb0f7f31a1f22f649728a9579b25c1d00; - - bytes32 public constant UPDATE_SANITY_PARAMS_ROLE = keccak256("UPDATE_SANITY_PARAMS_ROLE"); - - // total basis points = 100% - uint256 internal constant TOTAL_BP = 100_00; - - ILidoLocator public immutable LIDO_LOCATOR; - IHashConsensus public immutable HASH_CONSENSUS; - - /// @dev basis points base - uint256 private constant TOTAL_BASIS_POINTS = 100_00; - - constructor(address _lidoLocator, address _hashConsensus, uint64 _quarantinePeriod, uint16 _maxRewardRatioBP) { - LIDO_LOCATOR = ILidoLocator(payable(_lidoLocator)); - HASH_CONSENSUS = IHashConsensus(_hashConsensus); - _updateSanityParams(_quarantinePeriod, _maxRewardRatioBP); - } - - /// @notice returns the latest report timestamp - function latestReportTimestamp() external view returns (uint64) { - return _storage().vaultsDataTimestamp; - } - - /// @notice returns the quarantine period - function quarantinePeriod() external view returns (uint64) { - return _storage().quarantinePeriod; - } - - /// @notice returns the max reward ratio for refSlot total value, basis points - function maxRewardRatioBP() external view returns (uint16) { - return _storage().maxRewardRatioBP; - } - - /// @notice returns the quarantine info for the vault - /// @param _vault the address of the vault - // @dev returns zeroed structure if there is no active quarantine - function vaultQuarantine(address _vault) external view returns (QuarantineInfo memory) { - Quarantine storage q = _storage().vaultQuarantines[_vault]; - if (q.pendingTotalValueIncrease == 0) { - return QuarantineInfo(false, 0, 0, 0); - } - - return - QuarantineInfo( - true, - q.pendingTotalValueIncrease, - q.startTimestamp, - q.startTimestamp + _storage().quarantinePeriod - ); - } - - /// @notice update the sanity parameters - /// @param _quarantinePeriod the quarantine period - /// @param _maxRewardRatioBP the max EL CL rewards - function updateSanityParams(uint64 _quarantinePeriod, uint16 _maxRewardRatioBP) external { - _updateSanityParams(_quarantinePeriod, _maxRewardRatioBP); - } - - function setVaultDataTimestamp(uint64 _vaultsDataTimestamp) external { - Storage storage $ = _storage(); - $.vaultsDataTimestamp = uint64(_vaultsDataTimestamp); - } - - /// @notice Permissionless update of the vault data - /// @param _vault the address of the vault - /// @param _totalValue the total value of the vault - /// @param _cumulativeLidoFees the cumulative Lido fees accrued on the vault (nominated in ether) - /// @param _liabilityShares the liabilityShares of the vault - function updateVaultData( - address _vault, - uint256 _totalValue, - uint256 _cumulativeLidoFees, - uint256 _liabilityShares, - uint64 _vaultsDataTimestamp - ) external { - // bytes32 leaf = keccak256( - // bytes.concat(keccak256(abi.encode(_vault, _totalValue, _cumulativeLidoFees, _liabilityShares))) - // ); - //if (!MerkleProof.verify(_proof, _storage().vaultsDataTreeRoot, leaf)) revert InvalidProof(); - - int256 inOutDelta; - (_totalValue, inOutDelta) = _handleSanityChecks(_vault, _totalValue); - - _vaultHub().applyVaultReport( - _vault, - _vaultsDataTimestamp, - _totalValue, - inOutDelta, - _cumulativeLidoFees, - _liabilityShares - ); - } - - /// @notice handle sanity checks for the vault lazy report data - /// @param _vault the address of the vault - /// @param _totalValue the total value of the vault in refSlot - /// @return totalValue the smoothed total value of the vault after sanity checks - /// @return inOutDelta the inOutDelta in the refSlot - function _handleSanityChecks( - address _vault, - uint256 _totalValue - ) public returns (uint256 totalValue, int256 inOutDelta) { - VaultHub vaultHub = _vaultHub(); - VaultHub.VaultRecord memory record = vaultHub.vaultRecord(_vault); - - // 1. Calculate inOutDelta in the refSlot - int256 curInOutDelta = record.inOutDelta.value; - (uint256 refSlot, ) = HASH_CONSENSUS.getCurrentFrame(); - if (record.inOutDelta.refSlot == refSlot) { - inOutDelta = record.inOutDelta.refSlotValue; - } else { - inOutDelta = curInOutDelta; - } - - // 2. Sanity check for total value increase - totalValue = _processTotalValue(_vault, _totalValue, inOutDelta, record); - - // 3. Sanity check for dynamic total value underflow - if (int256(totalValue) + curInOutDelta - inOutDelta < 0) revert UnderflowInTotalValueCalculation(); - - return (totalValue, inOutDelta); - } - - function _processTotalValue( - address _vault, - uint256 _totalValue, - int256 _inOutDelta, - VaultHub.VaultRecord memory record - ) internal returns (uint256) { - Storage storage $ = _storage(); - - uint256 refSlotTotalValue = uint256( - int256(uint256(record.report.totalValue)) + _inOutDelta - record.report.inOutDelta - ); - // some percentage of funds hasn't passed through the vault's balance is allowed for the EL and CL rewards handling - uint256 limit = (refSlotTotalValue * (TOTAL_BP + $.maxRewardRatioBP)) / TOTAL_BP; - - if (_totalValue > limit) { - Quarantine storage q = $.vaultQuarantines[_vault]; - uint64 reportTs = $.vaultsDataTimestamp; - uint128 quarDelta = q.pendingTotalValueIncrease; - uint128 delta = SafeCast.toUint128(_totalValue - refSlotTotalValue); - - if (quarDelta == 0) { - // first overlimit report - _totalValue = refSlotTotalValue; - q.pendingTotalValueIncrease = delta; - q.startTimestamp = reportTs; - emit QuarantinedDeposit(_vault, delta); - } else if (reportTs - q.startTimestamp < $.quarantinePeriod) { - // quarantine not expired - _totalValue = refSlotTotalValue; - } else if (delta <= quarDelta + (refSlotTotalValue * $.maxRewardRatioBP) / TOTAL_BP) { - // quarantine expired - q.pendingTotalValueIncrease = 0; - emit QuarantineExpired(_vault, delta); - } else { - // start new quarantine - _totalValue = refSlotTotalValue + quarDelta; - q.pendingTotalValueIncrease = delta - quarDelta; - q.startTimestamp = reportTs; - emit QuarantinedDeposit(_vault, delta - quarDelta); - } - } - - return _totalValue; - } - - function _updateSanityParams(uint64 _quarantinePeriod, uint16 _maxRewardRatioBP) internal { - Storage storage $ = _storage(); - $.quarantinePeriod = _quarantinePeriod; - $.maxRewardRatioBP = _maxRewardRatioBP; - emit SanityParamsUpdated(_quarantinePeriod, _maxRewardRatioBP); - } - - function _mintableStETH(address _vault) internal view returns (uint256) { - VaultHub vaultHub = _vaultHub(); - uint256 maxLockableValue = vaultHub.maxLockableValue(_vault); - uint256 reserveRatioBP = vaultHub.vaultConnection(_vault).reserveRatioBP; - uint256 mintableStETHByRR = (maxLockableValue * (TOTAL_BASIS_POINTS - reserveRatioBP)) / TOTAL_BASIS_POINTS; - - uint256 effectiveShareLimit = _operatorGrid().effectiveShareLimit(_vault); - uint256 mintableStEthByShareLimit = ILido(LIDO_LOCATOR.lido()).getPooledEthBySharesRoundUp(effectiveShareLimit); - - return Math256.min(mintableStETHByRR, mintableStEthByShareLimit); - } - - function _storage() internal pure returns (Storage storage $) { - assembly { - $.slot := LAZY_ORACLE_STORAGE_LOCATION - } - } - - function _vaultHub() internal view returns (VaultHub) { - return VaultHub(payable(LIDO_LOCATOR.vaultHub())); - } - - function _operatorGrid() internal view returns (OperatorGrid) { - return OperatorGrid(LIDO_LOCATOR.operatorGrid()); - } - - event VaultsReportDataUpdated(uint256 indexed timestamp, bytes32 indexed root, string cid); - event QuarantinedDeposit(address indexed vault, uint128 delta); - event SanityParamsUpdated(uint64 quarantinePeriod, uint16 maxRewardRatioBP); - event QuarantineExpired(address indexed vault, uint128 delta); - error AdminCannotBeZero(); - error NotAuthorized(); - error InvalidProof(); - error UnderflowInTotalValueCalculation(); -} diff --git a/test/0.8.25/invariant-fuzzing/mocks/OperatorGridMock.sol b/test/0.8.25/invariant-fuzzing/mocks/OperatorGridMock.sol index c0653880a6..5825589a52 100644 --- a/test/0.8.25/invariant-fuzzing/mocks/OperatorGridMock.sol +++ b/test/0.8.25/invariant-fuzzing/mocks/OperatorGridMock.sol @@ -149,6 +149,47 @@ contract OperatorGridMock is AccessControlEnumerableUpgradeable { _disableInitializers(); } + /// @notice Get vault's tier limits + /// @param _vault address of the vault + /// @return nodeOperator node operator of the vault + /// @return tierId tier id of the vault + /// @return shareLimit share limit of the vault + /// @return reserveRatioBP reserve ratio of the vault + /// @return forcedRebalanceThresholdBP forced rebalance threshold of the vault + /// @return infraFeeBP infra fee of the vault + /// @return liquidityFeeBP liquidity fee of the vault + /// @return reservationFeeBP reservation fee of the vault + function vaultTierInfo( + address _vault + ) + external + view + returns ( + address nodeOperator, + uint256 tierId, + uint256 shareLimit, + uint256 reserveRatioBP, + uint256 forcedRebalanceThresholdBP, + uint256 infraFeeBP, + uint256 liquidityFeeBP, + uint256 reservationFeeBP + ) + { + ERC7201Storage storage $ = _getStorage(); + + tierId = $.vaultTier[_vault]; + + Tier memory t = $.tiers[tierId]; + nodeOperator = t.operator; + + shareLimit = t.shareLimit; + reserveRatioBP = t.reserveRatioBP; + forcedRebalanceThresholdBP = t.forcedRebalanceThresholdBP; + infraFeeBP = t.infraFeeBP; + liquidityFeeBP = t.liquidityFeeBP; + reservationFeeBP = t.reservationFeeBP; + } + /// @notice Initializes the contract with an admin /// @param _admin Address of the admin /// @param _defaultTierParams Default tier params for the default tier From 692a478c33e352e9c50bf29339de604b47902680 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 25 Nov 2025 17:59:27 +0000 Subject: [PATCH 15/16] test(fuzzing): updated invariants tests --- .../StakingVaultConstants.sol | 2 +- .../StakingVaultsFuzzing.t.sol | 146 ++++++----- .../StakingVaultsHandler.t.sol | 236 +++++++++--------- 3 files changed, 194 insertions(+), 190 deletions(-) diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol b/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol index 3e92f0411e..679a669b2e 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol @@ -6,7 +6,7 @@ library Constants { //OperatorGrid params //retrieved from default settings in deploy scripts uint256 public constant DEFAULT_TIER = 0; - uint256 public constant SHARE_LIMIT = 100; + uint256 public constant SHARE_LIMIT = 10 ether; uint256 public constant RESERVE_RATIO_BP = 2000; uint256 public constant FORCED_REBALANCE_THRESHOLD_BP = 1800; uint256 public constant INFRA_FEE_BP = 500; diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol index 14082888de..8820f3c812 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol @@ -190,47 +190,45 @@ contract StakingVaultsTest is Test { //First connect StakingVault to VaultHub svHandler.connectVault(); - // Configure fuzzing targets - bytes4[] memory svSelectors = new bytes4[](14); + //Configure fuzzing targets + bytes4[] memory svSelectors = new bytes4[](13); svSelectors[0] = svHandler.fund.selector; - svSelectors[1] = svHandler.VHwithdraw.selector; + svSelectors[1] = svHandler.withdraw.selector; svSelectors[2] = svHandler.forceRebalance.selector; svSelectors[3] = svHandler.forceValidatorExit.selector; svSelectors[4] = svHandler.mintShares.selector; svSelectors[5] = svHandler.burnShares.selector; svSelectors[6] = svHandler.transferAndBurnShares.selector; - svSelectors[7] = svHandler.voluntaryDisconnect.selector; - svSelectors[8] = svHandler.sv_otcDeposit.selector; - svSelectors[9] = svHandler.vh_otcDeposit.selector; - svSelectors[10] = svHandler.updateVaultData.selector; - svSelectors[11] = svHandler.SVwithdraw.selector; - svSelectors[12] = svHandler.connectVault.selector; - svSelectors[13] = svHandler.rebalance.selector; + svSelectors[7] = svHandler.rebalance.selector; + svSelectors[8] = svHandler.otcDepositToStakingVault.selector; + svSelectors[9] = svHandler.updateVaultData.selector; + svSelectors[10] = svHandler.withdrawFromStakingVault.selector; + svSelectors[11] = svHandler.connectVault.selector; + svSelectors[12] = svHandler.voluntaryDisconnect.selector; targetContract(address(svHandler)); targetSelector(FuzzSelector({addr: address(svHandler), selectors: svSelectors})); } - function test() public { - svHandler.fund(1 ether); - svHandler.updateVaultData(3); - svHandler.voluntaryDisconnect(); - } + // function test() public { + // svHandler.fund(1 ether); + // svHandler.updateVaultData(3); + // svHandler.voluntaryDisconnect(); + // } ////////// INVARIANTS ////////// - //With current deployed environment (no slashing, no stETH rebase) - //the staking Vault should never go below the rebalance threshold - //Meaning having less locked collateral than the threshold ratio limit (in regards to the liabilityShares converted in ETH) - //This is computed by rebalanceShortfall function - - // Invariant 1: Staking vault should never go below the rebalance threshold (collateral always covers liability). - function invariant1_liabilityShares_not_above_collateral() external { + /** + * Invariant 1: Staking Vault should never go below the rebalance threshold. + */ + function invariant1_liabilityShares_not_above_rebalance_threshold() external { uint256 rebalanceShares = vaultHubProxy.healthShortfallShares(address(stakingVaultProxy)); assertEq(rebalanceShares, 0, "Staking Vault should never go below the rebalance threshold"); } - // Invariant 2: Dynamic total value (including deltas) should never underflow (must be >= 0). + /** + * Invariant 2: Dynamic total value (including deltas) should never underflow (must be >= 0). + */ function invariant2_dynamic_totalValue_should_not_underflow() external { VaultHub.VaultRecord memory record = vaultHubProxy.vaultRecord(address(stakingVaultProxy)); assertGe( @@ -242,7 +240,9 @@ contract StakingVaultsTest is Test { ); } - // Invariant 3: forceRebalance should not revert when the vault has available balance and obligations. + /** + * Invariant 3: forceRebalance should not revert when the vault has available balance and obligations. + */ function invariant3_forceRebalance_should_not_revert_when_has_available_balance_and_obligations() external { bool forceRebalanceReverted = svHandler.didForceRebalanceReverted(); assertFalse( @@ -251,13 +251,17 @@ contract StakingVaultsTest is Test { ); } - // Invariant 4: forceValidatorExit should not revert when has obligations shortfall. + /** + * Invariant 4: forceValidatorExit should not revert when has obligations shortfall. + */ function invariant4_forceValidatorExit_should_not_revert_when_has_obligations_shortfall() external { bool forceValidatorExitReverted = svHandler.didForceValidatorExitReverted(); assertFalse(forceValidatorExitReverted, "forceValidatorExit should not revert when has obligations shortfall"); } - // Invariant 5: Applied total value should not be greater than reported total value. + /** + * Invariant 5: Applied total value should not be greater than reported total value. + */ function invariant5_applied_tv_should_not_be_greater_than_reported_tv() external { uint256 appliedTotalValue = svHandler.getAppliedTotalValue(); uint256 reportedTotalValue = svHandler.getReportedTotalValue(); @@ -269,8 +273,9 @@ contract StakingVaultsTest is Test { ); } - // внимательно следить при каких операциях он держится (update connection, socilized bad dept) - // Invariant 6: Liability shares should never be greater than connection share limit. + /** + * Invariant 6: Liability shares should never be greater than connection share limit. + */ function invariant6_liabilityshares_should_never_be_greater_than_connection_sharelimit() external { //Get the share limit from the vault uint256 liabilityShares = vaultHubProxy.liabilityShares(address(stakingVaultProxy)); @@ -295,13 +300,14 @@ contract StakingVaultsTest is Test { _; } - // Invariant 7: Locked amount must be >= max(connect deposit, slashing reserve, reserve ratio). + /** + * Invariant 7: Locked amount must be >= max(connect deposit, slashing reserve, reserve ratio). + */ function invariant7_locked_cannot_be_less_than_slashing_connected_reserve() external vaultMustBeConnected vaultNotPendingDisconnect { - //slashing reserve VaultHub.VaultRecord memory record = vaultHubProxy.vaultRecord(address(stakingVaultProxy)); VaultHub.VaultConnection memory connection = vaultHubProxy.vaultConnection(address(stakingVaultProxy)); uint256 forcedRebalanceThresholdBP = connection.forcedRebalanceThresholdBP; @@ -319,37 +325,14 @@ contract StakingVaultsTest is Test { ); } - // // function invariant_totalValue_should_be_greater_than_locked() vaultMustBeConnected vaultNotPendingDisconnect external { - // // //Get the total value of the vault - // // uint256 totalValue = vaultHubProxy.totalValue(address(stakingVaultProxy)); - // // if (totalValue == 0) { - // // // If totalValue is 0, we cannot check the invariant - // // //That's probably because the vault has just been created and no report has not been applied yet - // // return; - // // } - - // // //Get the locked amount - // // VaultHub.VaultRecord memory record = vaultHubProxy.vaultRecord(address(stakingVaultProxy)); - // // uint128 lockedAmount = record.locked; - - // // //VaultHub.VaultObligations memory vaultObligations = vaultHubProxy.vaultObligations(address(stakingVaultProxy)); - // // //uint256 unsettledObligations = vaultObligations.unsettledLidoFees + vaultObligations.redemptions; - - // // //Check that total value is greater than or equal to locked amount and unsettled obligations - // // assertGe(totalValue, lockedAmount , "Total value should be greater than or equal to locked amount"); - // // } - - // Invariant 8: Withdrawable value must be <= total value minus locked amount and unsettled obligations. + /** + * Invariant 8: Withdrawable value must be <= total value minus locked amount and unsettled obligations. + */ function invariant8_withdrawableValue_should_be_less_than_or_equal_to_totalValue_minus_locked_and_obligations() external { - //Get the withdrawable value of the vault uint256 withdrawableValue = vaultHubProxy.withdrawableValue(address(stakingVaultProxy)); - - //Get the total value of the vault uint256 totalValue = vaultHubProxy.totalValue(address(stakingVaultProxy)); - - //Get the locked amount and unsettled obligations uint256 lockedAmount = vaultHubProxy.locked(address(stakingVaultProxy)); (uint256 obligationsShares, uint256 obligationsFees) = vaultHubProxy.obligations(address(stakingVaultProxy)); @@ -365,26 +348,37 @@ contract StakingVaultsTest is Test { ); } - //The totalValue should be equal or above the real totalValue (EL+CL balance) - //totalValue = report.totalValue + current ioDelta - reported ioDelta - //This invariant catches the crit vulnerability that exploits - //- replay of same report - //- uncleared quarantine upon disconnect - //call path is pretty long but is: - //1. connectVault - //2. sv_otcDeposit - //3. updateVaultData -> triggers quarantine - //4. initializeDisconnect - //5. updateVaultData -> finalize disconnection - //6. connectVault - //7. updateVaultData -> generate a fresh report with TV - //8. SVwithdraw - //9. connectVault - //10. updateVaultData -> reuses previous report; quarantine is expired; TV is kept as is (special branch if the new quarantine delta is lower than the expired one). - // Invariant 9: Computed totalValue must be <= effective (real) total value. - // function invariant9_computed_totalValue_must_be_less_than_or_equal_to_effective_total_value() external { - // assertLe(svHandler.getVaultTotalValue(), svHandler.getEffectiveVaultTotalValue()); - // } + /** + * Invariant 9: The totalValue should be equal or above the real totalValue (EL+CL balance) + */ + function invariant9_totalValue_should_be_less_than_or_equal_to_effective_total_value() external { + uint256 totalValue = svHandler.getVaultTotalValue(); + uint256 effectiveTotalValue = svHandler.getEffectiveVaultTotalValue(); + assertLe(totalValue, effectiveTotalValue, "Total value should be less than or equal to effective total value"); + } + + /** + * Invariant 10: Total value should be greater than or equal to locked amount. + */ + function invariant10_totalValue_should_be_greater_than_or_equal_to_locked_amount() + external + vaultMustBeConnected + vaultNotPendingDisconnect + { + //Get the total value of the vault + uint256 totalValue = vaultHubProxy.totalValue(address(stakingVaultProxy)); + if (totalValue == 0) { + // If totalValue is 0, we cannot check the invariant + //That's probably because the vault has just been created and no report has not been applied yet + return; + } + + //Get the locked amount + uint256 lockedAmount = vaultHubProxy.locked(address(stakingVaultProxy)); + + //Check that total value is greater than or equal to locked amount and unsettled obligations + assertGe(totalValue, lockedAmount, "Total value should be greater than or equal to locked amount"); + } // For testing purposes only (guiding the fuzzing) // function invariant_state() external { diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol index 547797319d..d22d8b4388 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol @@ -18,6 +18,8 @@ import {LazyOracle} from "contracts/0.8.25/vaults/LazyOracle.sol"; import {Constants} from "./StakingVaultConstants.sol"; import {LidoLocatorMock, ConsensusContractMock} from "./mocks/CommonMocks.sol"; +import {console2} from "forge-std/console2.sol"; + /// @title StakingVaultsHandler /// @notice Handler contract for invariant fuzzing of a single staking vault in the Lido protocol. /// @dev Used by fuzzing contracts to simulate user and protocol actions, track state, and expose relevant variables for invariant checks. @@ -38,6 +40,7 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions uint256 totalValue; uint256 cumulativeLidoFees; uint256 liabilityShares; + uint256 maxLiabilityShares; uint64 reportTimestamp; } @@ -45,13 +48,10 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions address public userAccount; address public rootAccount; - uint256 public cl_balance = 0; // Amount deposited on beacon chain - uint256 constant MIN_SHARES = 1; uint256 constant MAX_SHARES = 1000; uint256 public sv_otcDeposited = 0; - uint256 public vh_otcDeposited = 0; bool public forceRebalanceReverted = false; bool public forceValidatorExitReverted = false; @@ -108,6 +108,23 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions _; } + modifier withFreshReport() { + _updateVaultData(); + _; + } + + modifier withConnectedVault() { + bool isConnected = vaultHub.isVaultConnected(address(stakingVault)); + bool isPendingDisconnect = vaultHub.isPendingDisconnect(address(stakingVault)); + if (!isConnected || isPendingDisconnect) return; + _; + } + + modifier withDisconnectedVault() { + if (vaultHub.vaultConnection(address(stakingVault)).vaultIndex != 0) return; + _; + } + // --- Getters for invariant checks --- function getAppliedTotalValue() public view returns (uint256) { @@ -128,17 +145,13 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions // --- VaultHub interactions --- /// @notice Connects the vault to the VaultHub, funding if needed - function connectVault() public { - //check if the vault is already connected - if (vaultHub.vaultConnection(address(stakingVault)).vaultIndex != 0) { - return; - } - // TODO: PDG.pendingActivations + function connectVault() public withDisconnectedVault { if (stakingVault.availableBalance() < Constants.CONNECT_DEPOSIT) { deal(address(userAccount), Constants.CONNECT_DEPOSIT); vm.prank(userAccount); stakingVault.fund{value: Constants.CONNECT_DEPOSIT}(); } + vm.prank(userAccount); stakingVault.transferOwnership(address(vaultHub)); vm.prank(userAccount); @@ -146,12 +159,10 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions } /// @notice Initiates voluntary disconnect for the vault - function voluntaryDisconnect() public { - //do nothing if disconnected or already disconnecting - if (!vaultHub.isVaultConnected(address(stakingVault)) || vaultHub.isPendingDisconnect(address(stakingVault))) - return; + function voluntaryDisconnect(uint256 shouldDisconnect) public withConnectedVault withFreshReport { + shouldDisconnect = bound(shouldDisconnect, 0, 10); + if (shouldDisconnect < 10) return; // 10% chance to disconnect - //decrease liabilities uint256 shares = vaultHub.liabilityShares(address(stakingVault)); if (shares != 0) { vaultHub.burnShares(address(stakingVault), shares); @@ -162,47 +173,39 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions } /// @notice Funds the vault via VaultHub - function fund(uint256 amount) public { + function fund(uint256 amount) public withConnectedVault { amount = bound(amount, 1, 1 ether); deal(address(userAccount), amount); + vm.prank(userAccount); vaultHub.fund{value: amount}(address(stakingVault)); } /// @notice Withdraws from the vault via VaultHub - function VHwithdraw(uint256 amount) public { - amount = bound(amount, 1, vaultHub.withdrawableValue(address(stakingVault))); + function withdraw(uint256 amount) public withConnectedVault withFreshReport { + uint256 withdrawableValue = vaultHub.withdrawableValue(address(stakingVault)); + if (withdrawableValue == 0) return; + + amount = bound(amount, 1, withdrawableValue); - //check that stakingVault is connected - if (!vaultHub.isVaultConnected(address(stakingVault))) { - return; - } vm.prank(userAccount); vaultHub.withdraw(address(stakingVault), userAccount, amount); } /// @notice Forces a rebalance if the vault has available balance and obligations - function forceRebalance() public { - // if (vaultHub.isVaultHealthy(address(stakingVault))) { - // return; - // } - + function forceRebalance() public withConnectedVault withFreshReport { uint256 availableBalance = Math256.min( stakingVault.availableBalance(), vaultHub.totalValue(address(stakingVault)) ); - if (availableBalance == 0) { - return; - } + if (availableBalance == 0) return; (uint256 obligationsShares, ) = vaultHub.obligations(address(stakingVault)); uint256 sharesToForceRebalance = Math256.min( obligationsShares, lidoContract.getSharesByPooledEth(availableBalance) ); - if (sharesToForceRebalance == 0) { - return; - } + if (sharesToForceRebalance == 0) return; vm.prank(userAccount); try vaultHub.forceRebalance(address(stakingVault)) {} catch { @@ -211,11 +214,9 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions } /// @notice Forces validator exit if vault has obligations shortfall - function forceValidatorExit() public { + function forceValidatorExit() public withConnectedVault withFreshReport { uint256 obligationsShortfallValue = vaultHub.obligationsShortfallValue(address(stakingVault)); - if (obligationsShortfallValue == 0) { - return; - } + if (obligationsShortfallValue == 0) return; bytes memory pubkeys = new bytes(0); vm.prank(rootAccount); //privileged account can force exit @@ -227,52 +228,55 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions } /// @notice Mints shares for the vault - function mintShares(uint256 shares) public { - shares = bound(shares, MIN_SHARES, MAX_SHARES); + function mintShares(uint256 shares) public withConnectedVault withFreshReport { + uint256 maxLiabilityShares = vaultHub.totalMintingCapacityShares(address(stakingVault), 0); + uint256 currShares = vaultHub.liabilityShares(address(stakingVault)); + uint256 sharesToMint = Math256.min(maxLiabilityShares - currShares, 0); + if (sharesToMint == 0) return; + + shares = bound(shares, MIN_SHARES, maxLiabilityShares); + vm.prank(userAccount); vaultHub.mintShares(address(stakingVault), userAccount, shares); } /// @notice Burns shares from the vault - function burnShares(uint256 shares) public { + function burnShares(uint256 shares) public withConnectedVault { shares = bound(shares, MIN_SHARES, MAX_SHARES); uint256 currShares = vaultHub.liabilityShares(address(stakingVault)); uint256 sharesToBurn = Math256.min(currShares, shares); - if (sharesToBurn == 0) { - return; - } + if (sharesToBurn == 0) return; + vm.prank(userAccount); vaultHub.burnShares(address(stakingVault), sharesToBurn); } /// @notice Transfers and burns shares from the vault - function transferAndBurnShares(uint256 shares) public { + function transferAndBurnShares(uint256 shares) public withConnectedVault { shares = bound(shares, MIN_SHARES, MAX_SHARES); uint256 currShares = vaultHub.liabilityShares(address(stakingVault)); uint256 sharesToBurn = Math256.min(currShares, shares); - if (sharesToBurn == 0) { - return; - } + if (sharesToBurn == 0) return; + vm.prank(userAccount); vaultHub.transferAndBurnShares(address(stakingVault), shares); } /// @notice Calls rebalance on the staking vault (via VaultHub) - function rebalance(uint256 amount) public { + function rebalance(uint256 amount) public withConnectedVault withFreshReport { VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVault)); - if (!vaultHub.isVaultConnected(address(stakingVault)) || vaultHub.isPendingDisconnect(address(stakingVault))) - return; uint256 totalValue = vaultHub.totalValue(address(stakingVault)); - amount = bound(amount, 1, totalValue); + uint256 sharesToRebalance = vaultHub.healthShortfallShares(address(stakingVault)); + if (sharesToRebalance == 0) return; vm.prank(userAccount); - vaultHub.rebalance(address(stakingVault), amount); + vaultHub.rebalance(address(stakingVault), sharesToRebalance); } /// @notice Returns the effective total value of the vault (EL + CL balance) function getEffectiveVaultTotalValue() public view returns (uint256) { - return address(stakingVault).balance + cl_balance; + return address(stakingVault).balance; } /// @notice Returns the reported total value of the vault @@ -281,79 +285,58 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions } /// @notice Simulates OTC deposit to the staking vault - function sv_otcDeposit(uint256 amount) public { + function otcDepositToStakingVault(uint256 amount) public { amount = bound(amount, 1 ether, 10 ether); sv_otcDeposited += amount; deal(address(stakingVault), address(stakingVault).balance + amount); } - /// @notice Simulates OTC deposit to the VaultHub - function vh_otcDeposit(uint256 amount) public { - amount = bound(amount, 1 ether, 10 ether); - vh_otcDeposited += amount; - deal(address(vaultHub), address(vaultHub).balance + amount); - } - // --- LazyOracle interactions --- /// @notice Updates vault data, simulating time shifts and quarantine logic - function updateVaultData(uint256 daysShift) public { - daysShift = bound(daysShift, 0, 1); - daysShift *= 3; // 0 or 3 days for quarantine period expiration + function updateVaultData() public withConnectedVault { + _updateVaultData(); + } + + // --- StakingVault interactions --- - //Check if vault is connected before proceeding - if (!vaultHub.isVaultConnected(address(stakingVault))) { + /// @notice Withdraws directly from the staking vault (when not managed by VaultHub) + function withdrawFromStakingVault(uint256 amount) public { + if (stakingVault.owner() != userAccount) { return; } + amount = bound(amount, 1, address(stakingVault).balance); - if (daysShift > 0) { - vm.warp(block.timestamp + daysShift * 1 days); - (uint256 refSlot1, ) = consensusContract.getCurrentFrame(); - vm.prank(accountingOracle); - lazyOracle.updateReportData(uint256(block.timestamp), refSlot1, bytes32(0), "test"); - - VaultHub.VaultRecord memory vaultRecord = vaultHub.vaultRecord(address(stakingVault)); + vm.prank(userAccount); + stakingVault.withdraw(userAccount, amount); + } - lastReport = VaultReport({ - totalValue: stakingVault.availableBalance() + cl_balance + sv_otcDeposited, //*/ vaultHub.totalValue(address(stakingVault)),// + sv_otcDeposited + cl_balance, - cumulativeLidoFees: vaultRecord.cumulativeLidoFees + vaultRecord.settledLidoFees + 1, - liabilityShares: vaultHub.liabilityShares(address(stakingVault)), - reportTimestamp: uint64(block.timestamp) - }); + function _updateVaultData() internal { + address _vault = address(stakingVault); - //reset otc deposit value - sv_otcDeposited = 0; - } + // prepare the next report + VaultHub.VaultRecord memory vaultRecord = vaultHub.vaultRecord(_vault); + VaultReport memory previousReport = lastReport; - // Simulate next ref slot - (uint256 refSlot, ) = consensusContract.getCurrentFrame(); - if (daysShift > 0) { - refSlot += daysShift; - consensusContract.setCurrentFrame(refSlot); - } - // If no new report since vault connection, skip - if (lastReport.totalValue == 0 && lastReport.cumulativeLidoFees == 0) return; + lastReport = VaultReport({ + totalValue: vaultHub.totalValue(_vault) + sv_otcDeposited, + cumulativeLidoFees: vaultRecord.cumulativeLidoFees + vaultRecord.settledLidoFees + 1, + liabilityShares: vaultHub.liabilityShares(_vault), + maxLiabilityShares: vaultRecord.maxLiabilityShares, + reportTimestamp: uint64(block.timestamp) + }); - //we update the reported total Value reportedTotalValue = lastReport.totalValue; - uint256 maxLiabilityShares = vaultHub.vaultRecord(address(stakingVault)).maxLiabilityShares; + _report(2); // bypassing fresh report + _report(3); // bypassing quarantine period expiration - //update the vault data - lazyOracle.updateVaultData( - address(stakingVault), - lastReport.totalValue, - lastReport.cumulativeLidoFees, - lastReport.liabilityShares, - maxLiabilityShares, - 0, - new bytes32[](0) - ); + // we update the applied total value (TV should go through sanity checks, quarantine, etc.) + appliedTotalValue = vaultHub.vaultRecord(_vault).report.totalValue; - bool isReportFresh = vaultHub.isReportFresh(address(stakingVault)); + //reset otc deposit value + sv_otcDeposited = 0; - //we update the applied total value (TV should go through sanity checks, quarantine, etc.) - appliedTotalValue = vaultHub.vaultRecord(address(stakingVault)).report.totalValue; // Accept ownership if disconnect was successful if (stakingVault.pendingOwner() == userAccount) { vm.prank(userAccount); @@ -361,16 +344,43 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions } } - // --- StakingVault interactions --- + function _report(uint256 daysShift) internal { + address _vault = address(stakingVault); + + // create the leaf for the new report + bytes32 leaf = keccak256( + bytes.concat( + keccak256( + abi.encode( + _vault, + lastReport.totalValue, + lastReport.cumulativeLidoFees, + lastReport.liabilityShares, + lastReport.maxLiabilityShares, + 0 // slashingReserve + ) + ) + ) + ); - /// @notice Withdraws directly from the staking vault (when not managed by VaultHub) - function SVwithdraw(uint256 amount) public { - if (stakingVault.owner() != userAccount) { - return; - } - amount = bound(amount, 1, address(stakingVault).balance); + (uint256 refSlot1, ) = consensusContract.getCurrentFrame(); + uint256 nextRefSlot = refSlot1 + daysShift; + consensusContract.setCurrentFrame(nextRefSlot); - vm.prank(userAccount); - stakingVault.withdraw(userAccount, amount); + vm.warp(block.timestamp + daysShift * 1 days); + + vm.prank(accountingOracle); + lazyOracle.updateReportData(uint256(block.timestamp), nextRefSlot, leaf, "test"); + + // update the vault data after bypassing the quarantine period expiration + lazyOracle.updateVaultData( + _vault, + lastReport.totalValue, + lastReport.cumulativeLidoFees, + lastReport.liabilityShares, + lastReport.maxLiabilityShares, + 0, + new bytes32[](0) // empty proof, as we are not updating the report data + ); } } From a6277a1ec05fbe4cb164d4ace5b9f694de7de8c4 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 25 Nov 2025 18:21:01 +0000 Subject: [PATCH 16/16] chore: improve coverage --- .github/workflows/tests-unit.yml | 2 +- .../StakingVaultsFuzzing.t.sol | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests-unit.yml b/.github/workflows/tests-unit.yml index 471024d4e5..f132b4bd07 100644 --- a/.github/workflows/tests-unit.yml +++ b/.github/workflows/tests-unit.yml @@ -37,4 +37,4 @@ jobs: run: forge --version - name: Run fuzzing and invariant tests - run: forge test -vvv + run: forge test diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol index 8820f3c812..ee7d74b13f 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol @@ -220,6 +220,11 @@ contract StakingVaultsTest is Test { /** * Invariant 1: Staking Vault should never go below the rebalance threshold. + * + * https://book.getfoundry.sh/reference/config/inline-test-config#in-line-invariant-configs + * forge-config: default.invariant.runs = 256 + * forge-config: default.invariant.depth = 256 + * forge-config: default.invariant.fail-on-revert = true */ function invariant1_liabilityShares_not_above_rebalance_threshold() external { uint256 rebalanceShares = vaultHubProxy.healthShortfallShares(address(stakingVaultProxy)); @@ -228,6 +233,11 @@ contract StakingVaultsTest is Test { /** * Invariant 2: Dynamic total value (including deltas) should never underflow (must be >= 0). + * + * https://book.getfoundry.sh/reference/config/inline-test-config#in-line-invariant-configs + * forge-config: default.invariant.runs = 256 + * forge-config: default.invariant.depth = 256 + * forge-config: default.invariant.fail-on-revert = true */ function invariant2_dynamic_totalValue_should_not_underflow() external { VaultHub.VaultRecord memory record = vaultHubProxy.vaultRecord(address(stakingVaultProxy)); @@ -242,6 +252,11 @@ contract StakingVaultsTest is Test { /** * Invariant 3: forceRebalance should not revert when the vault has available balance and obligations. + * + * https://book.getfoundry.sh/reference/config/inline-test-config#in-line-invariant-configs + * forge-config: default.invariant.runs = 256 + * forge-config: default.invariant.depth = 256 + * forge-config: default.invariant.fail-on-revert = true */ function invariant3_forceRebalance_should_not_revert_when_has_available_balance_and_obligations() external { bool forceRebalanceReverted = svHandler.didForceRebalanceReverted(); @@ -253,6 +268,11 @@ contract StakingVaultsTest is Test { /** * Invariant 4: forceValidatorExit should not revert when has obligations shortfall. + * + * https://book.getfoundry.sh/reference/config/inline-test-config#in-line-invariant-configs + * forge-config: default.invariant.runs = 256 + * forge-config: default.invariant.depth = 256 + * forge-config: default.invariant.fail-on-revert = true */ function invariant4_forceValidatorExit_should_not_revert_when_has_obligations_shortfall() external { bool forceValidatorExitReverted = svHandler.didForceValidatorExitReverted(); @@ -261,6 +281,11 @@ contract StakingVaultsTest is Test { /** * Invariant 5: Applied total value should not be greater than reported total value. + * + * https://book.getfoundry.sh/reference/config/inline-test-config#in-line-invariant-configs + * forge-config: default.invariant.runs = 256 + * forge-config: default.invariant.depth = 256 + * forge-config: default.invariant.fail-on-revert = true */ function invariant5_applied_tv_should_not_be_greater_than_reported_tv() external { uint256 appliedTotalValue = svHandler.getAppliedTotalValue(); @@ -275,6 +300,11 @@ contract StakingVaultsTest is Test { /** * Invariant 6: Liability shares should never be greater than connection share limit. + * + * https://book.getfoundry.sh/reference/config/inline-test-config#in-line-invariant-configs + * forge-config: default.invariant.runs = 256 + * forge-config: default.invariant.depth = 256 + * forge-config: default.invariant.fail-on-revert = true */ function invariant6_liabilityshares_should_never_be_greater_than_connection_sharelimit() external { //Get the share limit from the vault @@ -302,6 +332,11 @@ contract StakingVaultsTest is Test { /** * Invariant 7: Locked amount must be >= max(connect deposit, slashing reserve, reserve ratio). + * + * https://book.getfoundry.sh/reference/config/inline-test-config#in-line-invariant-configs + * forge-config: default.invariant.runs = 256 + * forge-config: default.invariant.depth = 256 + * forge-config: default.invariant.fail-on-revert = true */ function invariant7_locked_cannot_be_less_than_slashing_connected_reserve() external @@ -327,6 +362,11 @@ contract StakingVaultsTest is Test { /** * Invariant 8: Withdrawable value must be <= total value minus locked amount and unsettled obligations. + * + * https://book.getfoundry.sh/reference/config/inline-test-config#in-line-invariant-configs + * forge-config: default.invariant.runs = 256 + * forge-config: default.invariant.depth = 256 + * forge-config: default.invariant.fail-on-revert = true */ function invariant8_withdrawableValue_should_be_less_than_or_equal_to_totalValue_minus_locked_and_obligations() external @@ -350,6 +390,11 @@ contract StakingVaultsTest is Test { /** * Invariant 9: The totalValue should be equal or above the real totalValue (EL+CL balance) + * + * https://book.getfoundry.sh/reference/config/inline-test-config#in-line-invariant-configs + * forge-config: default.invariant.runs = 256 + * forge-config: default.invariant.depth = 256 + * forge-config: default.invariant.fail-on-revert = true */ function invariant9_totalValue_should_be_less_than_or_equal_to_effective_total_value() external { uint256 totalValue = svHandler.getVaultTotalValue(); @@ -359,6 +404,11 @@ contract StakingVaultsTest is Test { /** * Invariant 10: Total value should be greater than or equal to locked amount. + * + * https://book.getfoundry.sh/reference/config/inline-test-config#in-line-invariant-configs + * forge-config: default.invariant.runs = 256 + * forge-config: default.invariant.depth = 256 + * forge-config: default.invariant.fail-on-revert = true */ function invariant10_totalValue_should_be_greater_than_or_equal_to_locked_amount() external