diff --git a/.vscode/settings.json b/.vscode/settings.json
index a555cf2..4f9e6a0 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -12,5 +12,8 @@
],
"[solidity]": {
"editor.defaultFormatter": "JuanBlanco.solidity"
+ },
+ "[markdown]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
diff --git a/README.md b/README.md
index 244db76..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**
+

-
-
-## 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,31 +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/ # Core entropy/mnemonic/seed workflows
-│ ├── bits/ # Bit conversion helpers
-│ ├── 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
-│ ├── types/ # Shared DTOs and result types
-│ └── 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).
diff --git a/src/DESIGN.md b/src/DESIGN.md
index cf8c13d..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.
-- `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/types/` defines shared DTOs such as `ValidationResult`.
-- `src/wordlist/` loads and validates the English wordlist with index mappings.
-- `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 cdd1851..4b5527b 100644
--- a/src/bip39/DESIGN.md
+++ b/src/bip39/DESIGN.md
@@ -1,6 +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.
+- `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/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/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 8f1845d..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 { loadEnglishWordlist } from "./englishWordlist.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;
@@ -32,7 +29,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/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
new file mode 100644
index 0000000..55e2657
--- /dev/null
+++ b/src/bip39/mnemonic.ts
@@ -0,0 +1,176 @@
+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;
+ error_code: ErrorCode | null;
+ normalized_mnemonic: string | null;
+ word_count: number | null;
+ invalid_word: string | null;
+};
+
+export class MnemonicToEntropyError extends Error {
+ code: ErrorCode;
+
+ constructor(code: ErrorCode, message: string) {
+ super(message);
+ this.code = code;
+ this.name = "MnemonicToEntropyError";
+ }
+}
+
+export class InvalidMnemonicFormatError extends MnemonicToEntropyError {
+ constructor(message = "Invalid mnemonic format") {
+ super(ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, message);
+ this.name = "InvalidMnemonicFormatError";
+ }
+}
+
+export class InvalidWordCountError extends MnemonicToEntropyError {
+ constructor(message = "Invalid word count") {
+ super(ErrorCode.ERR_INVALID_WORD_COUNT, message);
+ this.name = "InvalidWordCountError";
+ }
+}
+
+export class WordNotInListError extends MnemonicToEntropyError {
+ constructor(message = "Word not in list") {
+ super(ErrorCode.ERR_WORD_NOT_IN_LIST, message);
+ this.name = "WordNotInListError";
+ }
+}
+
+export class ChecksumMismatchError extends MnemonicToEntropyError {
+ constructor(message = "Checksum mismatch") {
+ super(ErrorCode.ERR_CHECKSUM_MISMATCH, message);
+ this.name = "ChecksumMismatchError";
+ }
+}
+
+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);
+
+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]);
+
+const decodeMnemonic = (input: string | string[]): MnemonicDecodeResult => {
+ 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,
+ 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/mnemonicToEntropy.ts b/src/bip39/mnemonicToEntropy.ts
deleted file mode 100644
index 19fc240..0000000
--- a/src/bip39/mnemonicToEntropy.ts
+++ /dev/null
@@ -1,89 +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 { loadEnglishWordlist } from "./englishWordlist.js";
-
-export class MnemonicToEntropyError extends Error {
- code: ErrorCode;
-
- constructor(code: ErrorCode, message: string) {
- super(message);
- this.code = code;
- this.name = "MnemonicToEntropyError";
- }
-}
-
-export class InvalidMnemonicFormatError extends MnemonicToEntropyError {
- constructor(message = "Invalid mnemonic format") {
- super(ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, message);
- this.name = "InvalidMnemonicFormatError";
- }
-}
-
-export class InvalidWordCountError extends MnemonicToEntropyError {
- constructor(message = "Invalid word count") {
- super(ErrorCode.ERR_INVALID_WORD_COUNT, message);
- this.name = "InvalidWordCountError";
- }
-}
-
-export class WordNotInListError extends MnemonicToEntropyError {
- constructor(message = "Word not in list") {
- super(ErrorCode.ERR_WORD_NOT_IN_LIST, message);
- this.name = "WordNotInListError";
- }
-}
-
-export class ChecksumMismatchError extends MnemonicToEntropyError {
- constructor(message = "Checksum mismatch") {
- super(ErrorCode.ERR_CHECKSUM_MISMATCH, message);
- this.name = "ChecksumMismatchError";
- }
-}
-
-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 mnemonicToEntropy = (input: string | string[]): Uint8Array => {
- const parsed = parseMnemonicWordsStrict(input);
- if (!parsed.ok) {
- throw new InvalidMnemonicFormatError();
- }
-
- const { words } = parsed;
- const wordCount = words.length;
- if (!isValidWordCount(wordCount)) {
- throw new InvalidWordCountError();
- }
-
- const { wordToIndex } = loadEnglishWordlist();
- const indices = words.map((word) => {
- const index = wordToIndex.get(word);
- if (index === undefined) {
- throw new WordNotInListError(`Word not in list: ${word}`);
- }
- return 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)) {
- throw new ChecksumMismatchError();
- }
-
- return entropy;
-};
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/bip39/validateMnemonic.ts b/src/bip39/validateMnemonic.ts
deleted file mode 100644
index 803672f..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 { loadEnglishWordlist } from "./englishWordlist.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 } = loadEnglishWordlist();
- 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/wordlist/wordlist.ts b/src/bip39/wordlist.ts
similarity index 65%
rename from src/wordlist/wordlist.ts
rename to src/bip39/wordlist.ts
index b0516cb..06885c6 100644
--- a/src/wordlist/wordlist.ts
+++ b/src/bip39/wordlist.ts
@@ -1,7 +1,8 @@
+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[];
@@ -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/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 df8992f..ee4be3b 100644
--- a/src/cli/DESIGN.md
+++ b/src/cli/DESIGN.md
@@ -2,7 +2,11 @@
- 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.
+- 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/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
new file mode 100644
index 0000000..243b6ad
--- /dev/null
+++ b/src/cli/commands.ts
@@ -0,0 +1,75 @@
+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 { loadEnglishWordlistSync } from "../bip39/wordlist.js";
+import { normalizeMnemonicInput } from "../integration/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 } = loadEnglishWordlistSync();
+ 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..a290e76 100644
--- a/src/cli/runCli.ts
+++ b/src/cli/runCli.ts
@@ -1,14 +1,16 @@
-import { ENTROPY_BYTES, WORD_COUNTS } from "../constants/bip39.js";
-import type { ErrorCode } from "../errors/errorCodes.js";
+import { ENTROPY_BYTES, WORD_COUNTS } from "../bip39/constants.js";
+import { generateEntropy } from "../bip39/entropyGenerator.js";
+import { entropyToMnemonic } from "../bip39/entropyToMnemonic.js";
+import type { ErrorCode } from "../bip39/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/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 fbd7337..18a588e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,15 +1,20 @@
+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/mnemonicToEntropy.js";
+export * from "./bip39/errorCodes.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";
-export * from "./entropy/entropyGenerator.js";
-export * from "./errors/errorCodes.js";
+export * from "./bip39/strictMnemonic.js";
+export {
+ createWordlist,
+ indexToWord,
+ loadEnglishWordlist,
+ parseWordlist,
+ type Wordlist,
+ wordToIndex,
+} from "./bip39/wordlist.js";
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 * from "./wordlist/wordlist.js";
+export * from "./integration/normalizeMnemonicInput.js";
diff --git a/src/integration/DESIGN.md b/src/integration/DESIGN.md
index f06d337..dc097e2 100644
--- a/src/integration/DESIGN.md
+++ b/src/integration/DESIGN.md
@@ -1,5 +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 1f3ab18..9dc8610 100644
--- a/src/integration/externalIntegration.ts
+++ b/src/integration/externalIntegration.ts
@@ -1,9 +1,8 @@
+import { ErrorCode } from "../bip39/errorCodes.js";
+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";
+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/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/src/wordlist/DESIGN.md b/src/wordlist/DESIGN.md
deleted file mode 100644
index a2d116c..0000000
--- a/src/wordlist/DESIGN.md
+++ /dev/null
@@ -1,5 +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.
-- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only.
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/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 503ae30..119d58f 100644
--- a/tests/cli/commands.spec.ts
+++ b/tests/cli/commands.spec.ts
@@ -1,13 +1,15 @@
import assert from "node:assert/strict";
+import { pbkdf2Sync } from "node:crypto";
import { test } from "vitest";
-
-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 { ErrorCode } from "../../src/bip39/errorCodes.ts";
+import { InvalidMnemonicFormatError } from "../../src/bip39/mnemonic.ts";
+import {
+ generateMnemonicCommand,
+ generateMnemonicWithWordlistCommand,
+ mnemonicToEntropyCommand,
+ mnemonicToSeedCommand,
+ validateCommand,
+} from "../../src/cli/commands.ts";
const ENTROPY_HEX = "00000000000000000000000000000000";
const MNEMONIC =
@@ -16,19 +18,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);
@@ -40,26 +32,125 @@ 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);
-});
-
-test("generateEntropyCommand returns requested length", () => {
- const entropy = generateEntropyCommand(16);
- assert.equal(entropy.length, 16);
+ assert.deepEqual(validateCommand(MNEMONIC, true), {
+ ok: true,
+ normalized: MNEMONIC,
+ });
});
-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.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..f4863aa 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);
@@ -81,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/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 0e6c2f7..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];
@@ -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..67bb8f4 100644
--- a/tests/entropy-to-mnemonic.spec.ts
+++ b/tests/entropy-to-mnemonic.spec.ts
@@ -7,17 +7,19 @@ 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];
-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 () => {
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 0a7569f..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,
@@ -10,8 +10,7 @@ import {
MnemonicToEntropyError,
mnemonicToEntropy,
WordNotInListError,
-} from "../src/bip39/mnemonicToEntropy.ts";
-import { ErrorCode } from "../src/errors/errorCodes.ts";
+} from "../src/bip39/mnemonic.ts";
type Vector = [string, string, string, string];
@@ -75,3 +74,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..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)
@@ -56,6 +55,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 +63,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/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/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/validate-mnemonic.spec.ts b/tests/validate-mnemonic.spec.ts
index b873073..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 { validateMnemonic } from "../src/bip39/validateMnemonic.ts";
-import { ErrorCode } from "../src/errors/errorCodes.ts";
+import { ErrorCode } from "../src/bip39/errorCodes.ts";
+import { validateMnemonic } from "../src/bip39/mnemonic.ts";
const validMnemonic =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
diff --git a/tests/validation-result.spec.ts b/tests/validation-result.spec.ts
index bd88bbf..330d04b 100644
--- a/tests/validation-result.spec.ts
+++ b/tests/validation-result.spec.ts
@@ -1,19 +1,71 @@
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 type { ValidationResult as MnemonicValidationResult } from "../src/bip39/mnemonic.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();
+ 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..38709b5
--- /dev/null
+++ b/tests/wordlist-loaders.spec.ts
@@ -0,0 +1,320 @@
+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 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 },
+ { 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, loadEnglishWordlistSync: loadSync } =
+ await import("../src/bip39/wordlist.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: "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),
+ 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, loadEnglishWordlistSync: loadSync } =
+ await import("../src/bip39/wordlist.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, loadEnglishWordlistSync: loadSync } =
+ await import("../src/bip39/wordlist.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, loadEnglishWordlistSync: loadSync } =
+ await import("../src/bip39/wordlist.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, loadEnglishWordlistSync: loadSync } =
+ await import("../src/bip39/wordlist.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.each([
+ "async first",
+ "sync first",
+])("English loader caches stay separate when initialized %s", async (order) => {
+ const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } =
+ await import("../src/bip39/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/bip39/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/bip39/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/bip39/wordlist.ts");
+ const { entropyToMnemonic } = await import(
+ "../src/bip39/entropyToMnemonic.ts"
+ );
+ const { validateMnemonic, mnemonicToEntropy } = await import(
+ "../src/bip39/mnemonic.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, mnemonicToEntropy } = await import(
+ "../src/bip39/mnemonic.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);
+});
+
+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));
+});
diff --git a/tests/wordlist.spec.ts b/tests/wordlist.spec.ts
index 3ae97bc..fb86cb2 100644
--- a/tests/wordlist.spec.ts
+++ b/tests/wordlist.spec.ts
@@ -7,15 +7,12 @@ 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}`);
-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 () => {
@@ -70,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);
+});