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..84a621dbb6 --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/MultiStakingVaultFuzzing.t.sol @@ -0,0 +1,385 @@ +// 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]; + 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 = 5; + + 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 + 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 + 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 + 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 + 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 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 correct sequence"); + } + _; + } + + + // --- Getters for invariant checks --- + function getGroupShareLimit(uint256 groupId) public view returns (uint256) { + return groupShareLimit[groupId]; + } + + function getTierShareLimit(uint256 tierId) public view returns (uint256) { + return tierShareLimit[tierId]; + } + + + // --- VaultHub interactions --- + /// @notice Connects a vault to the VaultHub, funding if needed + function connectVault(uint256 id) public { + id = bound(id, 0, userAccount.length - 1); + VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVaults[id])); + 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])); + } + + /// @notice Initiates voluntary disconnect for a vault + function voluntaryDisconnect(uint256 id) public { + id = bound(id, 0, userAccount.length - 1); + VaultHub.VaultConnection memory vc = vaultHub.vaultConnection(address(stakingVaults[id])); + if (vc.vaultIndex == 0 || vc.pendingDisconnect == true) return; + 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])); + } + + /// @notice Funds a vault via VaultHub + function fund(uint256 id, uint256 amount) public { + id = bound(id, 0, userAccount.length - 1); + 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]))); + 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); + } + + /// @notice Forces a rebalance if the vault is unhealthy + function forceRebalance(uint256 id) public { + id = bound(id, 0, userAccount.length - 1); + if (vaultHub.isVaultHealthy(address(stakingVaults[id]))) { + return; + } + vm.prank(userAccount[id]); + try vaultHub.forceRebalance(address(stakingVaults[id])) { + } catch { + forceRebalanceReverted = true; + } + } + + /// @notice Forces validator exit if vault is unhealthy or obligations exceed threshold + function forceValidatorExit(uint256 id) public { + id = bound(id, 0, userAccount.length - 1); + uint256 redemptions = vaultHub.vaultObligations(address(stakingVaults[id])).redemptions; + if (vaultHub.isVaultHealthy(address(stakingVaults[id])) && redemptions < Math256.max(Constants.UNSETTLED_THRESHOLD, address(stakingVaults[id]).balance)) { + return; + } + bytes memory pubkeys = new bytes(0); + vm.prank(rootAccount); + try vaultHub.forceValidatorExit(address(stakingVaults[id]), pubkeys, userAccount[id]) { + } catch { + forceValidatorExitReverted = true; + } + } + + /// @notice Mints shares for a vault + function mintShares(uint256 id, uint256 shares) public { + id = bound(id, 0, userAccount.length - 1); + 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; + } + 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); + if (vaultHub.vaultConnection(address(stakingVaults[id])).vaultIndex == 0) { + return; + } + address nodeOperator = stakingVaults[id].nodeOperator(); + OperatorGridMock.Group memory nodeOperatorGroup = operatorGrid.group(nodeOperator); + _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; + uint256 requestedTierId = nodeOperatorGroup.tierIds[_requestedTierId]; + uint256 requestedTierShareLimit = operatorGrid.tier(requestedTierId).shareLimit; + + /////// 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); + sv_otcDeposited[id] += amount; + deal(address(stakingVaults[id]), address(stakingVaults[id]).balance + amount); + } + + /// @notice Simulates OTC deposit to the VaultHub + function vh_otcDeposit(uint256 amount) public { + amount = bound(amount, 1 ether, 10 ether); + vh_otcDeposited += amount; + deal(address(vaultHub), address(vaultHub).balance + amount); + } + + // --- LazyOracle interactions --- + + /// @notice Updates vault data, simulating time shifts and quarantine logic + function updateVaultData(uint256 id, uint256 daysShift) public { + id = bound(id, 0, userAccount.length - 1); + if (vaultHub.vaultConnection(address(stakingVaults[id])).vaultIndex == 0) { + return; + } + daysShift = bound(daysShift, 0, 1); + 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)); + VaultHub.VaultObligations memory obligations = vaultHub.vaultObligations(address(stakingVaults[id])); + + lastReport = VaultReport({ + totalValue: vaultHub.totalValue(address(stakingVaults[id])) + sv_otcDeposited[id] + cl_balance, + 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) + ); + // Accept ownership if disconnect was successful + if (stakingVaults[id].pendingOwner() == userAccount[id]) { + vm.prank(userAccount[id]); + stakingVaults[id].acceptOwnership(); + } + } + + // --- 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); + if (stakingVaults[id].owner() != userAccount[id]) { + return; + } + amount = bound(amount, 1, address(stakingVaults[id]).balance); + + vm.prank(userAccount[id]); + stakingVaults[id].withdraw(userAccount[id], amount); + } +} 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..855d15a058 --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/StakingVaultConstants.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity ^0.8.0; + +library Constants { + //OperatorGrid params + //retrieved from default settings in deploy scripts + uint256 public constant DEFAULT_TIER = 0; + uint256 public constant SHARE_LIMIT = 100; + uint256 public constant RESERVE_RATIO_BP = 2000; + uint256 public constant FORCED_REBALANCE_THRESHOLD_BP = 1800; + uint256 public constant INFRA_FEE_BP = 500; + uint256 public constant LIQUIDITY_FEE_BP = 400; + uint256 public constant RESERVATION_FEE_BP = 100; + + //VaultHub params + uint256 public constant RELATIVE_SHARE_LIMIT = 1000; + uint256 public constant UNSETTLED_THRESHOLD = 1 ether; + uint256 public constant TOTAL_BASIS_POINTS = 10000; + + //LidoMock params + uint256 public constant TOTAL_SHARES_MAINNET = 7810237 ether; + uint256 public constant TOTAL_POOLED_ETHER_MAINNET = 9365361 ether; + uint256 public constant EXTERNAL_SHARES_MAINNET = 0; + + uint256 public constant CONNECT_DEPOSIT = 1 ether; + + //LazyOracle params + uint64 public constant QUARANTINE_PERIOD = 3 days; + uint16 public constant MAX_REWARD_RATIO_BP = 350; //3.5% +} diff --git a/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol new file mode 100644 index 0000000000..1c6c22d1a7 --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsFuzzing.t.sol @@ -0,0 +1,381 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity 0.8.25; + +import {Test} from "forge-std/Test.sol"; +import "forge-std/console2.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts-v5.2/proxy/ERC1967/ERC1967Proxy.sol"; +import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; +import {StakingVault} from "contracts/0.8.25/vaults/StakingVault.sol"; +import {TierParams, OperatorGrid} from "contracts/0.8.25/vaults/OperatorGrid.sol"; + +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; +import {IHashConsensus} from "contracts/0.8.25/vaults/interfaces/IHashConsensus.sol"; +import {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; + +import {StakingVaultsHandler} from "./StakingVaultsHandler.t.sol"; +import {Constants} from "./StakingVaultConstants.sol"; + +import {LazyOracleMock} from "./mocks/LazyOracleMock.sol"; + +import {Math256} from "contracts/common/lib/Math256.sol"; + +contract StakingVaultsTest is Test { + VaultHub vaultHubProxy; + StakingVault stakingVaultProxy; + + StakingVaultsHandler svHandler; + + address private rootAccount = makeAddr("rootAccount"); + address private userAccount = makeAddr("userAccount"); + + address private treasury_addr = makeAddr("treasury"); + address private depositor = makeAddr("depositor"); + address private nodeOperator = makeAddr("nodeOperator"); + + //contracts addresses + address private pdg_addr = makeAddr("predepositGuarantee"); + address private accounting_addr = makeAddr("accounting"); + address private lazyOracle_addr = makeAddr("lazyoracle"); + address private operatorGrid_addr = makeAddr("operatorGrid"); + address private vaultHub_addr = makeAddr("vaultHub"); + address private lidoLocator_addr = makeAddr("lidoLocator"); + address private lido_addr = makeAddr("lido"); + address private consensusContract_addr = makeAddr("consensusContract"); + + function deployMockContracts() internal { + //Deploy LidoMock + deployCodeTo( + "CommonMocks.sol:LidoMock", + abi.encode( + Constants.TOTAL_SHARES_MAINNET, + Constants.TOTAL_POOLED_ETHER_MAINNET, + Constants.EXTERNAL_SHARES_MAINNET + ), + lido_addr + ); + + //Deploy LazyOracleMock + deployCodeTo( + "CommonMocks.sol:LazyOracleMock", + abi.encode( + lidoLocator_addr, + consensusContract_addr, + Constants.QUARANTINE_PERIOD, + Constants.MAX_REWARD_RATIO_BP + ), + lazyOracle_addr + ); + + //Deploy ConsensusContractMock + deployCodeTo("CommonMocks.sol:ConsensusContractMock", abi.encode(1, 0), consensusContract_addr); + + //Deploy LidoLocatorMock + deployCodeTo( + "CommonMocks.sol:LidoLocatorMock", + abi.encode( + lido_addr, + pdg_addr, + accounting_addr, + treasury_addr, + operatorGrid_addr, + lazyOracle_addr, + vaultHub_addr, + consensusContract_addr + ), + lidoLocator_addr + ); + } + + function deployOperatorGrid() internal { + TierParams memory defaultTierParams = TierParams({ + shareLimit: Constants.SHARE_LIMIT, + reserveRatioBP: Constants.RESERVE_RATIO_BP, + forcedRebalanceThresholdBP: Constants.FORCED_REBALANCE_THRESHOLD_BP, + infraFeeBP: Constants.INFRA_FEE_BP, + liquidityFeeBP: Constants.LIQUIDITY_FEE_BP, + reservationFeeBP: Constants.RESERVATION_FEE_BP + }); + + OperatorGrid operatorGrid = new OperatorGrid(ILidoLocator(address(lidoLocator_addr))); + + vm.prank(rootAccount); + deployCodeTo( + "ERC1967Proxy", + abi.encode( + operatorGrid, + abi.encodeWithSelector(OperatorGrid.initialize.selector, rootAccount, defaultTierParams) + ), + operatorGrid_addr + ); + } + + function deployVaultHub() internal { + VaultHub vaultHub = new VaultHub( + ILidoLocator(address(lidoLocator_addr)), + ILido(address(lido_addr)), + IHashConsensus(address(consensusContract_addr)), + Constants.RELATIVE_SHARE_LIMIT + ); + + vm.prank(rootAccount); + deployCodeTo( + "ERC1967Proxy", + abi.encode(vaultHub, abi.encodeWithSelector(VaultHub.initialize.selector, rootAccount)), + vaultHub_addr + ); + + vaultHubProxy = VaultHub(payable(vaultHub_addr)); + + bytes32 vaultMasterRole = vaultHubProxy.VAULT_MASTER_ROLE(); + vm.prank(rootAccount); + vaultHubProxy.grantRole(vaultMasterRole, rootAccount); + + bytes32 vaultCodehashSetRole = vaultHubProxy.VAULT_CODEHASH_SET_ROLE(); + vm.prank(rootAccount); + vaultHubProxy.grantRole(vaultCodehashSetRole, rootAccount); + + bytes32 validatorExitRole = vaultHubProxy.VALIDATOR_EXIT_ROLE(); + vm.prank(rootAccount); + vaultHubProxy.grantRole(validatorExitRole, rootAccount); + } + + function deployStakingVault() internal { + //Create StakingVault contract + StakingVault stakingVault = new StakingVault(address(0x22)); + ERC1967Proxy proxy = new ERC1967Proxy( + address(stakingVault), + abi.encodeWithSelector(StakingVault.initialize.selector, userAccount, nodeOperator, pdg_addr, "0x") + ); + stakingVaultProxy = StakingVault(payable(address(proxy))); + + vm.prank(rootAccount); + //Allow the stakingVault contract to be connected + vaultHubProxy.setAllowedCodehash(address(stakingVaultProxy).codehash, true); + } + + function setUp() public { + //LidoMock + //LidoLocatorMock + //LazyOracleMock + //ConsensusContractMock + deployMockContracts(); + + //VaultHub + deployVaultHub(); + + //OperatorGrid + deployOperatorGrid(); + + //StakingVault + deployStakingVault(); + + //Handler + svHandler = new StakingVaultsHandler(lidoLocator_addr, address(stakingVaultProxy), rootAccount, userAccount); + + //We advance time to avoid a freshly connected vault to be able to mint shares + //That would be possible because record.reportTimestamp (0 at connection) would be too close to block.timestamp (0 aswell) and considered fresh + vm.warp(block.timestamp + 3 days); + + //First connect StakingVault to VaultHub + svHandler.connectVault(); + + // Configure fuzzing targets + bytes4[] memory svSelectors = new bytes4[](14); + svSelectors[0] = svHandler.fund.selector; + svSelectors[1] = svHandler.VHwithdraw.selector; + svSelectors[2] = svHandler.forceRebalance.selector; + svSelectors[3] = svHandler.forceValidatorExit.selector; + svSelectors[4] = svHandler.mintShares.selector; + svSelectors[5] = svHandler.burnShares.selector; + svSelectors[6] = svHandler.transferAndBurnShares.selector; + svSelectors[7] = svHandler.voluntaryDisconnect.selector; + svSelectors[8] = svHandler.sv_otcDeposit.selector; + svSelectors[9] = svHandler.vh_otcDeposit.selector; + svSelectors[10] = svHandler.updateVaultData.selector; + svSelectors[11] = svHandler.SVwithdraw.selector; + svSelectors[12] = svHandler.connectVault.selector; + svSelectors[13] = svHandler.rebalance.selector; + + targetContract(address(svHandler)); + targetSelector(FuzzSelector({addr: address(svHandler), selectors: svSelectors})); + } + + ////////// INVARIANTS ////////// + + //With current deployed environement (no slashing, no stETH rebase) + //the staking Vault should never go below the rebalance threshold + //Meaning having less locked collateral than the threshold ratio limit (in regards to the liabilityShares converted in ETH) + //This is 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"); + } + + + // 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, + "Dynamic total value should not underflow" + ); + } + + // Invariant 3: forceRebalance should not revert when the vault is unhealthy. + function invariant3_forceRebalance_should_not_revert_when_unhealthy() external { + bool forceRebalanceReverted = svHandler.didForceRebalanceReverted(); + assertFalse(forceRebalanceReverted, "forceRebalance should not revert when unhealthy"); + } + + // 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, + "forceValidatorExit should not revert when unhealthy and vault balance is not sufficient" + ); + } + + // 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(); + + assertLe( + appliedTotalValue, + reportedTotalValue, + "Applied total value should not be greater than reported total value" + ); + } + + // 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)); + + //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; + } + _; + } + + // 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 + { + //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"); + // } + + // Invariant 8: Withdrawable value must be <= total value minus locked amount and unsettled obligations. + function invariant8_withdrawableValue_should_be_less_than_or_equal_to_totalValue_minus_locked_and_obligations() + external + { + //Get the withdrawable value of the vault + uint256 withdrawableValue = vaultHubProxy.withdrawableValue(address(stakingVaultProxy)); + + //Get the total value of the vault + uint256 totalValue = vaultHubProxy.totalValue(address(stakingVaultProxy)); + + //Get the locked amount and unsettled obligations + 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; + + assertLe( + withdrawableValue, + tv_minus_locked_and_obligations, + "Withdrawable value should be less than or equal to total value minus locked amount and unsettled obligations" + ); + } + + //The totalValue should be equal or above the real totalValue (EL+CL balance) + //totalValue = report.totalValue + current ioDelta - reported ioDelta + //This invariant catches the crit vulnerability that exploits + //- replay of same report + //- uncleared quarantine upon disconnect + //call path is pretty long but is: + //1. connectVault + //2. sv_otcDeposit + //3. updateVaultData -> triggers quarantine + //4. initializeDisconnect + //5. updateVaultData -> finalize disconnection + //6. connectVault + //7. updateVaultData -> generate a fresh report with TV + //8. SVwithdraw + //9. connectVault + //10. updateVaultData -> reuses previous report; quarantine is expired; TV is kept as is (special branch if the new quarantine delta is lower than the expired one). + // 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) + // 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..335d19dbba --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/StakingVaultsHandler.t.sol @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity ^0.8.25; + +import {CommonBase} from "forge-std/Base.sol"; +import {StdCheats} from "forge-std/StdCheats.sol"; +import {StdUtils} from "forge-std/StdUtils.sol"; + +import {StdAssertions} from "forge-std/StdAssertions.sol"; +import {Vm} from "forge-std/Vm.sol"; + +import {StakingVault} from "contracts/0.8.25/vaults/StakingVault.sol"; +import {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; +import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; +import {Math256} from "contracts/common/lib/Math256.sol"; +import {LidoLocatorMock, ConsensusContractMock} from "./mocks/CommonMocks.sol"; + +import {LazyOracleMock} from "./mocks/LazyOracleMock.sol"; +import {Constants} from "./StakingVaultConstants.sol"; +import "forge-std/console2.sol"; + + +/// @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; + 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; // Amount deposited on beacon chain + + uint256 constant MIN_SHARES = 1; + uint256 constant MAX_SHARES = 1000; + + uint256 public sv_otcDeposited = 0; + uint256 public vh_otcDeposited = 0; + + bool public forceRebalanceReverted = false; + bool public forceValidatorExitReverted = false; + + uint256 public appliedTotalValue = 0; + uint256 public reportedTotalValue = 0; + + /// @notice Sequence of actions for guided fuzzing + enum VaultAction { + CONNECT, + VOLUNTARY_DISCONNECT, + UPDATE_VAULT_DATA, + SV_OTC_DEPOSIT, + VH_OTC_DEPOSIT, + FUND, + VH_WITHDRAW, + SV_WITHDRAW + } + VaultAction[] public actionPath; + uint256 public actionIndex = 0; + + constructor(address _lidoLocator, address _stakingVault, address _rootAccount, address _userAccount) { + lidoLocator = LidoLocatorMock(_lidoLocator); + lidoContract = ILido(lidoLocator.lido()); + vaultHub = VaultHub(payable(lidoLocator.vaultHub())); + stakingVault = StakingVault(payable(_stakingVault)); + lazyOracle = LazyOracleMock(lidoLocator.lazyOracle()); + consensusContract = ConsensusContractMock(lidoLocator.consensusContract()); + rootAccount = _rootAccount; + userAccount = _userAccount; + actionPath = [ + VaultAction.CONNECT, // connect + VaultAction.SV_OTC_DEPOSIT, // OTC funds + VaultAction.UPDATE_VAULT_DATA, // trigger quarantine + VaultAction.VOLUNTARY_DISCONNECT, // pendingDisconnect + VaultAction.UPDATE_VAULT_DATA, // disconnected + 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 correct sequence + } + _; + } + + // --- Getters for invariant checks --- + + function getAppliedTotalValue() public view returns (uint256) { + return appliedTotalValue; + } + + function getReportedTotalValue() public view returns (uint256) { + return reportedTotalValue; + } + + function didForceRebalanceReverted() public view returns (bool) { + return forceRebalanceReverted; + } + + function didForceValidatorExitReverted() public view returns (bool) { + return forceValidatorExitReverted; + } + // --- 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)); + + //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)); + } + + /// @notice Funds the vault via VaultHub + function fund(uint256 amount) public { + amount = bound(amount, 1, 1 ether); + deal(address(userAccount), amount); + vm.prank(userAccount); + vaultHub.fund{value: amount}(address(stakingVault)); + } + + /// @notice Withdraws from the vault via VaultHub + function VHwithdraw(uint256 amount) public { + amount = bound(amount, 1, vaultHub.withdrawableValue(address(stakingVault))); + + //check that stakingVault is connected + if (vaultHub.vaultConnection(address(stakingVault)).vaultIndex == 0) { + return; + } + vm.prank(userAccount); + vaultHub.withdraw(address(stakingVault), userAccount, amount); + } + + /// @notice Forces a rebalance if the vault is unhealthy + function forceRebalance() public { + if (vaultHub.isVaultHealthy(address(stakingVault))) { + 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; + if ( + vaultHub.isVaultHealthy(address(stakingVault)) && + redemptions < Math256.max(Constants.UNSETTLED_THRESHOLD, address(stakingVault).balance) + ) { + return; + } + bytes memory pubkeys = new bytes(0); + vm.prank(rootAccount); //privileged account can force exit + try vaultHub.forceValidatorExit{value: 3000}(address(stakingVault), pubkeys, userAccount) { + // If the call succeeds, we do nothing + } catch { + forceValidatorExitReverted = true; + } + } + + /// @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; + } + 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; + } + vm.prank(userAccount); + 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) + function getEffectiveVaultTotalValue() public view returns (uint256) { + return address(stakingVault).balance + cl_balance; + } + + /// @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 --- + + /// @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 + console2.log("DaysShift = %d", daysShift); + + //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); + lazyOracle.setVaultDataTimestamp(uint64(block.timestamp)); + VaultHub.VaultObligations memory obligations = vaultHub.vaultObligations(address(stakingVault)); + + lastReport = VaultReport({ + totalValue: vaultHub.totalValue(address(stakingVault)) + sv_otcDeposited + cl_balance, + cumulativeLidoFees: obligations.settledLidoFees + obligations.unsettledLidoFees + 1, + liabilityShares: vaultHub.liabilityShares(address(stakingVault)), + reportTimestamp: uint64(block.timestamp) + }); + + //reset otc deposit value + sv_otcDeposited = 0; + } + // Simulate next ref slot + (uint256 refSlot, ) = consensusContract.getCurrentFrame(); + if (daysShift > 0) { + refSlot += daysShift; + consensusContract.setCurrentFrame(refSlot); + } + // If no new report since vault connection, skip + if (lastReport.totalValue == 0 && lastReport.cumulativeLidoFees == 0) return; + + //we update the reported total Value + reportedTotalValue = lastReport.totalValue; + + //update the vault data + lazyOracle.updateVaultData( + address(stakingVault), + lastReport.totalValue, + lastReport.cumulativeLidoFees, + lastReport.liabilityShares, + uint64(block.timestamp) + ); + + //we update the applied total value (TV should go through sanity checks, quarantine, etc.) + appliedTotalValue = vaultHub.vaultRecord(address(stakingVault)).report.totalValue; + // Accept ownership if disconnect was successful + if (stakingVault.pendingOwner() == userAccount) { + vm.prank(userAccount); + stakingVault.acceptOwnership(); + } + } + + // --- StakingVault interactions --- + + /// @notice Withdraws directly from the staking vault (when not managed by VaultHub) + function SVwithdraw(uint256 amount) public { + if (stakingVault.owner() != userAccount) { + return; + } + amount = bound(amount, 1, address(stakingVault).balance); + + vm.prank(userAccount); + stakingVault.withdraw(userAccount, amount); + } +} diff --git a/test/0.8.25/invariant-fuzzing/mocks/CommonMocks.sol b/test/0.8.25/invariant-fuzzing/mocks/CommonMocks.sol new file mode 100644 index 0000000000..16a7a7f21a --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/mocks/CommonMocks.sol @@ -0,0 +1,461 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.25; + +import {IHashConsensus} from "contracts/0.8.25/vaults/interfaces/IHashConsensus.sol"; +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; +import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; +import {OperatorGrid} from "contracts/0.8.25/vaults/OperatorGrid.sol"; +import {SafeCast} from "@openzeppelin/contracts-v5.2/utils/math/SafeCast.sol"; +import {Math256} from "contracts/common/lib/Math256.sol"; +import {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; + +contract ConsensusContractMock is IHashConsensus { + uint256 public refSlot; + uint256 public reportProcessingDeadlineSlot; + + constructor(uint256 _refSlot, uint256 _reportProcessingDeadlineSlot) { + refSlot = _refSlot; + reportProcessingDeadlineSlot = _reportProcessingDeadlineSlot; + } + + function getCurrentFrame() external view returns (uint256, uint256) { + return (refSlot, reportProcessingDeadlineSlot); + } + + function setCurrentFrame(uint256 newRefSlot) external { + refSlot = newRefSlot; + } + + function getIsMember(address) external view returns (bool) { + return true; + } + + function getChainConfig() + external + view + returns (uint256 slotsPerEpoch, uint256 secondsPerSlot, uint256 genesisTime) + { + return (0, 0, 0); + } + + function getFrameConfig() external view returns (uint256 initialEpoch, uint256 epochsPerFrame) { + return (0, 0); + } + + function getInitialRefSlot() external view returns (uint256) { + return 0; + } +} + +contract LidoLocatorMock { + address public lido_; + address public predepositGuarantee_; + address public accounting_; + address public treasury_; + address public operatorGrid_; + address public lazyOracle_; + address public vaultHub_; + address public consensusContract_; + + constructor( + address _lido, + address _predepositGuarantee, + address _accounting, + address _treasury, + address _operatorGrid, + address _lazyOracle, + address _vaultHub, + address _consensusContract + ) { + lido_ = _lido; + predepositGuarantee_ = _predepositGuarantee; + accounting_ = _accounting; + treasury_ = _treasury; + operatorGrid_ = _operatorGrid; + lazyOracle_ = _lazyOracle; + vaultHub_ = _vaultHub; + consensusContract_ = _consensusContract; + } + + function lido() external view returns (address) { + return lido_; + } + function operatorGrid() external view returns (address) { + return operatorGrid_; + } + + function predepositGuarantee() external view returns (address) { + return predepositGuarantee_; + } + + function accounting() external view returns (address) { + return accounting_; + } + + function treasury() external view returns (address) { + return treasury_; + } + + function lazyOracle() external view returns (address) { + return lazyOracle_; + } + + function vaultHub() external view returns (address) { + return vaultHub_; + } + + function consensusContract() external view returns (address) { + return consensusContract_; + } +} + +contract LazyOracleMock { + struct Storage { + /// @notice root of the vaults data tree + bytes32 vaultsDataTreeRoot; + /// @notice CID of the vaults data tree + string vaultsDataReportCid; + /// @notice timestamp of the vaults data + uint64 vaultsDataTimestamp; + /// @notice total value increase quarantine period + uint64 quarantinePeriod; + /// @notice max reward ratio for refSlot-observed total value, basis points + uint16 maxRewardRatioBP; + /// @notice deposit quarantines for each vault + mapping(address vault => Quarantine) vaultQuarantines; + } + + struct Quarantine { + uint128 pendingTotalValueIncrease; + uint64 startTimestamp; + } + + struct VaultInfo { + address vault; + uint96 vaultIndex; + uint256 balance; + bytes32 withdrawalCredentials; + uint256 liabilityShares; + uint256 mintableStETH; + uint96 shareLimit; + uint16 reserveRatioBP; + uint16 forcedRebalanceThresholdBP; + uint16 infraFeeBP; + uint16 liquidityFeeBP; + uint16 reservationFeeBP; + bool pendingDisconnect; + } + + struct QuarantineInfo { + bool isActive; + uint256 pendingTotalValueIncrease; + uint256 startTimestamp; + uint256 endTimestamp; + } + + // keccak256(abi.encode(uint256(keccak256("LazyOracle")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant LAZY_ORACLE_STORAGE_LOCATION = + 0xe5459f2b48ec5df2407caac4ec464a5cb0f7f31a1f22f649728a9579b25c1d00; + + bytes32 public constant UPDATE_SANITY_PARAMS_ROLE = keccak256("UPDATE_SANITY_PARAMS_ROLE"); + + // total basis points = 100% + uint256 internal constant TOTAL_BP = 100_00; + + ILidoLocator public immutable LIDO_LOCATOR; + IHashConsensus public immutable HASH_CONSENSUS; + + /// @dev basis points base + uint256 private constant TOTAL_BASIS_POINTS = 100_00; + + constructor(address _lidoLocator, address _hashConsensus, uint64 _quarantinePeriod, uint16 _maxRewardRatioBP) { + LIDO_LOCATOR = ILidoLocator(payable(_lidoLocator)); + HASH_CONSENSUS = IHashConsensus(_hashConsensus); + _updateSanityParams(_quarantinePeriod, _maxRewardRatioBP); + } + + /// @notice returns the latest report timestamp + function latestReportTimestamp() external view returns (uint64) { + return _storage().vaultsDataTimestamp; + } + + /// @notice returns the quarantine period + function quarantinePeriod() external view returns (uint64) { + return _storage().quarantinePeriod; + } + + /// @notice returns the max reward ratio for refSlot total value, basis points + function maxRewardRatioBP() external view returns (uint16) { + return _storage().maxRewardRatioBP; + } + + /// @notice returns the quarantine info for the vault + /// @param _vault the address of the vault + // @dev returns zeroed structure if there is no active quarantine + function vaultQuarantine(address _vault) external view returns (QuarantineInfo memory) { + Quarantine storage q = _storage().vaultQuarantines[_vault]; + if (q.pendingTotalValueIncrease == 0) { + return QuarantineInfo(false, 0, 0, 0); + } + + return + QuarantineInfo( + true, + q.pendingTotalValueIncrease, + q.startTimestamp, + q.startTimestamp + _storage().quarantinePeriod + ); + } + + /// @notice update the sanity parameters + /// @param _quarantinePeriod the quarantine period + /// @param _maxRewardRatioBP the max EL CL rewards + function updateSanityParams(uint64 _quarantinePeriod, uint16 _maxRewardRatioBP) external { + _updateSanityParams(_quarantinePeriod, _maxRewardRatioBP); + } + + function setVaultDataTimestamp(uint64 _vaultsDataTimestamp) external { + Storage storage $ = _storage(); + $.vaultsDataTimestamp = uint64(_vaultsDataTimestamp); + } + + /// @notice Permissionless update of the vault data + /// @param _vault the address of the vault + /// @param _totalValue the total value of the vault + /// @param _cumulativeLidoFees the cumulative Lido fees accrued on the vault (nominated in ether) + /// @param _liabilityShares the liabilityShares of the vault + function updateVaultData( + address _vault, + uint256 _totalValue, + uint256 _cumulativeLidoFees, + uint256 _liabilityShares, + uint64 _vaultsDataTimestamp + ) external { + // bytes32 leaf = keccak256( + // bytes.concat(keccak256(abi.encode(_vault, _totalValue, _cumulativeLidoFees, _liabilityShares))) + // ); + //if (!MerkleProof.verify(_proof, _storage().vaultsDataTreeRoot, leaf)) revert InvalidProof(); + + int256 inOutDelta; + (_totalValue, inOutDelta) = _handleSanityChecks(_vault, _totalValue); + + _vaultHub().applyVaultReport( + _vault, + _vaultsDataTimestamp, + _totalValue, + inOutDelta, + _cumulativeLidoFees, + _liabilityShares + ); + } + + /// @notice handle sanity checks for the vault lazy report data + /// @param _vault the address of the vault + /// @param _totalValue the total value of the vault in refSlot + /// @return totalValue the smoothed total value of the vault after sanity checks + /// @return inOutDelta the inOutDelta in the refSlot + function _handleSanityChecks( + address _vault, + uint256 _totalValue + ) public returns (uint256 totalValue, int256 inOutDelta) { + VaultHub vaultHub = _vaultHub(); + VaultHub.VaultRecord memory record = vaultHub.vaultRecord(_vault); + + // 1. Calculate inOutDelta in the refSlot + int256 curInOutDelta = record.inOutDelta.value; + (uint256 refSlot, ) = HASH_CONSENSUS.getCurrentFrame(); + if (record.inOutDelta.refSlot == refSlot) { + inOutDelta = record.inOutDelta.refSlotValue; + } else { + inOutDelta = curInOutDelta; + } + + // 2. Sanity check for total value increase + totalValue = _processTotalValue(_vault, _totalValue, inOutDelta, record); + + // 3. Sanity check for dynamic total value underflow + if (int256(totalValue) + curInOutDelta - inOutDelta < 0) revert UnderflowInTotalValueCalculation(); + + return (totalValue, inOutDelta); + } + + function _processTotalValue( + address _vault, + uint256 _totalValue, + int256 _inOutDelta, + VaultHub.VaultRecord memory record + ) internal returns (uint256) { + Storage storage $ = _storage(); + + uint256 refSlotTotalValue = uint256( + int256(uint256(record.report.totalValue)) + _inOutDelta - record.report.inOutDelta + ); + // some percentage of funds hasn't passed through the vault's balance is allowed for the EL and CL rewards handling + uint256 limit = (refSlotTotalValue * (TOTAL_BP + $.maxRewardRatioBP)) / TOTAL_BP; + + if (_totalValue > limit) { + Quarantine storage q = $.vaultQuarantines[_vault]; + uint64 reportTs = $.vaultsDataTimestamp; + uint128 quarDelta = q.pendingTotalValueIncrease; + uint128 delta = SafeCast.toUint128(_totalValue - refSlotTotalValue); + + if (quarDelta == 0) { + // first overlimit report + _totalValue = refSlotTotalValue; + q.pendingTotalValueIncrease = delta; + q.startTimestamp = reportTs; + emit QuarantinedDeposit(_vault, delta); + } else if (reportTs - q.startTimestamp < $.quarantinePeriod) { + // quarantine not expired + _totalValue = refSlotTotalValue; + } else if (delta <= quarDelta + (refSlotTotalValue * $.maxRewardRatioBP) / TOTAL_BP) { + // quarantine expired + q.pendingTotalValueIncrease = 0; + emit QuarantineExpired(_vault, delta); + } else { + // start new quarantine + _totalValue = refSlotTotalValue + quarDelta; + q.pendingTotalValueIncrease = delta - quarDelta; + q.startTimestamp = reportTs; + emit QuarantinedDeposit(_vault, delta - quarDelta); + } + } + + return _totalValue; + } + + function _updateSanityParams(uint64 _quarantinePeriod, uint16 _maxRewardRatioBP) internal { + Storage storage $ = _storage(); + $.quarantinePeriod = _quarantinePeriod; + $.maxRewardRatioBP = _maxRewardRatioBP; + emit SanityParamsUpdated(_quarantinePeriod, _maxRewardRatioBP); + } + + function _mintableStETH(address _vault) internal view returns (uint256) { + VaultHub vaultHub = _vaultHub(); + uint256 maxLockableValue = vaultHub.maxLockableValue(_vault); + uint256 reserveRatioBP = vaultHub.vaultConnection(_vault).reserveRatioBP; + uint256 mintableStETHByRR = (maxLockableValue * (TOTAL_BASIS_POINTS - reserveRatioBP)) / TOTAL_BASIS_POINTS; + + uint256 effectiveShareLimit = _operatorGrid().effectiveShareLimit(_vault); + uint256 mintableStEthByShareLimit = ILido(LIDO_LOCATOR.lido()).getPooledEthBySharesRoundUp(effectiveShareLimit); + + return Math256.min(mintableStETHByRR, mintableStEthByShareLimit); + } + + function _storage() internal pure returns (Storage storage $) { + assembly { + $.slot := LAZY_ORACLE_STORAGE_LOCATION + } + } + + function _vaultHub() internal view returns (VaultHub) { + return VaultHub(payable(LIDO_LOCATOR.vaultHub())); + } + + function _operatorGrid() internal view returns (OperatorGrid) { + return OperatorGrid(LIDO_LOCATOR.operatorGrid()); + } + + event VaultsReportDataUpdated(uint256 indexed timestamp, bytes32 indexed root, string cid); + event QuarantinedDeposit(address indexed vault, uint128 delta); + event SanityParamsUpdated(uint64 quarantinePeriod, uint16 maxRewardRatioBP); + event QuarantineExpired(address indexed vault, uint128 delta); + error AdminCannotBeZero(); + error NotAuthorized(); + error InvalidProof(); + error UnderflowInTotalValueCalculation(); +} + +contract LidoMock { + uint256 public totalShares; + uint256 public externalShares; + uint256 public totalPooledEther; + uint256 public bufferedEther; + + constructor(uint256 _totalShares, uint256 _totalPooledEther, uint256 _externalShares) { + if (_totalShares == 0) revert("totalShares cannot be 0"); + if (_totalPooledEther == 0) revert("totalPooledEther cannot be 0"); + + totalShares = _totalShares; + totalPooledEther = _totalPooledEther; + externalShares = _externalShares; + } + + function getSharesByPooledEth(uint256 _ethAmount) public view returns (uint256) { + return (_ethAmount * totalShares) / totalPooledEther; + } + + function getPooledEthByShares(uint256 _sharesAmount) public view returns (uint256) { + return (_sharesAmount * totalPooledEther) / totalShares; + } + + function getTotalShares() external view returns (uint256) { + return totalShares; + } + + function getExternalShares() external view returns (uint256) { + return externalShares; + } + + function mintExternalShares(address, uint256 _amountOfShares) external { + totalShares += _amountOfShares; + externalShares += _amountOfShares; + } + + function burnExternalShares(uint256 _amountOfShares) external { + totalShares -= _amountOfShares; + externalShares -= _amountOfShares; + } + + function stake() external payable { + uint256 sharesAmount = getSharesByPooledEth(msg.value); + totalShares += sharesAmount; + totalPooledEther += msg.value; + } + + function receiveRewards(uint256 _rewards) external { + totalPooledEther += _rewards; + } + + function getExternalEther() external view returns (uint256) { + return _getExternalEther(totalPooledEther); + } + + function _getExternalEther(uint256 _internalEther) internal view returns (uint256) { + return (externalShares * _internalEther) / (totalShares - externalShares); + } + + function rebalanceExternalEtherToInternal() external payable { + uint256 shares = getSharesByPooledEth(msg.value); + if (shares > externalShares) revert("not enough external shares"); + externalShares -= shares; + totalPooledEther += msg.value; + } + + function getPooledEthBySharesRoundUp(uint256 _sharesAmount) external view returns (uint256) { + uint256 etherAmount = (_sharesAmount * totalPooledEther) / totalShares; + if (_sharesAmount * totalPooledEther != etherAmount * totalShares) { + ++etherAmount; + } + return etherAmount; + } + + function transferSharesFrom(address, address, uint256) external pure returns (uint256) { + return 0; + } + + function getTotalPooledEther() external view returns (uint256) { + return totalPooledEther; + } + + function mintShares(address, uint256 _sharesAmount) external { + totalShares += _sharesAmount; + } + + function burnShares(uint256 _amountOfShares) external { + totalShares -= _amountOfShares; + } +} diff --git a/test/0.8.25/invariant-fuzzing/mocks/LazyOracleMock.sol b/test/0.8.25/invariant-fuzzing/mocks/LazyOracleMock.sol new file mode 100644 index 0000000000..8a4096bbe8 --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/mocks/LazyOracleMock.sol @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity 0.8.25; + +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; +import {IHashConsensus} from "contracts/0.8.25/vaults/interfaces/IHashConsensus.sol"; + +import {SafeCast} from "@openzeppelin/contracts-v5.2/utils/math/SafeCast.sol"; + +//import {AccessControlEnumerableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; + +import {Math256} from "contracts/common/lib/Math256.sol"; + +import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; +import {OperatorGrid} from "contracts/0.8.25/vaults/OperatorGrid.sol"; +//import {StakingVault} from "contracts/0.8.25/vaults/StakingVault.sol"; + +import {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; + +contract LazyOracleMock { + struct Storage { + /// @notice root of the vaults data tree + bytes32 vaultsDataTreeRoot; + /// @notice CID of the vaults data tree + string vaultsDataReportCid; + /// @notice timestamp of the vaults data + uint64 vaultsDataTimestamp; + /// @notice total value increase quarantine period + uint64 quarantinePeriod; + /// @notice max reward ratio for refSlot-observed total value, basis points + uint16 maxRewardRatioBP; + /// @notice deposit quarantines for each vault + mapping(address vault => Quarantine) vaultQuarantines; + } + + struct Quarantine { + uint128 pendingTotalValueIncrease; + uint64 startTimestamp; + } + + struct VaultInfo { + address vault; + uint96 vaultIndex; + uint256 balance; + bytes32 withdrawalCredentials; + uint256 liabilityShares; + uint256 mintableStETH; + uint96 shareLimit; + uint16 reserveRatioBP; + uint16 forcedRebalanceThresholdBP; + uint16 infraFeeBP; + uint16 liquidityFeeBP; + uint16 reservationFeeBP; + bool pendingDisconnect; + } + + struct QuarantineInfo { + bool isActive; + uint256 pendingTotalValueIncrease; + uint256 startTimestamp; + uint256 endTimestamp; + } + + // keccak256(abi.encode(uint256(keccak256("LazyOracle")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant LAZY_ORACLE_STORAGE_LOCATION = + 0xe5459f2b48ec5df2407caac4ec464a5cb0f7f31a1f22f649728a9579b25c1d00; + + bytes32 public constant UPDATE_SANITY_PARAMS_ROLE = keccak256("UPDATE_SANITY_PARAMS_ROLE"); + + // total basis points = 100% + uint256 internal constant TOTAL_BP = 100_00; + + ILidoLocator public immutable LIDO_LOCATOR; + IHashConsensus public immutable HASH_CONSENSUS; + + /// @dev basis points base + uint256 private constant TOTAL_BASIS_POINTS = 100_00; + + constructor(address _lidoLocator, address _hashConsensus, uint64 _quarantinePeriod, uint16 _maxRewardRatioBP) { + LIDO_LOCATOR = ILidoLocator(payable(_lidoLocator)); + HASH_CONSENSUS = IHashConsensus(_hashConsensus); + _updateSanityParams(_quarantinePeriod, _maxRewardRatioBP); + } + + /// @notice returns the latest report timestamp + function latestReportTimestamp() external view returns (uint64) { + return _storage().vaultsDataTimestamp; + } + + /// @notice returns the quarantine period + function quarantinePeriod() external view returns (uint64) { + return _storage().quarantinePeriod; + } + + /// @notice returns the max reward ratio for refSlot total value, basis points + function maxRewardRatioBP() external view returns (uint16) { + return _storage().maxRewardRatioBP; + } + + /// @notice returns the quarantine info for the vault + /// @param _vault the address of the vault + // @dev returns zeroed structure if there is no active quarantine + function vaultQuarantine(address _vault) external view returns (QuarantineInfo memory) { + Quarantine storage q = _storage().vaultQuarantines[_vault]; + if (q.pendingTotalValueIncrease == 0) { + return QuarantineInfo(false, 0, 0, 0); + } + + return + QuarantineInfo( + true, + q.pendingTotalValueIncrease, + q.startTimestamp, + q.startTimestamp + _storage().quarantinePeriod + ); + } + + /// @notice update the sanity parameters + /// @param _quarantinePeriod the quarantine period + /// @param _maxRewardRatioBP the max EL CL rewards + function updateSanityParams(uint64 _quarantinePeriod, uint16 _maxRewardRatioBP) external { + _updateSanityParams(_quarantinePeriod, _maxRewardRatioBP); + } + + function setVaultDataTimestamp(uint64 _vaultsDataTimestamp) external { + Storage storage $ = _storage(); + $.vaultsDataTimestamp = uint64(_vaultsDataTimestamp); + } + + /// @notice Permissionless update of the vault data + /// @param _vault the address of the vault + /// @param _totalValue the total value of the vault + /// @param _cumulativeLidoFees the cumulative Lido fees accrued on the vault (nominated in ether) + /// @param _liabilityShares the liabilityShares of the vault + function updateVaultData( + address _vault, + uint256 _totalValue, + uint256 _cumulativeLidoFees, + uint256 _liabilityShares, + uint64 _vaultsDataTimestamp + ) external { + // bytes32 leaf = keccak256( + // bytes.concat(keccak256(abi.encode(_vault, _totalValue, _cumulativeLidoFees, _liabilityShares))) + // ); + //if (!MerkleProof.verify(_proof, _storage().vaultsDataTreeRoot, leaf)) revert InvalidProof(); + + int256 inOutDelta; + (_totalValue, inOutDelta) = _handleSanityChecks(_vault, _totalValue); + + _vaultHub().applyVaultReport( + _vault, + _vaultsDataTimestamp, + _totalValue, + inOutDelta, + _cumulativeLidoFees, + _liabilityShares + ); + } + + /// @notice handle sanity checks for the vault lazy report data + /// @param _vault the address of the vault + /// @param _totalValue the total value of the vault in refSlot + /// @return totalValue the smoothed total value of the vault after sanity checks + /// @return inOutDelta the inOutDelta in the refSlot + function _handleSanityChecks( + address _vault, + uint256 _totalValue + ) public returns (uint256 totalValue, int256 inOutDelta) { + VaultHub vaultHub = _vaultHub(); + VaultHub.VaultRecord memory record = vaultHub.vaultRecord(_vault); + + // 1. Calculate inOutDelta in the refSlot + int256 curInOutDelta = record.inOutDelta.value; + (uint256 refSlot, ) = HASH_CONSENSUS.getCurrentFrame(); + if (record.inOutDelta.refSlot == refSlot) { + inOutDelta = record.inOutDelta.refSlotValue; + } else { + inOutDelta = curInOutDelta; + } + + // 2. Sanity check for total value increase + totalValue = _processTotalValue(_vault, _totalValue, inOutDelta, record); + + // 3. Sanity check for dynamic total value underflow + if (int256(totalValue) + curInOutDelta - inOutDelta < 0) revert UnderflowInTotalValueCalculation(); + + return (totalValue, inOutDelta); + } + + function _processTotalValue( + address _vault, + uint256 _totalValue, + int256 _inOutDelta, + VaultHub.VaultRecord memory record + ) internal returns (uint256) { + Storage storage $ = _storage(); + + uint256 refSlotTotalValue = uint256( + int256(uint256(record.report.totalValue)) + _inOutDelta - record.report.inOutDelta + ); + // some percentage of funds hasn't passed through the vault's balance is allowed for the EL and CL rewards handling + uint256 limit = (refSlotTotalValue * (TOTAL_BP + $.maxRewardRatioBP)) / TOTAL_BP; + + if (_totalValue > limit) { + Quarantine storage q = $.vaultQuarantines[_vault]; + uint64 reportTs = $.vaultsDataTimestamp; + uint128 quarDelta = q.pendingTotalValueIncrease; + uint128 delta = SafeCast.toUint128(_totalValue - refSlotTotalValue); + + if (quarDelta == 0) { + // first overlimit report + _totalValue = refSlotTotalValue; + q.pendingTotalValueIncrease = delta; + q.startTimestamp = reportTs; + emit QuarantinedDeposit(_vault, delta); + } else if (reportTs - q.startTimestamp < $.quarantinePeriod) { + // quarantine not expired + _totalValue = refSlotTotalValue; + } else if (delta <= quarDelta + (refSlotTotalValue * $.maxRewardRatioBP) / TOTAL_BP) { + // quarantine expired + q.pendingTotalValueIncrease = 0; + emit QuarantineExpired(_vault, delta); + } else { + // start new quarantine + _totalValue = refSlotTotalValue + quarDelta; + q.pendingTotalValueIncrease = delta - quarDelta; + q.startTimestamp = reportTs; + emit QuarantinedDeposit(_vault, delta - quarDelta); + } + } + + return _totalValue; + } + + function _updateSanityParams(uint64 _quarantinePeriod, uint16 _maxRewardRatioBP) internal { + Storage storage $ = _storage(); + $.quarantinePeriod = _quarantinePeriod; + $.maxRewardRatioBP = _maxRewardRatioBP; + emit SanityParamsUpdated(_quarantinePeriod, _maxRewardRatioBP); + } + + function _mintableStETH(address _vault) internal view returns (uint256) { + VaultHub vaultHub = _vaultHub(); + uint256 maxLockableValue = vaultHub.maxLockableValue(_vault); + uint256 reserveRatioBP = vaultHub.vaultConnection(_vault).reserveRatioBP; + uint256 mintableStETHByRR = (maxLockableValue * (TOTAL_BASIS_POINTS - reserveRatioBP)) / TOTAL_BASIS_POINTS; + + uint256 effectiveShareLimit = _operatorGrid().effectiveShareLimit(_vault); + uint256 mintableStEthByShareLimit = ILido(LIDO_LOCATOR.lido()).getPooledEthBySharesRoundUp(effectiveShareLimit); + + return Math256.min(mintableStETHByRR, mintableStEthByShareLimit); + } + + function _storage() internal pure returns (Storage storage $) { + assembly { + $.slot := LAZY_ORACLE_STORAGE_LOCATION + } + } + + function _vaultHub() internal view returns (VaultHub) { + return VaultHub(payable(LIDO_LOCATOR.vaultHub())); + } + + function _operatorGrid() internal view returns (OperatorGrid) { + return OperatorGrid(LIDO_LOCATOR.operatorGrid()); + } + + event VaultsReportDataUpdated(uint256 indexed timestamp, bytes32 indexed root, string cid); + event QuarantinedDeposit(address indexed vault, uint128 delta); + event SanityParamsUpdated(uint64 quarantinePeriod, uint16 maxRewardRatioBP); + event QuarantineExpired(address indexed vault, uint128 delta); + error AdminCannotBeZero(); + error NotAuthorized(); + error InvalidProof(); + error UnderflowInTotalValueCalculation(); +} diff --git a/test/0.8.25/invariant-fuzzing/mocks/OperatorGridMock.sol b/test/0.8.25/invariant-fuzzing/mocks/OperatorGridMock.sol new file mode 100644 index 0000000000..c0653880a6 --- /dev/null +++ b/test/0.8.25/invariant-fuzzing/mocks/OperatorGridMock.sol @@ -0,0 +1,699 @@ +// SPDX-FileCopyrightText: 2025 Lido +// SPDX-License-Identifier: GPL-3.0 + +// See contracts/COMPILERS.md +pragma solidity 0.8.25; + +import { + AccessControlEnumerableUpgradeable +} from "contracts/openzeppelin/5.2/upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; + +import {Math256} from "contracts/common/lib/Math256.sol"; +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; + +//import {Confirmable2Addresses} from "../utils/Confirmable2Addresses.sol"; + +import {IStakingVault} from "contracts/0.8.25/vaults/interfaces/IStakingVault.sol"; +import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; + +struct TierParams { + uint256 shareLimit; + uint256 reserveRatioBP; + uint256 forcedRebalanceThresholdBP; + uint256 infraFeeBP; + uint256 liquidityFeeBP; + uint256 reservationFeeBP; +} + +/** + * @title OperatorGrid + * @author loga4 + * @notice + * OperatorGrid is a contract that manages mint parameters for vaults when they are connected to the VaultHub. + * These parameters include: + * - shareLimit: maximum amount of shares that can be minted + * - reserveRatioBP: reserve ratio in basis points + * - forcedRebalanceThresholdBP: forced rebalance threshold in basis points + * - infraFeeBP: infra fee in basis points + * - liquidityFeeBP: liquidity fee in basis points + * - reservationFeeBP: reservation fee in basis points + * + * These parameters are determined by the Tier in which the Vault is registered. + * + */ +//, Confirmable2Addresses { +contract OperatorGridMock is AccessControlEnumerableUpgradeable { + /* + Key concepts: + 1. Default Registration: + - All Vaults initially have default tier (DEFAULT_TIER_ID = 0) + - The default tier has no group + + DEFAULT_TIER_ID = 0 + ┌──────────────────────┐ + │ Tier 1 │ + │ tierShareLimit = z │ + │ Vault_1 ... Vault_m │ + └──────────────────────┘ + + 2. Tier Change Process: + - To predefine vaults tier or modify the existing vault's connection parameters to VaultHub, a tier change must be requested + - Both vault owner and node operator must confirm the change (doesn't matter who confirms first) + - The confirmation has an expiry time (default 1 hour) + + 3. Tier Reset: + - When a vault is disconnected from VaultHub, its tier is automatically reset to the default tier (DEFAULT_TIER_ID) + + 4. Tier Capacity: + - Tiers are not limited by the number of vaults + - Tiers are limited by the sum of vaults' liability shares + + ┌──────────────────────────────────────────────────────┐ + │ Group 1 = operator 1 │ + │ ┌────────────────────────────────────────────────┐ │ + │ │ groupShareLimit = 1kk │ │ + │ └────────────────────────────────────────────────┘ │ + │ ┌──────────────────────┐ ┌──────────────────────┐ │ + │ │ Tier 1 │ │ Tier 2 │ │ + │ │ tierShareLimit = x │ │ tierShareLimit = y │ │ + │ │ Vault_2 ... Vault_k │ │ │ │ + │ └──────────────────────┘ └──────────────────────┘ │ + └──────────────────────────────────────────────────────┘ + */ + + bytes32 public constant REGISTRY_ROLE = keccak256("vaults.OperatorsGrid.Registry"); + + /// @notice Lido Locator contract + ILidoLocator public immutable LIDO_LOCATOR; + + uint256 public constant DEFAULT_TIER_ID = 0; + + // Special address to denote that default tier is not linked to any real operator + address public constant DEFAULT_TIER_OPERATOR = address(uint160(type(uint160).max)); + + /// @dev basis points base + uint256 internal constant TOTAL_BASIS_POINTS = 100_00; + /// @dev max value for fees in basis points - it's about 650% + uint256 internal constant MAX_FEE_BP = type(uint16).max; + + // ----------------------------- + // STRUCTS + // ----------------------------- + struct Group { + address operator; + uint96 shareLimit; + uint96 liabilityShares; + uint256[] tierIds; + } + + struct Tier { + address operator; + uint96 shareLimit; + uint96 liabilityShares; + uint16 reserveRatioBP; + uint16 forcedRebalanceThresholdBP; + uint16 infraFeeBP; + uint16 liquidityFeeBP; + uint16 reservationFeeBP; + } + + /** + * @notice ERC-7201 storage namespace for the OperatorGrid + * @dev ERC-7201 namespace is used to prevent upgrade collisions + * @custom:storage-location erc7201:Lido.Vaults.OperatorGrid + * @custom:tiers Tiers + * @custom:vaultTier Vault tier + * @custom:groups Groups + * @custom:nodeOperators Node operators + */ + struct ERC7201Storage { + Tier[] tiers; + mapping(address vault => uint256 tierId) vaultTier; + mapping(address nodeOperator => Group) groups; + address[] nodeOperators; + } + + /** + * @notice Storage offset slot for ERC-7201 namespace + * The storage namespace is used to prevent upgrade collisions + * keccak256(abi.encode(uint256(keccak256("Lido.Vaults.OperatorGrid")) - 1)) & ~bytes32(uint256(0xff)) + */ + bytes32 private constant OPERATOR_GRID_STORAGE_LOCATION = + 0x6b64617c951381e2c1eff2be939fe368ab6d76b7d335df2e47ba2309eba1c700; + + /// @notice Initializes the contract with a LidoLocator + /// @param _locator LidoLocator contract + constructor(ILidoLocator _locator) { + LIDO_LOCATOR = _locator; + + _disableInitializers(); + } + + /// @notice Initializes the contract with an admin + /// @param _admin Address of the admin + /// @param _defaultTierParams Default tier params for the default tier + function initialize(address _admin, TierParams calldata _defaultTierParams) external initializer { + if (_admin == address(0)) revert ZeroArgument("_admin"); + + __AccessControlEnumerable_init(); + //__Confirmations_init(); + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + + ERC7201Storage storage $ = _getStorage(); + + //create default tier with default share limit + $.tiers.push( + Tier({ + operator: DEFAULT_TIER_OPERATOR, + shareLimit: uint96(_defaultTierParams.shareLimit), + reserveRatioBP: uint16(_defaultTierParams.reserveRatioBP), + forcedRebalanceThresholdBP: uint16(_defaultTierParams.forcedRebalanceThresholdBP), + infraFeeBP: uint16(_defaultTierParams.infraFeeBP), + liquidityFeeBP: uint16(_defaultTierParams.liquidityFeeBP), + reservationFeeBP: uint16(_defaultTierParams.reservationFeeBP), + liabilityShares: 0 + }) + ); + } + + /// @notice Registers a new group + /// @param _nodeOperator address of the node operator + /// @param _shareLimit Maximum share limit for the group + function registerGroup(address _nodeOperator, uint256 _shareLimit) external onlyRole(REGISTRY_ROLE) { + if (_nodeOperator == address(0)) revert ZeroArgument("_nodeOperator"); + + ERC7201Storage storage $ = _getStorage(); + if ($.groups[_nodeOperator].operator != address(0)) revert GroupExists(); + + $.groups[_nodeOperator] = Group({ + operator: _nodeOperator, + shareLimit: uint96(_shareLimit), + liabilityShares: 0, + tierIds: new uint256[](0) + }); + $.nodeOperators.push(_nodeOperator); + + emit GroupAdded(_nodeOperator, uint96(_shareLimit)); + } + + /// @notice Updates the share limit of a group + /// @param _nodeOperator address of the node operator + /// @param _shareLimit New share limit value + function updateGroupShareLimit(address _nodeOperator, uint256 _shareLimit) external onlyRole(REGISTRY_ROLE) { + if (_nodeOperator == address(0)) revert ZeroArgument("_nodeOperator"); + + ERC7201Storage storage $ = _getStorage(); + Group storage group_ = $.groups[_nodeOperator]; + if (group_.operator == address(0)) revert GroupNotExists(); + + group_.shareLimit = uint96(_shareLimit); + + emit GroupShareLimitUpdated(_nodeOperator, uint96(_shareLimit)); + } + + /// @notice Returns a group by node operator address + /// @param _nodeOperator address of the node operator + /// @return Group + function group(address _nodeOperator) external view returns (Group memory) { + return _getStorage().groups[_nodeOperator]; + } + + /// @notice Returns a node operator address by index + /// @param _index index of the node operator + /// @return Node operator address + function nodeOperatorAddress(uint256 _index) external view returns (address) { + ERC7201Storage storage $ = _getStorage(); + if (_index >= $.nodeOperators.length) revert NodeOperatorNotExists(); + return $.nodeOperators[_index]; + } + + /// @notice Returns a node operator count + /// @return Node operator count + function nodeOperatorCount() external view returns (uint256) { + return _getStorage().nodeOperators.length; + } + + /// @notice Registers a new tier + /// @param _nodeOperator address of the node operator + /// @param _tiers array of tiers to register + function registerTiers(address _nodeOperator, TierParams[] calldata _tiers) external onlyRole(REGISTRY_ROLE) { + if (_nodeOperator == address(0)) revert ZeroArgument("_nodeOperator"); + + ERC7201Storage storage $ = _getStorage(); + Group storage group_ = $.groups[_nodeOperator]; + if (group_.operator == address(0)) revert GroupNotExists(); + + uint256 tierId = $.tiers.length; + uint256 length = _tiers.length; + for (uint256 i = 0; i < length; i++) { + _validateParams( + tierId, + _tiers[i].reserveRatioBP, + _tiers[i].forcedRebalanceThresholdBP, + _tiers[i].infraFeeBP, + _tiers[i].liquidityFeeBP, + _tiers[i].reservationFeeBP + ); + + Tier memory tier_ = Tier({ + operator: _nodeOperator, + shareLimit: uint96(_tiers[i].shareLimit), + reserveRatioBP: uint16(_tiers[i].reserveRatioBP), + forcedRebalanceThresholdBP: uint16(_tiers[i].forcedRebalanceThresholdBP), + infraFeeBP: uint16(_tiers[i].infraFeeBP), + liquidityFeeBP: uint16(_tiers[i].liquidityFeeBP), + reservationFeeBP: uint16(_tiers[i].reservationFeeBP), + liabilityShares: 0 + }); + $.tiers.push(tier_); + group_.tierIds.push(tierId); + + emit TierAdded( + _nodeOperator, + tierId, + uint96(tier_.shareLimit), + uint16(tier_.reserveRatioBP), + uint16(tier_.forcedRebalanceThresholdBP), + uint16(tier_.infraFeeBP), + uint16(tier_.liquidityFeeBP), + uint16(tier_.reservationFeeBP) + ); + + tierId++; + } + } + + /// @notice Returns a tier by ID + /// @param _tierId id of the tier + /// @return Tier + function tier(uint256 _tierId) external view returns (Tier memory) { + ERC7201Storage storage $ = _getStorage(); + if (_tierId >= $.tiers.length) revert TierNotExists(); + return $.tiers[_tierId]; + } + + /// @notice Returns a tiers count + /// @return Tiers count + function tiersCount() external view returns (uint256) { + return _getStorage().tiers.length; + } + + /// @notice Alters multiple tiers + /// @dev We do not enforce to update old vaults with the new tier params, only new ones. + /// @param _tierIds array of tier ids to alter + /// @param _tierParams array of new tier params + function alterTiers( + uint256[] calldata _tierIds, + TierParams[] calldata _tierParams + ) external onlyRole(REGISTRY_ROLE) { + if (_tierIds.length != _tierParams.length) revert ArrayLengthMismatch(); + + ERC7201Storage storage $ = _getStorage(); + uint256 length = _tierIds.length; + uint256 tiersLength = $.tiers.length; + + for (uint256 i = 0; i < length; i++) { + if (_tierIds[i] >= tiersLength) revert TierNotExists(); + + _validateParams( + _tierIds[i], + _tierParams[i].reserveRatioBP, + _tierParams[i].forcedRebalanceThresholdBP, + _tierParams[i].infraFeeBP, + _tierParams[i].liquidityFeeBP, + _tierParams[i].reservationFeeBP + ); + + Tier storage tier_ = $.tiers[_tierIds[i]]; + + tier_.shareLimit = uint96(_tierParams[i].shareLimit); + tier_.reserveRatioBP = uint16(_tierParams[i].reserveRatioBP); + tier_.forcedRebalanceThresholdBP = uint16(_tierParams[i].forcedRebalanceThresholdBP); + tier_.infraFeeBP = uint16(_tierParams[i].infraFeeBP); + tier_.liquidityFeeBP = uint16(_tierParams[i].liquidityFeeBP); + tier_.reservationFeeBP = uint16(_tierParams[i].reservationFeeBP); + + emit TierUpdated( + _tierIds[i], + tier_.shareLimit, + tier_.reserveRatioBP, + tier_.forcedRebalanceThresholdBP, + tier_.infraFeeBP, + tier_.liquidityFeeBP, + tier_.reservationFeeBP + ); + } + } + + /// @notice Vault tier change with multi-role confirmation + /// @param _vault address of the vault + /// @param _requestedTierId id of the tier + /// @param _requestedShareLimit share limit to set + /// @return bool Whether the tier change was confirmed. + /* + + Legend: + V = Vault1.liabilityShares + LS = liabilityShares + + Scheme1 - transfer Vault from default tier to Tier2 + + ┌──────────────────────────────┐ + │ Group 1 │ + │ │ + ┌────────────────────┐ │ ┌─────────┐ ┌───────────┐ │ + │ Tier 1 (default) │ confirm │ │ Tier 2 │ │ Tier 3 │ │ + │ LS: -V │ ─────> │ │ LS:+V │ │ │ │ + └────────────────────┘ │ └─────────┘ └───────────┘ │ + │ │ + │ Group1.liabilityShares: +V │ + └──────────────────────────────┘ + + After confirmation: + - Tier 1.liabilityShares = -V + - Tier 2.liabilityShares = +V + - Group1.liabilityShares = +V + + -------------------------------------------------------------------------- + Scheme2 - transfer Vault from Tier2 to Tier3, no need to change group minted shares + + ┌────────────────────────────────┐ ┌────────────────────────────────┐ + │ Group 1 │ │ Group 2 │ + │ │ │ │ + │ ┌───────────┐ ┌───────────┐ │ │ ┌───────────┐ │ + │ │ Tier 2 │ │ Tier 3 │ │ │ │ Tier 4 │ │ + │ │ LS:-V │ │ LS:+V │ │ │ │ │ │ + │ └───────────┘ └───────────┘ │ │ └───────────┘ │ + │ operator1 │ │ operator2 │ + └────────────────────────────────┘ └────────────────────────────────┘ + + After confirmation: + - Tier 2.liabilityShares = -V + - Tier 3.liabilityShares = +V + + NB: Cannot change from Tier2 to Tier1, because Tier1 has no group + NB: Cannot change from Tier2 to Tier4, because Tier4 has different operator. + + */ + function changeTier( + address _vault, + uint256 _requestedTierId, + uint256 _requestedShareLimit + ) external returns (bool) { + if (_vault == address(0)) revert ZeroArgument("_vault"); + + ERC7201Storage storage $ = _getStorage(); + if (_requestedTierId >= $.tiers.length) revert TierNotExists(); + if (_requestedTierId == DEFAULT_TIER_ID) revert CannotChangeToDefaultTier(); + + VaultHub vaultHub = _vaultHub(); + bool isVaultConnected = vaultHub.isVaultConnected(_vault); + + address vaultOwner = isVaultConnected ? vaultHub.vaultConnection(_vault).owner : IStakingVault(_vault).owner(); + + address nodeOperator = IStakingVault(_vault).nodeOperator(); + + uint256 vaultTierId = $.vaultTier[_vault]; + if (vaultTierId == _requestedTierId) revert TierAlreadySet(); + + Tier storage requestedTier = $.tiers[_requestedTierId]; + if (nodeOperator != requestedTier.operator) revert TierNotInOperatorGroup(); + if (_requestedShareLimit > requestedTier.shareLimit) + revert RequestedShareLimitTooHigh(_requestedShareLimit, requestedTier.shareLimit); + + // store the caller's confirmation; only proceed if the required number of confirmations is met. + //if (!_collectAndCheckConfirmations(msg.data, vaultOwner, nodeOperator)) return false; + uint256 vaultLiabilityShares = vaultHub.liabilityShares(_vault); + + //check if tier limit is exceeded + if (requestedTier.liabilityShares + vaultLiabilityShares > requestedTier.shareLimit) revert TierLimitExceeded(); + + // if the vault was in the default tier: + // - that mean that the vault has no group, so we decrease only the minted shares of the default tier + // - but need to check requested group limit exceeded + if (vaultTierId == DEFAULT_TIER_ID) { + Group storage requestedGroup = $.groups[nodeOperator]; + if (requestedGroup.liabilityShares + vaultLiabilityShares > requestedGroup.shareLimit) { + revert GroupLimitExceeded(); + } + requestedGroup.liabilityShares += uint96(vaultLiabilityShares); + } + + Tier storage currentTier = $.tiers[vaultTierId]; + + currentTier.liabilityShares -= uint96(vaultLiabilityShares); + requestedTier.liabilityShares += uint96(vaultLiabilityShares); + + $.vaultTier[_vault] = _requestedTierId; + + // Vault may not be connected to VaultHub yet. + // There are two possible flows: + // 1. Vault is created and connected to VaultHub immediately with the default tier. + // In this case, `VaultConnection` is non-zero and updateConnection must be called. + // 2. Vault is created, its tier is changed before connecting to VaultHub. + // In this case, `VaultConnection` is still zero, and updateConnection must be skipped. + // Hence, we update the VaultHub connection only if the vault is already connected. + vaultHub.updateConnection( + _vault, + _requestedShareLimit, + requestedTier.reserveRatioBP, + requestedTier.forcedRebalanceThresholdBP, + requestedTier.infraFeeBP, + requestedTier.liquidityFeeBP, + requestedTier.reservationFeeBP + ); + + emit TierChanged(_vault, _requestedTierId, _requestedShareLimit); + + return true; + } + + /// @notice Reset vault's tier to default + /// @param _vault address of the vault + /// @dev Requires vault's liabilityShares to be zero before resetting the tier + function resetVaultTier(address _vault) external { + if (msg.sender != LIDO_LOCATOR.vaultHub()) revert NotAuthorized("resetVaultTier", msg.sender); + + ERC7201Storage storage $ = _getStorage(); + + if ($.vaultTier[_vault] != DEFAULT_TIER_ID) { + $.vaultTier[_vault] = DEFAULT_TIER_ID; + + emit TierChanged(_vault, DEFAULT_TIER_ID, $.tiers[DEFAULT_TIER_ID].shareLimit); + } + } + + // ----------------------------- + // MINT / BURN + // ----------------------------- + + /// @notice Mint shares limit check + /// @param _vault address of the vault + /// @param _amount amount of shares will be minted + function onMintedShares(address _vault, uint256 _amount) external { + if (msg.sender != LIDO_LOCATOR.vaultHub()) revert NotAuthorized("onMintedShares", msg.sender); + + ERC7201Storage storage $ = _getStorage(); + + uint256 tierId = $.vaultTier[_vault]; + Tier storage tier_ = $.tiers[tierId]; + + uint96 tierLiabilityShares = tier_.liabilityShares; + if (tierLiabilityShares + _amount > tier_.shareLimit) revert TierLimitExceeded(); + + tier_.liabilityShares = tierLiabilityShares + uint96(_amount); + + if (tierId != DEFAULT_TIER_ID) { + Group storage group_ = $.groups[tier_.operator]; + uint96 groupMintedShares = group_.liabilityShares; + if (groupMintedShares + _amount > group_.shareLimit) revert GroupLimitExceeded(); + + group_.liabilityShares = groupMintedShares + uint96(_amount); + } + } + + /// @notice Burn shares limit check + /// @param _vault address of the vault + /// @param _amount amount of shares to burn + function onBurnedShares(address _vault, uint256 _amount) external { + if (msg.sender != LIDO_LOCATOR.vaultHub()) revert NotAuthorized("burnShares", msg.sender); + + ERC7201Storage storage $ = _getStorage(); + + uint256 tierId = $.vaultTier[_vault]; + + Tier storage tier_ = $.tiers[tierId]; + + // we skip the check for minted shared underflow, because it's done in the VaultHub.burnShares() + + tier_.liabilityShares -= uint96(_amount); + + if (tierId != DEFAULT_TIER_ID) { + Group storage group_ = $.groups[tier_.operator]; + group_.liabilityShares -= uint96(_amount); + } + } + + /// @notice Get vault limits + /// @param _vault address of the vault + /// @return nodeOperator node operator of the vault + /// @return tierId tier id of the vault + /// @return shareLimit share limit of the vault + /// @return reserveRatioBP reserve ratio of the vault + /// @return forcedRebalanceThresholdBP forced rebalance threshold of the vault + /// @return infraFeeBP infra fee of the vault + /// @return liquidityFeeBP liquidity fee of the vault + /// @return reservationFeeBP reservation fee of the vault + function vaultInfo( + address _vault + ) + external + view + returns ( + address nodeOperator, + uint256 tierId, + uint256 shareLimit, + uint256 reserveRatioBP, + uint256 forcedRebalanceThresholdBP, + uint256 infraFeeBP, + uint256 liquidityFeeBP, + uint256 reservationFeeBP + ) + { + ERC7201Storage storage $ = _getStorage(); + + tierId = $.vaultTier[_vault]; + + Tier memory t = $.tiers[tierId]; + nodeOperator = t.operator; + + shareLimit = t.shareLimit; + reserveRatioBP = t.reserveRatioBP; + forcedRebalanceThresholdBP = t.forcedRebalanceThresholdBP; + infraFeeBP = t.infraFeeBP; + liquidityFeeBP = t.liquidityFeeBP; + reservationFeeBP = t.reservationFeeBP; + } + + /// @notice Returns the effective share limit of a vault according to the OperatorGrid and vault share limits + /// @param _vault address of the vault + /// @return shareLimit effective share limit of the vault + function effectiveShareLimit(address _vault) public view returns (uint256) { + VaultHub vaultHub = _vaultHub(); + uint256 shareLimit = vaultHub.vaultConnection(_vault).shareLimit; + uint256 liabilityShares = vaultHub.liabilityShares(_vault); + + uint256 gridShareLimit = _gridRemainingShareLimit(_vault) + liabilityShares; + return Math256.min(gridShareLimit, shareLimit); + } + + /// @notice Returns the remaining share limit in a given tier and group + /// @param _vault address of the vault + /// @return remaining share limit + /// @dev remaining share limit inherits the limits of the vault tier and group, + /// and accounts liabilities of other vaults belonging to the same tier and group + function _gridRemainingShareLimit(address _vault) internal view returns (uint256) { + ERC7201Storage storage $ = _getStorage(); + uint256 tierId = $.vaultTier[_vault]; + Tier storage t = $.tiers[tierId]; + + uint256 tierLimit = t.shareLimit; + uint256 tierRemaining = tierLimit > t.liabilityShares ? tierLimit - t.liabilityShares : 0; + + if (tierId == DEFAULT_TIER_ID) return tierRemaining; + + Group storage g = $.groups[t.operator]; + uint256 groupLimit = g.shareLimit; + uint256 groupRemaining = groupLimit > g.liabilityShares ? groupLimit - g.liabilityShares : 0; + return Math256.min(tierRemaining, groupRemaining); + } + + /// @notice Validates tier parameters + /// @param _reserveRatioBP Reserve ratio + /// @param _forcedRebalanceThresholdBP Forced rebalance threshold + /// @param _infraFeeBP Infra fee + /// @param _liquidityFeeBP Liquidity fee + /// @param _reservationFeeBP Reservation fee + function _validateParams( + uint256 _tierId, + uint256 _reserveRatioBP, + uint256 _forcedRebalanceThresholdBP, + uint256 _infraFeeBP, + uint256 _liquidityFeeBP, + uint256 _reservationFeeBP + ) internal pure { + if (_reserveRatioBP == 0) revert ZeroArgument("_reserveRatioBP"); + if (_reserveRatioBP > TOTAL_BASIS_POINTS) + revert ReserveRatioTooHigh(_tierId, _reserveRatioBP, TOTAL_BASIS_POINTS); + + if (_forcedRebalanceThresholdBP == 0) revert ZeroArgument("_forcedRebalanceThresholdBP"); + if (_forcedRebalanceThresholdBP > _reserveRatioBP) + revert ForcedRebalanceThresholdTooHigh(_tierId, _forcedRebalanceThresholdBP, _reserveRatioBP); + + if (_infraFeeBP > MAX_FEE_BP) revert InfraFeeTooHigh(_tierId, _infraFeeBP, MAX_FEE_BP); + + if (_liquidityFeeBP > MAX_FEE_BP) revert LiquidityFeeTooHigh(_tierId, _liquidityFeeBP, MAX_FEE_BP); + + if (_reservationFeeBP > MAX_FEE_BP) revert ReservationFeeTooHigh(_tierId, _reservationFeeBP, MAX_FEE_BP); + } + + function _vaultHub() internal view returns (VaultHub) { + return VaultHub(payable(LIDO_LOCATOR.vaultHub())); + } + + function _getStorage() private pure returns (ERC7201Storage storage $) { + assembly { + $.slot := OPERATOR_GRID_STORAGE_LOCATION + } + } + + // ----------------------------- + // EVENTS + // ----------------------------- + event GroupAdded(address indexed nodeOperator, uint256 shareLimit); + event GroupShareLimitUpdated(address indexed nodeOperator, uint256 shareLimit); + event TierAdded( + address indexed nodeOperator, + uint256 indexed tierId, + uint256 shareLimit, + uint256 reserveRatioBP, + uint256 forcedRebalanceThresholdBP, + uint256 infraFeeBP, + uint256 liquidityFeeBP, + uint256 reservationFeeBP + ); + event TierChanged(address indexed vault, uint256 indexed tierId, uint256 shareLimit); + event TierUpdated( + uint256 indexed tierId, + uint256 shareLimit, + uint256 reserveRatioBP, + uint256 forcedRebalanceThresholdBP, + uint256 infraFeeBP, + uint256 liquidityFeeBP, + uint256 reservationFeeBP + ); + + // ----------------------------- + // ERRORS + // ----------------------------- + error NotAuthorized(string operation, address sender); + error ZeroArgument(string argument); + error GroupExists(); + error GroupNotExists(); + error GroupLimitExceeded(); + error NodeOperatorNotExists(); + error TierLimitExceeded(); + + error TierNotExists(); + error TierAlreadySet(); + error TierNotInOperatorGroup(); + error CannotChangeToDefaultTier(); + + error ReserveRatioTooHigh(uint256 tierId, uint256 reserveRatioBP, uint256 maxReserveRatioBP); + error ForcedRebalanceThresholdTooHigh(uint256 tierId, uint256 forcedRebalanceThresholdBP, uint256 reserveRatioBP); + error InfraFeeTooHigh(uint256 tierId, uint256 infraFeeBP, uint256 maxInfraFeeBP); + error LiquidityFeeTooHigh(uint256 tierId, uint256 liquidityFeeBP, uint256 maxLiquidityFeeBP); + error ReservationFeeTooHigh(uint256 tierId, uint256 reservationFeeBP, uint256 maxReservationFeeBP); + error ArrayLengthMismatch(); + error RequestedShareLimitTooHigh(uint256 requestedShareLimit, uint256 tierShareLimit); +} 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)