diff --git a/hardhat.config.ts b/hardhat.config.ts index fd2841a..54e5974 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -43,4 +43,5 @@ export default config; /* TASKS */ import "./tasks/deployTasks" import "./tasks/keyTasks" -import "./tasks/registerTasks" \ No newline at end of file +import "./tasks/registerTasks" +import "./tasks/batchRegisterTasks" \ No newline at end of file diff --git a/src/utilz/FileUtil.ts b/src/utilz/FileUtil.ts new file mode 100644 index 0000000..ee76f44 --- /dev/null +++ b/src/utilz/FileUtil.ts @@ -0,0 +1,36 @@ +import {promises as fs} from "fs"; +import path from "path"; + +// thin wrapper, easier to remember node11+ async api +// also double checks for success calls +// only async apis (!) +export class FileUtil { + + public static async readFileUtf8(path: string): Promise { + return await fs.readFile(path, { encoding: 'utf8', flag: 'r' }); // note: without 'r' the file might get created! + } + + public static async writeFileUtf8(path: string, content: string): Promise { + return await fs.writeFile(path, content); + } + + public static async existsDir(path:string):Promise { + return await fs.stat(path).then(stat => stat.isDirectory()).catch((e) => false); + } + + // this is the best recommended way to check file status in node 12+ + // only fs.stat() or fs.access() are not deprecated and both async + public static async existsFile(path: string) { + return await fs.stat(path).then(stat => stat.isFile()).catch((e) => false); + } + + public static async mkDir(path: string): Promise { + await fs.mkdir(path, {recursive: true}); + return this.existsDir(path); + } + + public static resolvePath(pathStr: string) : string { + return path.resolve(pathStr); + } + +} \ No newline at end of file diff --git a/src/utilz/KeyUtil.ts b/src/utilz/KeyUtil.ts new file mode 100644 index 0000000..ae3ecaf --- /dev/null +++ b/src/utilz/KeyUtil.ts @@ -0,0 +1,29 @@ +import crypto from "crypto"; +import {Wallet} from "ethers"; +import {FileUtil} from "./FileUtil"; + +export class KeyUtil { + + static createEthPrivateKey() { + return "0x" + crypto.randomBytes(32).toString("hex"); + } + + static async createEthPrivateKeyAsJson(pass: string): Promise<{ addr: string, pubKey: string, privKey: string, jsonContent: string }> { + const privKey = KeyUtil.createEthPrivateKey(); + const wallet1 = new Wallet(privKey); + let jsonContent = await wallet1.encrypt(pass); + return {addr: wallet1.address, pubKey: wallet1.publicKey, privKey: wallet1.privateKey, jsonContent}; + } + + static async createEthPrivateKeyAsFile(pass: string, filePath: string): Promise<{ addr: string }> { + let key = await this.createEthPrivateKeyAsJson(pass); + await FileUtil.writeFileUtf8(filePath, key.jsonContent); + return {addr: key.addr}; + } + + static async readEthPrivateKeyAddress(jsonFilePath: string): Promise { + const jsonData = JSON.parse(await FileUtil.readFileUtf8(jsonFilePath)); + return jsonData.address; + } + +} \ No newline at end of file diff --git a/src/utilz/envLoader.ts b/src/utilz/envLoader.ts index f09af59..f406397 100644 --- a/src/utilz/envLoader.ts +++ b/src/utilz/envLoader.ts @@ -3,17 +3,25 @@ import {NumUtil} from "./numUtil"; export class EnvLoader { - public static loadEnvOrFail() { + public static loadEnv() { + EnvLoader.loadEnvOrFail(false); + } + + public static loadEnvOrFail(fail: boolean = false) { // loads all .env variables into process.env.* variables // Optional support for CONFIG_DIR variable console.log(`config dir is ${process.env.CONFIG_DIR}`) let options = {} if (process.env.CONFIG_DIR) { - options = { path: `${process.env.CONFIG_DIR}/.env` } + options = {path: `${process.env.CONFIG_DIR}/.env`} } - const envFound = dotenv.config(options) + const envFound = dotenv.config(options); if (envFound.error) { - throw new Error("⚠️ Couldn't find .env file ⚠️") + if (fail) { + throw new Error("⚠️ Couldn't find .env file ⚠️") + } else { + console.log("[WARN] no .env file; if you wanted to load a specific .env please specify CONFIG_DIR env variable "); + } } } diff --git a/src/utilz/strUtil.ts b/src/utilz/strUtil.ts new file mode 100755 index 0000000..5bb4f27 --- /dev/null +++ b/src/utilz/strUtil.ts @@ -0,0 +1,53 @@ +export class StrUtil { + + public static isEmpty(s: string): boolean { + if (s == null) { + return true; + } + if (typeof s !== 'string') { + return false; + } + return s.length === 0 + } + + public static hasSize(s: string, minSize: number | null, maxSize: number | null): boolean { + if (s == null || typeof s !== 'string') { + return false; + } + const length = s.length; + if (minSize !== null && length < minSize) { + return false; + } + if (maxSize !== null && length > maxSize) { + return false; + } + return true; + } + + public static isHex(s: string): boolean { + if (StrUtil.isEmpty(s)) { + return false; + } + let pattern = /^[A-F0-9]+$/i; + let result = pattern.test(s); + return result; + } + + /** + * Return s if this is not empty, defaultValue otherwise + * @param s + * @param defaultValue + */ + public static getOrDefault(s: string, defaultValue: string) { + return StrUtil.isEmpty(s) ? defaultValue : s; + } + + public static toStringDeep(obj: any): string { + return JSON.stringify(obj, null, 4); + } + + // https://ethereum.stackexchange.com/questions/2045/is-ethereum-wallet-address-case-sensitive + public static normalizeEthAddress(addr: string): string { + return addr; + } +} \ No newline at end of file diff --git a/tasks/batchRegisterTasks.ts b/tasks/batchRegisterTasks.ts new file mode 100644 index 0000000..59a89bf --- /dev/null +++ b/tasks/batchRegisterTasks.ts @@ -0,0 +1,325 @@ +import {task, types} from "hardhat/config"; +import fs from "fs"; +import path from "path"; +import crypto from "crypto"; +import {Wallet} from "ethers"; +import {FileUtil} from "../src/utilz/FileUtil"; +import {KeyUtil} from "../src/utilz/KeyUtil"; +import {StrUtil} from "../src/utilz/strUtil"; +// import * as path from "node:path"; + +// todo const DEFAULT_DIR = '../push-vnode/docker'; +const DEFAULT_DIR = '../push-vnode/docker'; +const NETWORK_LOCALHOST = 'localhost'; +const NETWORK_SEPOLIA = 'sepolia'; + +/* + + +Generates node keys (node_key.json) for every validator from 1 to 15: +>npx hardhat --network localhost generateNodeKeys "../push-vnode/docker" "test" "v" "15" + +Generates console commands to register these keys in a smart contract: +(you should execute them manually because sometimes evm operations fail or timeout) +>npx hardhat --network localhost generateRegisterScript "../push-vnode/docker" "v" + +Generates .yml file (see push-vnode/docker/v.yml) for all node keys found: +(re-writes v.yml! , provide file param if needed) +>npx hardhat --network localhost generateYml "../push-vnode/docker" "v" + +NOTE: ../push-vnode/docker = could be changed to any dir, +existing .json keys would be kept, +.yml files will be overwritten + + + */ +task("generateNodeKeys", "") + .addPositionalParam("keyDir", 'dir with v1..vN /node_key.json', DEFAULT_DIR, types.string, true) + .addPositionalParam("keyPassword", 'keys are encrypted by default', 'test', types.string, true) + .addPositionalParam("nodeType", 'node type, one of V,S,A (validator, storage, archival)', 'v', types.string, true) + .addPositionalParam("nodeCount", 'node type, one of V,S,A (validator, storage, archival)', 10, types.int, true) + .setAction(async (args, hre) => { + // validate input + let nodeType = args.nodeType.toString(); + let nodeTypeAsText = nodeType.toLowerCase(); + switch (nodeType) { + case 'v': + nodeTypeAsText = 'validator node' + break; + case 's': + nodeTypeAsText = 'storage node' + break; + case 'a': + nodeTypeAsText = 'archival node' + break; + default: + throw new Error('invalid node type; valid options: v, s, a') + } + + let nodeCount: number = args.nodeCount; + if (!(nodeCount >= 1 && nodeCount <= 100)) { + throw new Error('invalid node count, 1..100 is valid range'); + } + + let keyDir = args.keyDir; + keyDir = FileUtil.resolvePath(keyDir); + if (!await FileUtil.existsDir(keyDir)) { + throw new Error('invalid keyDir'); + } + + let keyPassword = args.keyPassword; + if(StrUtil.isEmpty(keyPassword)) { + throw new Error('invalid keyPassword'); + } + // end validate input + console.log(`generating node keys [1..${nodeCount - 1}] in dir [${keyDir}], node type: ${nodeType}`) + + for (let index = 1; index <= nodeCount; index++) { + const nodeWithIndex = `${nodeType}${index}`; + const keyPath = path.join(keyDir, `${nodeWithIndex}/node_key.json`); + if (await FileUtil.existsFile(keyPath)) { + console.log(`[${nodeWithIndex}] key file ${keyPath} exists, skipping`); + continue; + } + console.log(`[${nodeWithIndex}] creating key file : ${keyPath} `); + + const nodeKeyDir = path.join(keyDir, nodeWithIndex); + if (!await FileUtil.mkDir(nodeKeyDir)) { + throw new Error(`failed to create dir ${nodeKeyDir} `); + } + let key = await KeyUtil.createEthPrivateKeyAsFile(keyPassword, keyPath); + console.log(`OK pubKey: ${key.addr}`); + } + }); + + +task("generateRegisterScript", "") + .addPositionalParam("keyDir", 'dir with v1..vN /node_key.json', DEFAULT_DIR, types.string, true) + .addPositionalParam("nodeType", 'node type, one of V,S,A (validator, storage, archival)', 'v', types.string, true) + .setAction(async (args, hre) => { + // validate input + let nodeType = args.nodeType.toLowerCase(); + let nodeTypeAsText = nodeType.toLowerCase(); + switch (nodeType) { + case 'v': + nodeTypeAsText = 'validator node' + break; + case 's': + nodeTypeAsText = 'storage node' + break; + case 'a': + nodeTypeAsText = 'archival node' + break; + default: + throw new Error('invalid node type; valid options: v, s, a') + } + + let keyDir = args.keyDir; + keyDir = FileUtil.resolvePath(keyDir); + if (!await FileUtil.existsDir(keyDir)) { + throw new Error('invalid keyDir'); + } + + let networkName = hre.network.name; + if (!(networkName == NETWORK_LOCALHOST || networkName == NETWORK_SEPOLIA)) { + throw new Error('invalid network name: only localhost or sepolia are supported'); + } + // end validate input + + await processDirectories(keyDir, nodeType, networkName) + }); + +// Function to process directories and generate commands +async function processDirectories(dir: string, nodeType: string, networkName: string): Promise { + // Regular expression to match "a" + number, "v" + number, "s" + number + const dirPattern = new RegExp(`^[${nodeType}](\\d+)$`); + + // Read the input directory + const subDirs = fs.readdirSync(dir).filter((subDir) => { + const fullPath = path.join(dir, subDir); + return fs.statSync(fullPath).isDirectory() && dirPattern.test(subDir); + }).sort((a, b) => { + let aVal = parseDir(a); + let bVal = parseDir(b); + return aVal.dirNumber - bVal.dirNumber; + });; + console.log("found subdirs: %o", subDirs); + for (const subDir of subDirs) { + const match = dirPattern.exec(subDir); + if (!match) continue; + + const dirLetter = subDir[0]; // "a", "v", or "s" + const dirNumber = parseInt(match[1], 10); // Extract number + const keyPath = path.join(dir, subDir, "node_key.json"); + + // Check if the JSON file exists + if (!fs.existsSync(keyPath)) { + console.warn(`JSON file not found: ${keyPath}`); + continue; + } + + // Read and parse the JSON file + const address = await KeyUtil.readEthPrivateKeyAddress(keyPath); + + if (!address) { + console.warn(`Address not found in file: ${keyPath}`); + continue; + } + + // Generate dynamic values + let portOnLocalhost: number; + let baseStake: number; + let cmd: string; + let nodeApiUrl = null; + if (dirLetter === "v") { + baseStake = 100; + portOnLocalhost = 4000; + cmd = "registerValidator"; + } else if (dirLetter === "s") { + baseStake = 200; + portOnLocalhost = 3000; + cmd = "registerStorage"; + } else if (dirLetter === "a") { + baseStake = 300; + portOnLocalhost = 5000; + cmd = "registerArchival"; + } else { + throw new Error(); + } + const calculatedPort = portOnLocalhost + dirNumber; + + if(networkName == NETWORK_LOCALHOST) { + nodeApiUrl = `http://localhost${dirNumber}:${portOnLocalhost + dirNumber}`; + } else if(networkName == NETWORK_SEPOLIA) { + nodeApiUrl = `https://${dirLetter}${dirLetter}${dirNumber}.dev.push.org`; + } else { + throw new Error('unsupported network name'); + } + + const line = `npx hardhat --network ${networkName} v:${cmd} ${address} "${nodeApiUrl}" ${baseStake + dirNumber}`; + + // Print the generated command + console.log(line); + } +} + + +task("generateYml", "") + .addPositionalParam("dockerDir", 'dir with v1..vN', DEFAULT_DIR, types.string, true) + .addPositionalParam("nodeType", 'node type, one of V,S,A (validator, storage, archival)', 'v', types.string, true) + .addPositionalParam("ymlFile", 'output file name', '', types.string, true) + .setAction(async (args, hre) => { + // validate input + let nodeType = args.nodeType.toLowerCase(); + let nodeTypeAsText = nodeType.toLowerCase(); + switch (nodeType) { + case 'v': + nodeTypeAsText = 'validator node' + break; + case 's': + nodeTypeAsText = 'storage node' + break; + case 'a': + nodeTypeAsText = 'archival node' + break; + default: + throw new Error('invalid node type; valid options: v, s, a') + } + + let dockerDir = args.dockerDir; + dockerDir = FileUtil.resolvePath(dockerDir); + if (!await FileUtil.existsDir(dockerDir)) { + throw new Error('invalid dockerDir'); + } + + let ymlFile = args.ymlFile; + if (StrUtil.isEmpty(ymlFile)) { + ymlFile = path.join(dockerDir, `${nodeType}.yml`); + } else { + ymlFile = path.join(dockerDir, ymlFile); + } + // end validate input + + console.log(`generating yml for node keys in dir [${dockerDir}], node type: ${nodeType}`) + const dirPattern = new RegExp(`^[${nodeType}](\\d+)$`); + + // Read the input directory + const subDirs = fs.readdirSync(dockerDir).filter((subDir) => { + const fullPath = path.join(dockerDir, subDir); + return fs.statSync(fullPath).isDirectory() && dirPattern.test(subDir); + }).sort((a, b) => { + let aVal = parseDir(a); + let bVal = parseDir(b); + return aVal.dirNumber - bVal.dirNumber; + }); + console.log("found subdirs: %o", subDirs); + let buf = +`version: '3' +services: +`; + for(const subDir of subDirs) { + let {dirLetter, dirNumber} = parseDir(subDir); + + let portOnLocalhost; + let entryPointScript = ""; + if (dirLetter === "v") { + portOnLocalhost = 4000 + dirNumber; + } else if (dirLetter === "s") { + portOnLocalhost = 3000 + dirNumber; + } else if (dirLetter === "a") { + portOnLocalhost = 5000 + dirNumber; + entryPointScript = ` + entrypoint: ['sh', '/entrypoint.sh']`; + } else { + throw new Error('dirLetter is ' + dirLetter); + } + + // DO NOT REFORMAT + buf += ` + ${nodeType}node${dirNumber}: + image: ${nodeType}node-main + container_name: ${nodeType}node${dirNumber} + networks: + push-dev-network: + aliases: + - ${nodeType}node${dirNumber}.local + environment: + DB_NAME: ${nodeType}node${dirNumber} + PORT: ${portOnLocalhost} + env_file: + - .env + - common.env + - ${nodeType}-specific.env + ports: + - "${portOnLocalhost}:${portOnLocalhost}"${entryPointScript} + volumes: + - ./${nodeType}${dirNumber}:/config + - ./${nodeType}${dirNumber}/log:/log + - ./_abi/:/config/abi/ + + `; + + } + buf += ` + +networks: + push-dev-network: + external: true +`; + + console.log('yaml: ------------------ \n\n%s', buf); + console.log('\n\n writing to %s', ymlFile) + await FileUtil.writeFileUtf8(ymlFile, buf); + }); + + +function parseDir(dirName: string) : {dirLetter: string, dirNumber: number} { + const dirPattern = new RegExp(`^[vsa](\\d+)$`);; + const match = dirPattern.exec(dirName); + if (!match) { + throw new Error('Invalid directory name format'); + } + const dirLetter = dirName[0].toLowerCase(); + const dirNumber = parseInt(match[1], 10); + return { dirLetter, dirNumber }; +} \ No newline at end of file diff --git a/tasks/registerTasks.ts b/tasks/registerTasks.ts index 1bfa327..322fee3 100644 --- a/tasks/registerTasks.ts +++ b/tasks/registerTasks.ts @@ -9,7 +9,7 @@ import type {BigNumber} from "ethers"; import {ValidatorV1} from "../typechain-types"; let info = console.log; -EnvLoader.loadEnvOrFail(); +EnvLoader.loadEnv(); /* ex: @@ -165,73 +165,6 @@ async function printBalance(hre: HardhatRuntimeEnvironment, pushCt: string, bala info(await push.balanceOf(balanceAddr)); } -task("generateRegisterScript", "") - .setAction(async (args, hre) => { - await processDirectories("/Users/w/chain/push-vnode/docker") - }); - -// Function to process directories and generate commands -async function processDirectories(dir: string): Promise { - // Regular expression to match "a" + number, "v" + number, "s" + number - const dirPattern = /^[avs](\d+)$/; - - // Read the input directory - const subDirs = fs.readdirSync(dir).filter((subDir) => { - const fullPath = path.join(dir, subDir); - return fs.statSync(fullPath).isDirectory() && dirPattern.test(subDir); - }); - - for (const subDir of subDirs) { - const match = dirPattern.exec(subDir); - if (!match) continue; - - const dirLetter = subDir[0]; // "a", "v", or "s" - const dirNumber = parseInt(match[1], 10); // Extract number - const fullPath = path.join(dir, subDir); - const jsonFilePath = path.join(fullPath, "node_key.json"); - - // Check if the JSON file exists - if (!fs.existsSync(jsonFilePath)) { - console.warn(`JSON file not found: ${jsonFilePath}`); - continue; - } - - // Read and parse the JSON file - const jsonData = JSON.parse(fs.readFileSync(jsonFilePath, "utf8")); - const address = jsonData.address; - - if (!address) { - console.warn(`Address not found in file: ${jsonFilePath}`); - continue; - } - - // Generate dynamic values - let basePort: number; - let baseStake: number; - let cmd:string; - if (dirLetter === "v") { - baseStake = 100; - basePort = 4000; - cmd = "registerValidator"; - } else if (dirLetter === "s") { - baseStake = 200; - basePort = 3000; - cmd = "registerStorage"; - } else if (dirLetter === "a") { - baseStake = 300; - basePort = 5000; - cmd = "registerArchival"; - } else { - throw new Error(); - } - const calculatedPort = basePort + dirNumber; - const line = `npx hardhat --network sepolia v:${cmd} ${address} "https://${dirLetter}${dirLetter}${dirNumber}.dev.push.org" ${baseStake + dirNumber}`; - - // Print the generated command - console.log(line); - } -} - function getConfig(hre: HardhatRuntimeEnvironment): { storageProxyCt: string; validatorProxyCt: string; pushCt: string } { let network = hre.network.name.toUpperCase();