From 7b7e38deb46ca2a96c2c6c4bac0aec9170df4637 Mon Sep 17 00:00:00 2001 From: xt0x Date: Tue, 8 Sep 2026 17:35:17 +0900 Subject: [PATCH 1/7] test(cli): enhance tests for mnemonic and entropy commands with normalization and error handling --- tests/acceptance.spec.ts | 134 ++++++++++++++-- tests/cli/commands.spec.ts | 152 ++++++++++++++++-- tests/cli/integration.spec.ts | 84 +++++++++- tests/cli/run.spec.ts | 170 ++++++++++++++++++-- tests/mnemonic-to-entropy.spec.ts | 54 +++++++ tests/mnemonic-to-seed.spec.ts | 77 +++++++++ tests/tsconfig.json | 8 + tests/validation-result.spec.ts | 70 +++++++-- tests/wordlist-loaders.spec.ts | 251 ++++++++++++++++++++++++++++++ tests/wordlist.spec.ts | 114 ++++++++++++-- 10 files changed, 1055 insertions(+), 59 deletions(-) create mode 100644 tests/tsconfig.json create mode 100644 tests/wordlist-loaders.spec.ts diff --git a/tests/acceptance.spec.ts b/tests/acceptance.spec.ts index 7646705..cb142f8 100644 --- a/tests/acceptance.spec.ts +++ b/tests/acceptance.spec.ts @@ -4,22 +4,19 @@ import { resolve } from "node:path"; import { test } from "vitest"; import { + ChecksumMismatchError, EntropyLengthError, + ErrorCode, entropyToMnemonic, -} from "../src/bip39/entropyToMnemonic.ts"; -import { - ChecksumMismatchError, InvalidMnemonicFormatError, + InvalidMnemonicSeedFormatError, InvalidWordCountError, + MnemonicToEntropyError, mnemonicToEntropy, - WordNotInListError, -} from "../src/bip39/mnemonicToEntropy.ts"; -import { - InvalidMnemonicSeedFormatError, mnemonicToSeed, -} from "../src/bip39/mnemonicToSeed.ts"; -import { validateMnemonic } from "../src/bip39/validateMnemonic.ts"; -import { ErrorCode } from "../src/errors/errorCodes.ts"; + validateMnemonic, + WordNotInListError, +} from "../src/index.ts"; const hexToBytes = (hex: string): Uint8Array => Uint8Array.from(hex.match(/.{2}/g) ?? [], (byte) => @@ -62,6 +59,123 @@ test("roundtrip covers all allowed entropy lengths", () => { } }); +// Fixed zero-entropy cases for every ENT/CS/MS row in assets/bip-0039.mediawiki. +// The pinned official vectors do not include the 15- and 21-word lengths. +test.each([ + { bytes: 16, wordCount: 12, lastWord: "about" }, + { bytes: 20, wordCount: 15, lastWord: "address" }, + { bytes: 24, wordCount: 18, lastWord: "agent" }, + { bytes: 28, wordCount: 21, lastWord: "admit" }, + { bytes: 32, wordCount: 24, lastWord: "art" }, +])("public APIs support $wordCount words with string and array input", ({ + bytes, + wordCount, + lastWord, +}) => { + const entropy = new Uint8Array(bytes); + const words = [...Array(wordCount - 1).fill("abandon"), lastWord]; + const mnemonic = words.join(" "); + assert.equal(entropyToMnemonic(entropy), mnemonic); + for (const input of [mnemonic, words]) { + assert.deepEqual(mnemonicToEntropy(input), entropy); + assert.deepEqual(validateMnemonic(input), { + ok: true, + error_code: null, + normalized_mnemonic: mnemonic, + word_count: wordCount, + invalid_word: null, + }); + } + assert.equal(words.join(" "), mnemonic); + assert.deepEqual(entropy, new Uint8Array(bytes)); +}); + +const invalidMnemonicCases = [ + { + label: "format before word count and unknown words", + mnemonic: "TYPO abandon", + code: ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + ErrorType: InvalidMnemonicFormatError, + message: "Invalid mnemonic format", + normalized: null, + wordCount: null, + invalidWord: null, + }, + { + label: "word count before unknown words", + mnemonic: "typo abandon", + code: ErrorCode.ERR_INVALID_WORD_COUNT, + ErrorType: InvalidWordCountError, + message: "Invalid word count", + normalized: "typo abandon", + wordCount: 2, + invalidWord: null, + }, + { + label: "unknown word before checksum", + mnemonic: `${"abandon ".repeat(11)}typo`, + code: ErrorCode.ERR_WORD_NOT_IN_LIST, + ErrorType: WordNotInListError, + message: "Word not in list: typo", + normalized: `${"abandon ".repeat(11)}typo`, + wordCount: 12, + invalidWord: "typo", + }, + { + label: "first unknown word when several are present", + mnemonic: `typo ${"abandon ".repeat(10)}unknown`, + code: ErrorCode.ERR_WORD_NOT_IN_LIST, + ErrorType: WordNotInListError, + message: "Word not in list: typo", + normalized: `typo ${"abandon ".repeat(10)}unknown`, + wordCount: 12, + invalidWord: "typo", + }, + { + label: "checksum after valid format, word count and words", + mnemonic: Array(12).fill("abandon").join(" "), + code: ErrorCode.ERR_CHECKSUM_MISMATCH, + ErrorType: ChecksumMismatchError, + message: "Checksum mismatch", + normalized: Array(12).fill("abandon").join(" "), + wordCount: 12, + invalidWord: null, + }, +]; + +test.each( + invalidMnemonicCases, +)("public validation and decoding preserve $label", ({ + mnemonic, + code, + ErrorType, + message, + normalized, + wordCount, + invalidWord, +}) => { + for (const input of [mnemonic, mnemonic.split(" ")]) { + assert.deepEqual(validateMnemonic(input), { + ok: false, + error_code: code, + normalized_mnemonic: normalized, + word_count: wordCount, + invalid_word: invalidWord, + }); + assert.throws( + () => mnemonicToEntropy(input), + (error: unknown) => { + assert.ok(error instanceof ErrorType); + assert.ok(error instanceof MnemonicToEntropyError); + assert.equal(error.name, ErrorType.name); + assert.equal(error.code, code); + assert.equal(error.message, message); + return true; + }, + ); + } +}); + test("failure cases from appendix C are enforced", () => { assert.throws( () => entropyToMnemonic(new Uint8Array(15)), diff --git a/tests/cli/commands.spec.ts b/tests/cli/commands.spec.ts index 503ae30..2dc3915 100644 --- a/tests/cli/commands.spec.ts +++ b/tests/cli/commands.spec.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; +import { pbkdf2Sync } from "node:crypto"; import { test } from "vitest"; +import { EntropyLengthError } from "../../src/bip39/entropyToMnemonic.ts"; +import { InvalidMnemonicFormatError } from "../../src/bip39/mnemonicToEntropy.ts"; import { entropyToMnemonicCommand } from "../../src/cli/commands/entropyToMnemonic.ts"; import { generateEntropyCommand } from "../../src/cli/commands/generateEntropy.ts"; import { generateMnemonicCommand } from "../../src/cli/commands/generateMnemonic.ts"; @@ -8,6 +11,8 @@ import { generateMnemonicWithWordlistCommand } from "../../src/cli/commands/gene import { mnemonicToEntropyCommand } from "../../src/cli/commands/mnemonicToEntropy.ts"; import { mnemonicToSeedCommand } from "../../src/cli/commands/mnemonicToSeed.ts"; import { validateCommand } from "../../src/cli/commands/validate.ts"; +import { InvalidEntropyLengthError } from "../../src/entropy/entropyGenerator.ts"; +import { ErrorCode } from "../../src/errors/errorCodes.ts"; const ENTROPY_HEX = "00000000000000000000000000000000"; const MNEMONIC = @@ -40,26 +45,147 @@ test("mnemonicToSeedCommand matches vector", () => { }); test("validateCommand returns normalized mnemonic", () => { - const result = validateCommand(MNEMONIC, true); - assert.equal(result.ok, true); - if (!result.ok) return; - assert.equal(result.normalized, MNEMONIC); + assert.deepEqual(validateCommand(MNEMONIC, true), { + ok: true, + normalized: MNEMONIC, + }); }); -test("generateEntropyCommand returns requested length", () => { - const entropy = generateEntropyCommand(16); - assert.equal(entropy.length, 16); +test.each([ + 16, 20, 24, 28, 32, +])("generateEntropyCommand returns %i bytes", (bytes) => { + const entropy = generateEntropyCommand(bytes); + assert.ok(entropy instanceof Uint8Array); + assert.equal(entropy.length, bytes); }); -test("generateMnemonicCommand returns requested word count", () => { - const mnemonic = generateMnemonicCommand(12); - assert.equal(mnemonic.split(" ").length, 12); +test.each([ + 12, 15, 18, 21, 24, +])("generateMnemonicCommand returns a valid %i-word mnemonic", (words) => { + const mnemonic = generateMnemonicCommand(words); + assert.equal(mnemonic.split(" ").length, words); + assert.deepEqual(validateCommand(mnemonic, true), { + ok: true, + normalized: mnemonic, + }); }); -test("generateMnemonicWithWordlistCommand returns mnemonic and wordlist", () => { - const result = generateMnemonicWithWordlistCommand(12); - assert.equal(result.mnemonic.split(" ").length, 12); +test.each([ + 12, 15, 18, 21, 24, +])("generateMnemonicWithWordlistCommand returns %i valid words and the English wordlist", (words) => { + const result = generateMnemonicWithWordlistCommand(words); + assert.equal(result.mnemonic.split(" ").length, words); + assert.deepEqual(validateCommand(result.mnemonic, true), { + ok: true, + normalized: result.mnemonic, + }); assert.equal(result.wordlist.length, 2048); + assert.equal(new Set(result.wordlist).size, 2048); assert.equal(result.wordlist[0], "abandon"); assert.equal(result.wordlist[result.wordlist.length - 1], "zoo"); + for (const word of result.mnemonic.split(" ")) { + assert.ok(result.wordlist.includes(word)); + } +}); + +test.each([ + ["uppercase", MNEMONIC.toUpperCase()], + ["whitespace", ` \t${MNEMONIC.replaceAll(" ", " \t")}\r\n`], + ["compatibility characters", MNEMONIC.replaceAll("a", "a")], +])("mnemonic commands normalize %s unless strict", (_label, input) => { + assert.deepEqual(validateCommand(input, false), { + ok: true, + normalized: MNEMONIC, + }); + assert.equal(bytesToHex(mnemonicToEntropyCommand(input, false)), ENTROPY_HEX); + assert.equal( + bytesToHex(mnemonicToSeedCommand(input, false, "TREZOR")), + SEED_HEX, + ); + assert.deepEqual(validateCommand(input, true), { + ok: false, + errorCode: ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + }); + assert.throws( + () => mnemonicToEntropyCommand(input, true), + InvalidMnemonicFormatError, + ); + + // BIP39 seed derivation applies NFKD even when CLI cleanup is disabled. + const strictSeed = pbkdf2Sync( + input.normalize("NFKD"), + "mnemonicTREZOR", + 2048, + 64, + "sha512", + ); + assert.equal( + bytesToHex(mnemonicToSeedCommand(input, true, "TREZOR")), + strictSeed.toString("hex"), + ); +}); + +test.each([ + ["", ErrorCode.ERR_INVALID_MNEMONIC_FORMAT], + ["abandon", ErrorCode.ERR_INVALID_WORD_COUNT], + [MNEMONIC.replace("about", "unknownword"), ErrorCode.ERR_WORD_NOT_IN_LIST], + [MNEMONIC.replace("about", "abandon"), ErrorCode.ERR_CHECKSUM_MISMATCH], +])("validateCommand reports the error for %s", (input, errorCode) => { + assert.deepEqual(validateCommand(input, true), { ok: false, errorCode }); +}); + +test.each([ + false, + true, +])("mnemonicToSeedCommand preserves passphrase case and spaces with strict=%s", (strict) => { + const passphrase = " TréZoR \t"; + const expected = pbkdf2Sync( + MNEMONIC, + `mnemonic${passphrase.normalize("NFKD")}`, + 2048, + 64, + "sha512", + ); + assert.equal( + bytesToHex(mnemonicToSeedCommand(MNEMONIC, strict, passphrase)), + expected.toString("hex"), + ); +}); + +test.each([ + "", + "not a bip39 sentence", + MNEMONIC.replace("about", "abandon"), +])("mnemonicToSeedCommand derives a seed without mnemonic validation: %s", (input) => { + const expected = pbkdf2Sync(input, "mnemonic", 2048, 64, "sha512"); + for (const strict of [false, true]) { + assert.equal( + bytesToHex(mnemonicToSeedCommand(input, strict, "")), + expected.toString("hex"), + ); + } +}); + +test("entropy commands preserve invalid-length exceptions", () => { + assert.throws(() => entropyToMnemonicCommand(new Uint8Array(17)), { + constructor: EntropyLengthError, + name: "EntropyLengthError", + code: ErrorCode.ERR_ENTROPY_LENGTH, + message: "Entropy must be 16/20/24/28/32 bytes", + }); + assert.throws(() => generateEntropyCommand(17), { + constructor: InvalidEntropyLengthError, + name: "InvalidEntropyLengthError", + message: "Entropy must be 16/20/24/28/32 bytes", + }); +}); + +test.each([ + generateMnemonicCommand, + generateMnemonicWithWordlistCommand, +])("%s preserves the unsupported-word-count exception", (generate) => { + assert.throws(() => generate(13), { + constructor: Error, + message: "Unsupported word count: 13", + }); }); diff --git a/tests/cli/integration.spec.ts b/tests/cli/integration.spec.ts index 7f80acf..d13ac32 100644 --- a/tests/cli/integration.spec.ts +++ b/tests/cli/integration.spec.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import { pbkdf2Sync } from "node:crypto"; import { test } from "vitest"; const MNEMONIC = @@ -13,5 +14,86 @@ test("cli validate runs from the TypeScript entrypoint", () => { ); assert.equal(result.status, 0); - assert.match(result.stdout, /^valid\nnormalized: /u); + assert.equal(result.error, undefined); + assert.equal(result.stdout, `valid\nnormalized: ${MNEMONIC}\n`); + assert.equal(result.stderr, ""); +}); + +test.each([ + { + command: "validate", + output: `valid\nnormalized: ${MNEMONIC}\n`, + }, + { + command: "mnemonic-to-entropy", + output: "00000000000000000000000000000000\n", + }, +])("cli $command normalizes piped stdin by default", ({ command, output }) => { + const result = spawnSync( + process.execPath, + ["--import", "tsx", "src/cli/index.ts", command], + { encoding: "utf8", input: `\t${MNEMONIC.toUpperCase()}\r\n` }, + ); + + assert.equal(result.error, undefined); + assert.equal(result.status, 0); + assert.equal(result.stdout, output); + assert.equal(result.stderr, ""); +}); + +test.each([ + "validate", + "mnemonic-to-entropy", +])("cli %s rejects the piped newline in strict mode", (command) => { + const result = spawnSync( + process.execPath, + ["--import", "tsx", "src/cli/index.ts", command, "--strict"], + { encoding: "utf8", input: `${MNEMONIC}\n` }, + ); + + assert.equal(result.error, undefined); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.equal( + result.stderr, + "error_code: ERR_INVALID_MNEMONIC_FORMAT\nmessage: Mnemonic format is invalid.\n", + ); +}); + +test.each([ + false, + true, +])("cli mnemonic-to-seed derives from piped stdin with strict=%s", (strict) => { + const input = `\t${MNEMONIC.toUpperCase()}\n`; + const args = ["--import", "tsx", "src/cli/index.ts", "mnemonic-to-seed"]; + if (strict) args.push("--strict"); + const result = spawnSync(process.execPath, args, { + encoding: "utf8", + input, + }); + const expectedSeed = pbkdf2Sync( + strict ? input : MNEMONIC, + "mnemonic", + 2048, + 64, + "sha512", + ).toString("hex"); + + assert.equal(result.error, undefined); + assert.equal(result.status, 0); + assert.equal(result.stdout, `${expectedSeed}\n`); + assert.equal(result.stderr, ""); +}); + +test("cli entropy-to-mnemonic accepts a newline-terminated hex stream", () => { + const result = spawnSync( + process.execPath, + ["--import", "tsx", "src/cli/index.ts", "entropy-to-mnemonic"], + { encoding: "utf8", input: "00000000000000000000000000000000\n" }, + ); + + assert.equal(result.error, undefined); + assert.equal(result.status, 0); + assert.equal(result.stdout, `${MNEMONIC}\n`); + assert.equal(result.stderr, ""); }); diff --git a/tests/cli/run.spec.ts b/tests/cli/run.spec.ts index 6b69252..1e78d58 100644 --- a/tests/cli/run.spec.ts +++ b/tests/cli/run.spec.ts @@ -10,6 +10,24 @@ const MNEMONIC = const SEED_HEX = "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553" + "1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04"; +const USAGE = `Usage: bip39 [options] [input] + +Commands: + validate [MNEMONIC] [--strict] + entropy-to-mnemonic [HEX] + mnemonic-to-entropy [MNEMONIC] [--strict] + mnemonic-to-seed [MNEMONIC] [--strict] [--passphrase ] + generate-entropy [--bytes <16|20|24|28|32>] + generate-mnemonic [--words <12|15|18|21|24>] + generate-mnemonic-with-wordlist [--words <12|15|18|21|24>] + +Options: + --help Show help + --strict Disable input normalization + --passphrase Passphrase for mnemonic-to-seed + --bytes Entropy bytes for generate-entropy + --words Word count for generate-mnemonic +`; const createIo = (stdin: string | null = null) => { const stdout: string[] = []; @@ -27,18 +45,19 @@ const createIo = (stdin: string | null = null) => { }; test("runCli validate succeeds with args", async () => { - const { io, stdout } = createIo(); + const { io, stdout, stderr } = createIo(); const exitCode = await runCli(["validate", MNEMONIC], io); assert.equal(exitCode, 0); - const output = stdout.join(""); - assert.match(output, /^valid\nnormalized: /u); + assert.equal(stdout.join(""), `valid\nnormalized: ${MNEMONIC}\n`); + assert.equal(stderr.join(""), ""); }); test("runCli validate reads from stdin", async () => { - const { io, stdout } = createIo(MNEMONIC); + const { io, stdout, stderr } = createIo(`\t${MNEMONIC.toUpperCase()}\n`); const exitCode = await runCli(["validate"], io); assert.equal(exitCode, 0); - assert.match(stdout.join(""), /^valid\nnormalized: /u); + assert.equal(stdout.join(""), `valid\nnormalized: ${MNEMONIC}\n`); + assert.equal(stderr.join(""), ""); }); test("runCli entropy-to-mnemonic outputs mnemonic", async () => { @@ -48,11 +67,15 @@ test("runCli entropy-to-mnemonic outputs mnemonic", async () => { assert.equal(stdout.join("").trim(), MNEMONIC); }); -test("runCli entropy-to-mnemonic rejects invalid hex", async () => { - const { io, stderr } = createIo(); - const exitCode = await runCli(["entropy-to-mnemonic", "0"], io); - assert.equal(exitCode, 2); - assert.match(stderr.join(""), /Invalid hex/u); +test.each([ + ["0", "Hex input must have even length"], + ["zz", "Hex input contains non-hex characters"], + [" \n", "Hex input is empty"], +])("runCli rejects invalid hex %j with usage exit code", async (input, message) => { + const { io, stdout, stderr } = createIo(); + assert.equal(await runCli(["entropy-to-mnemonic", input], io), 2); + assert.equal(stdout.join(""), ""); + assert.equal(stderr.join(""), `Invalid hex: ${message}\n`); }); test("runCli mnemonic-to-entropy outputs hex", async () => { @@ -72,6 +95,133 @@ test("runCli mnemonic-to-seed outputs seed hex", async () => { assert.equal(stdout.join("").trim(), SEED_HEX); }); +test.each([ + { + argv: ["validate", ...MNEMONIC.split(" ")], + output: `valid\nnormalized: ${MNEMONIC}\n`, + }, + { + argv: ["entropy-to-mnemonic", ENTROPY_HEX], + output: `${MNEMONIC}\n`, + }, + { + argv: ["mnemonic-to-entropy", ...MNEMONIC.split(" ")], + output: `${ENTROPY_HEX}\n`, + }, + { + argv: [ + "mnemonic-to-seed", + ...MNEMONIC.split(" "), + "--passphrase", + "TREZOR", + ], + output: `${SEED_HEX}\n`, + }, +])("runCli gives arguments precedence over stdin: $argv.0", async ({ + argv, + output, +}) => { + const { io, stdout, stderr } = createIo(); + io.readStdin = async () => { + assert.fail("stdin must not be read when an argument was supplied"); + }; + assert.equal(await runCli(argv, io), 0); + assert.equal(stdout.join(""), output); + assert.equal(stderr.join(""), ""); +}); + +test.each([ + { + argv: ["entropy-to-mnemonic", "00"], + code: "ERR_ENTROPY_LENGTH", + message: "Entropy length must be 16/20/24/28/32 bytes.", + }, + ...(["validate", "mnemonic-to-entropy"] as const).flatMap((command) => [ + { + argv: [command, `${MNEMONIC}\n`, "--strict"], + code: "ERR_INVALID_MNEMONIC_FORMAT", + message: "Mnemonic format is invalid.", + }, + { + argv: [command, "abandon"], + code: "ERR_INVALID_WORD_COUNT", + message: "Mnemonic word count must be 12/15/18/21/24.", + }, + { + argv: [command, MNEMONIC.replace("about", "unknownword")], + code: "ERR_WORD_NOT_IN_LIST", + message: "Mnemonic contains an unknown word.", + }, + { + argv: [command, MNEMONIC.replace("about", "abandon")], + code: "ERR_CHECKSUM_MISMATCH", + message: "Mnemonic checksum does not match.", + }, + ]), +])("runCli reports $code for $argv.0", async ({ argv, code, message }) => { + const { io, stdout, stderr } = createIo(); + assert.equal(await runCli(argv, io), 1); + assert.equal(stdout.join(""), ""); + assert.equal(stderr.join(""), `error_code: ${code}\nmessage: ${message}\n`); +}); + +test.each([ + { argv: [], message: "Missing command" }, + { argv: ["unknown"], message: "Unknown command: unknown" }, + { argv: ["validate", "--nope"], message: "Unknown option: --nope" }, + { + argv: ["mnemonic-to-seed", "--passphrase"], + message: "Missing value for --passphrase", + }, + { + argv: ["generate-entropy", "--bytes", "17"], + message: "Invalid --bytes value", + }, + { + argv: ["generate-mnemonic", "--words", "13"], + message: "Invalid --words value", + }, + { + argv: ["generate-mnemonic-with-wordlist", "--words", "13"], + message: "Invalid --words value", + }, + { + argv: ["entropy-to-mnemonic", ENTROPY_HEX, ENTROPY_HEX], + message: "Too many arguments", + }, + ...[ + "validate", + "entropy-to-mnemonic", + "mnemonic-to-entropy", + "mnemonic-to-seed", + ].map((command) => ({ argv: [command], message: "Missing input" })), +])("runCli reports usage error $message for $argv.0", async ({ + argv, + message, +}) => { + const { io, stdout, stderr } = createIo(); + assert.equal(await runCli(argv, io), 2); + assert.equal(stdout.join(""), ""); + assert.equal(stderr.join(""), `${message}\n${USAGE}`); +}); + +test("runCli prints help to stdout and succeeds", async () => { + const { io, stdout, stderr } = createIo(); + assert.equal(await runCli(["--help"], io), 0); + assert.equal(stdout.join(""), USAGE); + assert.equal(stderr.join(""), ""); +}); + +test("runCli reports unexpected I/O errors with exit code 3", async () => { + const { io, stdout, stderr } = createIo(); + io.readStdin = async () => { + throw new Error("stdin unavailable"); + }; + assert.equal(await runCli(["validate"], io), 3); + assert.equal(stdout.join(""), ""); + assert.equal(stderr.join(""), "Unexpected error: stdin unavailable\n"); +}); + test("runCli generate-entropy outputs hex of default length", async () => { const { io, stdout } = createIo(); const exitCode = await runCli(["generate-entropy"], io); diff --git a/tests/mnemonic-to-entropy.spec.ts b/tests/mnemonic-to-entropy.spec.ts index 0a7569f..8b3f0b1 100644 --- a/tests/mnemonic-to-entropy.spec.ts +++ b/tests/mnemonic-to-entropy.spec.ts @@ -75,3 +75,57 @@ test("mnemonicToEntropy error types share base class", () => { const error = new InvalidMnemonicFormatError(); assert.ok(error instanceof MnemonicToEntropyError); }); + +test.each([ + { + ErrorType: InvalidMnemonicFormatError, + name: "InvalidMnemonicFormatError", + code: ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + message: "Invalid mnemonic format", + }, + { + ErrorType: InvalidWordCountError, + name: "InvalidWordCountError", + code: ErrorCode.ERR_INVALID_WORD_COUNT, + message: "Invalid word count", + }, + { + ErrorType: WordNotInListError, + name: "WordNotInListError", + code: ErrorCode.ERR_WORD_NOT_IN_LIST, + message: "Word not in list", + }, + { + ErrorType: ChecksumMismatchError, + name: "ChecksumMismatchError", + code: ErrorCode.ERR_CHECKSUM_MISMATCH, + message: "Checksum mismatch", + }, +])("$name preserves its public identity and optional message", ({ + ErrorType, + name, + code, + message, +}) => { + for (const [error, expectedMessage] of [ + [new ErrorType(), message], + [new ErrorType("custom message"), "custom message"], + ] as const) { + assert.ok(error instanceof Error); + assert.ok(error instanceof MnemonicToEntropyError); + assert.equal(error.name, name); + assert.equal(error.code, code); + assert.equal(error.message, expectedMessage); + } +}); + +test("MnemonicToEntropyError preserves its supplied code and message", () => { + const error = new MnemonicToEntropyError( + ErrorCode.ERR_INVALID_WORD_COUNT, + "custom message", + ); + assert.ok(error instanceof Error); + assert.equal(error.name, "MnemonicToEntropyError"); + assert.equal(error.code, ErrorCode.ERR_INVALID_WORD_COUNT); + assert.equal(error.message, "custom message"); +}); diff --git a/tests/mnemonic-to-seed.spec.ts b/tests/mnemonic-to-seed.spec.ts index 771f168..b4f2e18 100644 --- a/tests/mnemonic-to-seed.spec.ts +++ b/tests/mnemonic-to-seed.spec.ts @@ -56,6 +56,7 @@ test("mnemonicToSeed matches official vectors with TREZOR", async () => { for (const [, mnemonic, seed] of payload.english) { const derived = mnemonicToSeed(mnemonic, "TREZOR"); assert.equal(toHex(derived), seed); + assert.equal(toHex(mnemonicToSeed(mnemonic.split(" "), "TREZOR")), seed); } }); @@ -63,4 +64,80 @@ test("mnemonicToSeed matches pbkdf2 output with empty passphrase", () => { const expected = deriveWithNode(validMnemonic, ""); const derived = mnemonicToSeed(validMnemonic, ""); assert.equal(toHex(derived), expected); + assert.equal(toHex(mnemonicToSeed(validMnemonic)), expected); +}); + +// BIP39 "From mnemonic to seed" specifies NFKD for both password and salt. +test("mnemonicToSeed normalizes Unicode mnemonic and passphrase to NFKD", () => { + const mnemonic = "caf\u00e9 \u2460"; + const passphrase = "\u212b"; + const expected = deriveWithNode("cafe\u0301 1", "A\u030a"); + assert.equal(toHex(mnemonicToSeed(mnemonic, passphrase)), expected); + assert.equal( + toHex(mnemonicToSeed(mnemonic.split(" "), passphrase)), + expected, + ); +}); + +// Seed derivation is independent of wordlist membership and checksum validation. +test.each([ + { label: "nonstandard word count", mnemonic: "abandon about" }, + { label: "unknown word", mnemonic: validMnemonic.replace("about", "typo") }, + { + label: "invalid checksum", + mnemonic: validMnemonic.replace("about", "abandon"), + }, +])("mnemonicToSeed accepts $label", ({ mnemonic }) => { + assert.equal( + toHex(mnemonicToSeed(mnemonic, "TREZOR")), + deriveWithNode(mnemonic, "TREZOR"), + ); +}); + +test.each([ + { label: "uppercase", mnemonic: validMnemonic.toUpperCase() }, + { label: "outer spaces", mnemonic: ` ${validMnemonic} ` }, + { + label: "repeated spaces", + mnemonic: validMnemonic.replace(" ", " "), + }, +])("mnemonicToSeed preserves $label in string input", ({ mnemonic }) => { + const seed = toHex(mnemonicToSeed(mnemonic)); + assert.equal(seed, deriveWithNode(mnemonic, "")); + assert.notEqual(seed, deriveWithNode(validMnemonic, "")); +}); + +test.each([ + { label: "empty array", input: [] }, + { label: "null", input: null }, + { label: "object", input: {} }, + { label: "number", input: 123 }, + { label: "tab in array word", input: ["abandon\tabout"] }, +])("mnemonicToSeed rejects $label with its format error", ({ input }) => { + assert.throws( + () => mnemonicToSeed(input as unknown as string[]), + (error: unknown) => { + assert.ok(error instanceof InvalidMnemonicSeedFormatError); + assert.equal(error.name, "InvalidMnemonicSeedFormatError"); + assert.equal(error.code, ErrorCode.ERR_INVALID_MNEMONIC_FORMAT); + assert.equal(error.message, "Invalid mnemonic format"); + return true; + }, + ); +}); + +test.each([ + { label: "null", passphrase: null }, + { label: "number", passphrase: 123 }, + { label: "array", passphrase: ["TREZOR"] }, +])("mnemonicToSeed rejects a $label passphrase", ({ passphrase }) => { + assert.throws( + () => mnemonicToSeed(validMnemonic, passphrase as unknown as string), + (error: unknown) => { + assert.ok(error instanceof InvalidMnemonicSeedFormatError); + assert.equal(error.code, ErrorCode.ERR_INVALID_MNEMONIC_FORMAT); + assert.equal(error.message, "Invalid mnemonic format"); + return true; + }, + ); }); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..ea3c626 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true + }, + "include": ["./**/*.ts", "../src/**/*.ts"] +} diff --git a/tests/validation-result.spec.ts b/tests/validation-result.spec.ts index bd88bbf..ff19115 100644 --- a/tests/validation-result.spec.ts +++ b/tests/validation-result.spec.ts @@ -1,19 +1,69 @@ import assert from "node:assert/strict"; -import { test } from "vitest"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { expectTypeOf, test } from "vitest"; -import { ErrorCode } from "../src/errors/errorCodes.ts"; -import type { ValidationResult } from "../src/types/validationResult.ts"; +import { + ErrorCode, + entropyToMnemonic, + loadEnglishWordlist, + mnemonicToEntropy, + mnemonicToSeed, + type ValidationResult, + validateMnemonic, + type Wordlist, +} from "../src/index.ts"; test("ValidationResult shape is stable", () => { - const sample: ValidationResult = { + const result: ValidationResult = validateMnemonic("abandon about"); + assert.deepEqual(result, { ok: false, error_code: ErrorCode.ERR_INVALID_WORD_COUNT, - normalized_mnemonic: null, - word_count: 11, + normalized_mnemonic: "abandon about", + word_count: 2, invalid_word: null, - }; + }); +}); - assert.equal(sample.ok, false); - assert.equal(sample.error_code, "ERR_INVALID_WORD_COUNT"); - assert.equal(sample.word_count, 11); +// Vitest transpiles type assertions; the compiler test below checks them too. +test("public validation and conversion types remain compatible", () => { + expectTypeOf().toEqualTypeOf<{ + ok: boolean; + error_code: ErrorCode | null; + normalized_mnemonic: string | null; + word_count: number | null; + invalid_word: string | null; + }>(); + expectTypeOf(validateMnemonic).toEqualTypeOf< + (input: string | string[]) => ValidationResult + >(); + expectTypeOf(entropyToMnemonic).toEqualTypeOf< + (entropy: Uint8Array) => string + >(); + expectTypeOf(mnemonicToEntropy).toEqualTypeOf< + (input: string | string[]) => Uint8Array + >(); + expectTypeOf(mnemonicToSeed).toEqualTypeOf< + (input: string | string[], passphrase?: string) => Uint8Array + >(); + expectTypeOf(loadEnglishWordlist).toEqualTypeOf<() => Promise>(); + expectTypeOf().toEqualTypeOf<{ + words: string[]; + wordToIndex: Map; + }>(); }); + +test("TypeScript checks the test suite and public API type contracts", () => { + const result = spawnSync( + process.execPath, + [ + createRequire(import.meta.url).resolve("typescript/bin/tsc"), + "-p", + fileURLToPath(new URL("./tsconfig.json", import.meta.url)), + ], + { encoding: "utf8", timeout: 20_000 }, + ); + assert.equal(result.error, undefined); + assert.equal(result.status, 0, `${result.stdout}${result.stderr}`); +}, 30_000); diff --git a/tests/wordlist-loaders.spec.ts b/tests/wordlist-loaders.spec.ts new file mode 100644 index 0000000..b75a535 --- /dev/null +++ b/tests/wordlist-loaders.spec.ts @@ -0,0 +1,251 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, test, vi } from "vitest"; + +const fileReads = vi.hoisted(() => ({ + readFile: vi.fn(), + readFileSync: vi.fn(), +})); + +vi.mock("node:fs/promises", async (importOriginal) => ({ + ...(await importOriginal()), + readFile: fileReads.readFile, +})); + +vi.mock("node:fs", async (importOriginal) => ({ + ...(await importOriginal()), + readFileSync: fileReads.readFileSync, +})); + +const { readFileSync } = + await vi.importActual("node:fs"); +const englishText = readFileSync( + new URL("../assets/english.txt", import.meta.url), + "utf8", +); +const words = Array.from({ length: 2048 }, (_, index) => `word${index}`); +const wordsWith = (replacements: Record): string[] => + words.map((word, index) => replacements[index] ?? word); +const mnemonic = `${"abandon ".repeat(11)}about`; + +beforeEach(() => { + vi.resetModules(); + fileReads.readFile.mockReset().mockResolvedValue(englishText); + fileReads.readFileSync.mockReset().mockReturnValue(englishText); +}); + +afterEach(() => { + vi.resetModules(); + vi.clearAllMocks(); +}); + +// BIP39 uses an ordered, 2048-entry dictionary. These parsing and error +// contracts characterize the two existing loaders before their consolidation. +test.each([ + { name: "LF", separator: "\n", trailingNewline: false }, + { name: "LF with final newline", separator: "\n", trailingNewline: true }, + { name: "CRLF", separator: "\r\n", trailingNewline: false }, + { name: "CRLF with final newline", separator: "\r\n", trailingNewline: true }, +])("both English loaders accept $name and preserve indices", async (input) => { + const text = + words.join(input.separator) + + (input.trailingNewline ? input.separator : ""); + fileReads.readFile.mockResolvedValue(text); + fileReads.readFileSync.mockReturnValue(text); + const { loadEnglishWordlist: loadAsync } = await import( + "../src/wordlist/wordlist.ts" + ); + const { loadEnglishWordlist: loadSync } = await import( + "../src/bip39/englishWordlist.ts" + ); + for (const list of [await loadAsync(), loadSync()]) { + assert.deepEqual(list.words, words); + assert.deepEqual( + [...list.wordToIndex], + words.map((word, i) => [word, i]), + ); + } +}); + +test.each([ + { + name: "too few words", + lines: words.slice(1), + asyncMessage: "Wordlist must contain 2048 words, got 2047", + syncMessage: "Wordlist must contain 2048 words, got 2047", + }, + { + name: "too many words", + lines: [...words, "extra"], + asyncMessage: "Wordlist must contain 2048 words, got 2049", + syncMessage: "Wordlist must contain 2048 words, got 2049", + }, + { + name: "an empty word", + lines: wordsWith({ 100: "" }), + asyncMessage: "Wordlist contains empty lines", + syncMessage: "Wordlist contains an empty word", + }, + { + name: "a leading empty line with an incorrect count", + lines: ["", ...words], + asyncMessage: "Wordlist contains empty lines", + syncMessage: "Wordlist must contain 2048 words, got 2049", + }, + { + name: "two final newlines", + lines: [...words, "", ""], + asyncMessage: "Wordlist contains empty lines", + syncMessage: "Wordlist must contain 2048 words, got 2049", + }, + { + name: "duplicate words", + lines: wordsWith({ 1: "word0" }), + asyncMessage: "Duplicate word detected: word0", + syncMessage: "Duplicate word detected: word0", + }, + { + name: "a duplicate with an incorrect count", + lines: [...words, "word0"], + asyncMessage: "Wordlist must contain 2048 words, got 2049", + syncMessage: "Wordlist must contain 2048 words, got 2049", + }, + { + name: "a duplicate before an empty word", + lines: wordsWith({ 1: "word0", 100: "" }), + asyncMessage: "Wordlist contains empty lines", + syncMessage: "Duplicate word detected: word0", + }, + { + name: "an empty word before a duplicate", + lines: wordsWith({ 1: "", 100: "word0" }), + asyncMessage: "Wordlist contains empty lines", + syncMessage: "Wordlist contains an empty word", + }, +])("English loaders preserve error precedence for $name", async (input) => { + fileReads.readFile.mockResolvedValue(input.lines.join("\n")); + fileReads.readFileSync.mockReturnValue(input.lines.join("\n")); + const { loadEnglishWordlist: loadAsync } = await import( + "../src/wordlist/wordlist.ts" + ); + const { loadEnglishWordlist: loadSync } = await import( + "../src/bip39/englishWordlist.ts" + ); + await assert.rejects(loadAsync, { + name: "Error", + message: input.asyncMessage, + }); + assert.throws(loadSync, { name: "Error", message: input.syncMessage }); +}); + +test("English loaders reuse a successfully loaded dictionary", async () => { + const { loadEnglishWordlist: loadAsync } = await import( + "../src/wordlist/wordlist.ts" + ); + const { loadEnglishWordlist: loadSync } = await import( + "../src/bip39/englishWordlist.ts" + ); + const asyncList = await loadAsync(); + const syncList = loadSync(); + fileReads.readFile.mockRejectedValue(new Error("Wordlist unavailable")); + fileReads.readFileSync.mockImplementation(() => { + throw new Error("Wordlist unavailable"); + }); + assert.strictEqual(await loadAsync(), asyncList); + assert.strictEqual(loadSync(), syncList); +}); + +test("English loaders retry after a failed file read", async () => { + const failure = new Error("Wordlist unavailable"); + fileReads.readFile.mockRejectedValueOnce(failure); + fileReads.readFileSync.mockImplementationOnce(() => { + throw failure; + }); + const { loadEnglishWordlist: loadAsync } = await import( + "../src/wordlist/wordlist.ts" + ); + const { loadEnglishWordlist: loadSync } = await import( + "../src/bip39/englishWordlist.ts" + ); + await assert.rejects(loadAsync, failure); + assert.throws(loadSync, failure); + assert.equal((await loadAsync()).words[0], "abandon"); + assert.equal(loadSync().words[0], "abandon"); +}); + +test("English loaders retry after malformed wordlist contents", async () => { + fileReads.readFile.mockResolvedValueOnce("incomplete\n"); + fileReads.readFileSync.mockReturnValueOnce("incomplete\n"); + const { loadEnglishWordlist: loadAsync } = await import( + "../src/wordlist/wordlist.ts" + ); + const { loadEnglishWordlist: loadSync } = await import( + "../src/bip39/englishWordlist.ts" + ); + const error = { + name: "Error", + message: "Wordlist must contain 2048 words, got 1", + }; + await assert.rejects(loadAsync, error); + assert.throws(loadSync, error); + assert.equal((await loadAsync()).words[2047], "zoo"); + assert.equal(loadSync().words[2047], "zoo"); +}); + +test("mutating the public dictionary does not affect core BIP39 operations", async () => { + const { loadEnglishWordlist } = await import("../src/wordlist/wordlist.ts"); + const { entropyToMnemonic } = await import( + "../src/bip39/entropyToMnemonic.ts" + ); + const { validateMnemonic } = await import("../src/bip39/validateMnemonic.ts"); + const { mnemonicToEntropy } = await import( + "../src/bip39/mnemonicToEntropy.ts" + ); + const list = await loadEnglishWordlist(); + const originalWords = [...list.words]; + const originalIndices = new Map(list.wordToIndex); + try { + list.words.fill("changed"); + list.wordToIndex.clear(); + assert.equal(entropyToMnemonic(new Uint8Array(16)), mnemonic); + assert.equal(validateMnemonic(mnemonic).ok, true); + assert.deepEqual(mnemonicToEntropy(mnemonic), new Uint8Array(16)); + } finally { + list.words.splice(0, list.words.length, ...originalWords); + for (const [word, index] of originalIndices) { + list.wordToIndex.set(word, index); + } + } +}); + +test.each([ + { + name: "invalid format", + input: "abandon unknown", + code: "ERR_INVALID_MNEMONIC_FORMAT", + errorName: "InvalidMnemonicFormatError", + message: "Invalid mnemonic format", + }, + { + name: "invalid word count", + input: "unknown", + code: "ERR_INVALID_WORD_COUNT", + errorName: "InvalidWordCountError", + message: "Invalid word count", + }, +])("$name is rejected without loading the wordlist", async (input) => { + fileReads.readFileSync.mockImplementation(() => { + throw new Error("Wordlist unavailable"); + }); + const { validateMnemonic } = await import("../src/bip39/validateMnemonic.ts"); + const { mnemonicToEntropy } = await import( + "../src/bip39/mnemonicToEntropy.ts" + ); + assert.equal(validateMnemonic(input.input).error_code, input.code); + assert.throws(() => mnemonicToEntropy(input.input), { + name: input.errorName, + code: input.code, + message: input.message, + }); + assert.equal(fileReads.readFile.mock.calls.length, 0); + assert.equal(fileReads.readFileSync.mock.calls.length, 0); +}); diff --git a/tests/wordlist.spec.ts b/tests/wordlist.spec.ts index 3ae97bc..1f86b70 100644 --- a/tests/wordlist.spec.ts +++ b/tests/wordlist.spec.ts @@ -12,10 +12,7 @@ import { const makeWords = (count: number): string[] => Array.from({ length: count }, (_, i) => `word${i}`); -const makeText = (words: string[], withTrailingNewline = false): string => { - const text = words.join("\n"); - return withTrailingNewline ? `${text}\n` : text; -}; +const makeText = (words: string[]): string => words.join("\n"); test("createWordlist accepts 2048 unique words", () => { const words = makeWords(2048); @@ -26,22 +23,82 @@ test("createWordlist accepts 2048 unique words", () => { assert.equal(list.words[2047], "word2047"); }); -test("createWordlist rejects incorrect length", () => { - const words = makeWords(2047); - assert.throws(() => createWordlist(words)); +test.each([ + 0, 2047, 2049, +])("createWordlist rejects %i words before checking their contents", (count) => { + const words = Array.from({ length: count }, () => ""); + assert.throws(() => createWordlist(words), { + name: "Error", + message: `Wordlist must contain 2048 words, got ${count}`, + }); }); test("createWordlist rejects duplicate words", () => { const words = makeWords(2048); words[2047] = "word0"; - assert.throws(() => createWordlist(words)); + assert.throws(() => createWordlist(words), { + name: "Error", + message: "Duplicate word detected: word0", + }); }); -test("parseWordlist parses text and preserves order", () => { +test("createWordlist rejects an empty word", () => { const words = makeWords(2048); - const list = parseWordlist(makeText(words, true)); - assert.equal(list.words[0], "word0"); - assert.equal(list.words[2047], "word2047"); + words[100] = ""; + assert.throws(() => createWordlist(words), { + name: "Error", + message: "Wordlist contains an empty word", + }); +}); + +test("createWordlist copies its input array", () => { + const words = makeWords(2048); + const list = createWordlist(words); + words[0] = "changed"; + words.pop(); + assert.equal(list.words.length, 2048); + assert.equal(indexToWord(list, 0), "word0"); + assert.equal(wordToIndex(list, "word0"), 0); +}); + +test.each([ + { name: "LF", separator: "\n", trailingNewline: false }, + { name: "LF with final newline", separator: "\n", trailingNewline: true }, + { name: "CRLF", separator: "\r\n", trailingNewline: false }, + { name: "CRLF with final newline", separator: "\r\n", trailingNewline: true }, +])("parseWordlist accepts $name and preserves every index", ({ + separator, + trailingNewline, +}) => { + const words = makeWords(2048); + const text = words.join(separator) + (trailingNewline ? separator : ""); + const list = parseWordlist(text); + assert.deepEqual(list.words, words); + assert.deepEqual( + [...list.wordToIndex], + words.map((word, i) => [word, i]), + ); +}); + +test.each([ + ["leading", `\n${makeText(makeWords(2048))}`], + ["internal", makeText(makeWords(2048)).replace("word100\n", "\n")], + ["extra trailing", `${makeText(makeWords(2048))}\n\n`], +])("parseWordlist rejects %s empty lines before size errors", (_, text) => { + assert.throws(() => parseWordlist(text), { + name: "Error", + message: "Wordlist contains empty lines", + }); +}); + +test("parseWordlist checks empty lines before duplicate words", () => { + const words = makeWords(2048); + words[1] = "word0"; + words[100] = ""; + assert.throws(() => parseWordlist(makeText(words)), { + name: "Error", + message: "Wordlist contains empty lines", + }); }); test("indexToWord and wordToIndex are inverse", () => { @@ -54,13 +111,40 @@ test("indexToWord and wordToIndex are inverse", () => { test("indexToWord throws on out-of-range", () => { const list = createWordlist(makeWords(2048)); - assert.throws(() => indexToWord(list, -1)); - assert.throws(() => indexToWord(list, 2048)); + for (const index of [-1, 2048]) { + assert.throws(() => indexToWord(list, index), { + name: "Error", + message: `Index out of range: ${index}`, + }); + } +}); + +test.each([ + 0.5, + Number.NaN, + Number.POSITIVE_INFINITY, +])("indexToWord rejects non-integer index %s", (index) => { + const list = createWordlist(makeWords(2048)); + assert.throws(() => indexToWord(list, index), { + name: "Error", + message: `Index must be an integer: ${index}`, + }); +}); + +test("indexToWord and wordToIndex observe changes to the returned dictionary", () => { + const list = createWordlist(makeWords(2048)); + list.words[0] = "changed"; + list.wordToIndex.set("changed", 0); + assert.equal(indexToWord(list, 0), "changed"); + assert.equal(wordToIndex(list, "changed"), 0); }); test("wordToIndex throws on unknown word", () => { const list = createWordlist(makeWords(2048)); - assert.throws(() => wordToIndex(list, "unknown")); + assert.throws(() => wordToIndex(list, "unknown"), { + name: "Error", + message: "Word not in list: unknown", + }); }); test("loadEnglishWordlist loads 2048 words with stable mapping", async () => { From a5e5df36b3631e8d563cdb5e386676c2e9a18191 Mon Sep 17 00:00:00 2001 From: xt0x Date: Tue, 8 Sep 2026 17:40:05 +0900 Subject: [PATCH 2/7] refactor(cli): restructure command handling and consolidate command files --- src/cli/DESIGN.md | 3 +- src/cli/commands.ts | 76 +++++++++++++++++++ src/cli/commands/entropyToMnemonic.ts | 4 - src/cli/commands/generateEntropy.ts | 4 - src/cli/commands/generateMnemonic.ts | 13 ---- .../commands/generateMnemonicWithWordlist.ts | 15 ---- src/cli/commands/mnemonicToEntropy.ts | 10 --- src/cli/commands/mnemonicToSeed.ts | 11 --- src/cli/commands/validate.ts | 31 -------- src/cli/runCli.ts | 20 ++--- tests/cli/commands.spec.ts | 48 ++---------- tests/cli/run.spec.ts | 13 ++++ tests/entropy-generator.spec.ts | 11 ++- tests/entropy-to-mnemonic.spec.ts | 16 ++-- 14 files changed, 126 insertions(+), 149 deletions(-) create mode 100644 src/cli/commands.ts delete mode 100644 src/cli/commands/entropyToMnemonic.ts delete mode 100644 src/cli/commands/generateEntropy.ts delete mode 100644 src/cli/commands/generateMnemonic.ts delete mode 100644 src/cli/commands/generateMnemonicWithWordlist.ts delete mode 100644 src/cli/commands/mnemonicToEntropy.ts delete mode 100644 src/cli/commands/mnemonicToSeed.ts delete mode 100644 src/cli/commands/validate.ts diff --git a/src/cli/DESIGN.md b/src/cli/DESIGN.md index df8992f..f9786b0 100644 --- a/src/cli/DESIGN.md +++ b/src/cli/DESIGN.md @@ -2,7 +2,8 @@ - Purpose: Provide a human-friendly CLI wrapper for BIP39 core APIs. - `runCli` handles argument parsing, input resolution (args/stdin), and exit codes. -- `commands/` contains small adapters that invoke core use cases. +- `commands.ts` groups mnemonic generation, optional input normalization, and validation-result adapters with their result types. +- `runCli` calls core entropy generation and entropy-to-mnemonic conversion directly; adapters are used where CLI-specific behavior is needed. - `hex.ts` handles hex encoding/decoding for byte outputs. - The CLI defaults to normalized input, with `--strict` to disable normalization. - Added `generate-mnemonic-with-wordlist` to emit a generated mnemonic plus the full English wordlist. diff --git a/src/cli/commands.ts b/src/cli/commands.ts new file mode 100644 index 0000000..2f775e6 --- /dev/null +++ b/src/cli/commands.ts @@ -0,0 +1,76 @@ +import { loadEnglishWordlist } from "../bip39/englishWordlist.js"; +import { entropyToMnemonic } from "../bip39/entropyToMnemonic.js"; +import { mnemonicToEntropy } from "../bip39/mnemonicToEntropy.js"; +import { mnemonicToSeed } from "../bip39/mnemonicToSeed.js"; +import { validateMnemonic } from "../bip39/validateMnemonic.js"; +import { entropyBitsForWordCount, type WordCount } from "../constants/bip39.js"; +import { generateEntropy } from "../entropy/entropyGenerator.js"; +import { ErrorCode } from "../errors/errorCodes.js"; +import { normalizeMnemonicInput } from "../normalize/normalizeMnemonicInput.js"; + +export const generateMnemonicCommand = (words: number): string => { + const entropyBits = entropyBitsForWordCount(words as WordCount); + const bytes = entropyBits / 8; + if (!Number.isInteger(bytes)) { + throw new Error("Invalid word count for entropy bytes"); + } + return entropyToMnemonic(generateEntropy(bytes)); +}; + +export type MnemonicWithWordlist = { + mnemonic: string; + wordlist: string[]; +}; + +export const generateMnemonicWithWordlistCommand = ( + words: number, +): MnemonicWithWordlist => { + const mnemonic = generateMnemonicCommand(words); + const { words: wordlist } = loadEnglishWordlist(); + return { mnemonic, wordlist }; +}; + +export const mnemonicToEntropyCommand = ( + input: string, + strict: boolean, +): Uint8Array => { + const normalized = strict ? input : normalizeMnemonicInput(input); + return mnemonicToEntropy(normalized); +}; + +export const mnemonicToSeedCommand = ( + input: string, + strict: boolean, + passphrase: string, +): Uint8Array => { + const normalized = strict ? input : normalizeMnemonicInput(input); + return mnemonicToSeed(normalized, passphrase); +}; + +export type ValidateCommandResult = + | { + ok: true; + normalized: string; + } + | { + ok: false; + errorCode: ErrorCode; + }; + +export const validateCommand = ( + input: string, + strict: boolean, +): ValidateCommandResult => { + const normalized = strict ? input : normalizeMnemonicInput(input); + const result = validateMnemonic(normalized); + if (!result.ok) { + return { + ok: false, + errorCode: result.error_code ?? ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + }; + } + return { + ok: true, + normalized: result.normalized_mnemonic ?? normalized, + }; +}; diff --git a/src/cli/commands/entropyToMnemonic.ts b/src/cli/commands/entropyToMnemonic.ts deleted file mode 100644 index 99551bd..0000000 --- a/src/cli/commands/entropyToMnemonic.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { entropyToMnemonic } from "../../bip39/entropyToMnemonic.js"; - -export const entropyToMnemonicCommand = (entropy: Uint8Array): string => - entropyToMnemonic(entropy); diff --git a/src/cli/commands/generateEntropy.ts b/src/cli/commands/generateEntropy.ts deleted file mode 100644 index 4539d74..0000000 --- a/src/cli/commands/generateEntropy.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { generateEntropy } from "../../entropy/entropyGenerator.js"; - -export const generateEntropyCommand = (bytes: number): Uint8Array => - generateEntropy(bytes); diff --git a/src/cli/commands/generateMnemonic.ts b/src/cli/commands/generateMnemonic.ts deleted file mode 100644 index cab4409..0000000 --- a/src/cli/commands/generateMnemonic.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { entropyToMnemonic } from "../../bip39/entropyToMnemonic.js"; -import type { WordCount } from "../../constants/bip39.js"; -import { entropyBitsForWordCount } from "../../constants/bip39.js"; -import { generateEntropy } from "../../entropy/entropyGenerator.js"; - -export const generateMnemonicCommand = (words: number): string => { - const entropyBits = entropyBitsForWordCount(words as WordCount); - const bytes = entropyBits / 8; - if (!Number.isInteger(bytes)) { - throw new Error("Invalid word count for entropy bytes"); - } - return entropyToMnemonic(generateEntropy(bytes)); -}; diff --git a/src/cli/commands/generateMnemonicWithWordlist.ts b/src/cli/commands/generateMnemonicWithWordlist.ts deleted file mode 100644 index a714a2d..0000000 --- a/src/cli/commands/generateMnemonicWithWordlist.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { loadEnglishWordlist } from "../../bip39/englishWordlist.js"; -import { generateMnemonicCommand } from "./generateMnemonic.js"; - -export type MnemonicWithWordlist = { - mnemonic: string; - wordlist: string[]; -}; - -export const generateMnemonicWithWordlistCommand = ( - words: number, -): MnemonicWithWordlist => { - const mnemonic = generateMnemonicCommand(words); - const { words: wordlist } = loadEnglishWordlist(); - return { mnemonic, wordlist }; -}; diff --git a/src/cli/commands/mnemonicToEntropy.ts b/src/cli/commands/mnemonicToEntropy.ts deleted file mode 100644 index d0449fb..0000000 --- a/src/cli/commands/mnemonicToEntropy.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { mnemonicToEntropy } from "../../bip39/mnemonicToEntropy.js"; -import { normalizeMnemonicInput } from "../../normalize/normalizeMnemonicInput.js"; - -export const mnemonicToEntropyCommand = ( - input: string, - strict: boolean, -): Uint8Array => { - const normalized = strict ? input : normalizeMnemonicInput(input); - return mnemonicToEntropy(normalized); -}; diff --git a/src/cli/commands/mnemonicToSeed.ts b/src/cli/commands/mnemonicToSeed.ts deleted file mode 100644 index 9fe4a15..0000000 --- a/src/cli/commands/mnemonicToSeed.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { mnemonicToSeed } from "../../bip39/mnemonicToSeed.js"; -import { normalizeMnemonicInput } from "../../normalize/normalizeMnemonicInput.js"; - -export const mnemonicToSeedCommand = ( - input: string, - strict: boolean, - passphrase: string, -): Uint8Array => { - const normalized = strict ? input : normalizeMnemonicInput(input); - return mnemonicToSeed(normalized, passphrase); -}; diff --git a/src/cli/commands/validate.ts b/src/cli/commands/validate.ts deleted file mode 100644 index c710f88..0000000 --- a/src/cli/commands/validate.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { validateMnemonic } from "../../bip39/validateMnemonic.js"; -import { ErrorCode } from "../../errors/errorCodes.js"; -import { normalizeMnemonicInput } from "../../normalize/normalizeMnemonicInput.js"; - -export type ValidateCommandResult = - | { - ok: true; - normalized: string; - } - | { - ok: false; - errorCode: ErrorCode; - }; - -export const validateCommand = ( - input: string, - strict: boolean, -): ValidateCommandResult => { - const normalized = strict ? input : normalizeMnemonicInput(input); - const result = validateMnemonic(normalized); - if (!result.ok) { - return { - ok: false, - errorCode: result.error_code ?? ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, - }; - } - return { - ok: true, - normalized: result.normalized_mnemonic ?? normalized, - }; -}; diff --git a/src/cli/runCli.ts b/src/cli/runCli.ts index 21b0b90..35e9762 100644 --- a/src/cli/runCli.ts +++ b/src/cli/runCli.ts @@ -1,14 +1,16 @@ +import { entropyToMnemonic } from "../bip39/entropyToMnemonic.js"; import { ENTROPY_BYTES, WORD_COUNTS } from "../constants/bip39.js"; +import { generateEntropy } from "../entropy/entropyGenerator.js"; import type { ErrorCode } from "../errors/errorCodes.js"; import { ERROR_MESSAGES } from "../integration/errorMessages.js"; import { parseArgs } from "./args.js"; -import { entropyToMnemonicCommand } from "./commands/entropyToMnemonic.js"; -import { generateEntropyCommand } from "./commands/generateEntropy.js"; -import { generateMnemonicCommand } from "./commands/generateMnemonic.js"; -import { generateMnemonicWithWordlistCommand } from "./commands/generateMnemonicWithWordlist.js"; -import { mnemonicToEntropyCommand } from "./commands/mnemonicToEntropy.js"; -import { mnemonicToSeedCommand } from "./commands/mnemonicToSeed.js"; -import { validateCommand } from "./commands/validate.js"; +import { + generateMnemonicCommand, + generateMnemonicWithWordlistCommand, + mnemonicToEntropyCommand, + mnemonicToSeedCommand, + validateCommand, +} from "./commands.js"; import { bytesToHex, hexToBytes } from "./hex.js"; export type CliIO = { @@ -130,7 +132,7 @@ export const runCli = async (argv: string[], io: CliIO): Promise => { io.writeStderr(`Invalid hex: ${(error as Error).message}\n`); return 2; } - const mnemonic = entropyToMnemonicCommand(bytes); + const mnemonic = entropyToMnemonic(bytes); io.writeStdout(`${mnemonic}\n`); return 0; } @@ -164,7 +166,7 @@ export const runCli = async (argv: string[], io: CliIO): Promise => { io.writeStderr(usage); return 2; } - const entropy = generateEntropyCommand(bytes); + const entropy = generateEntropy(bytes); io.writeStdout(`${bytesToHex(entropy)}\n`); return 0; } diff --git a/tests/cli/commands.spec.ts b/tests/cli/commands.spec.ts index 2dc3915..83fa9c9 100644 --- a/tests/cli/commands.spec.ts +++ b/tests/cli/commands.spec.ts @@ -2,16 +2,14 @@ import assert from "node:assert/strict"; import { pbkdf2Sync } from "node:crypto"; import { test } from "vitest"; -import { EntropyLengthError } from "../../src/bip39/entropyToMnemonic.ts"; import { InvalidMnemonicFormatError } from "../../src/bip39/mnemonicToEntropy.ts"; -import { entropyToMnemonicCommand } from "../../src/cli/commands/entropyToMnemonic.ts"; -import { generateEntropyCommand } from "../../src/cli/commands/generateEntropy.ts"; -import { generateMnemonicCommand } from "../../src/cli/commands/generateMnemonic.ts"; -import { generateMnemonicWithWordlistCommand } from "../../src/cli/commands/generateMnemonicWithWordlist.ts"; -import { mnemonicToEntropyCommand } from "../../src/cli/commands/mnemonicToEntropy.ts"; -import { mnemonicToSeedCommand } from "../../src/cli/commands/mnemonicToSeed.ts"; -import { validateCommand } from "../../src/cli/commands/validate.ts"; -import { InvalidEntropyLengthError } from "../../src/entropy/entropyGenerator.ts"; +import { + generateMnemonicCommand, + generateMnemonicWithWordlistCommand, + mnemonicToEntropyCommand, + mnemonicToSeedCommand, + validateCommand, +} from "../../src/cli/commands.ts"; import { ErrorCode } from "../../src/errors/errorCodes.ts"; const ENTROPY_HEX = "00000000000000000000000000000000"; @@ -21,19 +19,9 @@ const SEED_HEX = "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553" + "1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04"; -const hexToBytes = (hex: string): Uint8Array => - Uint8Array.from(hex.match(/.{2}/gu) ?? [], (pair) => - Number.parseInt(pair, 16), - ); - const bytesToHex = (bytes: Uint8Array): string => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); -test("entropyToMnemonicCommand matches vector", () => { - const mnemonic = entropyToMnemonicCommand(hexToBytes(ENTROPY_HEX)); - assert.equal(mnemonic, MNEMONIC); -}); - test("mnemonicToEntropyCommand matches vector", () => { const entropy = mnemonicToEntropyCommand(MNEMONIC, false); assert.equal(bytesToHex(entropy), ENTROPY_HEX); @@ -51,14 +39,6 @@ test("validateCommand returns normalized mnemonic", () => { }); }); -test.each([ - 16, 20, 24, 28, 32, -])("generateEntropyCommand returns %i bytes", (bytes) => { - const entropy = generateEntropyCommand(bytes); - assert.ok(entropy instanceof Uint8Array); - assert.equal(entropy.length, bytes); -}); - test.each([ 12, 15, 18, 21, 24, ])("generateMnemonicCommand returns a valid %i-word mnemonic", (words) => { @@ -166,20 +146,6 @@ test.each([ } }); -test("entropy commands preserve invalid-length exceptions", () => { - assert.throws(() => entropyToMnemonicCommand(new Uint8Array(17)), { - constructor: EntropyLengthError, - name: "EntropyLengthError", - code: ErrorCode.ERR_ENTROPY_LENGTH, - message: "Entropy must be 16/20/24/28/32 bytes", - }); - assert.throws(() => generateEntropyCommand(17), { - constructor: InvalidEntropyLengthError, - name: "InvalidEntropyLengthError", - message: "Entropy must be 16/20/24/28/32 bytes", - }); -}); - test.each([ generateMnemonicCommand, generateMnemonicWithWordlistCommand, diff --git a/tests/cli/run.spec.ts b/tests/cli/run.spec.ts index 1e78d58..f4863aa 100644 --- a/tests/cli/run.spec.ts +++ b/tests/cli/run.spec.ts @@ -231,6 +231,19 @@ test("runCli generate-entropy outputs hex of default length", async () => { assert.match(hex, /^[0-9a-f]+$/u); }); +test.each([ + 16, 20, 24, 28, 32, +])("runCli generate-entropy outputs %i bytes as hex", async (bytes) => { + const { io, stdout, stderr } = createIo(); + const exitCode = await runCli( + ["generate-entropy", "--bytes", String(bytes)], + io, + ); + assert.equal(exitCode, 0); + assert.equal(stderr.join(""), ""); + assert.match(stdout.join(""), new RegExp(`^[0-9a-f]{${bytes * 2}}\\n$`, "u")); +}); + test("runCli generate-mnemonic outputs requested word count", async () => { const { io, stdout } = createIo(); const exitCode = await runCli(["generate-mnemonic", "--words", "12"], io); diff --git a/tests/entropy-generator.spec.ts b/tests/entropy-generator.spec.ts index 0e6c2f7..935d307 100644 --- a/tests/entropy-generator.spec.ts +++ b/tests/entropy-generator.spec.ts @@ -17,9 +17,14 @@ test("generateEntropy returns allowed lengths", () => { } }); -test("generateEntropy rejects invalid lengths", () => { - assert.throws(() => generateEntropy(15), InvalidEntropyLengthError); - assert.throws(() => generateEntropy(33), InvalidEntropyLengthError); +test.each([ + 15, 17, 33, +])("generateEntropy rejects %i bytes with its length error", (bytes) => { + assert.throws(() => generateEntropy(bytes), { + constructor: InvalidEntropyLengthError, + name: "InvalidEntropyLengthError", + message: "Entropy must be 16/20/24/28/32 bytes", + }); }); test("EntropyGenerator allows deterministic output in tests", () => { diff --git a/tests/entropy-to-mnemonic.spec.ts b/tests/entropy-to-mnemonic.spec.ts index 74199aa..8e10529 100644 --- a/tests/entropy-to-mnemonic.spec.ts +++ b/tests/entropy-to-mnemonic.spec.ts @@ -11,13 +11,15 @@ import { ErrorCode } from "../src/errors/errorCodes.ts"; type Vector = [string, string, string, string]; -test("entropyToMnemonic rejects invalid entropy length", () => { - assert.throws( - () => entropyToMnemonic(new Uint8Array(15)), - (error) => - error instanceof EntropyLengthError && - error.code === ErrorCode.ERR_ENTROPY_LENGTH, - ); +test.each([ + 15, 17, +])("entropyToMnemonic rejects %i bytes with its length error", (bytes) => { + assert.throws(() => entropyToMnemonic(new Uint8Array(bytes)), { + constructor: EntropyLengthError, + name: "EntropyLengthError", + code: ErrorCode.ERR_ENTROPY_LENGTH, + message: "Entropy must be 16/20/24/28/32 bytes", + }); }); test("entropyToMnemonic matches official vectors", async () => { From 1abe2be1e430f2266614dd808ee59df41369917a Mon Sep 17 00:00:00 2001 From: xt0x Date: Tue, 8 Sep 2026 18:01:20 +0900 Subject: [PATCH 3/7] refactor(wordlist): consolidate wordlist loading and improve caching mechanisms --- src/DESIGN.md | 3 +- src/bip39/DESIGN.md | 1 + src/bip39/englishWordlist.ts | 44 -------------- src/bip39/entropyToMnemonic.ts | 4 +- src/bip39/mnemonicToEntropy.ts | 4 +- src/bip39/validateMnemonic.ts | 4 +- src/cli/DESIGN.md | 1 + src/cli/commands.ts | 4 +- src/index.ts | 9 ++- src/wordlist/DESIGN.md | 4 ++ src/wordlist/wordlist.ts | 31 ++++++++-- tests/wordlist-loaders.spec.ts | 108 +++++++++++++++++++++++---------- tests/wordlist.spec.ts | 10 +++ 13 files changed, 136 insertions(+), 91 deletions(-) delete mode 100644 src/bip39/englishWordlist.ts diff --git a/src/DESIGN.md b/src/DESIGN.md index cf8c13d..b317829 100644 --- a/src/DESIGN.md +++ b/src/DESIGN.md @@ -12,6 +12,7 @@ - `src/cli/` provides a command-line interface wrapping the core APIs. - `src/integration/` wires core APIs to external systems (UI/BIP32) and error messaging. - `src/types/` defines shared DTOs such as `ValidationResult`. -- `src/wordlist/` loads and validates the English wordlist with index mappings. +- `src/wordlist/` centralizes English wordlist parsing and index mappings, with independent synchronous and asynchronous loader caches. - `src/index.ts` re-exports the public surface for these foundational modules. +- Wordlist exports are explicit so the synchronous loader remains internal to core and CLI modules. - Build output is emitted to `dist/`; `src/` contains TypeScript sources only. diff --git a/src/bip39/DESIGN.md b/src/bip39/DESIGN.md index cdd1851..6ba1d9e 100644 --- a/src/bip39/DESIGN.md +++ b/src/bip39/DESIGN.md @@ -3,4 +3,5 @@ - Purpose: Core BIP39 conversion functions built on fixed assets and primitives. - Scope: Deterministic conversions only; no UI or random entropy generation. - Includes: `entropyToMnemonic`, `mnemonicToEntropy`, `mnemonicToSeed`, and `validateMnemonic`. +- English wordlist loading is delegated to `loadEnglishWordlistSync` in `src/wordlist/wordlist.ts`; this directory does not duplicate file parsing or caching. - Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/bip39/englishWordlist.ts b/src/bip39/englishWordlist.ts deleted file mode 100644 index d8cc778..0000000 --- a/src/bip39/englishWordlist.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; - -import { WORDLIST_SIZE } from "../constants/bip39.js"; - -export type EnglishWordlist = { - words: string[]; - wordToIndex: Map; -}; - -const ENGLISH_WORDLIST_PATH = "assets/english.txt"; - -let cachedEnglishWordlist: EnglishWordlist | null = null; - -export const loadEnglishWordlist = (): EnglishWordlist => { - if (cachedEnglishWordlist) { - return cachedEnglishWordlist; - } - const filePath = resolve(process.cwd(), ENGLISH_WORDLIST_PATH); - const text = readFileSync(filePath, "utf8"); - const lines = text - .split("\n") - .map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)); - if (lines.length > 0 && lines[lines.length - 1] === "") { - lines.pop(); - } - if (lines.length !== WORDLIST_SIZE) { - throw new Error( - `Wordlist must contain ${WORDLIST_SIZE} words, got ${lines.length}`, - ); - } - const wordToIndex = new Map(); - lines.forEach((word, index) => { - if (word.length === 0) { - throw new Error("Wordlist contains an empty word"); - } - if (wordToIndex.has(word)) { - throw new Error(`Duplicate word detected: ${word}`); - } - wordToIndex.set(word, index); - }); - cachedEnglishWordlist = { words: lines, wordToIndex }; - return cachedEnglishWordlist; -}; diff --git a/src/bip39/entropyToMnemonic.ts b/src/bip39/entropyToMnemonic.ts index 8f1845d..748fa66 100644 --- a/src/bip39/entropyToMnemonic.ts +++ b/src/bip39/entropyToMnemonic.ts @@ -5,7 +5,7 @@ import { } from "../constants/bip39.js"; import { sha256 } from "../crypto/crypto.js"; import { ErrorCode } from "../errors/errorCodes.js"; -import { loadEnglishWordlist } from "./englishWordlist.js"; +import { loadEnglishWordlistSync } from "../wordlist/wordlist.js"; export class EntropyLengthError extends Error { code = ErrorCode.ERR_ENTROPY_LENGTH; @@ -32,7 +32,7 @@ export const entropyToMnemonic = (entropy: Uint8Array): string => { const checksum = bytesToBits(sha256(entropy)).slice(0, checksumBits); const combined = entropyBitArray.concat(checksum); const indices = bitsToIntegers(combined, 11); - const { words } = loadEnglishWordlist(); + const { words } = loadEnglishWordlistSync(); const mnemonicWords = indices.map((index) => { const word = words[index]; if (word === undefined) { diff --git a/src/bip39/mnemonicToEntropy.ts b/src/bip39/mnemonicToEntropy.ts index 19fc240..db65dd3 100644 --- a/src/bip39/mnemonicToEntropy.ts +++ b/src/bip39/mnemonicToEntropy.ts @@ -3,7 +3,7 @@ import { WORD_COUNTS } from "../constants/bip39.js"; import { sha256 } from "../crypto/crypto.js"; import { ErrorCode } from "../errors/errorCodes.js"; import { parseMnemonicWordsStrict } from "../parser/strictMnemonic.js"; -import { loadEnglishWordlist } from "./englishWordlist.js"; +import { loadEnglishWordlistSync } from "../wordlist/wordlist.js"; export class MnemonicToEntropyError extends Error { code: ErrorCode; @@ -64,7 +64,7 @@ export const mnemonicToEntropy = (input: string | string[]): Uint8Array => { throw new InvalidWordCountError(); } - const { wordToIndex } = loadEnglishWordlist(); + const { wordToIndex } = loadEnglishWordlistSync(); const indices = words.map((word) => { const index = wordToIndex.get(word); if (index === undefined) { diff --git a/src/bip39/validateMnemonic.ts b/src/bip39/validateMnemonic.ts index 803672f..7c6e5e4 100644 --- a/src/bip39/validateMnemonic.ts +++ b/src/bip39/validateMnemonic.ts @@ -4,7 +4,7 @@ import { sha256 } from "../crypto/crypto.js"; import { ErrorCode } from "../errors/errorCodes.js"; import { parseMnemonicWordsStrict } from "../parser/strictMnemonic.js"; import type { ValidationResult } from "../types/validationResult.js"; -import { loadEnglishWordlist } from "./englishWordlist.js"; +import { loadEnglishWordlistSync } from "../wordlist/wordlist.js"; const isValidWordCount = (count: number): boolean => (WORD_COUNTS as readonly number[]).includes(count); @@ -41,7 +41,7 @@ export const validateMnemonic = ( }; } - const { wordToIndex } = loadEnglishWordlist(); + const { wordToIndex } = loadEnglishWordlistSync(); const indices: number[] = []; for (const word of words) { const index = wordToIndex.get(word); diff --git a/src/cli/DESIGN.md b/src/cli/DESIGN.md index f9786b0..84a3a8e 100644 --- a/src/cli/DESIGN.md +++ b/src/cli/DESIGN.md @@ -7,3 +7,4 @@ - `hex.ts` handles hex encoding/decoding for byte outputs. - The CLI defaults to normalized input, with `--strict` to disable normalization. - Added `generate-mnemonic-with-wordlist` to emit a generated mnemonic plus the full English wordlist. +- Wordlist output uses the synchronous loader in `src/wordlist/wordlist.ts`, sharing the core dictionary while remaining independent of the public asynchronous loader's cache. diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 2f775e6..27e7b40 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -1,4 +1,3 @@ -import { loadEnglishWordlist } from "../bip39/englishWordlist.js"; import { entropyToMnemonic } from "../bip39/entropyToMnemonic.js"; import { mnemonicToEntropy } from "../bip39/mnemonicToEntropy.js"; import { mnemonicToSeed } from "../bip39/mnemonicToSeed.js"; @@ -7,6 +6,7 @@ import { entropyBitsForWordCount, type WordCount } from "../constants/bip39.js"; import { generateEntropy } from "../entropy/entropyGenerator.js"; import { ErrorCode } from "../errors/errorCodes.js"; import { normalizeMnemonicInput } from "../normalize/normalizeMnemonicInput.js"; +import { loadEnglishWordlistSync } from "../wordlist/wordlist.js"; export const generateMnemonicCommand = (words: number): string => { const entropyBits = entropyBitsForWordCount(words as WordCount); @@ -26,7 +26,7 @@ export const generateMnemonicWithWordlistCommand = ( words: number, ): MnemonicWithWordlist => { const mnemonic = generateMnemonicCommand(words); - const { words: wordlist } = loadEnglishWordlist(); + const { words: wordlist } = loadEnglishWordlistSync(); return { mnemonic, wordlist }; }; diff --git a/src/index.ts b/src/index.ts index fbd7337..8357235 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,4 +12,11 @@ export * from "./integration/externalIntegration.js"; export * from "./normalize/normalizeMnemonicInput.js"; export * from "./parser/strictMnemonic.js"; export * from "./types/validationResult.js"; -export * from "./wordlist/wordlist.js"; +export { + createWordlist, + indexToWord, + loadEnglishWordlist, + parseWordlist, + type Wordlist, + wordToIndex, +} from "./wordlist/wordlist.js"; diff --git a/src/wordlist/DESIGN.md b/src/wordlist/DESIGN.md index a2d116c..0c3a919 100644 --- a/src/wordlist/DESIGN.md +++ b/src/wordlist/DESIGN.md @@ -2,4 +2,8 @@ - Purpose: Load the English wordlist in file order and build index mappings. - Scope: File parsing, integrity checks, and index lookups; no normalization or crypto. +- `wordlist.ts` owns the shared `Wordlist` type, line splitting, validation, and index construction for both loaders. +- Public `loadEnglishWordlist` uses asynchronous file reads; `loadEnglishWordlistSync` serves core conversions and CLI generation and is excluded from the root public exports. +- The two loaders keep separate caches because returned dictionaries are mutable; each caches only successful loads. +- Parsing preserves existing error precedence: the public parser checks empty lines first, while the synchronous loader checks word count before empty or duplicate words in file order. - Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/wordlist/wordlist.ts b/src/wordlist/wordlist.ts index b0516cb..a6b077c 100644 --- a/src/wordlist/wordlist.ts +++ b/src/wordlist/wordlist.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; @@ -10,7 +11,9 @@ export type Wordlist = { const ENGLISH_WORDLIST_PATH = "assets/english.txt"; -let cachedEnglishWordlist: Wordlist | null = null; +// Public callers can mutate their dictionary without changing core operations. +let cachedAsyncEnglishWordlist: Wordlist | null = null; +let cachedSyncEnglishWordlist: Wordlist | null = null; export const createWordlist = (words: string[]): Wordlist => { if (words.length !== WORDLIST_SIZE) { @@ -33,13 +36,18 @@ export const createWordlist = (words: string[]): Wordlist => { return { words: [...words], wordToIndex }; }; -export const parseWordlist = (text: string): Wordlist => { +const splitWordlistLines = (text: string): string[] => { const lines = text .split("\n") .map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)); if (lines.length > 0 && lines[lines.length - 1] === "") { lines.pop(); } + return lines; +}; + +export const parseWordlist = (text: string): Wordlist => { + const lines = splitWordlistLines(text); if (lines.some((line) => line.length === 0)) { throw new Error("Wordlist contains empty lines"); } @@ -47,13 +55,24 @@ export const parseWordlist = (text: string): Wordlist => { }; export const loadEnglishWordlist = async (): Promise => { - if (cachedEnglishWordlist) { - return cachedEnglishWordlist; + if (cachedAsyncEnglishWordlist) { + return cachedAsyncEnglishWordlist; } const filePath = resolve(process.cwd(), ENGLISH_WORDLIST_PATH); const text = await readFile(filePath, "utf8"); - cachedEnglishWordlist = parseWordlist(text); - return cachedEnglishWordlist; + cachedAsyncEnglishWordlist = parseWordlist(text); + return cachedAsyncEnglishWordlist; +}; + +export const loadEnglishWordlistSync = (): Wordlist => { + if (cachedSyncEnglishWordlist) { + return cachedSyncEnglishWordlist; + } + const filePath = resolve(process.cwd(), ENGLISH_WORDLIST_PATH); + const text = readFileSync(filePath, "utf8"); + // The synchronous contract checks length before empty or duplicate words. + cachedSyncEnglishWordlist = createWordlist(splitWordlistLines(text)); + return cachedSyncEnglishWordlist; }; export const indexToWord = (wordlist: Wordlist, index: number): string => { diff --git a/tests/wordlist-loaders.spec.ts b/tests/wordlist-loaders.spec.ts index b75a535..7f9093a 100644 --- a/tests/wordlist-loaders.spec.ts +++ b/tests/wordlist-loaders.spec.ts @@ -39,7 +39,7 @@ afterEach(() => { }); // BIP39 uses an ordered, 2048-entry dictionary. These parsing and error -// contracts characterize the two existing loaders before their consolidation. +// contracts preserve the distinct synchronous and asynchronous entry points. test.each([ { name: "LF", separator: "\n", trailingNewline: false }, { name: "LF with final newline", separator: "\n", trailingNewline: true }, @@ -51,12 +51,8 @@ test.each([ (input.trailingNewline ? input.separator : ""); fileReads.readFile.mockResolvedValue(text); fileReads.readFileSync.mockReturnValue(text); - const { loadEnglishWordlist: loadAsync } = await import( - "../src/wordlist/wordlist.ts" - ); - const { loadEnglishWordlist: loadSync } = await import( - "../src/bip39/englishWordlist.ts" - ); + const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = + await import("../src/wordlist/wordlist.ts"); for (const list of [await loadAsync(), loadSync()]) { assert.deepEqual(list.words, words); assert.deepEqual( @@ -67,6 +63,12 @@ test.each([ }); test.each([ + { + name: "an empty file", + lines: [], + asyncMessage: "Wordlist must contain 2048 words, got 0", + syncMessage: "Wordlist must contain 2048 words, got 0", + }, { name: "too few words", lines: words.slice(1), @@ -124,12 +126,8 @@ test.each([ ])("English loaders preserve error precedence for $name", async (input) => { fileReads.readFile.mockResolvedValue(input.lines.join("\n")); fileReads.readFileSync.mockReturnValue(input.lines.join("\n")); - const { loadEnglishWordlist: loadAsync } = await import( - "../src/wordlist/wordlist.ts" - ); - const { loadEnglishWordlist: loadSync } = await import( - "../src/bip39/englishWordlist.ts" - ); + const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = + await import("../src/wordlist/wordlist.ts"); await assert.rejects(loadAsync, { name: "Error", message: input.asyncMessage, @@ -138,12 +136,8 @@ test.each([ }); test("English loaders reuse a successfully loaded dictionary", async () => { - const { loadEnglishWordlist: loadAsync } = await import( - "../src/wordlist/wordlist.ts" - ); - const { loadEnglishWordlist: loadSync } = await import( - "../src/bip39/englishWordlist.ts" - ); + const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = + await import("../src/wordlist/wordlist.ts"); const asyncList = await loadAsync(); const syncList = loadSync(); fileReads.readFile.mockRejectedValue(new Error("Wordlist unavailable")); @@ -160,12 +154,8 @@ test("English loaders retry after a failed file read", async () => { fileReads.readFileSync.mockImplementationOnce(() => { throw failure; }); - const { loadEnglishWordlist: loadAsync } = await import( - "../src/wordlist/wordlist.ts" - ); - const { loadEnglishWordlist: loadSync } = await import( - "../src/bip39/englishWordlist.ts" - ); + const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = + await import("../src/wordlist/wordlist.ts"); await assert.rejects(loadAsync, failure); assert.throws(loadSync, failure); assert.equal((await loadAsync()).words[0], "abandon"); @@ -175,12 +165,8 @@ test("English loaders retry after a failed file read", async () => { test("English loaders retry after malformed wordlist contents", async () => { fileReads.readFile.mockResolvedValueOnce("incomplete\n"); fileReads.readFileSync.mockReturnValueOnce("incomplete\n"); - const { loadEnglishWordlist: loadAsync } = await import( - "../src/wordlist/wordlist.ts" - ); - const { loadEnglishWordlist: loadSync } = await import( - "../src/bip39/englishWordlist.ts" - ); + const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = + await import("../src/wordlist/wordlist.ts"); const error = { name: "Error", message: "Wordlist must contain 2048 words, got 1", @@ -191,6 +177,66 @@ test("English loaders retry after malformed wordlist contents", async () => { assert.equal(loadSync().words[2047], "zoo"); }); +test.each([ + "async first", + "sync first", +])("English loader caches stay separate when initialized %s", async (order) => { + const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = + await import("../src/wordlist/wordlist.ts"); + if (order === "sync first") loadSync(); + const asyncList = await loadAsync(); + const syncList = loadSync(); + assert.notStrictEqual(asyncList, syncList); + assert.notStrictEqual(asyncList.words, syncList.words); + assert.notStrictEqual(asyncList.wordToIndex, syncList.wordToIndex); + assert.deepEqual(asyncList, syncList); + assert.strictEqual(await loadAsync(), asyncList); + assert.strictEqual(loadSync(), syncList); +}); + +test("an asynchronous read can finish independently of a synchronous read", async () => { + let resolveRead!: (text: string) => void; + fileReads.readFile.mockReturnValueOnce( + new Promise((resolve) => { + resolveRead = resolve; + }), + ); + const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = + await import("../src/wordlist/wordlist.ts"); + const pending = loadAsync(); + assert.ok(pending instanceof Promise); + assert.equal(fileReads.readFileSync.mock.calls.length, 0); + const syncList = loadSync(); + assert.equal(syncList.words[0], "abandon"); + resolveRead(englishText); + const asyncList = await pending; + assert.notStrictEqual(asyncList, syncList); + assert.strictEqual(await loadAsync(), asyncList); + assert.strictEqual(loadSync(), syncList); +}); + +test("a pending asynchronous read can fail and retry without affecting the synchronous cache", async () => { + let rejectRead!: (error: Error) => void; + fileReads.readFile.mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectRead = reject; + }), + ); + const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = + await import("../src/wordlist/wordlist.ts"); + const pending = loadAsync(); + const failure = new Error("Asynchronous read failed"); + const rejection = assert.rejects(pending, failure); + const syncList = loadSync(); + rejectRead(failure); + await rejection; + assert.strictEqual(loadSync(), syncList); + const asyncList = await loadAsync(); + assert.equal(asyncList.words[0], "abandon"); + assert.notStrictEqual(asyncList, syncList); + assert.strictEqual(loadSync(), syncList); +}); + test("mutating the public dictionary does not affect core BIP39 operations", async () => { const { loadEnglishWordlist } = await import("../src/wordlist/wordlist.ts"); const { entropyToMnemonic } = await import( diff --git a/tests/wordlist.spec.ts b/tests/wordlist.spec.ts index 1f86b70..8939e3a 100644 --- a/tests/wordlist.spec.ts +++ b/tests/wordlist.spec.ts @@ -154,3 +154,13 @@ test("loadEnglishWordlist loads 2048 words with stable mapping", async () => { assert.equal(wordToIndex(list, list.words[0]), 0); assert.equal(wordToIndex(list, list.words[2047]), 2047); }); + +test("the public entry point preserves wordlist exports and keeps the synchronous loader internal", async () => { + const api = await import("../src/index.ts"); + assert.strictEqual(api.createWordlist, createWordlist); + assert.strictEqual(api.parseWordlist, parseWordlist); + assert.strictEqual(api.loadEnglishWordlist, loadEnglishWordlist); + assert.strictEqual(api.indexToWord, indexToWord); + assert.strictEqual(api.wordToIndex, wordToIndex); + assert.equal("loadEnglishWordlistSync" in api, false); +}); From bf6380fbc82748a9026ce704dfc11282de318ff3 Mon Sep 17 00:00:00 2001 From: xt0x Date: Tue, 8 Sep 2026 18:07:37 +0900 Subject: [PATCH 4/7] refactor(bip39): consolidate mnemonic validation and entropy recovery into a single module --- README.md | 4 +- src/DESIGN.md | 3 +- src/bip39/DESIGN.md | 4 + .../{mnemonicToEntropy.ts => mnemonic.ts} | 109 ++++++++++++++++-- src/bip39/validateMnemonic.ts | 85 -------------- src/cli/DESIGN.md | 1 + src/cli/commands.ts | 3 +- src/index.ts | 4 +- src/integration/DESIGN.md | 1 + src/integration/externalIntegration.ts | 3 +- src/types/DESIGN.md | 5 - src/types/validationResult.ts | 9 -- tests/cli/commands.spec.ts | 2 +- tests/mnemonic-to-entropy.spec.ts | 2 +- tests/validate-mnemonic.spec.ts | 2 +- tests/validation-result.spec.ts | 2 + tests/wordlist-loaders.spec.ts | 35 +++++- 17 files changed, 144 insertions(+), 130 deletions(-) rename src/bip39/{mnemonicToEntropy.ts => mnemonic.ts} (52%) delete mode 100644 src/bip39/validateMnemonic.ts delete mode 100644 src/types/DESIGN.md delete mode 100644 src/types/validationResult.ts diff --git a/README.md b/README.md index 244db76..e8d9c0d 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ The main repository-level configuration files are: . ├── assets/ # Pinned specification assets and test vectors ├── src/ # TypeScript source code -│ ├── bip39/ # Core entropy/mnemonic/seed workflows +│ ├── bip39/ # Core workflows and their result types │ ├── bits/ # Bit conversion helpers │ ├── cli/ # Command-line interface │ ├── constants/ # Fixed BIP39 constants and mappings @@ -150,7 +150,7 @@ The main repository-level configuration files are: │ ├── integration/ # Error messaging and integration adapters │ ├── normalize/ # Compatibility input normalization │ ├── parser/ # Strict mnemonic parsing rules -│ ├── types/ # Shared DTOs and result types +│ ├── wordlist/ # English wordlist parsing, loading, and lookups │ └── index.ts # Public export surface ├── tests/ # Unit and integration tests ├── biome.json # Lint/format configuration diff --git a/src/DESIGN.md b/src/DESIGN.md index b317829..c28be98 100644 --- a/src/DESIGN.md +++ b/src/DESIGN.md @@ -3,7 +3,7 @@ - Purpose: TypeScript source for the BIP39 implementation. - `src/constants/` defines fixed BIP39 constants and length/word-count relations. - `src/bits/` implements bit and chunk conversions used in mnemonic encoding. -- `src/bip39/` contains core BIP39 conversion functions. +- `src/bip39/` contains core BIP39 conversion functions, with validation, entropy recovery, and `ValidationResult` colocated in `mnemonic.ts`. - `src/errors/` defines standard error codes and priority ordering. - `src/crypto/` wraps SHA-256 and PBKDF2-HMAC-SHA512 using standard libraries. - `src/normalize/` provides a compatibility input adapter (trim, NFKD, lowercase). @@ -11,7 +11,6 @@ - `src/entropy/` generates entropy via secure randomness with injectable providers for tests. - `src/cli/` provides a command-line interface wrapping the core APIs. - `src/integration/` wires core APIs to external systems (UI/BIP32) and error messaging. -- `src/types/` defines shared DTOs such as `ValidationResult`. - `src/wordlist/` centralizes English wordlist parsing and index mappings, with independent synchronous and asynchronous loader caches. - `src/index.ts` re-exports the public surface for these foundational modules. - Wordlist exports are explicit so the synchronous loader remains internal to core and CLI modules. diff --git a/src/bip39/DESIGN.md b/src/bip39/DESIGN.md index 6ba1d9e..812a7c7 100644 --- a/src/bip39/DESIGN.md +++ b/src/bip39/DESIGN.md @@ -3,5 +3,9 @@ - Purpose: Core BIP39 conversion functions built on fixed assets and primitives. - Scope: Deterministic conversions only; no UI or random entropy generation. - Includes: `entropyToMnemonic`, `mnemonicToEntropy`, `mnemonicToSeed`, and `validateMnemonic`. +- `mnemonic.ts` groups validation, entropy recovery, `ValidationResult`, and the existing recovery error classes around a private decoder. +- The decoder checks format, word count, word membership, and checksum in that order. Both public APIs propagate infrastructure errors unchanged. +- `validateMnemonic` returns exactly its five public fields; `mnemonicToEntropy` translates decoder failures into the existing exception classes. Decoded entropy stays internal to validation. +- Seed derivation remains separate from strict mnemonic validation. - English wordlist loading is delegated to `loadEnglishWordlistSync` in `src/wordlist/wordlist.ts`; this directory does not duplicate file parsing or caching. - Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/bip39/mnemonicToEntropy.ts b/src/bip39/mnemonic.ts similarity index 52% rename from src/bip39/mnemonicToEntropy.ts rename to src/bip39/mnemonic.ts index db65dd3..2b5b01a 100644 --- a/src/bip39/mnemonicToEntropy.ts +++ b/src/bip39/mnemonic.ts @@ -5,6 +5,14 @@ import { ErrorCode } from "../errors/errorCodes.js"; import { parseMnemonicWordsStrict } from "../parser/strictMnemonic.js"; import { loadEnglishWordlistSync } from "../wordlist/wordlist.js"; +export type ValidationResult = { + ok: boolean; + error_code: ErrorCode | null; + normalized_mnemonic: string | null; + word_count: number | null; + invalid_word: string | null; +}; + export class MnemonicToEntropyError extends Error { code: ErrorCode; @@ -43,6 +51,25 @@ export class ChecksumMismatchError extends MnemonicToEntropyError { } } +type MnemonicDecodeResult = + | { + ok: true; + entropy: Uint8Array; + normalized_mnemonic: string; + word_count: number; + } + | { + ok: false; + error_code: + | ErrorCode.ERR_INVALID_MNEMONIC_FORMAT + | ErrorCode.ERR_INVALID_WORD_COUNT + | ErrorCode.ERR_WORD_NOT_IN_LIST + | ErrorCode.ERR_CHECKSUM_MISMATCH; + normalized_mnemonic: string | null; + word_count: number | null; + invalid_word: string | null; + }; + const isValidWordCount = (count: number): boolean => (WORD_COUNTS as readonly number[]).includes(count); @@ -52,26 +79,45 @@ const checksumBitsForWordCount = (wordCount: number): number => const arraysEqual = (a: number[], b: number[]): boolean => a.length === b.length && a.every((value, index) => value === b[index]); -export const mnemonicToEntropy = (input: string | string[]): Uint8Array => { +const decodeMnemonic = (input: string | string[]): MnemonicDecodeResult => { const parsed = parseMnemonicWordsStrict(input); if (!parsed.ok) { - throw new InvalidMnemonicFormatError(); + return { + ok: false, + error_code: ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + normalized_mnemonic: null, + word_count: null, + invalid_word: null, + }; } - const { words } = parsed; + const { words, normalized_mnemonic } = parsed; const wordCount = words.length; if (!isValidWordCount(wordCount)) { - throw new InvalidWordCountError(); + return { + ok: false, + error_code: ErrorCode.ERR_INVALID_WORD_COUNT, + normalized_mnemonic, + word_count: wordCount, + invalid_word: null, + }; } const { wordToIndex } = loadEnglishWordlistSync(); - const indices = words.map((word) => { + const indices: number[] = []; + for (const word of words) { const index = wordToIndex.get(word); if (index === undefined) { - throw new WordNotInListError(`Word not in list: ${word}`); + return { + ok: false, + error_code: ErrorCode.ERR_WORD_NOT_IN_LIST, + normalized_mnemonic, + word_count: wordCount, + invalid_word: word, + }; } - return index; - }); + indices.push(index); + } const bits = integersToBits(indices, 11); const checksumBits = checksumBitsForWordCount(wordCount); @@ -79,11 +125,52 @@ export const mnemonicToEntropy = (input: string | string[]): Uint8Array => { const entropyBitArray = bits.slice(0, entropyBits); const checksumBitArray = bits.slice(entropyBits); const entropy = bitsToBytes(entropyBitArray); - const expectedChecksum = bytesToBits(sha256(entropy)).slice(0, checksumBits); + if (!arraysEqual(checksumBitArray, expectedChecksum)) { - throw new ChecksumMismatchError(); + return { + ok: false, + error_code: ErrorCode.ERR_CHECKSUM_MISMATCH, + normalized_mnemonic, + word_count: wordCount, + invalid_word: null, + }; } - return entropy; + return { + ok: true, + entropy, + normalized_mnemonic, + word_count: wordCount, + }; +}; + +export const validateMnemonic = ( + input: string | string[], +): ValidationResult => { + const result = decodeMnemonic(input); + return { + ok: result.ok, + error_code: result.ok ? null : result.error_code, + normalized_mnemonic: result.normalized_mnemonic, + word_count: result.word_count, + invalid_word: result.ok ? null : result.invalid_word, + }; +}; + +export const mnemonicToEntropy = (input: string | string[]): Uint8Array => { + const result = decodeMnemonic(input); + if (result.ok) { + return result.entropy; + } + switch (result.error_code) { + case ErrorCode.ERR_INVALID_MNEMONIC_FORMAT: + throw new InvalidMnemonicFormatError(); + case ErrorCode.ERR_INVALID_WORD_COUNT: + throw new InvalidWordCountError(); + case ErrorCode.ERR_WORD_NOT_IN_LIST: + throw new WordNotInListError(`Word not in list: ${result.invalid_word}`); + case ErrorCode.ERR_CHECKSUM_MISMATCH: + throw new ChecksumMismatchError(); + } }; diff --git a/src/bip39/validateMnemonic.ts b/src/bip39/validateMnemonic.ts deleted file mode 100644 index 7c6e5e4..0000000 --- a/src/bip39/validateMnemonic.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { bitsToBytes, bytesToBits, integersToBits } from "../bits/bitOps.js"; -import { WORD_COUNTS } from "../constants/bip39.js"; -import { sha256 } from "../crypto/crypto.js"; -import { ErrorCode } from "../errors/errorCodes.js"; -import { parseMnemonicWordsStrict } from "../parser/strictMnemonic.js"; -import type { ValidationResult } from "../types/validationResult.js"; -import { loadEnglishWordlistSync } from "../wordlist/wordlist.js"; - -const isValidWordCount = (count: number): boolean => - (WORD_COUNTS as readonly number[]).includes(count); - -const checksumBitsForWordCount = (wordCount: number): number => - wordCount === 0 ? 0 : (wordCount * 11) / 33; - -const arraysEqual = (a: number[], b: number[]): boolean => - a.length === b.length && a.every((value, index) => value === b[index]); - -export const validateMnemonic = ( - input: string | string[], -): ValidationResult => { - const parsed = parseMnemonicWordsStrict(input); - if (!parsed.ok) { - return { - ok: false, - error_code: ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, - normalized_mnemonic: null, - word_count: null, - invalid_word: null, - }; - } - - const { words, normalized_mnemonic } = parsed; - const wordCount = words.length; - if (!isValidWordCount(wordCount)) { - return { - ok: false, - error_code: ErrorCode.ERR_INVALID_WORD_COUNT, - normalized_mnemonic, - word_count: wordCount, - invalid_word: null, - }; - } - - const { wordToIndex } = loadEnglishWordlistSync(); - const indices: number[] = []; - for (const word of words) { - const index = wordToIndex.get(word); - if (index === undefined) { - return { - ok: false, - error_code: ErrorCode.ERR_WORD_NOT_IN_LIST, - normalized_mnemonic, - word_count: wordCount, - invalid_word: word, - }; - } - indices.push(index); - } - - const bits = integersToBits(indices, 11); - const checksumBits = checksumBitsForWordCount(wordCount); - const entropyBits = bits.length - checksumBits; - const entropyBitArray = bits.slice(0, entropyBits); - const checksumBitArray = bits.slice(entropyBits); - const entropy = bitsToBytes(entropyBitArray); - const expectedChecksum = bytesToBits(sha256(entropy)).slice(0, checksumBits); - - if (!arraysEqual(checksumBitArray, expectedChecksum)) { - return { - ok: false, - error_code: ErrorCode.ERR_CHECKSUM_MISMATCH, - normalized_mnemonic, - word_count: wordCount, - invalid_word: null, - }; - } - - return { - ok: true, - error_code: null, - normalized_mnemonic, - word_count: wordCount, - invalid_word: null, - }; -}; diff --git a/src/cli/DESIGN.md b/src/cli/DESIGN.md index 84a3a8e..94f9681 100644 --- a/src/cli/DESIGN.md +++ b/src/cli/DESIGN.md @@ -3,6 +3,7 @@ - Purpose: Provide a human-friendly CLI wrapper for BIP39 core APIs. - `runCli` handles argument parsing, input resolution (args/stdin), and exit codes. - `commands.ts` groups mnemonic generation, optional input normalization, and validation-result adapters with their result types. +- Validation and entropy recovery delegate to the shared `src/bip39/mnemonic.ts` module. - `runCli` calls core entropy generation and entropy-to-mnemonic conversion directly; adapters are used where CLI-specific behavior is needed. - `hex.ts` handles hex encoding/decoding for byte outputs. - The CLI defaults to normalized input, with `--strict` to disable normalization. diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 27e7b40..f5cde2f 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -1,7 +1,6 @@ import { entropyToMnemonic } from "../bip39/entropyToMnemonic.js"; -import { mnemonicToEntropy } from "../bip39/mnemonicToEntropy.js"; +import { mnemonicToEntropy, validateMnemonic } from "../bip39/mnemonic.js"; import { mnemonicToSeed } from "../bip39/mnemonicToSeed.js"; -import { validateMnemonic } from "../bip39/validateMnemonic.js"; import { entropyBitsForWordCount, type WordCount } from "../constants/bip39.js"; import { generateEntropy } from "../entropy/entropyGenerator.js"; import { ErrorCode } from "../errors/errorCodes.js"; diff --git a/src/index.ts b/src/index.ts index 8357235..3c9085f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,6 @@ export * from "./bip39/entropyToMnemonic.js"; -export * from "./bip39/mnemonicToEntropy.js"; +export * from "./bip39/mnemonic.js"; export * from "./bip39/mnemonicToSeed.js"; -export * from "./bip39/validateMnemonic.js"; export * from "./bits/bitOps.js"; export * from "./constants/bip39.js"; export * from "./crypto/crypto.js"; @@ -11,7 +10,6 @@ export * from "./integration/errorMessages.js"; export * from "./integration/externalIntegration.js"; export * from "./normalize/normalizeMnemonicInput.js"; export * from "./parser/strictMnemonic.js"; -export * from "./types/validationResult.js"; export { createWordlist, indexToWord, diff --git a/src/integration/DESIGN.md b/src/integration/DESIGN.md index f06d337..6032733 100644 --- a/src/integration/DESIGN.md +++ b/src/integration/DESIGN.md @@ -2,4 +2,5 @@ - Purpose: Connect core BIP39 APIs to external layers like UI or BIP32. - Scope: Normalize UI input, orchestrate validation/derivation, and map error codes to messages. +- Validation and its `ValidationResult` type come from `src/bip39/mnemonic.ts`; seed derivation remains a separate core API. - Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/integration/externalIntegration.ts b/src/integration/externalIntegration.ts index 1f3ab18..d50f88f 100644 --- a/src/integration/externalIntegration.ts +++ b/src/integration/externalIntegration.ts @@ -1,8 +1,7 @@ +import { type ValidationResult, validateMnemonic } from "../bip39/mnemonic.js"; import { mnemonicToSeed } from "../bip39/mnemonicToSeed.js"; -import { validateMnemonic } from "../bip39/validateMnemonic.js"; import { ErrorCode } from "../errors/errorCodes.js"; import { normalizeMnemonicInput } from "../normalize/normalizeMnemonicInput.js"; -import type { ValidationResult } from "../types/validationResult.js"; import { ERROR_MESSAGES } from "./errorMessages.js"; export type SeedDerivationResult = diff --git a/src/types/DESIGN.md b/src/types/DESIGN.md deleted file mode 100644 index 9e5d29e..0000000 --- a/src/types/DESIGN.md +++ /dev/null @@ -1,5 +0,0 @@ -# types design - -- Purpose: Shared DTOs and types used by public APIs. -- Scope: Type declarations only; no runtime logic. -- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/types/validationResult.ts b/src/types/validationResult.ts deleted file mode 100644 index bab48ab..0000000 --- a/src/types/validationResult.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ErrorCode } from "../errors/errorCodes.js"; - -export type ValidationResult = { - ok: boolean; - error_code: ErrorCode | null; - normalized_mnemonic: string | null; - word_count: number | null; - invalid_word: string | null; -}; diff --git a/tests/cli/commands.spec.ts b/tests/cli/commands.spec.ts index 83fa9c9..e25b19b 100644 --- a/tests/cli/commands.spec.ts +++ b/tests/cli/commands.spec.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { pbkdf2Sync } from "node:crypto"; import { test } from "vitest"; -import { InvalidMnemonicFormatError } from "../../src/bip39/mnemonicToEntropy.ts"; +import { InvalidMnemonicFormatError } from "../../src/bip39/mnemonic.ts"; import { generateMnemonicCommand, generateMnemonicWithWordlistCommand, diff --git a/tests/mnemonic-to-entropy.spec.ts b/tests/mnemonic-to-entropy.spec.ts index 8b3f0b1..8b80e9b 100644 --- a/tests/mnemonic-to-entropy.spec.ts +++ b/tests/mnemonic-to-entropy.spec.ts @@ -10,7 +10,7 @@ import { MnemonicToEntropyError, mnemonicToEntropy, WordNotInListError, -} from "../src/bip39/mnemonicToEntropy.ts"; +} from "../src/bip39/mnemonic.ts"; import { ErrorCode } from "../src/errors/errorCodes.ts"; type Vector = [string, string, string, string]; diff --git a/tests/validate-mnemonic.spec.ts b/tests/validate-mnemonic.spec.ts index b873073..10aa7d7 100644 --- a/tests/validate-mnemonic.spec.ts +++ b/tests/validate-mnemonic.spec.ts @@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { test } from "vitest"; -import { validateMnemonic } from "../src/bip39/validateMnemonic.ts"; +import { validateMnemonic } from "../src/bip39/mnemonic.ts"; import { ErrorCode } from "../src/errors/errorCodes.ts"; const validMnemonic = diff --git a/tests/validation-result.spec.ts b/tests/validation-result.spec.ts index ff19115..330d04b 100644 --- a/tests/validation-result.spec.ts +++ b/tests/validation-result.spec.ts @@ -4,6 +4,7 @@ import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { expectTypeOf, test } from "vitest"; +import type { ValidationResult as MnemonicValidationResult } from "../src/bip39/mnemonic.ts"; import { ErrorCode, entropyToMnemonic, @@ -28,6 +29,7 @@ test("ValidationResult shape is stable", () => { // Vitest transpiles type assertions; the compiler test below checks them too. test("public validation and conversion types remain compatible", () => { + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf<{ ok: boolean; error_code: ErrorCode | null; diff --git a/tests/wordlist-loaders.spec.ts b/tests/wordlist-loaders.spec.ts index 7f9093a..ff8419a 100644 --- a/tests/wordlist-loaders.spec.ts +++ b/tests/wordlist-loaders.spec.ts @@ -242,9 +242,8 @@ test("mutating the public dictionary does not affect core BIP39 operations", asy const { entropyToMnemonic } = await import( "../src/bip39/entropyToMnemonic.ts" ); - const { validateMnemonic } = await import("../src/bip39/validateMnemonic.ts"); - const { mnemonicToEntropy } = await import( - "../src/bip39/mnemonicToEntropy.ts" + const { validateMnemonic, mnemonicToEntropy } = await import( + "../src/bip39/mnemonic.ts" ); const list = await loadEnglishWordlist(); const originalWords = [...list.words]; @@ -282,9 +281,8 @@ test.each([ fileReads.readFileSync.mockImplementation(() => { throw new Error("Wordlist unavailable"); }); - const { validateMnemonic } = await import("../src/bip39/validateMnemonic.ts"); - const { mnemonicToEntropy } = await import( - "../src/bip39/mnemonicToEntropy.ts" + const { validateMnemonic, mnemonicToEntropy } = await import( + "../src/bip39/mnemonic.ts" ); assert.equal(validateMnemonic(input.input).error_code, input.code); assert.throws(() => mnemonicToEntropy(input.input), { @@ -295,3 +293,28 @@ test.each([ assert.equal(fileReads.readFile.mock.calls.length, 0); assert.equal(fileReads.readFileSync.mock.calls.length, 0); }); + +test("validation and decoding propagate wordlist read failures unchanged and retry", async () => { + const failure = new Error("Wordlist unavailable"); + fileReads.readFileSync.mockImplementation(() => { + throw failure; + }); + const { validateMnemonic, mnemonicToEntropy } = await import( + "../src/bip39/mnemonic.ts" + ); + for (const operation of [validateMnemonic, mnemonicToEntropy]) { + assert.throws( + () => operation(mnemonic), + (error: unknown) => error === failure, + ); + } + fileReads.readFileSync.mockReturnValue(englishText); + assert.deepEqual(validateMnemonic(mnemonic), { + ok: true, + error_code: null, + normalized_mnemonic: mnemonic, + word_count: 12, + invalid_word: null, + }); + assert.deepEqual(mnemonicToEntropy(mnemonic), new Uint8Array(16)); +}); From af2b98a7dffda21d3a29f63628b5f1c7dbb2a32d Mon Sep 17 00:00:00 2001 From: xt0x Date: Tue, 8 Sep 2026 19:47:31 +0900 Subject: [PATCH 5/7] refactor(bip39): reorganize code structure and consolidate error handling --- README.md | 12 ++--------- src/DESIGN.md | 17 +++++----------- src/bip39/DESIGN.md | 18 ++++++++++++----- src/{bits => bip39}/bitOps.ts | 0 .../bip39.ts => bip39/constants.ts} | 0 src/{crypto => bip39}/crypto.ts | 4 ++-- src/{entropy => bip39}/entropyGenerator.ts | 2 +- src/bip39/entropyToMnemonic.ts | 13 +++++------- src/{errors => bip39}/errorCodes.ts | 0 src/bip39/mnemonic.ts | 12 +++++------ src/bip39/mnemonicToSeed.ts | 4 ++-- src/{parser => bip39}/strictMnemonic.ts | 2 +- src/{wordlist => bip39}/wordlist.ts | 2 +- src/bits/DESIGN.md | 5 ----- src/cli/DESIGN.md | 3 ++- src/cli/commands.ts | 10 +++++----- src/cli/runCli.ts | 6 +++--- src/constants/DESIGN.md | 5 ----- src/crypto/DESIGN.md | 5 ----- src/entropy/DESIGN.md | 5 ----- src/errors/DESIGN.md | 5 ----- src/index.ts | 20 +++++++++---------- src/integration/DESIGN.md | 7 +++++-- src/integration/errorMessages.ts | 2 +- src/integration/externalIntegration.ts | 4 ++-- .../normalizeMnemonicInput.ts | 0 src/normalize/DESIGN.md | 5 ----- src/parser/DESIGN.md | 5 ----- src/wordlist/DESIGN.md | 9 --------- tests/bits.spec.ts | 2 +- tests/cli/commands.spec.ts | 3 +-- tests/constants.spec.ts | 2 +- tests/crypto.spec.ts | 4 ++-- tests/entropy-generator.spec.ts | 2 +- tests/entropy-to-mnemonic.spec.ts | 2 +- tests/errors.spec.ts | 2 +- tests/integration.spec.ts | 3 +-- tests/mnemonic-to-entropy.spec.ts | 3 +-- tests/mnemonic-to-seed.spec.ts | 3 +-- tests/normalize-mnemonic.spec.ts | 2 +- tests/strict-mnemonic.spec.ts | 4 ++-- tests/validate-mnemonic.spec.ts | 3 +-- tests/wordlist-loaders.spec.ts | 18 ++++++++--------- tests/wordlist.spec.ts | 2 +- 44 files changed, 91 insertions(+), 146 deletions(-) rename src/{bits => bip39}/bitOps.ts (100%) rename src/{constants/bip39.ts => bip39/constants.ts} (100%) rename src/{crypto => bip39}/crypto.ts (85%) rename src/{entropy => bip39}/entropyGenerator.ts (95%) rename src/{errors => bip39}/errorCodes.ts (100%) rename src/{parser => bip39}/strictMnemonic.ts (97%) rename src/{wordlist => bip39}/wordlist.ts (98%) delete mode 100644 src/bits/DESIGN.md delete mode 100644 src/constants/DESIGN.md delete mode 100644 src/crypto/DESIGN.md delete mode 100644 src/entropy/DESIGN.md delete mode 100644 src/errors/DESIGN.md rename src/{normalize => integration}/normalizeMnemonicInput.ts (100%) delete mode 100644 src/normalize/DESIGN.md delete mode 100644 src/parser/DESIGN.md delete mode 100644 src/wordlist/DESIGN.md diff --git a/README.md b/README.md index e8d9c0d..7ce7e4e 100644 --- a/README.md +++ b/README.md @@ -140,17 +140,9 @@ The main repository-level configuration files are: . ├── assets/ # Pinned specification assets and test vectors ├── src/ # TypeScript source code -│ ├── bip39/ # Core workflows and their result types -│ ├── bits/ # Bit conversion helpers +│ ├── bip39/ # BIP39 library, primitives, entropy, and wordlists │ ├── cli/ # Command-line interface -│ ├── constants/ # Fixed BIP39 constants and mappings -│ ├── crypto/ # SHA-256 and PBKDF2 wrappers -│ ├── entropy/ # Secure entropy generation -│ ├── errors/ # Standard error codes -│ ├── integration/ # Error messaging and integration adapters -│ ├── normalize/ # Compatibility input normalization -│ ├── parser/ # Strict mnemonic parsing rules -│ ├── wordlist/ # English wordlist parsing, loading, and lookups +│ ├── integration/ # Shared input normalization and external adapters │ └── index.ts # Public export surface ├── tests/ # Unit and integration tests ├── biome.json # Lint/format configuration diff --git a/src/DESIGN.md b/src/DESIGN.md index c28be98..2f4ebde 100644 --- a/src/DESIGN.md +++ b/src/DESIGN.md @@ -1,17 +1,10 @@ # src design - Purpose: TypeScript source for the BIP39 implementation. -- `src/constants/` defines fixed BIP39 constants and length/word-count relations. -- `src/bits/` implements bit and chunk conversions used in mnemonic encoding. -- `src/bip39/` contains core BIP39 conversion functions, with validation, entropy recovery, and `ValidationResult` colocated in `mnemonic.ts`. -- `src/errors/` defines standard error codes and priority ordering. -- `src/crypto/` wraps SHA-256 and PBKDF2-HMAC-SHA512 using standard libraries. -- `src/normalize/` provides a compatibility input adapter (trim, NFKD, lowercase). -- `src/parser/` implements strict mnemonic parsing contracts. -- `src/entropy/` generates entropy via secure randomness with injectable providers for tests. -- `src/cli/` provides a command-line interface wrapping the core APIs. -- `src/integration/` wires core APIs to external systems (UI/BIP32) and error messaging. -- `src/wordlist/` centralizes English wordlist parsing and index mappings, with independent synchronous and asynchronous loader caches. -- `src/index.ts` re-exports the public surface for these foundational modules. +- `src/bip39/` groups the BIP39 library: conversions, validation and result types, strict parsing, constants, error codes, bit operations, cryptographic primitives, secure entropy generation, and English wordlists. These remain separate files with their existing responsibilities. +- `src/integration/` provides shared CLI/UI input normalization, external adapters (UI/BIP32), and error messaging. +- `src/cli/` provides argument handling, command adapters, and text output around the library APIs. +- Dependencies flow from CLI and integration adapters into the BIP39 library; the library does not depend on either adapter directory. +- `src/index.ts` preserves the public export surface independently of the internal file layout. - Wordlist exports are explicit so the synchronous loader remains internal to core and CLI modules. - Build output is emitted to `dist/`; `src/` contains TypeScript sources only. diff --git a/src/bip39/DESIGN.md b/src/bip39/DESIGN.md index 812a7c7..4b5527b 100644 --- a/src/bip39/DESIGN.md +++ b/src/bip39/DESIGN.md @@ -1,11 +1,19 @@ # bip39 design -- Purpose: Core BIP39 conversion functions built on fixed assets and primitives. -- Scope: Deterministic conversions only; no UI or random entropy generation. -- Includes: `entropyToMnemonic`, `mnemonicToEntropy`, `mnemonicToSeed`, and `validateMnemonic`. +- Purpose: Keep the BIP39 library and its supporting primitives together, with each responsibility in a separate module. +- Scope: Conversion, validation, strict parsing, wordlist loading, and secure entropy generation, including runtime file and standard-library crypto operations. UI/CLI input normalization and presentation remain in the adapter directories. +- `entropyToMnemonic.ts` handles entropy encoding; `mnemonicToSeed.ts` handles seed derivation separately from strict mnemonic validation. - `mnemonic.ts` groups validation, entropy recovery, `ValidationResult`, and the existing recovery error classes around a private decoder. - The decoder checks format, word count, word membership, and checksum in that order. Both public APIs propagate infrastructure errors unchanged. - `validateMnemonic` returns exactly its five public fields; `mnemonicToEntropy` translates decoder failures into the existing exception classes. Decoded entropy stays internal to validation. -- Seed derivation remains separate from strict mnemonic validation. -- English wordlist loading is delegated to `loadEnglishWordlistSync` in `src/wordlist/wordlist.ts`; this directory does not duplicate file parsing or caching. +- `strictMnemonic.ts` enforces NFKD, spacing, and lowercase ASCII for the English profile and extracts word arrays; it does not apply compatibility input normalization. +- `constants.ts` defines fixed BIP39 constants and deterministic length/word-count relations without I/O or crypto dependencies. +- `bitOps.ts` provides pure conversions between bytes, bit arrays, and integer chunks in MSB-first order. +- `errorCodes.ts` defines error identifiers and priority ordering; user-facing messages belong to the integration layer. +- `crypto.ts` wraps standard SHA-256 and PBKDF2-HMAC-SHA512 primitives and maps PBKDF2 failures; it does not implement cryptographic algorithms itself. +- `entropyGenerator.ts` validates allowed byte lengths and delegates secure entropy generation to a randomness provider, with provider injection for tests. +- `wordlist.ts` owns the shared `Wordlist` type, line splitting, integrity checks, file-order index mappings, and both English wordlist loaders; it performs no input normalization or crypto. +- Public `loadEnglishWordlist` reads asynchronously; `loadEnglishWordlistSync` serves conversions and CLI generation and stays excluded from the root public exports. +- The loaders maintain separate caches because returned dictionaries are mutable; each caches only successful loads. +- Wordlist parsing preserves error precedence: the public parser checks empty lines first, while the synchronous loader checks word count before empty or duplicate words in file order. - Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/bits/bitOps.ts b/src/bip39/bitOps.ts similarity index 100% rename from src/bits/bitOps.ts rename to src/bip39/bitOps.ts diff --git a/src/constants/bip39.ts b/src/bip39/constants.ts similarity index 100% rename from src/constants/bip39.ts rename to src/bip39/constants.ts diff --git a/src/crypto/crypto.ts b/src/bip39/crypto.ts similarity index 85% rename from src/crypto/crypto.ts rename to src/bip39/crypto.ts index 07c153e..f397cc7 100644 --- a/src/crypto/crypto.ts +++ b/src/bip39/crypto.ts @@ -1,7 +1,7 @@ import { createHash, pbkdf2Sync } from "node:crypto"; -import { PBKDF2_ITERATIONS, SEED_BYTES } from "../constants/bip39.js"; -import { ErrorCode } from "../errors/errorCodes.js"; +import { PBKDF2_ITERATIONS, SEED_BYTES } from "./constants.js"; +import { ErrorCode } from "./errorCodes.js"; export class Pbkdf2FailureError extends Error { code = ErrorCode.ERR_PBKDF2_FAILURE; diff --git a/src/entropy/entropyGenerator.ts b/src/bip39/entropyGenerator.ts similarity index 95% rename from src/entropy/entropyGenerator.ts rename to src/bip39/entropyGenerator.ts index e29abd3..e27fdb2 100644 --- a/src/entropy/entropyGenerator.ts +++ b/src/bip39/entropyGenerator.ts @@ -1,6 +1,6 @@ import { randomBytes } from "node:crypto"; -import { ENTROPY_BYTES } from "../constants/bip39.js"; +import { ENTROPY_BYTES } from "./constants.js"; export class InvalidEntropyLengthError extends Error { constructor(message = "Invalid entropy length") { diff --git a/src/bip39/entropyToMnemonic.ts b/src/bip39/entropyToMnemonic.ts index 748fa66..e5b59e6 100644 --- a/src/bip39/entropyToMnemonic.ts +++ b/src/bip39/entropyToMnemonic.ts @@ -1,11 +1,8 @@ -import { bitsToIntegers, bytesToBits } from "../bits/bitOps.js"; -import { - checksumBitsForEntropyBits, - ENTROPY_BYTES, -} from "../constants/bip39.js"; -import { sha256 } from "../crypto/crypto.js"; -import { ErrorCode } from "../errors/errorCodes.js"; -import { loadEnglishWordlistSync } from "../wordlist/wordlist.js"; +import { bitsToIntegers, bytesToBits } from "./bitOps.js"; +import { checksumBitsForEntropyBits, ENTROPY_BYTES } from "./constants.js"; +import { sha256 } from "./crypto.js"; +import { ErrorCode } from "./errorCodes.js"; +import { loadEnglishWordlistSync } from "./wordlist.js"; export class EntropyLengthError extends Error { code = ErrorCode.ERR_ENTROPY_LENGTH; diff --git a/src/errors/errorCodes.ts b/src/bip39/errorCodes.ts similarity index 100% rename from src/errors/errorCodes.ts rename to src/bip39/errorCodes.ts diff --git a/src/bip39/mnemonic.ts b/src/bip39/mnemonic.ts index 2b5b01a..55e2657 100644 --- a/src/bip39/mnemonic.ts +++ b/src/bip39/mnemonic.ts @@ -1,9 +1,9 @@ -import { bitsToBytes, bytesToBits, integersToBits } from "../bits/bitOps.js"; -import { WORD_COUNTS } from "../constants/bip39.js"; -import { sha256 } from "../crypto/crypto.js"; -import { ErrorCode } from "../errors/errorCodes.js"; -import { parseMnemonicWordsStrict } from "../parser/strictMnemonic.js"; -import { loadEnglishWordlistSync } from "../wordlist/wordlist.js"; +import { bitsToBytes, bytesToBits, integersToBits } from "./bitOps.js"; +import { WORD_COUNTS } from "./constants.js"; +import { sha256 } from "./crypto.js"; +import { ErrorCode } from "./errorCodes.js"; +import { parseMnemonicWordsStrict } from "./strictMnemonic.js"; +import { loadEnglishWordlistSync } from "./wordlist.js"; export type ValidationResult = { ok: boolean; diff --git a/src/bip39/mnemonicToSeed.ts b/src/bip39/mnemonicToSeed.ts index ba39530..f473569 100644 --- a/src/bip39/mnemonicToSeed.ts +++ b/src/bip39/mnemonicToSeed.ts @@ -1,5 +1,5 @@ -import { pbkdf2HmacSha512 } from "../crypto/crypto.js"; -import { ErrorCode } from "../errors/errorCodes.js"; +import { pbkdf2HmacSha512 } from "./crypto.js"; +import { ErrorCode } from "./errorCodes.js"; export class InvalidMnemonicSeedFormatError extends Error { code = ErrorCode.ERR_INVALID_MNEMONIC_FORMAT; diff --git a/src/parser/strictMnemonic.ts b/src/bip39/strictMnemonic.ts similarity index 97% rename from src/parser/strictMnemonic.ts rename to src/bip39/strictMnemonic.ts index aa11174..6a6b4c2 100644 --- a/src/parser/strictMnemonic.ts +++ b/src/bip39/strictMnemonic.ts @@ -1,4 +1,4 @@ -import { ErrorCode } from "../errors/errorCodes.js"; +import { ErrorCode } from "./errorCodes.js"; export type StrictMnemonicParseSuccess = { ok: true; diff --git a/src/wordlist/wordlist.ts b/src/bip39/wordlist.ts similarity index 98% rename from src/wordlist/wordlist.ts rename to src/bip39/wordlist.ts index a6b077c..06885c6 100644 --- a/src/wordlist/wordlist.ts +++ b/src/bip39/wordlist.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { WORDLIST_SIZE } from "../constants/bip39.js"; +import { WORDLIST_SIZE } from "./constants.js"; export type Wordlist = { words: string[]; diff --git a/src/bits/DESIGN.md b/src/bits/DESIGN.md deleted file mode 100644 index d6671b9..0000000 --- a/src/bits/DESIGN.md +++ /dev/null @@ -1,5 +0,0 @@ -# bits design - -- Purpose: Convert between bytes, bit arrays, and fixed-size integer chunks. -- Scope: Pure bit manipulation utilities with deterministic ordering (MSB-first). -- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/cli/DESIGN.md b/src/cli/DESIGN.md index 94f9681..ee4be3b 100644 --- a/src/cli/DESIGN.md +++ b/src/cli/DESIGN.md @@ -5,7 +5,8 @@ - `commands.ts` groups mnemonic generation, optional input normalization, and validation-result adapters with their result types. - Validation and entropy recovery delegate to the shared `src/bip39/mnemonic.ts` module. - `runCli` calls core entropy generation and entropy-to-mnemonic conversion directly; adapters are used where CLI-specific behavior is needed. +- Library functions and constants come from `src/bip39/`; compatibility normalization and error messages come from `src/integration/`. - `hex.ts` handles hex encoding/decoding for byte outputs. - The CLI defaults to normalized input, with `--strict` to disable normalization. - Added `generate-mnemonic-with-wordlist` to emit a generated mnemonic plus the full English wordlist. -- Wordlist output uses the synchronous loader in `src/wordlist/wordlist.ts`, sharing the core dictionary while remaining independent of the public asynchronous loader's cache. +- Wordlist output uses the synchronous loader in `src/bip39/wordlist.ts`, sharing the core dictionary while remaining independent of the public asynchronous loader's cache. diff --git a/src/cli/commands.ts b/src/cli/commands.ts index f5cde2f..243b6ad 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -1,11 +1,11 @@ +import { entropyBitsForWordCount, type WordCount } from "../bip39/constants.js"; +import { generateEntropy } from "../bip39/entropyGenerator.js"; import { entropyToMnemonic } from "../bip39/entropyToMnemonic.js"; +import { ErrorCode } from "../bip39/errorCodes.js"; import { mnemonicToEntropy, validateMnemonic } from "../bip39/mnemonic.js"; import { mnemonicToSeed } from "../bip39/mnemonicToSeed.js"; -import { entropyBitsForWordCount, type WordCount } from "../constants/bip39.js"; -import { generateEntropy } from "../entropy/entropyGenerator.js"; -import { ErrorCode } from "../errors/errorCodes.js"; -import { normalizeMnemonicInput } from "../normalize/normalizeMnemonicInput.js"; -import { loadEnglishWordlistSync } from "../wordlist/wordlist.js"; +import { loadEnglishWordlistSync } from "../bip39/wordlist.js"; +import { normalizeMnemonicInput } from "../integration/normalizeMnemonicInput.js"; export const generateMnemonicCommand = (words: number): string => { const entropyBits = entropyBitsForWordCount(words as WordCount); diff --git a/src/cli/runCli.ts b/src/cli/runCli.ts index 35e9762..a290e76 100644 --- a/src/cli/runCli.ts +++ b/src/cli/runCli.ts @@ -1,7 +1,7 @@ +import { ENTROPY_BYTES, WORD_COUNTS } from "../bip39/constants.js"; +import { generateEntropy } from "../bip39/entropyGenerator.js"; import { entropyToMnemonic } from "../bip39/entropyToMnemonic.js"; -import { ENTROPY_BYTES, WORD_COUNTS } from "../constants/bip39.js"; -import { generateEntropy } from "../entropy/entropyGenerator.js"; -import type { ErrorCode } from "../errors/errorCodes.js"; +import type { ErrorCode } from "../bip39/errorCodes.js"; import { ERROR_MESSAGES } from "../integration/errorMessages.js"; import { parseArgs } from "./args.js"; import { diff --git a/src/constants/DESIGN.md b/src/constants/DESIGN.md deleted file mode 100644 index 86236f0..0000000 --- a/src/constants/DESIGN.md +++ /dev/null @@ -1,5 +0,0 @@ -# constants design - -- Purpose: Define fixed BIP39 constants and deterministic length/word-count relations. -- Scope: Pure data and small helpers with no I/O or crypto dependencies. -- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/crypto/DESIGN.md b/src/crypto/DESIGN.md deleted file mode 100644 index 0e35bde..0000000 --- a/src/crypto/DESIGN.md +++ /dev/null @@ -1,5 +0,0 @@ -# crypto design - -- Purpose: Provide thin wrappers around standard SHA-256 and PBKDF2-HMAC-SHA512 primitives. -- Scope: No custom crypto; only fixed-parameter calls and error mapping. -- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/entropy/DESIGN.md b/src/entropy/DESIGN.md deleted file mode 100644 index e386ec0..0000000 --- a/src/entropy/DESIGN.md +++ /dev/null @@ -1,5 +0,0 @@ -# entropy design - -- Purpose: Provide secure entropy generation with injectable providers for testing. -- Scope: Validation of allowed byte lengths and delegation to a randomness provider. -- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/errors/DESIGN.md b/src/errors/DESIGN.md deleted file mode 100644 index 8a1edb2..0000000 --- a/src/errors/DESIGN.md +++ /dev/null @@ -1,5 +0,0 @@ -# errors design - -- Purpose: Centralize standard BIP39 error codes and their priority ordering. -- Scope: Error identifiers only; no formatting or UI messaging. -- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/index.ts b/src/index.ts index 3c9085f..18a588e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,12 @@ +export * from "./bip39/bitOps.js"; +export * from "./bip39/constants.js"; +export * from "./bip39/crypto.js"; +export * from "./bip39/entropyGenerator.js"; export * from "./bip39/entropyToMnemonic.js"; +export * from "./bip39/errorCodes.js"; export * from "./bip39/mnemonic.js"; export * from "./bip39/mnemonicToSeed.js"; -export * from "./bits/bitOps.js"; -export * from "./constants/bip39.js"; -export * from "./crypto/crypto.js"; -export * from "./entropy/entropyGenerator.js"; -export * from "./errors/errorCodes.js"; -export * from "./integration/errorMessages.js"; -export * from "./integration/externalIntegration.js"; -export * from "./normalize/normalizeMnemonicInput.js"; -export * from "./parser/strictMnemonic.js"; +export * from "./bip39/strictMnemonic.js"; export { createWordlist, indexToWord, @@ -17,4 +14,7 @@ export { parseWordlist, type Wordlist, wordToIndex, -} from "./wordlist/wordlist.js"; +} from "./bip39/wordlist.js"; +export * from "./integration/errorMessages.js"; +export * from "./integration/externalIntegration.js"; +export * from "./integration/normalizeMnemonicInput.js"; diff --git a/src/integration/DESIGN.md b/src/integration/DESIGN.md index 6032733..dc097e2 100644 --- a/src/integration/DESIGN.md +++ b/src/integration/DESIGN.md @@ -1,6 +1,9 @@ # integration design -- Purpose: Connect core BIP39 APIs to external layers like UI or BIP32. -- Scope: Normalize UI input, orchestrate validation/derivation, and map error codes to messages. +- Purpose: Connect the BIP39 library to external layers like CLI, UI, or BIP32. +- Scope: Provide compatibility input normalization, orchestrate UI validation/derivation, and map error codes to messages. +- `normalizeMnemonicInput.ts` is the shared CLI/UI adapter for trimming, whitespace normalization, NFKD normalization, and lowercasing for the English profile. It remains separate from the library's strict parser and seed derivation. +- `externalIntegration.ts` implements UI and downstream seed adapters; `errorMessages.ts` maps the library's error identifiers to presentation text. - Validation and its `ValidationResult` type come from `src/bip39/mnemonic.ts`; seed derivation remains a separate core API. +- Integration adapters depend on `src/bip39/` and do not depend on the CLI. - Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/integration/errorMessages.ts b/src/integration/errorMessages.ts index 8118d16..d53bdb0 100644 --- a/src/integration/errorMessages.ts +++ b/src/integration/errorMessages.ts @@ -1,4 +1,4 @@ -import { ErrorCode } from "../errors/errorCodes.js"; +import { ErrorCode } from "../bip39/errorCodes.js"; export const ERROR_MESSAGES: Record = { [ErrorCode.ERR_ENTROPY_LENGTH]: diff --git a/src/integration/externalIntegration.ts b/src/integration/externalIntegration.ts index d50f88f..9dc8610 100644 --- a/src/integration/externalIntegration.ts +++ b/src/integration/externalIntegration.ts @@ -1,8 +1,8 @@ +import { ErrorCode } from "../bip39/errorCodes.js"; import { type ValidationResult, validateMnemonic } from "../bip39/mnemonic.js"; import { mnemonicToSeed } from "../bip39/mnemonicToSeed.js"; -import { ErrorCode } from "../errors/errorCodes.js"; -import { normalizeMnemonicInput } from "../normalize/normalizeMnemonicInput.js"; import { ERROR_MESSAGES } from "./errorMessages.js"; +import { normalizeMnemonicInput } from "./normalizeMnemonicInput.js"; export type SeedDerivationResult = | { diff --git a/src/normalize/normalizeMnemonicInput.ts b/src/integration/normalizeMnemonicInput.ts similarity index 100% rename from src/normalize/normalizeMnemonicInput.ts rename to src/integration/normalizeMnemonicInput.ts diff --git a/src/normalize/DESIGN.md b/src/normalize/DESIGN.md deleted file mode 100644 index 1a6d912..0000000 --- a/src/normalize/DESIGN.md +++ /dev/null @@ -1,5 +0,0 @@ -# normalize design - -- Purpose: Provide a compatibility adapter for mnemonic input. -- Scope: Trim, whitespace normalization, NFKD normalization, and lowercase for English profile. -- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/parser/DESIGN.md b/src/parser/DESIGN.md deleted file mode 100644 index bac680f..0000000 --- a/src/parser/DESIGN.md +++ /dev/null @@ -1,5 +0,0 @@ -# parser design - -- Purpose: Enforce strict mnemonic input contracts and extract word arrays. -- Scope: Validation of NFKD, spacing, and lowercase ASCII for the English profile. -- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/wordlist/DESIGN.md b/src/wordlist/DESIGN.md deleted file mode 100644 index 0c3a919..0000000 --- a/src/wordlist/DESIGN.md +++ /dev/null @@ -1,9 +0,0 @@ -# wordlist design - -- Purpose: Load the English wordlist in file order and build index mappings. -- Scope: File parsing, integrity checks, and index lookups; no normalization or crypto. -- `wordlist.ts` owns the shared `Wordlist` type, line splitting, validation, and index construction for both loaders. -- Public `loadEnglishWordlist` uses asynchronous file reads; `loadEnglishWordlistSync` serves core conversions and CLI generation and is excluded from the root public exports. -- The two loaders keep separate caches because returned dictionaries are mutable; each caches only successful loads. -- Parsing preserves existing error precedence: the public parser checks empty lines first, while the synchronous loader checks word count before empty or duplicate words in file order. -- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/tests/bits.spec.ts b/tests/bits.spec.ts index 0faf9a9..ed8d178 100644 --- a/tests/bits.spec.ts +++ b/tests/bits.spec.ts @@ -6,7 +6,7 @@ import { bitsToIntegers, bytesToBits, integersToBits, -} from "../src/bits/bitOps.ts"; +} from "../src/bip39/bitOps.ts"; test("bytesToBits reads MSB to LSB", () => { const bits = bytesToBits(Uint8Array.from([0x80, 0x01])); diff --git a/tests/cli/commands.spec.ts b/tests/cli/commands.spec.ts index e25b19b..119d58f 100644 --- a/tests/cli/commands.spec.ts +++ b/tests/cli/commands.spec.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { pbkdf2Sync } from "node:crypto"; import { test } from "vitest"; - +import { ErrorCode } from "../../src/bip39/errorCodes.ts"; import { InvalidMnemonicFormatError } from "../../src/bip39/mnemonic.ts"; import { generateMnemonicCommand, @@ -10,7 +10,6 @@ import { mnemonicToSeedCommand, validateCommand, } from "../../src/cli/commands.ts"; -import { ErrorCode } from "../../src/errors/errorCodes.ts"; const ENTROPY_HEX = "00000000000000000000000000000000"; const MNEMONIC = diff --git a/tests/constants.spec.ts b/tests/constants.spec.ts index 52f408c..97b9d7e 100644 --- a/tests/constants.spec.ts +++ b/tests/constants.spec.ts @@ -12,7 +12,7 @@ import { WORD_COUNTS, WORDLIST_SIZE, wordCountForEntropyBits, -} from "../src/constants/bip39.ts"; +} from "../src/bip39/constants.ts"; test("BIP39 constants match spec", () => { assert.deepEqual(ENTROPY_BYTES, [16, 20, 24, 28, 32]); diff --git a/tests/crypto.spec.ts b/tests/crypto.spec.ts index e0d3ad3..9d12bea 100644 --- a/tests/crypto.spec.ts +++ b/tests/crypto.spec.ts @@ -5,8 +5,8 @@ import { Pbkdf2FailureError, pbkdf2HmacSha512, sha256, -} from "../src/crypto/crypto.ts"; -import { ErrorCode } from "../src/errors/errorCodes.ts"; +} from "../src/bip39/crypto.ts"; +import { ErrorCode } from "../src/bip39/errorCodes.ts"; const bytesToHex = (bytes: Uint8Array): string => Array.from(bytes) diff --git a/tests/entropy-generator.spec.ts b/tests/entropy-generator.spec.ts index 935d307..8496e01 100644 --- a/tests/entropy-generator.spec.ts +++ b/tests/entropy-generator.spec.ts @@ -6,7 +6,7 @@ import { type EntropyGenerator, generateEntropy, InvalidEntropyLengthError, -} from "../src/entropy/entropyGenerator.ts"; +} from "../src/bip39/entropyGenerator.ts"; const allowed = [16, 20, 24, 28, 32]; diff --git a/tests/entropy-to-mnemonic.spec.ts b/tests/entropy-to-mnemonic.spec.ts index 8e10529..67bb8f4 100644 --- a/tests/entropy-to-mnemonic.spec.ts +++ b/tests/entropy-to-mnemonic.spec.ts @@ -7,7 +7,7 @@ import { EntropyLengthError, entropyToMnemonic, } from "../src/bip39/entropyToMnemonic.ts"; -import { ErrorCode } from "../src/errors/errorCodes.ts"; +import { ErrorCode } from "../src/bip39/errorCodes.ts"; type Vector = [string, string, string, string]; diff --git a/tests/errors.spec.ts b/tests/errors.spec.ts index 08e03de..19cda39 100644 --- a/tests/errors.spec.ts +++ b/tests/errors.spec.ts @@ -5,7 +5,7 @@ import { ErrorCode, MNEMONIC_TO_SEED_ERROR_PRIORITY, MNEMONIC_VALIDATION_ERROR_PRIORITY, -} from "../src/errors/errorCodes.ts"; +} from "../src/bip39/errorCodes.ts"; test("Error codes are fixed", () => { const codes = Object.values(ErrorCode); diff --git a/tests/integration.spec.ts b/tests/integration.spec.ts index 442065b..22247ac 100644 --- a/tests/integration.spec.ts +++ b/tests/integration.spec.ts @@ -1,8 +1,7 @@ import assert from "node:assert/strict"; import { test } from "vitest"; - +import { ErrorCode } from "../src/bip39/errorCodes.ts"; import { mnemonicToSeed } from "../src/bip39/mnemonicToSeed.ts"; -import { ErrorCode } from "../src/errors/errorCodes.ts"; import { deriveBip32RootFromMnemonic, deriveSeedForUi, diff --git a/tests/mnemonic-to-entropy.spec.ts b/tests/mnemonic-to-entropy.spec.ts index 8b80e9b..4f6ceee 100644 --- a/tests/mnemonic-to-entropy.spec.ts +++ b/tests/mnemonic-to-entropy.spec.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { test } from "vitest"; - +import { ErrorCode } from "../src/bip39/errorCodes.ts"; import { ChecksumMismatchError, InvalidMnemonicFormatError, @@ -11,7 +11,6 @@ import { mnemonicToEntropy, WordNotInListError, } from "../src/bip39/mnemonic.ts"; -import { ErrorCode } from "../src/errors/errorCodes.ts"; type Vector = [string, string, string, string]; diff --git a/tests/mnemonic-to-seed.spec.ts b/tests/mnemonic-to-seed.spec.ts index b4f2e18..a26bdfc 100644 --- a/tests/mnemonic-to-seed.spec.ts +++ b/tests/mnemonic-to-seed.spec.ts @@ -3,12 +3,11 @@ import { pbkdf2Sync } from "node:crypto"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { test } from "vitest"; - +import { ErrorCode } from "../src/bip39/errorCodes.ts"; import { InvalidMnemonicSeedFormatError, mnemonicToSeed, } from "../src/bip39/mnemonicToSeed.ts"; -import { ErrorCode } from "../src/errors/errorCodes.ts"; const toHex = (bytes: Uint8Array): string => Array.from(bytes) diff --git a/tests/normalize-mnemonic.spec.ts b/tests/normalize-mnemonic.spec.ts index 0ec8b8f..26ae1c5 100644 --- a/tests/normalize-mnemonic.spec.ts +++ b/tests/normalize-mnemonic.spec.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import { normalizeMnemonicInput } from "../src/normalize/normalizeMnemonicInput.ts"; +import { normalizeMnemonicInput } from "../src/integration/normalizeMnemonicInput.ts"; test("normalizeMnemonicInput trims and collapses whitespace", () => { const input = " Abandon\tabandon\nABOUT "; diff --git a/tests/strict-mnemonic.spec.ts b/tests/strict-mnemonic.spec.ts index 15dda36..8555a04 100644 --- a/tests/strict-mnemonic.spec.ts +++ b/tests/strict-mnemonic.spec.ts @@ -1,11 +1,11 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import { ErrorCode } from "../src/errors/errorCodes.ts"; +import { ErrorCode } from "../src/bip39/errorCodes.ts"; import { parseMnemonicWordsStrict, type StrictMnemonicParseResult, -} from "../src/parser/strictMnemonic.ts"; +} from "../src/bip39/strictMnemonic.ts"; const validMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; diff --git a/tests/validate-mnemonic.spec.ts b/tests/validate-mnemonic.spec.ts index 10aa7d7..b8faec3 100644 --- a/tests/validate-mnemonic.spec.ts +++ b/tests/validate-mnemonic.spec.ts @@ -2,9 +2,8 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { test } from "vitest"; - +import { ErrorCode } from "../src/bip39/errorCodes.ts"; import { validateMnemonic } from "../src/bip39/mnemonic.ts"; -import { ErrorCode } from "../src/errors/errorCodes.ts"; const validMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; diff --git a/tests/wordlist-loaders.spec.ts b/tests/wordlist-loaders.spec.ts index ff8419a..38709b5 100644 --- a/tests/wordlist-loaders.spec.ts +++ b/tests/wordlist-loaders.spec.ts @@ -52,7 +52,7 @@ test.each([ fileReads.readFile.mockResolvedValue(text); fileReads.readFileSync.mockReturnValue(text); const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = - await import("../src/wordlist/wordlist.ts"); + await import("../src/bip39/wordlist.ts"); for (const list of [await loadAsync(), loadSync()]) { assert.deepEqual(list.words, words); assert.deepEqual( @@ -127,7 +127,7 @@ test.each([ fileReads.readFile.mockResolvedValue(input.lines.join("\n")); fileReads.readFileSync.mockReturnValue(input.lines.join("\n")); const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = - await import("../src/wordlist/wordlist.ts"); + await import("../src/bip39/wordlist.ts"); await assert.rejects(loadAsync, { name: "Error", message: input.asyncMessage, @@ -137,7 +137,7 @@ test.each([ test("English loaders reuse a successfully loaded dictionary", async () => { const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = - await import("../src/wordlist/wordlist.ts"); + await import("../src/bip39/wordlist.ts"); const asyncList = await loadAsync(); const syncList = loadSync(); fileReads.readFile.mockRejectedValue(new Error("Wordlist unavailable")); @@ -155,7 +155,7 @@ test("English loaders retry after a failed file read", async () => { throw failure; }); const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = - await import("../src/wordlist/wordlist.ts"); + await import("../src/bip39/wordlist.ts"); await assert.rejects(loadAsync, failure); assert.throws(loadSync, failure); assert.equal((await loadAsync()).words[0], "abandon"); @@ -166,7 +166,7 @@ test("English loaders retry after malformed wordlist contents", async () => { fileReads.readFile.mockResolvedValueOnce("incomplete\n"); fileReads.readFileSync.mockReturnValueOnce("incomplete\n"); const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = - await import("../src/wordlist/wordlist.ts"); + await import("../src/bip39/wordlist.ts"); const error = { name: "Error", message: "Wordlist must contain 2048 words, got 1", @@ -182,7 +182,7 @@ test.each([ "sync first", ])("English loader caches stay separate when initialized %s", async (order) => { const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = - await import("../src/wordlist/wordlist.ts"); + await import("../src/bip39/wordlist.ts"); if (order === "sync first") loadSync(); const asyncList = await loadAsync(); const syncList = loadSync(); @@ -202,7 +202,7 @@ test("an asynchronous read can finish independently of a synchronous read", asyn }), ); const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = - await import("../src/wordlist/wordlist.ts"); + await import("../src/bip39/wordlist.ts"); const pending = loadAsync(); assert.ok(pending instanceof Promise); assert.equal(fileReads.readFileSync.mock.calls.length, 0); @@ -223,7 +223,7 @@ test("a pending asynchronous read can fail and retry without affecting the synch }), ); const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = - await import("../src/wordlist/wordlist.ts"); + await import("../src/bip39/wordlist.ts"); const pending = loadAsync(); const failure = new Error("Asynchronous read failed"); const rejection = assert.rejects(pending, failure); @@ -238,7 +238,7 @@ test("a pending asynchronous read can fail and retry without affecting the synch }); test("mutating the public dictionary does not affect core BIP39 operations", async () => { - const { loadEnglishWordlist } = await import("../src/wordlist/wordlist.ts"); + const { loadEnglishWordlist } = await import("../src/bip39/wordlist.ts"); const { entropyToMnemonic } = await import( "../src/bip39/entropyToMnemonic.ts" ); diff --git a/tests/wordlist.spec.ts b/tests/wordlist.spec.ts index 8939e3a..fb86cb2 100644 --- a/tests/wordlist.spec.ts +++ b/tests/wordlist.spec.ts @@ -7,7 +7,7 @@ import { loadEnglishWordlist, parseWordlist, wordToIndex, -} from "../src/wordlist/wordlist.ts"; +} from "../src/bip39/wordlist.ts"; const makeWords = (count: number): string[] => Array.from({ length: count }, (_, i) => `word${i}`); From 0f114c11db2b95b3ea3a85acdff0b9de46c8dc7b Mon Sep 17 00:00:00 2001 From: xt0x Date: Tue, 8 Sep 2026 23:46:04 +0900 Subject: [PATCH 6/7] refactor(vscode): update settings structure and add markdown formatter --- .vscode/settings.json | 31 +++++++++++--------- README.md | 67 ++++++++++++++++++++++++++----------------- 2 files changed, 57 insertions(+), 41 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index a555cf2..8b176df 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,16 +1,19 @@ { - "editor.formatOnSave": true, - "editor.defaultFormatter": "biomejs.biome", - "editor.codeActionsOnSave": { - "source.fixAll.biome": "explicit", - "source.organizeImports.biome": "explicit" - }, - "github.copilot.chat.commitMessageGeneration.instructions": [ - { - "file": ".vscode/commit-message-instructions.md" - } - ], - "[solidity]": { - "editor.defaultFormatter": "JuanBlanco.solidity" - } + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome", + "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit", + "source.organizeImports.biome": "explicit" + }, + "github.copilot.chat.commitMessageGeneration.instructions": [ + { + "file": ".vscode/commit-message-instructions.md" + } + ], + "[solidity]": { + "editor.defaultFormatter": "JuanBlanco.solidity" + }, + "[markdown]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } } diff --git a/README.md b/README.md index 7ce7e4e..c99bacb 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,13 @@ -
-

BIP39

-

TypeScript library and CLI for generating, validating, and converting BIP39 English mnemonics

+# BIP39 + +**TypeScript library and CLI for generating, validating, and converting BIP39 English mnemonics** + License TypeScript Node pnpm -
- -## Project Overview +## Overview This repository provides the core BIP39 workflows for the English wordlist profile: @@ -77,11 +76,11 @@ node dist/cli/index.js mnemonic-to-seed "abandon abandon abandon abandon abandon ```ts import { - entropyToMnemonic, - generateEntropy, - mnemonicToEntropy, - mnemonicToSeed, - validateMnemonic, + entropyToMnemonic, + generateEntropy, + mnemonicToEntropy, + mnemonicToSeed, + validateMnemonic, } from "./dist/index.js"; const entropy = generateEntropy(16); @@ -91,10 +90,10 @@ const seed = mnemonicToSeed(mnemonic, "TREZOR"); const validation = validateMnemonic(mnemonic); console.log({ - mnemonic, - roundTripEntropyLength: roundTripEntropy.length, - seedLength: seed.length, - validation, + mnemonic, + roundTripEntropyLength: roundTripEntropy.length, + seedLength: seed.length, + validation, }); ``` @@ -134,23 +133,37 @@ The main repository-level configuration files are: - `tsconfig.json` for TypeScript compilation and build output settings - `biome.json` for linting and formatting -## Directory Structure +## Structure ```text . -├── assets/ # Pinned specification assets and test vectors -├── src/ # TypeScript source code -│ ├── bip39/ # BIP39 library, primitives, entropy, and wordlists -│ ├── cli/ # Command-line interface -│ ├── integration/ # Shared input normalization and external adapters -│ └── index.ts # Public export surface -├── tests/ # Unit and integration tests -├── biome.json # Lint/format configuration -├── package.json # Scripts and package metadata -├── README.md # Project overview and usage -└── tsconfig.json # TypeScript compilation configuration +├── assets/ +├── src/ +│ ├── bip39/ +│ ├── cli/ +│ ├── integration/ +│ └── index.ts +├── tests/ +├── biome.json +├── package.json +├── README.md +└── tsconfig.json ``` +| Path | Description | +| --- | --- | +| `assets/` | Pinned specification assets and test vectors | +| `src/` | TypeScript source code | +| `src/bip39/` | BIP39 library, primitives, entropy, and wordlists | +| `src/cli/` | Command-line interface | +| `src/integration/` | Shared input normalization and external adapters | +| `src/index.ts` | Public export surface | +| `tests/` | Unit and integration tests | +| `biome.json` | Lint/format configuration | +| `package.json` | Scripts and package metadata | +| `README.md` | Project overview and usage | +| `tsconfig.json` | TypeScript compilation configuration | + ## License MIT - see [LICENSE](LICENSE). From b3208a5c417556f2ccce9bb4e3d9e76da0a1a9d8 Mon Sep 17 00:00:00 2001 From: xt0x Date: Wed, 9 Sep 2026 00:38:54 +0900 Subject: [PATCH 7/7] refactor(vscode): format settings for consistency and readability --- .vscode/settings.json | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 8b176df..4f9e6a0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,19 +1,19 @@ { - "editor.formatOnSave": true, - "editor.defaultFormatter": "biomejs.biome", - "editor.codeActionsOnSave": { - "source.fixAll.biome": "explicit", - "source.organizeImports.biome": "explicit" - }, - "github.copilot.chat.commitMessageGeneration.instructions": [ - { - "file": ".vscode/commit-message-instructions.md" - } - ], - "[solidity]": { - "editor.defaultFormatter": "JuanBlanco.solidity" - }, - "[markdown]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - } + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome", + "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit", + "source.organizeImports.biome": "explicit" + }, + "github.copilot.chat.commitMessageGeneration.instructions": [ + { + "file": ".vscode/commit-message-instructions.md" + } + ], + "[solidity]": { + "editor.defaultFormatter": "JuanBlanco.solidity" + }, + "[markdown]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } }