Skip to content

jmwallet.cli.mnemonic

jmwallet.cli.mnemonic

Mnemonic generation, validation, encryption, and interactive input.

Attributes

MnemonicMetaValue = int | str module-attribute

Functions:

decrypt_mnemonic(encrypted_data: bytes, password: str) -> str

Decrypt a mnemonic with a password.

Args: encrypted_data: The encrypted bytes (salt + Fernet token) password: The password for decryption

Returns: The decrypted mnemonic phrase

Raises: ValueError: If decryption fails (wrong password or corrupted data)

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def decrypt_mnemonic(encrypted_data: bytes, password: str) -> str:
    """
    Decrypt a mnemonic with a password.

    Args:
        encrypted_data: The encrypted bytes (salt + Fernet token)
        password: The password for decryption

    Returns:
        The decrypted mnemonic phrase

    Raises:
        ValueError: If decryption fails (wrong password or corrupted data)
    """
    import base64

    from cryptography.fernet import Fernet, InvalidToken
    from cryptography.hazmat.primitives import hashes
    from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC

    if len(encrypted_data) < 16:
        raise ValueError("Invalid encrypted data")

    # Extract salt and encrypted token
    salt = encrypted_data[:16]
    encrypted_token = encrypted_data[16:]

    # Derive key from password
    kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(),
        length=32,
        salt=salt,
        iterations=600_000,
    )
    key = base64.urlsafe_b64encode(kdf.derive(password.encode("utf-8")))

    # Decrypt
    fernet = Fernet(key)
    try:
        decrypted = fernet.decrypt(encrypted_token)
        return decrypted.decode("utf-8")
    except InvalidToken as e:
        raise ValueError("Decryption failed - wrong password or corrupted file") from e
    except UnicodeDecodeError as e:
        raise ValueError(
            "Decrypted content is not valid UTF-8. File may be corrupted or "
            "encrypted with a different tool"
        ) from e

encrypt_mnemonic(mnemonic: str, password: str) -> bytes

Encrypt a mnemonic with a password using Fernet (AES-128-CBC).

Uses PBKDF2 to derive a key from the password.

Args: mnemonic: The mnemonic phrase to encrypt password: The password for encryption

Returns: Encrypted bytes (base64-encoded internally by Fernet)

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def encrypt_mnemonic(mnemonic: str, password: str) -> bytes:
    """
    Encrypt a mnemonic with a password using Fernet (AES-128-CBC).

    Uses PBKDF2 to derive a key from the password.

    Args:
        mnemonic: The mnemonic phrase to encrypt
        password: The password for encryption

    Returns:
        Encrypted bytes (base64-encoded internally by Fernet)
    """
    import base64

    from cryptography.fernet import Fernet
    from cryptography.hazmat.primitives import hashes
    from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC

    # Generate a random salt
    salt = os.urandom(16)

    # Derive a key from password using PBKDF2
    kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(),
        length=32,
        salt=salt,
        iterations=600_000,  # High iteration count for security
    )
    key = base64.urlsafe_b64encode(kdf.derive(password.encode("utf-8")))

    # Encrypt the mnemonic
    fernet = Fernet(key)
    encrypted = fernet.encrypt(mnemonic.encode("utf-8"))

    # Prepend salt to encrypted data
    return salt + encrypted

format_word_suggestions(matches: list[str], max_display: int = 8) -> str

Format word suggestions for display.

Args: matches: List of matching words max_display: Maximum number of words to display

Returns: Formatted suggestion string

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
414
415
416
417
418
419
420
421
422
423
424
425
426
427
def format_word_suggestions(matches: list[str], max_display: int = 8) -> str:
    """
    Format word suggestions for display.

    Args:
        matches: List of matching words
        max_display: Maximum number of words to display

    Returns:
        Formatted suggestion string
    """
    if len(matches) <= max_display:
        return ", ".join(matches)
    return ", ".join(matches[:max_display]) + f", ... (+{len(matches) - max_display} more)"

generate_mnemonic_secure(word_count: int = 24) -> str

Generate a BIP39 mnemonic from secure entropy.

Args: word_count: Number of words (12, 15, 18, 21, or 24)

Returns: BIP39 mnemonic phrase with valid checksum

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def generate_mnemonic_secure(word_count: int = 24) -> str:
    """
    Generate a BIP39 mnemonic from secure entropy.

    Args:
        word_count: Number of words (12, 15, 18, 21, or 24)

    Returns:
        BIP39 mnemonic phrase with valid checksum
    """
    from mnemonic import Mnemonic

    if word_count not in (12, 15, 18, 21, 24):
        raise ValueError("word_count must be 12, 15, 18, 21, or 24")

    # Calculate entropy bits: 12 words = 128 bits, 24 words = 256 bits
    # Formula: word_count * 11 = entropy_bits + checksum_bits
    # checksum_bits = entropy_bits / 32
    # So: word_count * 11 = entropy_bits * (1 + 1/32) = entropy_bits * 33/32
    # entropy_bits = word_count * 11 * 32 / 33
    entropy_bits = {12: 128, 15: 160, 18: 192, 21: 224, 24: 256}[word_count]

    m = Mnemonic("english")
    return m.generate(strength=entropy_bits)

get_bip39_wordlist() -> list[str]

Get the BIP39 English wordlist.

Returns: List of 2048 BIP39 words in order.

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
386
387
388
389
390
391
392
393
394
395
396
def get_bip39_wordlist() -> list[str]:
    """
    Get the BIP39 English wordlist.

    Returns:
        List of 2048 BIP39 words in order.
    """
    from mnemonic import Mnemonic

    m = Mnemonic("english")
    return list(m.wordlist)

get_word_completions(prefix: str, wordlist: list[str]) -> list[str]

Get BIP39 words that start with the given prefix.

Args: prefix: The prefix to match (case-insensitive) wordlist: The BIP39 wordlist

Returns: List of matching words

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
399
400
401
402
403
404
405
406
407
408
409
410
411
def get_word_completions(prefix: str, wordlist: list[str]) -> list[str]:
    """
    Get BIP39 words that start with the given prefix.

    Args:
        prefix: The prefix to match (case-insensitive)
        wordlist: The BIP39 wordlist

    Returns:
        List of matching words
    """
    prefix_lower = prefix.lower()
    return [w for w in wordlist if w.startswith(prefix_lower)]

interactive_mnemonic_input(word_count: int = 24) -> str

Interactively input a BIP39 mnemonic with autocomplete support.

Features: - Real-time suggestions as you type (shows matches when <= 10) - Auto-completes when only one word matches (after 3+ chars typed) - Tab completion for partial matches - Supports pasting all words at once - Validates each word against BIP39 wordlist

Args: word_count: Expected number of words (12, 15, 18, 21, or 24)

Returns: The complete mnemonic phrase

Raises: typer.Exit: If user cancels input (Ctrl+C)

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
def interactive_mnemonic_input(word_count: int = 24) -> str:
    """
    Interactively input a BIP39 mnemonic with autocomplete support.

    Features:
    - Real-time suggestions as you type (shows matches when <= 10)
    - Auto-completes when only one word matches (after 3+ chars typed)
    - Tab completion for partial matches
    - Supports pasting all words at once
    - Validates each word against BIP39 wordlist

    Args:
        word_count: Expected number of words (12, 15, 18, 21, or 24)

    Returns:
        The complete mnemonic phrase

    Raises:
        typer.Exit: If user cancels input (Ctrl+C)
    """
    from rich.console import Console

    console = Console()
    wordlist = get_bip39_wordlist()
    words: list[str] = []

    # Check if we can use real-time input
    use_realtime = _supports_raw_terminal()

    # Fallback: set up readline completion if available
    has_readline = False
    if not use_realtime:
        try:
            import readline

            def completer(text: str, state: int) -> str | None:
                matches = get_word_completions(text, wordlist)
                if state < len(matches):
                    return matches[state]
                return None

            readline.set_completer(completer)
            readline.parse_and_bind("tab: complete")
            readline.set_completer_delims(" ")
            has_readline = True
        except ImportError:
            pass

    console.print("\n[bold]Enter your BIP39 mnemonic phrase[/bold]")
    if use_realtime:
        console.print(
            f"[dim]Expected: {word_count} words | Tab to complete | "
            f"Backspace to go back | Ctrl+C to cancel[/dim]"
        )
    else:
        console.print(
            f"[dim]Expected: {word_count} words | Tab to autocomplete | Ctrl+C to cancel[/dim]"
        )
    console.print(
        "[dim]Tip: You can paste all words at once (space, comma, or semicolon separated)[/dim]"
    )
    console.print()

    try:
        while len(words) < word_count:
            word_num = len(words) + 1
            prompt_text = f"Word {word_num}/{word_count}: "

            try:
                if use_realtime:
                    user_input = _interactive_word_input(prompt_text, wordlist)
                    if user_input is None:
                        # Go back to previous word if possible
                        if words:
                            removed = words.pop()
                            console.print(f"  [yellow]Removed: {removed}[/yellow]")
                        continue
                    user_input = user_input.strip().lower()
                elif has_readline:
                    user_input = input(prompt_text).strip().lower()
                else:
                    # For terminals without readline, use typer.prompt
                    user_input = (
                        typer.prompt(
                            f"Word {word_num}/{word_count}",
                            prompt_suffix=": ",
                            show_default=False,
                        )
                        .strip()
                        .lower()
                    )
            except EOFError:
                console.print("\n[red]Input cancelled[/red]")
                raise typer.Exit(1)

            if not user_input:
                continue

            # Normalize separators: support comma, semicolon, and space
            import re

            normalized_input = re.sub(r"[,;\s]+", " ", user_input).strip()

            # Check if user pasted multiple words at once
            input_parts = normalized_input.split()
            if len(input_parts) > 1:
                # Validate all pasted words
                all_valid = all(part in wordlist for part in input_parts)
                if all_valid:
                    remaining_slots = word_count - len(words)
                    if len(input_parts) <= remaining_slots:
                        for part in input_parts:
                            words.append(part)
                            console.print(f"  [green]{part}[/green]", highlight=False)
                        continue
                    else:
                        console.print(
                            f"  [red]Too many words: got {len(input_parts)}, "
                            f"only {remaining_slots} remaining[/red]"
                        )
                        continue
                else:
                    # Find which words are invalid
                    invalid_words = [part for part in input_parts if part not in wordlist]
                    console.print(f"  [red]Invalid BIP39 words: {', '.join(invalid_words)}[/red]")
                    continue

            # Check for exact match (single word)
            single_word = input_parts[0] if len(input_parts) == 1 else normalized_input
            if single_word in wordlist:
                words.append(single_word)
                # Only print confirmation if not using realtime (realtime already shows it)
                if not use_realtime:
                    console.print(f"  [green]{single_word}[/green]", highlight=False)
                continue

            # Check for prefix matches
            matches = get_word_completions(single_word, wordlist)

            if len(matches) == 0:
                console.print(f"  [red]'{single_word}' - no matching BIP39 word[/red]")
                continue
            elif len(matches) == 1:
                # Auto-complete unique match
                word = matches[0]
                words.append(word)
                if not use_realtime:
                    console.print(
                        f"  [green]{word}[/green] [dim](auto-completed from '{single_word}')[/dim]"
                    )
            else:
                # Show suggestions
                console.print(f"  [yellow]Matches: {format_word_suggestions(matches)}[/yellow]")
                console.print("  [dim]Type more characters to narrow down[/dim]")

    except KeyboardInterrupt:
        console.print("\n[red]Input cancelled[/red]")
        raise typer.Exit(1)
    finally:
        # Restore readline settings if we modified them
        if has_readline:
            try:
                import readline

                readline.set_completer(None)
            except ImportError:
                pass

    mnemonic = " ".join(words)

    # Validate the complete mnemonic
    console.print()
    if validate_mnemonic(mnemonic):
        console.print("[bold green]Mnemonic checksum valid![/bold green]")
    else:
        console.print("[bold red]WARNING: Mnemonic checksum INVALID![/bold red]")
        console.print(
            "[yellow]The words are valid BIP39 words but the checksum doesn't match.[/yellow]"
        )
        console.print("[yellow]This could mean a word was entered incorrectly.[/yellow]")
        if not typer.confirm("Continue anyway?", default=False):
            raise typer.Exit(1)

    return mnemonic

load_mnemonic_file(mnemonic_file: Path, password: str | None = None) -> str

Load a mnemonic from a file, decrypting if necessary.

Args: mnemonic_file: Path to the mnemonic file password: Password for decryption (required if file is encrypted)

Returns: The mnemonic phrase

Raises: ValueError: If file is encrypted but no password provided

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
def load_mnemonic_file(
    mnemonic_file: Path,
    password: str | None = None,
) -> str:
    """
    Load a mnemonic from a file, decrypting if necessary.

    Args:
        mnemonic_file: Path to the mnemonic file
        password: Password for decryption (required if file is encrypted)

    Returns:
        The mnemonic phrase

    Raises:
        ValueError: If file is encrypted but no password provided
    """
    if not mnemonic_file.exists():
        raise FileNotFoundError(f"Mnemonic file not found: {mnemonic_file}")

    data = mnemonic_file.read_bytes()

    # Try to detect if file is encrypted
    # Encrypted files start with 16-byte salt + Fernet token
    # Plaintext files are ASCII only
    try:
        text = data.decode("utf-8")
        # Check if it looks like a valid mnemonic (words separated by spaces)
        words = text.strip().split()
        if len(words) in (12, 15, 18, 21, 24) and all(w.isalpha() for w in words):
            return text.strip()
    except UnicodeDecodeError:
        pass

    # File appears to be encrypted
    if not password:
        raise ValueError(
            "Mnemonic file appears to be encrypted. "
            "Set wallet.mnemonic_password in config or use interactive prompt"
        )

    return decrypt_mnemonic(data, password)

load_mnemonic_meta(mnemonic_file: Path) -> dict[str, MnemonicMetaValue]

Load wallet metadata from a companion .meta file.

Returns an empty dict if the file does not exist (backward-compatible with mnemonics created before this feature was added).

Args: mnemonic_file: Path to the mnemonic file.

Returns: Dict with metadata fields (creation_height, fingerprint).

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
def load_mnemonic_meta(mnemonic_file: Path) -> dict[str, MnemonicMetaValue]:
    """Load wallet metadata from a companion ``.meta`` file.

    Returns an empty dict if the file does not exist (backward-compatible
    with mnemonics created before this feature was added).

    Args:
        mnemonic_file: Path to the mnemonic file.

    Returns:
        Dict with metadata fields (``creation_height``, ``fingerprint``).
    """
    import json

    path = _meta_path(mnemonic_file)
    if not path.exists():
        return {}

    try:
        data = json.loads(path.read_text())
        if isinstance(data, dict):
            return data
        logger.warning(f"Mnemonic metadata file has unexpected format: {path}")
        return {}
    except (json.JSONDecodeError, OSError) as exc:
        logger.warning(f"Failed to read mnemonic metadata from {path}: {exc}")
        return {}

load_mnemonic_meta_fingerprint(mnemonic_file: Path) -> str | None

Return the stored wallet fingerprint from a .meta file, if valid.

Returns None when the file is missing, has no fingerprint field, or the value is not a valid 8-char hex string. This lets per-wallet read commands resolve the active wallet without decrypting the mnemonic.

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def load_mnemonic_meta_fingerprint(mnemonic_file: Path) -> str | None:
    """Return the stored wallet fingerprint from a ``.meta`` file, if valid.

    Returns ``None`` when the file is missing, has no ``fingerprint`` field,
    or the value is not a valid 8-char hex string. This lets per-wallet read
    commands resolve the active wallet without decrypting the mnemonic.
    """
    raw = load_mnemonic_meta(mnemonic_file).get("fingerprint")
    if not isinstance(raw, str):
        return None
    fp = raw.strip().lower()
    if len(fp) != 8:
        return None
    try:
        bytes.fromhex(fp)
    except ValueError:
        return None
    return fp

prompt_password_with_confirmation(max_attempts: int = 3) -> str

Prompt for a password with confirmation, retrying on mismatch.

Args: max_attempts: Maximum number of attempts before giving up

Returns: The confirmed password

Raises: typer.Exit: If passwords don't match after max_attempts

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def prompt_password_with_confirmation(max_attempts: int = 3) -> str:
    """
    Prompt for a password with confirmation, retrying on mismatch.

    Args:
        max_attempts: Maximum number of attempts before giving up

    Returns:
        The confirmed password

    Raises:
        typer.Exit: If passwords don't match after max_attempts
    """
    for attempt in range(max_attempts):
        password = typer.prompt("Enter encryption password", default="", hide_input=True)
        if not password:
            typer.echo("WARNING: Empty password means the mnemonic will be stored in PLAINTEXT.")
            if not typer.confirm("Continue without encryption?", default=False):
                continue
            return password
        confirm = typer.prompt("Confirm password", hide_input=True)
        if password == confirm:
            return password
        remaining = max_attempts - attempt - 1
        if remaining > 0:
            typer.echo(f"Passwords do not match. {remaining} attempt(s) remaining.")
        else:
            logger.error("Passwords do not match after maximum attempts")
            raise typer.Exit(1)
    # Should not reach here, but satisfy type checker
    raise typer.Exit(1)

save_mnemonic_file(mnemonic: str, output_file: Path, password: str | None = None) -> None

Save a mnemonic to a file, optionally encrypted.

Args: mnemonic: The mnemonic phrase to save output_file: The output file path password: Optional password for encryption

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
def save_mnemonic_file(
    mnemonic: str,
    output_file: Path,
    password: str | None = None,
) -> None:
    """
    Save a mnemonic to a file, optionally encrypted.

    Args:
        mnemonic: The mnemonic phrase to save
        output_file: The output file path
        password: Optional password for encryption
    """
    output_file.parent.mkdir(parents=True, exist_ok=True)

    if password:
        encrypted = encrypt_mnemonic(mnemonic, password)
        output_file.write_bytes(encrypted)
        os.chmod(output_file, 0o600)
        logger.info(f"Encrypted mnemonic saved to {output_file}")
    else:
        output_file.write_text(mnemonic)
        os.chmod(output_file, 0o600)
        logger.warning(f"Mnemonic saved to {output_file} (PLAINTEXT - consider using --password)")

save_mnemonic_meta(mnemonic_file: Path, *, creation_height: int | None = None, fingerprint: str | None = None) -> None

Persist wallet metadata alongside a mnemonic file.

The metadata is stored as a small JSON file (<mnemonic_file>.meta) next to the mnemonic file. Existing fields are preserved: only the fields passed here are added/updated, so writing creation_height does not drop a previously stored fingerprint and vice versa.

Args: mnemonic_file: Path to the mnemonic file (the .meta suffix is added). creation_height: Block height at the time the wallet was created. fingerprint: 8-char hex BIP32 m/0 wallet fingerprint. Stored so per-wallet read commands (history, list-bonds, registry-show) can resolve the active wallet's identity without decrypting the mnemonic.

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
def save_mnemonic_meta(
    mnemonic_file: Path,
    *,
    creation_height: int | None = None,
    fingerprint: str | None = None,
) -> None:
    """Persist wallet metadata alongside a mnemonic file.

    The metadata is stored as a small JSON file (``<mnemonic_file>.meta``)
    next to the mnemonic file. Existing fields are preserved: only the
    fields passed here are added/updated, so writing ``creation_height``
    does not drop a previously stored ``fingerprint`` and vice versa.

    Args:
        mnemonic_file: Path to the mnemonic file (the .meta suffix is added).
        creation_height: Block height at the time the wallet was created.
        fingerprint: 8-char hex BIP32 ``m/0`` wallet fingerprint. Stored so
            per-wallet read commands (history, list-bonds, registry-show)
            can resolve the active wallet's identity without decrypting the
            mnemonic.
    """
    updates: dict[str, MnemonicMetaValue] = {}
    if creation_height is not None:
        updates["creation_height"] = creation_height
    if fingerprint is not None:
        updates["fingerprint"] = fingerprint.strip().lower()

    if not updates:
        return  # Nothing to persist

    # Merge into any existing metadata so concurrent fields are not lost.
    meta = load_mnemonic_meta(mnemonic_file)
    meta.update(updates)
    _write_mnemonic_meta(mnemonic_file, meta)

update_mnemonic_meta_fingerprint(mnemonic_file: Path, fingerprint: str) -> None

Backfill the wallet fingerprint into the .meta file if not already set.

No-op when the stored fingerprint already matches, avoiding needless rewrites. Failures are swallowed (best-effort cache) so a read-only data directory never breaks the calling command.

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
366
367
368
369
370
371
372
373
374
375
376
377
378
def update_mnemonic_meta_fingerprint(mnemonic_file: Path, fingerprint: str) -> None:
    """Backfill the wallet fingerprint into the ``.meta`` file if not already set.

    No-op when the stored fingerprint already matches, avoiding needless
    rewrites. Failures are swallowed (best-effort cache) so a read-only data
    directory never breaks the calling command.
    """
    try:
        if load_mnemonic_meta_fingerprint(mnemonic_file) == fingerprint.strip().lower():
            return
        save_mnemonic_meta(mnemonic_file, fingerprint=fingerprint)
    except OSError as exc:  # pragma: no cover - defensive (read-only datadir)
        logger.debug(f"Could not persist wallet fingerprint to metadata: {exc}")

validate_mnemonic(mnemonic: str) -> bool

Validate a BIP39 mnemonic phrase.

Args: mnemonic: The mnemonic phrase to validate

Returns: True if valid, False otherwise

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def validate_mnemonic(mnemonic: str) -> bool:
    """
    Validate a BIP39 mnemonic phrase.

    Args:
        mnemonic: The mnemonic phrase to validate

    Returns:
        True if valid, False otherwise
    """
    from mnemonic import Mnemonic

    m = Mnemonic("english")
    return m.check(mnemonic)