Skip to content

jmwallet.cli.wallet

jmwallet.cli.wallet

Wallet management commands: import, generate, info, validate.

Attributes

Classes

Functions:

delete_wallet(mnemonic_file: Annotated[Path | None, typer.Option('--mnemonic-file', '-f', help='Path to mnemonic file', envvar='MNEMONIC_FILE')] = None, prompt_bip39_passphrase: Annotated[bool, typer.Option('--prompt-bip39-passphrase', help='Prompt for the BIP39 passphrase used by this wallet')] = False, allow_fingerprint_mismatch: Annotated[bool, typer.Option('--allow-fingerprint-mismatch', help='Proceed when the mnemonic .meta fingerprint differs from the derived wallet')] = False, network: Annotated[str | None, typer.Option('--network', '-n', help='Bitcoin network')] = None, backend_type: Annotated[str | None, typer.Option('--backend', '-b', help='Backend: descriptor_wallet | neutrino')] = None, rpc_url: Annotated[str | None, typer.Option('--rpc-url', envvar='BITCOIN_RPC_URL')] = None, core_wallet_dir: Annotated[Path | None, typer.Option('--core-wallet-dir', help='Host-local Bitcoin Core -walletdir containing the descriptor wallet')] = None, keep_backend_wallet: Annotated[bool, typer.Option('--keep-backend-wallet', help='Keep the Bitcoin Core descriptor wallet (required for remote Core cleanup)')] = False, delete_history: Annotated[bool, typer.Option('--delete-history', help="Delete this wallet's fingerprint-scoped rows from history.csv")] = False, delete_bond_registry: Annotated[bool, typer.Option('--delete-bond-registry', help="Delete this wallet's fidelity-bond registry entries")] = False, dry_run: Annotated[bool, typer.Option('--dry-run', help='Show the deletion plan without changing anything')] = False, yes: Annotated[bool, typer.Option('--yes', '-y', help='Skip the fingerprint confirmation prompt')] = False, data_dir: Annotated[Path | None, typer.Option('--data-dir', envvar='JOINMARKET_DATA_DIR', help='Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR)')] = None, config_file: Annotated[Path | None, typer.Option('--config-file', envvar='JOINMARKET_CONFIG_FILE', help='Config file path (decoupled from data dir). Defaults to <data-dir>/config.toml')] = None, log_level: Annotated[str | None, typer.Option('--log-level', '-l', help='Log level')] = None) -> None

Permanently delete one wallet and its private local state.

The mnemonic file, companion metadata, UTXO labels/freezes, and history reconstruction cache are always deleted. CoinJoin history and fidelity-bond registry entries are retained unless their explicit deletion flags are set.

Bitcoin Core has no wallet deletion RPC, so descriptor-wallet deletion also requires host-local access to Core's configured wallet directory. Neutrino watched addresses are removed from a current neutrino-api before local files; shared chain, filter, and confirmed-history data remain. Stop makers, takers, the wallet daemon, and other wallet users first.

Source code in jmwallet/src/jmwallet/cli/wallet.py
 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
 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
145
146
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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
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
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
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
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
351
352
353
354
355
356
357
358
359
360
361
362
@app.command("delete")
def delete_wallet(
    mnemonic_file: Annotated[
        Path | None,
        typer.Option("--mnemonic-file", "-f", help="Path to mnemonic file", envvar="MNEMONIC_FILE"),
    ] = None,
    prompt_bip39_passphrase: Annotated[
        bool,
        typer.Option(
            "--prompt-bip39-passphrase",
            help="Prompt for the BIP39 passphrase used by this wallet",
        ),
    ] = False,
    allow_fingerprint_mismatch: Annotated[
        bool,
        typer.Option(
            "--allow-fingerprint-mismatch",
            help="Proceed when the mnemonic .meta fingerprint differs from the derived wallet",
        ),
    ] = False,
    network: Annotated[str | None, typer.Option("--network", "-n", help="Bitcoin network")] = None,
    backend_type: Annotated[
        str | None,
        typer.Option("--backend", "-b", help="Backend: descriptor_wallet | neutrino"),
    ] = None,
    rpc_url: Annotated[str | None, typer.Option("--rpc-url", envvar="BITCOIN_RPC_URL")] = None,
    core_wallet_dir: Annotated[
        Path | None,
        typer.Option(
            "--core-wallet-dir",
            help="Host-local Bitcoin Core -walletdir containing the descriptor wallet",
        ),
    ] = None,
    keep_backend_wallet: Annotated[
        bool,
        typer.Option(
            "--keep-backend-wallet",
            help="Keep the Bitcoin Core descriptor wallet (required for remote Core cleanup)",
        ),
    ] = False,
    delete_history: Annotated[
        bool,
        typer.Option(
            "--delete-history",
            help="Delete this wallet's fingerprint-scoped rows from history.csv",
        ),
    ] = False,
    delete_bond_registry: Annotated[
        bool,
        typer.Option(
            "--delete-bond-registry",
            help="Delete this wallet's fidelity-bond registry entries",
        ),
    ] = False,
    dry_run: Annotated[
        bool,
        typer.Option("--dry-run", help="Show the deletion plan without changing anything"),
    ] = False,
    yes: Annotated[
        bool,
        typer.Option("--yes", "-y", help="Skip the fingerprint confirmation prompt"),
    ] = False,
    data_dir: Annotated[
        Path | None,
        typer.Option(
            "--data-dir",
            envvar="JOINMARKET_DATA_DIR",
            help="Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR)",
        ),
    ] = None,
    config_file: Annotated[
        Path | None,
        typer.Option(
            "--config-file",
            envvar="JOINMARKET_CONFIG_FILE",
            help="Config file path (decoupled from data dir). Defaults to <data-dir>/config.toml",
        ),
    ] = None,
    log_level: Annotated[
        str | None,
        typer.Option("--log-level", "-l", help="Log level"),
    ] = None,
) -> None:
    """Permanently delete one wallet and its private local state.

    The mnemonic file, companion metadata, UTXO labels/freezes, and history
    reconstruction cache are always deleted. CoinJoin history and fidelity-bond
    registry entries are retained unless their explicit deletion flags are set.

    Bitcoin Core has no wallet deletion RPC, so descriptor-wallet deletion also
    requires host-local access to Core's configured wallet directory. Neutrino
    watched addresses are removed from a current neutrino-api before local files;
    shared chain, filter, and confirmed-history data remain.
    Stop makers, takers, the wallet daemon, and other wallet users first.
    """
    from jmwallet.backends.descriptor_wallet import generate_wallet_name, get_mnemonic_fingerprint
    from jmwallet.wallet.deletion import (
        collect_neutrino_watch_addresses,
        core_wallet_path,
        delete_core_descriptor_wallet,
        delete_wallet_data,
        local_wallet_artifact_paths,
        remove_neutrino_wallet_watches,
    )

    settings = setup_cli(log_level, data_dir=data_dir, config_file=config_file)
    resolved_mnemonic_file = mnemonic_file or resolve_configured_mnemonic_file(settings)
    if resolved_mnemonic_file is None:
        logger.error(
            "jm-wallet delete requires a file-backed wallet. Pass --mnemonic-file or configure "
            "wallet.mnemonic_file."
        )
        raise typer.Exit(1)

    try:
        resolved = resolve_mnemonic(
            settings,
            mnemonic_file=resolved_mnemonic_file,
            prompt_bip39_passphrase=prompt_bip39_passphrase,
        )
        if resolved is None:
            raise ValueError("No mnemonic provided")
    except (FileNotFoundError, ValueError) as exc:
        logger.error(str(exc))
        raise typer.Exit(1)

    backend_settings = resolve_backend_settings(
        settings,
        network=network,
        backend_type=backend_type,
        rpc_url=rpc_url,
        data_dir=data_dir,
    )
    fingerprint = get_mnemonic_fingerprint(
        resolved.mnemonic,
        resolved.bip39_passphrase or "",
    )
    from jmwallet.cli.mnemonic import load_mnemonic_meta_fingerprint

    cached_fingerprint = load_mnemonic_meta_fingerprint(resolved_mnemonic_file)
    if (
        cached_fingerprint is not None
        and cached_fingerprint != fingerprint
        and not allow_fingerprint_mismatch
    ):
        logger.error(
            "The fingerprint stored beside this mnemonic does not match the wallet derived with "
            "the current BIP39 passphrase. Verify the passphrase, or use "
            "--allow-fingerprint-mismatch only after confirming the displayed deletion plan."
        )
        logger.bind(sensitive=True).error(
            f"Stored fingerprint {cached_fingerprint}, derived fingerprint {fingerprint}"
        )
        raise typer.Exit(1)

    supported_backends = {"descriptor_wallet", "neutrino"}
    if backend_settings.backend_type not in supported_backends:
        logger.error(
            f"Unsupported backend {backend_settings.backend_type!r}; expected descriptor_wallet "
            "or neutrino."
        )
        raise typer.Exit(2)
    wallet_name = generate_wallet_name(fingerprint, backend_settings.network)

    core_path: Path | None = None
    if backend_settings.backend_type == "descriptor_wallet":
        if keep_backend_wallet and core_wallet_dir is not None:
            logger.error("Use either --core-wallet-dir or --keep-backend-wallet, not both.")
            raise typer.Exit(2)
        if not keep_backend_wallet:
            if core_wallet_dir is None:
                logger.error(
                    "Bitcoin Core does not expose wallet deletion over RPC. Pass the host-local "
                    "--core-wallet-dir path, or explicitly use --keep-backend-wallet."
                )
                raise typer.Exit(2)
            try:
                core_path = core_wallet_path(core_wallet_dir, wallet_name)
            except (OSError, ValueError) as exc:
                logger.error(str(exc))
                raise typer.Exit(1)
    elif core_wallet_dir is not None or keep_backend_wallet:
        logger.error("Core wallet options cannot be used with the Neutrino backend.")
        raise typer.Exit(2)

    neutrino_watch_addresses: tuple[str, ...] | None = None
    if backend_settings.backend_type == "neutrino":
        try:
            neutrino_watch_addresses = collect_neutrino_watch_addresses(
                data_dir=backend_settings.data_dir,
                mnemonic=resolved.mnemonic,
                bip39_passphrase=resolved.bip39_passphrase,
                fingerprint=fingerprint,
                network=backend_settings.bitcoin_network,
                neutrino_url=backend_settings.neutrino_url,
                mixdepth_count=settings.wallet.mixdepth_count,
                gap_limit=settings.wallet.gap_limit,
                scan_range=settings.wallet.scan_range,
            )
        except ValueError as exc:
            logger.error(f"Wallet deletion stopped before confirmation: {exc}")
            raise typer.Exit(1)

    typer.echo("Wallet deletion plan")
    typer.echo(f"  Fingerprint: {fingerprint}")
    typer.echo(f"  Backend: {backend_settings.backend_type}")
    if core_path is not None:
        typer.echo(f"  Bitcoin Core wallet: {core_path}")
    elif backend_settings.backend_type == "descriptor_wallet":
        typer.echo(f"  Bitcoin Core wallet: keep {wallet_name}")
    else:
        typer.echo(f"  Neutrino watched addresses: remove {len(neutrino_watch_addresses or ())}")
        typer.echo("  Neutrino state: keep shared chain, filter, and confirmed-history data")
    for path in local_wallet_artifact_paths(
        backend_settings.data_dir,
        resolved_mnemonic_file,
        fingerprint,
    ):
        typer.echo(f"  Delete local file: {path}")
    typer.echo(
        "  CoinJoin history: "
        + ("delete matching rows" if delete_history else "keep matching rows")
    )
    typer.echo(
        "  Fidelity-bond registry: "
        + ("delete matching entries" if delete_bond_registry else "keep matching entries")
    )

    if dry_run:
        typer.echo("Dry run complete; nothing was deleted.")
        return

    if not yes:
        typer.echo("Ensure the mnemonic is backed up and all wallet processes are stopped.")
        confirmation = typer.prompt(f"Type {fingerprint} to permanently delete this wallet")
        if confirmation.strip().lower() != fingerprint:
            typer.echo("Wallet deletion cancelled.")
            raise typer.Exit(1)

    neutrino_cleanup = (0, 0)
    if neutrino_watch_addresses is not None:
        try:
            neutrino_cleanup = asyncio.run(
                remove_neutrino_wallet_watches(backend_settings, list(neutrino_watch_addresses))
            )
        except Exception as exc:
            logger.error(f"Neutrino cleanup failed; local wallet data was not deleted: {exc}")
            raise typer.Exit(1)

    deleted_core_path: Path | None = None
    try:
        if core_path is not None:
            deleted_core_path = asyncio.run(
                delete_core_descriptor_wallet(
                    backend_settings,
                    wallet_name,
                    core_wallet_dir=core_path.parent,
                )
            )
        result = delete_wallet_data(
            data_dir=backend_settings.data_dir,
            mnemonic_file=resolved_mnemonic_file,
            mnemonic=resolved.mnemonic,
            bip39_passphrase=resolved.bip39_passphrase,
            fingerprint=fingerprint,
            network=backend_settings.bitcoin_network,
            delete_history=delete_history,
            delete_bond_registry=delete_bond_registry,
            core_path=deleted_core_path,
        )
    except Exception as exc:
        logger.error(
            f"Wallet deletion stopped: {exc}. Some earlier items in the displayed plan may "
            "already have been removed; correct the error and rerun the command."
        )
        raise typer.Exit(1)

    typer.echo(f"Deleted wallet {fingerprint}.")
    typer.echo(f"  Removed files: {len(result.removed_paths)}")
    if delete_history:
        typer.echo(f"  Removed history rows: {result.history_entries}")
    if delete_bond_registry:
        typer.echo(f"  Removed fidelity-bond entries: {result.bond_entries}")
    if neutrino_watch_addresses is not None:
        typer.echo(f"  Removed Neutrino watched addresses: {neutrino_cleanup[0]}")
        typer.echo(f"  Removed Neutrino UTXOs: {neutrino_cleanup[1]}")
    if keep_backend_wallet:
        typer.echo(f"  Kept Bitcoin Core wallet: {wallet_name}")
    typer.echo(
        "Remove or update wallet.mnemonic_file in config.toml if it points to the deleted file."
    )

generate(word_count: Annotated[int, typer.Option('--words', '-w', help='Number of words (12, 15, 18, 21, or 24)')] = 24, save: Annotated[bool, typer.Option('--save/--no-save', help='Save to file (default: save)')] = True, output_file: Annotated[Path | None, typer.Option('--output', '-o', help='Output file path')] = None, prompt_password: Annotated[bool, typer.Option('--prompt-password/--no-prompt-password', help='Prompt for password interactively (default: prompt)')] = True, force: Annotated[bool, typer.Option('--force', '-f', help='Overwrite existing file without confirmation')] = False, data_dir: Annotated[Path | None, typer.Option('--data-dir', envvar='JOINMARKET_DATA_DIR', help='Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR). When --output is not given, the wallet is saved under <data-dir>/wallets/default.mnemonic.')] = None) -> None

Generate a new BIP39 mnemonic phrase with secure entropy.

By default, saves to /wallets/default.mnemonic with password protection. The data directory is taken from --data-dir, the JOINMARKET_DATA_DIR environment variable, or ~/.joinmarket-ng (in that order of precedence). Use --no-save to only display the mnemonic without saving.

Source code in jmwallet/src/jmwallet/cli/wallet.py
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
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
@app.command()
def generate(
    word_count: Annotated[
        int, typer.Option("--words", "-w", help="Number of words (12, 15, 18, 21, or 24)")
    ] = 24,
    save: Annotated[
        bool, typer.Option("--save/--no-save", help="Save to file (default: save)")
    ] = True,
    output_file: Annotated[
        Path | None, typer.Option("--output", "-o", help="Output file path")
    ] = None,
    prompt_password: Annotated[
        bool,
        typer.Option(
            "--prompt-password/--no-prompt-password",
            help="Prompt for password interactively (default: prompt)",
        ),
    ] = True,
    force: Annotated[
        bool,
        typer.Option("--force", "-f", help="Overwrite existing file without confirmation"),
    ] = False,
    data_dir: Annotated[
        Path | None,
        typer.Option(
            "--data-dir",
            envvar="JOINMARKET_DATA_DIR",
            help=(
                "Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR). "
                "When --output is not given, the wallet is saved under "
                "<data-dir>/wallets/default.mnemonic."
            ),
        ),
    ] = None,
) -> None:
    """Generate a new BIP39 mnemonic phrase with secure entropy.

    By default, saves to <data-dir>/wallets/default.mnemonic with password
    protection. The data directory is taken from --data-dir, the
    JOINMARKET_DATA_DIR environment variable, or ~/.joinmarket-ng (in that
    order of precedence). Use --no-save to only display the mnemonic without
    saving.
    """
    setup_cli(data_dir=data_dir)

    try:
        # Auto-enable save if output_file is specified (even if --no-save was used)
        should_save = save or output_file is not None

        if should_save:
            if output_file is None:
                output_file = get_default_data_dir() / "wallets" / "default.mnemonic"

            # Check if file already exists BEFORE generating the seed
            if output_file.exists() and not force:
                logger.warning("Wallet file already exists")
                logger.bind(sensitive=True).warning(f"Wallet file already exists: {output_file}")
                overwrite = typer.confirm("Overwrite existing wallet file?", default=False)
                if not overwrite:
                    typer.echo("Wallet generation cancelled")
                    raise typer.Exit(1)

        mnemonic = generate_mnemonic_secure(word_count)

        # Validate the generated mnemonic
        if not validate_mnemonic(mnemonic):
            logger.error("Generated mnemonic failed validation - this should not happen")
            raise typer.Exit(1)

        # Always display the mnemonic first
        typer.echo("\n" + "=" * 80)
        typer.echo("GENERATED MNEMONIC - WRITE THIS DOWN AND KEEP IT SAFE!")
        typer.echo("=" * 80)
        typer.echo(f"\n{mnemonic}\n")
        typer.echo("=" * 80)
        typer.echo("\nThis mnemonic controls your Bitcoin funds.")
        typer.echo("Anyone with this phrase can spend your coins.")
        typer.echo("Store it securely offline - NEVER share it with anyone!")
        typer.echo("=" * 80 + "\n")

        if should_save:
            # Narrowing for the type checker: the first ``should_save`` block
            # above always assigns a concrete path.
            assert output_file is not None

            # Prompt for password if requested
            password: str | None = None
            # Allow callers (typically the TUI) to pre-provide the password
            # via MNEMONIC_PASSWORD so the user isn't asked for it again
            # after having already entered it in a whiptail dialog
            # (issue #462). An empty env value is treated as "no password".
            env_password = os.environ.get("MNEMONIC_PASSWORD")
            if env_password:
                password = env_password
            elif prompt_password:
                password = prompt_password_with_confirmation()

            save_mnemonic_file(mnemonic, output_file, password)

            # Generated wallets cannot contain a pre-existing fidelity bond.
            # Write this even if the best-effort creation-height lookup below
            # fails, so they are never mistaken for imported legacy wallets.
            from jmwallet.cli.mnemonic import (
                FIDELITY_BOND_RECOVERY_NOT_REQUIRED,
                reset_mnemonic_meta,
                save_mnemonic_meta,
            )

            reset_mnemonic_meta(output_file)
            save_mnemonic_meta(
                output_file,
                fidelity_bond_recovery=FIDELITY_BOND_RECOVERY_NOT_REQUIRED,
            )

            # Record the wallet's birthday so the first sync does not rescan
            # a year of history for a brand-new (empty) wallet (issue #472).
            _record_wallet_creation_height(output_file)

            typer.echo(f"\nMnemonic saved to: {output_file}")
            if password:
                typer.echo("File is encrypted - you will need the password to use it.")
            else:
                typer.echo("WARNING: File is NOT encrypted")
                typer.echo("For production use, generate again with a password!")
            typer.echo("KEEP THIS FILE SECURE - IT CONTROLS YOUR FUNDS!")
        else:
            typer.echo("\nMnemonic NOT saved (--no-save was used)")
            typer.echo("To save it, run: jm-wallet generate")

    except ValueError as e:
        logger.error(f"Failed to generate mnemonic: {e}")
        raise typer.Exit(1)
    except typer.Exit:
        # Re-raise Exit exceptions without modification
        raise
    except Exception as e:
        logger.error(f"Unexpected error: {e}")
        raise typer.Exit(1)

import_mnemonic(word_count: Annotated[int, typer.Option('--words', '-w', help='Number of words (12, 15, 18, 21, or 24)')] = 24, output_file: Annotated[Path | None, typer.Option('--output', '-o', help='Output file path')] = None, prompt_password: Annotated[bool, typer.Option('--prompt-password/--no-prompt-password', help='Prompt for password interactively (default: prompt)')] = True, force: Annotated[bool, typer.Option('--force', '-f', help='Overwrite existing file without confirmation')] = False, data_dir: Annotated[Path | None, typer.Option('--data-dir', envvar='JOINMARKET_DATA_DIR', help='Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR). When --output is not given, the wallet is saved under <data-dir>/wallets/default.mnemonic.')] = None) -> None

Import an existing BIP39 mnemonic phrase to create/recover a wallet.

Enter your existing mnemonic interactively with autocomplete support, or set the MNEMONIC environment variable.

By default, saves to /wallets/default.mnemonic with password protection. The data directory is taken from --data-dir, the JOINMARKET_DATA_DIR environment variable, or ~/.joinmarket-ng (in that order of precedence).

Examples: jm-wallet import # Interactive input, 24 words jm-wallet import --words 12 # Interactive input, 12 words MNEMONIC="word1 word2 ..." jm-wallet import # Via env var jm-wallet import -o my-wallet.mnemonic # Custom output file

Source code in jmwallet/src/jmwallet/cli/wallet.py
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
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
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
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
@app.command("import")
def import_mnemonic(
    word_count: Annotated[
        int, typer.Option("--words", "-w", help="Number of words (12, 15, 18, 21, or 24)")
    ] = 24,
    output_file: Annotated[
        Path | None, typer.Option("--output", "-o", help="Output file path")
    ] = None,
    prompt_password: Annotated[
        bool,
        typer.Option(
            "--prompt-password/--no-prompt-password",
            help="Prompt for password interactively (default: prompt)",
        ),
    ] = True,
    force: Annotated[
        bool,
        typer.Option("--force", "-f", help="Overwrite existing file without confirmation"),
    ] = False,
    data_dir: Annotated[
        Path | None,
        typer.Option(
            "--data-dir",
            envvar="JOINMARKET_DATA_DIR",
            help=(
                "Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR). "
                "When --output is not given, the wallet is saved under "
                "<data-dir>/wallets/default.mnemonic."
            ),
        ),
    ] = None,
) -> None:
    """Import an existing BIP39 mnemonic phrase to create/recover a wallet.

    Enter your existing mnemonic interactively with autocomplete support,
    or set the MNEMONIC environment variable.

    By default, saves to <data-dir>/wallets/default.mnemonic with password
    protection. The data directory is taken from --data-dir, the
    JOINMARKET_DATA_DIR environment variable, or ~/.joinmarket-ng (in that
    order of precedence).

    Examples:
        jm-wallet import                          # Interactive input, 24 words
        jm-wallet import --words 12               # Interactive input, 12 words
        MNEMONIC="word1 word2 ..." jm-wallet import  # Via env var
        jm-wallet import -o my-wallet.mnemonic    # Custom output file
    """
    setup_cli(data_dir=data_dir)

    if word_count not in (12, 15, 18, 21, 24):
        logger.error(f"Invalid word count: {word_count}. Must be 12, 15, 18, 21, or 24.")
        raise typer.Exit(1)

    # Get mnemonic from env var or interactive input
    env_mnemonic = os.environ.get("MNEMONIC")
    if env_mnemonic:
        mnemonic = env_mnemonic.strip()
        # Validate provided mnemonic
        words = mnemonic.split()
        if len(words) != word_count:
            logger.warning(
                f"Mnemonic has {len(words)} words but --words={word_count} was specified. "
                f"Using actual word count: {len(words)}"
            )
        if not validate_mnemonic(mnemonic):
            logger.error("Provided mnemonic is INVALID (bad checksum)")
            if not typer.confirm("Continue anyway?", default=False):
                raise typer.Exit(1)
        resolved_mnemonic = mnemonic
    else:
        # Interactive input with autocomplete
        if not sys.stdin.isatty():
            logger.error("Interactive input requires a terminal. Set MNEMONIC env var instead.")
            raise typer.Exit(1)
        resolved_mnemonic = interactive_mnemonic_input(word_count)

    # Display summary
    typer.echo("\n" + "=" * 80)
    typer.echo("IMPORTED MNEMONIC")
    typer.echo("=" * 80)
    word_list = resolved_mnemonic.split()
    typer.echo(f"Word count: {len(word_list)}")
    typer.echo(f"First word: {word_list[0]}")
    typer.echo(f"Last word: {word_list[-1]}")
    typer.echo("=" * 80 + "\n")

    # Determine output file
    if output_file is None:
        output_file = get_default_data_dir() / "wallets" / "default.mnemonic"

    # Check if file exists
    if output_file.exists() and not force:
        logger.warning("Wallet file already exists")
        logger.bind(sensitive=True).warning(f"Wallet file already exists: {output_file}")
        if not typer.confirm("Overwrite existing wallet file?", default=False):
            typer.echo("Import cancelled")
            raise typer.Exit(1)

    # Get password for encryption
    password: str | None = None
    # Allow callers (typically the TUI) to pre-provide the password via
    # MNEMONIC_PASSWORD so the user isn't prompted again after already
    # entering it in a whiptail dialog (issue #462).
    env_password = os.environ.get("MNEMONIC_PASSWORD")
    if env_password:
        password = env_password
    elif prompt_password:
        password = prompt_password_with_confirmation()

    # Save the mnemonic and defer the expensive canonical fidelity-bond scan
    # until the first blockchain synchronization.
    save_mnemonic_file(resolved_mnemonic, output_file, password)
    from jmwallet.cli.mnemonic import (
        FIDELITY_BOND_RECOVERY_PENDING,
        reset_mnemonic_meta,
        save_mnemonic_meta,
    )

    reset_mnemonic_meta(output_file)
    save_mnemonic_meta(
        output_file,
        fidelity_bond_recovery=FIDELITY_BOND_RECOVERY_PENDING,
    )

    typer.echo(f"\nMnemonic saved to: {output_file}")
    if password:
        typer.echo("File is encrypted - you will need the password to use it.")
    else:
        typer.echo("WARNING: File is NOT encrypted")
        typer.echo("For production use, consider using a password!")
    typer.echo("\nWallet import complete. You can now use other jm-wallet commands.")
    typer.echo(
        "Note: an imported wallet has no recorded creation height, so the "
        "first sync scans about one year of blockchain history (with a full "
        "rescan continuing in the background). This can take a while on "
        "mainnet; progress is reported while it runs."
    )

info(mnemonic_file: Annotated[Path | None, typer.Option('--mnemonic-file', '-f', help='Path to mnemonic file', envvar='MNEMONIC_FILE')] = None, prompt_bip39_passphrase: Annotated[bool, typer.Option('--prompt-bip39-passphrase', help='Prompt for BIP39 passphrase interactively')] = False, network: Annotated[str | None, typer.Option('--network', '-n', help='Bitcoin network')] = None, backend_type: Annotated[str | None, typer.Option('--backend', '-b', help='Backend: descriptor_wallet | neutrino')] = None, rpc_url: Annotated[str | None, typer.Option('--rpc-url', envvar='BITCOIN_RPC_URL')] = None, neutrino_url: Annotated[str | None, typer.Option('--neutrino-url', envvar='NEUTRINO_URL')] = None, extended: Annotated[bool, typer.Option('--extended', '-e', help='Show detailed addresses, derivations, and UTXO outpoints')] = False, gap: Annotated[int, typer.Option('--gap', '-g', help='Max address gap to show in extended view')] = 6, show_empty: Annotated[bool, typer.Option('--show-empty/--no-show-empty', help='In --extended view, show addresses with zero balance. When disabled (default), empty addresses are hidden except for the first unused one per branch so you still have a fresh receive address.')] = False, scan_status: Annotated[bool, typer.Option('--scan-status', help="Print Bitcoin Core's wallet scan/coverage diagnostics and exit (descriptor wallet only). Use it when the wallet proposes already-used addresses; if coverage is incomplete, repair it with `jm-wallet rescan`. See the wallet scanning docs.")] = False, data_dir: Annotated[Path | None, typer.Option('--data-dir', envvar='JOINMARKET_DATA_DIR', help='Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR)')] = None, config_file: Annotated[Path | None, typer.Option('--config-file', envvar='JOINMARKET_CONFIG_FILE', help='Config file path (decoupled from data dir). Defaults to <data-dir>/config.toml')] = None, log_level: Annotated[str | None, typer.Option('--log-level', '-l', help='Log level')] = None) -> None

Display wallet information and balances by mixdepth.

Source code in jmwallet/src/jmwallet/cli/wallet.py
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
@app.command()
def info(
    mnemonic_file: Annotated[
        Path | None,
        typer.Option("--mnemonic-file", "-f", help="Path to mnemonic file", envvar="MNEMONIC_FILE"),
    ] = None,
    prompt_bip39_passphrase: Annotated[
        bool,
        typer.Option(
            "--prompt-bip39-passphrase",
            help="Prompt for BIP39 passphrase interactively",
        ),
    ] = False,
    network: Annotated[str | None, typer.Option("--network", "-n", help="Bitcoin network")] = None,
    backend_type: Annotated[
        str | None,
        typer.Option("--backend", "-b", help="Backend: descriptor_wallet | neutrino"),
    ] = None,
    rpc_url: Annotated[str | None, typer.Option("--rpc-url", envvar="BITCOIN_RPC_URL")] = None,
    neutrino_url: Annotated[
        str | None, typer.Option("--neutrino-url", envvar="NEUTRINO_URL")
    ] = None,
    extended: Annotated[
        bool,
        typer.Option(
            "--extended",
            "-e",
            help="Show detailed addresses, derivations, and UTXO outpoints",
        ),
    ] = False,
    gap: Annotated[
        int, typer.Option("--gap", "-g", help="Max address gap to show in extended view")
    ] = 6,
    show_empty: Annotated[
        bool,
        typer.Option(
            "--show-empty/--no-show-empty",
            help=(
                "In --extended view, show addresses with zero balance. "
                "When disabled (default), empty addresses are hidden except "
                "for the first unused one per branch so you still have a "
                "fresh receive address."
            ),
        ),
    ] = False,
    scan_status: Annotated[
        bool,
        typer.Option(
            "--scan-status",
            help=(
                "Print Bitcoin Core's wallet scan/coverage diagnostics and exit "
                "(descriptor wallet only). Use it when the wallet proposes "
                "already-used addresses; if coverage is incomplete, repair it "
                "with `jm-wallet rescan`. See the wallet scanning docs."
            ),
        ),
    ] = False,
    data_dir: Annotated[
        Path | None,
        typer.Option(
            "--data-dir",
            envvar="JOINMARKET_DATA_DIR",
            help="Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR)",
        ),
    ] = None,
    config_file: Annotated[
        Path | None,
        typer.Option(
            "--config-file",
            envvar="JOINMARKET_CONFIG_FILE",
            help="Config file path (decoupled from data dir). Defaults to <data-dir>/config.toml",
        ),
    ] = None,
    log_level: Annotated[
        str | None,
        typer.Option("--log-level", "-l", help="Log level"),
    ] = None,
) -> None:
    """Display wallet information and balances by mixdepth."""
    settings = setup_cli(log_level, data_dir=data_dir, config_file=config_file)

    try:
        resolved = resolve_mnemonic(
            settings,
            mnemonic_file=mnemonic_file,
            prompt_bip39_passphrase=prompt_bip39_passphrase,
        )
        if not resolved:
            raise ValueError("No mnemonic provided")
        resolved_mnemonic = resolved.mnemonic
        resolved_bip39_passphrase = resolved.bip39_passphrase
    except (FileNotFoundError, ValueError) as e:
        logger.error(str(e))
        raise typer.Exit(1)

    # Resolve backend settings with CLI overrides taking priority
    backend = resolve_backend_settings(
        settings,
        network=network,
        backend_type=backend_type,
        rpc_url=rpc_url,
        neutrino_url=neutrino_url,
        data_dir=data_dir,
    )

    asyncio.run(
        _show_wallet_info(
            resolved_mnemonic,
            backend,
            resolved_bip39_passphrase,
            extended=extended,
            display_gap=gap,
            gap_limit=settings.wallet.gap_limit,
            scan_range=settings.wallet.scan_range,
            mixdepth_count=settings.wallet.mixdepth_count,
            max_sats_freeze_reuse=settings.wallet.max_sats_freeze_reuse,
            reconstruct_history=settings.wallet.reconstruct_history,
            show_empty=show_empty,
            creation_height=resolved.creation_height if resolved else None,
            mnemonic_file=resolved.mnemonic_file if resolved else None,
            scan_status_only=scan_status,
        )
    )

rescan(mnemonic_file: Annotated[Path | None, typer.Option('--mnemonic-file', '-f', help='Path to mnemonic file', envvar='MNEMONIC_FILE')] = None, prompt_bip39_passphrase: Annotated[bool, typer.Option('--prompt-bip39-passphrase', help='Prompt for BIP39 passphrase interactively')] = False, network: Annotated[str | None, typer.Option('--network', '-n', help='Bitcoin network')] = None, rpc_url: Annotated[str | None, typer.Option('--rpc-url', envvar='BITCOIN_RPC_URL')] = None, start_height: Annotated[int, typer.Option('--start-height', help="Block height to rescan from (default: 0 = genesis). The wallet's recorded creation height is used as a floor when available, so values below it are clamped up automatically. Honored both on its own and together with --scan-depth.")] = 0, scan_depth: Annotated[int | None, typer.Option('--scan-depth', help='Widen the descriptor address-index range to N per branch before rescanning (re-imports descriptors). Use this once for a wallet whose used addresses sit beyond the configured [wallet].scan_range. See the wallet scanning docs.')] = None, data_dir: Annotated[Path | None, typer.Option('--data-dir', envvar='JOINMARKET_DATA_DIR', help='Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR)')] = None, config_file: Annotated[Path | None, typer.Option('--config-file', envvar='JOINMARKET_CONFIG_FILE', help='Config file path (decoupled from data dir). Defaults to <data-dir>/config.toml')] = None, log_level: Annotated[str | None, typer.Option('--log-level', '-l', help='Log level')] = None) -> None

Rescan the blockchain to repair a descriptor wallet's coverage.

Two kinds of gap can leave the wallet unaware of its own coins:

  • Time coverage: Bitcoin Core has not scanned far enough back. Plain jm-wallet rescan (optionally --start-height H) re-scans blocks against the current descriptor range.
  • Index coverage: a used address sits beyond the imported address range (common for wallets migrated from legacy joinmarket-clientserver). Pass --scan-depth N to widen the range to N per branch, then rescan. --scan-depth can be combined with --start-height H to widen the range and only rescan from height H (defaults to genesis).

Rescans can take a long time on mainnet. Duration varies substantially with the Bitcoin node and its storage performance. Rescans are read-only and run server-side, so Ctrl-C only stops progress polling, not the scan; re-attach later with jm-wallet info --scan-status. See docs/technical/wallet-scanning.md.

Source code in jmwallet/src/jmwallet/cli/wallet.py
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
@app.command()
def rescan(
    mnemonic_file: Annotated[
        Path | None,
        typer.Option("--mnemonic-file", "-f", help="Path to mnemonic file", envvar="MNEMONIC_FILE"),
    ] = None,
    prompt_bip39_passphrase: Annotated[
        bool,
        typer.Option(
            "--prompt-bip39-passphrase",
            help="Prompt for BIP39 passphrase interactively",
        ),
    ] = False,
    network: Annotated[str | None, typer.Option("--network", "-n", help="Bitcoin network")] = None,
    rpc_url: Annotated[str | None, typer.Option("--rpc-url", envvar="BITCOIN_RPC_URL")] = None,
    start_height: Annotated[
        int,
        typer.Option(
            "--start-height",
            help=(
                "Block height to rescan from (default: 0 = genesis). The "
                "wallet's recorded creation height is used as a floor when "
                "available, so values below it are clamped up automatically. "
                "Honored both on its own and together with --scan-depth."
            ),
        ),
    ] = 0,
    scan_depth: Annotated[
        int | None,
        typer.Option(
            "--scan-depth",
            help=(
                "Widen the descriptor address-index range to N per branch "
                "before rescanning (re-imports descriptors). Use this once for "
                "a wallet whose used addresses sit beyond the configured "
                "[wallet].scan_range. See the wallet scanning docs."
            ),
        ),
    ] = None,
    data_dir: Annotated[
        Path | None,
        typer.Option(
            "--data-dir",
            envvar="JOINMARKET_DATA_DIR",
            help="Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR)",
        ),
    ] = None,
    config_file: Annotated[
        Path | None,
        typer.Option(
            "--config-file",
            envvar="JOINMARKET_CONFIG_FILE",
            help="Config file path (decoupled from data dir). Defaults to <data-dir>/config.toml",
        ),
    ] = None,
    log_level: Annotated[
        str | None,
        typer.Option("--log-level", "-l", help="Log level"),
    ] = None,
) -> None:
    """Rescan the blockchain to repair a descriptor wallet's coverage.

    Two kinds of gap can leave the wallet unaware of its own coins:

    - Time coverage: Bitcoin Core has not scanned far enough back. Plain
      `jm-wallet rescan` (optionally `--start-height H`) re-scans blocks
      against the current descriptor range.
    - Index coverage: a used address sits beyond the imported address range
      (common for wallets migrated from legacy joinmarket-clientserver). Pass
      `--scan-depth N` to widen the range to N per branch, then rescan.
      `--scan-depth` can be combined with `--start-height H` to widen the
      range and only rescan from height H (defaults to genesis).

    Rescans can take a long time on mainnet. Duration varies substantially with
    the Bitcoin node and its storage performance. Rescans are read-only and run
    server-side, so Ctrl-C only stops progress polling, not the scan; re-attach
    later with `jm-wallet info --scan-status`. See docs/technical/wallet-scanning.md.
    """
    settings = setup_cli(log_level, data_dir=data_dir, config_file=config_file)

    try:
        resolved = resolve_mnemonic(
            settings,
            mnemonic_file=mnemonic_file,
            prompt_bip39_passphrase=prompt_bip39_passphrase,
        )
        if not resolved:
            raise ValueError("No mnemonic provided")
    except (FileNotFoundError, ValueError) as e:
        logger.error(str(e))
        raise typer.Exit(1)

    backend_settings = resolve_backend_settings(
        settings,
        network=network,
        rpc_url=rpc_url,
        data_dir=data_dir,
    )

    # Rescan is a Bitcoin Core wallet operation; the Neutrino backend has
    # no analogue and trying to force it down a descriptor_wallet code
    # path would just fail later with a confusing connection error.
    if backend_settings.backend_type != "descriptor_wallet":
        logger.error(
            "jm-wallet rescan is only supported with the descriptor_wallet backend "
            f"(configured backend: {backend_settings.backend_type}). The Neutrino "
            "backend reuses its own filter cache and does not expose a rescan."
        )
        raise typer.Exit(2)

    asyncio.run(
        _run_rescan(
            mnemonic=resolved.mnemonic,
            backend_settings=backend_settings,
            bip39_passphrase=resolved.bip39_passphrase,
            start_height=start_height,
            creation_height=resolved.creation_height,
            mnemonic_file=resolved.mnemonic_file,
            scan_depth=scan_depth,
            gap_limit=settings.wallet.gap_limit,
            scan_range=settings.wallet.scan_range,
            mixdepth_count=settings.wallet.mixdepth_count,
            max_sats_freeze_reuse=settings.wallet.max_sats_freeze_reuse,
            reconstruct_history=settings.wallet.reconstruct_history,
        )
    )

showseed(mnemonic_file: Annotated[Path, typer.Option('--mnemonic-file', '-f', help='Path to the mnemonic file', envvar='MNEMONIC_FILE')], password: Annotated[str | None, typer.Option('--password', '-p', help='Password for an encrypted mnemonic file. If not given, the MNEMONIC_PASSWORD env var is used, otherwise an interactive prompt is shown.', envvar='MNEMONIC_PASSWORD')] = None, numbered: Annotated[bool, typer.Option('--numbered/--no-numbered', help='Print each seed word on its own line, prefixed with its index.')] = True, yes: Annotated[bool, typer.Option('--yes', '-y', help="Skip the interactive 'Are you sure?' confirmation. Use with care.")] = False) -> None

Display the BIP39 seed words (mnemonic) of an existing wallet.

Reads the encrypted .mnemonic file produced by jm-wallet generate (or any compatible wallet) and prints the seed words to stdout.

SECURITY: - The seed words give full control over all funds. Never share them, never type them into a website, never store them in cloud sync. - Only run this command in a private setting. Output goes to stdout in plaintext; redirect carefully. - The password is required when the mnemonic file is encrypted.

Source code in jmwallet/src/jmwallet/cli/wallet.py
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
@app.command()
def showseed(
    mnemonic_file: Annotated[
        Path,
        typer.Option(
            "--mnemonic-file",
            "-f",
            help="Path to the mnemonic file",
            envvar="MNEMONIC_FILE",
        ),
    ],
    password: Annotated[
        str | None,
        typer.Option(
            "--password",
            "-p",
            help=(
                "Password for an encrypted mnemonic file. If not given, the "
                "MNEMONIC_PASSWORD env var is used, otherwise an interactive "
                "prompt is shown."
            ),
            envvar="MNEMONIC_PASSWORD",
        ),
    ] = None,
    numbered: Annotated[
        bool,
        typer.Option(
            "--numbered/--no-numbered",
            help="Print each seed word on its own line, prefixed with its index.",
        ),
    ] = True,
    yes: Annotated[
        bool,
        typer.Option(
            "--yes",
            "-y",
            help="Skip the interactive 'Are you sure?' confirmation. Use with care.",
        ),
    ] = False,
) -> None:
    """Display the BIP39 seed words (mnemonic) of an existing wallet.

    Reads the encrypted ``.mnemonic`` file produced by ``jm-wallet generate``
    (or any compatible wallet) and prints the seed words to stdout.

    SECURITY:
    - The seed words give full control over all funds. Never share them, never
      type them into a website, never store them in cloud sync.
    - Only run this command in a private setting. Output goes to stdout in
      plaintext; redirect carefully.
    - The password is required when the mnemonic file is encrypted.
    """
    if not mnemonic_file.exists():
        print(f"Error: Mnemonic file not found: {mnemonic_file}")
        raise typer.Exit(1)

    # Try plaintext load first; if encrypted, prompt for / use password.
    try:
        mnemonic = load_mnemonic_file(mnemonic_file)
    except ValueError as e:
        if "encrypted" in str(e).lower():
            if not password:
                password = typer.prompt("Enter password to decrypt mnemonic file", hide_input=True)
            try:
                mnemonic = load_mnemonic_file(mnemonic_file, password)
            except ValueError as e2:
                msg = str(e2).lower()
                if "decryption failed" in msg or "wrong password" in msg:
                    print("Error: Incorrect password.")
                else:
                    print(f"Error: {e2}")
                raise typer.Exit(1)
            except FileNotFoundError as e2:
                print(f"Error: {e2}")
                raise typer.Exit(1)
        else:
            print(f"Error: {e}")
            raise typer.Exit(1)
    except FileNotFoundError as e:
        print(f"Error: {e}")
        raise typer.Exit(1)

    if not yes:
        # Interactive guard so seed words are never accidentally splashed on
        # a shared terminal (e.g. when the user mistypes another command).
        confirm = typer.confirm(
            "About to print the BIP39 seed words to stdout. "
            "Are you in a private setting and sure you want to continue?",
            default=False,
        )
        if not confirm:
            print("Aborted.")
            raise typer.Exit(1)

    words = mnemonic.strip().split()

    typer.secho(
        "WARNING: Anyone with these words can spend all your funds. "
        "Do not share them, photograph them, or paste them into any website.",
        fg=typer.colors.RED,
        bold=True,
        err=True,
    )

    if numbered:
        for i, word in enumerate(words, start=1):
            print(f"{i:2d}. {word}")
    else:
        print(mnemonic.strip())

validate(mnemonic_file: Annotated[Path | None, typer.Option('--mnemonic-file', '-f', help='Path to mnemonic file', envvar='MNEMONIC_FILE')] = None) -> None

Validate a mnemonic phrase.

Provide a mnemonic via --mnemonic-file, the MNEMONIC environment variable, or enter it interactively when prompted.

Source code in jmwallet/src/jmwallet/cli/wallet.py
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
@app.command()
def validate(
    mnemonic_file: Annotated[
        Path | None,
        typer.Option("--mnemonic-file", "-f", help="Path to mnemonic file", envvar="MNEMONIC_FILE"),
    ] = None,
) -> None:
    """Validate a mnemonic phrase.

    Provide a mnemonic via --mnemonic-file, the MNEMONIC environment variable,
    or enter it interactively when prompted.
    """
    import os

    mnemonic: str = ""

    if mnemonic_file:
        try:
            mnemonic = load_mnemonic_file(mnemonic_file)
        except ValueError as e:
            if "encrypted" in str(e).lower():
                # File is encrypted, prompt for password
                password = typer.prompt("Enter password to decrypt mnemonic file", hide_input=True)
                try:
                    mnemonic = load_mnemonic_file(mnemonic_file, password)
                except (FileNotFoundError, ValueError) as e2:
                    print(f"Error: {e2}")
                    raise typer.Exit(1)
            else:
                print(f"Error: {e}")
                raise typer.Exit(1)
        except FileNotFoundError as e:
            print(f"Error: {e}")
            raise typer.Exit(1)
    else:
        env_mnemonic = os.environ.get("MNEMONIC")
        if env_mnemonic:
            mnemonic = env_mnemonic.strip()
        else:
            mnemonic = typer.prompt("Enter mnemonic to validate")

    if validate_mnemonic(mnemonic):
        print("Mnemonic is VALID")
        word_count = len(mnemonic.strip().split())
        print(f"Word count: {word_count}")
    else:
        print("Mnemonic is INVALID")
        raise typer.Exit(1)

verify_password(mnemonic_file: Annotated[Path, typer.Option('--mnemonic-file', '-f', help='Path to encrypted mnemonic file', envvar='MNEMONIC_FILE')], password: Annotated[str | None, typer.Option('--password', '-p', help='Password to verify. If not provided, read from MNEMONIC_PASSWORD env or prompt.', envvar='MNEMONIC_PASSWORD')] = None, prompt: Annotated[bool, typer.Option('--prompt/--no-prompt', help='Prompt for password if not provided via flag/env.')] = True) -> None

Verify that a password can decrypt an encrypted mnemonic file.

Exits with status 0 if the password is correct, 1 otherwise. Intended for scripting (e.g. the TUI) to validate a password before storing it in config.toml. No mnemonic content is printed.

Source code in jmwallet/src/jmwallet/cli/wallet.py
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
@app.command("verify-password")
def verify_password(
    mnemonic_file: Annotated[
        Path,
        typer.Option(
            "--mnemonic-file",
            "-f",
            help="Path to encrypted mnemonic file",
            envvar="MNEMONIC_FILE",
        ),
    ],
    password: Annotated[
        str | None,
        typer.Option(
            "--password",
            "-p",
            help="Password to verify. If not provided, read from MNEMONIC_PASSWORD env or prompt.",
            envvar="MNEMONIC_PASSWORD",
        ),
    ] = None,
    prompt: Annotated[
        bool,
        typer.Option(
            "--prompt/--no-prompt",
            help="Prompt for password if not provided via flag/env.",
        ),
    ] = True,
) -> None:
    """Verify that a password can decrypt an encrypted mnemonic file.

    Exits with status 0 if the password is correct, 1 otherwise.
    Intended for scripting (e.g. the TUI) to validate a password before
    storing it in config.toml. No mnemonic content is printed.
    """
    if not mnemonic_file.exists():
        print(f"Error: Mnemonic file not found: {mnemonic_file}")
        raise typer.Exit(1)

    # Detect plaintext wallets up front: there is nothing to verify.
    try:
        data = mnemonic_file.read_bytes()
        text = data.decode("utf-8")
        words = text.strip().split()
        if len(words) in (12, 15, 18, 21, 24) and all(w.isalpha() for w in words):
            print("Mnemonic file is not encrypted; no password to verify.")
            raise typer.Exit(2)
    except UnicodeDecodeError:
        pass

    if not password and prompt:
        password = typer.prompt("Enter password to verify", hide_input=True)

    if not password:
        print("Error: No password provided.")
        raise typer.Exit(1)

    try:
        load_mnemonic_file(mnemonic_file, password)
    except ValueError as e:
        # Wrong password or corrupt file -- do not leak details.
        msg = str(e).lower()
        if "decryption failed" in msg or "wrong password" in msg:
            print("Password is INCORRECT")
        else:
            print(f"Error: {e}")
        raise typer.Exit(1)
    except FileNotFoundError as e:
        print(f"Error: {e}")
        raise typer.Exit(1)

    print("Password is CORRECT")