Skip to content

jmwallet.cli.wallet

jmwallet.cli.wallet

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

Attributes

Classes

Functions:

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
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
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
@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.
    """
    if data_dir is not None:
        os.environ["JOINMARKET_DATA_DIR"] = str(data_dir)
    setup_logging()

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

            # 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
 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
@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
    """
    if data_dir is not None:
        os.environ["JOINMARKET_DATA_DIR"] = str(data_dir)
    setup_logging()

    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(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
    save_mnemonic_file(resolved_mnemonic, output_file, password)

    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 address view with derivations')] = 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
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
511
512
513
514
515
@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 address view with derivations")
    ] = 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,
            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,
            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 are slow (20+ minutes on mainnet from genesis) but read-only. The scan runs server-side in Bitcoin Core, so Ctrl-C only stops the 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
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
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
1614
1615
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
@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 are slow (20+ minutes on mainnet from genesis) but read-only. The
    scan runs server-side in Bitcoin Core, so Ctrl-C only stops the 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,
            scan_depth=scan_depth,
            gap_limit=settings.wallet.gap_limit,
            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
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
@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
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
@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
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
@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")