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

FIDELITY_BOND_RECOVERY_STARTED = 'started' module-attribute

MnemonicMetaValue = Any module-attribute

Classes

FidelityBondRecoveryInProgressError

Bases: RuntimeError

Another process already owns this mnemonic's recovery attempt.

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
283
284
class FidelityBondRecoveryInProgressError(RuntimeError):
    """Another process already owns this mnemonic's recovery attempt."""

Functions:

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

Record user-confirmed prior recovery, excluding active recovery attempts.

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
426
427
428
429
430
431
432
def acknowledge_fidelity_bond_recovery_scanned(
    mnemonic_file: Path,
    wallet_fingerprint: str,
) -> None:
    """Record user-confirmed prior recovery, excluding active recovery attempts."""
    with _mnemonic_meta_lock(mnemonic_file, recovery=True):
        mark_fidelity_bond_recovery_complete(mnemonic_file, wallet_fingerprint)

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
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
147
148
149
150
151
152
153
154
155
156
157
158
159
160
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
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
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

fidelity_bond_recovery_attempt(mnemonic_file: Path, wallet_fingerprint: str, *, automatic: bool = False) -> Iterator[bool]

Claim recovery before any RPC and complete it only after durable success.

The long-lived lock excludes concurrent explicit retries. The separate short metadata lock lets unrelated metadata updates proceed during a scan. Errors and process termination leave started intact, never pending.

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
@contextmanager
def fidelity_bond_recovery_attempt(
    mnemonic_file: Path,
    wallet_fingerprint: str,
    *,
    automatic: bool = False,
) -> Iterator[bool]:
    """Claim recovery before any RPC and complete it only after durable success.

    The long-lived lock excludes concurrent explicit retries. The separate
    short metadata lock lets unrelated metadata updates proceed during a scan.
    Errors and process termination leave ``started`` intact, never ``pending``.
    """
    with _mnemonic_meta_lock(mnemonic_file, recovery=True):
        with _mnemonic_meta_lock(mnemonic_file):
            claimed = not automatic or mnemonic_requires_fidelity_bond_recovery(
                mnemonic_file, wallet_fingerprint
            )
            if claimed:
                meta = load_mnemonic_meta(mnemonic_file, strict=True)
                meta[_fidelity_bond_recovery_key(wallet_fingerprint)] = (
                    FIDELITY_BOND_RECOVERY_STARTED
                )
                _write_mnemonic_meta(mnemonic_file, meta)
        yield claimed
        if claimed:
            mark_fidelity_bond_recovery_complete(mnemonic_file, wallet_fingerprint)

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
546
547
548
549
550
551
552
553
554
555
556
557
558
559
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
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
518
519
520
521
522
523
524
525
526
527
528
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
531
532
533
534
535
536
537
538
539
540
541
542
543
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
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
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
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
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
250
251
252
253
254
255
256
257
258
259
260
261
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_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
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
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 recovery completion for the selected wallet.

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
415
416
417
418
419
420
421
422
423
def mark_fidelity_bond_recovery_complete(
    mnemonic_file: Path,
    wallet_fingerprint: str,
) -> None:
    """Persist recovery completion for the selected wallet."""
    with _mnemonic_meta_lock(mnemonic_file):
        meta = load_mnemonic_meta(mnemonic_file, strict=True)
        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 the wallet explicitly requests its first automatic recovery.

New imports are explicitly marked pending, while generated wallets are marked not-required. Missing recovery metadata does not establish that a recovery is needed. Started attempts require explicit retry because Core can finish scanning after the originating process exits.

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
364
365
366
367
368
369
370
371
372
373
374
375
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
411
412
def mnemonic_requires_fidelity_bond_recovery(
    mnemonic_file: Path,
    wallet_fingerprint: str,
) -> bool:
    """Return whether the wallet explicitly requests its first automatic recovery.

    New imports are explicitly marked pending, while generated wallets are
    marked not-required. Missing recovery metadata does not establish that a
    recovery is needed. Started attempts require explicit retry because Core
    can finish scanning after the originating process exits.
    """
    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(
            "Fidelity bond recovery completion is unconfirmed. No automatic retry will run. "
            "Check `jm-wallet info --scan-status` for Bitcoin Core scan activity; "
            "run `jm-wallet recover-bonds` to explicitly retry recovery."
        )
        return False

    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")
    if not (
        isinstance(creation_height, int)
        and not isinstance(creation_height, bool)
        and creation_height >= 0
    ):
        logger.info(
            "This wallet has no fidelity bond recovery metadata; coverage is unknown. "
            "No automatic recovery scan will run, and registered bonds still sync normally. "
            "A regular wallet history scan does not establish that all 960 bond addresses "
            "were scanned. Use `jm-wallet recover-bonds` to discover historical bonds, "
            "or `jm-wallet recover-bonds --mark-scanned` only if full bond recovery "
            "already completed. Use the same wallet file and BIP39 passphrase."
        )
    return False

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
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
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
287
288
289
290
291
def reset_mnemonic_meta(mnemonic_file: Path) -> None:
    """Remove metadata that belongs to mnemonic contents being replaced."""
    with _mnemonic_meta_lock(mnemonic_file, recovery=True):
        with _mnemonic_meta_lock(mnemonic_file):
            _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
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
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
309
310
311
312
313
314
315
316
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
344
345
346
347
348
349
350
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.
    with _mnemonic_meta_lock(mnemonic_file):
        meta = load_mnemonic_meta(mnemonic_file, strict=True)
        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
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
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:
        with _mnemonic_meta_lock(mnemonic_file):
            meta = load_mnemonic_meta(mnemonic_file, strict=True)
            if "identity_version" in meta:
                return
            raw = meta.get("fingerprint")
            try:
                cached = WalletIdentity.validate_fingerprint(raw) if isinstance(raw, str) else None
            except ValueError:
                cached = None
            if cached is not None:
                # Wallet resolution already warned about a mismatch.
                return
            meta["fingerprint"] = WalletIdentity.validate_fingerprint(fingerprint)
            _write_mnemonic_meta(mnemonic_file, meta)
    except (OSError, ValueError) 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
61
62
63
64
65
66
67
68
69
70
71
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)