diff --git a/contracts/contracts/mocks/MockDepositContract.sol b/contracts/contracts/mocks/MockDepositContract.sol index dbe41ec780..b7e751b301 100644 --- a/contracts/contracts/mocks/MockDepositContract.sol +++ b/contracts/contracts/mocks/MockDepositContract.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.0; import { IDepositContract } from "./../interfaces/IDepositContract.sol"; contract MockDepositContract is IDepositContract { - uint256 deposit_count; + uint256 public deposit_count; function deposit( bytes calldata pubkey, @@ -47,6 +47,8 @@ contract MockDepositContract is IDepositContract { deposit_data_root != 0, "DepositContract: invalid deposit_data_root" ); + + deposit_count += 1; } function get_deposit_root() external view override returns (bytes32) { diff --git a/contracts/contracts/mocks/MockSSVNetwork.sol b/contracts/contracts/mocks/MockSSVNetwork.sol index 4eb096aca3..7fb52e08b7 100644 --- a/contracts/contracts/mocks/MockSSVNetwork.sol +++ b/contracts/contracts/mocks/MockSSVNetwork.sol @@ -4,13 +4,20 @@ pragma solidity ^0.8.0; import { Cluster } from "./../interfaces/ISSVNetwork.sol"; contract MockSSVNetwork { + uint256 public registeredValidators; + uint256 public exitedValidators; + uint256 public removedValidators; + + /* solhint-disable no-unused-vars */ function registerValidator( bytes calldata publicKey, uint64[] calldata operatorIds, bytes calldata sharesData, uint256 amount, Cluster memory cluster - ) external {} + ) external { + registeredValidators += 1; + } function bulkRegisterValidator( bytes[] calldata publicKeys, @@ -18,18 +25,24 @@ contract MockSSVNetwork { bytes[] calldata sharesData, uint256 amount, Cluster memory cluster - ) external {} + ) external { + registeredValidators += publicKeys.length; + } function exitValidator( bytes calldata publicKey, uint64[] calldata operatorIds - ) external {} + ) external { + exitedValidators += 1; + } function removeValidator( bytes calldata publicKey, uint64[] calldata operatorIds, Cluster memory cluster - ) external {} + ) external { + removedValidators += 1; + } function deposit( address clusterOwner, @@ -37,4 +50,5 @@ contract MockSSVNetwork { uint256 amount, Cluster memory cluster ) external {} + /* solhint-enable no-unused-vars */ } diff --git a/contracts/deploy/deployActions.js b/contracts/deploy/deployActions.js index 2892193eb5..92af148d33 100644 --- a/contracts/deploy/deployActions.js +++ b/contracts/deploy/deployActions.js @@ -6,7 +6,7 @@ const { getOracleAddresses, isMainnet, isHolesky, -} = require("../test/helpers.js"); +} = require("../test/helpers"); const { deployWithConfirmation, withConfirmation } = require("../utils/deploy"); const { metapoolLPCRVPid, @@ -904,7 +904,7 @@ const deployNativeStakingSSVStrategy = async () => { assetAddresses.WETH, // wethAddress assetAddresses.SSV, // ssvToken assetAddresses.SSVNetwork, // ssvNetwork - 500, // maxValidators + 600, // maxValidators dFeeAccumulatorProxy.address, // feeAccumulator assetAddresses.beaconChainDepositContract, // depositContractMock ] diff --git a/contracts/docs/plantuml/oethProcesses-pause.png b/contracts/docs/plantuml/oethProcesses-pause.png new file mode 100644 index 0000000000..30ad48ee42 Binary files /dev/null and b/contracts/docs/plantuml/oethProcesses-pause.png differ diff --git a/contracts/test/_fixture.js b/contracts/test/_fixture.js index bfb127f981..3fc891f4c1 100644 --- a/contracts/test/_fixture.js +++ b/contracts/test/_fixture.js @@ -27,6 +27,7 @@ const { getOracleAddresses, oethUnits, ousdUnits, + ethUnits, units, isFork, isHolesky, @@ -153,7 +154,7 @@ const simpleOETHFixture = deployments.createFixture(async () => { } } else { // Fund WETH contract - await hardhatSetBalance(weth.address, "999999999999999"); + await hardhatSetBalance(weth.address, "99999999999999999"); // Fund all with mockTokens await fundAccountsForOETHUnitTests(); @@ -300,7 +301,7 @@ const defaultFixture = deployments.createFixture(async () => { const nativeStakingFeeAccumulatorProxy = await ethers.getContract( "NativeStakingFeeAccumulatorProxy" ); - const nativeStakingFeeAccumulator = await ethers.getContractAt( + let nativeStakingFeeAccumulator = await ethers.getContractAt( "FeeAccumulator", nativeStakingFeeAccumulatorProxy.address ); @@ -369,6 +370,7 @@ const defaultFixture = deployments.createFixture(async () => { cvxBooster, cvxRewardPool, depositContractUtils, + depositContract, LUSDMetaStrategy, oethDripper, oethZapper, @@ -522,6 +524,14 @@ const defaultFixture = deployments.createFixture(async () => { fluxStrategyProxy.address ); + const nativeStakingFeeAccumulatorProxy = await ethers.getContract( + "NativeStakingFeeAccumulatorProxy" + ); + nativeStakingFeeAccumulator = await ethers.getContractAt( + "FeeAccumulator", + nativeStakingFeeAccumulatorProxy.address + ); + vaultValueChecker = await ethers.getContract("VaultValueChecker"); oethVaultValueChecker = await ethers.getContract("OETHVaultValueChecker"); } else { @@ -559,6 +569,7 @@ const defaultFixture = deployments.createFixture(async () => { cvxBooster = await ethers.getContract("MockBooster"); cvxRewardPool = await ethers.getContract("MockRewardPool"); depositContractUtils = await ethers.getContract("DepositContractUtils"); + depositContract = await ethers.getContract("MockDepositContract"); adai = await ethers.getContract("MockADAI"); aaveToken = await ethers.getContract("MockAAVEToken"); @@ -744,7 +755,7 @@ const defaultFixture = deployments.createFixture(async () => { cvxBooster, cvxRewardPool, depositContractUtils, - + depositContract, aaveStrategy, aaveToken, aaveAddressProvider, @@ -1650,6 +1661,88 @@ async function nativeStakingSSVStrategyFixture() { return fixture; } +/** + * NativeStakingSSVStrategy fixture with 600 validators deposited + */ +async function nativeStakingValidatorDepositsFixture() { + const fixture = await nativeStakingSSVStrategyFixture(); + + const { + nativeStakingSSVStrategy, + validatorRegistrator, + governor, + weth, + josh, + } = fixture; + + const testValidator = { + publicKey: + "0xaba6acd335d524a89fb89b9977584afdb23f34a6742547fa9ec1c656fbd2bfc0e7a234460328c2731828c9a43be06e25", + operatorIds: [348, 352, 361, 377], + sharesData: + "0x859f01c8f609cb5cb91f0c98e9b39b077775f10302d0db0edc4ea65e692c97920d5169f6281845a956404c0ba90b88060b74aa3755347441a5729b90bf30a449fa568e21915d11733c7135602b2a3d1a4dce41218ecb0fdb1788ee7e48a9ebd4b4b34f62deea20e9212ce78040dcad2e6382c2f4d4c8b3515a840e1693574068e26c0d58f17dc47d30efe4393f2660dc988aba6166b67732e8df7d9a69d316f330779b2fa4d14712d3bb60436d912bab4464c7c31ae8d2a966d7829063821fc899cc3ec4a8c7098b042323eb9d9cc4d5e945c6d5e6d4eb1b2484163d4b8cd83eea4cc195a68320f023b4d2405cda5110a2eea2c12b70abd9b6bfb567a7850a95fe073a0485c787744efc8658789b0faaff0d942b3c7b89540f594d007936f23c3e7c79fabfe1e2c49199a3f374198e231ca391909ca05ee3c89a7292207131653f6f2f5f5d638d4789a8029001b93827f6f45ef062c9a9d1360a3aedae00fbb8c34495056bacc98c6cecfc1171c84a1e47f3bc328539dbbcd6c79c2aebf7833c684bd807cc8c4dfd6b660e64aece6adf659a969851cf976dead050e9d14aa9c358326c0c0f0cb747041830e124ec872fcf6f82e7f05024da9e6bad10319ca085a0d1519b04c60738043babc1f5a144655e6a28922c2734701c5c93b845996589b8fd246e1bcd97570951cdbed032eeb9c2ac49ac8aeb2e988b6a5513ddcef9ca9bd592c0bce7d38041b52e69e85cda5fd0b84f905c7212b299cf265ee603add20d6459e0841dd05524e96574eebb46473151ec10a08873f7075e15342852f9f16aeb8305211706632475c39ccd8da33969390d035f8a68324e7adced66a726f80532b425cc82dd52a2edc10989db0167317b472a0016215dae35b4c26b28c0ebcf56e115eb32231449812e9ce866a8c0b3128878d3878f5be0670051a8bf94807123c54e6ea2f51607e32c2fe1b132c905c81965dd6d2a7474aa40b65f18d34084a74ba9a21fbdfba3bfaf6b11175d85f03181d655fda086d8dbe2f03dfa2e1b7140b1d9dc68fc9e22f184ed278599d29f6660af128e4c548de6926912d920e35575db90338a1a840f8d8842685f5b459fda573eaf5c5180e3369fc50faa681941dbe7dec83ee9649f30c1a0eac1f8a42fb3083d9274f4c622e2aa1e74b70fa6c027b4f23e1f80bfc4f69248b4d0b3e0eee9372869f97eb89d8d155e469191c48834ad58dd831f1b73409d71fccb958b6582a4ac3f98bcffff2abd393cbe64d7397ada699ecc75301e3be9e9b4ee92a990202c6a5e5112de5ea9cd666f41cdac4611575c8efe2137d6132cd4d4eea0de159eab44588a88f887e4263f673fb365415df537c77a4aaaee12dceff022eafcb8e6973eec7e18eb65cfeefa845b79754ec52a9270f0a7e570b1dd2171e629d498f34e6371726fa8cfe6863f9263c5222a953a44612944183789ad1020de8da527bf850429558dda7896059476e497284512c946d7a57acda3c3ee722d280c0d0daf758d6be88db48e96e14124832c38aa6d0dd38baeb4f246b01d7b0beb55c3983fb182cbf630b778384cc13ab6216611bc1eab94ffe17bb1e829700c99ec28fae1a87eaefd9c8edc4cdf3b6f2b07d85e0d8090ddfb2df4280dacd13a1f30cf946f5606940dc3f75622159b1c6f84bfdbd4ba9fa0f1d522f52bc2049da53f0d06931d650ef1274eb0247844c36349617095f9734e89be683fd7bd5001b416d800c53ec8e8eb533c418a83e803daf6fdfd552ca745bb2b24d8abe899ea89572524343386a035b675e9d5eeae81aefb3a24397f36fe501c66b27d1c0e453fcc975c888d9d6d5a4ca0a4b32b41deebed70", + signature: + "0x90157a1c1b26384f0b4d41bec867d1a000f75e7b634ac7c4c6d8dfc0b0eaeb73bcc99586333d42df98c6b0a8c5ef0d8d071c68991afcd8fbbaa8b423e3632ee4fe0782bc03178a30a8bc6261f64f84a6c833fb96a0f29de1c34ede42c4a859b0", + depositDataRoot: + "0xdbe778a625c68446f3cc8b2009753a5e7dd7c37b8721ee98a796bb9179dfe8ac", + }; + + const emptyCluster = [ + 0, // validatorCount + 0, // networkFeeIndex + 0, // index + true, // active + 0, // balance + ]; + + await nativeStakingSSVStrategy + .connect(governor) + .setStakeETHThreshold(parseEther("19200")); // 32 * 600 = 19200 + + await weth + .connect(josh) + .transfer(nativeStakingSSVStrategy.address, parseEther("19200")); // 32 * 600 = 19200 + + const pubKeys = []; + const sharesData = []; + const stakeData = []; + + for (let i = 0; i < 600; i++) { + // this just creates incrementally bigger validator pubKeys that are of sufficient length + const pubkey = "0x" + (i + 1).toString(16).padStart(96, "0"); + + pubKeys[i] = pubkey; + sharesData[i] = testValidator.sharesData; + stakeData[i] = { + pubkey, + signature: testValidator.signature, + depositDataRoot: testValidator.depositDataRoot, + }; + } + + const ssvAmount = ethUnits("10"); + + for (let i = 0; i < 3; i++) { + // Register a new validator with the SSV Network + await nativeStakingSSVStrategy + .connect(validatorRegistrator) + .registerSsvValidators( + pubKeys.slice(i * 200, (i + 1) * 200), + testValidator.operatorIds, + sharesData.slice(i * 200, (i + 1) * 200), + ssvAmount, + emptyCluster + ); + + // Stake ETH to the new validator + nativeStakingSSVStrategy + .connect(validatorRegistrator) + .stakeEth(stakeData.slice(i * 200, (i + 1) * 200)); + } + + return fixture; +} + /** * Generalized strategy fixture that works only in forked environment * @@ -2483,8 +2576,9 @@ module.exports = { untiltBalancerMetaStableWETHPool, fraxETHStrategyFixture, frxEthRedeemStrategyFixture, - lidoWithdrawalStrategyFixture, nativeStakingSSVStrategyFixture, + nativeStakingValidatorDepositsFixture, + lidoWithdrawalStrategyFixture, oethMorphoAaveFixture, oeth1InchSwapperFixture, oethCollateralSwapFixture, diff --git a/contracts/test/strategies/nativeSSVStakingValidatorSim.js b/contracts/test/strategies/nativeSSVStakingValidatorSim.js new file mode 100644 index 0000000000..27ba4361d3 --- /dev/null +++ b/contracts/test/strategies/nativeSSVStakingValidatorSim.js @@ -0,0 +1,50 @@ +const { isCI } = require("../helpers"); +const ValidatorSimulator = require("../../utils/ValidatorSimulator"); +const { + createFixtureLoader, + nativeStakingValidatorDepositsFixture, +} = require("./../_fixture"); +const loadFixture = createFixtureLoader(nativeStakingValidatorDepositsFixture); + +let validatorSimulator; +describe("Unit test: Native SSV Staking Strategy", function () { + this.timeout(0); + + // Retry up to 3 times on CI + this.retries(isCI ? 3 : 0); + + let fixture; + beforeEach(async () => { + fixture = await loadFixture(); + const { + nativeStakingSSVStrategy, + depositContract, + ssvNetwork, + validatorRegistrator, + } = fixture; + + validatorSimulator = new ValidatorSimulator( + nativeStakingSSVStrategy, + depositContract, + ssvNetwork, + validatorRegistrator + ); + }); + + describe("Native SSV Staking validator simulations", function () { + const maxSlashedValidators = 5; + const maxWithdrawnValidators = 50; + + // loop through possible combinations of slashed and withdrawn validators + for (let i = 0; i < maxSlashedValidators + 1; i++) { + for (let j = 0; j < maxWithdrawnValidators + 1; j++) { + it(`Should correctly do accounting when ${i} validators are slashed and ${j} validators are fully withdrawn`, async () => { + await validatorSimulator.executeSimulation({ + validatorSlashes: i, + validatorFullWithdrawals: j, + }); + }); + } + } + }); +}); diff --git a/contracts/utils/ValidatorSimulator.js b/contracts/utils/ValidatorSimulator.js new file mode 100644 index 0000000000..ff9c2bfcbe --- /dev/null +++ b/contracts/utils/ValidatorSimulator.js @@ -0,0 +1,168 @@ +const { expect } = require("chai"); + +const hre = require("hardhat"); +const { ethers } = hre; +const { BigNumber } = ethers; +const { parseEther, formatEther } = require("ethers/lib/utils"); +const { setBalance } = require("@nomicfoundation/hardhat-network-helpers"); + +const log = require("./logger")("test:unit:staking:simulator"); + +class ValidatorSimulator { + nativeStakingContract; + depositContract; + ssvNetworkContract; + registrator; + + constructor( + _nativeStakingContract, + _depositContract, + _ssvNetworkContract, + _registrator + ) { + this.nativeStakingContract = _nativeStakingContract; + this.depositContract = _depositContract; + this.ssvNetworkContract = _ssvNetworkContract; + this.registrator = _registrator; + } + + async executeSimulation({ validatorSlashes, validatorFullWithdrawals }) { + const [ + stakedValidators, + registeredValidators, + exitedValidators, + removedValidators, + activeDepositedValidators, + ] = await this.fetchStats(); + + log( + `Running test starting with ${stakedValidators} staked validators of which ${validatorSlashes} are slashed ` + + `and ${validatorFullWithdrawals} have fully withdrawn.` + ); + + expect(stakedValidators).to.equal( + registeredValidators, + "stakedValidators and registeredValidators should match" + ); + expect(exitedValidators).to.equal( + removedValidators, + "exitedValidators and removedValidators should match" + ); + + // check here to update yearly non MEV APY: https://www.blocknative.com/ethereum-staking-calculator + const yearlyNonMEVApy = 0.0395; // 3.95% + // check here the "Sweep delay" stat to update: https://www.validatorqueue.com/ + const validatorSweepCycleLength = 8.9; // in days + + const rewardsPerCyclePerValidator = parseEther("32") + .mul(BigNumber.from(`${yearlyNonMEVApy * 10000}`)) + .div(BigNumber.from("10000")) // gets rewards per year + .div(BigNumber.from("365")) // gets rewards per day + .mul(BigNumber.from(`${validatorSweepCycleLength * 10}`)) // gets rewards per cycle * 10 + .div(BigNumber.from("10")); // gets rewards per cycle + + /* Validators that have been slashed or have done a full withdrawal might still have earned some of the ETH. + * We just halve the rewards, assuming the median/average earnings of exited validators is still half + * of the beacon chain sweep cycle. + */ + const rewardsPerCyclePerExitedValidator = rewardsPerCyclePerValidator.div( + BigNumber.from("2") + ); + + const fullyStakedValidators = + stakedValidators - validatorSlashes - validatorFullWithdrawals; + const beaconChainPartialSweeps = + // rewards from fully staked validators + rewardsPerCyclePerValidator + .mul(BigNumber.from(fullyStakedValidators.toString())) + .add( + // rewards of slashed and fully exited validators + rewardsPerCyclePerExitedValidator.mul( + BigNumber.from(`${validatorSlashes + validatorFullWithdrawals}`) + ) + ); + + const beaconChainSlashes = parseEther("30.9").mul( + BigNumber.from(validatorSlashes.toString()) + ); + const beaconChainFullWithdrawals = parseEther("32").mul( + BigNumber.from(validatorFullWithdrawals.toString()) + ); + const totalBeaconChainRewards = beaconChainPartialSweeps + .add(beaconChainSlashes) + .add(beaconChainFullWithdrawals); + + log( + `The test ran with ${activeDepositedValidators} active deposited validators of which ` + + `${validatorSlashes} have been slashed and ${validatorFullWithdrawals} fully withdrawn.` + ); + + log( + `The test yielded ${formatEther( + beaconChainPartialSweeps + )} ETH in partial withdrawal sweeps,` + + `${formatEther(beaconChainSlashes)} ETH from slashes and ${formatEther( + beaconChainFullWithdrawals + )} ` + + `ETH from full withdrawals. Totaling to: ${formatEther( + totalBeaconChainRewards + )} ETH. ` + ); + + await setBalance( + this.nativeStakingContract.address, + totalBeaconChainRewards + ); + + // do the accounting + await this.nativeStakingContract.connect(this.registrator).doAccounting(); + + const [, , , , activeDepositedValidatorsAfter, paused] = + await this.fetchStats(); + + expect(paused).to.equal(false, "Fuse is blown, it shouldn't be"); + expect(activeDepositedValidatorsAfter).to.equal( + stakedValidators - validatorSlashes - validatorFullWithdrawals, + "Unexpected number of active validators" + ); + // TODO: check that ether remaining on the native staking wasn't close (1 ETH vicinity) to + // blow the fuse + log(`activeDepositedValidatorsAfter`, activeDepositedValidatorsAfter); + } + + async fetchStats() { + let depositCount, + registeredValidators, + exitedValidators, + removedValidators, + activeDepositedValidators, + paused; + + depositCount = Number(await this.depositContract.deposit_count()); + registeredValidators = Number( + await this.ssvNetworkContract.registeredValidators() + ); + exitedValidators = Number(await this.ssvNetworkContract.exitedValidators()); + removedValidators = Number( + await this.ssvNetworkContract.removedValidators() + ); + removedValidators = Number( + await this.ssvNetworkContract.removedValidators() + ); + activeDepositedValidators = Number( + await this.nativeStakingContract.activeDepositedValidators() + ); + paused = await this.nativeStakingContract.paused(); + + return [ + depositCount, + registeredValidators, + exitedValidators, + removedValidators, + activeDepositedValidators, + paused, + ]; + } +} + +module.exports = ValidatorSimulator; diff --git a/contracts/utils/funding.js b/contracts/utils/funding.js index 579f4e9d8a..184fe5c11f 100644 --- a/contracts/utils/funding.js +++ b/contracts/utils/funding.js @@ -42,7 +42,7 @@ const fundAccountsForOETHUnitTests = async () => { for (const address of signerAddresses) { const signer = await ethers.provider.getSigner(address); - await weth.connect(signer).mint(oethUnits("1000")); + await weth.connect(signer).mint(oethUnits("100000")); await rETH.connect(signer).mint(oethUnits("1000")); await stETH.connect(signer).mint(oethUnits("1000")); await frxETH.connect(signer).mint(oethUnits("1000"));