From 81edd6af911fc4389754bf1c7a39e2e9b66a5b75 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:20:37 +0900 Subject: [PATCH 1/3] support secrets file --- .../source/configuration-generator.ts | 51 ++++++++++++++++- .../source/contracts/network-generator.ts | 6 ++ .../core/source/commands/config-generate.ts | 55 ++++++++++++++++++- 3 files changed, 106 insertions(+), 6 deletions(-) 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.ts b/packages/core/source/commands/config-generate.ts index 3efcef052..0ddf4945f 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,20 @@ 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 +299,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; From 8d15efd01e48870a0390174124198dea7db612da Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:20:47 +0900 Subject: [PATCH 2/3] add tests --- .../source/configuration-generator.test.ts | 92 +++++++++++++++++++ .../source/commands/config-generate.test.ts | 56 ++++++++++- 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/packages/configuration-generator/source/configuration-generator.test.ts b/packages/configuration-generator/source/configuration-generator.test.ts index 0b91f7572..4e3f1f438 100644 --- a/packages/configuration-generator/source/configuration-generator.test.ts +++ b/packages/configuration-generator/source/configuration-generator.test.ts @@ -209,6 +209,98 @@ 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/core/source/commands/config-generate.test.ts b/packages/core/source/commands/config-generate.test.ts index a33c84304..f4f316889 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,60 @@ 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")); From 3fa16d4c14a942ba5f485058ee953c031938235e Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Fri, 17 Jul 2026 05:30:41 +0000 Subject: [PATCH 3/3] style: resolve style guide violations [ci-lint-fix] --- .../source/configuration-generator.test.ts | 15 +++++++++------ .../core/source/commands/config-generate.test.ts | 5 ++++- packages/core/source/commands/config-generate.ts | 4 +--- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/configuration-generator/source/configuration-generator.test.ts b/packages/configuration-generator/source/configuration-generator.test.ts index 4e3f1f438..058447883 100644 --- a/packages/configuration-generator/source/configuration-generator.test.ts +++ b/packages/configuration-generator/source/configuration-generator.test.ts @@ -210,11 +210,9 @@ describe<{ }); // 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_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"; + 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, @@ -262,7 +260,8 @@ describe<{ 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 })), + () => + generator.generate(options({ validatorMnemonics: [MNEMONIC_A, "not a real mnemonic"], validators: 2 })), "validatorMnemonics[1] is not a valid BIP39 mnemonic.", ); @@ -295,7 +294,11 @@ describe<{ await assert.rejects( () => generator.generate( - options({ genesisMnemonic: MNEMONIC_A, validatorMnemonics: [MNEMONIC_A, MNEMONIC_B], validators: 2 }), + options({ + genesisMnemonic: MNEMONIC_A, + validatorMnemonics: [MNEMONIC_A, MNEMONIC_B], + validators: 2, + }), ), "genesisMnemonic must not also be a validator mnemonic.", ); diff --git a/packages/core/source/commands/config-generate.test.ts b/packages/core/source/commands/config-generate.test.ts index f4f316889..7ef90eb9f 100644 --- a/packages/core/source/commands/config-generate.test.ts +++ b/packages/core/source/commands/config-generate.test.ts @@ -98,7 +98,10 @@ describe<{ // 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), + () => + cli + .withFlags(generateFlags(configPath, { force: true, secretsFile, validators: "9" })) + .execute(Command), "validatorMnemonics length (3) does not match the validators count (9).", ); }); diff --git a/packages/core/source/commands/config-generate.ts b/packages/core/source/commands/config-generate.ts index 0ddf4945f..816ecaba6 100644 --- a/packages/core/source/commands/config-generate.ts +++ b/packages/core/source/commands/config-generate.ts @@ -285,9 +285,7 @@ export class Command extends Commands.Command { // 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; + secrets.validatorMnemonics && !validatorsExplicit ? secrets.validatorMnemonics.length : options.validators; return { ...options,