diff --git a/biome.json b/biome.json index 88e5b0c..8eb7df5 100644 --- a/biome.json +++ b/biome.json @@ -6,7 +6,13 @@ "useIgnoreFile": true }, "files": { - "includes": ["**", "!!**/dist", "!!**/coverage", "!notes/grass-source"] + "includes": [ + "**", + "!!**/dist", + "!!**/coverage", + "!assets", + "!notes/grass-source" + ] }, "formatter": { "enabled": true diff --git a/src/bip39/DESIGN.md b/src/bip39/DESIGN.md index 1245792..cdd1851 100644 --- a/src/bip39/DESIGN.md +++ b/src/bip39/DESIGN.md @@ -2,4 +2,5 @@ - Purpose: Core BIP39 conversion functions built on fixed assets and primitives. - Scope: Deterministic conversions only; no UI or random entropy generation. +- Includes: `entropyToMnemonic`, `mnemonicToEntropy`, `mnemonicToSeed`, and `validateMnemonic`. - Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/bip39/englishWordlist.ts b/src/bip39/englishWordlist.ts new file mode 100644 index 0000000..d8cc778 --- /dev/null +++ b/src/bip39/englishWordlist.ts @@ -0,0 +1,44 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { WORDLIST_SIZE } from "../constants/bip39.js"; + +export type EnglishWordlist = { + words: string[]; + wordToIndex: Map; +}; + +const ENGLISH_WORDLIST_PATH = "assets/english.txt"; + +let cachedEnglishWordlist: EnglishWordlist | null = null; + +export const loadEnglishWordlist = (): EnglishWordlist => { + if (cachedEnglishWordlist) { + return cachedEnglishWordlist; + } + const filePath = resolve(process.cwd(), ENGLISH_WORDLIST_PATH); + const text = readFileSync(filePath, "utf8"); + const lines = text + .split("\n") + .map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)); + if (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); + } + if (lines.length !== WORDLIST_SIZE) { + throw new Error( + `Wordlist must contain ${WORDLIST_SIZE} words, got ${lines.length}`, + ); + } + const wordToIndex = new Map(); + lines.forEach((word, index) => { + if (word.length === 0) { + throw new Error("Wordlist contains an empty word"); + } + if (wordToIndex.has(word)) { + throw new Error(`Duplicate word detected: ${word}`); + } + wordToIndex.set(word, index); + }); + cachedEnglishWordlist = { words: lines, wordToIndex }; + return cachedEnglishWordlist; +}; diff --git a/src/bip39/entropyToMnemonic.ts b/src/bip39/entropyToMnemonic.ts index 9ce9aa9..8f1845d 100644 --- a/src/bip39/entropyToMnemonic.ts +++ b/src/bip39/entropyToMnemonic.ts @@ -1,18 +1,11 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; - import { bitsToIntegers, bytesToBits } from "../bits/bitOps.js"; import { checksumBitsForEntropyBits, ENTROPY_BYTES, - WORDLIST_SIZE, } from "../constants/bip39.js"; import { sha256 } from "../crypto/crypto.js"; import { ErrorCode } from "../errors/errorCodes.js"; - -const ENGLISH_WORDLIST_PATH = "assets/english.txt"; - -let cachedEnglishWords: string[] | null = null; +import { loadEnglishWordlist } from "./englishWordlist.js"; export class EntropyLengthError extends Error { code = ErrorCode.ERR_ENTROPY_LENGTH; @@ -23,37 +16,6 @@ export class EntropyLengthError extends Error { } } -const loadEnglishWords = (): string[] => { - if (cachedEnglishWords) { - return cachedEnglishWords; - } - 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 seen = new Set(); - for (const word of lines) { - if (word.length === 0) { - throw new Error("Wordlist contains an empty word"); - } - if (seen.has(word)) { - throw new Error(`Duplicate word detected: ${word}`); - } - seen.add(word); - } - cachedEnglishWords = lines; - return lines; -}; - const isValidEntropyLength = (entropyBytes: number): boolean => (ENTROPY_BYTES as readonly number[]).includes(entropyBytes); @@ -70,7 +32,7 @@ export const entropyToMnemonic = (entropy: Uint8Array): string => { const checksum = bytesToBits(sha256(entropy)).slice(0, checksumBits); const combined = entropyBitArray.concat(checksum); const indices = bitsToIntegers(combined, 11); - const words = loadEnglishWords(); + const { words } = loadEnglishWordlist(); const mnemonicWords = indices.map((index) => { const word = words[index]; if (word === undefined) { diff --git a/src/bip39/mnemonicToEntropy.ts b/src/bip39/mnemonicToEntropy.ts new file mode 100644 index 0000000..19fc240 --- /dev/null +++ b/src/bip39/mnemonicToEntropy.ts @@ -0,0 +1,89 @@ +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 new file mode 100644 index 0000000..ba39530 --- /dev/null +++ b/src/bip39/mnemonicToSeed.ts @@ -0,0 +1,49 @@ +import { pbkdf2HmacSha512 } from "../crypto/crypto.js"; +import { ErrorCode } from "../errors/errorCodes.js"; + +export class InvalidMnemonicSeedFormatError extends Error { + code = ErrorCode.ERR_INVALID_MNEMONIC_FORMAT; + + constructor(message = "Invalid mnemonic format") { + super(message); + this.name = "InvalidMnemonicSeedFormatError"; + } +} + +const hasWhitespace = (value: string): boolean => /\s/u.test(value); + +const normalizeNfkd = (value: string): string => value.normalize("NFKD"); + +const normalizeMnemonicInput = (input: string | string[]): string => { + if (typeof input === "string") { + return input; + } + if (!Array.isArray(input) || input.length === 0) { + throw new InvalidMnemonicSeedFormatError(); + } + for (const item of input) { + if (typeof item !== "string") { + throw new InvalidMnemonicSeedFormatError(); + } + if (item.length === 0 || hasWhitespace(item)) { + throw new InvalidMnemonicSeedFormatError(); + } + } + return input.join(" "); +}; + +export const mnemonicToSeed = ( + mnemonic: string | string[], + passphrase = "", +): Uint8Array => { + if (typeof passphrase !== "string") { + throw new InvalidMnemonicSeedFormatError(); + } + const mnemonicText = normalizeMnemonicInput(mnemonic); + const normalizedMnemonic = normalizeNfkd(mnemonicText); + const normalizedPassphrase = normalizeNfkd(passphrase); + const encoder = new TextEncoder(); + const password = encoder.encode(normalizedMnemonic); + const salt = encoder.encode(`mnemonic${normalizedPassphrase}`); + return pbkdf2HmacSha512(password, salt); +}; diff --git a/src/bip39/validateMnemonic.ts b/src/bip39/validateMnemonic.ts new file mode 100644 index 0000000..803672f --- /dev/null +++ b/src/bip39/validateMnemonic.ts @@ -0,0 +1,85 @@ +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/index.ts b/src/index.ts index 36a1d12..35e4e13 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ export * from "./bip39/entropyToMnemonic.js"; +export * from "./bip39/mnemonicToEntropy.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"; diff --git a/test/acceptance.spec.ts b/test/acceptance.spec.ts new file mode 100644 index 0000000..6d82d37 --- /dev/null +++ b/test/acceptance.spec.ts @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import test from "node:test"; + +import { + EntropyLengthError, + entropyToMnemonic, +} from "../src/bip39/entropyToMnemonic.ts"; +import { + ChecksumMismatchError, + InvalidMnemonicFormatError, + InvalidWordCountError, + 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"; + +const hexToBytes = (hex: string): Uint8Array => + Uint8Array.from(hex.match(/.{2}/g) ?? [], (byte) => + Number.parseInt(byte, 16), + ); + +const bytesToHex = (bytes: Uint8Array): string => + Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + +type Vector = [string, string, string, string]; + +const loadVectors = async (): Promise => { + const filePath = resolve(process.cwd(), "assets/vectors.json"); + const payload = JSON.parse(await readFile(filePath, "utf8")) as { + english: Vector[]; + }; + return payload.english; +}; + +test("roundtrip entropy -> mnemonic -> entropy holds for vectors", async () => { + const vectors = await loadVectors(); + for (const [entropyHex, mnemonic] of vectors) { + const entropy = hexToBytes(entropyHex); + const derivedMnemonic = entropyToMnemonic(entropy); + assert.equal(derivedMnemonic, mnemonic); + const roundtrip = mnemonicToEntropy(derivedMnemonic); + assert.equal(bytesToHex(roundtrip), entropyHex); + } +}); + +test("roundtrip covers all allowed entropy lengths", () => { + const lengths = [16, 20, 24, 28, 32]; + for (const length of lengths) { + const entropy = Uint8Array.from({ length }, (_, i) => i & 0xff); + const mnemonic = entropyToMnemonic(entropy); + const roundtrip = mnemonicToEntropy(mnemonic); + assert.equal(bytesToHex(roundtrip), bytesToHex(entropy)); + } +}); + +test("failure cases from appendix C are enforced", () => { + assert.throws( + () => entropyToMnemonic(new Uint8Array(15)), + (error) => + error instanceof EntropyLengthError && + error.code === ErrorCode.ERR_ENTROPY_LENGTH, + ); + + assert.throws( + () => + mnemonicToEntropy( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon", + ), + (error) => + error instanceof InvalidWordCountError && + error.code === ErrorCode.ERR_INVALID_WORD_COUNT, + ); + + assert.throws( + () => + mnemonicToEntropy( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon", + ), + (error) => + error instanceof ChecksumMismatchError && + error.code === ErrorCode.ERR_CHECKSUM_MISMATCH, + ); + + assert.throws( + () => + mnemonicToEntropy( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon typo", + ), + (error) => + error instanceof WordNotInListError && + error.code === ErrorCode.ERR_WORD_NOT_IN_LIST, + ); + + assert.throws( + () => + mnemonicToEntropy( + "abandon\tabandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + ), + (error) => + error instanceof InvalidMnemonicFormatError && + error.code === ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + ); + + assert.throws( + () => mnemonicToSeed(["abandon", "", "abandon"]), + (error) => + error instanceof InvalidMnemonicSeedFormatError && + error.code === ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + ); +}); + +test("error priority favors word list before checksum", () => { + const result = validateMnemonic( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon typo", + ); + assert.equal(result.ok, false); + assert.equal(result.error_code, ErrorCode.ERR_WORD_NOT_IN_LIST); + assert.equal(result.invalid_word, "typo"); +}); + +test("error priority favors invalid format before word count", () => { + const result = validateMnemonic( + "abandon\tabandon abandon abandon abandon abandon abandon abandon abandon abandon abandon", + ); + assert.equal(result.ok, false); + assert.equal(result.error_code, ErrorCode.ERR_INVALID_MNEMONIC_FORMAT); +}); diff --git a/test/mnemonic-to-entropy.spec.ts b/test/mnemonic-to-entropy.spec.ts new file mode 100644 index 0000000..3cdc5b2 --- /dev/null +++ b/test/mnemonic-to-entropy.spec.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import test from "node:test"; + +import { + ChecksumMismatchError, + InvalidMnemonicFormatError, + InvalidWordCountError, + MnemonicToEntropyError, + mnemonicToEntropy, + WordNotInListError, +} from "../src/bip39/mnemonicToEntropy.ts"; +import { ErrorCode } from "../src/errors/errorCodes.ts"; + +type Vector = [string, string, string, string]; + +const validMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +test("mnemonicToEntropy rejects invalid format", () => { + assert.throws( + () => mnemonicToEntropy(` ${validMnemonic}`), + (error) => + error instanceof InvalidMnemonicFormatError && + error.code === ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + ); +}); + +test("mnemonicToEntropy rejects invalid word count", () => { + assert.throws( + () => mnemonicToEntropy(validMnemonic.replace(" about", "")), + (error) => + error instanceof InvalidWordCountError && + error.code === ErrorCode.ERR_INVALID_WORD_COUNT, + ); +}); + +test("mnemonicToEntropy rejects word not in list", () => { + assert.throws( + () => mnemonicToEntropy(validMnemonic.replace("about", "typo")), + (error) => + error instanceof WordNotInListError && + error.code === ErrorCode.ERR_WORD_NOT_IN_LIST, + ); +}); + +test("mnemonicToEntropy rejects checksum mismatch", () => { + const invalid = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"; + assert.throws( + () => mnemonicToEntropy(invalid), + (error) => + error instanceof ChecksumMismatchError && + error.code === ErrorCode.ERR_CHECKSUM_MISMATCH, + ); +}); + +test("mnemonicToEntropy matches official vectors", async () => { + const filePath = resolve(process.cwd(), "assets/vectors.json"); + const payload = JSON.parse(await readFile(filePath, "utf8")) as { + english: Vector[]; + }; + + for (const [entropyHex, mnemonic] of payload.english) { + const entropy = mnemonicToEntropy(mnemonic); + const hex = Array.from(entropy) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + assert.equal(hex, entropyHex); + } +}); + +test("mnemonicToEntropy error types share base class", () => { + const error = new InvalidMnemonicFormatError(); + assert.ok(error instanceof MnemonicToEntropyError); +}); diff --git a/test/mnemonic-to-seed.spec.ts b/test/mnemonic-to-seed.spec.ts new file mode 100644 index 0000000..2e985d3 --- /dev/null +++ b/test/mnemonic-to-seed.spec.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { pbkdf2Sync } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import test from "node:test"; + +import { + InvalidMnemonicSeedFormatError, + mnemonicToSeed, +} from "../src/bip39/mnemonicToSeed.ts"; +import { ErrorCode } from "../src/errors/errorCodes.ts"; + +const toHex = (bytes: Uint8Array): string => + Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + +const validMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +const deriveWithNode = (mnemonic: string, passphrase: string): string => { + const normalizedMnemonic = mnemonic.normalize("NFKD"); + const normalizedPassphrase = passphrase.normalize("NFKD"); + const salt = `mnemonic${normalizedPassphrase}`; + const derived = pbkdf2Sync(normalizedMnemonic, salt, 2048, 64, "sha512"); + return derived.toString("hex"); +}; + +test("mnemonicToSeed rejects invalid list input", () => { + assert.throws( + () => mnemonicToSeed(["abandon", ""]), + (error) => + error instanceof InvalidMnemonicSeedFormatError && + error.code === ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + ); + assert.throws( + () => mnemonicToSeed(["abandon", "about about"]), + (error) => + error instanceof InvalidMnemonicSeedFormatError && + error.code === ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + ); + assert.throws( + () => mnemonicToSeed(["abandon", 123 as unknown as string]), + (error) => + error instanceof InvalidMnemonicSeedFormatError && + error.code === ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + ); +}); + +test("mnemonicToSeed matches official vectors with TREZOR", async () => { + const filePath = resolve(process.cwd(), "assets/vectors.json"); + const payload = JSON.parse(await readFile(filePath, "utf8")) as { + english: [string, string, string, string][]; + }; + + for (const [, mnemonic, seed] of payload.english) { + const derived = mnemonicToSeed(mnemonic, "TREZOR"); + assert.equal(toHex(derived), seed); + } +}); + +test("mnemonicToSeed matches pbkdf2 output with empty passphrase", () => { + const expected = deriveWithNode(validMnemonic, ""); + const derived = mnemonicToSeed(validMnemonic, ""); + assert.equal(toHex(derived), expected); +}); diff --git a/test/validate-mnemonic.spec.ts b/test/validate-mnemonic.spec.ts new file mode 100644 index 0000000..ac561ea --- /dev/null +++ b/test/validate-mnemonic.spec.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import test from "node:test"; + +import { validateMnemonic } from "../src/bip39/validateMnemonic.ts"; +import { ErrorCode } from "../src/errors/errorCodes.ts"; + +const validMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +type Vector = [string, string, string, string]; + +test("validateMnemonic rejects invalid format", () => { + const result = validateMnemonic(` ${validMnemonic}`); + assert.deepEqual(result, { + ok: false, + error_code: ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + normalized_mnemonic: null, + word_count: null, + invalid_word: null, + }); +}); + +test("validateMnemonic rejects invalid word count", () => { + const result = validateMnemonic(validMnemonic.replace(" about", "")); + assert.equal(result.ok, false); + assert.equal(result.error_code, ErrorCode.ERR_INVALID_WORD_COUNT); + assert.equal(result.word_count, 11); +}); + +test("validateMnemonic rejects word not in list", () => { + const result = validateMnemonic(validMnemonic.replace("about", "typo")); + assert.equal(result.ok, false); + assert.equal(result.error_code, ErrorCode.ERR_WORD_NOT_IN_LIST); + assert.equal(result.invalid_word, "typo"); +}); + +test("validateMnemonic rejects checksum mismatch", () => { + const invalid = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"; + const result = validateMnemonic(invalid); + assert.equal(result.ok, false); + assert.equal(result.error_code, ErrorCode.ERR_CHECKSUM_MISMATCH); +}); + +test("validateMnemonic accepts valid mnemonic", () => { + const result = validateMnemonic(validMnemonic); + assert.equal(result.ok, true); + assert.equal(result.error_code, null); + assert.equal(result.normalized_mnemonic, validMnemonic); + assert.equal(result.word_count, 12); +}); + +test("validateMnemonic matches official vectors", async () => { + const filePath = resolve(process.cwd(), "assets/vectors.json"); + const payload = JSON.parse(await readFile(filePath, "utf8")) as { + english: Vector[]; + }; + + for (const [, mnemonic] of payload.english) { + const result = validateMnemonic(mnemonic); + assert.equal(result.ok, true); + } +});