diff --git a/test/0.8.25/Accounting.t.sol b/test/0.8.25/Accounting.t.sol index c54bb9734f..7ceec98c90 100644 --- a/test/0.8.25/Accounting.t.sol +++ b/test/0.8.25/Accounting.t.sol @@ -247,8 +247,7 @@ contract AccountingHandler is CommonBase, StdCheats, StdUtils { withdrawalVaultBalance: 0, sharesRequestedToBurn: 0, withdrawalFinalizationBatches: new uint256[](0), - vaultValues: new uint256[](0), - inOutDeltas: new int256[](0) + vaultsTotalDeficit: 0 }); ghost.unifiedClBalanceWei = int256(fuzz._clBalanceWei + currentReport.withdrawalVaultBalance); // ? diff --git a/test/0.8.25/Protocol__Deployment.t.sol b/test/0.8.25/Protocol__Deployment.t.sol index 46849401ae..3bf2f79ec8 100644 --- a/test/0.8.25/Protocol__Deployment.t.sol +++ b/test/0.8.25/Protocol__Deployment.t.sol @@ -14,7 +14,9 @@ import {LidoLocator} from "contracts/0.8.9/LidoLocator.sol"; import {LimitsList} from "contracts/0.8.9/sanity_checks/OracleReportSanityChecker.sol"; import {StakingRouter__MockForLidoAccountingFuzzing} from "./contracts/StakingRouter__MockForLidoAccountingFuzzing.sol"; -import {SecondOpinionOracle__MockForAccountingFuzzing} from "./contracts/SecondOpinionOracle__MockForAccountingFuzzing.sol"; +import { + SecondOpinionOracle__MockForAccountingFuzzing +} from "./contracts/SecondOpinionOracle__MockForAccountingFuzzing.sol"; import {WithdrawalQueue, IWstETH} from "../../contracts/0.8.9/WithdrawalQueue.sol"; import {WithdrawalQueueERC721} from "../../contracts/0.8.9/WithdrawalQueueERC721.sol"; @@ -265,7 +267,7 @@ contract BaseProtocolTest is Test { // Deploy AccountingOracle deployCodeTo( "AccountingOracle.sol:AccountingOracle", - abi.encode(address(lidoLocator), lidoLocator.legacyOracle(), 12, genesisTimestamp), + abi.encode(address(lidoLocator), 12, genesisTimestamp), lidoLocator.accountingOracle() ); @@ -296,6 +298,8 @@ contract BaseProtocolTest is Test { "OracleReportSanityChecker.sol:OracleReportSanityChecker", abi.encode( address(lidoLocator), + lidoLocator.accountingOracle(), + lidoLocator.accounting(), rootAccount, [ limitList.exitedValidatorsPerDayLimit, @@ -394,7 +398,6 @@ contract BaseProtocolTest is Test { accountingOracle: makeAddr("dummy-locator:accountingOracle"), depositSecurityModule: makeAddr("dummy-locator:depositSecurityModule"), elRewardsVault: makeAddr("dummy-locator:elRewardsVault"), - legacyOracle: makeAddr("dummy-locator:legacyOracle"), lido: lido, oracleReportSanityChecker: makeAddr("dummy-locator:oracleReportSanityChecker"), postTokenRebaseReceiver: address(0), @@ -408,7 +411,12 @@ contract BaseProtocolTest is Test { accounting: makeAddr("dummy-locator:accounting"), predepositGuarantee: makeAddr("dummy-locator:predeposit_guarantee"), wstETH: wstETHAdr, - vaultHub: makeAddr("dummy-locator:vaultHub") + vaultHub: makeAddr("dummy-locator:vaultHub"), + lazyOracle: makeAddr("dummy-locator:lazyOracle"), + operatorGrid: makeAddr("dummy-locator:operatorGrid"), + validatorExitDelayVerifier: makeAddr("dummy-locator:validatorExitDelayVerifier"), + triggerableWithdrawalsGateway: makeAddr("dummy-locator:triggerableWithdrawalsGateway"), + vaultFactory: makeAddr("dummy-locator:vaultFactory") }); return LidoLocator(deployCode("LidoLocator.sol:LidoLocator", abi.encode(config))); diff --git a/test/0.8.25/vaults/staking-vault/RewardSimulator.sol b/test/0.8.25/vaults/staking-vault/RewardSimulator.sol new file mode 100644 index 0000000000..6c1b3723b6 --- /dev/null +++ b/test/0.8.25/vaults/staking-vault/RewardSimulator.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {RandomLib} from "./RandomLib.sol"; + +contract RewardSimulator { + using RandomLib for RandomLib.Storage; + + uint256 constant SECONDS_PER_DAY = 86400; + uint256 constant APR_DENOMINATOR = 10000; + uint256 constant DAYS_PER_YEAR = 365; + + uint256 internal immutable APR_MIN; + uint256 internal immutable APR_MAX; + uint256 internal immutable MIN_VALIDATOR_BALANCE; + + uint256 private currentAPR; + uint256 private lastRewardTimestamp; + RandomLib.Storage private rnd; + + constructor(uint256 _seed, uint256 _aprMin, uint256 _aprMax, uint256 _minValidatorBalance) { + rnd.seed = _seed; + lastRewardTimestamp = block.timestamp; + APR_MIN = _aprMin; + APR_MAX = _aprMax; + MIN_VALIDATOR_BALANCE = _minValidatorBalance; + currentAPR = APR_MIN + rnd.randInt(APR_MAX - APR_MIN); + } + + function getDailyReward() public returns (uint256) { + uint256 timePassed = block.timestamp - lastRewardTimestamp; + if (timePassed < SECONDS_PER_DAY) { + return 0; + } + + uint256 daysPassed = timePassed / SECONDS_PER_DAY; + lastRewardTimestamp += daysPassed * SECONDS_PER_DAY; + + uint256 yearlyReward = (MIN_VALIDATOR_BALANCE * currentAPR) / APR_DENOMINATOR; + uint256 dailyReward = (yearlyReward * daysPassed) / DAYS_PER_YEAR; + + int256 randomVariation = int256(rnd.randInt(200)) - 100; + dailyReward = uint256((int256(dailyReward) * (1000 + randomVariation)) / 1000); + + if (rnd.randBool()) { + currentAPR = APR_MIN + rnd.randInt(APR_MAX - APR_MIN); + } + + return dailyReward; + } +} diff --git a/test/0.8.25/vaults/staking-vault/StakingVaultTest.t.sol b/test/0.8.25/vaults/staking-vault/StakingVaultTest.t.sol deleted file mode 100644 index 699fe8b71c..0000000000 --- a/test/0.8.25/vaults/staking-vault/StakingVaultTest.t.sol +++ /dev/null @@ -1,570 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -// for testing purposes only -// ┌────────────────────┐ -// │ │ -// │ Owner │ -// │ │ -// └──┬──────────────┬──┘ -// │ │ -// fund withdraw -// SV.balance += x SV.balance -= x -// inOutDelta += x inOutDelta -= x -// │ │ -// │ │ -// ┌──────────────────┐──report(old data)──▶┌─▼──────────────▼─┐ depositToBeaconChain ┌───────────────────────┐ -// │ │ │ │◀───SV.balance -= deposit──│ Depositor │ -// │ │ rebalance │ │ └───────────────────────┘ -// │ VaultHub │─ SV.balance -= x──▶│ StakingVault │ -// │ │ inOutDelta -= x │ │ rewards ┌───────────────────────┐ -// │ │ │ │◀───SV.balance += reward───│ Validator │ -// └──────────────────┘───────lock─────────▶└──────────────────┘ └───────────────────────┘ - -pragma solidity ^0.8.0; - -import {Test} from "forge-std/Test.sol"; -import {console2} from "forge-std/console2.sol"; -import {Math} from "@openzeppelin/contracts-v5.2/utils/math/Math.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts-v5.2/proxy/ERC1967/ERC1967Proxy.sol"; -import {StakingVault} from "contracts/0.8.25/vaults/StakingVault.sol"; -import {IStakingVault} from "contracts/0.8.25/vaults/interfaces/IStakingVault.sol"; -import {DepositContract__MockForStakingVault} from "./contracts/DepositContract__MockForStakingVault.sol"; -import {RandomLib} from "./RandomLib.sol"; - -contract VaultHubMock is Test { - using RandomLib for RandomLib.Storage; - - uint256 constant TOTAL_BASIS_POINTS = 10000; - uint256 constant MAX_MINTABLE_RATIO_BP = 8000; // 80% can be used for minting (100% - reserve ratio) - uint256 constant RESERVE_RATIO_BP = 1000; // 10% reserve ratio - uint256 constant REWARD_RATE_MIN_BP = 100; // 1% min reward rate - uint256 constant REWARD_RATE_MAX_BP = 500; // 5% max reward rate - uint256 constant TREASURY_FEE_BP = 500; // 5% treasury fee - uint256 constant REBALANCE_THRESHOLD_BP = 9500; // 95% - vault needs rebalance if valuation drops below this - - RandomLib.Storage private rnd; - - constructor(uint256 _seed) { - rnd.seed = _seed; - } - - function getTotalEtherToLock(uint256 vaultValuation) public returns (uint256) { - uint256 maxMintableEther = (vaultValuation * MAX_MINTABLE_RATIO_BP) / TOTAL_BASIS_POINTS; - uint256 amountToMint = rnd.randInt(maxMintableEther); - return (amountToMint * TOTAL_BASIS_POINTS) / MAX_MINTABLE_RATIO_BP; - } - - function getNewValuation(uint256 currentValuation) public returns (uint256) { - uint256 rewardRateBP = REWARD_RATE_MIN_BP + rnd.randInt(REWARD_RATE_MAX_BP - REWARD_RATE_MIN_BP); - uint256 newValuation = currentValuation + (currentValuation * rewardRateBP) / TOTAL_BASIS_POINTS; - uint256 treasuryFee = ((newValuation - currentValuation) * TREASURY_FEE_BP) / TOTAL_BASIS_POINTS; - newValuation -= treasuryFee; - return newValuation; - } - - function getNewLocked(uint256 currentLocked) public returns (uint256) { - return rnd.randInt(currentLocked / 2, currentLocked); - } - - function getAmountToUnlock(uint256 currentValuation, uint256 currentLocked) public pure returns (uint256) { - uint256 minRequiredValuation = (currentLocked * TOTAL_BASIS_POINTS) / RESERVE_RATIO_BP; - uint256 rebalanceThreshold = (minRequiredValuation * REBALANCE_THRESHOLD_BP) / TOTAL_BASIS_POINTS; - - if (currentValuation >= rebalanceThreshold) { - return 0; - } - - uint256 targetLocked = (currentValuation * RESERVE_RATIO_BP) / TOTAL_BASIS_POINTS; - return currentLocked - targetLocked; - } - - event Mock__Rebalanced(address indexed vault, uint256 amount); - - function rebalance() external payable { - emit Mock__Rebalanced(msg.sender, msg.value); - } -} - -contract ValidatorMock is Test { - using RandomLib for RandomLib.Storage; - - uint256 constant SECONDS_PER_DAY = 86400; - uint256 constant APR_DENOMINATOR = 10000; - uint256 constant DAYS_PER_YEAR = 365; - uint256 constant APR_MIN = 300; // 3.00% minimum APR - uint256 constant APR_MAX = 500; // 5.00% maximum APR - uint256 constant MIN_VALIDATOR_BALANCE = 32 ether; - - uint256 private currentAPR; - uint256 private lastRewardTimestamp; - - RandomLib.Storage private rnd; - - constructor(uint256 _seed) { - rnd.seed = _seed; - lastRewardTimestamp = block.timestamp; - currentAPR = APR_MIN + rnd.randInt(APR_MAX - APR_MIN); - } - - function getDailyReward() public returns (uint256) { - uint256 timePassed = block.timestamp - lastRewardTimestamp; - if (timePassed < SECONDS_PER_DAY) { - return 0; - } - - uint256 daysPassed = timePassed / SECONDS_PER_DAY; - lastRewardTimestamp += daysPassed * SECONDS_PER_DAY; - - uint256 yearlyReward = (MIN_VALIDATOR_BALANCE * currentAPR) / APR_DENOMINATOR; - uint256 dailyReward = (yearlyReward * daysPassed) / DAYS_PER_YEAR; - - int256 randomVariation = int256(rnd.randInt(200)) - 100; - dailyReward = uint256((int256(dailyReward) * (1000 + randomVariation)) / 1000); - - if (rnd.randBool()) { - currentAPR = APR_MIN + rnd.randInt(APR_MAX - APR_MIN); - } - - return dailyReward; - } -} - -contract StakingVaultTest is Test { - using RandomLib for RandomLib.Storage; - - error LockedCannotDecreaseOutsideOfReport(uint256 currentlyLocked, uint256 attemptedLocked); - error NotAuthorized(string operation, address sender); - error ZeroArgument(string name); - error InsufficientBalance(uint256 balance); - error RebalanceAmountExceedsValuation(uint256 valuation, uint256 rebalanceAmount); - - uint256 constant ITERATIONS = 32; - uint256 constant MAJOR_STATE_TRANSITIONS = 32; - uint256 constant VALIDATOR_DEPOSIT = 32 ether; - uint256 constant CONNECT_DEPOSIT = 1 ether; - uint256 constant SECONDS_PER_DAY = 86400; - - StakingVault private stakingVault; - StakingVault private stakingVaultProxy; - VaultHubMock private vaultHub; - ValidatorMock private validator; - - uint256 private deposits; - uint256 private withdrawals; - uint256 private rewards; - uint256 private randomUserDeposits; - uint256 private depositsToBeaconChain; - uint256 private vaultHubBalance; - - address private depositor = address(0x001); - address private owner = address(0x002); - address private nodeOperator = address(0x003); - address private user = address(0x004); - - RandomLib.Storage private rnd; - - bool private isConnectedToHub; - bool private hasValidator; - - function testSolvencyAllTransitions() external { - runTests(5686631772487049791906286); - } - - function testFuzz_SolvencyAllTransitions(uint256 _seed) external { - runTests(_seed); - } - - function runTests(uint256 _seed) internal { - require( - MAJOR_STATE_TRANSITIONS * ITERATIONS * 10 ** 25 * 2 <= type(uint256).max, - "MAJOR_STATE_TRANSITIONS * ITERATIONS overflow" - ); - deploy(_seed); - - uint256 initialBalance = address(stakingVaultProxy).balance; - int256 initialInOutDelta = stakingVaultProxy.inOutDelta(); - - for (uint256 i = 0; i < MAJOR_STATE_TRANSITIONS; i++) { - performMajorStateTransition(hasValidator, isConnectedToHub); - for (uint256 iterationIdx = 0; iterationIdx < ITERATIONS; iterationIdx++) { - randomTransition(hasValidator, isConnectedToHub); - } - } - - uint256 finalBalance = address(stakingVaultProxy).balance; - int256 finalInOutDelta = stakingVaultProxy.inOutDelta(); - - console2.log("VaultHub balance: %d", vaultHubBalance); - assertEq( - deposits + initialBalance + rewards + randomUserDeposits, - finalBalance + withdrawals + depositsToBeaconChain + vaultHubBalance - ); - assertEq(initialInOutDelta + int256(deposits), finalInOutDelta + int256(withdrawals) + int256(vaultHubBalance)); - } - - function deploy(uint256 _seed) public { - rnd.seed = _seed; - isConnectedToHub = false; - hasValidator = false; - deposits = 0; - withdrawals = 0; - rewards = 0; - randomUserDeposits = 0; - depositsToBeaconChain = 0; - vaultHubBalance = 0; - - DepositContract__MockForStakingVault depositContract = new DepositContract__MockForStakingVault(); - vaultHub = new VaultHubMock(_seed); - validator = new ValidatorMock(_seed); - stakingVault = new StakingVault(address(vaultHub), depositor, address(depositContract)); - - ERC1967Proxy proxy = new ERC1967Proxy( - address(stakingVault), - abi.encodeWithSelector(StakingVault.initialize.selector, owner, nodeOperator, "0x") - ); - stakingVaultProxy = StakingVault(payable(address(proxy))); - } - - function performMajorStateTransition(bool _hasValidator, bool _isConnectedToHub) internal { - bool oldIsConnectedToHub = _isConnectedToHub; - bool oldHasValidator = _hasValidator; - - hasValidator = rnd.randBool(); - isConnectedToHub = rnd.randBool(); - - if (oldIsConnectedToHub != isConnectedToHub) { - if (isConnectedToHub) { - transitionConnectVaultToHub(); - } else if (!isConnectedToHub) { - transitionDisconnectVaultFromHub(); - } - } - - if (oldHasValidator != hasValidator) { - if (hasValidator) { - transitionDepositToBeaconChain(); - } else if (!hasValidator) { - transitionValidatorExitAndReturnDeposit(); - } - } - } - - function randomTransition(bool _hasValidator, bool _isConnectedToHub) internal { - vm.warp(block.timestamp + rnd.randInt(2 * SECONDS_PER_DAY)); - function() internal[] memory availableTransitions = getAvailableTransitions(_hasValidator, _isConnectedToHub); - uint256 transitionIndex = rnd.randInt(availableTransitions.length - 1); - availableTransitions[transitionIndex](); - } - - function baseTransitions() internal pure returns (function() internal[] memory) { - function() internal[] memory transitions = new function() internal[](3); - transitions[0] = transitionRandomFund; - transitions[1] = transitionRandomWithdraw; - transitions[2] = transitionRandomUserDeposit; - return transitions; - } - - function validatorTransitions() internal pure returns (function() internal[] memory) { - function() internal[] memory transitions = new function() internal[](1); - transitions[0] = transitionRandomReceiveReward; - return transitions; - } - - function vaultHubTransitions() internal pure returns (function() internal[] memory) { - function() internal[] memory transitions = new function() internal[](3); - transitions[0] = transitionRandomMintShares; - transitions[1] = transitionRandomReport; - transitions[2] = transitionRandomRebalance; - return transitions; - } - - function mergeTransitions( - function() internal[] memory _transitionsA, - function() internal[] memory _transitionsB - ) internal pure returns (function() internal[] memory) { - function() internal[] memory result = new function() internal[](_transitionsA.length + _transitionsB.length); - for (uint256 txIdx = 0; txIdx < _transitionsA.length; txIdx++) { - result[txIdx] = _transitionsA[txIdx]; - } - for (uint256 txIdx = 0; txIdx < _transitionsB.length; txIdx++) { - result[_transitionsA.length + txIdx] = _transitionsB[txIdx]; - } - return result; - } - - function getAvailableTransitions( - bool _hasValidator, - bool _isConnectedToHub - ) internal pure returns (function() internal[] memory) { - if (_hasValidator && _isConnectedToHub) { - return mergeTransitions(baseTransitions(), mergeTransitions(validatorTransitions(), vaultHubTransitions())); - } else if (_hasValidator && !_isConnectedToHub) { - return mergeTransitions(validatorTransitions(), baseTransitions()); - } else if (!_hasValidator && _isConnectedToHub) { - return mergeTransitions(baseTransitions(), vaultHubTransitions()); - } else { - return baseTransitions(); - } - } - - function transitionRandomUserDeposit() internal { - console2.log("Deposit by random user with random amount"); - - uint256 amount = rnd.randAmountD18(); - address randomUser = rnd.randAddress(); - deal(randomUser, amount); - if (amount == 0) { - vm.expectRevert(abi.encodeWithSelector(ZeroArgument.selector, "msg.value")); - } - vm.prank(randomUser); - payable(address(stakingVaultProxy)).transfer(amount); - randomUserDeposits += amount; - } - - function transitionRandomFund() internal { - console2.log("Fund vault with random amount of funds"); - - uint256 amount = rnd.randAmountD18(); - int256 inOutDeltaBefore = stakingVaultProxy.inOutDelta(); - uint256 valuationBefore = stakingVaultProxy.valuation(); - uint256 balanceBefore = address(stakingVaultProxy).balance; - - deal(owner, amount); - if (amount == 0) { - vm.expectRevert(abi.encodeWithSelector(ZeroArgument.selector, "msg.value")); - } - - vm.prank(owner); - stakingVaultProxy.fund{value: amount}(); - deposits += amount; - - assertEq(stakingVaultProxy.inOutDelta(), inOutDeltaBefore + int256(amount)); - assertEq(stakingVaultProxy.valuation(), valuationBefore + amount); - assertEq(address(stakingVaultProxy).balance, balanceBefore + amount); - } - - function transitionRandomWithdraw() internal { - console2.log("Withdraw random amount of funds"); - - uint256 unlocked = stakingVaultProxy.unlocked(); - uint256 vaultBalance = address(stakingVaultProxy).balance; - uint256 minWithdrawal = Math.min(unlocked, vaultBalance); - uint256 withdrawableAmount = rnd.randInt(minWithdrawal); - int256 inOutDeltaBefore = stakingVaultProxy.inOutDelta(); - uint256 valuationBefore = stakingVaultProxy.valuation(); - uint256 balanceBefore = address(stakingVaultProxy).balance; - - deal(owner, withdrawableAmount); - if (withdrawableAmount == 0) { - vm.expectRevert(abi.encodeWithSelector(ZeroArgument.selector, "_ether")); - } - - vm.prank(owner); - stakingVaultProxy.withdraw(owner, withdrawableAmount); - withdrawals += withdrawableAmount; - - assertEq(inOutDeltaBefore, stakingVaultProxy.inOutDelta() + int256(withdrawableAmount)); - assertEq(valuationBefore, stakingVaultProxy.valuation() + withdrawableAmount); - assertEq(balanceBefore, address(stakingVaultProxy).balance + withdrawableAmount); - } - - function transitionRandomReceiveReward() internal { - console2.log("Receive random reward"); - - uint256 dailyReward = validator.getDailyReward(); - uint256 valuationBefore = stakingVaultProxy.valuation(); - uint256 balanceBefore = address(stakingVaultProxy).balance; - - vm.deal(address(stakingVaultProxy), address(stakingVaultProxy).balance + dailyReward); - rewards += dailyReward; - - assertEq(valuationBefore, stakingVaultProxy.valuation()); - assertEq(address(stakingVaultProxy).balance, balanceBefore + dailyReward); - } - - function transitionDepositToBeaconChain() internal { - console2.log("------Deposit to Beacon Chain and start simulating validator------"); - - deal(owner, VALIDATOR_DEPOSIT); - vm.prank(owner); - stakingVaultProxy.fund{value: VALIDATOR_DEPOSIT}(); - deposits += VALIDATOR_DEPOSIT; - - int256 inOutDeltaBefore = stakingVaultProxy.inOutDelta(); - uint256 valuationBefore = stakingVaultProxy.valuation(); - uint256 balanceBefore = address(stakingVaultProxy).balance; - - IStakingVault.Deposit[] memory newDeposits = new IStakingVault.Deposit[](1); - bytes memory pubkey = new bytes(48); - bytes32 firstPart = bytes32(uint256(1)); - bytes16 secondPart = bytes16(bytes32(uint256(2))); - assembly { - mstore(add(pubkey, 32), firstPart) - mstore(add(pubkey, 64), secondPart) - } - newDeposits[0] = IStakingVault.Deposit({ - pubkey: pubkey, - signature: bytes.concat(bytes32(uint256(2))), - amount: VALIDATOR_DEPOSIT, - depositDataRoot: bytes32(uint256(3)) - }); - - vm.prank(depositor); - stakingVaultProxy.depositToBeaconChain(newDeposits); - depositsToBeaconChain += VALIDATOR_DEPOSIT; - - assertEq(inOutDeltaBefore, stakingVaultProxy.inOutDelta()); - assertEq(valuationBefore, stakingVaultProxy.valuation()); - assertEq(balanceBefore, address(stakingVaultProxy).balance + VALIDATOR_DEPOSIT); - } - - function transitionValidatorExitAndReturnDeposit() internal { - console2.log("------Validator exit and return deposit------"); - - uint256 balanceBefore = address(stakingVaultProxy).balance; - uint256 valuationBefore = stakingVaultProxy.valuation(); - - deal(address(stakingVaultProxy), address(stakingVaultProxy).balance + VALIDATOR_DEPOSIT); - depositsToBeaconChain -= VALIDATOR_DEPOSIT; - - assertEq(address(stakingVaultProxy).balance, balanceBefore + VALIDATOR_DEPOSIT); - assertEq(valuationBefore, stakingVaultProxy.valuation()); - } - - function transitionRandomDepositToBeaconChain() internal { - console2.log("Deposit to Beacon Chain with random amount"); - - uint256 amount = rnd.randAmountD18(); - int256 inOutDeltaBefore = stakingVaultProxy.inOutDelta(); - uint256 valuationBefore = stakingVaultProxy.valuation(); - uint256 balanceBefore = address(stakingVaultProxy).balance; - - bytes memory pubkey = new bytes(48); - bytes32 firstPart = bytes32(uint256(1)); - bytes16 secondPart = bytes16(bytes32(uint256(2))); - - assembly { - mstore(add(pubkey, 32), firstPart) - mstore(add(pubkey, 64), secondPart) - } - - IStakingVault.Deposit[] memory newDeposits = new IStakingVault.Deposit[](1); - newDeposits[0] = IStakingVault.Deposit({ - pubkey: pubkey, - signature: bytes.concat(bytes32(uint256(2))), - amount: amount, - depositDataRoot: bytes32(uint256(3)) - }); - - vm.prank(depositor); - stakingVaultProxy.depositToBeaconChain(newDeposits); - depositsToBeaconChain += amount; - - assertEq(inOutDeltaBefore, stakingVaultProxy.inOutDelta()); - assertEq(valuationBefore, stakingVaultProxy.valuation()); - assertEq(balanceBefore, address(stakingVaultProxy).balance + amount); - } - - function transitionConnectVaultToHub() internal { - console2.log("------Connect Vault to Hub------"); - - vm.prank(address(vaultHub)); - stakingVaultProxy.lock(CONNECT_DEPOSIT); - - assertEq(stakingVaultProxy.locked(), CONNECT_DEPOSIT); - } - - function transitionDisconnectVaultFromHub() internal { - console2.log("------Disconnect Vault from Hub------"); - - uint256 valuation = stakingVaultProxy.valuation(); - int256 inOutDelta = stakingVaultProxy.inOutDelta(); - - vm.prank(address(vaultHub)); - stakingVaultProxy.report(valuation, inOutDelta, 0); - - assertEq(stakingVaultProxy.locked(), 0); - assertEq(inOutDelta, stakingVaultProxy.inOutDelta()); - assertEq( - int256(stakingVaultProxy.valuation()) + inOutDelta, - int256(valuation) + stakingVaultProxy.inOutDelta() - ); - } - - function transitionRandomMintShares() internal { - console2.log("Mint shares with random amount"); - - uint256 vaultValuation = stakingVaultProxy.valuation(); - uint256 totalEtherToLock = vaultHub.getTotalEtherToLock(vaultValuation); - - uint256 currentLocked = stakingVaultProxy.locked(); - if (totalEtherToLock < currentLocked) { - vm.expectRevert( - abi.encodeWithSelector(LockedCannotDecreaseOutsideOfReport.selector, currentLocked, totalEtherToLock) - ); - } - - vm.prank(address(vaultHub)); - stakingVaultProxy.lock(totalEtherToLock); - - assertEq(stakingVaultProxy.locked(), totalEtherToLock < currentLocked ? currentLocked : totalEtherToLock); - } - - function transitionRandomReport() internal { - console2.log("Receive report"); - - uint256 currentValuation = stakingVaultProxy.valuation(); - int256 currentInOutDelta = stakingVaultProxy.inOutDelta(); - uint256 currentLocked = stakingVaultProxy.locked(); - - uint256 newValuation = vaultHub.getNewValuation(currentValuation); - uint256 newLocked = vaultHub.getNewLocked(currentLocked); - - vm.prank(address(vaultHub)); - stakingVaultProxy.report(newValuation, currentInOutDelta, newLocked); - - assertEq(stakingVaultProxy.locked(), newLocked); - assertEq(currentInOutDelta, stakingVaultProxy.inOutDelta()); - assertEq( - int256(stakingVaultProxy.valuation()) + currentInOutDelta, - int256(newValuation) + stakingVaultProxy.inOutDelta() - ); - } - - function transitionRandomRebalance() internal { - console2.log("Rebalance with random amount"); - - int256 inOutDelta = stakingVaultProxy.inOutDelta(); - uint256 valuation = stakingVaultProxy.valuation(); - uint256 balance = address(stakingVaultProxy).balance; - uint256 locked = stakingVaultProxy.locked(); - uint256 etherToRebalance = vaultHub.getAmountToUnlock(valuation, locked); - bool isRebalanceAllowed = false; - - if (etherToRebalance == 0) { - vm.expectRevert(abi.encodeWithSelector(ZeroArgument.selector, "_ether")); - } else if (etherToRebalance > balance) { - vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector, balance)); - } else if (etherToRebalance > valuation) { - vm.expectRevert( - abi.encodeWithSelector(RebalanceAmountExceedsValuation.selector, valuation, etherToRebalance) - ); - } else if (valuation >= locked) { - vm.expectRevert(abi.encodeWithSelector(NotAuthorized.selector, "rebalance", address(vaultHub))); - } else { - isRebalanceAllowed = true; - vaultHubBalance += etherToRebalance; - } - vm.prank(address(vaultHub)); - stakingVaultProxy.rebalance(etherToRebalance); - - if (isRebalanceAllowed) { - assertEq(inOutDelta, stakingVaultProxy.inOutDelta() + int256(etherToRebalance)); - assertEq(valuation, stakingVaultProxy.valuation() + etherToRebalance); - assertEq(balance, address(stakingVaultProxy).balance + etherToRebalance); - } else { - assertEq(inOutDelta, stakingVaultProxy.inOutDelta()); - assertEq(valuation, stakingVaultProxy.valuation()); - assertEq(balance, address(stakingVaultProxy).balance); - } - } -} diff --git a/test/0.8.25/vaults/staking-vault/VaultHubSolvency.t.sol b/test/0.8.25/vaults/staking-vault/VaultHubSolvency.t.sol new file mode 100644 index 0000000000..62ced6743b --- /dev/null +++ b/test/0.8.25/vaults/staking-vault/VaultHubSolvency.t.sol @@ -0,0 +1,834 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {console2} from "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 {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; +import {OperatorGrid} from "contracts/0.8.25/vaults/OperatorGrid.sol"; +import {Math256} from "contracts/common/lib/Math256.sol"; +import {DepositContract__MockForStakingVault} from "./contracts/DepositContract__MockForStakingVault.sol"; +import {RandomLib} from "./RandomLib.sol"; +import {RewardSimulator} from "./RewardSimulator.sol"; +import {IHashConsensus} from "contracts/0.8.25/vaults/interfaces/IHashConsensus.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 refSlot, uint256 reportProcessingDeadlineSlot) { + return (refSlot, reportProcessingDeadlineSlot); + } + + function getIsMember(address addr) 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 OperatorGridMock { + uint256 public shareLimit; + uint256 public reserveRatioBP; + uint256 public forcedRebalanceThresholdBP; + uint256 public infraFeeBP; + uint256 public liquidityFeeBP; + uint256 public reservationFeeBP; + + constructor( + uint256 _shareLimit, + uint256 _reserveRatioBP, + uint256 _forcedRebalanceThresholdBP, + uint256 _infraFeeBP, + uint256 _liquidityFeeBP, + uint256 _reservationFeeBP + ) { + shareLimit = _shareLimit; + reserveRatioBP = _reserveRatioBP; + forcedRebalanceThresholdBP = _forcedRebalanceThresholdBP; + infraFeeBP = _infraFeeBP; + liquidityFeeBP = _liquidityFeeBP; + reservationFeeBP = _reservationFeeBP; + } + + function vaultInfo( + address + ) external view returns (address, uint256, uint256, uint256, uint256, uint256, uint256, uint256) { + return ( + address(0), + 0, + shareLimit, + reserveRatioBP, + forcedRebalanceThresholdBP, + infraFeeBP, + liquidityFeeBP, + reservationFeeBP + ); + } + + function onMintedShares(address, uint256) external pure { + return; + } + + function onBurnedShares(address, uint256) external pure { + return; + } + + function resetVaultTier(address) external pure { + return; + } +} + +contract LidoLocatorMock { + address public predepositGuarantee_; + address public accounting_; + address public treasury_; + address public operatorGrid_; + address public lazyOracle_; + + constructor( + address _predepositGuarantee, + address _accounting, + address _treasury, + address _operatorGrid, + address _lazyOracle + ) { + predepositGuarantee_ = _predepositGuarantee; + accounting_ = _accounting; + treasury_ = _treasury; + operatorGrid_ = _operatorGrid; + lazyOracle_ = _lazyOracle; + } + + 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_; + } +} + +contract LazyOracleMock { + uint256 public latestReportTimestamp_; + + constructor(uint256 _latestReportTimestamp) { + latestReportTimestamp_ = _latestReportTimestamp; + } + + function setLatestReportTimestamp(uint256 _latestReportTimestamp) external { + latestReportTimestamp_ = _latestReportTimestamp; + } + + function latestReportTimestamp() external view returns (uint256) { + return latestReportTimestamp_; + } +} + +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; + } +} + +contract VaultHubTest is Test { + using RandomLib for RandomLib.Storage; + + struct Vault { + StakingVault stakingVaultProxy; + ValidatorState validatorState; + uint256 lifetime; + uint256 lastInactivePenaltyTime; + VaultState lastState; + } + + enum VaultState { + MintingAllowed, + Healthy, + Unhealthy, + BadDebt, + Unknown + } + + enum ValidatorState { + Active, + Inactive, + Slashed + } + + enum TestMode { + BadPerformingValidators, + WellPerformingValidators, + All + } + + VaultHub vaultHubProxy; + LidoMock lido; + DepositContract__MockForStakingVault depositContract; + RewardSimulator rewardSimulatorForValidator; + RewardSimulator rewardSimulatorForCoreProtocol; + OperatorGridMock operatorGrid; + LazyOracleMock lazyOracle; + ConsensusContractMock consensusContract; + + address private owner = makeAddr("owner"); + address private predepositGuarantee = makeAddr("predepositGuarantee"); + address private accounting = makeAddr("accounting"); + address private treasury = makeAddr("treasury"); + address private depositor = makeAddr("depositor"); + address private nodeOperator = makeAddr("nodeOperator"); + + RandomLib.Storage private rnd; + + uint256 internal constant ITERATIONS = 170; + uint256 internal constant CONNECTED_VAULTS_LIMIT = 100; + uint256 internal constant TOTAL_BASIS_POINTS = 100_00; + uint256 internal constant SECONDS_PER_DAY = 86400; + uint256 internal constant LOCKED_AMOUNT = 32 ether; + uint256 internal constant MAX_USERS_TO_STAKING = 100; + uint256 internal constant CHANCE_TO_CREATE_AND_CONNECT_VAULT = 10; + uint256 internal constant CHANCE_TO_CALL_REBALANCE_OR_BURN = 10; + uint256 internal constant CHANCE_TO_CALL_WITHDRAW = 30; + + uint256 private constant INACTIVE_PENALTY_PERIOD = 21 days; + uint256 private constant INACTIVE_PENALTY_TOTAL_BP = 6000; + uint256 private constant INACTIVE_PENALTY_PER_DAY_BP = INACTIVE_PENALTY_TOTAL_BP / 21; + + Vault[] private vaults; + uint256 private totalSharesMinted; + uint256 private totalSharesBurned; + uint256 private connectedVaults; + uint256 private disconnectedVaults; + uint256 private relativeShareLimitBp; + + function deploy(uint256 _seed) public { + rnd.seed = _seed; + + depositContract = new DepositContract__MockForStakingVault(); + + relativeShareLimitBp = 2000; // 20% + // these numbers were taken from mainnet protocol state + lido = new LidoMock(7810237 * 10 ** 18, 9365361 * 10 ** 18, 0); + + // average validator APR is between 2.8-5.7% + rewardSimulatorForValidator = new RewardSimulator(_seed, 280, 570, 32 ether); + // average core protocol APR is between 2.78-3.6% + rewardSimulatorForCoreProtocol = new RewardSimulator(_seed, 278, 360, lido.getTotalPooledEther()); + + uint256 shareLimit = 35 ether + rnd.randInt(5) * 1 ether; // between 35 and 40 ETH + uint256 reserveRatioBp = 1000 + rnd.randInt(1000); // between 10% and 20% + uint256 forcedRebalanceThresholdBp = 500 + rnd.randInt(reserveRatioBp - 500); // between 5% and 95% of reserveRatioBp + uint256 infraFeeBp = 500 + rnd.randInt(500); // between 5% and 10% + uint256 liquidityFeeBp = 500 + rnd.randInt(500); // between 5% and 10% + uint256 reservationFeeBp = 500 + rnd.randInt(500); // between 5% and 10% + operatorGrid = new OperatorGridMock( + shareLimit, + reserveRatioBp, + forcedRebalanceThresholdBp, + infraFeeBp, + liquidityFeeBp, + reservationFeeBp + ); + + lazyOracle = new LazyOracleMock(block.timestamp); + LidoLocatorMock lidoLocator = new LidoLocatorMock( + depositor, + accounting, + treasury, + address(operatorGrid), + address(lazyOracle) + ); + consensusContract = new ConsensusContractMock(0, 0); + VaultHub vaultHub = new VaultHub( + ILidoLocator(address(lidoLocator)), + ILido(address(lido)), + IHashConsensus(address(consensusContract)), + relativeShareLimitBp + ); + ERC1967Proxy proxy = new ERC1967Proxy( + address(vaultHub), + abi.encodeWithSelector(VaultHub.initialize.selector, owner) + ); + vaultHubProxy = VaultHub(payable(address(proxy))); + + bytes32 vaultMasterRole = vaultHubProxy.VAULT_MASTER_ROLE(); + vm.prank(owner); + vaultHubProxy.grantRole(vaultMasterRole, owner); + + bytes32 vaultCodehashSetRole = vaultHubProxy.VAULT_CODEHASH_SET_ROLE(); + vm.prank(owner); + vaultHubProxy.grantRole(vaultCodehashSetRole, owner); + } + + function createAndConnectVault(bool _addCodehash, TestMode _testMode) internal { + StakingVault stakingVault = new StakingVault(address(depositContract)); + ERC1967Proxy proxy = new ERC1967Proxy( + address(stakingVault), + abi.encodeWithSelector(StakingVault.initialize.selector, owner, nodeOperator, depositor, "0x") + ); + StakingVault stakingVaultProxy = StakingVault(payable(address(proxy))); + + if (_addCodehash) { + vm.prank(owner); + vaultHubProxy.setAllowedCodehash(address(stakingVaultProxy).codehash, true); + } + + deal(address(owner), LOCKED_AMOUNT); + vm.prank(owner); + stakingVaultProxy.fund{value: LOCKED_AMOUNT}(); + + vm.prank(owner); + stakingVaultProxy.transferOwnership(address(vaultHubProxy)); + + vm.prank(owner); + vaultHubProxy.connectVault(address(stakingVaultProxy)); + + ValidatorState validatorState; + if (_testMode == TestMode.BadPerformingValidators) { + validatorState = rnd.randBool() ? ValidatorState.Inactive : ValidatorState.Slashed; + } else if (_testMode == TestMode.WellPerformingValidators) { + validatorState = ValidatorState.Active; + } else if (_testMode == TestMode.All) { + validatorState = ValidatorState(rnd.randInt(2)); + } + + Vault memory vault = Vault({ + stakingVaultProxy: stakingVaultProxy, + lifetime: rnd.randInt(ITERATIONS / 2), + lastInactivePenaltyTime: 0, + validatorState: validatorState, + lastState: VaultState.Unknown + }); + vaults.push(vault); + + console2.log("Creating and connecting vault", address(stakingVaultProxy)); + + connectedVaults++; + } + + function runTests(uint256 _seed, TestMode _testMode) internal { + deploy(_seed); + + createAndConnectVault(true, _testMode); + + for (uint256 iterationIdx = 0; iterationIdx < ITERATIONS; iterationIdx++) { + if (vaults.length < CONNECTED_VAULTS_LIMIT && rnd.randInt(100) < CHANCE_TO_CREATE_AND_CONNECT_VAULT) { + createAndConnectVault(false, _testMode); + } + updateReportAndVaultData(); + + doRandomActions(); + + transitionRandomCoreProtocolStaking(); + transitionRandomCoreProtocolReceiveReward(); + removeAndDisconnectDeadVault(); + + if (rnd.randInt(100) >= 20) { + vm.warp(block.timestamp + SECONDS_PER_DAY); + } else { + vm.warp(block.timestamp + SECONDS_PER_DAY * 2); + } + + checkVaultsForShareLimits(); + } + + assertEq(connectedVaults, disconnectedVaults + vaults.length); + + uint256 sharesLeftover = 0; + for (uint256 i = 0; i < vaults.length; i++) { + Vault memory vault = vaults[i]; + sharesLeftover += vaultHubProxy.vaultRecord(address(vault.stakingVaultProxy)).liabilityShares; + } + assertEq(totalSharesMinted, totalSharesBurned + sharesLeftover); + } + + function padDataIfNeeded(bytes32[] memory _data) internal pure returns (bytes32[] memory) { + if (_data.length == 1) { + bytes32[] memory paddedData = new bytes32[](2); + paddedData[0] = _data[0]; + paddedData[1] = bytes32(0); + return paddedData; + } + return _data; + } + + function updateReportAndVaultData() internal { + lazyOracle.setLatestReportTimestamp(block.timestamp); + for (uint256 i = 0; i < vaults.length; i++) { + if (rnd.randInt(100) < 10) { + continue; + } + + Vault memory vault = vaults[i]; + VaultHub.VaultRecord memory vaultRecord = vaultHubProxy.vaultRecord(address(vault.stakingVaultProxy)); + uint256 valuation = address(vault.stakingVaultProxy).balance; + VaultHub.Int112WithRefSlotCache memory inOutDelta = vaultRecord.inOutDelta; + uint256 sharesMinted = vaultRecord.liabilityShares; + uint256 treasureFeeShares = 0; + + vm.prank(address(lazyOracle)); + vaultHubProxy.applyVaultReport( + address(vault.stakingVaultProxy), + uint64(block.timestamp), + uint256(valuation), + int256(inOutDelta.value), + treasureFeeShares, + sharesMinted + ); + } + } + + function checkVaultsForShareLimits() internal view { + uint256 totalSharesMinted = 0; + for (uint256 i = 0; i < vaults.length; i++) { + Vault memory vault = vaults[i]; + uint256 sharesMinted = vaultHubProxy.vaultRecord(address(vault.stakingVaultProxy)).liabilityShares; + totalSharesMinted += sharesMinted; + + if (getVaultState(vault) != VaultState.BadDebt) { + assertLe( + lido.getPooledEthBySharesRoundUp(sharesMinted), + vaultHubProxy.totalValue(address(vault.stakingVaultProxy)) + ); + } + } + uint256 relativeMaxShareLimitPerVault = (lido.getTotalShares() * relativeShareLimitBp) / TOTAL_BASIS_POINTS; + assertLe(totalSharesMinted, relativeMaxShareLimitPerVault); + } + + function disconnectVault(Vault memory _vault) internal { + VaultHub.VaultRecord memory record = vaultHubProxy.vaultRecord(address(_vault.stakingVaultProxy)); + if (record.liabilityShares > 0) { + vm.prank(owner); + vaultHubProxy.burnShares(address(_vault.stakingVaultProxy), record.liabilityShares); + totalSharesBurned += record.liabilityShares; + } + vm.prank(owner); + vaultHubProxy.disconnect(address(_vault.stakingVaultProxy)); + + disconnectedVaults++; + } + + function removeAndDisconnectDeadVault() internal { + uint256 vaultsLength = vaults.length; + for (uint256 i = 0; i < vaultsLength; i++) { + Vault storage vault = vaults[i]; + if (vault.lifetime > 0) { + vault.lifetime--; + } + + if (vault.lifetime == 0) { + console2.log("removeAndDisconnectDeadVault", address(vault.stakingVaultProxy)); + disconnectVault(vault); + Vault memory lastVault = vaults[vaultsLength - 1]; + vaults[i] = lastVault; + vaults.pop(); + vaultsLength--; + } + } + } + + function doRandomActions() internal { + for (uint256 vaultIdx = 0; vaultIdx < vaults.length; vaultIdx++) { + Vault storage vault = vaults[vaultIdx]; + + VaultState state = getVaultState(vault); + if (state != vault.lastState) { + printVaultState(state, vault.lastState, address(vault.stakingVaultProxy)); + vault.lastState = state; + } + + if (state == VaultState.MintingAllowed) { + if (rnd.randInt(100) < CHANCE_TO_CALL_WITHDRAW) { + transitionRandWithdraw(vault.stakingVaultProxy); + } else { + transitionMintMaxAllowedShares(vault.stakingVaultProxy); + } + } else if (state == VaultState.Healthy) { + if (rnd.randInt(100) < CHANCE_TO_CALL_REBALANCE_OR_BURN) { + transitionRandomBurn(vault.stakingVaultProxy); + } + } else if (state == VaultState.Unhealthy) { + if (rnd.randBool()) { + transitionRandomRebalance(vault.stakingVaultProxy); + } else { + transitionForceRebalance(vault.stakingVaultProxy); + } + } else if (getVaultState(vault) == VaultState.BadDebt) { + // should be call rebalance but it reverts since this scenario is not supported yet + uint256 rebalanceShortfall = vaultHubProxy.rebalanceShortfall(address(vault.stakingVaultProxy)); + assertEq(rebalanceShortfall, type(uint256).max); + vault.lifetime = 0; + } + + if (vault.validatorState == ValidatorState.Active) { + transitionVaultRandomReceiveReward(vault.stakingVaultProxy); + } else if (vault.validatorState == ValidatorState.Inactive) { + transitionValidatorInactivePenalty(vault.stakingVaultProxy, vault); + if (address(vault.stakingVaultProxy).balance < 16 ether) { + vault.lifetime = 0; + } + } else if (vault.validatorState == ValidatorState.Slashed) { + transitionValidatorSlashedPenalty(vault.stakingVaultProxy); + vault.lifetime = 0; + } + } + } + + function testSolvencyAllTransitions() external { + runTests(5686631772487049791906286, TestMode.All); + } + + function testSolvencyBadPerformingValidators() external { + runTests(123618273619736182376, TestMode.BadPerformingValidators); + } + + function testSolvencyWellPerformingValidators() external { + runTests(23172389139823, TestMode.WellPerformingValidators); + } + + function testFuzz_SolvencyAllTransitions(uint256 _seed) external { + TestMode testMode = TestMode(rnd.randInt(2)); + runTests(_seed, testMode); + } + + function transitionRandomCoreProtocolStaking() internal { + uint256 amountOfUseres = rnd.randInt(MAX_USERS_TO_STAKING); + for (uint256 i = 0; i < amountOfUseres; i++) { + address randomUser = rnd.randAddress(); + uint256 amount = rnd.randAmountD18(); + deal(randomUser, amount); + vm.prank(randomUser); + lido.stake{value: amount}(); + } + } + + function transitionRandomCoreProtocolReceiveReward() internal { + uint256 dailyReward = rewardSimulatorForCoreProtocol.getDailyReward(); + if (dailyReward > 0) { + console2.log("Receive reward for core protocol", dailyReward); + lido.receiveRewards(dailyReward); + } + } + + function transitionVaultRandomReceiveReward(StakingVault _stakingVault) internal { + uint256 dailyReward = rewardSimulatorForValidator.getDailyReward(); + if (dailyReward > 0) { + vm.deal(address(_stakingVault), address(_stakingVault).balance + dailyReward); + console2.log("Receive random reward", address(_stakingVault), dailyReward); + } + } + + function transitionValidatorInactivePenalty(StakingVault _stakingVault, Vault storage _vault) internal { + uint256 timePassed = block.timestamp - _vault.lastInactivePenaltyTime; + if (timePassed < SECONDS_PER_DAY) { + return; + } + + uint256 daysPassed = timePassed / SECONDS_PER_DAY; + _vault.lastInactivePenaltyTime = block.timestamp; + + uint256 currentBalance = address(_stakingVault).balance; + uint256 dailyPenalty = (currentBalance * INACTIVE_PENALTY_PER_DAY_BP * daysPassed) / TOTAL_BASIS_POINTS; + console2.log("dailyPenalty", formatEth(dailyPenalty)); + + if (currentBalance > dailyPenalty) { + vm.deal(address(_stakingVault), currentBalance - dailyPenalty); + console2.log("balance after penalty", formatEth(address(_stakingVault).balance)); + } + } + + function transitionValidatorSlashedPenalty(StakingVault _stakingVault) internal { + uint256 penalty = 1 ether; + if (address(_stakingVault).balance < penalty) { + penalty = address(_stakingVault).balance; + } + vm.deal(address(_stakingVault), address(_stakingVault).balance - penalty); + } + + function transitionMintMaxAllowedShares(StakingVault _stakingVault) internal { + if (!vaultHubProxy.isReportFresh(address(_stakingVault))) { + return; + } + uint256 totalShares = lido.getTotalShares(); + uint256 totalPooledEther = lido.getTotalPooledEther(); + + VaultHub.VaultConnection memory vaultConnection = vaultHubProxy.vaultConnection(address(_stakingVault)); + VaultHub.VaultRecord memory vaultRecord = vaultHubProxy.vaultRecord(address(_stakingVault)); + + uint256 maxMintableRatioBP = TOTAL_BASIS_POINTS - vaultConnection.reserveRatioBP; + uint256 maxLockableValue = vaultHubProxy.maxLockableValue(address(_stakingVault)); + uint256 maxMintableEther = (maxLockableValue * maxMintableRatioBP) / TOTAL_BASIS_POINTS; + uint256 maxMintableShares = lido.getSharesByPooledEth(maxMintableEther); + uint256 sharesLimitedByMaxMintableShares = maxMintableShares - vaultRecord.liabilityShares; + uint256 sharesLimitedByShareLimit = vaultConnection.shareLimit - vaultRecord.liabilityShares; + uint256 amountOfSharesToMint = Math256.min(sharesLimitedByMaxMintableShares, sharesLimitedByShareLimit); + + if (amountOfSharesToMint == 0) { + return; + } + + vm.prank(owner); + vaultHubProxy.mintShares(address(_stakingVault), address(owner), amountOfSharesToMint); + console2.log("Mint random shares", address(_stakingVault), amountOfSharesToMint); + + totalSharesMinted += amountOfSharesToMint; + } + + function transitionRandomBurn(StakingVault _stakingVault) internal { + VaultHub.VaultRecord memory record = vaultHubProxy.vaultRecord(address(_stakingVault)); + uint256 amountOfSharesToBurn = 0; + uint256 random = rnd.randInt(100); + if (random < 10) { + // 10% chance to burn first 10% of shares + amountOfSharesToBurn = rnd.randInt(record.liabilityShares / 10); + } else if (random < 20) { + // 10% chance to burn last 10% of shares + amountOfSharesToBurn = record.liabilityShares - rnd.randInt(record.liabilityShares / 10); + } else { + // 80% chance to burn random amount + amountOfSharesToBurn = rnd.randInt(record.liabilityShares); + } + + if (amountOfSharesToBurn > 0) { + console2.log("Burn random shares", address(_stakingVault), amountOfSharesToBurn); + vm.prank(owner); + vaultHubProxy.burnShares(address(_stakingVault), amountOfSharesToBurn); + totalSharesBurned += amountOfSharesToBurn; + } + } + + function transitionForceRebalance(StakingVault _stakingVault) internal { + if (vaultHubProxy.isVaultHealthy(address(_stakingVault))) { + return; + } + uint256 rebalanceShortfall = vaultHubProxy.rebalanceShortfall(address(_stakingVault)); + uint256 sharesToBurn = lido.getSharesByPooledEth(rebalanceShortfall); + totalSharesBurned += sharesToBurn; + vm.prank(owner); + vaultHubProxy.forceRebalance(address(_stakingVault)); + console2.log("Force rebalance sharesToBurn:", address(_stakingVault), sharesToBurn); + } + + function transitionRandomRebalance(StakingVault _stakingVault) internal { + if (vaultHubProxy.isVaultHealthy(address(_stakingVault))) { + return; + } + uint256 rebalanceShortfall = vaultHubProxy.rebalanceShortfall(address(_stakingVault)); + uint256 rebalanceAmount = rnd.randInt(rebalanceShortfall); + uint256 sharesToBurn = lido.getSharesByPooledEth(rebalanceAmount); + totalSharesBurned += sharesToBurn; + vm.prank(owner); + vaultHubProxy.rebalance(address(_stakingVault), rebalanceAmount); + console2.log("Rebalance sharesToBurn:", address(_stakingVault), sharesToBurn); + } + + function transitionRandWithdraw(StakingVault _stakingVault) internal { + if (!vaultHubProxy.isReportFresh(address(_stakingVault))) { + return; + } + + uint256 maxWithdrawableAmount = vaultHubProxy.withdrawableValue(address(_stakingVault)); + uint256 withdrawAmount = rnd.randInt(maxWithdrawableAmount); + vm.prank(owner); + vaultHubProxy.withdraw(address(_stakingVault), address(owner), withdrawAmount); + } + + function getVaultState(Vault memory _vault) internal view returns (VaultState) { + VaultHub.VaultRecord memory vaultRecord = vaultHubProxy.vaultRecord(address(_vault.stakingVaultProxy)); + VaultHub.VaultConnection memory vaultConnection = vaultHubProxy.vaultConnection( + address(_vault.stakingVaultProxy) + ); + + if ( + lido.getPooledEthByShares(vaultRecord.liabilityShares) <= + (vaultHubProxy.totalValue(address(_vault.stakingVaultProxy)) * + (TOTAL_BASIS_POINTS - vaultConnection.reserveRatioBP)) / + TOTAL_BASIS_POINTS + ) { + return VaultState.MintingAllowed; + } else if ( + lido.getPooledEthByShares(vaultRecord.liabilityShares) <= + (vaultHubProxy.totalValue(address(_vault.stakingVaultProxy)) * + (TOTAL_BASIS_POINTS - vaultConnection.forcedRebalanceThresholdBP)) / + TOTAL_BASIS_POINTS + ) { + return VaultState.Healthy; + } else if ( + lido.getPooledEthByShares(vaultRecord.liabilityShares) <= + vaultHubProxy.totalValue(address(_vault.stakingVaultProxy)) + ) { + return VaultState.Unhealthy; + } else { + return VaultState.BadDebt; + } + } + + function printVaultState(VaultState _state, VaultState _oldState, address _vaultAddress) internal pure { + console2.log( + string.concat( + "----vaultState: ", + getVaultStateString(_oldState), + " -> ", + getVaultStateString(_state), + " ", + vm.toString(_vaultAddress) + ) + ); + } + + function getVaultStateString(VaultState _state) internal pure returns (string memory) { + if (_state == VaultState.MintingAllowed) { + return "MintingAllowed"; + } else if (_state == VaultState.Healthy) { + return "Healthy"; + } else if (_state == VaultState.Unhealthy) { + return "Unhealthy"; + } else if (_state == VaultState.BadDebt) { + return "BadDebt"; + } else { + return "Unknown"; + } + } + + function formatEth(uint256 weiAmount) internal pure returns (string memory) { + uint256 ether_value = weiAmount / 1e18; + uint256 decimal_part = weiAmount % 1e18; + + uint256 decimals4 = (decimal_part / 1e14); + + if (ether_value == 0 && decimals4 == 0) { + return string.concat(vm.toString(weiAmount), " wei"); + } + + string memory etherStr = vm.toString(ether_value); + string memory decimalStr = vm.toString(decimals4); + + while (bytes(decimalStr).length < 4) { + decimalStr = string.concat("0", decimalStr); + } + + return string.concat(etherStr, ".", decimalStr, " ETH"); + } +}