Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
51 changes: 48 additions & 3 deletions packages/configuration-generator/source/configuration-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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)
Expand Down Expand Up @@ -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[] = [
{
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/source/contracts/network-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
59 changes: 58 additions & 1 deletion packages/core/source/commands/config-generate.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"));

Expand Down
53 changes: 50 additions & 3 deletions packages/core/source/commands/config-generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -21,6 +23,7 @@ type Flag = {
type Flags = Omit<AppContracts.NetworkGenerator.Options, "peers" | "rewardAmount"> & {
peers: string;
rewardAmount: number | string;
secretsFile?: string;
};

@injectable()
Expand Down Expand Up @@ -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() },
{
Expand Down Expand Up @@ -202,7 +213,7 @@ export class Command extends Commands.Command {
if (flags.force || allFlagsSet) {
return await configurationApp
.get<ConfigurationGenerator>(Identifiers.ConfigurationGenerator)
.generate(this.#convertFlags(options));
.generate(this.#convertFlags(options, flags.validators !== undefined));
}

const response = await prompts([
Expand Down Expand Up @@ -256,7 +267,7 @@ export class Command extends Commands.Command {

await configurationApp
.get<ConfigurationGenerator>(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
Expand All @@ -267,19 +278,55 @@ 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
.split(",")
.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;
Expand Down
Loading