From 8291624fd259378da6627def62ea6201cf08bfb5 Mon Sep 17 00:00:00 2001 From: Nicholas Addison Date: Tue, 9 Jun 2026 15:57:55 +1000 Subject: [PATCH 1/3] All removeSsvValidator to be called for the Compounding Staking SSV Strategy on the ConsolidationController --- .../NativeStaking/ConsolidationController.sol | 22 ++++++++++++++----- ...196_deploy_compounding_staking_strategy.js | 8 ++++++- contracts/tasks/tasks.js | 6 +++++ contracts/tasks/validatorCompound.js | 21 +++++++++++++----- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/contracts/contracts/strategies/NativeStaking/ConsolidationController.sol b/contracts/contracts/strategies/NativeStaking/ConsolidationController.sol index a8acad2a43..f97785300b 100644 --- a/contracts/contracts/strategies/NativeStaking/ConsolidationController.sol +++ b/contracts/contracts/strategies/NativeStaking/ConsolidationController.sol @@ -26,6 +26,8 @@ contract ConsolidationController is Ownable { address public immutable validatorRegistrator; /// @dev The old Native Staking Strategy connected to the second SSV cluster address internal immutable nativeStakingStrategy2; + /// @dev The old Compounding Staking SSV Strategy connected to the SSV cluster being removed + address internal immutable compoundingStakingSsvStrategy; /// @dev The new Compounding Staking Strategy CompoundingStakingStrategy internal immutable targetStrategy; @@ -56,12 +58,14 @@ contract ConsolidationController is Ownable { address _owner, address _validatorRegistrator, address _nativeStakingStrategy2, + address _compoundingStakingSsvStrategy, address _targetStrategy ) { _transferOwnership(_owner); validatorRegistrator = _validatorRegistrator; nativeStakingStrategy2 = _nativeStakingStrategy2; + compoundingStakingSsvStrategy = _compoundingStakingSsvStrategy; targetStrategy = CompoundingStakingStrategy(payable(_targetStrategy)); } @@ -332,11 +336,13 @@ contract ConsolidationController is Ownable { } /** - * @notice Removing source validators is not allowed during the consolidation process - * as consolidated validators will be in EXITING state hence can not be consolidated after removal. + * @notice Removes a validator from an old staking strategy's SSV cluster. + * @dev Removing validators from the native source strategy is not allowed while + * that strategy is being consolidated, as consolidated validators are in EXITING + * state and can not be consolidated after removal. * Only callable by the validator registrator. - * @param _sourceStrategy The address of the old Native Staking Strategy - * @param publicKey The public key of the validator to remove which must have EXITING or REGISTERED state. + * @param _sourceStrategy The address of the old Native Staking Strategy or old Compounding Staking SSV Strategy + * @param publicKey The public key of the validator to remove * @param operatorIds The operator IDs for the source SSV cluster * @param cluster The SSV cluster information for the source validator */ @@ -346,8 +352,12 @@ contract ConsolidationController is Ownable { uint64[] calldata operatorIds, Cluster calldata cluster ) external onlyRegistrator { - // Check sourceStrategy is a valid old Native Staking Strategy - _checkSourceStrategy(_sourceStrategy); + // Check sourceStrategy is a valid old staking strategy + require( + _sourceStrategy == nativeStakingStrategy2 || + _sourceStrategy == compoundingStakingSsvStrategy, + "Invalid source strategy" + ); // Prevent removing a validator from the SSV cluster before the consolidation // process has been completed for the source strategy being consolidated. // This prevents validators that have been exited rather than consolidated but that's ok. diff --git a/contracts/deploy/mainnet/196_deploy_compounding_staking_strategy.js b/contracts/deploy/mainnet/196_deploy_compounding_staking_strategy.js index aab688fed9..15e2980325 100644 --- a/contracts/deploy/mainnet/196_deploy_compounding_staking_strategy.js +++ b/contracts/deploy/mainnet/196_deploy_compounding_staking_strategy.js @@ -154,6 +154,7 @@ module.exports = deploymentWithGovernanceProposal( addresses.mainnet.Guardian, // Admin 5/8 multisig addresses.mainnet.talosRelayer, // New Talos Relayer cNativeStakingStrategy2.address, // Old Native Staking Strategy 2 + cCompoundingStakingSSVStrategy.address, // Old Compounding Staking SSV Strategy cStrategy.address, // New Compounding Staking Strategy ] ); @@ -167,6 +168,11 @@ module.exports = deploymentWithGovernanceProposal( signature: "upgradeTo(address)", args: [dCompoundingStakingSSVStrategy.address], }, + { + contract: cCompoundingStakingSSVStrategy, + signature: "setRegistrator(address)", + args: [dConsolidationController.address], + }, { contract: cNativeStakingStrategy2Proxy, signature: "upgradeTo(address)", @@ -208,7 +214,7 @@ module.exports = deploymentWithGovernanceProposal( compoundingSsvClusterForWithdraw, ], }); - actions.splice(5, 0, { + actions.splice(6, 0, { contract: cOETHVault, signature: "removeStrategy(address)", args: [cCompoundingStakingSSVStrategyProxy.address], diff --git a/contracts/tasks/tasks.js b/contracts/tasks/tasks.js index af66480577..1961bd26de 100644 --- a/contracts/tasks/tasks.js +++ b/contracts/tasks/tasks.js @@ -2452,6 +2452,12 @@ subtask( undefined, types.string ) + .addOptionalParam( + "consol", + "Call the consolidation controller instead of the strategy", + false, + types.boolean + ) .setAction(async (taskArgs) => { const signer = await getSigner(); await removeValidator({ ...taskArgs, signer }); diff --git a/contracts/tasks/validatorCompound.js b/contracts/tasks/validatorCompound.js index f86b5ad6f9..dbde601040 100644 --- a/contracts/tasks/validatorCompound.js +++ b/contracts/tasks/validatorCompound.js @@ -890,7 +890,7 @@ async function setRegistrator({ account, type }) { await logTxDetails(tx, "setRegistrator"); } -async function removeValidator({ pubkey, operatorids }) { +async function removeValidator({ pubkey, operatorids, consol = false }) { const signer = await getSigner(); log(`Splitting operator IDs ${operatorids}`); @@ -900,6 +900,9 @@ async function removeValidator({ pubkey, operatorids }) { "CompoundingStakingSSVStrategyProxy", "CompoundingStakingSSVStrategy" ); + const contract = consol + ? await resolveContract("ConsolidationController") + : strategy; // Cluster details const { chainId } = await ethers.provider.getNetwork(); @@ -909,10 +912,18 @@ async function removeValidator({ pubkey, operatorids }) { ownerAddress: strategy.address, }); - log(`About to remove compounding validator with pubkey ${pubkey}`); - const tx = await strategy - .connect(signer) - .removeSsvValidator(pubkey, operatorIds, cluster); + log( + `About to remove compounding validator with pubkey ${pubkey} via ${ + consol ? "ConsolidationController" : "CompoundingStakingSSVStrategy" + }` + ); + const tx = consol + ? await contract + .connect(signer) + .removeSsvValidator(strategy.address, pubkey, operatorIds, cluster) + : await contract + .connect(signer) + .removeSsvValidator(pubkey, operatorIds, cluster); await logTxDetails(tx, "removeSsvValidator"); } From 969165e8abc6d5c680d2f0145179f22376a1bf8f Mon Sep 17 00:00:00 2001 From: Nicholas Addison Date: Tue, 9 Jun 2026 17:52:57 +1000 Subject: [PATCH 2/3] Split withdrawSsvClusterEth and removeStrategy into separate gov prop so the 10 validators can be removed post upgrade --- ...196_deploy_compounding_staking_strategy.js | 47 ----- ...197_remove_old_compounding_ssv_strategy.js | 178 ++++++++++++++++++ contracts/utils/ssv.js | 14 +- 3 files changed, 191 insertions(+), 48 deletions(-) create mode 100644 contracts/deploy/mainnet/197_remove_old_compounding_ssv_strategy.js diff --git a/contracts/deploy/mainnet/196_deploy_compounding_staking_strategy.js b/contracts/deploy/mainnet/196_deploy_compounding_staking_strategy.js index 15e2980325..76e10052e9 100644 --- a/contracts/deploy/mainnet/196_deploy_compounding_staking_strategy.js +++ b/contracts/deploy/mainnet/196_deploy_compounding_staking_strategy.js @@ -1,10 +1,6 @@ const addresses = require("../../utils/addresses"); -const { isFork } = require("../../test/helpers"); const { beaconChainGenesisTimeMainnet } = require("../../utils/constants"); const { deploymentWithGovernanceProposal } = require("../../utils/deploy"); -const { getClusterInfo, splitOperatorIds } = require("../../utils/ssv"); - -const compoundingSsvClusterOperatorIds = "2070,2071,2072,2073"; module.exports = deploymentWithGovernanceProposal( { @@ -15,7 +11,6 @@ module.exports = deploymentWithGovernanceProposal( }, async ({ deployWithConfirmation, ethers, withConfirmation }) => { const { deployerAddr } = await getNamedAccounts(); - const { chainId } = await ethers.provider.getNetwork(); const sDeployer = await ethers.provider.getSigner(deployerAddr); const cOETHVaultProxy = await ethers.getContract("OETHVaultProxy"); @@ -41,24 +36,6 @@ module.exports = deploymentWithGovernanceProposal( "NativeStakingSSVStrategy", cNativeStakingStrategy2Proxy.address ); - const compoundingOperatorIds = splitOperatorIds( - compoundingSsvClusterOperatorIds - ); - const { cluster: compoundingSsvCluster } = await getClusterInfo({ - chainId, - operatorids: compoundingOperatorIds.join(","), - ownerAddress: cCompoundingStakingSSVStrategyProxy.address, - }); - const compoundingSsvClusterEthBalance = ethers.BigNumber.from( - compoundingSsvCluster.ethBalance || 0 - ); - const compoundingSsvClusterForWithdraw = { - validatorCount: compoundingSsvCluster.validatorCount, - networkFeeIndex: compoundingSsvCluster.networkFeeIndex, - index: compoundingSsvCluster.index, - active: compoundingSsvCluster.active, - balance: compoundingSsvClusterEthBalance, - }; console.log("Deploy CompoundingStakingSSVStrategy implementation"); const dCompoundingStakingSSVStrategy = await deployWithConfirmation( @@ -159,9 +136,6 @@ module.exports = deploymentWithGovernanceProposal( ] ); - const shouldWithdrawCompoundingSsvCluster = - !isFork || Number(compoundingSsvCluster.validatorCount) === 0; - const actions = [ { contract: cCompoundingStakingSSVStrategyProxy, @@ -200,27 +174,6 @@ module.exports = deploymentWithGovernanceProposal( }, ]; - // This can be simplified once the compounding SSV cluster has been fully exited and withdrawn, - // but for now we need to withdraw the cluster from the old strategy and remove the old strategy - // from the vault within the same proposal to ensure the safety of users' funds. - if (shouldWithdrawCompoundingSsvCluster) { - actions.splice(1, 0, { - contract: cCompoundingStakingSSVStrategy, - signature: - "withdrawSsvClusterEth(uint64[],uint256,(uint32,uint64,uint64,bool,uint256))", - args: [ - compoundingOperatorIds, - compoundingSsvClusterEthBalance, - compoundingSsvClusterForWithdraw, - ], - }); - actions.splice(6, 0, { - contract: cOETHVault, - signature: "removeStrategy(address)", - args: [cCompoundingStakingSSVStrategyProxy.address], - }); - } - return { name: "Deploy new vanilla compounding staking strategy, upgrade SSV strategies and deploy consolidation controller", actions, diff --git a/contracts/deploy/mainnet/197_remove_old_compounding_ssv_strategy.js b/contracts/deploy/mainnet/197_remove_old_compounding_ssv_strategy.js new file mode 100644 index 0000000000..75816ac10b --- /dev/null +++ b/contracts/deploy/mainnet/197_remove_old_compounding_ssv_strategy.js @@ -0,0 +1,178 @@ +const addresses = require("../../utils/addresses"); +const { deploymentWithGovernanceProposal } = require("../../utils/deploy"); +const { impersonateAndFund } = require("../../utils/signers"); +const { + getClusterInfo, + normalizeCluster, + splitOperatorIds, +} = require("../../utils/ssv"); +const { isFork } = require("../../test/helpers"); +const { hashPubKey } = require("../../utils/beacon"); +const { + getStorageAt, + setStorageAt, +} = require("@nomicfoundation/hardhat-network-helpers"); +const { BigNumber } = require("ethers"); +const { keccak256, solidityPack, hexZeroPad } = require("ethers/lib/utils"); + +const compoundingSsvClusterOperatorIds = "2070,2071,2072,2073"; +const validatorMappingSlot = 55; +const exitedValidatorState = 6; +const compoundingSsvValidatorPubkeys = [ + "0x8b5f08ce7af02245ae664a96fde5af1585edaa257f852490e5bb1957f1b3433b9b213577a3e475d6b034618f641f3bee", + "0x8a29191751a94a7eb921cd832972a875376b629bd3978c92ab3daaf596cab5e3cd1877cf63e65fe59ccc769c3b77a607", + "0xb5d37226e27e0ab066541ccb795e04149300bb8c0b0fd528785f6a940e94c624b65ef1eb771f78a5f2685317b7e6f34f", + "0x87b76ce8ea170a8a6db6842848eca2f3117367ada43120401a7f3095498a910a1455352bd12d15f5a07693f61e5b8c37", + "0x8427639adf9c746f7d7271ddee3bbcd7a1f3b4beb3bd67224c345d7c7e7cffd58d61d5bc84a3ab7d0f909ebf71da7b8b", + "0x84ef4399aa33bbea588965cad4f1df99a2586ef2791cc2527677f1f10a922996ad9b6cd7c8287ca215dc7dffb2e7946d", + "0x9695233248996e2d288baef676ee03ef30467eba161258894abeb382fa89ed7381dac05745a5c95df456533ef8a5fdad", + "0x9226889b28bee5478d0039a86bb913b645769ec3af18f08cc93ab46421fe8b3493e7e13b381682cf48fd3d5fa67c2f08", + "0x8f52f57132e409e749f0fc8305c4e2784c33abf43f80f1cc329b06ca94f7c50638b47a350b03c2ef4cc72860fee29730", + "0xa4258aa50aba9d7441f734213ae76fad9809572a593765c25c25d7afd42b83baba06397bd9e264a9fa24c3327a308682", +]; + +const setForkValidatorExited = async (strategy, pubkey) => { + const validatorSlot = keccak256( + solidityPack( + ["bytes32", "uint256"], + [hashPubKey(pubkey), validatorMappingSlot] + ) + ); + const existingValue = BigNumber.from( + await getStorageAt(strategy.address, validatorSlot) + ); + const exitedValue = existingValue + .and(BigNumber.from(2).pow(256).sub(256)) + .or(exitedValidatorState); + + await setStorageAt( + strategy.address, + validatorSlot, + hexZeroPad(exitedValue.toHexString(), 32) + ); +}; + +const parseRemovedCluster = (ssvNetwork, receipt) => { + for (const log of receipt.logs) { + if (log.address.toLowerCase() !== ssvNetwork.address.toLowerCase()) { + continue; + } + + try { + const parsed = ssvNetwork.interface.parseLog(log); + if (parsed.name === "ValidatorRemoved") { + return parsed.args.cluster; + } + } catch (err) { + // Ignore logs from other SSV events. + } + } + + throw new Error("ValidatorRemoved event not found"); +}; + +module.exports = deploymentWithGovernanceProposal( + { + deployName: "197_remove_old_compounding_ssv_strategy", + forceDeploy: false, + reduceQueueTime: true, + deployerIsProposer: false, + proposalId: "", + }, + async ({ ethers }) => { + const { chainId } = await ethers.provider.getNetwork(); + + const cOETHVaultProxy = await ethers.getContract("OETHVaultProxy"); + const cOETHVault = await ethers.getContractAt( + "IVault", + cOETHVaultProxy.address + ); + const cCompoundingStakingSSVStrategyProxy = await ethers.getContract( + "CompoundingStakingSSVStrategyProxy" + ); + const cCompoundingStakingSSVStrategy = await ethers.getContractAt( + "CompoundingStakingSSVStrategy", + cCompoundingStakingSSVStrategyProxy.address + ); + + const compoundingOperatorIds = splitOperatorIds( + compoundingSsvClusterOperatorIds + ); + let { cluster: compoundingSsvCluster } = await getClusterInfo({ + chainId, + operatorids: compoundingOperatorIds.join(","), + ownerAddress: cCompoundingStakingSSVStrategyProxy.address, + }); + + if (isFork && Number(compoundingSsvCluster.validatorCount) !== 0) { + const cConsolidationController = await ethers.getContract( + "ConsolidationController" + ); + const cSsvNetwork = await ethers.getContractAt( + "ISSVNetwork", + addresses.mainnet.SSVNetwork + ); + const sValidatorRegistrator = await impersonateAndFund( + addresses.mainnet.talosRelayer + ); + + console.log( + `Removing ${compoundingSsvValidatorPubkeys.length} validators from old compounding SSV cluster on fork` + ); + + for (const pubkey of compoundingSsvValidatorPubkeys) { + await setForkValidatorExited(cCompoundingStakingSSVStrategy, pubkey); + + const tx = await cConsolidationController + .connect(sValidatorRegistrator) + .removeSsvValidator( + cCompoundingStakingSSVStrategy.address, + pubkey, + compoundingOperatorIds, + compoundingSsvCluster + ); + const receipt = await tx.wait(); + compoundingSsvCluster = normalizeCluster( + parseRemovedCluster(cSsvNetwork, receipt) + ); + } + } + + if (Number(compoundingSsvCluster.validatorCount) !== 0) { + throw new Error( + `Compounding SSV cluster still has ${compoundingSsvCluster.validatorCount} validators` + ); + } + + const compoundingSsvClusterEthBalance = ethers.BigNumber.from( + compoundingSsvCluster.balance + ); + + console.log( + `Withdrawing ${ethers.utils.formatEther( + compoundingSsvClusterEthBalance + )} ETH from the old compounding SSV cluster` + ); + + return { + name: "Withdraw old compounding SSV cluster ETH and remove strategy from OETH Vault", + actions: [ + { + contract: cCompoundingStakingSSVStrategy, + signature: + "withdrawSsvClusterEth(uint64[],uint256,(uint32,uint64,uint64,bool,uint256))", + args: [ + compoundingOperatorIds, + compoundingSsvClusterEthBalance, + compoundingSsvCluster, + ], + }, + { + contract: cOETHVault, + signature: "removeStrategy(address)", + args: [cCompoundingStakingSSVStrategyProxy.address], + }, + ], + }; + } +); diff --git a/contracts/utils/ssv.js b/contracts/utils/ssv.js index 9594038ea3..c189496b64 100644 --- a/contracts/utils/ssv.js +++ b/contracts/utils/ssv.js @@ -15,6 +15,17 @@ const emptyCluster = { balance: 0, }; +const normalizeCluster = (cluster) => ({ + validatorCount: cluster.validatorCount, + networkFeeIndex: cluster.networkFeeIndex, + index: cluster.index, + active: cluster.active, + balance: + cluster.migrated && cluster.ethBalance !== undefined + ? cluster.ethBalance + : cluster.balance, +}); + const splitValidatorKey = async ({ keystorelocation, keystorepass, @@ -130,7 +141,7 @@ const getClusterInfo = async ({ ownerAddress, operatorids, chainId }) => { return { block: response.data.cluster.blockNumber, - cluster: response.data.cluster, + cluster: normalizeCluster(response.data.cluster), }; } catch (err) { if (err.response) { @@ -196,6 +207,7 @@ module.exports = { printClusterInfo, getClusterInfo, getClusterNonce, + normalizeCluster, sortOperatorIds, splitOperatorIds, splitValidatorKey, From a59cf40bc3a7168240dcd6ed63c06e84c41ec00d Mon Sep 17 00:00:00 2001 From: Nicholas Addison Date: Tue, 9 Jun 2026 18:46:04 +1000 Subject: [PATCH 3/3] Added back NON_REGISTERED check on registerSsvValidator --- .../CompoundingStakingSSVStrategy.sol | 4 ++ .../test/strategies/compoundingSSVStaking.js | 47 ++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/contracts/contracts/strategies/NativeStaking/CompoundingStakingSSVStrategy.sol b/contracts/contracts/strategies/NativeStaking/CompoundingStakingSSVStrategy.sol index ba132fa879..dd4b1b3675 100644 --- a/contracts/contracts/strategies/NativeStaking/CompoundingStakingSSVStrategy.sol +++ b/contracts/contracts/strategies/NativeStaking/CompoundingStakingSSVStrategy.sol @@ -68,6 +68,10 @@ contract CompoundingStakingSSVStrategy is CompoundingStakingStrategy { // Hash the public key using the Beacon Chain's format bytes32 pubKeyHash = _hashPubKey(publicKey); + if (validator[pubKeyHash].state != ValidatorState.NON_REGISTERED) { + revert NotRegisteredOrVerified(); + } + // Store the validator state as registered validator[pubKeyHash].state = ValidatorState.REGISTERED; diff --git a/contracts/test/strategies/compoundingSSVStaking.js b/contracts/test/strategies/compoundingSSVStaking.js index 90c8922ca4..4497eadfa1 100644 --- a/contracts/test/strategies/compoundingSSVStaking.js +++ b/contracts/test/strategies/compoundingSSVStaking.js @@ -1082,7 +1082,52 @@ describe("Unit test: Compounding SSV Staking Strategy", function () { emptyCluster, { value: ethUnits("2") } ) - ).to.be.reverted; + ).to.be.revertedWithCustomError("NotRegisteredOrVerified()"); + }); + + it("Should revert when re-registering a removed validator", async () => { + const { compoundingStakingSSVStrategy, validatorRegistrator } = fixture; + + const testValidator = testValidators[0]; + + // Register a new validator with the SSV Network + await compoundingStakingSSVStrategy + .connect(validatorRegistrator) + .registerSsvValidator( + testValidator.publicKey, + testValidator.operatorIds, + testValidator.sharesData, + emptyCluster, + { value: ethUnits("2") } + ); + + await compoundingStakingSSVStrategy + .connect(validatorRegistrator) + .removeSsvValidator( + testValidator.publicKey, + testValidator.operatorIds, + emptyCluster + ); + + expect( + ( + await compoundingStakingSSVStrategy.validator( + testValidator.publicKeyHash + ) + ).state + ).to.equal(7, "Validator state not 7 (REMOVED)"); + + await expect( + compoundingStakingSSVStrategy + .connect(validatorRegistrator) + .registerSsvValidator( + testValidator.publicKey, + testValidator.operatorIds, + testValidator.sharesData, + emptyCluster, + { value: ethUnits("2") } + ) + ).to.be.revertedWithCustomError("NotRegisteredOrVerified()"); }); it("Should revert when staking because of insufficient ETH balance", async () => {