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
 51
 52
 53
 54
 55
 56
 57
 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
 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
@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 jmcore.wallet_metadata import load_mnemonic_meta, registered_identities, selected_identity

    from jmwallet.cli.mnemonic import load_mnemonic_meta_fingerprint

    identities = registered_identities(resolved_mnemonic_file)
    meta = load_mnemonic_meta(resolved_mnemonic_file, strict=True)
    entries = meta.get("identities", {})
    if len(identities) > 1 or not isinstance(entries, dict) or len(entries) != len(identities):
        logger.error(
            "This mnemonic file holds multiple registered wallet identities "
            "or unconfirmed entries. Deletion would "
            "remove their shared seed material. Back up the mnemonic and sidecar and retire "
            "all derived wallets explicitly; this command will not delete the shared file."
        )
        raise typer.Exit(1)
    identity = selected_identity(resolved_mnemonic_file)
    cached_fingerprint = (
        identity.fingerprint
        if identity is not None
        else 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, register_identity: Annotated[bool | None, typer.Option('--register-identity/--no-register-identity', help='Confirm and select a derived wallet identity (default: follow BIP39 setting)')] = None, 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
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
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
@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,
    register_identity: Annotated[
        bool | None,
        typer.Option(
            "--register-identity/--no-register-identity",
            help="Confirm and select a derived wallet identity (default: follow BIP39 setting)",
        ),
    ] = None,
    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.
    """
    settings = 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!")
            _onboard_saved_identity(settings, output_file, password, register_identity, mnemonic)
        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, register_identity: Annotated[bool | None, typer.Option('--register-identity/--no-register-identity', help='Confirm and select a derived wallet identity (default: follow BIP39 setting)')] = None, 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
362
363
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
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
503
504
505
506
507
508
509
510
@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,
    register_identity: Annotated[
        bool | None,
        typer.Option(
            "--register-identity/--no-register-identity",
            help="Confirm and select a derived wallet identity (default: follow BIP39 setting)",
        ),
    ] = None,
    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
    """
    settings = 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("\nSeed imported. Wallet recovery is not yet verified.")
    typer.echo(
        "WARNING: Previously spent addresses may still appear unused. "
        "Before receiving or spending, synchronize and compare known addresses "
        "and balances with your original records."
    )
    typer.echo(
        "A visible balance or an idle scan does not prove complete recovery. "
        "Historical recovery requires a backend with the necessary block and history data."
    )
    _onboard_saved_identity(settings, output_file, password, register_identity, resolved_mnemonic)

info(ctx: typer.Context, 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, view: Annotated[str, typer.Option('--view', help="Extended-view layout: 'legacy' (the existing layout, default), 'full' (pipe-aligned table, one line per UTXO, exact confirmation counts, explicit spendable state), or 'compact' (80-column variant of 'full': two lines per UTXO, three for funded bonds, full copyable outpoints). Requires --extended.")] = 'legacy', 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, show_utxos: Annotated[bool, typer.Option('--show-utxos', help='In the basic view, add a per-mixdepth UTXO breakdown grouped by type (cj-out, cj-change, deposit, reg-change) plus fidelity bonds.')] = 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
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
@app.command()
def info(
    ctx: typer.Context,
    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,
    view: Annotated[
        str,
        typer.Option(
            "--view",
            help=(
                "Extended-view layout: 'legacy' (the existing layout, default), "
                "'full' (pipe-aligned table, one line per UTXO, exact "
                "confirmation counts, explicit spendable state), or 'compact' "
                "(80-column variant of 'full': two lines per UTXO, three for "
                "funded bonds, full copyable outpoints). Requires --extended."
            ),
        ),
    ] = "legacy",
    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,
    show_utxos: Annotated[
        bool,
        typer.Option(
            "--show-utxos",
            help=(
                "In the basic view, add a per-mixdepth UTXO breakdown grouped "
                "by type (cj-out, cj-change, deposit, reg-change) plus "
                "fidelity bonds."
            ),
        ),
    ] = 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."""
    # Fail fast on invalid flag combinations, before any mnemonic prompt or
    # backend connection: --view only selects the *extended* layout, so it is
    # meaningless (and likely a user mistake) without --extended.
    if view not in ("legacy", "full", "compact"):
        typer.echo(f"Error: Invalid --view '{view}'. Choose from: legacy, full, compact.", err=True)
        raise typer.Exit(2)
    view_source = ctx.get_parameter_source("view")
    if view_source is not None and view_source.name == "COMMANDLINE" and not extended:
        typer.echo("Error: --view requires --extended.", err=True)
        raise typer.Exit(2)

    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,
            view=view,
            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,
            show_utxos=show_utxos,
            show_fidelity_bonds=settings.wallet.info_show_fidelity_bonds,
            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
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
@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(ctx: typer.Context, 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
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
@app.command()
def showseed(
    ctx: typer.Context,
    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.
    """
    # Recent Typer versions vendor Click, so the enum's identity is not shared.
    source = ctx.get_parameter_source("password")
    if source is not None and source.name == "COMMANDLINE":
        typer.echo(
            "WARNING: Passing a password on the command line can expose it to other users.",
            err=True,
        )

    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
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
@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(ctx: typer.Context, 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
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
@app.command("verify-password")
def verify_password(
    ctx: typer.Context,
    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.
    """
    # Recent Typer versions vendor Click, so the enum's identity is not shared.
    source = ctx.get_parameter_source("password")
    if source is not None and source.name == "COMMANDLINE":
        typer.echo(
            "WARNING: Passing a password on the command line can expose it to other users.",
            err=True,
        )

    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")