diff --git a/mainnet-contracts/src/ValidatorTicket.sol b/mainnet-contracts/src/ValidatorTicket.sol index 7f4c16b..f6c42a3 100644 --- a/mainnet-contracts/src/ValidatorTicket.sol +++ b/mainnet-contracts/src/ValidatorTicket.sol @@ -8,7 +8,7 @@ import { ERC20PermitUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { Address } from "@openzeppelin/contracts/utils/Address.sol"; -import { ValidatorTicketStorage } from "src/ValidatorTicketStorage.sol"; +import { ValidatorTicketStorage } from "./ValidatorTicketStorage.sol"; import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import { IPufferOracle } from "./interface/IPufferOracle.sol"; import { IValidatorTicket } from "./interface/IValidatorTicket.sol"; diff --git a/package.json b/package.json index ce6aa9e..4404fd5 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "license": "ISC", "private": true, "workspaces": { - "packages": ["mainnet-contracts"], + "packages": ["mainnet-contracts", "utility-scripts"], "nohoist": [ "**/*" ] diff --git a/utility-scripts/.gitignore b/utility-scripts/.gitignore new file mode 100644 index 0000000..85198aa --- /dev/null +++ b/utility-scripts/.gitignore @@ -0,0 +1,14 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env diff --git a/utility-scripts/README.md b/utility-scripts/README.md new file mode 100644 index 0000000..8c9f102 --- /dev/null +++ b/utility-scripts/README.md @@ -0,0 +1,10 @@ +# Utility scripts + +### Validator registration using Safe multisig + +1. Make sure that Safe has enough VT & pufETH for the registrations +2. Run the calldata generation script +```bash +forge script script/GenerateBLSKeysAndRegisterValidatorsCalldata.s.sol:GenerateBLSKeysAndRegisterValidatorsCalldata --rpc-url=$ETH_RPC_URL -vvv --ffi +``` +3. Copy & paste the addresses & calldata in the Safe Transaction Builder diff --git a/utility-scripts/foundry.toml b/utility-scripts/foundry.toml new file mode 100644 index 0000000..ee63864 --- /dev/null +++ b/utility-scripts/foundry.toml @@ -0,0 +1,22 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] + +allow_paths = ["../node_modules", "./node_modules"] +block_number = 0 # Our RAVE evidence is generated for blockhash(0) which is bytes32(0) +fs_permissions = [{ access = "read-write", path = "./"}] +optimizer = true +optimizer_runs = 200 +evm_version = "cancun" # is live on mainnet +seed = "0x1337" +solc = "0.8.26" + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options + +[fmt] +line_length = 120 +int_types = "long" +tab_width = 4 +quote_style = "double" +bracket_spacing = true \ No newline at end of file diff --git a/utility-scripts/package.json b/utility-scripts/package.json new file mode 100644 index 0000000..bcadf66 --- /dev/null +++ b/utility-scripts/package.json @@ -0,0 +1,39 @@ +{ + "name": "utility-scripts", + "description": "", + "version": "1.0.0", + "author": { + "name": "Puffer Finance", + "url": "https://puffer.fi" + }, + "dependencies": { + "@openzeppelin/contracts": "5.0.1", + "mainnet-contracts": "*", + "@openzeppelin/contracts-upgradeable": "5.0.1", + "eigenlayer-contracts": "https://github.com/Layr-Labs/eigenlayer-contracts.git#1bf4c12", + "eigenlayer-middleware": "https://github.com/bxmmm1/eigenlayer-middleware.git#dbf6c1a" + }, + "devDependencies": { + "forge-std": "github:foundry-rs/forge-std#v1.8.2" + }, + "homepage": "https://puffer.fi", + "keywords": [ + "blockchain", + "foundry", + "smart-contracts", + "solidity", + "web3", + "ethereum", + "puffer", + "puffer-finance", + "solidity", + "LRT", + "eigenlayer", + "restaking", + "liquid-staking" + ], + "scripts": { + "fmt": "forge fmt", + "build": "forge build" + } +} diff --git a/utility-scripts/parse-foundry-json.js b/utility-scripts/parse-foundry-json.js new file mode 100644 index 0000000..8df1b89 --- /dev/null +++ b/utility-scripts/parse-foundry-json.js @@ -0,0 +1,27 @@ +const fs = require('fs'); + +// Read input JSON data from file (assuming it's saved as input.json) +const inputJson = fs.readFileSync('safe-registration-file.json', 'utf8'); + +// Parse the JSON data +const data = JSON.parse(inputJson); + +// Remove the surrounding quotes from the string +const trimmedString = data.transactions.substring(1, data.transactions.length - 1); + +// // Replace escaped backslashes with a single backslash +let cleanedString = trimmedString.replace(/\\\\/g, '\\'); +cleanedString = cleanedString.replace(/\"{/g, '{'); +cleanedString = "[" + cleanedString.replace(/\}"/g, '}') + "]"; + +// Parse the cleaned string into a JavaScript array +const jsonArray = JSON.parse(cleanedString); + +// Update the data object with the parsed transactions array +data.transactions = jsonArray; + +// Convert data back to JSON format +const outputJson = JSON.stringify(data, null, 2); + +// Print the output JSON +fs.writeFileSync('safe-registration-file.json', outputJson, 'utf8') diff --git a/utility-scripts/registration-data/.gitkeep b/utility-scripts/registration-data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/utility-scripts/remappings.txt b/utility-scripts/remappings.txt new file mode 100644 index 0000000..6b508aa --- /dev/null +++ b/utility-scripts/remappings.txt @@ -0,0 +1,12 @@ +ds-test/=node_modules/forge-std/lib/ds-test/src/ +forge-std/=node_modules/forge-std/src/ +@openzeppelin-contracts-upgradeable/=node_modules/@openzeppelin/contracts-upgradeable/ +@openzeppelin/contracts-upgradeable/=node_modules/@openzeppelin/contracts-upgradeable/ +@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/ +eigenlayer/=node_modules/eigenlayer-contracts/src/contracts +eigenlayer-middleware/=node_modules/eigenlayer-middleware/src/ +eigenlayer-test/=node_modules/eigenlayer-contracts/src/test +eigenlayer-contracts/=node_modules/eigenlayer-contracts/ +mainnet-contracts/=node_modules/mainnet-contracts/ +rave/=node_modules/rave/src/ +rave-test/=node_modules/rave/test/ \ No newline at end of file diff --git a/utility-scripts/script/GenerateBLSKeysAndRegisterValidatorsCalldata.s.sol b/utility-scripts/script/GenerateBLSKeysAndRegisterValidatorsCalldata.s.sol new file mode 100644 index 0000000..de61c98 --- /dev/null +++ b/utility-scripts/script/GenerateBLSKeysAndRegisterValidatorsCalldata.s.sol @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import "forge-std/Script.sol"; +import { stdJson } from "forge-std/StdJson.sol"; +import { Permit } from "../../mainnet-contracts/src/structs/Permit.sol"; +import { ValidatorKeyData } from "mainnet-contracts/src/struct/ValidatorKeyData.sol"; +import { IPufferProtocol } from "mainnet-contracts/src/interface/IPufferProtocol.sol"; +import { PufferProtocol } from "mainnet-contracts/src/PufferProtocol.sol"; +import { PufferVaultV2 } from "mainnet-contracts/src/PufferVaultV2.sol"; +import { ValidatorTicket } from "mainnet-contracts/src/ValidatorTicket.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/** + * See the docs for more detailed information: https://docs.puffer.fi/nodes/registration#batch-registering-validators + * + * To run the simulation: + * + * forge script script/GenerateBLSKeysAndRegisterValidatorsCalldata.s.sol:GenerateBLSKeysAndRegisterValidatorsCalldata --rpc-url=$RPC_URL -vvv --ffi + * + */ +contract GenerateBLSKeysAndRegisterValidatorsCalldata is Script { + address validatorTicketAddress; + PufferVaultV2 internal pufETH; + ValidatorTicket internal validatorTicket; + address internal protocolAddress; + PufferProtocol internal pufferProtocol; + string internal registrationJson; + + string forkVersion; + + bytes32 moduleToRegisterTo; + + mapping(bytes32 keyHash => bool registered) internal pubKeys; + bytes[] internal registeredPubKeys; + + struct Tx { + address to; + bytes data; + } + + function setUp() public { + if (block.chainid == 17000) { + // Holesky + validatorTicketAddress = 0xB028194785178a94Fe608994A4d5AD84c285A640; + protocolAddress = 0xE00c79408B9De5BaD2FDEbB1688997a68eC988CD; + pufferProtocol = PufferProtocol(protocolAddress); + forkVersion = "0x01017000"; + } else if (block.chainid == 1) { + // Mainnet + validatorTicketAddress = 0x7D26AD6F6BA9D6bA1de0218Ae5e20CD3a273a55A; + protocolAddress = 0xf7b6B32492c2e13799D921E84202450131bd238B; + pufferProtocol = PufferProtocol(protocolAddress); + forkVersion = "0x00000000"; + } + + pufETH = pufferProtocol.PUFFER_VAULT(); + validatorTicket = pufferProtocol.VALIDATOR_TICKET(); + } + + function run() public { + uint256 guardiansLength = pufferProtocol.GUARDIAN_MODULE().getGuardians().length; + + uint256 specificModule = vm.promptUint("Do you want to register to a specific module? (0: No, 1: Yes)"); + if (specificModule == 1) { + uint256 pufferModuleIdx = vm.promptUint( + "Please enter the module number to which you wish to register. Enter '0' to register to PUFFER_MODULE_0, Enter '1' to register to PUFFER_MODULE_1, ..." + ); + moduleToRegisterTo = + bytes32(abi.encodePacked(string.concat("PUFFER_MODULE_", vm.toString(pufferModuleIdx)))); + } + + uint256 numberOfValidators = vm.promptUint("How many validators would you like to register?"); + require(numberOfValidators > 0, "Number of validators must be greater than 0"); + + uint256 vtAmount = vm.promptUint("Enter the VT amount per validator (28 is minimum)"); + require(vtAmount >= 28, "VT amount must be at least 28"); + + address safe = vm.promptAddress("Enter the safe address"); + require(safe != address(0), "Invalid safe address"); + + // Validate pufETH & VT balances + _validateBalances(safe, numberOfValidators, vtAmount); + + bytes32[] memory moduleWeights = pufferProtocol.getModuleWeights(); + uint256 moduleSelectionIndex = pufferProtocol.getModuleSelectIndex(); + + bytes memory approveVTCalldata = + abi.encodeCall(ERC20.approve, (protocolAddress, vtAmount * numberOfValidators * 1 ether)); + bytes memory approvePufETHCalldata = + abi.encodeCall(ERC20.approve, (protocolAddress, 2 ether * numberOfValidators)); + + // 2 token approvals + validator registrations + Tx[] memory transactions = new Tx[](numberOfValidators + 2); + transactions[0] = Tx({ to: validatorTicketAddress, data: approveVTCalldata }); + transactions[1] = Tx({ to: address(pufETH), data: approvePufETHCalldata }); + + for (uint256 i = 0; i < numberOfValidators; ++i) { + // Select the module to register to + bytes32 moduleName = moduleWeights[(moduleSelectionIndex + i) % moduleWeights.length]; + + // If the user specified a module to register to, use that instead + if (moduleToRegisterTo != bytes32(0)) { + require(pufferProtocol.getModuleAddress(moduleToRegisterTo) != address(0), "Invalid Puffer Module"); + moduleName = moduleToRegisterTo; + } + + _generateValidatorKey(i, moduleName); + + // Read the registration JSON file + registrationJson = vm.readFile(string.concat("./registration-data/", vm.toString(i), ".json")); + + bytes[] memory blsEncryptedPrivKeyShares = new bytes[](guardiansLength); + blsEncryptedPrivKeyShares[0] = stdJson.readBytes(registrationJson, ".bls_enc_priv_key_shares[0]"); + + ValidatorKeyData memory validatorData = ValidatorKeyData({ + blsPubKey: stdJson.readBytes(registrationJson, ".bls_pub_key"), + signature: stdJson.readBytes(registrationJson, ".signature"), + depositDataRoot: stdJson.readBytes32(registrationJson, ".deposit_data_root"), + blsEncryptedPrivKeyShares: blsEncryptedPrivKeyShares, + blsPubKeySet: stdJson.readBytes(registrationJson, ".bls_pub_key_set"), + raveEvidence: "" + }); + + Permit memory pufETHPermit; + pufETHPermit.amount = 2 ether; + + Permit memory vtPermit; + vtPermit.amount = vtAmount * 1 ether; + + bytes memory registerValidatorKeyCalldata = + abi.encodeCall(PufferProtocol.registerValidatorKey, (validatorData, moduleName, pufETHPermit, vtPermit)); + + transactions[i + 2] = Tx({ to: protocolAddress, data: registerValidatorKeyCalldata }); + + registeredPubKeys.push(validatorData.blsPubKey); + } + + // Create Safe TX JSON + _createSafeJson(safe, transactions); + + console.log("Validator PubKeys:"); + for (uint256 i = 0; i < registeredPubKeys.length; ++i) { + console.logBytes(registeredPubKeys[i]); + } + } + + function _createSafeJson(address safe, Tx[] memory transactions) internal { + // First we need to craft the JSON file for the transactions batch + string memory root = "root"; + + vm.serializeString(root, "version", "\"1.0\""); + vm.serializeUint(root, "createdAt", block.timestamp * 1000); + // Needs to be a string + vm.serializeString(root, "chainId", string.concat("\"", Strings.toString(block.chainid), "\"")); + + string memory meta = "meta"; + vm.serializeString(meta, "name", "Transactions Batch"); + vm.serializeString(meta, "txBuilderVersion", "\"1.16.5\""); + vm.serializeAddress(meta, "createdFromSafeAddress", safe); + vm.serializeString(meta, "createdFromOwnerAddress", ""); + vm.serializeString(meta, "checksum", ""); + string memory metaOutput = vm.serializeString(meta, "description", ""); + + string[] memory txs = new string[](transactions.length); + + for (uint256 i = 0; i < transactions.length; ++i) { + string memory singleTx = "tx"; + + vm.serializeAddress(singleTx, "to", transactions[i].to); + vm.serializeString(singleTx, "value", "\"0\""); + txs[i] = vm.serializeBytes(singleTx, "data", transactions[i].data); + } + + vm.serializeString(root, "transactions", txs); + string memory finalJson = vm.serializeString(root, "meta", metaOutput); + vm.writeJson(finalJson, "./safe-registration-file.json"); + + // Because foundry doesn't support creating JSON array of objects, we need to run NodeJS script to convert this to a valid JSON + + string[] memory inputs = new string[](2); + inputs[0] = "node"; + inputs[1] = "parse-foundry-json"; + vm.ffi(inputs); + } + + // Validates the pufETH and VT balances for the `safe` (node operator) + function _validateBalances(address safe, uint256 numberOfValidators, uint256 vtBalancePerValidator) internal view { + uint256 pufETHRequired = pufETH.convertToSharesUp(numberOfValidators * 2 ether); + + if (pufETH.balanceOf(safe) < pufETHRequired) { + revert("Insufficient pufETH balance"); + } + + uint256 vtRequired = numberOfValidators * vtBalancePerValidator * 1 ether; + + if (validatorTicket.balanceOf(safe) < vtRequired) { + revert("Insufficient VT balance"); + } + } + + // Generates a new validator key using coral https://github.com/PufferFinance/coral/tree/main + function _generateValidatorKey(uint256 idx, bytes32 moduleName) internal { + uint256 numberOfGuardians = pufferProtocol.GUARDIAN_MODULE().getGuardians().length; + bytes[] memory guardianPubKeys = pufferProtocol.GUARDIAN_MODULE().getGuardiansEnclavePubkeys(); + address moduleAddress = IPufferProtocol(protocolAddress).getModuleAddress(moduleName); + bytes memory withdrawalCredentials = IPufferProtocol(protocolAddress).getWithdrawalCredentials(moduleAddress); + + string[] memory inputs = new string[](17); + inputs[0] = "coral-cli"; + inputs[1] = "validator"; + inputs[2] = "keygen"; + inputs[3] = "--guardian-threshold"; + inputs[4] = vm.toString(numberOfGuardians); + inputs[5] = "--module-name"; + inputs[6] = vm.toString(moduleName); + inputs[7] = "--withdrawal-credentials"; + inputs[8] = vm.toString(withdrawalCredentials); + inputs[9] = "--guardian-pubkeys"; + inputs[10] = vm.toString(guardianPubKeys[0]); //@todo: Add support for multiple guardians + inputs[11] = "--fork-version"; + inputs[12] = forkVersion; + inputs[13] = "--password-file"; + inputs[14] = "validator-keystore-password.txt"; + inputs[15] = "--output-file"; + inputs[16] = string.concat("./registration-data/", vm.toString(idx), ".json"); + + vm.ffi(inputs); + } +} diff --git a/utility-scripts/validator-keystore-password.txt b/utility-scripts/validator-keystore-password.txt new file mode 100644 index 0000000..1abbb15 --- /dev/null +++ b/utility-scripts/validator-keystore-password.txt @@ -0,0 +1 @@ +securePassword \ No newline at end of file