diff --git a/packages/configuration-generator/source/configuration-generator.test.ts b/packages/configuration-generator/source/configuration-generator.test.ts index 0b91f7572..058447883 100644 --- a/packages/configuration-generator/source/configuration-generator.test.ts +++ b/packages/configuration-generator/source/configuration-generator.test.ts @@ -209,6 +209,101 @@ describe<{ assert.equal(internalOptions.premine, "125000000000000000000000000"); }); + // Three valid, distinct BIP39 mnemonics for the externally-supplied-secrets cases. + const MNEMONIC_A = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const MNEMONIC_B = "legal winner thank year wave sausage worth useful legal winner thank yellow"; + const MNEMONIC_C = "letter advice cage absurd amount doctor acoustic avoid letter advice cage above"; + + it("should use externally supplied validator mnemonics matching the validator count", async ({ + generator, + configPath, + genesis, + }) => { + const generate = spy(genesis, "generate"); + + await generator.generate(options({ validatorMnemonics: [MNEMONIC_A, MNEMONIC_B], validators: 2 })); + + // validators.json holds exactly the supplied secrets, in order. + assert.equal(readJSONSync(join(configPath, "validators.json")).secrets, [MNEMONIC_A, MNEMONIC_B]); + + // The active round-validator set matches the supplied list. + const crypto = readJSONSync(join(configPath, "crypto.json")); + assert.equal(crypto.milestones[1], { height: 1, roundValidators: 2 }); + + // The generator receives the supplied mnemonics. + const [, validatorMnemonics] = generate.getCallArgs(0) as [string, string[], any]; + assert.equal(validatorMnemonics, [MNEMONIC_A, MNEMONIC_B]); + }); + + it("should reject validator mnemonics whose count differs from the validators count", async ({ + generator, + configPath, + }) => { + await assert.rejects( + () => generator.generate(options({ validatorMnemonics: [MNEMONIC_A, MNEMONIC_B], validators: 3 })), + "validatorMnemonics length (2) does not match the validators count (3).", + ); + + // Rejected up front, before any config file is written. + assert.false(existsSync(join(configPath, "crypto.json"))); + }); + + it("should use an externally supplied genesis mnemonic", async ({ generator, configPath, genesis }) => { + const generate = spy(genesis, "generate"); + + await generator.generate(options({ genesisMnemonic: MNEMONIC_C })); + + // The genesis wallet is derived from the supplied mnemonic. + assert.equal(readJSONSync(join(configPath, "genesis-wallet.json")).passphrase, MNEMONIC_C); + assert.equal(generate.getCallArgs(0)[0], MNEMONIC_C); + }); + + it("should reject an invalid validator mnemonic before touching the EVM", async ({ generator, configPath }) => { + await assert.rejects( + () => + generator.generate(options({ validatorMnemonics: [MNEMONIC_A, "not a real mnemonic"], validators: 2 })), + "validatorMnemonics[1] is not a valid BIP39 mnemonic.", + ); + + // Validation happens up front, before any config file is written. + assert.false(existsSync(join(configPath, "crypto.json"))); + }); + + it("should reject an invalid genesis mnemonic", async ({ generator }) => { + await assert.rejects( + () => generator.generate(options({ genesisMnemonic: "nope" })), + "genesisMnemonic is not a valid BIP39 mnemonic.", + ); + }); + + it("should reject an empty validator mnemonic list", async ({ generator }) => { + await assert.rejects( + () => generator.generate(options({ validatorMnemonics: [] })), + "validatorMnemonics must be a non-empty array.", + ); + }); + + it("should reject duplicate validator mnemonics", async ({ generator }) => { + await assert.rejects( + () => generator.generate(options({ validatorMnemonics: [MNEMONIC_A, MNEMONIC_A], validators: 2 })), + "validatorMnemonics contains duplicate entries.", + ); + }); + + it("should reject a genesis mnemonic that is also a validator mnemonic", async ({ generator }) => { + await assert.rejects( + () => + generator.generate( + options({ + genesisMnemonic: MNEMONIC_A, + validatorMnemonics: [MNEMONIC_A, MNEMONIC_B], + validators: 2, + }), + ), + "genesisMnemonic must not also be a validator mnemonic.", + ); + }); + it("should write database settings to .env when all DB options are provided", async ({ generator, configPath }) => { await generator.generate( options({ diff --git a/packages/configuration-generator/source/configuration-generator.ts b/packages/configuration-generator/source/configuration-generator.ts index c612a595f..9cfdae068 100644 --- a/packages/configuration-generator/source/configuration-generator.ts +++ b/packages/configuration-generator/source/configuration-generator.ts @@ -3,6 +3,7 @@ import type { Contracts } from "@mainsail/contracts"; import { Identifiers } from "@mainsail/constants"; import { inject, injectable } from "@mainsail/container"; import { Application } from "@mainsail/kernel"; +import { validateMnemonic } from "bip39"; import dayjs from "dayjs"; import { ensureDirSync, pathExistsSync } from "fs-extra/esm"; import { join } from "path"; @@ -21,6 +22,12 @@ import { } from "./generators/index.js"; import { Identifiers as InternalIdentifiers } from "./identifiers.js"; +const assertMnemonic = (mnemonic: unknown, label: string): void => { + if (typeof mnemonic !== "string" || !validateMnemonic(mnemonic)) { + throw new Error(`${label} is not a valid BIP39 mnemonic.`); + } +}; + @injectable() export class ConfigurationGenerator { @inject(InternalIdentifiers.Application) @@ -85,8 +92,11 @@ export class ConfigurationGenerator { ...options, }; - const genesisWalletMnemonic = this.mnemonicGenerator.generate(); - let validatorsMnemonics = this.mnemonicGenerator.generateMany(internalOptions.validators); + this.#assertCustomSecrets(internalOptions); + + const genesisWalletMnemonic = internalOptions.genesisMnemonic ?? this.mnemonicGenerator.generate(); + let validatorsMnemonics = + internalOptions.validatorMnemonics ?? this.mnemonicGenerator.generateMany(internalOptions.validators); const tasks: Task[] = [ { @@ -148,7 +158,11 @@ export class ConfigurationGenerator { snapshotHash: this.importer.snapshotHash, }; - if (this.importer.validators && options.mockFakeValidatorBlsKeys) { + if ( + this.importer.validators && + options.mockFakeValidatorBlsKeys && + !internalOptions.validatorMnemonics + ) { validatorsMnemonics = await this.#prepareMockValidatorKeys(internalOptions); } } @@ -224,6 +238,37 @@ export class ConfigurationGenerator { this.logger.info(`Configuration generated on location: ${this.configurationPath}`); } + #assertCustomSecrets(options: Contracts.NetworkGenerator.InternalOptions): void { + if (options.genesisMnemonic !== undefined) { + assertMnemonic(options.genesisMnemonic, "genesisMnemonic"); + } + + if (options.validatorMnemonics !== undefined) { + if (!Array.isArray(options.validatorMnemonics) || options.validatorMnemonics.length === 0) { + throw new Error("validatorMnemonics must be a non-empty array."); + } + + if (options.validatorMnemonics.length !== options.validators) { + throw new Error( + `validatorMnemonics length (${options.validatorMnemonics.length}) does not match the validators count (${options.validators}).`, + ); + } + + for (const [index, mnemonic] of options.validatorMnemonics.entries()) { + assertMnemonic(mnemonic, `validatorMnemonics[${index}]`); + } + + const unique = new Set(options.validatorMnemonics); + if (unique.size !== options.validatorMnemonics.length) { + throw new Error("validatorMnemonics contains duplicate entries."); + } + + if (options.genesisMnemonic !== undefined && unique.has(options.genesisMnemonic)) { + throw new Error("genesisMnemonic must not also be a validator mnemonic."); + } + } + } + #prepareEnvironmentOptions(options: Contracts.NetworkGenerator.InternalOptions): EnvironmentData { const data: EnvironmentData = { MAINSAIL_P2P_PORT: options.coreP2PPort, diff --git a/packages/contracts/source/contracts/network-generator.ts b/packages/contracts/source/contracts/network-generator.ts index 24d2f6b22..868840866 100644 --- a/packages/contracts/source/contracts/network-generator.ts +++ b/packages/contracts/source/contracts/network-generator.ts @@ -68,6 +68,12 @@ export type InternalOptions = EnvironmentOptions & configPath?: string; overwriteConfig: boolean; + // Externally supplied secrets. When provided they are used verbatim instead of + // generating random ones — required e.g. for a mainnet genesis built from + // pre-generated validator keys. Each must be a valid BIP39 mnemonic. + genesisMnemonic?: string; + validatorMnemonics?: string[]; + // Testing createLegacyColdWallets?: boolean; }; diff --git a/packages/core/source/commands/config-generate.test.ts b/packages/core/source/commands/config-generate.test.ts index a33c84304..7ef90eb9f 100644 --- a/packages/core/source/commands/config-generate.test.ts +++ b/packages/core/source/commands/config-generate.test.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from "fs"; -import { ensureDirSync, readJSONSync } from "fs-extra/esm"; +import { ensureDirSync, readJSONSync, writeJSONSync } from "fs-extra/esm"; import { join } from "path"; import prompts from "prompts"; import { dirSync, setGracefulCleanup } from "tmp"; @@ -63,6 +63,63 @@ describe<{ assert.length(readJSONSync(join(configPath, "devnet", "validators.json")).secrets, 3); }); + it("should generate genesis from an external secrets file", async ({ cli, configPath }) => { + const validatorMnemonics = [ + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "legal winner thank year wave sausage worth useful legal winner thank yellow", + "letter advice cage absurd amount doctor acoustic avoid letter advice cage above", + ]; + const genesisMnemonic = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"; + const secretsFile = join(dirSync().name, "secrets.json"); + writeJSONSync(secretsFile, { genesisMnemonic, validatorMnemonics }); + + // --validators is omitted, so the count is derived from the supplied list (3). + await cli + .withFlags(generateFlags(configPath, { force: true, secretsFile, validators: undefined })) + .execute(Command); + + // validators.json holds exactly the supplied secrets, and the genesis wallet uses the supplied mnemonic. + assert.equal(readJSONSync(join(configPath, "devnet", "validators.json")).secrets, validatorMnemonics); + assert.equal(readJSONSync(join(configPath, "devnet", "genesis-wallet.json")).passphrase, genesisMnemonic); + + // The genesis block was produced and the active round-validator set follows the supplied list. + assert.equal(readJSONSync(join(configPath, "devnet", "crypto.json")).milestones[1].roundValidators, 3); + }); + + it("should reject an explicit --validators that conflicts with the secrets file", async ({ cli, configPath }) => { + const secretsFile = join(dirSync().name, "secrets.json"); + writeJSONSync(secretsFile, { + validatorMnemonics: [ + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "legal winner thank year wave sausage worth useful legal winner thank yellow", + "letter advice cage absurd amount doctor acoustic avoid letter advice cage above", + ], + }); + + // Explicit --validators (9) conflicts with the 3 supplied mnemonics — rejected, not overridden. + await assert.rejects( + () => + cli + .withFlags(generateFlags(configPath, { force: true, secretsFile, validators: "9" })) + .execute(Command), + "validatorMnemonics length (3) does not match the validators count (9).", + ); + }); + + it("should reject an external secrets file with an invalid mnemonic", async ({ cli, configPath }) => { + const secretsFile = join(dirSync().name, "bad-secrets.json"); + writeJSONSync(secretsFile, { validatorMnemonics: ["not a valid mnemonic"] }); + + // --validators omitted so the count matches (1), letting the BIP39 check be the one to fire. + await assert.rejects( + () => + cli + .withFlags(generateFlags(configPath, { force: true, secretsFile, validators: undefined })) + .execute(Command), + "validatorMnemonics[0] is not a valid BIP39 mnemonic.", + ); + }); + it("should throw if the configuration destination already exists", async ({ cli, configPath }) => { ensureDirSync(join(configPath, "devnet")); diff --git a/packages/core/source/commands/config-generate.ts b/packages/core/source/commands/config-generate.ts index 3efcef052..816ecaba6 100644 --- a/packages/core/source/commands/config-generate.ts +++ b/packages/core/source/commands/config-generate.ts @@ -6,6 +6,8 @@ import { Identifiers as AppIdentifiers, Identifiers as CliIdentifiers } from "@m import { inject, injectable, postConstruct } from "@mainsail/container"; import { Contracts as AppContracts } from "@mainsail/contracts"; import envPaths from "env-paths"; +import { existsSync } from "fs"; +import { readJSONSync } from "fs-extra/esm"; import Joi from "joi"; import path from "path"; import prompts from "prompts"; @@ -21,6 +23,7 @@ type Flag = { type Flags = Omit & { peers: string; rewardAmount: number | string; + secretsFile?: string; }; @injectable() @@ -150,6 +153,14 @@ export class Command extends Commands.Command { default: "127.0.0.1", }, + // Externally supplied secrets + { + name: "secretsFile", + description: + 'Path to a JSON file with externally-generated secrets: { "genesisMnemonic"?: string, "validatorMnemonics": string[] }. A validators.json-style { "secrets": [...] } is also accepted. When provided, the validator count follows the supplied list.', + schema: Joi.string(), + }, + // General { name: "configPath", description: "Configuration path.", schema: Joi.string() }, { @@ -202,7 +213,7 @@ export class Command extends Commands.Command { if (flags.force || allFlagsSet) { return await configurationApp .get(Identifiers.ConfigurationGenerator) - .generate(this.#convertFlags(options)); + .generate(this.#convertFlags(options, flags.validators !== undefined)); } const response = await prompts([ @@ -256,7 +267,7 @@ export class Command extends Commands.Command { await configurationApp .get(Identifiers.ConfigurationGenerator) - .generate(this.#convertFlags(options)); + .generate(this.#convertFlags(options, true)); } finally { // The genesis generation boots the Rust EVM, whose native runtime keeps the // event loop alive until the instance is disposed — without this, the process @@ -267,9 +278,18 @@ export class Command extends Commands.Command { } } - #convertFlags(options: Flags): AppContracts.NetworkGenerator.Options { + #convertFlags(options: Flags, validatorsExplicit: boolean): AppContracts.NetworkGenerator.Options { + const secrets = this.#readSecrets(options); + + // When validator secrets come from a file and no explicit --validators was given, the + // count follows the file. An explicit, conflicting --validators is left intact so the + // generator rejects the mismatch rather than silently overriding it. + const validators = + secrets.validatorMnemonics && !validatorsExplicit ? secrets.validatorMnemonics.length : options.validators; + return { ...options, + ...secrets, // Trim each entry and drop empty ones: --peers="" means no peers, and // "a, b, c" must not keep leading spaces past the first entry. peers: options.peers @@ -277,9 +297,36 @@ export class Command extends Commands.Command { .map((peer) => peer.trim()) .filter((peer) => peer.length > 0), rewardAmount: options.rewardAmount.toString(), + validators, }; } + #readSecrets(options: Flags): { genesisMnemonic?: string; validatorMnemonics?: string[] } { + if (!options.secretsFile) { + return {}; + } + + const resolved = path.resolve(options.secretsFile); + if (!existsSync(resolved)) { + throw new Error(`Secrets file not found: ${resolved}`); + } + + const contents = readJSONSync(resolved); + const validatorMnemonics = contents.validatorMnemonics ?? contents.secrets; + const genesisMnemonic = contents.genesisMnemonic ?? contents.genesis; + + // Shape checks only; per-mnemonic BIP39 validation happens in the generator. + if (validatorMnemonics !== undefined && !Array.isArray(validatorMnemonics)) { + throw new Error(`Secrets file ${resolved}: "validatorMnemonics" must be an array of mnemonics.`); + } + + if (genesisMnemonic !== undefined && typeof genesisMnemonic !== "string") { + throw new Error(`Secrets file ${resolved}: "genesisMnemonic" must be a string.`); + } + + return { genesisMnemonic, validatorMnemonics }; + } + #getConfigurationPath(options: Flags, applicationName?: string): string { const paths = envPaths(options.token, { suffix: "core" }); const configPath = options.configPath ? options.configPath : paths.config;