From f9a0a71892db43e889e20b889c87d75fc6056245 Mon Sep 17 00:00:00 2001 From: fsle <148141389+fsle@users.noreply.github.com> Date: Tue, 5 Aug 2025 22:41:16 +0200 Subject: [PATCH 01/14] invariant fuzzing; catches the crit about uncleared quarantine upon disconnection + report replay --- test/0.8.25/invariant-fuzzing/CommonMocks.sol | 461 +++++++++++++++ .../StakingVaultConstants.sol | 28 + .../StakingVaultsFuzzing.t.sol | 229 ++++++++ .../StakingVaultsHandler.t.sol | 275 +++++++++ test/0.8.25/invariant-fuzzing/output.txt | 544 ++++++++++++++++++ 5 files changed, 1537 insertions(+) create mode 100644 test/0.8.25/invariant-fuzzing/CommonMocks.sol 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/output.txt diff --git a/test/0.8.25/invariant-fuzzing/CommonMocks.sol b/test/0.8.25/invariant-fuzzing/CommonMocks.sol new file mode 100644 index 0000000000..16a7a7f21a --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/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/StakingVaultConstants.sol b/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol new file mode 100644 index 0000000000..46b0da284d --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol @@ -0,0 +1,28 @@ +// 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; + + //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..1edc382c0e --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol @@ -0,0 +1,229 @@ +// 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 "./CommonMocks.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); + + //First connect StakingVault to VaultHub + svHandler.connectVault(); + + // Configure fuzzing targets + bytes4[] memory svSelectors = new bytes4[](11); + svSelectors[0] = svHandler.fund.selector; + svSelectors[1] = svHandler.VHwithdraw.selector; + svSelectors[2] = svHandler.rebalance.selector; + svSelectors[3] = svHandler.mintShares.selector; + svSelectors[4] = svHandler.burnShares.selector; + svSelectors[5] = svHandler.transferAndBurnShares.selector; + svSelectors[6] = svHandler.voluntaryDisconnect.selector; + svSelectors[7] = svHandler.otcDeposit.selector; + svSelectors[8] = svHandler.updateVaultData.selector; + svSelectors[9] = svHandler.SVwithdraw.selector; + svSelectors[10] = svHandler.connectVault.selector; + + targetContract(address(svHandler)); + targetSelector(FuzzSelector({addr: address(svHandler), selectors: svSelectors})); + } + + ////////// INVARIANTS ////////// + + /* + Ideas of invariants to implement: + - locked amount should always above reserve ratio in regards to the liabilityShares of the SV + //liabilityShare should not be upper to collateral + // function invariant_liabilityShares_not_above_collateral() external { + // assertGt(svHandler.getVaultTotalValue(),svHandler.getEffectiveVaultTotalValue()); + // } + + */ + + //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. 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..53a31cdc3f --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol @@ -0,0 +1,275 @@ +// 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 {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 {LazyOracleMock, LidoLocatorMock, ConsensusContractMock} from "./CommonMocks.sol"; + +import {Constants} from "./StakingVaultConstants.sol"; +import "forge-std/console2.sol"; + +/** +TODO: + - function triggerValidatorWithdrawals() + - PDG funcs + - proveUnknownValidatorToPDG + - compensateDisprovenPredepositFromPDG +**/ + +contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils { + // 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 otcDeposited = 0; + + enum VaultAction { + CONNECT, + VOLUNTARY_DISCONNECT, + UPDATE_VAULT_DATA, + 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.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"); + } + _; + } + + ////////// 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 rebalance(uint256 amount) public { + amount = bound(amount, 1, address(stakingVault).balance); + vm.prank(userAccount); + vaultHub.rebalance(address(stakingVault), amount); + } + + 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 otcDeposit(uint256 amount) public actionIndexUpdate(VaultAction.OTC_DEPOSIT) { + amount = bound(amount, 1 ether, 10 ether); + otcDeposited += amount; + deal(address(address(stakingVault)), 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)) + otcDeposited, + cumulativeLidoFees: obligations.settledLidoFees + obligations.unsettledLidoFees + 1, + liabilityShares: vaultHub.liabilityShares(address(stakingVault)), + reportTimestamp: uint64(block.timestamp) + }); + + //reset otc deposit value + 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); + } + + //update the vault data + lazyOracle.updateVaultData( + address(stakingVault), + lastReport.totalValue, + lastReport.cumulativeLidoFees, + lastReport.liabilityShares, + uint64(block.timestamp) + ); + + //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/output.txt b/test/0.8.25/invariant-fuzzing/output.txt new file mode 100644 index 0000000000..b336c2976b --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/output.txt @@ -0,0 +1,544 @@ +forge test -vvv test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol + +Logs: + Bound result 9000000000000002508 + Bound result 1 + DaysShift = 3 + Bound result 0 + DaysShift = 0 + Bound result 1 + DaysShift = 3 + Bound result 3842411754778025856 + Bound result 0 + DaysShift = 0 + +Traces: + [43017] StakingVaultsHandler::otcDeposit(2507) + ├─ [0] console::log("Bound result", 9000000000000002508 [9e18]) [staticcall] + │ └─ ← [Stop] + ├─ [0] VM::deal(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 9000000000000002508 [9e18]) + │ └─ ← [Return] + └─ ← [Stop] + + [304981] StakingVaultsHandler::updateVaultData(75637045475 [7.563e10]) + ├─ [0] console::log("Bound result", 1) [staticcall] + │ └─ ← [Stop] + ├─ [0] console::log("DaysShift = %d", 3) [staticcall] + │ └─ ← [Stop] + ├─ [0] VM::warp(259201 [2.592e5]) + │ └─ ← [Return] + ├─ [5591] lazyoracle::setVaultDataTimestamp(259201 [2.592e5]) + │ └─ ← [Stop] + ├─ [10904] vaultHub::vaultObligations(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ ├─ [5921] VaultHub::vaultObligations(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ └─ ← [Return] VaultObligations({ settledLidoFees: 0, unsettledLidoFees: 0, redemptions: 0 }) + │ └─ ← [Return] VaultObligations({ settledLidoFees: 0, unsettledLidoFees: 0, redemptions: 0 }) + ├─ [6472] vaultHub::totalValue(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ ├─ [5995] VaultHub::totalValue(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ └─ ← [Return] 1000000000000000000 [1e18] + │ └─ ← [Return] 1000000000000000000 [1e18] + ├─ [3619] vaultHub::liabilityShares(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ ├─ [3142] VaultHub::liabilityShares(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ └─ ← [Return] 0 + │ └─ ← [Return] 0 + ├─ [4688] consensusContract::getCurrentFrame() [staticcall] + │ └─ ← [Return] 1, 0 + ├─ [3337] consensusContract::setCurrentFrame(4) + │ └─ ← [Stop] + ├─ [147549] lazyoracle::updateVaultData(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 10000000000000002508 [1e19], 1, 0, 259201 [2.592e5]) + │ ├─ [2538] lidoLocator::vaultHub() [staticcall] + │ │ └─ ← [Return] vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D] + │ ├─ [6458] vaultHub::vaultRecord(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ │ ├─ [5945] VaultHub::vaultRecord(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ │ └─ ← [Return] VaultRecord({ report: Report({ totalValue: 1000000000000000000 [1e18], inOutDelta: 1000000000000000000 [1e18] }), locked: 1000000000000000000 [1e18], liabilityShares: 0, inOutDelta: Int112WithRefSlotCache({ value: 1000000000000000000 [1e18], refSlotValue: 0, refSlot: 0 }), reportTimestamp: 0 }) + │ │ └─ ← [Return] VaultRecord({ report: Report({ totalValue: 1000000000000000000 [1e18], inOutDelta: 1000000000000000000 [1e18] }), locked: 1000000000000000000 [1e18], liabilityShares: 0, inOutDelta: Int112WithRefSlotCache({ value: 1000000000000000000 [1e18], refSlotValue: 0, refSlot: 0 }), reportTimestamp: 0 }) + │ ├─ [688] consensusContract::getCurrentFrame() [staticcall] + │ │ └─ ← [Return] 4, 0 + │ ├─ emit QuarantinedDeposit(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], delta: 9000000000000002508 [9e18]) + │ ├─ [538] lidoLocator::vaultHub() [staticcall] + │ │ └─ ← [Return] vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D] + │ ├─ [100875] vaultHub::applyVaultReport(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 259201 [2.592e5], 1000000000000000000 [1e18], 1000000000000000000 [1e18], 1, 0) + │ │ ├─ [100374] VaultHub::applyVaultReport(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 259201 [2.592e5], 1000000000000000000 [1e18], 1000000000000000000 [1e18], 1, 0) [delegatecall] + │ │ │ ├─ [2538] lidoLocator::lazyOracle() [staticcall] + │ │ │ │ └─ ← [Return] lazyoracle: [0x7C2098EA6428fF3Dc2e11f99096f298eAb463F50] + │ │ │ ├─ emit LidoFeesUpdated(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], unsettledLidoFees: 1, settledLidoFees: 0) + │ │ │ ├─ [5973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ emit VaultReportApplied(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], reportTimestamp: 259201 [2.592e5], reportTotalValue: 1000000000000000000 [1e18], reportInOutDelta: 1000000000000000000 [1e18], reportCumulativeLidoFees: 1, reportLiabilityShares: 0) + │ │ │ ├─ [1286] lido::getSharesByPooledEth(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [1286] lido::getSharesByPooledEth(9000000000000002508 [9e18]) [staticcall] + │ │ │ │ └─ ← [Return] 7505544420551436254 [7.505e18] + │ │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [7627] ERC1967Proxy::fallback() [staticcall] + │ │ │ │ ├─ [2653] StakingVault::beaconChainDepositsPaused() [delegatecall] + │ │ │ │ │ └─ ← [Return] false + │ │ │ │ └─ ← [Return] false + │ │ │ └─ ← [Stop] + │ │ └─ ← [Return] + │ └─ ← [Stop] + ├─ [3119] ERC1967Proxy::fallback() [staticcall] + │ ├─ [2645] StakingVault::pendingOwner() [delegatecall] + │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000 + │ └─ ← [Return] 0x0000000000000000000000000000000000000000 + └─ ← [Stop] + + [178934] StakingVaultsHandler::voluntaryDisconnect() + ├─ [13504] vaultHub::vaultConnection(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ ├─ [8479] VaultHub::vaultConnection(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ └─ ← [Return] VaultConnection({ owner: 0x2D4b02C00A39Fb5A7df795d8464562227657b566, shareLimit: 1000, vaultIndex: 1, pendingDisconnect: false, reserveRatioBP: 2000, forcedRebalanceThresholdBP: 1800, infraFeeBP: 500, liquidityFeeBP: 400, reservationFeeBP: 100, isBeaconDepositsManuallyPaused: false }) + │ └─ ← [Return] VaultConnection({ owner: 0x2D4b02C00A39Fb5A7df795d8464562227657b566, shareLimit: 1000, vaultIndex: 1, pendingDisconnect: false, reserveRatioBP: 2000, forcedRebalanceThresholdBP: 1800, infraFeeBP: 500, liquidityFeeBP: 400, reservationFeeBP: 100, isBeaconDepositsManuallyPaused: false }) + ├─ [3619] vaultHub::liabilityShares(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ ├─ [3142] VaultHub::liabilityShares(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ └─ ← [Return] 0 + │ └─ ← [Return] 0 + ├─ [0] VM::prank(userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566]) + │ └─ ← [Return] + ├─ [135274] vaultHub::voluntaryDisconnect(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) + │ ├─ [134800] VaultHub::voluntaryDisconnect(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ ├─ [5286] lido::getSharesByPooledEth(0) [staticcall] + │ │ │ └─ ← [Return] 0 + │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ └─ ← [Return] 0 + │ │ ├─ [1286] lido::getSharesByPooledEth(9000000000000002508 [9e18]) [staticcall] + │ │ │ └─ ← [Return] 7505544420551436254 [7.505e18] + │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ └─ ← [Return] 0 + │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ └─ ← [Return] 0 + │ │ ├─ [2560] lidoLocator::treasury() [staticcall] + │ │ │ └─ ← [Return] treasury: [0xf43Bca55E8091977223Fa5b776E23528D205dcA8] + │ │ ├─ [4688] consensusContract::getCurrentFrame() [staticcall] + │ │ │ └─ ← [Return] 4, 0 + │ │ ├─ emit VaultInOutDeltaUpdated(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], inOutDelta: 999999999999999999 [9.999e17]) + │ │ ├─ [44314] ERC1967Proxy::fallback(treasury: [0xf43Bca55E8091977223Fa5b776E23528D205dcA8], 1) + │ │ │ ├─ [39337] StakingVault::withdraw(treasury: [0xf43Bca55E8091977223Fa5b776E23528D205dcA8], 1) [delegatecall] + │ │ │ │ ├─ [0] treasury::fallback{value: 1}() + │ │ │ │ │ └─ ← [Stop] + │ │ │ │ ├─ emit EtherWithdrawn(recipient: treasury: [0xf43Bca55E8091977223Fa5b776E23528D205dcA8], amount: 1) + │ │ │ │ └─ ← [Stop] + │ │ │ └─ ← [Return] + │ │ ├─ emit VaultObligationsSettled(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], rebalanced: 0, transferredToLido: 1, unsettledRedemptions: 0, unsettledLidoFees: 0, settledLidoFees: 1) + │ │ ├─ [2581] lidoLocator::operatorGrid() [staticcall] + │ │ │ └─ ← [Return] operatorGrid: [0x8AFE2092eCE1aF4ba3d4d188acfB187f36003D6f] + │ │ ├─ [10887] operatorGrid::resetVaultTier(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) + │ │ │ ├─ [5913] OperatorGrid::resetVaultTier(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ │ │ ├─ [2538] lidoLocator::vaultHub() [staticcall] + │ │ │ │ │ └─ ← [Return] vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D] + │ │ │ │ └─ ← [Stop] + │ │ │ └─ ← [Return] + │ │ ├─ emit VaultDisconnectInitiated(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) + │ │ └─ ← [Stop] + │ └─ ← [Return] + └─ ← [Stop] + + [204175] StakingVaultsHandler::updateVaultData(868) + ├─ [0] console::log("Bound result", 0) [staticcall] + │ └─ ← [Stop] + ├─ [0] console::log("DaysShift = %d", 0) [staticcall] + │ └─ ← [Stop] + ├─ [4688] consensusContract::getCurrentFrame() [staticcall] + │ └─ ← [Return] 4, 0 + ├─ [149797] lazyoracle::updateVaultData(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 10000000000000002508 [1e19], 1, 0, 259201 [2.592e5]) + │ ├─ [2538] lidoLocator::vaultHub() [staticcall] + │ │ └─ ← [Return] vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D] + │ ├─ [16958] vaultHub::vaultRecord(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ │ ├─ [11945] VaultHub::vaultRecord(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ │ └─ ← [Return] VaultRecord({ report: Report({ totalValue: 1000000000000000000 [1e18], inOutDelta: 1000000000000000000 [1e18] }), locked: 0, liabilityShares: 0, inOutDelta: Int112WithRefSlotCache({ value: 999999999999999999 [9.999e17], refSlotValue: 1000000000000000000 [1e18], refSlot: 4 }), reportTimestamp: 259201 [2.592e5] }) + │ │ └─ ← [Return] VaultRecord({ report: Report({ totalValue: 1000000000000000000 [1e18], inOutDelta: 1000000000000000000 [1e18] }), locked: 0, liabilityShares: 0, inOutDelta: Int112WithRefSlotCache({ value: 999999999999999999 [9.999e17], refSlotValue: 1000000000000000000 [1e18], refSlot: 4 }), reportTimestamp: 259201 [2.592e5] }) + │ ├─ [688] consensusContract::getCurrentFrame() [staticcall] + │ │ └─ ← [Return] 4, 0 + │ ├─ [538] lidoLocator::vaultHub() [staticcall] + │ │ └─ ← [Return] vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D] + │ ├─ [109533] vaultHub::applyVaultReport(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 259201 [2.592e5], 1000000000000000000 [1e18], 1000000000000000000 [1e18], 1, 0) + │ │ ├─ [109032] VaultHub::applyVaultReport(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 259201 [2.592e5], 1000000000000000000 [1e18], 1000000000000000000 [1e18], 1, 0) [delegatecall] + │ │ │ ├─ [2538] lidoLocator::lazyOracle() [staticcall] + │ │ │ │ └─ ← [Return] lazyoracle: [0x7C2098EA6428fF3Dc2e11f99096f298eAb463F50] + │ │ │ ├─ [5286] lido::getSharesByPooledEth(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [1286] lido::getSharesByPooledEth(9000000000000002507 [9e18]) [staticcall] + │ │ │ │ └─ ← [Return] 7505544420551436253 [7.505e18] + │ │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [31904] ERC1967Proxy::fallback(userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566]) + │ │ │ │ ├─ [26930] StakingVault::transferOwnership(userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566]) [delegatecall] + │ │ │ │ │ ├─ emit OwnershipTransferStarted(previousOwner: vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D], newOwner: userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566]) + │ │ │ │ │ └─ ← [Stop] + │ │ │ │ └─ ← [Return] + │ │ │ ├─ emit VaultDisconnectCompleted(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) + │ │ │ └─ ← [Stop] + │ │ └─ ← [Return] + │ └─ ← [Stop] + ├─ [1119] ERC1967Proxy::fallback() [staticcall] + │ ├─ [645] StakingVault::pendingOwner() [delegatecall] + │ │ └─ ← [Return] userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566] + │ └─ ← [Return] userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566] + ├─ [0] VM::prank(userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566]) + │ └─ ← [Return] + ├─ [6160] ERC1967Proxy::fallback() + │ ├─ [5689] StakingVault::acceptOwnership() [delegatecall] + │ │ ├─ emit OwnershipTransferred(previousOwner: vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D], newOwner: userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566]) + │ │ └─ ← [Stop] + │ └─ ← [Return] + └─ ← [Stop] + + [310237] StakingVaultsHandler::connectVault() + ├─ [13504] vaultHub::vaultConnection(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ ├─ [8479] VaultHub::vaultConnection(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ └─ ← [Return] VaultConnection({ owner: 0x0000000000000000000000000000000000000000, shareLimit: 0, vaultIndex: 0, pendingDisconnect: false, reserveRatioBP: 0, forcedRebalanceThresholdBP: 0, infraFeeBP: 0, liquidityFeeBP: 0, reservationFeeBP: 0, isBeaconDepositsManuallyPaused: false }) + │ └─ ← [Return] VaultConnection({ owner: 0x0000000000000000000000000000000000000000, shareLimit: 0, vaultIndex: 0, pendingDisconnect: false, reserveRatioBP: 0, forcedRebalanceThresholdBP: 0, infraFeeBP: 0, liquidityFeeBP: 0, reservationFeeBP: 0, isBeaconDepositsManuallyPaused: false }) + ├─ [0] VM::prank(userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566]) + │ └─ ← [Return] + ├─ [31904] ERC1967Proxy::fallback(vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D]) + │ ├─ [26930] StakingVault::transferOwnership(vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D]) [delegatecall] + │ │ ├─ emit OwnershipTransferStarted(previousOwner: userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566], newOwner: vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D]) + │ │ └─ ← [Stop] + │ └─ ← [Return] + ├─ [0] VM::prank(userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566]) + │ └─ ← [Return] + ├─ [235169] vaultHub::connectVault(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) + │ ├─ [234695] VaultHub::connectVault(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ ├─ [1119] ERC1967Proxy::fallback() [staticcall] + │ │ │ ├─ [645] StakingVault::pendingOwner() [delegatecall] + │ │ │ │ └─ ← [Return] vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D] + │ │ │ └─ ← [Return] vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D] + │ │ ├─ [3177] ERC1967Proxy::fallback() [staticcall] + │ │ │ ├─ [2703] StakingVault::isOssified() [delegatecall] + │ │ │ │ └─ ← [Return] false + │ │ │ └─ ← [Return] false + │ │ ├─ [2582] lidoLocator::predepositGuarantee() [staticcall] + │ │ │ └─ ← [Return] predepositGuarantee: [0xE8E43c12E9560fA4c9dB30B6771789B453b0C618] + │ │ ├─ [3118] ERC1967Proxy::fallback() [staticcall] + │ │ │ ├─ [2644] StakingVault::depositor() [delegatecall] + │ │ │ │ └─ ← [Return] predepositGuarantee: [0xE8E43c12E9560fA4c9dB30B6771789B453b0C618] + │ │ │ └─ ← [Return] predepositGuarantee: [0xE8E43c12E9560fA4c9dB30B6771789B453b0C618] + │ │ ├─ [2581] lidoLocator::operatorGrid() [staticcall] + │ │ │ └─ ← [Return] operatorGrid: [0x8AFE2092eCE1aF4ba3d4d188acfB187f36003D6f] + │ │ ├─ [16818] operatorGrid::vaultInfo(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ │ │ ├─ [11805] OperatorGrid::vaultInfo(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ │ │ └─ ← [Return] 0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF, 0, 1000, 2000, 1800, 500, 400, 100 + │ │ │ └─ ← [Return] 0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF, 0, 1000, 2000, 1800, 500, 400, 100 + │ │ ├─ [2498] lido::getTotalShares() [staticcall] + │ │ │ └─ ← [Return] 7810237000000000000000000 [7.81e24] + │ │ ├─ [2538] lidoLocator::lazyOracle() [staticcall] + │ │ │ └─ ← [Return] lazyoracle: [0x7C2098EA6428fF3Dc2e11f99096f298eAb463F50] + │ │ ├─ [2535] lazyoracle::latestReportTimestamp() [staticcall] + │ │ │ └─ ← [Return] 259201 [2.592e5] + │ │ ├─ [1120] ERC1967Proxy::fallback() [staticcall] + │ │ │ ├─ [646] StakingVault::owner() [delegatecall] + │ │ │ │ └─ ← [Return] userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566] + │ │ │ └─ ← [Return] userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566] + │ │ ├─ [6160] ERC1967Proxy::fallback() + │ │ │ ├─ [5689] StakingVault::acceptOwnership() [delegatecall] + │ │ │ │ ├─ emit OwnershipTransferred(previousOwner: userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566], newOwner: vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D]) + │ │ │ │ └─ ← [Stop] + │ │ │ └─ ← [Return] + │ │ ├─ emit VaultConnected(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], shareLimit: 1000, reserveRatioBP: 2000, forcedRebalanceThresholdBP: 1800, infraFeeBP: 500, liquidityFeeBP: 400, reservationFeeBP: 100) + │ │ └─ ← [Stop] + │ └─ ← [Return] + └─ ← [Stop] + + [109096] StakingVaultsHandler::voluntaryDisconnect() + ├─ [13504] vaultHub::vaultConnection(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ ├─ [8479] VaultHub::vaultConnection(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ └─ ← [Return] VaultConnection({ owner: 0x2D4b02C00A39Fb5A7df795d8464562227657b566, shareLimit: 1000, vaultIndex: 1, pendingDisconnect: false, reserveRatioBP: 2000, forcedRebalanceThresholdBP: 1800, infraFeeBP: 500, liquidityFeeBP: 400, reservationFeeBP: 100, isBeaconDepositsManuallyPaused: false }) + │ └─ ← [Return] VaultConnection({ owner: 0x2D4b02C00A39Fb5A7df795d8464562227657b566, shareLimit: 1000, vaultIndex: 1, pendingDisconnect: false, reserveRatioBP: 2000, forcedRebalanceThresholdBP: 1800, infraFeeBP: 500, liquidityFeeBP: 400, reservationFeeBP: 100, isBeaconDepositsManuallyPaused: false }) + ├─ [3619] vaultHub::liabilityShares(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ ├─ [3142] VaultHub::liabilityShares(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ └─ ← [Return] 0 + │ └─ ← [Return] 0 + ├─ [0] VM::prank(userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566]) + │ └─ ← [Return] + ├─ [65436] vaultHub::voluntaryDisconnect(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) + │ ├─ [64962] VaultHub::voluntaryDisconnect(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ ├─ [5286] lido::getSharesByPooledEth(0) [staticcall] + │ │ │ └─ ← [Return] 0 + │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ └─ ← [Return] 0 + │ │ ├─ [1286] lido::getSharesByPooledEth(9000000000000002507 [9e18]) [staticcall] + │ │ │ └─ ← [Return] 7505544420551436253 [7.505e18] + │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ └─ ← [Return] 0 + │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ └─ ← [Return] 0 + │ │ ├─ [2581] lidoLocator::operatorGrid() [staticcall] + │ │ │ └─ ← [Return] operatorGrid: [0x8AFE2092eCE1aF4ba3d4d188acfB187f36003D6f] + │ │ ├─ [10887] operatorGrid::resetVaultTier(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) + │ │ │ ├─ [5913] OperatorGrid::resetVaultTier(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ │ │ ├─ [2538] lidoLocator::vaultHub() [staticcall] + │ │ │ │ │ └─ ← [Return] vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D] + │ │ │ │ └─ ← [Stop] + │ │ │ └─ ← [Return] + │ │ ├─ emit VaultDisconnectInitiated(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) + │ │ └─ ← [Stop] + │ └─ ← [Return] + └─ ← [Stop] + + [278833] StakingVaultsHandler::updateVaultData(3540135484465009483 [3.54e18]) + ├─ [0] console::log("Bound result", 1) [staticcall] + │ └─ ← [Stop] + ├─ [0] console::log("DaysShift = %d", 3) [staticcall] + │ └─ ← [Stop] + ├─ [0] VM::warp(518401 [5.184e5]) + │ └─ ← [Return] + ├─ [5591] lazyoracle::setVaultDataTimestamp(518401 [5.184e5]) + │ └─ ← [Stop] + ├─ [10904] vaultHub::vaultObligations(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ ├─ [5921] VaultHub::vaultObligations(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ └─ ← [Return] VaultObligations({ settledLidoFees: 0, unsettledLidoFees: 0, redemptions: 0 }) + │ └─ ← [Return] VaultObligations({ settledLidoFees: 0, unsettledLidoFees: 0, redemptions: 0 }) + ├─ [6472] vaultHub::totalValue(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ ├─ [5995] VaultHub::totalValue(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ └─ ← [Return] 9000000000000002507 [9e18] + │ └─ ← [Return] 9000000000000002507 [9e18] + ├─ [3619] vaultHub::liabilityShares(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ ├─ [3142] VaultHub::liabilityShares(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ └─ ← [Return] 0 + │ └─ ← [Return] 0 + ├─ [4688] consensusContract::getCurrentFrame() [staticcall] + │ └─ ← [Return] 4, 0 + ├─ [3337] consensusContract::setCurrentFrame(7) + │ └─ ← [Stop] + ├─ [173007] lazyoracle::updateVaultData(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 9000000000000002507 [9e18], 1, 0, 518401 [5.184e5]) + │ ├─ [2538] lidoLocator::vaultHub() [staticcall] + │ │ └─ ← [Return] vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D] + │ ├─ [6458] vaultHub::vaultRecord(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ │ ├─ [5945] VaultHub::vaultRecord(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ │ └─ ← [Return] VaultRecord({ report: Report({ totalValue: 9000000000000002507 [9e18], inOutDelta: 9000000000000002507 [9e18] }), locked: 0, liabilityShares: 0, inOutDelta: Int112WithRefSlotCache({ value: 9000000000000002507 [9e18], refSlotValue: 0, refSlot: 0 }), reportTimestamp: 259201 [2.592e5] }) + │ │ └─ ← [Return] VaultRecord({ report: Report({ totalValue: 9000000000000002507 [9e18], inOutDelta: 9000000000000002507 [9e18] }), locked: 0, liabilityShares: 0, inOutDelta: Int112WithRefSlotCache({ value: 9000000000000002507 [9e18], refSlotValue: 0, refSlot: 0 }), reportTimestamp: 259201 [2.592e5] }) + │ ├─ [688] consensusContract::getCurrentFrame() [staticcall] + │ │ └─ ← [Return] 7, 0 + │ ├─ [538] lidoLocator::vaultHub() [staticcall] + │ │ └─ ← [Return] vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D] + │ ├─ [151127] vaultHub::applyVaultReport(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 518401 [5.184e5], 9000000000000002507 [9e18], 9000000000000002507 [9e18], 1, 0) + │ │ ├─ [150626] VaultHub::applyVaultReport(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 518401 [5.184e5], 9000000000000002507 [9e18], 9000000000000002507 [9e18], 1, 0) [delegatecall] + │ │ │ ├─ [2538] lidoLocator::lazyOracle() [staticcall] + │ │ │ │ └─ ← [Return] lazyoracle: [0x7C2098EA6428fF3Dc2e11f99096f298eAb463F50] + │ │ │ ├─ emit LidoFeesUpdated(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], unsettledLidoFees: 1, settledLidoFees: 0) + │ │ │ ├─ [5286] lido::getSharesByPooledEth(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [1286] lido::getSharesByPooledEth(9000000000000002507 [9e18]) [staticcall] + │ │ │ │ └─ ← [Return] 7505544420551436253 [7.505e18] + │ │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [2560] lidoLocator::treasury() [staticcall] + │ │ │ │ └─ ← [Return] treasury: [0xf43Bca55E8091977223Fa5b776E23528D205dcA8] + │ │ │ ├─ [688] consensusContract::getCurrentFrame() [staticcall] + │ │ │ │ └─ ← [Return] 7, 0 + │ │ │ ├─ emit VaultInOutDeltaUpdated(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], inOutDelta: 9000000000000002506 [9e18]) + │ │ │ ├─ [19314] ERC1967Proxy::fallback(treasury: [0xf43Bca55E8091977223Fa5b776E23528D205dcA8], 1) + │ │ │ │ ├─ [14337] StakingVault::withdraw(treasury: [0xf43Bca55E8091977223Fa5b776E23528D205dcA8], 1) [delegatecall] + │ │ │ │ │ ├─ [0] treasury::fallback{value: 1}() + │ │ │ │ │ │ └─ ← [Stop] + │ │ │ │ │ ├─ emit EtherWithdrawn(recipient: treasury: [0xf43Bca55E8091977223Fa5b776E23528D205dcA8], amount: 1) + │ │ │ │ │ └─ ← [Stop] + │ │ │ │ └─ ← [Return] + │ │ │ ├─ emit VaultObligationsSettled(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], rebalanced: 0, transferredToLido: 1, unsettledRedemptions: 0, unsettledLidoFees: 0, settledLidoFees: 1) + │ │ │ ├─ [25404] ERC1967Proxy::fallback(userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566]) + │ │ │ │ ├─ [24930] StakingVault::transferOwnership(userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566]) [delegatecall] + │ │ │ │ │ ├─ emit OwnershipTransferStarted(previousOwner: vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D], newOwner: userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566]) + │ │ │ │ │ └─ ← [Stop] + │ │ │ │ └─ ← [Return] + │ │ │ ├─ emit VaultDisconnectCompleted(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) + │ │ │ └─ ← [Stop] + │ │ └─ ← [Return] + │ └─ ← [Stop] + ├─ [1119] ERC1967Proxy::fallback() [staticcall] + │ ├─ [645] StakingVault::pendingOwner() [delegatecall] + │ │ └─ ← [Return] userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566] + │ └─ ← [Return] userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566] + ├─ [0] VM::prank(userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566]) + │ └─ ← [Return] + ├─ [6160] ERC1967Proxy::fallback() + │ ├─ [5689] StakingVault::acceptOwnership() [delegatecall] + │ │ ├─ emit OwnershipTransferred(previousOwner: vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D], newOwner: userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566]) + │ │ └─ ← [Stop] + │ └─ ← [Return] + └─ ← [Stop] + + [72508] StakingVaultsHandler::SVwithdraw(6860636051248840587910698474871198435 [6.86e36]) + ├─ [7620] ERC1967Proxy::fallback() [staticcall] + │ ├─ [2646] StakingVault::owner() [delegatecall] + │ │ └─ ← [Return] userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566] + │ └─ ← [Return] userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566] + ├─ [0] console::log("Bound result", 3842411754778025856 [3.842e18]) [staticcall] + │ └─ ← [Stop] + ├─ [0] VM::prank(userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566]) + │ └─ ← [Return] + ├─ [37814] ERC1967Proxy::fallback(userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566], 3842411754778025856 [3.842e18]) + │ ├─ [37337] StakingVault::withdraw(userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566], 3842411754778025856 [3.842e18]) [delegatecall] + │ │ ├─ [0] userAccount::fallback{value: 3842411754778025856}() + │ │ │ └─ ← [Stop] + │ │ ├─ emit EtherWithdrawn(recipient: userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566], amount: 3842411754778025856 [3.842e18]) + │ │ └─ ← [Stop] + │ └─ ← [Return] + └─ ← [Stop] + + [310237] StakingVaultsHandler::connectVault() + ├─ [13504] vaultHub::vaultConnection(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ ├─ [8479] VaultHub::vaultConnection(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ └─ ← [Return] VaultConnection({ owner: 0x0000000000000000000000000000000000000000, shareLimit: 0, vaultIndex: 0, pendingDisconnect: false, reserveRatioBP: 0, forcedRebalanceThresholdBP: 0, infraFeeBP: 0, liquidityFeeBP: 0, reservationFeeBP: 0, isBeaconDepositsManuallyPaused: false }) + │ └─ ← [Return] VaultConnection({ owner: 0x0000000000000000000000000000000000000000, shareLimit: 0, vaultIndex: 0, pendingDisconnect: false, reserveRatioBP: 0, forcedRebalanceThresholdBP: 0, infraFeeBP: 0, liquidityFeeBP: 0, reservationFeeBP: 0, isBeaconDepositsManuallyPaused: false }) + ├─ [0] VM::prank(userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566]) + │ └─ ← [Return] + ├─ [31904] ERC1967Proxy::fallback(vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D]) + │ ├─ [26930] StakingVault::transferOwnership(vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D]) [delegatecall] + │ │ ├─ emit OwnershipTransferStarted(previousOwner: userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566], newOwner: vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D]) + │ │ └─ ← [Stop] + │ └─ ← [Return] + ├─ [0] VM::prank(userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566]) + │ └─ ← [Return] + ├─ [235169] vaultHub::connectVault(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) + │ ├─ [234695] VaultHub::connectVault(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ ├─ [1119] ERC1967Proxy::fallback() [staticcall] + │ │ │ ├─ [645] StakingVault::pendingOwner() [delegatecall] + │ │ │ │ └─ ← [Return] vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D] + │ │ │ └─ ← [Return] vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D] + │ │ ├─ [3177] ERC1967Proxy::fallback() [staticcall] + │ │ │ ├─ [2703] StakingVault::isOssified() [delegatecall] + │ │ │ │ └─ ← [Return] false + │ │ │ └─ ← [Return] false + │ │ ├─ [2582] lidoLocator::predepositGuarantee() [staticcall] + │ │ │ └─ ← [Return] predepositGuarantee: [0xE8E43c12E9560fA4c9dB30B6771789B453b0C618] + │ │ ├─ [3118] ERC1967Proxy::fallback() [staticcall] + │ │ │ ├─ [2644] StakingVault::depositor() [delegatecall] + │ │ │ │ └─ ← [Return] predepositGuarantee: [0xE8E43c12E9560fA4c9dB30B6771789B453b0C618] + │ │ │ └─ ← [Return] predepositGuarantee: [0xE8E43c12E9560fA4c9dB30B6771789B453b0C618] + │ │ ├─ [2581] lidoLocator::operatorGrid() [staticcall] + │ │ │ └─ ← [Return] operatorGrid: [0x8AFE2092eCE1aF4ba3d4d188acfB187f36003D6f] + │ │ ├─ [16818] operatorGrid::vaultInfo(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ │ │ ├─ [11805] OperatorGrid::vaultInfo(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ │ │ └─ ← [Return] 0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF, 0, 1000, 2000, 1800, 500, 400, 100 + │ │ │ └─ ← [Return] 0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF, 0, 1000, 2000, 1800, 500, 400, 100 + │ │ ├─ [2498] lido::getTotalShares() [staticcall] + │ │ │ └─ ← [Return] 7810237000000000000000000 [7.81e24] + │ │ ├─ [2538] lidoLocator::lazyOracle() [staticcall] + │ │ │ └─ ← [Return] lazyoracle: [0x7C2098EA6428fF3Dc2e11f99096f298eAb463F50] + │ │ ├─ [2535] lazyoracle::latestReportTimestamp() [staticcall] + │ │ │ └─ ← [Return] 518401 [5.184e5] + │ │ ├─ [1120] ERC1967Proxy::fallback() [staticcall] + │ │ │ ├─ [646] StakingVault::owner() [delegatecall] + │ │ │ │ └─ ← [Return] userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566] + │ │ │ └─ ← [Return] userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566] + │ │ ├─ [6160] ERC1967Proxy::fallback() + │ │ │ ├─ [5689] StakingVault::acceptOwnership() [delegatecall] + │ │ │ │ ├─ emit OwnershipTransferred(previousOwner: userAccount: [0x2D4b02C00A39Fb5A7df795d8464562227657b566], newOwner: vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D]) + │ │ │ │ └─ ← [Stop] + │ │ │ └─ ← [Return] + │ │ ├─ emit VaultConnected(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], shareLimit: 1000, reserveRatioBP: 2000, forcedRebalanceThresholdBP: 1800, infraFeeBP: 500, liquidityFeeBP: 400, reservationFeeBP: 100) + │ │ └─ ← [Stop] + │ └─ ← [Return] + └─ ← [Stop] + + [211464] StakingVaultsHandler::updateVaultData(340126622102151179895729601984262475255939545195358619426284158544426754 [3.401e71]) + ├─ [0] console::log("Bound result", 0) [staticcall] + │ └─ ← [Stop] + ├─ [0] console::log("DaysShift = %d", 0) [staticcall] + │ └─ ← [Stop] + ├─ [4688] consensusContract::getCurrentFrame() [staticcall] + │ └─ ← [Return] 7, 0 + ├─ [164880] lazyoracle::updateVaultData(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 9000000000000002507 [9e18], 1, 0, 518401 [5.184e5]) + │ ├─ [2538] lidoLocator::vaultHub() [staticcall] + │ │ └─ ← [Return] vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D] + │ ├─ [16958] vaultHub::vaultRecord(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ │ ├─ [11945] VaultHub::vaultRecord(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ │ └─ ← [Return] VaultRecord({ report: Report({ totalValue: 5157588245221976650 [5.157e18], inOutDelta: 5157588245221976650 [5.157e18] }), locked: 1000000000000000000 [1e18], liabilityShares: 0, inOutDelta: Int112WithRefSlotCache({ value: 5157588245221976650 [5.157e18], refSlotValue: 0, refSlot: 0 }), reportTimestamp: 518401 [5.184e5] }) + │ │ └─ ← [Return] VaultRecord({ report: Report({ totalValue: 5157588245221976650 [5.157e18], inOutDelta: 5157588245221976650 [5.157e18] }), locked: 1000000000000000000 [1e18], liabilityShares: 0, inOutDelta: Int112WithRefSlotCache({ value: 5157588245221976650 [5.157e18], refSlotValue: 0, refSlot: 0 }), reportTimestamp: 518401 [5.184e5] }) + │ ├─ [688] consensusContract::getCurrentFrame() [staticcall] + │ │ └─ ← [Return] 7, 0 + │ ├─ emit QuarantineExpired(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], delta: 3842411754778025857 [3.842e18]) + │ ├─ [538] lidoLocator::vaultHub() [staticcall] + │ │ └─ ← [Return] vaultHub: [0x501dF6A337e602e9fbd0FD7Af98FD9c3329fe51D] + │ ├─ [119173] vaultHub::applyVaultReport(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 518401 [5.184e5], 9000000000000002507 [9e18], 5157588245221976650 [5.157e18], 1, 0) + │ │ ├─ [118672] VaultHub::applyVaultReport(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 518401 [5.184e5], 9000000000000002507 [9e18], 5157588245221976650 [5.157e18], 1, 0) [delegatecall] + │ │ │ ├─ [2538] lidoLocator::lazyOracle() [staticcall] + │ │ │ │ └─ ← [Return] lazyoracle: [0x7C2098EA6428fF3Dc2e11f99096f298eAb463F50] + │ │ │ ├─ emit LidoFeesUpdated(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], unsettledLidoFees: 1, settledLidoFees: 0) + │ │ │ ├─ [5973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ emit VaultReportApplied(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], reportTimestamp: 518401 [5.184e5], reportTotalValue: 9000000000000002507 [9e18], reportInOutDelta: 5157588245221976650 [5.157e18], reportCumulativeLidoFees: 1, reportLiabilityShares: 0) + │ │ │ ├─ [1286] lido::getSharesByPooledEth(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [1286] lido::getSharesByPooledEth(5157588245221976650 [5.157e18]) [staticcall] + │ │ │ │ └─ ← [Return] 4301167519714163206 [4.301e18] + │ │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [2560] lidoLocator::treasury() [staticcall] + │ │ │ │ └─ ← [Return] treasury: [0xf43Bca55E8091977223Fa5b776E23528D205dcA8] + │ │ │ ├─ [688] consensusContract::getCurrentFrame() [staticcall] + │ │ │ │ └─ ← [Return] 7, 0 + │ │ │ ├─ emit VaultInOutDeltaUpdated(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], inOutDelta: 5157588245221976649 [5.157e18]) + │ │ │ ├─ [19314] ERC1967Proxy::fallback(treasury: [0xf43Bca55E8091977223Fa5b776E23528D205dcA8], 1) + │ │ │ │ ├─ [14337] StakingVault::withdraw(treasury: [0xf43Bca55E8091977223Fa5b776E23528D205dcA8], 1) [delegatecall] + │ │ │ │ │ ├─ [0] treasury::fallback{value: 1}() + │ │ │ │ │ │ └─ ← [Stop] + │ │ │ │ │ ├─ emit EtherWithdrawn(recipient: treasury: [0xf43Bca55E8091977223Fa5b776E23528D205dcA8], amount: 1) + │ │ │ │ │ └─ ← [Stop] + │ │ │ │ └─ ← [Return] + │ │ │ ├─ emit VaultObligationsSettled(vault: ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], rebalanced: 0, transferredToLido: 1, unsettledRedemptions: 0, unsettledLidoFees: 0, settledLidoFees: 1) + │ │ │ ├─ [1973] lido::getPooledEthBySharesRoundUp(0) [staticcall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [3127] ERC1967Proxy::fallback() [staticcall] + │ │ │ │ ├─ [2653] StakingVault::beaconChainDepositsPaused() [delegatecall] + │ │ │ │ │ └─ ← [Return] false + │ │ │ │ └─ ← [Return] false + │ │ │ └─ ← [Stop] + │ │ └─ ← [Return] + │ └─ ← [Stop] + ├─ [3119] ERC1967Proxy::fallback() [staticcall] + │ ├─ [2645] StakingVault::pendingOwner() [delegatecall] + │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000 + │ └─ ← [Return] 0x0000000000000000000000000000000000000000 + └─ ← [Stop] + + [33300] StakingVaultsTest::invariant_check_totalValue() + ├─ [18828] StakingVaultsHandler::getVaultTotalValue() + │ ├─ [10972] vaultHub::totalValue(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ │ ├─ [5995] VaultHub::totalValue(ERC1967Proxy: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [delegatecall] + │ │ │ └─ ← [Return] 9000000000000002506 [9e18] + │ │ └─ ← [Return] 9000000000000002506 [9e18] + │ └─ ← [Return] 9000000000000002506 [9e18] + ├─ [5426] StakingVaultsHandler::getEffectiveVaultTotalValue() + │ └─ ← [Return] 5157588245221976649 [5.157e18] + ├─ [0] VM::assertLe(9000000000000002506 [9e18], 5157588245221976649 [5.157e18]) [staticcall] + │ └─ ← [Revert] assertion failed: 9000000000000002506 > 5157588245221976649 + └─ ← [Revert] assertion failed: 9000000000000002506 > 5157588245221976649 + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 215.31ms (207.56ms CPU time) + +Ran 1 test suite in 220.85ms (215.31ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol:StakingVaultsTest +[FAIL: assertion failed: 9000000000000002505 > 4395295350638772351] + [Sequence] (original: 60, shrunk: 10) + sender=0x0000000000000000000000000000000000004B84 addr=[test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol:StakingVaultsHandler]0xc7183455a4C133Ae270771860664b6B7ec320bB1 calldata=otcDeposit(uint256) args=[2507] + sender=0x0000000000000000000000000000000000000103 addr=[test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol:StakingVaultsHandler]0xc7183455a4C133Ae270771860664b6B7ec320bB1 calldata=updateVaultData(uint256) args=[75637045475 [7.563e10]] + sender=0x0000000000000000000000000000000000002906 addr=[test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol:StakingVaultsHandler]0xc7183455a4C133Ae270771860664b6B7ec320bB1 calldata=voluntaryDisconnect() args=[] + sender=0x2ab792e53C33c91c5beacc502Ce52FB50a91d18F addr=[test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol:StakingVaultsHandler]0xc7183455a4C133Ae270771860664b6B7ec320bB1 calldata=updateVaultData(uint256) args=[868] + sender=0x0000000000000000000000000000000000000472 addr=[test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol:StakingVaultsHandler]0xc7183455a4C133Ae270771860664b6B7ec320bB1 calldata=connectVault() args=[] + sender=0x0000000000000000000000000000000000002F9F addr=[test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol:StakingVaultsHandler]0xc7183455a4C133Ae270771860664b6B7ec320bB1 calldata=voluntaryDisconnect() args=[] + sender=0x0000000000000000000000000000000000005509 addr=[test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol:StakingVaultsHandler]0xc7183455a4C133Ae270771860664b6B7ec320bB1 calldata=updateVaultData(uint256) args=[3540135484465009483 [3.54e18]] + sender=0x00000000000000000000000000000000000029A3 addr=[test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol:StakingVaultsHandler]0xc7183455a4C133Ae270771860664b6B7ec320bB1 calldata=SVwithdraw(uint256) args=[6860636051248840587910698474871198435 [6.86e36]] + sender=0x0000000000000000000000000000000000004D71 addr=[test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol:StakingVaultsHandler]0xc7183455a4C133Ae270771860664b6B7ec320bB1 calldata=connectVault() args=[] + sender=0x6B29775b48cd953AaFD9084292E6872818526152 addr=[test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol:StakingVaultsHandler]0xc7183455a4C133Ae270771860664b6B7ec320bB1 calldata=updateVaultData(uint256) args=[340126622102151179895729601984262475255939545195358619426284158544426754 [3.401e71]] + invariant_check_totalValue() (runs: 0, calls: 0, reverts: 133) From 3dd03ef47f27f4fd338643d793a1fc800f2e414b 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 02/14] refactor + add of multiple invariants --- .../StakingVaultConstants.sol | 2 + .../StakingVaultsFuzzing.t.sol | 190 ++++- .../StakingVaultsHandler.t.sol | 89 ++- .../{ => mocks}/CommonMocks.sol | 0 .../mocks/LazyOracleMock.sol | 276 +++++++ .../mocks/OperatorGridMock.sol | 699 ++++++++++++++++++ 6 files changed, 1222 insertions(+), 34 deletions(-) rename test/0.8.25/invariant-fuzzing/{ => mocks}/CommonMocks.sol (100%) 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 index 46b0da284d..995e7b4dc1 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol @@ -14,6 +14,8 @@ library Constants { //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; diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol index 1edc382c0e..14628caea5 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol @@ -16,7 +16,9 @@ import {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; import {StakingVaultsHandler} from "./StakingVaultsHandler.t.sol"; import {Constants} from "./StakingVaultConstants.sol"; -import {LazyOracleMock} from "./CommonMocks.sol"; +import {LazyOracleMock} from "./mocks/LazyOracleMock.sol"; + +import {Math256} from "contracts/common/lib/Math256.sol"; contract StakingVaultsTest is Test { VaultHub vaultHubProxy; @@ -167,22 +169,28 @@ contract StakingVaultsTest is Test { //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[](11); + bytes4[] memory svSelectors = new bytes4[](13); svSelectors[0] = svHandler.fund.selector; svSelectors[1] = svHandler.VHwithdraw.selector; - svSelectors[2] = svHandler.rebalance.selector; - svSelectors[3] = svHandler.mintShares.selector; - svSelectors[4] = svHandler.burnShares.selector; - svSelectors[5] = svHandler.transferAndBurnShares.selector; - svSelectors[6] = svHandler.voluntaryDisconnect.selector; - svSelectors[7] = svHandler.otcDeposit.selector; - svSelectors[8] = svHandler.updateVaultData.selector; - svSelectors[9] = svHandler.SVwithdraw.selector; - svSelectors[10] = svHandler.connectVault.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})); @@ -190,15 +198,153 @@ contract StakingVaultsTest is Test { ////////// INVARIANTS ////////// - /* - Ideas of invariants to implement: - - locked amount should always above reserve ratio in regards to the liabilityShares of the SV - //liabilityShare should not be upper to collateral - // function invariant_liabilityShares_not_above_collateral() external { - // assertGt(svHandler.getVaultTotalValue(),svHandler.getEffectiveVaultTotalValue()); + //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 @@ -207,7 +353,7 @@ contract StakingVaultsTest is Test { //- uncleared quarantine upon disconnect //call path is pretty long but is: //1. connectVault - //2. otcDeposit + //2. sv_otcDeposit //3. updateVaultData -> triggers quarantine //4. initializeDisconnect //5. updateVaultData -> finalize disconnection @@ -216,9 +362,9 @@ 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()); - } + // function invariant_check_totalValue() external { + // assertLe(svHandler.getVaultTotalValue(), svHandler.getEffectiveVaultTotalValue()); + // } /* //for testing purposes only (guiding the fuzzing) diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol index 53a31cdc3f..7521af15e4 100644 --- a/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol @@ -5,14 +5,17 @@ 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 {LazyOracleMock, LidoLocatorMock, ConsensusContractMock} from "./CommonMocks.sol"; +import {LidoLocatorMock, ConsensusContractMock} from "./mocks/CommonMocks.sol"; +import {LazyOracleMock} from "./mocks/LazyOracleMock.sol"; import {Constants} from "./StakingVaultConstants.sol"; import "forge-std/console2.sol"; @@ -24,7 +27,7 @@ TODO: - compensateDisprovenPredepositFromPDG **/ -contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils { +contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils, StdAssertions { // Protocol contracts ILido public lidoContract; LidoLocatorMock public lidoLocator; @@ -51,13 +54,21 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils { uint256 constant MIN_SHARES = 1; uint256 constant MAX_SHARES = 100; - uint256 public otcDeposited = 0; + 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, - OTC_DEPOSIT, + SV_OTC_DEPOSIT, + VH_OTC_DEPOSIT, FUND, VH_WITHDRAW, SV_WITHDRAW @@ -76,7 +87,7 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils { userAccount = _userAccount; actionPath = [ VaultAction.CONNECT, //connect - VaultAction.OTC_DEPOSIT, //otc funds + VaultAction.SV_OTC_DEPOSIT, //otc funds VaultAction.UPDATE_VAULT_DATA, //trigger quarantine VaultAction.VOLUNTARY_DISCONNECT, //pendingDisconnect VaultAction.UPDATE_VAULT_DATA, //disconnected @@ -99,6 +110,23 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils { _; } + ////////// 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 @@ -152,10 +180,34 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils { vaultHub.withdraw(address(stakingVault), userAccount, amount); } - function rebalance(uint256 amount) public { - amount = bound(amount, 1, address(stakingVault).balance); + function forceRebalance() public { + //Avoid revert when vault is healthy + if (vaultHub.isVaultHealthy(address(stakingVault))) { + return; //no need to rebalance + } + vm.prank(userAccount); - vaultHub.rebalance(address(stakingVault), amount); + 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 { @@ -203,12 +255,18 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils { return vaultHub.totalValue(address(stakingVault)); } - function otcDeposit(uint256 amount) public actionIndexUpdate(VaultAction.OTC_DEPOSIT) { + function sv_otcDeposit(uint256 amount) public actionIndexUpdate(VaultAction.SV_OTC_DEPOSIT) { amount = bound(amount, 1 ether, 10 ether); - otcDeposited += amount; + 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) { @@ -222,14 +280,15 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils { VaultHub.VaultObligations memory obligations = vaultHub.vaultObligations(address(stakingVault)); lastReport = VaultReport({ - totalValue: vaultHub.totalValue(address(stakingVault)) + otcDeposited, + 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 - otcDeposited = 0; + sv_otcDeposited = 0; } //path to trigger to get quarantine back in TV @@ -242,6 +301,9 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils { consensusContract.setCurrentFrame(refSlot); } + //we update the reported total Value + reportedTotalValue = lastReport.totalValue; + //update the vault data lazyOracle.updateVaultData( address(stakingVault), @@ -251,6 +313,9 @@ contract StakingVaultsHandler is CommonBase, StdCheats, StdUtils { 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); diff --git a/test/0.8.25/invariant-fuzzing/CommonMocks.sol b/test/0.8.25/invariant-fuzzing/mocks/CommonMocks.sol similarity index 100% rename from test/0.8.25/invariant-fuzzing/CommonMocks.sol rename to test/0.8.25/invariant-fuzzing/mocks/CommonMocks.sol 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 1c0c2cfbb9008f9b7b89320d3a86921cde2e7ff9 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 03/14] 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 ce5234af26eefa2bae1c98a38cbc52b3ae4fd690 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 04/14] 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 9b99a68b42dbb11ce5444495ae025c4c6743e496 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 05/14] 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 f13ebf5686a388ac98d14dc5b3c56b8e9a7e5333 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 06/14] 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 9347ffeaaa53ae08ddc6b1b8cefd0a78ad9dc621 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 07/14] 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 27d5bbe315d7397d2c6649696c8ed6839846b2af 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 08/14] 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 3de669f9dfbb1aa1101077f57c24f4aa604876c0 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 09/14] 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 daa9821e87cbb7020e82afb469838d8279059ae8 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 10/14] 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 b057b965b3931f5662d43a703e8b3771c193689f 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 11/14] 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 36f67b2ed7d920140de453f0a430fcb2d6af5045 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 12/14] 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 866c46f26daaa2df434967f2ae4b4280a19ae9fd 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 13/14] 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 58bfc7e0fea6e722c7e93533e6cf353d3a3995f2 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 14/14] 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"); + } + } + }