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
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
@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:
            # 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)

            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
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 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
@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.")

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, 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
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
@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,
    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)

    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,
            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, 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
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
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
@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,
    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)

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

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
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
@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
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
@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
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
@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")