Skip to content

jmwallet.cli.mnemonic

jmwallet.cli.mnemonic

Mnemonic generation, validation, encryption, and interactive input.

Attributes

FIDELITY_BOND_RECOVERY_COMPLETE = 'complete' module-attribute

FIDELITY_BOND_RECOVERY_NOT_REQUIRED = 'not_required' module-attribute

FIDELITY_BOND_RECOVERY_PENDING = 'pending' module-attribute

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
 97
 98
 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
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
58
59
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
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
494
495
496
497
498
499
500
501
502
503
504
505
506
507
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
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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
    """
    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]

    return generate_wallet_mnemonic(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
466
467
468
469
470
471
472
473
474
475
476
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
479
480
481
482
483
484
485
486
487
488
489
490
491
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
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
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
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
204
205
206
207
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
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
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
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("Mnemonic metadata file has unexpected format")
        logger.bind(sensitive=True).warning(f"Mnemonic metadata file has unexpected format: {path}")
        return {}
    except (json.JSONDecodeError, OSError) as exc:
        logger.warning("Failed to read mnemonic metadata")
        logger.bind(sensitive=True).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
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
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

mark_fidelity_bond_recovery_complete(mnemonic_file: Path, wallet_fingerprint: str) -> None

Persist successful completion of the imported-wallet bond scan.

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
413
414
415
416
417
418
419
420
def mark_fidelity_bond_recovery_complete(
    mnemonic_file: Path,
    wallet_fingerprint: str,
) -> None:
    """Persist successful completion of the imported-wallet bond scan."""
    meta = load_mnemonic_meta(mnemonic_file)
    meta[_fidelity_bond_recovery_key(wallet_fingerprint)] = FIDELITY_BOND_RECOVERY_COMPLETE
    _write_mnemonic_meta(mnemonic_file, meta)

mnemonic_requires_fidelity_bond_recovery(mnemonic_file: Path, wallet_fingerprint: str) -> bool

Return whether a file-backed wallet still needs canonical bond recovery.

New imports are explicitly marked pending, while generated wallets are marked not-required. For wallets created by older releases, a valid creation height identifies a generated wallet; metadata without one is treated as a restored wallet and receives one recovery scan.

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
def mnemonic_requires_fidelity_bond_recovery(
    mnemonic_file: Path,
    wallet_fingerprint: str,
) -> bool:
    """Return whether a file-backed wallet still needs canonical bond recovery.

    New imports are explicitly marked pending, while generated wallets are
    marked not-required. For wallets created by older releases, a valid
    creation height identifies a generated wallet; metadata without one is
    treated as a restored wallet and receives one recovery scan.
    """
    meta = load_mnemonic_meta(mnemonic_file)
    wallet_state = meta.get(_fidelity_bond_recovery_key(wallet_fingerprint))
    if wallet_state == FIDELITY_BOND_RECOVERY_COMPLETE:
        return False
    if wallet_state is not None:
        logger.warning(f"Ignoring invalid wallet-specific bond recovery state: {wallet_state!r}")

    state = meta.get("fidelity_bond_recovery")
    if state == FIDELITY_BOND_RECOVERY_PENDING:
        return True
    if state in {
        FIDELITY_BOND_RECOVERY_COMPLETE,
        FIDELITY_BOND_RECOVERY_NOT_REQUIRED,
    }:
        return False
    if state is not None:
        logger.warning(f"Ignoring invalid fidelity bond recovery state: {state!r}")

    creation_height = meta.get("creation_height")
    return not (
        isinstance(creation_height, int)
        and not isinstance(creation_height, bool)
        and creation_height >= 0
    )

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
147
148
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
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)

reset_mnemonic_meta(mnemonic_file: Path) -> None

Remove metadata that belongs to mnemonic contents being replaced.

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
275
276
277
def reset_mnemonic_meta(mnemonic_file: Path) -> None:
    """Remove metadata that belongs to mnemonic contents being replaced."""
    _meta_path(mnemonic_file).unlink(missing_ok=True)

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
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
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
    """
    if password:
        encrypted = encrypt_mnemonic(mnemonic, password)
        atomic_write_private(output_file, encrypted)
        logger.info("Encrypted mnemonic saved")
        logger.bind(sensitive=True).info(f"Encrypted mnemonic saved to {output_file}")
    else:
        atomic_write_private(output_file, mnemonic.encode("utf-8"))
        logger.warning("Mnemonic saved in plaintext, consider using --password")
        logger.bind(sensitive=True).warning(f"Mnemonic saved to {output_file} in plaintext")

save_mnemonic_meta(mnemonic_file: Path, *, creation_height: int | None = None, fingerprint: str | None = None, fidelity_bond_recovery: 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. fidelity_bond_recovery: Durable state for the one-time canonical bond scan performed after mnemonic import.

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
def save_mnemonic_meta(
    mnemonic_file: Path,
    *,
    creation_height: int | None = None,
    fingerprint: str | None = None,
    fidelity_bond_recovery: 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.
        fidelity_bond_recovery: Durable state for the one-time canonical bond
            scan performed after mnemonic import.
    """
    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 fidelity_bond_recovery is not None:
        if fidelity_bond_recovery not in _FIDELITY_BOND_RECOVERY_STATES:
            raise ValueError(f"Invalid fidelity bond recovery state: {fidelity_bond_recovery}")
        updates["fidelity_bond_recovery"] = fidelity_bond_recovery

    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
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
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("Could not persist wallet fingerprint to metadata")
        logger.bind(sensitive=True).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
45
46
47
48
49
50
51
52
53
54
55
def validate_mnemonic(mnemonic: str) -> bool:
    """
    Validate a BIP39 mnemonic phrase.

    Args:
        mnemonic: The mnemonic phrase to validate

    Returns:
        True if valid, False otherwise
    """
    return validate_bip39_checksum(mnemonic)