diff --git a/.gitignore b/.gitignore index e2ac4a3fba..7d28879380 100644 --- a/.gitignore +++ b/.gitignore @@ -60,8 +60,8 @@ contracts/coverage.json contracts/build/ contracts/dist/ contracts/.localKeyValueStorage -contracts/.localKeyValueStorageMainnet -contracts/.localKeyValueStorageHolesky +contracts/.localKeyValueStorage.mainnet +contracts/.localKeyValueStorage.holesky contracts/scripts/defender-actions/dist/ todo.txt diff --git a/contracts/contracts/strategies/NativeStaking/ValidatorRegistrator.sol b/contracts/contracts/strategies/NativeStaking/ValidatorRegistrator.sol index 9b6455e012..2dc590f69f 100644 --- a/contracts/contracts/strategies/NativeStaking/ValidatorRegistrator.sol +++ b/contracts/contracts/strategies/NativeStaking/ValidatorRegistrator.sol @@ -226,12 +226,17 @@ abstract contract ValidatorRegistrator is Governable, Pausable { /// @notice Registers a new validator in the SSV Cluster. /// Only the registrator can call this function. + /// @param publicKey The public key of the validator + /// @param operatorIds The operator IDs of the SSV Cluster + /// @param sharesData The validator shares data + /// @param ssvAmount The amount of SSV tokens to be deposited to the SSV cluster + /// @param cluster The SSV cluster details including the validator count and SSV balance // slither-disable-start reentrancy-no-eth function registerSsvValidator( bytes calldata publicKey, uint64[] calldata operatorIds, bytes calldata sharesData, - uint256 amount, + uint256 ssvAmount, Cluster calldata cluster ) external onlyRegistrator whenNotPaused { bytes32 pubKeyHash = keccak256(publicKey); @@ -243,7 +248,7 @@ abstract contract ValidatorRegistrator is Governable, Pausable { publicKey, operatorIds, sharesData, - amount, + ssvAmount, cluster ); emit SSVValidatorRegistered(pubKeyHash, publicKey, operatorIds); @@ -256,6 +261,8 @@ abstract contract ValidatorRegistrator is Governable, Pausable { /// @notice Exit a validator from the Beacon chain. /// The staked ETH will eventually swept to this native staking strategy. /// Only the registrator can call this function. + /// @param publicKey The public key of the validator + /// @param operatorIds The operator IDs of the SSV Cluster // slither-disable-start reentrancy-no-eth function exitSsvValidator( bytes calldata publicKey, @@ -277,6 +284,9 @@ abstract contract ValidatorRegistrator is Governable, Pausable { /// Make sure `exitSsvValidator` is called before and the validate has exited the Beacon chain. /// If removed before the validator has exited the beacon chain will result in the validator being slashed. /// Only the registrator can call this function. + /// @param publicKey The public key of the validator + /// @param operatorIds The operator IDs of the SSV Cluster + /// @param cluster The SSV cluster details including the validator count and SSV balance // slither-disable-start reentrancy-no-eth function removeSsvValidator( bytes calldata publicKey, @@ -306,16 +316,18 @@ abstract contract ValidatorRegistrator is Governable, Pausable { /// @dev A SSV cluster is defined by the SSVOwnerAddress and the set of operatorIds. /// uses "onlyStrategist" modifier so continuous front-running can't DOS our maintenance service /// that tries to top up SSV tokens. - /// @param cluster The SSV cluster details that must be derived from emitted events from the SSVNetwork contract. + /// @param operatorIds The operator IDs of the SSV Cluster + /// @param ssvAmount The amount of SSV tokens to be deposited to the SSV cluster + /// @param cluster The SSV cluster details including the validator count and SSV balance function depositSSV( uint64[] memory operatorIds, - uint256 amount, + uint256 ssvAmount, Cluster memory cluster ) external onlyStrategist { ISSVNetwork(SSV_NETWORK_ADDRESS).deposit( address(this), operatorIds, - amount, + ssvAmount, cluster ); } diff --git a/contracts/docs/NativeStakingSSVStrategySquashed.svg b/contracts/docs/NativeStakingSSVStrategySquashed.svg index a210ef9d8b..2c31395017 100644 --- a/contracts/docs/NativeStakingSSVStrategySquashed.svg +++ b/contracts/docs/NativeStakingSSVStrategySquashed.svg @@ -91,10 +91,10 @@    setStakeETHThreshold(_amount: uint256) <<onlyGovernor>> <<ValidatorRegistrator>>    resetStakeETHTally() <<onlyStakingMonitor>> <<ValidatorRegistrator>>    stakeEth(validators: ValidatorStakeData[]) <<onlyRegistrator, whenNotPaused>> <<ValidatorRegistrator>> -    registerSsvValidator(publicKey: bytes, operatorIds: uint64[], sharesData: bytes, amount: uint256, cluster: Cluster) <<onlyRegistrator, whenNotPaused>> <<ValidatorRegistrator>> +    registerSsvValidator(publicKey: bytes, operatorIds: uint64[], sharesData: bytes, ssvAmount: uint256, cluster: Cluster) <<onlyRegistrator, whenNotPaused>> <<ValidatorRegistrator>>    exitSsvValidator(publicKey: bytes, operatorIds: uint64[]) <<onlyRegistrator, whenNotPaused>> <<ValidatorRegistrator>>    removeSsvValidator(publicKey: bytes, operatorIds: uint64[], cluster: Cluster) <<onlyRegistrator, whenNotPaused>> <<ValidatorRegistrator>> -    depositSSV(operatorIds: uint64[], amount: uint256, cluster: Cluster) <<onlyStrategist>> <<ValidatorRegistrator>> +    depositSSV(operatorIds: uint64[], ssvAmount: uint256, cluster: Cluster) <<onlyStrategist>> <<ValidatorRegistrator>>    setFuseInterval(_fuseIntervalStart: uint256, _fuseIntervalEnd: uint256) <<onlyGovernor>> <<ValidatorAccountant>>    doAccounting(): (accountingValid: bool) <<onlyRegistrator, whenNotPaused>> <<ValidatorAccountant>>    manuallyFixAccounting(_validatorsDelta: int256, _consensusRewardsDelta: int256, _ethToVaultAmount: uint256) <<onlyStrategist, whenPaused>> <<ValidatorAccountant>> diff --git a/contracts/docs/plantuml/oethProcesses-register.png b/contracts/docs/plantuml/oethProcesses-register.png index a1f1021dba..03ac1f8f61 100644 Binary files a/contracts/docs/plantuml/oethProcesses-register.png and b/contracts/docs/plantuml/oethProcesses-register.png differ diff --git a/contracts/docs/plantuml/oethProcesses.png b/contracts/docs/plantuml/oethProcesses.png index 18f51ef248..4bb4928a15 100644 Binary files a/contracts/docs/plantuml/oethProcesses.png and b/contracts/docs/plantuml/oethProcesses.png differ diff --git a/contracts/docs/plantuml/oethProcesses.puml b/contracts/docs/plantuml/oethProcesses.puml index 6ac90f4cda..ab3747c685 100644 --- a/contracts/docs/plantuml/oethProcesses.puml +++ b/contracts/docs/plantuml/oethProcesses.puml @@ -83,10 +83,10 @@ api -> api: split(key) note right : splits validator key into multiple KeyShares return -reg -> api: status(uuid) +reg -> api: GET\neth/staking/ssv/request/status/uuid activate api -return status,\nvalidatorRegistration,\nshareData -note right : validatorRegistration contains the pubkey, operatorIds and cluster details +return status,\npubkey\nvalidatorRegistration,\nshareData +note right : validatorRegistration contains the operatorIds and cluster details reg -> nativeStrat : registerSsvValidator(\npublicKey,\noperatorIds,\nsharesData,\namount,\ncluster) activate nativeStrat @@ -107,6 +107,17 @@ return return return +end group + +... 60 minutes ... + +group Registrator stakes to a new SSV validator + +reg -> api: GET\neth/staking/ssv/request/deposit-data/uuid +activate api +return status,\ndepositData +note right : depositData contains the signature and depositDataRoot + reg -> nativeStrat : stakeEth([\npubkey,\nsignature,\ndepositDataRoot]) activate nativeStrat nativeStrat -> nativeStrat diff --git a/contracts/scripts/defender-actions/operateValidators.js b/contracts/scripts/defender-actions/registerValidators.js similarity index 96% rename from contracts/scripts/defender-actions/operateValidators.js rename to contracts/scripts/defender-actions/registerValidators.js index d6c1c5fe07..f635949041 100644 --- a/contracts/scripts/defender-actions/operateValidators.js +++ b/contracts/scripts/defender-actions/registerValidators.js @@ -6,7 +6,7 @@ const { const { KeyValueStoreClient, } = require("@openzeppelin/defender-kvstore-client"); -const { operateValidators } = require("../../tasks/validator"); +const { registerValidators } = require("../../tasks/validator"); const addresses = require("../../utils/addresses"); const nativeStakingStrategyAbi = require("../../abi/native_staking_SSV_strategy.json"); @@ -83,7 +83,7 @@ const handler = async (event) => { clear: true, }; - await operateValidators({ + await registerValidators({ signer, contracts, store, diff --git a/contracts/scripts/defender-actions/rollup.config.cjs b/contracts/scripts/defender-actions/rollup.config.cjs index 939c02e2e1..06704a3159 100644 --- a/contracts/scripts/defender-actions/rollup.config.cjs +++ b/contracts/scripts/defender-actions/rollup.config.cjs @@ -4,7 +4,11 @@ const json = require("@rollup/plugin-json"); const builtins = require("builtin-modules"); const commonConfig = { - plugins: [resolve({ preferBuiltins: true, exportConditions: ["node"] }), commonjs(), json({ compact: true })], + plugins: [ + resolve({ preferBuiltins: true, exportConditions: ["node"] }), + commonjs(), + json({ compact: true }), + ], // Do not bundle these packages. // ethers is required to be bundled even though its an Autotask package. external: [ @@ -25,10 +29,18 @@ const commonConfig = { module.exports = [ { ...commonConfig, - input: "operateValidators.js", + input: "registerValidators.js", + output: { + file: "dist/registerValidators/index.js", + format: "cjs", + }, + }, + { + ...commonConfig, + input: "stakeValidators.js", output: { - file: "dist/operateValidators/index.js", + file: "dist/stakeValidators/index.js", format: "cjs", }, - } + }, ]; diff --git a/contracts/tasks/tasks.js b/contracts/tasks/tasks.js index 60f6e36621..5529744b5a 100644 --- a/contracts/tasks/tasks.js +++ b/contracts/tasks/tasks.js @@ -6,14 +6,8 @@ const { setActionVars } = require("./defender"); const { execute, executeOnFork, proposal, governors } = require("./governance"); const { smokeTest, smokeTestCheck } = require("./smokeTest"); const addresses = require("../utils/addresses"); -const { getDefenderSigner } = require("../utils/signers"); const { networkMap } = require("../utils/hardhat-helpers"); -const { resolveContract } = require("../utils/resolvers"); -const { - KeyValueStoreClient, -} = require("@openzeppelin/defender-kvstore-client"); -const { operateValidators } = require("./validator"); -const { formatUnits } = require("ethers/lib/utils"); +const { getSigner } = require("../utils/signers"); const { storeStorageLayoutForAllContracts, @@ -36,6 +30,7 @@ const { tokenTransfer, tokenTransferFrom, } = require("./tokens"); +const { depositWETH, withdrawWETH } = require("./weth"); const { allocate, capital, @@ -74,6 +69,13 @@ const { setRewardTokenAddresses, checkBalance, } = require("./strategy"); +const { + validatorOperationsConfig, + registerValidators, + stakeValidators, + resetStakeETHTally, + setStakeETHThreshold, +} = require("./validator"); // can not import from utils/deploy since that imports hardhat globally const withConfirmation = async (deployOrTransactionPromise) => { @@ -215,6 +217,37 @@ task("transferFrom").setAction(async (_, __, runSuper) => { return runSuper(); }); +// WETH tasks +subtask("depositWETH", "Deposit ETH into WETH") + .addParam("amount", "Amount of ETH to deposit", undefined, types.float) + .setAction(async (taskArgs) => { + const signer = await getSigner(); + + const { chainId } = await ethers.provider.getNetwork(); + const wethAddress = addresses[networkMap[chainId]].WETH; + const weth = await ethers.getContractAt("IWETH9", wethAddress); + + await depositWETH({ ...taskArgs, weth, signer }); + }); +task("depositWETH").setAction(async (_, __, runSuper) => { + return runSuper(); +}); + +subtask("withdrawWETH", "Withdraw ETH from WETH") + .addParam("amount", "Amount of ETH to withdraw", undefined, types.float) + .setAction(async (taskArgs) => { + const signer = await getSigner(); + + const { chainId } = await ethers.provider.getNetwork(); + const wethAddress = addresses[networkMap[chainId]].WETH; + const weth = await ethers.getContractAt("IWETH9", wethAddress); + + await withdrawWETH({ ...taskArgs, weth, signer }); + }); +task("withdrawWETH").setAction(async (_, __, runSuper) => { + return runSuper(); +}); + // Vault tasks. task("allocate", "Call allocate() on the Vault") .addOptionalParam( @@ -280,6 +313,12 @@ subtask("mint", "Mint OTokens from the Vault using collateral assets") types.string ) .addOptionalParam("min", "Minimum amount of OTokens to mint", 0, types.float) + .addOptionalParam( + "approve", + "Approve the asset to the OETH Vault before the mint", + true, + types.boolean + ) .setAction(mint); task("mint").setAction(async (_, __, runSuper) => { return runSuper(); @@ -869,16 +908,17 @@ subtask("getClusterInfo", "Print out information regarding SSV cluster") types.string ) .setAction(async (taskArgs) => { - const network = await ethers.provider.getNetwork(); - const ssvNetwork = addresses[networkMap[network.chainId]].SSVNetwork; + const { chainId } = await ethers.provider.getNetwork(); + const network = networkMap[chainId]; + const ssvNetwork = addresses[network].SSVNetwork; log( - `Fetching cluster info for cluster owner ${taskArgs.owner} with operator ids: ${taskArgs.operatorids} from the ${network.name} network using ssvNetworkContract ${ssvNetwork}` + `Fetching cluster info for cluster owner ${taskArgs.owner} with operator ids: ${taskArgs.operatorids} from the ${network} network using ssvNetworkContract ${ssvNetwork}` ); await printClusterInfo({ ...taskArgs, ownerAddress: taskArgs.owner, - chainId: network.chainId, + chainId: chainId, ssvNetwork, }); }); @@ -911,15 +951,13 @@ subtask( "deployNativeStakingProxy", "Deploy the native staking proxy via the Defender Relayer" ).setAction(async () => { - const defenderSigner = await getDefenderSigner(); + const signer = await getSigner(); log("Deploy NativeStakingSSVStrategyProxy"); const nativeStakingProxyFactory = await ethers.getContractFactory( "NativeStakingSSVStrategyProxy" ); - const contract = await nativeStakingProxyFactory - .connect(defenderSigner) - .deploy(); + const contract = await nativeStakingProxyFactory.connect(signer).deploy(); await contract.deployed(); log(`Address of deployed contract is: ${contract.address}`); }); @@ -937,16 +975,16 @@ subtask( ) .addParam("address", "Address of the new governor", undefined, types.string) .setAction(async (taskArgs) => { - const defenderSigner = await getDefenderSigner(); + const signer = await getSigner(); - log("Tranfer governance of NativeStakingSSVStrategyProxy"); + log("Transfer governance of NativeStakingSSVStrategyProxy"); const nativeStakingProxyFactory = await ethers.getContract( "NativeStakingSSVStrategyProxy" ); await withConfirmation( nativeStakingProxyFactory - .connect(defenderSigner) + .connect(signer) .transferGovernance(taskArgs.address) ); log( @@ -959,97 +997,83 @@ task("transferGovernanceNativeStakingProxy").setAction( } ); -// Defender +// Validator Operations + subtask( - "operateValidators", + "registerValidators", "Creates the required amount of new SSV validators and stakes ETH" ) - .addOptionalParam("index", "Index of Native Staking contract", 1, types.int) - .addOptionalParam( - "stake", - "Stake 32 ether after registering a new SSV validator", - true, - types.boolean - ) .addOptionalParam( "days", "SSV Cluster operational time in days", 40, types.int ) - .addOptionalParam("clear", "Clear storage", true, types.boolean) + .addOptionalParam("clear", "Clear storage", false, types.boolean) .setAction(async (taskArgs) => { - const network = await ethers.provider.getNetwork(); - const isMainnet = network.chainId === 1; - const isHolesky = network.chainId === 17000; - const addressesSet = isMainnet ? addresses.mainnet : addresses.holesky; - - if (!isMainnet && !isHolesky) { - throw new Error( - "operate validators is supported on Mainnet and Holesky only" - ); - } - - const storeFilePath = require("path").join( - __dirname, - "..", - `.localKeyValueStorage${isMainnet ? "Mainnet" : "Holesky"}` - ); + const config = await validatorOperationsConfig(taskArgs); + await registerValidators(config); + }); +task("registerValidators").setAction(async (_, __, runSuper) => { + return runSuper(); +}); - const store = new KeyValueStoreClient({ path: storeFilePath }); - const signer = await getDefenderSigner(); +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 + ) + .setAction(async (taskArgs) => { + const config = await validatorOperationsConfig(taskArgs); + await stakeValidators(config); + }); +task("stakeValidators").setAction(async (_, __, runSuper) => { + return runSuper(); +}); - const WETH = await ethers.getContractAt("IWETH9", addressesSet.WETH); - const SSV = await ethers.getContractAt("IERC20", addressesSet.SSV); +subtask( + "resetStakeETHTally", + "Resets the amount of Ether staked back to zero" +).setAction(async () => { + const signer = await getSigner(); - // TODO: use index to target different native staking strategies when we have more than 1 - const nativeStakingStrategy = await resolveContract( - "NativeStakingSSVStrategyProxy", - "NativeStakingSSVStrategy" - ); + const nativeStakingProxyFactory = await ethers.getContract( + "NativeStakingSSVStrategyProxy" + ); - log( - "Balance of SSV tokens on the native staking contract: ", - formatUnits(await SSV.balanceOf(nativeStakingStrategy.address)) + await resetStakeETHTally({ + signer, + nativeStakingProxyFactory, + }); +}); +task("resetStakeETHTally").setAction(async (_, __, runSuper) => { + return runSuper(); +}); + +subtask( + "setStakeETHThreshold", + "Sets the amount of Ether than can be staked before needing a reset" +) + .addParam("amount", "Amount in ether", undefined, types.int) + .setAction(async (taskArgs) => { + const signer = await getSigner(); + + const nativeStakingProxyFactory = await ethers.getContract( + "NativeStakingSSVStrategyProxy" ); - const contracts = { - nativeStakingStrategy, - WETH, - }; - const feeAccumulatorAddress = - await nativeStakingStrategy.FEE_ACCUMULATOR_ADDRESS(); - - const p2p_api_key = isMainnet - ? process.env.P2P_MAINNET_API_KEY - : process.env.P2P_HOLESKY_API_KEY; - if (!p2p_api_key) { - throw new Error( - "P2P API key environment variable is not set. P2P_MAINNET_API_KEY or P2P_HOLESKY_API_KEY" - ); - } - const p2p_base_url = isMainnet ? "api.p2p.org" : "api-test-holesky.p2p.org"; - - const config = { - feeAccumulatorAddress, - p2p_api_key, - p2p_base_url, - // 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, - stake: taskArgs.stake, - clear: taskArgs.clear, - }; - - await operateValidators({ + await setStakeETHThreshold({ + ...taskArgs, signer, - contracts, - store, - config, + nativeStakingProxyFactory, }); }); -task("operateValidators").setAction(async (_, __, runSuper) => { +task("setStakeETHThreshold").setAction(async (_, __, runSuper) => { return runSuper(); }); diff --git a/contracts/tasks/validator.js b/contracts/tasks/validator.js index ee033a6162..a194040b4f 100644 --- a/contracts/tasks/validator.js +++ b/contracts/tasks/validator.js @@ -1,14 +1,86 @@ const fetch = require("node-fetch"); -const { defaultAbiCoder, formatUnits, hexDataSlice, parseEther } = +const { defaultAbiCoder, formatUnits, hexDataSlice, parseEther, keccak256 } = require("ethers").utils; const { v4: uuidv4 } = require("uuid"); - -//const { resolveContract } = require("../utils/resolvers"); +const { + KeyValueStoreClient, +} = require("@openzeppelin/defender-kvstore-client"); + +const { getClusterInfo } = require("./ssv"); +const addresses = require("../utils/addresses"); +const { resolveContract } = require("../utils/resolvers"); +const { getSigner } = require("../utils/signers"); const { sleep } = require("../utils/time"); -//const { getClusterInfo } = require("./ssv"); +const { logTxDetails } = require("../utils/txLogger"); +const { networkMap } = require("../utils/hardhat-helpers"); const log = require("../utils/logger")("task:p2p"); +const validatorStateEnum = { + 0: "NOT_REGISTERED", + 1: "REGISTERED", + 2: "STAKED", + 3: "EXITED", + 4: "EXIT_COMPLETE", +}; + +const validatorOperationsConfig = async (taskArgs) => { + const { chainId } = await ethers.provider.getNetwork(); + const network = networkMap[chainId]; + + if (!network) { + throw new Error( + `registerValidators does not support chain with id ${chainId}` + ); + } + const addressesSet = addresses[network]; + const isMainnet = network === "mainnet"; + + const signer = await getSigner(); + + const storeFilePath = require("path").join( + __dirname, + "..", + `.localKeyValueStorage.${network}` + ); + + const WETH = await ethers.getContractAt("IWETH9", addressesSet.WETH); + + const nativeStakingStrategy = await resolveContract( + "NativeStakingSSVStrategyProxy", + "NativeStakingSSVStrategy" + ); + const feeAccumulatorAddress = + await nativeStakingStrategy.FEE_ACCUMULATOR_ADDRESS(); + + const p2p_api_key = isMainnet + ? process.env.P2P_MAINNET_API_KEY + : process.env.P2P_HOLESKY_API_KEY; + if (!p2p_api_key) { + throw new Error( + "P2P API key environment variable is not set. P2P_MAINNET_API_KEY or P2P_HOLESKY_API_KEY" + ); + } + const p2p_base_url = isMainnet ? "api.p2p.org" : "api-test-holesky.p2p.org"; + + return { + store: new KeyValueStoreClient({ path: storeFilePath }), + signer, + 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, + stake: taskArgs.stake, + clear: taskArgs.clear, + uuid: taskArgs.uuid, + }; +}; + /* When same UUID experiences and error threshold amount of times it is * discarded. */ @@ -32,16 +104,17 @@ const ERROR_THRESHOLD = 5; * - TODO: (implement this) if fuse of the native staking strategy is blown * stop with all the operations */ -const operateValidators = async ({ store, signer, contracts, config }) => { - const { - feeAccumulatorAddress, - p2p_api_key, - p2p_base_url, - validatorSpawnOperationalPeriodInDays, - stake, - clear, - } = config; - +const registerValidators = async ({ + store, + signer, + p2p_api_key, + p2p_base_url, + nativeStakingStrategy, + feeAccumulatorAddress, + WETH, + validatorSpawnOperationalPeriodInDays, + clear, +}) => { let currentState = await getState(store); log("currentState", currentState); @@ -50,13 +123,15 @@ const operateValidators = async ({ store, signer, contracts, config }) => { currentState = undefined; } - if (!(await stakingContractHas32ETH(contracts))) { - log(`Native staking contract doesn't have enough ETH, exiting`); + if (!(await stakingContractHas32ETH(nativeStakingStrategy, WETH))) { + console.log( + `Native staking contract doesn't have enough WETH available to stake. Does depositToStrategy or resetStakeETHTally need to be called?` + ); return; } - if (await stakingContractPaused(contracts)) { - log(`Native staking contract is paused... exiting`); + if (await stakingContractPaused(nativeStakingStrategy)) { + console.log(`Native staking contract is paused... exiting`); return; } @@ -64,33 +139,36 @@ const operateValidators = async ({ store, signer, contracts, config }) => { while (true) { if (!currentState) { await createValidatorRequest( - p2p_api_key, // api key + store, + "validator_creation_issued", // next state + p2p_api_key, p2p_base_url, - contracts.nativeStakingStrategy.address, // SSV owner address & withdrawal address + nativeStakingStrategy.address, // SSV owner address & withdrawal address feeAccumulatorAddress, // execution layer fee recipient - validatorSpawnOperationalPeriodInDays, - store + validatorSpawnOperationalPeriodInDays ); currentState = await getState(store); } if (currentState.state === "validator_creation_issued") { - await confirmValidatorCreatedRequest( - p2p_api_key, - p2p_base_url, + await confirmValidatorRegistered( + store, currentState.uuid, - store + "validator_creation_confirmed", // next state + p2p_api_key, + p2p_base_url ); currentState = await getState(store); } if (currentState.state === "validator_creation_confirmed") { await broadcastRegisterValidator( - signer, store, currentState.uuid, + "register_transaction_broadcast", // next state + signer, currentState.metadata, - contracts.nativeStakingStrategy + nativeStakingStrategy ); currentState = await getState(store); } @@ -99,22 +177,110 @@ const operateValidators = async ({ store, signer, contracts, config }) => { await waitForTransactionAndUpdateStateOnSuccess( store, currentState.uuid, - contracts.nativeStakingStrategy.provider, + "validator_registered", // next state + nativeStakingStrategy.provider, currentState.metadata.validatorRegistrationTx, - "registerSsvValidator", // name of transaction we are waiting for - "validator_registered" // new state when transaction confirmed + "registerSsvValidator" // name of transaction we are waiting for ); currentState = await getState(store); + break; } - if (!stake) 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 stakeValidators = async ({ + store, + signer, + nativeStakingStrategy, + WETH, + p2p_api_key, + p2p_base_url, + uuid, +}) => { + let currentState; + if (!uuid) { + let currentState = await getState(store); + log("currentState", currentState); + + if (!currentState) { + console.log(`Failed to get state from local storage`); + return; + } + } + + if (!(await stakingContractHas32ETH(nativeStakingStrategy, WETH))) { + console.log(`Native staking contract doesn't have enough ETH, exiting`); + return; + } + + if (await stakingContractPaused(nativeStakingStrategy)) { + console.log(`Native staking contract is paused... exiting`); + return; + } + + const executeOperateLoop = async () => { + while (true) { + if (!currentState) { + await confirmValidatorRegistered( + store, + uuid, + "validator_registered", // next state + p2p_api_key, + p2p_base_url + ); + currentState = await getState(store); + + // Check the validator has not already been staked + const hashedPubkey = keccak256(currentState.metadata.pubkey); + const status = await nativeStakingStrategy.validatorsStates( + hashedPubkey + ); + if (validatorStateEnum[status] !== "REGISTERED") { + console.log( + `Validator with pubkey ${currentState.metadata.pubkey} not in REGISTERED state. Current state: ${validatorStateEnum[status]}` + ); + await clearState(currentState.uuid, store); + break; + } + } 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") { await depositEth( - signer, store, currentState.uuid, - contracts.nativeStakingStrategy, + "deposit_transaction_broadcast", // next state + signer, + nativeStakingStrategy, + currentState.metadata.pubkey, currentState.metadata.depositData ); currentState = await getState(store); @@ -124,10 +290,10 @@ const operateValidators = async ({ store, signer, contracts, config }) => { await waitForTransactionAndUpdateStateOnSuccess( store, currentState.uuid, - contracts.nativeStakingStrategy.provider, + "deposit_confirmed", // next state + nativeStakingStrategy.provider, currentState.metadata.depositTx, - "stakeEth", // name of transaction we are waiting for - "deposit_confirmed" // new state when transaction confirmed + "stakeEth" // name of transaction we are waiting for ); currentState = await getState(store); @@ -137,6 +303,7 @@ const operateValidators = async ({ store, signer, contracts, config }) => { await clearState(currentState.uuid, store); break; } + await sleep(1000); } }; @@ -206,6 +373,7 @@ const updateState = async (requestUUID, state, store, metadata = {}) => { "validator_creation_confirmed", "register_transaction_broadcast", "validator_registered", + "deposit_data_got", "deposit_transaction_broadcast", "deposit_confirmed", ].includes(state) @@ -254,46 +422,46 @@ const getState = async (store) => { return JSON.parse(await store.get("currentRequest")); }; -const stakingContractPaused = async (contracts) => { - const paused = await contracts.nativeStakingStrategy.paused(); +const stakingContractPaused = async (nativeStakingStrategy) => { + const paused = await nativeStakingStrategy.paused(); log(`Native staking contract is ${paused ? "" : "not "}paused`); return paused; }; -const stakingContractHas32ETH = async (contracts) => { - const address = contracts.nativeStakingStrategy.address; - const wethBalance = await contracts.WETH.balanceOf(address); +const stakingContractHas32ETH = async (nativeStakingStrategy, WETH) => { + const address = nativeStakingStrategy.address; + const wethBalance = await WETH.balanceOf(address); log( `Native Staking Strategy has ${formatUnits(wethBalance, 18)} WETH in total` ); - const stakeETHThreshold = contracts.nativeStakingStrategy.stakeETHThreshold(); - const stakeETHTally = contracts.nativeStakingStrategy.stakeETHTally(); - const remainingETH = stakeETHThreshold.sub(stakeETHTally); + const stakeETHThreshold = await nativeStakingStrategy.stakeETHThreshold(); + const stakeETHTally = await nativeStakingStrategy.stakeETHTally(); + const remainingWETH = stakeETHThreshold.sub(stakeETHTally); log( `Native Staking Strategy has staked ${formatUnits( stakeETHTally )} of ${formatUnits(stakeETHThreshold)} ETH with ${formatUnits( - remainingETH - )} ETH remaining` + remainingWETH + )} WETH remaining` ); // Take the minimum of the remainingETH and the WETH balance - const availableETH = wethBalance.gt(remainingETH) - ? remainingETH + const availableETH = wethBalance.gt(remainingWETH) + ? remainingWETH : wethBalance; log( `Native Staking Strategy has ${formatUnits( availableETH - )} ETH available to stake` + )} WETH available to stake` ); return availableETH.gte(parseEther("32")); }; -/* Make a GET or POST request to P2P service - * @param api_key: p2p service api key +/* 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 */ @@ -309,7 +477,7 @@ const p2pRequest = async (url, api_key, method, body) => { const bodyString = JSON.stringify(body); log( - `Creating a P2P ${method} request with ${url} `, + `About to call P2P API: ${method} ${url} `, body != undefined ? ` and body: ${bodyString}` : "" ); @@ -321,24 +489,26 @@ const p2pRequest = async (url, api_key, method, body) => { const response = await rawResponse.json(); if (response.error != null) { - log("Request to P2P service failed with an error:", response); + log("Call to P2P API failed with response:", response); throw new Error( - `Call to P2P has failed: ${JSON.stringify(response.error)}` + `Failed to call to P2P API. Error: ${JSON.stringify(response.error)}` ); } else { - log("Request to P2P service succeeded: ", response); + log(`${method} request to P2P API succeeded:`); + log(response); } return response; }; const createValidatorRequest = async ( + store, + nextState, p2p_api_key, p2p_base_url, nativeStakingStrategy, feeAccumulatorAddress, - validatorSpawnOperationalPeriodInDays, - store + validatorSpawnOperationalPeriodInDays ) => { const uuid = uuidv4(); await p2pRequest( @@ -357,19 +527,19 @@ const createValidatorRequest = async ( } ); - await updateState(uuid, "validator_creation_issued", store); + await updateState(uuid, nextState, store); }; const waitForTransactionAndUpdateStateOnSuccess = async ( store, uuid, + nextState, provider, txHash, - methodName, - newState + methodName ) => { log( - `Waiting for transaction with hash "${txHash}" method "${methodName}" and uuid "${uuid}" to be mined...` + `Waiting for transaction with hash "${txHash}", method "${methodName}" and uuid "${uuid}" to be mined...` ); const tx = await provider.waitForTransaction(txHash); if (!tx) { @@ -377,17 +547,22 @@ const waitForTransactionAndUpdateStateOnSuccess = async ( `Transaction with hash "${txHash}" not found for method "${methodName}" and uuid "${uuid}"` ); } - await updateState(uuid, newState, store); + log( + `Transaction with hash "${txHash}", method "${methodName}" and uuid "${uuid}" has been mined` + ); + await updateState(uuid, nextState, store); }; const depositEth = async ( - signer, store, uuid, + nextState, + signer, nativeStakingStrategy, + pubkey, depositData ) => { - const { pubkey, signature, depositDataRoot } = depositData; + const { signature, depositDataRoot } = depositData; try { log(`About to stake ETH with:`); log(`pubkey: ${pubkey}`); @@ -403,7 +578,7 @@ const depositEth = async ( log(`Transaction to stake ETH has been broadcast with hash: ${tx.hash}`); - await updateState(uuid, "deposit_transaction_broadcast", store, { + await updateState(uuid, nextState, store, { depositTx: tx.hash, }); } catch (e) { @@ -414,9 +589,10 @@ const depositEth = async ( }; const broadcastRegisterValidator = async ( - signer, store, uuid, + nextState, + signer, metadata, nativeStakingStrategy ) => { @@ -433,11 +609,9 @@ const broadcastRegisterValidator = async ( // 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 publicKey = metadata.depositData.pubkey; + const publicKey = metadata.pubkey; if (!publicKey) { - throw Error( - `pubkey not found in metadata.depositData: ${metadata?.depositData}` - ); + throw Error(`pubkey not found in metadata: ${metadata}`); } const { sharesData } = metadata; if (!sharesData) { @@ -466,7 +640,7 @@ const broadcastRegisterValidator = async ( `Transaction to register SSV Validator has been broadcast with hash: ${tx.hash}` ); - await updateState(uuid, "register_transaction_broadcast", store, { + await updateState(uuid, nextState, store, { validatorRegistrationTx: tx.hash, }); } catch (e) { @@ -476,11 +650,12 @@ const broadcastRegisterValidator = async ( } }; -const confirmValidatorCreatedRequest = async ( - p2p_api_key, - p2p_base_url, +const confirmValidatorRegistered = async ( + store, uuid, - store + nextState, + p2p_api_key, + p2p_base_url ) => { const doConfirmation = async () => { const response = await p2pRequest( @@ -488,44 +663,93 @@ const confirmValidatorCreatedRequest = async ( p2p_api_key, "GET" ); + const isReady = + response.result?.status === "ready" || + response.result?.status === "validator-ready"; if (response.error != null) { - log(`Error processing request uuid: ${uuid} error: ${response}`); - } else if (response.result.status === "ready") { + 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 pubkey = response.result.encryptedShares[0].publicKey; const registerValidatorData = response.result.validatorRegistrationTxs[0].data; - const depositData = response.result.depositData[0]; const sharesData = response.result.encryptedShares[0].sharesData; - await updateState(uuid, "validator_creation_confirmed", store, { + await updateState(uuid, nextState, store, { + pubkey, registerValidatorData, - depositData, sharesData, }); - log(`Validator created using uuid: ${uuid} is ready`); - log(`Primary key: ${depositData.pubkey}`); - log(`signature: ${depositData.signature}`); - log(`depositDataRoot: ${depositData.depositDataRoot}`); + log(`Public key: ${pubkey}`); log(`sharesData: ${sharesData}`); return true; - } else { + } + }; + + await retry(doConfirmation, uuid, store); +}; + +const getDepositData = async ( + store, + uuid, + nextState, + p2p_api_key, + p2p_base_url +) => { + const doConfirmation = async () => { + 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}`); + log(response); + return false; + } else if (response.result?.status != "validator-ready") { log( - `Validator created using uuid: ${uuid} not yet ready. State: ${response.result.status}` + `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[0]; + await updateState(uuid, nextState, store, { + depositData, + }); + log(`signature: ${depositData.signature}`); + log(`depositDataRoot: ${depositData.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; - const attempts = 20; while (true) { - if (await doConfirmation()) { + if (await apiCall()) { break; } counter++; if (counter > attempts) { - log( - `Tried validating the validator formation with ${attempts} but failed` - ); + log(`Failed P2P API call after ${attempts} attempts.`); await clearState( uuid, store, @@ -537,42 +761,70 @@ const confirmValidatorCreatedRequest = async ( } }; -// async function exitValidator({ publicKey, signer, operatorIds }) { -// const strategy = await resolveContract( -// "NativeStakingSSVStrategyProxy", -// "NativeStakingSSVStrategy" -// ); - -// log(`About to exit validator`); -// const tx = await strategy -// .connect(signer) -// .exitSsvValidator(publicKey, operatorIds); -// await logTxDetails(tx, "exitSsvValidator"); -// } - -// async function removeValidator({ publicKey, signer, operatorIds }) { -// const strategy = await resolveContract( -// "NativeStakingSSVStrategyProxy", -// "NativeStakingSSVStrategy" -// ); - -// // Cluster details -// const { cluster } = await getClusterInfo({ -// chainId: hre.network.config.chainId, -// ssvNetwork: hre.network.name.toUpperCase(), -// operatorIds, -// ownerAddress: strategy.address, -// }); - -// log(`About to exit validator`); -// const tx = await strategy -// .connect(signer) -// .removeSsvValidator(publicKey, operatorIds, cluster); -// await logTxDetails(tx, "removeSsvValidator"); -// } +async function exitValidator({ publicKey, signer, operatorIds }) { + const strategy = await resolveContract( + "NativeStakingSSVStrategyProxy", + "NativeStakingSSVStrategy" + ); + + log(`About to exit validator`); + const tx = await strategy + .connect(signer) + .exitSsvValidator(publicKey, operatorIds); + await logTxDetails(tx, "exitSsvValidator"); +} + +async function removeValidator({ publicKey, signer, operatorIds }) { + const strategy = await resolveContract( + "NativeStakingSSVStrategyProxy", + "NativeStakingSSVStrategy" + ); + + // Cluster details + const { cluster } = await getClusterInfo({ + chainId: hre.network.config.chainId, + ssvNetwork: hre.network.name.toUpperCase(), + operatorIds, + ownerAddress: strategy.address, + }); + + log(`About to exit validator`); + const tx = await strategy + .connect(signer) + .removeSsvValidator(publicKey, operatorIds, cluster); + await logTxDetails(tx, "removeSsvValidator"); +} + +async function resetStakeETHTally({ signer }) { + const strategy = await resolveContract( + "NativeStakingSSVStrategyProxy", + "NativeStakingSSVStrategy" + ); + + log(`About to resetStakeETHTally`); + const tx = await strategy.connect(signer).resetStakeETHTally(); + await logTxDetails(tx, "resetStakeETHTally"); +} + +async function setStakeETHThreshold({ signer, amount }) { + const strategy = await resolveContract( + "NativeStakingSSVStrategyProxy", + "NativeStakingSSVStrategy" + ); + + const threshold = parseEther(amount.toString()); + + log(`About to setStakeETHThreshold`); + const tx = await strategy.connect(signer).setStakeETHThreshold(threshold); + await logTxDetails(tx, "setStakeETHThreshold"); +} module.exports = { - operateValidators, - //removeValidator, - //exitValidator, + validatorOperationsConfig, + registerValidators, + stakeValidators, + removeValidator, + exitValidator, + resetStakeETHTally, + setStakeETHThreshold, }; diff --git a/contracts/tasks/vault.js b/contracts/tasks/vault.js index f31db84369..ba49d4259b 100644 --- a/contracts/tasks/vault.js +++ b/contracts/tasks/vault.js @@ -133,7 +133,7 @@ async function capital({ symbol, pause }, hre) { } } -async function mint({ amount, asset, symbol, min }, hre) { +async function mint({ amount, asset, symbol, min, approve }, hre) { const signer = await getSigner(); const { vault } = await getContract(hre, symbol); @@ -142,7 +142,12 @@ async function mint({ amount, asset, symbol, min }, hre) { const assetUnits = parseUnits(amount.toString(), await cAsset.decimals()); const minUnits = parseUnits(min.toString()); - await cAsset.connect(signer).approve(vault.address, assetUnits); + if (approve) { + const approveTx = await cAsset + .connect(signer) + .approve(vault.address, assetUnits); + await logTxDetails(approveTx, "approve"); + } log(`About to mint ${symbol} using ${amount} ${asset}`); const tx = await vault diff --git a/contracts/tasks/weth.js b/contracts/tasks/weth.js new file mode 100644 index 0000000000..b9f35396f6 --- /dev/null +++ b/contracts/tasks/weth.js @@ -0,0 +1,23 @@ +const { parseUnits } = require("ethers").utils; + +const { logTxDetails } = require("../utils/txLogger"); + +const log = require("../utils/logger")("task:weth"); + +const depositWETH = async ({ weth, amount, signer }) => { + const etherAmount = parseUnits(amount.toString()); + + log(`About to deposit ${amount} ETH for WETH`); + const tx = await weth.connect(signer).deposit({ value: etherAmount }); + await logTxDetails(tx, "deposit"); +}; + +const withdrawWETH = async ({ weth, amount, signer }) => { + const etherAmount = parseUnits(amount.toString()); + + log(`About to withdraw ${amount} ETH from WETH`); + const tx = await weth.connect(signer).withdraw(etherAmount); + await logTxDetails(tx, "withdraw"); +}; + +module.exports = { depositWETH, withdrawWETH }; diff --git a/contracts/utils/resolvers.js b/contracts/utils/resolvers.js index b47f5b229e..498a1b50fa 100644 --- a/contracts/utils/resolvers.js +++ b/contracts/utils/resolvers.js @@ -1,5 +1,6 @@ const addresses = require("./addresses"); const { ethereumAddress } = require("./regex"); +const { networkMap } = require("./hardhat-helpers"); const log = require("./logger")("task:assets"); @@ -15,12 +16,8 @@ const resolveAsset = async (symbol) => { // Not using helpers here as they import hardhat which won't work for Hardhat tasks if (process.env.FORK === "true" || hre.network.name != "hardhat") { - const network = - hre.network.name != "hardhat" - ? hre.network.name != "hardhat" - : hre.network.config.chainId == 17000 - ? "holesky" - : "mainnet"; + const { chainId } = await hre.ethers.provider.getNetwork(); + const network = networkMap[chainId] || "mainnet"; const assetAddr = addresses[network][symbol + "Proxy"] || addresses[network][symbol]; diff --git a/contracts/utils/signers.js b/contracts/utils/signers.js index 439d6ee07f..520688f3e9 100644 --- a/contracts/utils/signers.js +++ b/contracts/utils/signers.js @@ -1,5 +1,8 @@ const { Wallet } = require("ethers"); -const { Defender } = require("@openzeppelin/defender-sdk"); +const { + DefenderRelaySigner, + DefenderRelayProvider, +} = require("@openzeppelin/defender-relay-client/lib/ethers"); const { ethereumAddress, privateKey } = require("./regex"); const { hardhatSetBalance } = require("../test/_fund"); @@ -59,7 +62,7 @@ async function getSigner(address = undefined) { } const getDefenderSigner = async () => { - const speed = process.env.SPEED || "fast"; + const speed = process.env.SPEED || "fastest"; if (!["safeLow", "average", "fast", "fastest"].includes(speed)) { console.error( `Defender Relay Speed param must be either 'safeLow', 'average', 'fast' or 'fastest'. Not "${speed}"` @@ -67,28 +70,26 @@ const getDefenderSigner = async () => { process.exit(2); } - const network = await ethers.provider.getNetwork(); - const isMainnet = network.chainId === 1; - const isHolesky = network.chainId === 17000; + const { chainId } = await ethers.provider.getNetwork(); + const isMainnet = chainId === 1; - const apiKeyName = isMainnet - ? "DEFENDER_API_KEY" - : "HOLESKY_DEFENDER_API_KEY"; - const apiKeySecret = isMainnet - ? "DEFENDER_API_SECRET" - : "HOLESKY_DEFENDER_API_SECRET"; + const apiKey = isMainnet + ? process.env.DEFENDER_API_KEY + : process.env.HOLESKY_DEFENDER_API_KEY || process.env.DEFENDER_API_KEY; + const apiSecret = isMainnet + ? process.env.DEFENDER_API_SECRET + : process.env.HOLESKY_DEFENDER_API_SECRET || + process.env.DEFENDER_API_SECRET; - const credentials = { - relayerApiKey: process.env[apiKeyName], - relayerApiSecret: process.env[apiKeySecret], - }; + const credentials = { apiKey, apiSecret }; - const client = new Defender(credentials); - const provider = client.relaySigner.getProvider(); + const provider = new DefenderRelayProvider(credentials); + const signer = new DefenderRelaySigner(credentials, provider, { + speed, + }); - const signer = client.relaySigner.getSigner(provider, { speed }); log( - `Using Defender Relayer account ${await signer.getAddress()} from env vars ${apiKeyName} and ${apiKeySecret}` + `Using Defender Relayer account ${await signer.getAddress()} with key ${apiKey} and speed ${speed}` ); return signer; };