From 109ca4e760ebe63789e053231622e326f7771815 Mon Sep 17 00:00:00 2001 From: Nicholas Addison Date: Mon, 11 May 2026 15:25:16 +1000 Subject: [PATCH 1/4] Governance proposal to claim the SSV from the staking strategies --- .../194_claim_ssv_from_staking_strategies.js | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 contracts/deploy/mainnet/194_claim_ssv_from_staking_strategies.js diff --git a/contracts/deploy/mainnet/194_claim_ssv_from_staking_strategies.js b/contracts/deploy/mainnet/194_claim_ssv_from_staking_strategies.js new file mode 100644 index 0000000000..c769f13650 --- /dev/null +++ b/contracts/deploy/mainnet/194_claim_ssv_from_staking_strategies.js @@ -0,0 +1,77 @@ +const addresses = require("../../utils/addresses"); +const { deploymentWithGovernanceProposal } = require("../../utils/deploy"); + +const strategyConfigs = [ + { + proxyName: "NativeStakingSSVStrategy2Proxy", + label: "2nd Native Staking SSV Strategy", + }, + { + proxyName: "NativeStakingSSVStrategy3Proxy", + label: "3rd Native Staking SSV Strategy", + }, + { + proxyName: "CompoundingStakingSSVStrategyProxy", + label: "Compounding Staking SSV Strategy", + }, +]; + +module.exports = deploymentWithGovernanceProposal( + { + deployName: "194_claim_ssv_from_staking_strategies", + forceDeploy: false, + reduceQueueTime: true, + deployerIsProposer: false, + proposalId: "", + }, + async ({ ethers }) => { + const ssv = await ethers.getContractAt("IERC20", addresses.mainnet.SSV); + const recipient = addresses.multichainStrategist; + const actions = []; + + let totalSSV = ethers.BigNumber.from(0); + + for (const strategyConfig of strategyConfigs) { + const proxy = await ethers.getContract(strategyConfig.proxyName); + const strategy = await ethers.getContractAt("IStrategy", proxy.address); + const balance = await ssv.balanceOf(strategy.address); + + console.log( + `${strategyConfig.label} ${ + strategy.address + } has ${ethers.utils.formatEther(balance)} SSV` + ); + + if (balance.isZero()) { + continue; + } + + actions.push({ + contract: strategy, + signature: "transferToken(address,uint256)", + args: [ssv.address, balance], + }); + + totalSSV = totalSSV.add(balance); + } + + if (totalSSV.isZero()) { + throw new Error("No SSV found on staking strategies"); + } + + actions.push({ + contract: ssv, + signature: "transfer(address,uint256)", + args: [recipient, totalSSV], + }); + + console.log( + `Transferring ${ethers.utils.formatEther(totalSSV)} SSV to ${recipient}` + ); + + return { + name: "Claim SSV from staking strategies and transfer to Guardian multisig", + actions, + }; + } +); From 312de875a61baad04aabd2a4348811bf7fa225db Mon Sep 17 00:00:00 2001 From: Nicholas Addison Date: Mon, 11 May 2026 15:48:15 +1000 Subject: [PATCH 2/4] Renamed Hardhat task depositSSV to depositCluster and have it work with ETH deposits Removed old registerValidators and stakeValidators Hardhat tasks using the P2P APIs Hardhat registerValidator now works with ETH deposits --- .../contracts/interfaces/ISSVNetwork.sol | 11 +- contracts/contracts/mocks/MockSSVNetwork.sol | 3 +- contracts/tasks/ssv.js | 19 +- contracts/tasks/tasks.js | 91 +- contracts/tasks/validator.js | 85 -- contracts/tasks/validatorCompound.js | 14 +- contracts/utils/validator.js | 833 +----------------- 7 files changed, 38 insertions(+), 1018 deletions(-) diff --git a/contracts/contracts/interfaces/ISSVNetwork.sol b/contracts/contracts/interfaces/ISSVNetwork.sol index 5ea6692152..1191575725 100644 --- a/contracts/contracts/interfaces/ISSVNetwork.sol +++ b/contracts/contracts/interfaces/ISSVNetwork.sol @@ -159,9 +159,8 @@ interface ISSVNetwork { function deposit( address clusterOwner, uint64[] memory operatorIds, - uint256 amount, Cluster memory cluster - ) external; + ) external payable; function executeOperatorFee(uint64 operatorId) external; @@ -201,11 +200,9 @@ interface ISSVNetwork { function proxiableUUID() external view returns (bytes32); - function reactivate( - uint64[] memory operatorIds, - uint256 amount, - Cluster memory cluster - ) external; + function reactivate(uint64[] memory operatorIds, Cluster memory cluster) + external + payable; function reduceOperatorFee(uint64 operatorId, uint256 fee) external; diff --git a/contracts/contracts/mocks/MockSSVNetwork.sol b/contracts/contracts/mocks/MockSSVNetwork.sol index 1b1c8d9db2..393c1aeaa2 100644 --- a/contracts/contracts/mocks/MockSSVNetwork.sol +++ b/contracts/contracts/mocks/MockSSVNetwork.sol @@ -32,9 +32,8 @@ contract MockSSVNetwork { function deposit( address clusterOwner, uint64[] calldata operatorIds, - uint256 amount, Cluster memory cluster - ) external {} + ) external payable {} function setFeeRecipientAddress(address recipient) external {} } diff --git a/contracts/tasks/ssv.js b/contracts/tasks/ssv.js index 448660b435..271cb394e5 100644 --- a/contracts/tasks/ssv.js +++ b/contracts/tasks/ssv.js @@ -60,10 +60,11 @@ const printClusterInfo = async (options) => { console.log(`Cluster: ${JSON.stringify(cluster.cluster, null, " ")}`); }; -const depositSSV = async ({ amount, index, operatorids }) => { +const depositCluster = async ({ amount, index, operatorids }) => { const amountBN = parseUnits(amount.toString(), 18); log(`Splitting operator IDs ${operatorids}`); - const operatorIds = await sortOperatorIds(operatorids); + const sortedOperatorIds = await sortOperatorIds(operatorids); + const operatorIds = splitOperatorIds(sortedOperatorIds); const signer = await getSigner(); @@ -78,22 +79,24 @@ const depositSSV = async ({ amount, index, operatorids }) => { const clusterInfo = await getClusterInfo({ chainId, ssvNetwork: ssvNetwork.address, - operatorids, + operatorids: sortedOperatorIds, ownerAddress: strategy.address, }); log( `About to deposit ${formatUnits( amountBN - )} SSV tokens to the SSV Network for native staking strategy ${ + )} ETH to the SSV Network for native staking strategy ${ strategy.address } with operator IDs ${operatorIds}` ); log(`Cluster: ${JSON.stringify(clusterInfo.cluster)}`); - const tx = await strategy + const tx = await ssvNetwork .connect(signer) - .depositSSV(operatorIds, amountBN, clusterInfo.cluster); - await logTxDetails(tx, "depositSSV"); + .deposit(strategy.address, operatorIds, clusterInfo.cluster, { + value: amountBN, + }); + await logTxDetails(tx, "depositCluster"); }; const migrateClusterToETH = async ({ type, amount, operatorids, index }) => { @@ -136,7 +139,7 @@ const migrateClusterToETH = async ({ type, amount, operatorids, index }) => { module.exports = { printClusterInfo, - depositSSV, + depositCluster, migrateClusterToETH, removeValidator, }; diff --git a/contracts/tasks/tasks.js b/contracts/tasks/tasks.js index 653ebbcd3e..d8e5a34274 100644 --- a/contracts/tasks/tasks.js +++ b/contracts/tasks/tasks.js @@ -66,7 +66,7 @@ const { calculateMaxPricePerVoteTask, manageBribes } = require("./poolBooster"); const { updateVotemarketEpochsTask } = require("./votemarket"); const { manageMerklBribesTask } = require("./merklPoolBooster"); const { - depositSSV, + depositCluster, migrateClusterToETH, printClusterInfo, removeValidator: removeOldValidator, @@ -92,7 +92,6 @@ const { updateWOETHOraclePrice, } = require("./strategy"); const { - validatorOperationsConfig, exitValidator, doAccounting, manuallyFixAccounting, @@ -123,7 +122,6 @@ const { undelegateValidator, withdrawFromSFC, } = require("../utils/sonicActions"); -const { registerValidators, stakeValidators } = require("../utils/validator"); const { harvestAndSwap } = require("./harvest"); const { deployForceEtherSender, forceSend } = require("./simulation"); const { sleep } = require("../utils/time"); @@ -1161,10 +1159,10 @@ task("getClusterInfo").setAction(async (_, __, runSuper) => { }); subtask( - "depositSSV", - "Deposit SSV tokens from the native staking strategy into an SSV Cluster" + "depositCluster", + "Deposit ETH into an SSV cluster for a native staking strategy" ) - .addParam("amount", "Amount of SSV tokens to deposit", undefined, types.float) + .addParam("amount", "Amount of ETH to deposit", undefined, types.float) .addOptionalParam( "index", "The number of the Native Staking Contract deployed.", @@ -1177,8 +1175,8 @@ subtask( undefined, types.string ) - .setAction(depositSSV); -task("depositSSV").setAction(async (_, __, runSuper) => { + .setAction(depositCluster); +task("depositCluster").setAction(async (_, __, runSuper) => { return runSuper(); }); @@ -1274,77 +1272,6 @@ task("deployStakingProxy").setAction(async (_, __, runSuper) => { return runSuper(); }); -// Validator Operations - -subtask( - "registerValidators", - "Creates the required amount of new SSV validators and stakes ETH" -) - .addOptionalParam( - "days", - "SSV Cluster operational time in days", - 2, - types.int - ) - .addOptionalParam( - "validators", - "The number of validators to register. defaults to the max that can be registered", - undefined, - types.int - ) - .addOptionalParam("clear", "Clear storage", false, types.boolean) - .addOptionalParam( - "eth", - "Override the days option and set the amount of ETH to deposit to the cluster.", - undefined, - types.float - ) - .addOptionalParam( - "uuid", - "uuid of P2P's request SSV validator API call. Used to reprocess a registration that failed to get the SSV request status.", - undefined, - types.string - ) - .addOptionalParam( - "index", - "The number of the Native Staking Contract deployed.", - undefined, - types.int - ) - .setAction(async (taskArgs) => { - const config = await validatorOperationsConfig(taskArgs); - const signer = await getSigner(); - await registerValidators({ ...config, signer }); - }); -task("registerValidators").setAction(async (_, __, runSuper) => { - return runSuper(); -}); - -subtask( - "stakeValidators", - "Creates the required amount of new SSV validators and stakes ETH" -) - .addOptionalParam( - "uuid", - "uuid of P2P's request SSV validator API call", - undefined, - types.string - ) - .addOptionalParam( - "index", - "The number of the Native Staking Contract deployed.", - undefined, - types.int - ) - .setAction(async (taskArgs) => { - const config = await validatorOperationsConfig(taskArgs); - const signer = await getSigner(); - await stakeValidators({ ...config, signer }); - }); -task("stakeValidators").setAction(async (_, __, runSuper) => { - return runSuper(); -}); - /** * This function relays the messages between mainnet and base networks. * @@ -2454,10 +2381,10 @@ subtask( types.string ) .addOptionalParam( - "ssv", - "Amount of SSV to deposit to the cluster.", + "eth", + "Amount of ETH to deposit to the cluster.", 0, - types.int + types.float ) .setAction(async (taskArgs) => { await registerValidator(taskArgs); diff --git a/contracts/tasks/validator.js b/contracts/tasks/validator.js index bfdd24636c..9c3af39d73 100644 --- a/contracts/tasks/validator.js +++ b/contracts/tasks/validator.js @@ -13,90 +13,6 @@ const { sleep } = require("../utils/time"); const log = require("../utils/logger")("task:p2p"); -// This is in a separate file as it uses hardhat. -// We don't want the registerValidators and stakeValidators functions to use hardhat -// as they are using in Defender Actions. -// This is only used by Hardhat tasks registerValidators and stakeValidators -const validatorOperationsConfig = async (taskArgs) => { - const networkName = await getNetworkName(); - - const addressesSet = addresses[networkName]; - const isMainnet = networkName === "mainnet"; - - const WETH = await ethers.getContractAt("IWETH9", addressesSet.WETH); - - const nativeStakingStrategy = await resolveNativeStakingStrategyProxy( - taskArgs.index - ); - const feeAccumulatorAddress = - await nativeStakingStrategy.FEE_ACCUMULATOR_ADDRESS(); - - const p2p_api_key = isMainnet - ? process.env.P2P_MAINNET_API_KEY - : process.env.P2P_HOODI_API_KEY; - if (!p2p_api_key) { - throw new Error( - "P2P API key environment variable is not set. P2P_MAINNET_API_KEY or P2P_HOODI_API_KEY" - ); - } - const p2p_base_url = isMainnet ? "api.p2p.org" : "api-test.p2p.org"; - - const awsS3AccessKeyId = process.env.AWS_ACCESS_S3_KEY_ID; - const awsS3SexcretAccessKeyId = process.env.AWS_SECRET_S3_ACCESS_KEY; - const s3BucketName = process.env.VALIDATOR_KEYS_S3_BUCKET_NAME; - - if (!awsS3AccessKeyId) { - throw new Error("Secret AWS_ACCESS_S3_KEY_ID not set"); - } - if (!awsS3SexcretAccessKeyId) { - throw new Error("Secret AWS_SECRET_S3_ACCESS_KEY not set"); - } - if (!s3BucketName) { - throw new Error("Secret VALIDATOR_KEYS_S3_BUCKET_NAME not set"); - } - - // Convert the ETH amount to wei in string format if it is provided - const ethAmount = - taskArgs.eth >= 0 - ? parseEther(taskArgs.eth.toString()).toString() - : undefined; - - // Local/dev mode — simple in-memory store - const store = new Map(); - const kvClient = { - async get(key) { - return store.get(key) ?? null; - }, - async put(key, value) { - store.set(key, value); - return value; - }, - async del(key) { - store.delete(key); - }, - }; - - return { - store: kvClient, - p2p_api_key, - p2p_base_url, - nativeStakingStrategy, - feeAccumulatorAddress, - WETH, - // how much SSV (expressed in days of runway) gets deposited into the - // SSV Network contract on validator registration. This is calculated - // at a Cluster level rather than a single validator. - validatorSpawnOperationalPeriodInDays: taskArgs.days, - clear: taskArgs.clear, - uuid: taskArgs.uuid, - maxValidatorsToRegister: taskArgs.validators, - ethAmount, - awsS3AccessKeyId, - awsS3SexcretAccessKeyId, - s3BucketName, - }; -}; - // @dev check validator is eligible for exit - // has been active for at least 256 epochs async function verifyMinActivationTime({ pubkey }) { @@ -403,7 +319,6 @@ const resolveFeeAccumulatorProxy = async (index) => { }; module.exports = { - validatorOperationsConfig, exitValidator, doAccounting, resetStakeETHTally, diff --git a/contracts/tasks/validatorCompound.js b/contracts/tasks/validatorCompound.js index 70cad05061..020ee136ed 100644 --- a/contracts/tasks/validatorCompound.js +++ b/contracts/tasks/validatorCompound.js @@ -86,7 +86,13 @@ async function registerValidatorCreateRequest({ days }) { * If the UUID is passed to this function then pubkey, shares, operatorIds are * ignored and fetched from the P2P */ -async function registerValidator({ pubkey, shares, operatorids, ssv, uuid }) { +async function registerValidator({ + pubkey, + shares, + operatorids, + eth = 0, + uuid, +}) { const signer = await getSigner(); if (uuid) { @@ -104,7 +110,7 @@ async function registerValidator({ pubkey, shares, operatorids, ssv, uuid }) { log(`Splitting operator IDs ${operatorids}`); const operatorIds = splitOperatorIds(operatorids); - const ssvAmount = parseUnits(ssv.toString(), 18); + const ethAmount = parseUnits(eth.toString(), 18); const strategy = await resolveContract( "CompoundingStakingSSVStrategyProxy", @@ -122,7 +128,9 @@ async function registerValidator({ pubkey, shares, operatorids, ssv, uuid }) { log(`About to register compounding validator with pubkey ${pubkey}`); const tx = await strategy .connect(signer) - .registerSsvValidator(pubkey, operatorIds, shares, ssvAmount, cluster); + .registerSsvValidator(pubkey, operatorIds, shares, cluster, { + value: ethAmount, + }); await logTxDetails(tx, "registerValidator"); } diff --git a/contracts/utils/validator.js b/contracts/utils/validator.js index 733d24dc1c..1d6c6ace5f 100644 --- a/contracts/utils/validator.js +++ b/contracts/utils/validator.js @@ -1,442 +1,6 @@ -const fetch = require("node-fetch"); -const { ethers } = require("ethers"); -const { defaultAbiCoder, formatUnits, hexDataSlice, parseEther, keccak256 } = - require("ethers").utils; -const { v4: uuidv4 } = require("uuid"); +const { formatUnits, parseEther } = require("ethers").utils; -const { storePrivateKeyToS3 } = require("./amazon"); -const { sleep } = require("./time"); -const { p2pApiEncodedKey } = require("./constants"); -const { mainnet } = require("./addresses"); -const { logTxDetails } = require("./txLogger"); - -const log = require("./logger")("task:p2p"); - -const validatorStateEnum = { - 0: "NOT_REGISTERED", - 1: "REGISTERED", - 2: "STAKED", - 3: "EXITED", - 4: "EXIT_COMPLETE", -}; - -/* When same UUID experiences and error threshold amount of times it is - * discarded. - */ -const ERROR_THRESHOLD = 5; -/* - * Spawns and maintains the required amount of validators throughout - * their setup cycle which consists of: - * - check balance of (W)ETH and crate P2P SSV cluster creation request - * - wait for the cluster to become operational - * - batch register the cluster on the SSV network - * - verify the complete cluster has been registered - * - batch stake the ETH to each of the validators - * - * Needs to also handle: - * - if anytime in the spawn cycle the number of (W)ETH falls below the - * required stake amount (withdrawal from Node Operator), mark the spawn - * process as failed - * - if spawn process gets stuck at any of the above steps and is not able to - * recover in X amount of times (e.g. 5 times). Mark the process as failed - * and start over. - * - TODO: (implement this) if fuse of the native staking strategy is blown - * stop with all the operations - */ -const registerValidators = async ({ - store, - signer, - p2p_api_key, - p2p_base_url, - nativeStakingStrategy, - feeAccumulatorAddress, - WETH, - validatorSpawnOperationalPeriodInDays, - clear, - uuid, - maxValidatorsToRegister, - ethAmount, - awsS3AccessKeyId, - awsS3SexcretAccessKeyId, - s3BucketName, -}) => { - if (uuid && clear) { - throw new Error(`Can not clear state and use a uuid at the same time.`); - } - let currentState; - if (!uuid) { - // If starting a new registration or restarting a failed one - currentState = await getState(store); - log("currentState", currentState); - } else { - // If restarting a registration that failed to get the SSV request status - await clearState(uuid, store); - await updateState(uuid, "validator_creation_issued", store); - currentState = await getState(store); - log(`Processing uuid: ${uuid}`); - } - - // If clearing the local storage so a new registration can be started - if (clear && currentState?.uuid) { - await clearState(currentState.uuid, store); - currentState = undefined; - } - - // Calculate how many validators can be staked to - const validatorsForEth = await validatorsThatCanBeStaked( - nativeStakingStrategy, - WETH - ); - if (validatorsForEth == 0 || validatorsForEth < maxValidatorsToRegister) { - console.log( - `Native staking contract doesn't have enough WETH available to stake. Does depositToStrategy or resetStakeETHTally need to be called?` - ); - if (maxValidatorsToRegister) { - console.log( - `Requested to spawn ${maxValidatorsToRegister} validators but only ${validatorsForEth} can be spawned.` - ); - } - return; - } - const validatorsCount = - maxValidatorsToRegister === undefined || - validatorsForEth < maxValidatorsToRegister - ? validatorsForEth - : maxValidatorsToRegister; - log(`validatorsCount: ${validatorsCount}`); - - // Check if this Native Staking Contract is not paused - if (await stakingContractPaused(nativeStakingStrategy)) { - console.log(`Native staking contract is paused... exiting`); - return; - } - - const executeOperateLoop = async () => { - while (true) { - if (!currentState) { - await createValidatorRequest( - store, - "validator_creation_issued", // next state - p2p_api_key, - p2p_base_url, - nativeStakingStrategy.address, // SSV owner address & withdrawal address - feeAccumulatorAddress, // execution layer fee recipient - validatorSpawnOperationalPeriodInDays, - validatorsCount - ); - currentState = await getState(store); - } - - if (currentState.state === "validator_creation_issued") { - await confirmValidatorRegistered( - store, - currentState.uuid, - "validator_creation_confirmed", // next state - p2p_api_key, - p2p_base_url, - awsS3AccessKeyId, - awsS3SexcretAccessKeyId, - s3BucketName - ); - currentState = await getState(store); - } - - if (currentState.state === "validator_creation_confirmed") { - await broadcastRegisterValidator( - store, - currentState.uuid, - "register_transaction_broadcast", // next state - signer, - currentState.metadata, - nativeStakingStrategy, - ethAmount - ); - currentState = await getState(store); - } - - if (currentState.state === "register_transaction_broadcast") { - await waitForTransactionAndUpdateStateOnSuccess( - store, - currentState.uuid, - "validator_registered", // next state - nativeStakingStrategy.provider, - currentState.metadata.validatorRegistrationTx, - "registerSsvValidator" // name of transaction we are waiting for - ); - currentState = await getState(store); - break; - } - - if (currentState.state === "validator_registered") { - log( - `Validator has been registered. Run the stakeValidators task to stake the validator` - ); - break; - } - - log(`Waiting for 5 seconds...`); - await sleep(5000); - } - }; - - try { - if ((await getErrorCount(store)) >= ERROR_THRESHOLD) { - await clearState( - currentState.uuid, - store, - `Errors have reached the threshold(${ERROR_THRESHOLD}) discarding attempt` - ); - return; - } - await executeOperateLoop(); - } catch (e) { - await increaseErrorCount(currentState ? currentState.uuid : "", store, e); - throw e; - } -}; - -const stakeValidators = async ({ - store, - signer, - nativeStakingStrategy, - WETH, - p2p_api_key, - p2p_base_url, - uuid, - awsS3AccessKeyId, - awsS3SexcretAccessKeyId, - s3BucketName, -}) => { - if (await stakingContractPaused(nativeStakingStrategy)) { - log(`Native staking contract is paused... exiting`); - return; - } - - let currentState; - if (!uuid) { - currentState = await getState(store); - log("currentState", currentState); - - if (!currentState) { - log( - `There are no registered validators in local storage. Have you run registerValidators?` - ); - return; - } - } else { - log(`Processing uuid: ${uuid}`); - } - - const executeOperateLoop = async () => { - while (true) { - if (!currentState) { - await confirmValidatorRegistered( - store, - uuid, - "validator_registered", // next state - p2p_api_key, - p2p_base_url, - awsS3AccessKeyId, - awsS3SexcretAccessKeyId, - s3BucketName - ); - currentState = await getState(store); - - // Check the first validator has not already been staked - const hashedPubkey = keccak256(currentState.metadata.pubkeys[0]); - const status = await nativeStakingStrategy.validatorsStates( - hashedPubkey - ); - if (validatorStateEnum[status] !== "REGISTERED") { - log( - `Validator with pubkey ${currentState.metadata.pubkeys[0]} not in REGISTERED state. Current state: ${validatorStateEnum[status]}` - ); - // await clearState(currentState.uuid, store); - // TODO just remove the validator that has already been staked from the metadata - break; - } else { - log( - `Validator with pubkey ${currentState.metadata.pubkeys[0]} is in the expected REGISTERED state.` - ); - } - } - - if (currentState.state === "validator_registered") { - await getDepositData( - store, - currentState.uuid, - "deposit_data_got", // next state - p2p_api_key, - p2p_base_url - ); - currentState = await getState(store); - } - - if (currentState.state === "deposit_data_got") { - const validatorsForEth = await validatorsThatCanBeStaked( - nativeStakingStrategy, - WETH - ); - const validatorsInState = currentState.metadata.pubkeys.length; - if (validatorsForEth < validatorsInState) { - `Native staking contract only has enough WETH to stake to ${validatorsForEth} validators, not ${validatorsInState}. Does depositToStrategy or resetStakeETHTally need to be called?`; - return; - } - - await depositEth( - store, - currentState.uuid, - "deposit_transaction_broadcast", // next state - signer, - nativeStakingStrategy, - currentState.metadata.pubkeys, - currentState.metadata.depositData - ); - currentState = await getState(store); - } - - if (currentState.state === "deposit_transaction_broadcast") { - await waitForTransactionAndUpdateStateOnSuccess( - store, - currentState.uuid, - "deposit_confirmed", // next state - nativeStakingStrategy.provider, - currentState.metadata.depositTx, - "stakeEth" // name of transaction we are waiting for - ); - - currentState = await getState(store); - } - - if (currentState.state === "deposit_confirmed") { - await clearState(currentState.uuid, store); - break; - } - - await sleep(1000); - } - }; - - try { - if ((await getErrorCount(store)) >= ERROR_THRESHOLD) { - await clearState( - currentState.uuid, - store, - `Errors have reached the threshold(${ERROR_THRESHOLD}) discarding attempt` - ); - return; - } - await executeOperateLoop(); - } catch (e) { - await increaseErrorCount(currentState ? currentState.uuid : "", store, e); - throw e; - } -}; - -const getErrorCount = async (store) => { - const existingRequest = await getState(store); - return existingRequest && existingRequest.errorCount - ? existingRequest.errorCount - : 0; -}; - -const increaseErrorCount = async (requestUUID, store, error) => { - if (!requestUUID) { - return; - } - - const existingRequest = await getState(store); - const existingErrorCount = existingRequest.errorCount - ? existingRequest.errorCount - : 0; - const newErrorCount = existingErrorCount + 1; - - await store.put( - "currentRequest", - JSON.stringify({ - ...existingRequest, - errorCount: newErrorCount, - }) - ); - log( - `Operate validators loop uuid: ${requestUUID} encountered an error ${newErrorCount} times. Error: `, - error - ); -}; - -/* Each P2P request has a life cycle that results in the following states stored - * in the shared Defender key-value storage memory. - * - "validator_creation_issued" the create request that creates a validator issued - * - "validator_creation_confirmed" confirmation that the validator has been created - * - "register_transaction_broadcast" the transaction to register the validator on - * the SSV network has been broadcast to the Ethereum network - * - "validator_registered" the register transaction has been confirmed - * - "deposit_transaction_broadcast" the stake transaction staking 32 ETH has been - * broadcast to the Ethereum network - * - "deposit_confirmed" transaction to stake 32 ETH has been confirmed - */ -const updateState = async (requestUUID, state, store, metadata = {}) => { - if ( - ![ - "validator_creation_issued", - "validator_creation_confirmed", - "register_transaction_broadcast", - "validator_registered", - "deposit_data_got", - "deposit_transaction_broadcast", - "deposit_confirmed", - ].includes(state) - ) { - throw new Error(`Unexpected state: ${state}`); - } - - const existingRequest = await getState(store); - const existingMetadata = - existingRequest && existingRequest.metadata ? existingRequest.metadata : {}; - - await store.put( - "currentRequest", - JSON.stringify({ - uuid: requestUUID, - state: state, - metadata: { ...existingMetadata, ...metadata }, - }) - ); -}; - -const clearState = async (uuid, store, error = false) => { - if (error) { - log( - `Clearing state tracking of ${uuid} request because of an error: ${error}` - ); - } else { - log( - `Clearing state tracking of ${uuid} request as it has completed its spawn cycle` - ); - } - await store.del("currentRequest"); -}; - -/* Fetches the state of the current/ongoing cluster creation if there is any - * returns either: - * - false if there is no cluster - * - - */ -const getState = async (store) => { - const currentState = await store.get("currentRequest"); - if (!currentState) { - return currentState; - } - - return JSON.parse(await store.get("currentRequest")); -}; - -const stakingContractPaused = async (nativeStakingStrategy) => { - const paused = await nativeStakingStrategy.paused(); - - log(`Native staking contract is ${paused ? "" : "not "}paused`); - return paused; -}; +const log = require("./logger")("utils:validator"); const validatorsThatCanBeStaked = async (nativeStakingStrategy, WETH) => { const address = nativeStakingStrategy.address; @@ -472,399 +36,6 @@ const validatorsThatCanBeStaked = async (nativeStakingStrategy, WETH) => { return validatorCount; }; -/* Make a GET or POST request to P2P API - * @param api_key: P2P API key - * @param method: http method that can either be POST or GET - * @param body: body object in case of a POST request - */ -const p2pRequest = async (url, api_key, method, body) => { - const headers = { - Accept: "application/json", - Authorization: `Bearer ${api_key}`, - }; - - if (method === "POST") { - headers["Content-Type"] = "application/json"; - } - - const bodyString = JSON.stringify(body); - log( - `About to call P2P API: ${method} ${url} `, - body != undefined ? ` and body: ${bodyString}` : "" - ); - - const rawResponse = await fetch(url, { - method, - headers, - body: bodyString, - }); - - const response = await rawResponse.json(); - if (response.error != null) { - log(`Call to P2P API failed: ${method} ${url}`); - // TODO: response might be too big for the logs to handle? - //log(`response: `, response); - throw new Error( - `Failed to call to P2P API. Error: ${JSON.stringify(response.error)}` - ); - } else { - log(`${method} request to P2P API succeeded:`); - // TODO: response might be too big for the logs to handle? - //log(response); - } - - return response; -}; - -const createValidatorRequest = async ( - store, - nextState, - p2p_api_key, - p2p_base_url, - nativeStakingStrategy, - feeAccumulatorAddress, - validatorSpawnOperationalPeriodInDays, - validatorsCount -) => { - const uuid = uuidv4(); - log(`About to create a SSV validator request with uuid: ${uuid}`); - await p2pRequest( - `https://${p2p_base_url}/api/v1/eth/staking/ssv/request/create`, - p2p_api_key, - "POST", - { - validatorsCount, - id: uuid, - withdrawalAddress: nativeStakingStrategy, - feeRecipientAddress: feeAccumulatorAddress, - ssvOwnerAddress: nativeStakingStrategy, - type: "with-encrypt-key", - operationPeriodInDays: validatorSpawnOperationalPeriodInDays, - ecdhPublicKey: p2pApiEncodedKey, - } - ); - - await updateState(uuid, nextState, store); - - log(`About to wait for 90 seconds for the P2P API to process the request...`); - await sleep(90000); -}; - -const waitForTransactionAndUpdateStateOnSuccess = async ( - store, - uuid, - nextState, - provider, - txHash, - methodName -) => { - log( - `Waiting for transaction with hash "${txHash}", method "${methodName}" and uuid "${uuid}" to be mined...` - ); - const tx = await provider.waitForTransaction(txHash); - if (!tx) { - throw Error( - `Transaction with hash "${txHash}" not found for method "${methodName}" and uuid "${uuid}"` - ); - } - if (tx.status !== 1) { - throw Error( - `Transaction with hash "${txHash}" failed for method "${methodName}" and uuid "${uuid}"` - ); - } - log( - `Transaction with hash "${txHash}", method "${methodName}" and uuid "${uuid}" has been mined` - ); - await updateState(uuid, nextState, store); -}; - -const depositEth = async ( - store, - uuid, - nextState, - signer, - nativeStakingStrategy, - pubkeys, - depositData -) => { - try { - log(`About to stake ETH with:`); - - // Check none of the validators are already registered - await depositFrontRunCheck(pubkeys, nativeStakingStrategy.provider); - - const validatorsStakeData = depositData.map((d) => ({ - pubkey: d.pubkey, - signature: d.signature, - depositDataRoot: d.depositDataRoot, - })); - log(`validators stake data: ${JSON.stringify(validatorsStakeData)}`); - const tx = await nativeStakingStrategy - .connect(signer) - .stakeEth(validatorsStakeData); - - log(`Transaction to stake ETH has been broadcast with hash: ${tx.hash}`); - - await updateState(uuid, nextState, store, { - depositTx: tx.hash, - }); - } catch (e) { - log(`Submitting transaction failed with: `, e); - //await clearState(uuid, store, `Transaction to deposit to validator fails`) - throw e; - } -}; - -const depositFrontRunCheck = async (pubkeys, provider) => { - const latestBlock = await provider.getBlockNumber(); - - // Create a contract instance - const depositContract = new ethers.Contract( - // Address - mainnet.beaconChainDepositContract, - // ABI - [ - "event DepositEvent(bytes pubkey, bytes withdrawal_credentials, bytes amount, bytes signature, bytes index)", - ], - provider - ); - - // Check the events from the last 1000 blocks - const recentBlocks = 1000; - const filter = { - address: depositContract.address, - topics: [ - "0x649bbc62d0e31342afea4e5cd82d4049e7e1ee912fc0889aa790803be39038c5", - ], - fromBlock: latestBlock - recentBlocks, - toBlock: "latest", - }; - const logs = await provider.getLogs(filter); - log(`Checking ${logs.length} logs for duplicate deposits of public keys:`); - log(pubkeys); - - for (const eventLog of logs) { - const parsedLog = depositContract.interface.parseLog(eventLog); - const eventPubkey = parsedLog.args.pubkey; - - if (pubkeys.includes(eventPubkey.toLowerCase())) { - throw Error(`Validator with pubkey ${eventPubkey} has already deposited`); - } - } -}; - -const broadcastRegisterValidator = async ( - store, - uuid, - nextState, - signer, - metadata, - nativeStakingStrategy, - ethAmount -) => { - const registerTransactionParams = defaultAbiCoder.decode( - [ - "bytes", - "uint64[]", - "bytes", - "uint256", - "tuple(uint32, uint64, uint64, bool, uint256)", - ], - hexDataSlice(metadata.registerValidatorData, 4) - ); - // the publicKey and sharesData params are not encoded correctly by P2P so we will ignore them - const [, operatorIds, , amount, cluster] = registerTransactionParams; - // get publicKey and sharesData state storage - const publicKeys = metadata.pubkeys; - if (!publicKeys) { - throw Error(`pubkeys not found in metadata: ${metadata}`); - } - const { sharesData } = metadata; - if (!sharesData) { - throw Error(`sharesData not found in metadata: ${metadata}`); - } - - ethAmount = ethAmount !== undefined ? ethAmount : amount; - - // Check the first validator has not already been registered - const hashedPubkey = keccak256(metadata.pubkeys[0]); - const status = await nativeStakingStrategy.validatorsStates(hashedPubkey); - if (validatorStateEnum[status] !== "NOT_REGISTERED") { - log( - `Validator with pubkey ${metadata.pubkeys[0]} is not in NOT_REGISTERED state. Current state: ${validatorStateEnum[status]}` - ); - throw Error( - `public key has already been registered for uuid ${uuid}: ${metadata.pubkeys[0]} ` - ); - } - - log(`About to register validator with:`); - log(`publicKeys: ${publicKeys}`); - log(`operatorIds: ${operatorIds}`); - log(`sharesData: ${sharesData}`); - log(`ethAmount: ${ethAmount}`); - log(`cluster: ${cluster}`); - - try { - const tx = await nativeStakingStrategy - .connect(signer) - .registerSsvValidators(publicKeys, operatorIds, sharesData, cluster, { - value: ethAmount, - }); - - await logTxDetails(tx, "registerSsvValidators"); - - log( - `Transaction to register SSV Validator has been broadcast with hash: ${tx.hash}` - ); - - await updateState(uuid, nextState, store, { - validatorRegistrationTx: tx.hash, - }); - } catch (e) { - log(`Submitting transaction failed with: `, e); - //await clearState(uuid, store, `Transaction to register SSV Validator fails`) - throw e; - } -}; - -const confirmValidatorRegistered = async ( - store, - uuid, - nextState, - p2p_api_key, - p2p_base_url, - awsS3AccessKeyId, - awsS3SexcretAccessKeyId, - s3BucketName -) => { - const doConfirmation = async () => { - if (!uuid) { - throw Error(`UUID is required to get validator status.`); - } - - const response = await p2pRequest( - `https://${p2p_base_url}/api/v1/eth/staking/ssv/request/status/${uuid}`, - p2p_api_key, - "GET" - ); - - const isReady = - response.result?.status === "ready" || - response.result?.status === "validator-ready"; - if (response.error != null) { - log( - `Error getting validator status with uuid ${uuid}: ${response.error}` - ); - log(response); - return false; - } else if (!isReady) { - log( - `Validators with request uuid ${uuid} are not ready yet. Status: ${response?.result?.status}` - ); - return false; - } else { - log(`Validators requested with uuid ${uuid} are ready`); - - const registerValidatorData = - response.result.validatorRegistrationTxs[0].data; - const sharesData = []; - const pubkeys = []; - const nonces = []; - const result = response.result; - for (let i = 0; i < result.encryptedShares.length; i++) { - const encryptedShare = result.encryptedShares[i]; - pubkeys[i] = encryptedShare.publicKey; - nonces[i] = encryptedShare.nonce; - sharesData[i] = encryptedShare.sharesData; - - await storePrivateKeyToS3({ - pubkey: pubkeys[i], - encryptedPrivateKey: encryptedShare.ecdhEncryptedPrivateKey, - awsS3AccessKeyId, - awsS3SexcretAccessKeyId, - s3BucketName, - }); - } - await updateState(uuid, nextState, store, { - pubkeys, - registerValidatorData, - sharesData, - }); - log(`Public keys: ${pubkeys}`); - log(`nonces: ${nonces}`); - log(`registerValidatorData: ${registerValidatorData}`); - return true; - } - }; - - await retry(doConfirmation, uuid, store); -}; - -const getDepositData = async ( - store, - uuid, - nextState, - p2p_api_key, - p2p_base_url -) => { - const doConfirmation = async () => { - if (!uuid) { - throw Error(`UUID is required to get deposit data.`); - } - const response = await p2pRequest( - `https://${p2p_base_url}/api/v1/eth/staking/ssv/request/deposit-data/${uuid}`, - p2p_api_key, - "GET" - ); - if (response.error != null) { - log(`Error getting deposit data with uuid ${uuid}: ${response.error}`); - // TODO: we shouldn't log full P2P responses. They break the logs - //log(response); - return false; - } else if (response.result?.status != "validator-ready") { - log( - `Deposit data with request uuid ${uuid} are not ready yet. Status: ${response.result?.status}` - ); - return false; - } else if (response.result?.status === "validator-ready") { - log(`Deposit data with request uuid ${uuid} is ready`); - - const depositData = response.result.depositData; - await updateState(uuid, nextState, store, { - depositData, - }); - log(`signature 0: ${depositData[0].signature}`); - log(`depositDataRoot 0: ${depositData[0].depositDataRoot}`); - return true; - } else { - log(`Error getting deposit data with uuid ${uuid}: ${response.error}`); - log(response); - throw Error(`Failed to get deposit data with uuid ${uuid}.`); - } - }; - - await retry(doConfirmation, uuid, store); -}; - -const retry = async (apiCall, uuid, store, attempts = 20) => { - let counter = 0; - while (true) { - if (await apiCall()) { - break; - } - counter++; - - if (counter > attempts) { - // Will not clear the state - throw new Error(`Failed P2P API call after ${attempts} attempts.`); - } - await sleep(3000); - } -}; - module.exports = { - registerValidators, - stakeValidators, validatorsThatCanBeStaked, }; From 45e5091c2333a844d0363a93a1f8980f546f61ea Mon Sep 17 00:00:00 2001 From: Nicholas Addison Date: Mon, 11 May 2026 16:25:54 +1000 Subject: [PATCH 3/4] Bump deploy number --- ...g_strategies.js => 195_claim_ssv_from_staking_strategies.js} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename contracts/deploy/mainnet/{194_claim_ssv_from_staking_strategies.js => 195_claim_ssv_from_staking_strategies.js} (97%) diff --git a/contracts/deploy/mainnet/194_claim_ssv_from_staking_strategies.js b/contracts/deploy/mainnet/195_claim_ssv_from_staking_strategies.js similarity index 97% rename from contracts/deploy/mainnet/194_claim_ssv_from_staking_strategies.js rename to contracts/deploy/mainnet/195_claim_ssv_from_staking_strategies.js index c769f13650..b5fab4e3d6 100644 --- a/contracts/deploy/mainnet/194_claim_ssv_from_staking_strategies.js +++ b/contracts/deploy/mainnet/195_claim_ssv_from_staking_strategies.js @@ -18,7 +18,7 @@ const strategyConfigs = [ module.exports = deploymentWithGovernanceProposal( { - deployName: "194_claim_ssv_from_staking_strategies", + deployName: "195_claim_ssv_from_staking_strategies", forceDeploy: false, reduceQueueTime: true, deployerIsProposer: false, From 974f8b686e2c4e701c37438381ddd9e9f1a87e1b Mon Sep 17 00:00:00 2001 From: Nicholas Addison Date: Mon, 11 May 2026 16:27:37 +1000 Subject: [PATCH 4/4] Added .migration record --- contracts/deployments/mainnet/.migrations.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/contracts/deployments/mainnet/.migrations.json b/contracts/deployments/mainnet/.migrations.json index 9ddb7e54a6..4f4c45c84c 100644 --- a/contracts/deployments/mainnet/.migrations.json +++ b/contracts/deployments/mainnet/.migrations.json @@ -79,5 +79,6 @@ "189_harvesting_eip1271_ogn": 1774552993, "190_remove_3rd_native_staking_strategy": 1775468903, "192_migrate_ssv_clusters_to_eth": 1777859891, - "193_permissioned_rebase_module": 1778095857 -} \ No newline at end of file + "193_permissioned_rebase_module": 1778095857, + "195_claim_ssv_from_staking_strategies": 1778480807 +}