Skip to content

jmwallet.cli.cold_wallet

jmwallet.cli.cold_wallet

Cold wallet workflow: create-bond-address, generate-hot-keypair, prepare-certificate-message, import-certificate, spend-bond + crypto verification helpers.

Attributes

Functions

create_bond_address(pubkey: Annotated[str, typer.Argument(help='Public key (hex, 33 bytes compressed)')], locktime: Annotated[int, typer.Option('--locktime', '-L', help='Locktime as Unix timestamp')] = 0, locktime_date: Annotated[str | None, typer.Option('--locktime-date', '-d', help='Locktime as date (YYYY-MM, must be 1st of month)')] = None, network: Annotated[str, typer.Option('--network', '-n')] = 'mainnet', data_dir: Annotated[Path | None, typer.Option('--data-dir', help='Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR)')] = None, no_save: Annotated[bool, typer.Option('--no-save', help='Do not save the bond to the registry')] = False, log_level: Annotated[str, typer.Option('--log-level', '-l')] = 'INFO') -> None

Create a fidelity bond address from a public key (cold wallet workflow).

This command creates a timelocked P2WSH bond address from a public key WITHOUT requiring your mnemonic or private keys. Use this for true cold storage security.

WORKFLOW: 1. Use Sparrow Wallet (or similar) with your hardware wallet 2. Navigate to your wallet's receive addresses 3. Find or create an address at the fidelity bond derivation path (m/84'/0'/0'/2/0) 4. Copy the public key from the address details 5. Use this command with the public key to create the bond address 6. Fund the bond address from any wallet 7. Use 'prepare-certificate-message' and hardware wallet signing for certificates

Your hardware wallet never needs to be connected to this online tool.

Source code in jmwallet/src/jmwallet/cli/cold_wallet.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 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
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
@app.command("create-bond-address")
def create_bond_address(
    pubkey: Annotated[str, typer.Argument(help="Public key (hex, 33 bytes compressed)")],
    locktime: Annotated[
        int, typer.Option("--locktime", "-L", help="Locktime as Unix timestamp")
    ] = 0,
    locktime_date: Annotated[
        str | None,
        typer.Option(
            "--locktime-date", "-d", help="Locktime as date (YYYY-MM, must be 1st of month)"
        ),
    ] = None,
    network: Annotated[str, typer.Option("--network", "-n")] = "mainnet",
    data_dir: Annotated[
        Path | None,
        typer.Option(
            "--data-dir",
            help="Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR)",
        ),
    ] = None,
    no_save: Annotated[
        bool,
        typer.Option("--no-save", help="Do not save the bond to the registry"),
    ] = False,
    log_level: Annotated[str, typer.Option("--log-level", "-l")] = "INFO",
) -> None:
    """
    Create a fidelity bond address from a public key (cold wallet workflow).

    This command creates a timelocked P2WSH bond address from a public key WITHOUT
    requiring your mnemonic or private keys. Use this for true cold storage security.

    WORKFLOW:
    1. Use Sparrow Wallet (or similar) with your hardware wallet
    2. Navigate to your wallet's receive addresses
    3. Find or create an address at the fidelity bond derivation path (m/84'/0'/0'/2/0)
    4. Copy the public key from the address details
    5. Use this command with the public key to create the bond address
    6. Fund the bond address from any wallet
    7. Use 'prepare-certificate-message' and hardware wallet signing for certificates

    Your hardware wallet never needs to be connected to this online tool.
    """
    setup_logging(log_level)

    # Strip wpkh() wrapper if present (Sparrow copies pubkey as "wpkh(03abcd...)")
    pubkey = pubkey.strip()
    if pubkey.startswith("wpkh(") and pubkey.endswith(")"):
        pubkey = pubkey[5:-1]
        logger.info("Stripped wpkh() wrapper from public key")

    # Validate pubkey
    try:
        pubkey_bytes = bytes.fromhex(pubkey)
        if len(pubkey_bytes) != 33:
            raise ValueError("Public key must be 33 bytes (compressed)")
        # Verify it's a valid compressed pubkey (starts with 02 or 03)
        if pubkey_bytes[0] not in (0x02, 0x03):
            raise ValueError("Invalid compressed public key format")
    except ValueError as e:
        logger.error(f"Invalid public key: {e}")
        raise typer.Exit(1)

    # Parse locktime
    from jmcore.timenumber import is_valid_locktime, parse_locktime_date

    if locktime_date:
        try:
            locktime = parse_locktime_date(locktime_date)
        except ValueError as e:
            logger.error(f"Invalid locktime date: {e}")
            logger.info("Use format: YYYY-MM or YYYY-MM-DD (must be 1st of month)")
            logger.info("Valid range: 2020-01 to 2099-12")
            raise typer.Exit(1)

    if locktime <= 0:
        logger.error("Locktime is required. Use --locktime or --locktime-date")
        raise typer.Exit(1)

    # Validate locktime is a valid timenumber (1st of month, midnight UTC)
    if not is_valid_locktime(locktime):
        from jmcore.timenumber import get_nearest_valid_locktime

        suggested = get_nearest_valid_locktime(locktime, round_up=True)
        suggested_dt = datetime.fromtimestamp(suggested)
        logger.warning(
            f"Locktime {locktime} is not a valid fidelity bond locktime "
            f"(must be 1st of month at midnight UTC)"
        )
        logger.info(f"Suggested locktime: {suggested} ({suggested_dt.strftime('%Y-%m-%d')})")
        logger.info("Use --locktime-date YYYY-MM for correct format")
        raise typer.Exit(1)

    # Validate locktime is in the future
    if locktime <= datetime.now().timestamp():
        logger.warning("Locktime is in the past - the bond will be immediately spendable")

    from jmcore.btc_script import disassemble_script, mk_freeze_script
    from jmcore.paths import get_default_data_dir

    from jmwallet.wallet.address import script_to_p2wsh_address
    from jmwallet.wallet.bond_registry import (
        create_bond_info,
        load_registry,
        save_registry,
    )

    # Create the witness script from the public key
    witness_script = mk_freeze_script(pubkey, locktime)
    address = script_to_p2wsh_address(witness_script, network)

    locktime_dt = datetime.fromtimestamp(locktime)
    disassembled = disassemble_script(witness_script)

    # Resolve data directory
    resolved_data_dir = data_dir if data_dir else get_default_data_dir()

    # Save to registry unless --no-save
    saved = False
    existing = False
    if not no_save:
        registry = load_registry(resolved_data_dir)
        existing_bond = registry.get_bond_by_address(address)
        if existing_bond:
            existing = True
            logger.info(f"Bond already exists in registry (created: {existing_bond.created_at})")
        else:
            # For bonds created from pubkey, we don't have the derivation path or index
            # So we use placeholder values
            bond_info = create_bond_info(
                address=address,
                locktime=locktime,
                index=-1,  # Unknown index for pubkey-based bonds
                path="external",  # Path is unknown when created from pubkey
                pubkey_hex=pubkey,
                witness_script=witness_script,
                network=network,
            )
            registry.add_bond(bond_info)
            save_registry(registry, resolved_data_dir)
            saved = True

    # Compute the underlying P2WPKH address for the pubkey (for user confirmation)
    from jmwallet.wallet.address import pubkey_to_p2wpkh_address

    p2wpkh_address = pubkey_to_p2wpkh_address(bytes.fromhex(pubkey), network)

    print("\n" + "=" * 80)
    print("FIDELITY BOND ADDRESS (created from public key)")
    print("=" * 80)
    print(f"\nBond Address (P2WSH):  {address}")
    print(f"Signing Address:       {p2wpkh_address}")
    print("  (Use this address in Sparrow to sign messages)")
    print(f"Locktime:              {locktime} ({locktime_dt.strftime('%Y-%m-%d %H:%M:%S')})")
    print(f"Network:               {network}")
    print(f"Public Key:            {pubkey}")
    print()
    print("-" * 80)
    print("WITNESS SCRIPT (redeemScript)")
    print("-" * 80)
    print(f"Hex:          {witness_script.hex()}")
    print(f"Disassembled: {disassembled}")
    print("-" * 80)
    if saved:
        print(f"\nSaved to registry: {resolved_data_dir / 'fidelity_bonds.json'}")
    elif existing:
        print("\nBond already in registry (not updated)")
    elif no_save:
        print("\nNot saved to registry (--no-save)")
    print("\n" + "=" * 80)
    print("HOW TO GET PUBLIC KEY FROM SPARROW WALLET:")
    print("=" * 80)
    print("  1. Open Sparrow Wallet and connect your hardware wallet")
    print("  2. Go to Addresses tab")
    print("  3. Choose any address from the Deposit (m/84'/0'/0'/0/x) or")
    print("     Change (m/84'/0'/0'/1/x) account - use index 0 for simplicity")
    print("  4. Right-click the address and select 'Copy Public Key'")
    print("     If Sparrow copies it as 'wpkh(03abcd...)', that's fine -")
    print("     this command strips the wpkh() wrapper automatically")
    print("  5. Note the DERIVATION PATH: double-click the address (or click the")
    print("     receive arrow) to see it (e.g., m/84'/0'/0'/0/0)")
    print("  6. Note the MASTER FINGERPRINT: go to Settings (bottom-left) ->")
    print("     Keystores section (e.g., aabbccdd)")
    print("  -> You will need both when running 'spend-bond' later")
    print()
    print("NOTE: The /2 fidelity bond derivation path is NOT available in Sparrow.")
    print("      Using /0 (deposit) or /1 (change) addresses works fine.")
    print()
    print("IMPORTANT:")
    print("  - Funds sent to the Bond Address are LOCKED until the locktime!")
    print("  - Remember which address you used for the bond's public key")
    print("  - Your private keys never leave the hardware wallet")
    print("=" * 80 + "\n")

generate_hot_keypair(bond_address: Annotated[str | None, typer.Option('--bond-address', help='Bond address to associate keypair with (saves to registry)')] = None, data_dir: Annotated[Path | None, typer.Option('--data-dir', help='Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR)')] = None, log_level: Annotated[str, typer.Option('--log-level')] = 'INFO') -> None

Generate a hot wallet keypair for fidelity bond certificates.

This generates a random keypair that will be used for signing nick messages in the fidelity bond proof. The private key stays in the hot wallet, while the public key is used to create a certificate signed by the cold wallet.

The certificate chain is: UTXO keypair (cold) -> signs -> certificate (hot) -> signs -> nick proofs

If --bond-address is provided, the keypair is saved to the bond registry and will be automatically used when importing the certificate.

SECURITY: - The hot wallet private key should be stored securely - If compromised, an attacker can impersonate your bond until cert expires - But they CANNOT spend your bond funds (those remain in cold storage)

Source code in jmwallet/src/jmwallet/cli/cold_wallet.py
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
@app.command("generate-hot-keypair")
def generate_hot_keypair(
    bond_address: Annotated[
        str | None,
        typer.Option(
            "--bond-address",
            help="Bond address to associate keypair with (saves to registry)",
        ),
    ] = None,
    data_dir: Annotated[
        Path | None,
        typer.Option(
            "--data-dir",
            help="Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR)",
        ),
    ] = None,
    log_level: Annotated[str, typer.Option("--log-level")] = "INFO",
) -> None:
    """
    Generate a hot wallet keypair for fidelity bond certificates.

    This generates a random keypair that will be used for signing nick messages
    in the fidelity bond proof. The private key stays in the hot wallet, while
    the public key is used to create a certificate signed by the cold wallet.

    The certificate chain is:
      UTXO keypair (cold) -> signs -> certificate (hot) -> signs -> nick proofs

    If --bond-address is provided, the keypair is saved to the bond registry
    and will be automatically used when importing the certificate.

    SECURITY:
    - The hot wallet private key should be stored securely
    - If compromised, an attacker can impersonate your bond until cert expires
    - But they CANNOT spend your bond funds (those remain in cold storage)
    """
    setup_logging(log_level)

    from coincurve import PrivateKey
    from jmcore.paths import get_default_data_dir

    # Generate a random private key
    privkey = PrivateKey()
    pubkey = privkey.public_key.format(compressed=True)

    resolved_data_dir = data_dir if data_dir else get_default_data_dir()

    # Optionally save to registry
    saved_to_registry = False
    saved_key_file: Path | None = None
    if bond_address:
        from jmwallet.wallet.bond_registry import load_registry, save_registry

        registry = load_registry(resolved_data_dir)
        bond = registry.get_bond_by_address(bond_address)

        if bond:
            bond.cert_pubkey = pubkey.hex()
            bond.cert_privkey = privkey.secret.hex()
            save_registry(registry, resolved_data_dir)
            saved_to_registry = True
            logger.info(f"Saved hot keypair to bond registry for {bond_address}")
        else:
            logger.warning(f"Bond not found for address: {bond_address}")
            logger.info("Private key will be written to a local key file")

    if not saved_to_registry:
        resolved_data_dir.mkdir(parents=True, exist_ok=True)
        key_file_name = f"hot_certificate_key_{pubkey.hex()[:16]}.json"
        saved_key_file = resolved_data_dir / key_file_name
        key_content = (
            json.dumps(
                {
                    "cert_pubkey": pubkey.hex(),
                    "cert_privkey": privkey.secret.hex(),
                },
                indent=2,
            )
            + "\n"
        )
        fd = os.open(saved_key_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        with os.fdopen(fd, "w") as f:
            f.write(key_content)
        logger.info(f"Wrote hot keypair to {saved_key_file} with mode 0600")

    print("\n" + "=" * 80)
    print("HOT WALLET KEYPAIR FOR FIDELITY BOND CERTIFICATE")
    print("=" * 80)
    print(f"Public Key (hex):  {pubkey.hex()}")
    if saved_to_registry:
        print(f"\nSaved to bond registry for: {bond_address}")
        print("  (The keypair will be used automatically with import-certificate)")
    elif saved_key_file is not None:
        print(f"\nPrivate key saved to: {saved_key_file}")
        print("  (File permissions set to 0600)")
    print("\n" + "=" * 80)
    print("NEXT STEPS:")
    print("  1. Use the public key with 'prepare-certificate-message'")
    print("  2. Sign the certificate message with your hardware wallet (Sparrow)")
    print("  3. Import the certificate with 'import-certificate'")
    if not saved_to_registry and saved_key_file is not None:
        print("\nNOTE: Keep the key file secure; you will need it for import-certificate.")
    print("\nSECURITY:")
    print("  - This is the HOT wallet key - it will be used to sign nick proofs")
    print("  - If this key is compromised, attacker can impersonate your bond")
    print("  - But your BOND FUNDS remain safe in cold storage!")
    print("=" * 80 + "\n")

import_certificate(address: Annotated[str, typer.Argument(help='Bond address')], cert_pubkey: Annotated[str | None, typer.Option('--cert-pubkey', help='Certificate pubkey (hex)')] = None, cert_signature: Annotated[str, typer.Option('--cert-signature', help='Certificate signature (base64)')] = '', cert_expiry: Annotated[int, typer.Option('--cert-expiry', help='Certificate expiry as ABSOLUTE period number (from prepare-certificate-message)')] = 0, data_dir: Annotated[Path | None, typer.Option('--data-dir', help='Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR)')] = None, skip_verification: Annotated[bool, typer.Option('--skip-verification', help='Skip signature verification (not recommended)')] = False, network: Annotated[str | None, typer.Option('--network', '-n', help='Bitcoin network')] = None, backend_type: Annotated[str | None, typer.Option('--backend', '-b', help='Backend: scantxoutset | 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, mempool_api: Annotated[str, typer.Option('--mempool-api', help='Mempool API URL for validating cert expiry. Only used when no Bitcoin node backend is configured. Example: http://localhost:8999/api')] = '', current_block: Annotated[int | None, typer.Option('--current-block', help='Current block height override for offline/air-gapped workflows. Skips all network block-height lookups.')] = None, log_level: Annotated[str, typer.Option('--log-level')] = 'INFO') -> None

Import a certificate signature for a fidelity bond (cold wallet support).

This imports a certificate generated with 'prepare-certificate-message' into the bond registry, allowing the hot wallet to use it for making offers.

IMPORTANT: The --cert-expiry value must match EXACTLY what was used in prepare-certificate-message. This is an ABSOLUTE period number, not a duration.

If --cert-pubkey is not provided, it will be loaded from the bond registry. The certificate private key is loaded from the bond registry, or requested via an interactive hidden prompt if unavailable there.

The signature should be the base64 output from Sparrow's message signing tool, using the 'Standard (Electrum)' format.

Source code in jmwallet/src/jmwallet/cli/cold_wallet.py
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 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
@app.command("import-certificate")
def import_certificate(
    address: Annotated[str, typer.Argument(help="Bond address")],
    cert_pubkey: Annotated[
        str | None, typer.Option("--cert-pubkey", help="Certificate pubkey (hex)")
    ] = None,
    cert_signature: Annotated[
        str, typer.Option("--cert-signature", help="Certificate signature (base64)")
    ] = "",
    cert_expiry: Annotated[
        int,
        typer.Option(
            "--cert-expiry",
            help="Certificate expiry as ABSOLUTE period number (from prepare-certificate-message)",
        ),
    ] = 0,  # 0 means "must be provided"
    data_dir: Annotated[
        Path | None,
        typer.Option(
            "--data-dir",
            help="Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR)",
        ),
    ] = None,
    skip_verification: Annotated[
        bool,
        typer.Option("--skip-verification", help="Skip signature verification (not recommended)"),
    ] = False,
    network: Annotated[str | None, typer.Option("--network", "-n", help="Bitcoin network")] = None,
    backend_type: Annotated[
        str | None,
        typer.Option(
            "--backend", "-b", help="Backend: scantxoutset | 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,
    mempool_api: Annotated[
        str,
        typer.Option(
            "--mempool-api",
            help=(
                "Mempool API URL for validating cert expiry. "
                "Only used when no Bitcoin node backend is configured. "
                "Example: http://localhost:8999/api"
            ),
        ),
    ] = "",
    current_block: Annotated[
        int | None,
        typer.Option(
            "--current-block",
            help=(
                "Current block height override for offline/air-gapped workflows. "
                "Skips all network block-height lookups."
            ),
        ),
    ] = None,
    log_level: Annotated[str, typer.Option("--log-level")] = "INFO",
) -> None:
    """
    Import a certificate signature for a fidelity bond (cold wallet support).

    This imports a certificate generated with 'prepare-certificate-message' into the
    bond registry, allowing the hot wallet to use it for making offers.

    IMPORTANT: The --cert-expiry value must match EXACTLY what was used in
    prepare-certificate-message. This is an ABSOLUTE period number, not a duration.

    If --cert-pubkey is not provided, it will be loaded from the bond registry.
    The certificate private key is loaded from the bond registry, or requested via
    an interactive hidden prompt if unavailable there.

    The signature should be the base64 output from Sparrow's message signing tool,
    using the 'Standard (Electrum)' format.
    """
    settings = setup_cli(log_level)

    from coincurve import PrivateKey
    from jmcore.paths import get_default_data_dir

    from jmwallet.wallet.bond_registry import load_registry, save_registry

    # Load registry first to get bond info
    resolved_data_dir = data_dir if data_dir else get_default_data_dir()
    registry = load_registry(resolved_data_dir)

    # Find bond by address
    bond = registry.get_bond_by_address(address)
    if not bond:
        logger.error(f"Bond not found for address: {address}")
        logger.info("Make sure you have created the bond with 'create-bond-address' first")
        raise typer.Exit(1)

    # Get cert_pubkey from argument or registry
    if not cert_pubkey:
        if bond.cert_pubkey:
            cert_pubkey = bond.cert_pubkey
            logger.info("Using certificate pubkey from bond registry")
        else:
            logger.error("--cert-pubkey is required")
            logger.info("Run 'generate-hot-keypair --bond-address <addr>' first")
            raise typer.Exit(1)

    cert_privkey = bond.cert_privkey
    if cert_privkey:
        logger.info("Using certificate privkey from bond registry")
    else:
        cert_privkey = typer.prompt("Certificate private key (hex)", hide_input=True).strip()
        if not cert_privkey:
            logger.error("Certificate private key is required")
            logger.info("Run 'generate-hot-keypair --bond-address <addr>' first")
            raise typer.Exit(1)

    if not cert_signature:
        logger.error("--cert-signature is required")
        raise typer.Exit(1)

    # Validate cert_expiry is provided
    if cert_expiry == 0:
        logger.error("--cert-expiry is required")
        logger.info("Use the same value shown by 'prepare-certificate-message'")
        raise typer.Exit(1)

    # Fetch current block height to validate cert_expiry is in the future.
    # Priority: explicit --current-block > configured node backend > --mempool-api.
    import asyncio

    current_block_height: int | None = None
    if current_block is not None:
        if current_block < 0:
            logger.error("--current-block must be >= 0")
            raise typer.Exit(1)
        current_block_height = current_block
        logger.info(f"Current block height: {current_block_height} (from --current-block)")
    else:
        backend_settings = resolve_backend_settings(
            settings,
            network=network,
            backend_type=backend_type,
            rpc_url=rpc_url,
            neutrino_url=neutrino_url,
            data_dir=data_dir,
        )

        node_available = bool(backend_settings.rpc_url or backend_settings.neutrino_url)

        if node_available:
            from jmwallet.backends.bitcoin_core import BitcoinCoreBackend
            from jmwallet.backends.neutrino import NeutrinoBackend

            try:
                if backend_settings.backend_type == "neutrino":
                    node_backend: BitcoinCoreBackend | NeutrinoBackend = NeutrinoBackend(
                        neutrino_url=backend_settings.neutrino_url,
                        network=backend_settings.network,
                        tls_cert_path=backend_settings.neutrino_tls_cert,
                        auth_token=backend_settings.neutrino_auth_token,
                    )
                else:
                    node_backend = BitcoinCoreBackend(
                        rpc_url=backend_settings.rpc_url,
                        rpc_user=backend_settings.rpc_user,
                        rpc_password=backend_settings.rpc_password,
                    )
                current_block_height = asyncio.run(node_backend.get_block_height())
                logger.debug(f"Current block height: {current_block_height} (from node)")
            except Exception as e:
                logger.warning(f"Failed to fetch block height from Bitcoin node: {e}")

        if current_block_height is None and mempool_api:
            from jmwallet.backends.mempool import MempoolBackend

            try:
                mempool_backend = MempoolBackend(base_url=mempool_api)
                current_block_height = asyncio.run(mempool_backend.get_block_height())
                logger.debug(f"Current block height: {current_block_height} (from mempool API)")
            except Exception as e:
                logger.warning(f"Failed to fetch block height from mempool API {mempool_api}: {e}")

        if current_block_height is None:
            logger.error("Cannot determine current block height for certificate expiry validation")
            logger.info(
                "Provide --current-block for offline mode, configure a Bitcoin node, "
                "or set --mempool-api <url>."
            )
            raise typer.Exit(1)

    # Validate cert_expiry is in the future
    retarget_interval = 2016
    assert current_block_height is not None
    expiry_block = cert_expiry * retarget_interval
    if current_block_height >= expiry_block:
        logger.error("Certificate has ALREADY EXPIRED!")
        logger.error(f"  Current block: {current_block_height}")
        logger.error(f"  Cert expiry:   period {cert_expiry} (block {expiry_block})")
        logger.info("Run 'prepare-certificate-message' again with current block height")
        logger.info("and re-sign the new message with your hardware wallet.")
        raise typer.Exit(1)

    blocks_remaining = expiry_block - current_block_height
    weeks_remaining = blocks_remaining // retarget_interval * 2
    logger.info(f"Certificate valid for ~{weeks_remaining} weeks ({blocks_remaining} blocks)")

    # Validate inputs
    try:
        cert_pubkey_bytes = bytes.fromhex(cert_pubkey)
        if len(cert_pubkey_bytes) != 33:
            raise ValueError("Certificate pubkey must be 33 bytes")
        if cert_pubkey_bytes[0] not in (0x02, 0x03):
            raise ValueError("Invalid compressed public key format")

        cert_privkey_bytes = bytes.fromhex(cert_privkey)
        if len(cert_privkey_bytes) != 32:
            raise ValueError("Certificate privkey must be 32 bytes")

        # Decode signature from base64 (Sparrow output)
        try:
            cert_sig_bytes = base64.b64decode(cert_signature)
        except Exception:
            # Try hex format as fallback
            try:
                cert_sig_bytes = bytes.fromhex(cert_signature)
            except Exception:
                raise ValueError("Signature must be base64 (from Sparrow) or hex encoded")

        # Verify that privkey matches pubkey
        privkey = PrivateKey(cert_privkey_bytes)
        derived_pubkey = privkey.public_key.format(compressed=True)
        if derived_pubkey != cert_pubkey_bytes:
            raise ValueError("Certificate privkey does not match cert_pubkey!")

    except ValueError as e:
        logger.error(f"Invalid input: {e}")
        raise typer.Exit(1)

    # Get the bond's utxo pubkey
    utxo_pubkey = bytes.fromhex(bond.pubkey)

    # Verify certificate signature (unless skipped)
    if not skip_verification:
        # The signature from Sparrow is a 65-byte recoverable signature:
        # 1 byte header (recovery ID + 27 for compressed) + 32 bytes R + 32 bytes S
        if len(cert_sig_bytes) == 65:
            logger.info("Detected 65-byte recoverable signature (Sparrow/Electrum format)")
            verified = _verify_recoverable_signature(
                cert_sig_bytes, cert_pubkey, cert_expiry, utxo_pubkey
            )
        else:
            # Try DER format
            logger.info(f"Signature is {len(cert_sig_bytes)} bytes, trying DER format")
            verified = _verify_der_signature(cert_sig_bytes, cert_pubkey, cert_expiry, utxo_pubkey)

        if not verified:
            logger.error("Certificate signature verification failed!")
            logger.error("The signature does not match the bond's public key.")
            logger.info("Make sure you:")
            logger.info("  1. Selected the correct signing address in Sparrow")
            logger.info("  2. Copied the message EXACTLY as shown by prepare-certificate-message")
            logger.info("  3. Used 'Standard (Electrum)' format in Sparrow")
            raise typer.Exit(1)

        logger.info("Certificate signature verified successfully")
    else:
        logger.warning("Skipping signature verification - use at your own risk!")

    # Convert recoverable signature to DER format for storage
    # The maker code expects DER signatures
    if len(cert_sig_bytes) == 65:
        der_sig = _recoverable_to_der(cert_sig_bytes)
    else:
        der_sig = cert_sig_bytes

    # Update bond with certificate
    bond.cert_pubkey = cert_pubkey
    bond.cert_privkey = cert_privkey
    bond.cert_signature = der_sig.hex()  # Store as hex DER
    bond.cert_expiry = cert_expiry

    save_registry(registry, resolved_data_dir)

    # Calculate expiry info for display
    expiry_block = cert_expiry * retarget_interval
    blocks_remaining = expiry_block - current_block_height
    weeks_remaining = blocks_remaining // retarget_interval * 2
    expiry_info = f"~{weeks_remaining} weeks remaining"

    print("\n" + "=" * 80)
    print("CERTIFICATE IMPORTED SUCCESSFULLY")
    print("=" * 80)
    print(f"\nBond Address:          {address}")
    print(f"Certificate Pubkey:    {cert_pubkey}")
    print(f"Certificate Expiry:    period {cert_expiry} (block {expiry_block}, {expiry_info})")
    print(f"\nRegistry updated: {resolved_data_dir / 'fidelity_bonds.json'}")
    print("\n" + "=" * 80)
    print("NEXT STEPS:")
    print("  The maker bot will automatically use this certificate when creating")
    print("  fidelity bond proofs. Your cold wallet private key is never needed!")
    print("=" * 80 + "\n")

prepare_certificate_message(bond_address: Annotated[str, typer.Argument(help='Bond P2WSH address')], cert_pubkey: Annotated[str | None, typer.Option('--cert-pubkey', help='Certificate public key (hex)')] = None, validity_periods: Annotated[int, typer.Option('--validity-periods', help='Certificate validity in 2016-block periods from now (1=~2wk, 52=~2yr)')] = 52, data_dir_opt: Annotated[Path | None, typer.Option('--data-dir', help='Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR)')] = None, network: Annotated[str | None, typer.Option('--network', '-n', help='Bitcoin network')] = None, backend_type: Annotated[str | None, typer.Option('--backend', '-b', help='Backend: scantxoutset | 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, mempool_api: Annotated[str, typer.Option('--mempool-api', help='Mempool API URL for fetching block height. Only used when no Bitcoin node backend is configured. Example: http://localhost:8999/api')] = '', current_block: Annotated[int | None, typer.Option('--current-block', help='Current block height override for offline/air-gapped workflows. Skips all network block-height lookups.')] = None, log_level: Annotated[str, typer.Option('--log-level')] = 'INFO') -> None

Prepare certificate message for signing with hardware wallet (cold wallet support).

This generates the message that needs to be signed by the bond UTXO's private key. The message can then be signed using a hardware wallet via tools like Sparrow Wallet.

IMPORTANT: This command does NOT require your mnemonic or private keys. It only prepares the message that you will sign with your hardware wallet.

If --cert-pubkey is not provided and the bond already has a hot keypair saved in the registry (from generate-hot-keypair --bond-address), it will be used.

The certificate message format for Sparrow is plain ASCII text: "fidelity-bond-cert||"

Where cert_expiry is the ABSOLUTE period number (current_period + validity_periods). The reference implementation validates that current_block < cert_expiry * 2016.

Source code in jmwallet/src/jmwallet/cli/cold_wallet.py
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
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
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
@app.command("prepare-certificate-message")
def prepare_certificate_message(
    bond_address: Annotated[str, typer.Argument(help="Bond P2WSH address")],
    cert_pubkey: Annotated[
        str | None,
        typer.Option("--cert-pubkey", help="Certificate public key (hex)"),
    ] = None,
    validity_periods: Annotated[
        int,
        typer.Option(
            "--validity-periods",
            help="Certificate validity in 2016-block periods from now (1=~2wk, 52=~2yr)",
        ),
    ] = 52,  # ~2 years validity
    data_dir_opt: Annotated[
        Path | None,
        typer.Option(
            "--data-dir",
            help="Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR)",
        ),
    ] = None,
    network: Annotated[str | None, typer.Option("--network", "-n", help="Bitcoin network")] = None,
    backend_type: Annotated[
        str | None,
        typer.Option(
            "--backend", "-b", help="Backend: scantxoutset | 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,
    mempool_api: Annotated[
        str,
        typer.Option(
            "--mempool-api",
            help=(
                "Mempool API URL for fetching block height. "
                "Only used when no Bitcoin node backend is configured. "
                "Example: http://localhost:8999/api"
            ),
        ),
    ] = "",
    current_block: Annotated[
        int | None,
        typer.Option(
            "--current-block",
            help=(
                "Current block height override for offline/air-gapped workflows. "
                "Skips all network block-height lookups."
            ),
        ),
    ] = None,
    log_level: Annotated[str, typer.Option("--log-level")] = "INFO",
) -> None:
    """
    Prepare certificate message for signing with hardware wallet (cold wallet support).

    This generates the message that needs to be signed by the bond UTXO's private key.
    The message can then be signed using a hardware wallet via tools like Sparrow Wallet.

    IMPORTANT: This command does NOT require your mnemonic or private keys.
    It only prepares the message that you will sign with your hardware wallet.

    If --cert-pubkey is not provided and the bond already has a hot keypair saved
    in the registry (from generate-hot-keypair --bond-address), it will be used.

    The certificate message format for Sparrow is plain ASCII text:
      "fidelity-bond-cert|<cert_pubkey_hex>|<cert_expiry>"

    Where cert_expiry is the ABSOLUTE period number (current_period + validity_periods).
    The reference implementation validates that current_block < cert_expiry * 2016.
    """
    settings = setup_cli(log_level)

    from jmcore.paths import get_default_data_dir

    from jmwallet.wallet.bond_registry import load_registry

    # Resolve data directory
    data_dir = data_dir_opt if data_dir_opt else get_default_data_dir()
    registry = load_registry(data_dir)
    bond = registry.get_bond_by_address(bond_address)

    if not bond:
        logger.error(f"Bond not found for address: {bond_address}")
        logger.info("Make sure you have created the bond with 'create-bond-address' first")
        raise typer.Exit(1)

    # Get cert_pubkey from argument or registry
    if not cert_pubkey:
        if bond.cert_pubkey:
            cert_pubkey = bond.cert_pubkey
            logger.info("Using certificate pubkey from bond registry")
        else:
            logger.error("--cert-pubkey is required")
            logger.info(
                "Run 'generate-hot-keypair --bond-address <addr>' first, or provide --cert-pubkey"
            )
            raise typer.Exit(1)

    # Validate cert_pubkey
    try:
        cert_pubkey_bytes = bytes.fromhex(cert_pubkey)
        if len(cert_pubkey_bytes) != 33:
            raise ValueError("Certificate pubkey must be 33 bytes (compressed)")
        if cert_pubkey_bytes[0] not in (0x02, 0x03):
            raise ValueError("Invalid compressed public key format")
    except ValueError as e:
        logger.error(f"Invalid certificate pubkey: {e}")
        raise typer.Exit(1)

    # Fetch current block height.
    # Priority: explicit --current-block > configured node backend > --mempool-api.
    import asyncio

    current_block_height: int
    if current_block is not None:
        if current_block < 0:
            logger.error("--current-block must be >= 0")
            raise typer.Exit(1)
        current_block_height = current_block
        logger.info(f"Current block height: {current_block_height} (from --current-block)")
    else:
        backend_settings = resolve_backend_settings(
            settings,
            network=network,
            backend_type=backend_type,
            rpc_url=rpc_url,
            neutrino_url=neutrino_url,
            data_dir=data_dir_opt,
        )

        node_available = bool(backend_settings.rpc_url or backend_settings.neutrino_url)

        if node_available:
            from jmwallet.backends.bitcoin_core import BitcoinCoreBackend
            from jmwallet.backends.neutrino import NeutrinoBackend

            try:
                if backend_settings.backend_type == "neutrino":
                    node_backend: BitcoinCoreBackend | NeutrinoBackend = NeutrinoBackend(
                        neutrino_url=backend_settings.neutrino_url,
                        network=backend_settings.network,
                        tls_cert_path=backend_settings.neutrino_tls_cert,
                        auth_token=backend_settings.neutrino_auth_token,
                    )
                else:
                    node_backend = BitcoinCoreBackend(
                        rpc_url=backend_settings.rpc_url,
                        rpc_user=backend_settings.rpc_user,
                        rpc_password=backend_settings.rpc_password,
                    )
                current_block_height = asyncio.run(node_backend.get_block_height())
                logger.info(f"Current block height: {current_block_height} (from node)")
            except Exception as e:
                logger.error(f"Failed to fetch block height from Bitcoin node: {e}")
                raise typer.Exit(1)
        elif mempool_api:
            from jmwallet.backends.mempool import MempoolBackend

            try:
                mempool_backend = MempoolBackend(base_url=mempool_api)
                current_block_height = asyncio.run(mempool_backend.get_block_height())
                logger.info(f"Current block height: {current_block_height} (from mempool API)")
            except Exception as e:
                logger.error(f"Failed to fetch block height from mempool API {mempool_api}: {e}")
                raise typer.Exit(1)
        else:
            logger.error("No block height source available.")
            logger.info(
                "Provide --current-block for offline mode, configure a Bitcoin node, "
                "or supply --mempool-api <url> as a fallback."
            )
            raise typer.Exit(1)

    # Calculate cert_expiry as ABSOLUTE period number
    # Reference: yieldgenerator.py line 139
    # cert_expiry = ((blocks + BLOCK_COUNT_SAFETY) // RETARGET_INTERVAL) + CERT_MAX_VALIDITY_TIME
    retarget_interval = 2016
    block_count_safety = 2
    current_period = (current_block_height + block_count_safety) // retarget_interval
    cert_expiry = current_period + validity_periods

    # Validate cert_expiry fits in 2 bytes (uint16)
    if cert_expiry > 65535:
        logger.error(f"cert_expiry {cert_expiry} exceeds maximum 65535")
        raise typer.Exit(1)

    # Calculate expiry details for display
    expiry_block = cert_expiry * retarget_interval
    blocks_until_expiry = expiry_block - current_block_height
    weeks_until_expiry = blocks_until_expiry // 2016 * 2

    # Create ASCII certificate message (hex pubkey - compatible with Sparrow text input)
    # This format allows users to paste directly into Sparrow's message field
    cert_msg_ascii = f"fidelity-bond-cert|{cert_pubkey}|{cert_expiry}"

    # Save message to file for easier signing workflows
    data_dir.mkdir(parents=True, exist_ok=True)
    message_file = data_dir / "certificate_message.txt"
    message_file.write_text(cert_msg_ascii)

    # Get the signing address (P2WPKH address for the bond's pubkey)
    from jmwallet.wallet.address import pubkey_to_p2wpkh_address

    bond_pubkey = bytes.fromhex(bond.pubkey)
    # Determine network from bond
    signing_address = pubkey_to_p2wpkh_address(bond_pubkey, bond.network)

    print("\n" + "=" * 80)
    print("FIDELITY BOND CERTIFICATE MESSAGE")
    print("=" * 80)
    print(f"\nBond Address (P2WSH):  {bond_address}")
    print(f"Signing Address:       {signing_address}")
    print("  (Select this address in Sparrow to sign)")
    print(f"Certificate Pubkey:    {cert_pubkey}")
    print(f"\nCurrent Block:         {current_block_height} (period {current_period})")
    print(f"Cert Expiry:           period {cert_expiry} (block {expiry_block})")
    print(f"Validity:              ~{weeks_until_expiry} weeks ({blocks_until_expiry} blocks)")
    print("\n" + "-" * 80)
    print("MESSAGE TO SIGN (copy this EXACTLY into Sparrow):")
    print("-" * 80)
    print(cert_msg_ascii)
    print("-" * 80)
    print(f"\nMessage saved to: {message_file}")
    print("\n" + "=" * 80)
    print("HOW TO SIGN THIS MESSAGE:")
    print("=" * 80)
    print()
    print("Sparrow Wallet with Hardware Wallet:")
    print("  1. Open Sparrow Wallet and connect your hardware wallet")
    print("  2. Go to Tools -> Sign/Verify Message")
    print(f"  3. Select the Signing Address shown above: {signing_address}")
    print("  4. Copy the entire message above (fidelity-bond-cert|...) and")
    print("     paste it into the 'Message' field in Sparrow")
    print("  5. Select 'Standard (Electrum)' format (NOT BIP322)")
    print("  6. Click 'Sign Message' - hardware wallet will prompt for confirmation")
    print("  7. Copy the resulting base64 signature")
    print()
    print("After signing, import with:")
    print("  jm-wallet import-certificate <bond_address> \\")
    print("    --cert-signature '<base64_signature>' \\")
    print(f"    --cert-expiry {cert_expiry}")
    print("=" * 80 + "\n")

spend_bond(bond_address: Annotated[str, typer.Argument(help='Bond P2WSH address to spend')], destination: Annotated[str, typer.Argument(help='Destination address for the funds')], fee_rate: Annotated[float, typer.Option('--fee-rate', '-f', help='Fee rate in sat/vB')] = 1.0, master_fingerprint: Annotated[str | None, typer.Option('--master-fingerprint', '-m', help="Master key fingerprint (4 bytes hex, e.g. 'aabbccdd'). Found in Sparrow: Settings -> Keystore -> Master fingerprint. Enables Sparrow and HWI to identify the signing key.")] = None, derivation_path: Annotated[str | None, typer.Option('--derivation-path', '-p', help='BIP32 derivation path of the key used for the bond (e.g. "m/84\'/0\'/0\'/0/0"). This is the path of the address whose pubkey was used in \'create-bond-address\'. Check Sparrow: Addresses tab -> right-click the address -> Copy -> Derivation Path.')] = None, output_file: Annotated[Path | None, typer.Option('--output', '-o', help='Save PSBT to file (default: stdout only)')] = None, test_unfunded: Annotated[bool, typer.Option('--test-unfunded', help='Allow generating a test PSBT even when the bond is unfunded, using a synthetic UTXO for signer compatibility testing.')] = False, test_utxo_value: Annotated[int, typer.Option('--test-utxo-value', help='Synthetic UTXO value in sats when using --test-unfunded (default: 100000).')] = 100000, data_dir: Annotated[Path | None, typer.Option('--data-dir', help='Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR)')] = None, log_level: Annotated[str, typer.Option('--log-level', '-l')] = 'INFO') -> None

Generate a PSBT to spend a cold storage fidelity bond after locktime expires.

This creates a Partially Signed Bitcoin Transaction (PSBT) that can be signed using HWI (hardware wallet) or the mnemonic signing script (software wallet).

The PSBT includes the witness script (CLTV timelock) needed to spend the bond.

REQUIREMENTS: - The bond must exist in the registry (created with 'create-bond-address') - The bond must be funded (use 'registry-sync' to update UTXO info), unless using --test-unfunded for a dry-run signer test - The locktime must have expired (or be close enough for your use case)

SIGNING:

Most hardware wallets (Trezor, Coldcard, BitBox02, KeepKey) CANNOT sign CLTV timelock P2WSH scripts -- their firmware rejects custom witness scripts. Ledger and Blockstream Jade DO support arbitrary witness scripts and may work via HWI (scripts/sign_bond_psbt.py).

Option A - Mnemonic signing (works with any device): 1. Run: python scripts/sign_bond_mnemonic.py 2. Enter your BIP39 mnemonic when prompted (hidden input) 3. Broadcast: bitcoin-cli sendrawtransaction

Option B - HWI signing (Ledger and Jade only): 1. Install HWI: pip install -U hwi 2. Connect and unlock your hardware wallet 3. Run: python scripts/sign_bond_psbt.py

See docs/technical/privacy.md for strategies to reduce mnemonic exposure (dedicated BIP39 passphrase, BIP-85 derived keys, air-gapped signing).

NOTE: Sparrow Wallet also cannot sign CLTV timelock scripts.

Source code in jmwallet/src/jmwallet/cli/cold_wallet.py
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
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
1110
1111
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
1221
1222
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
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
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
1410
1411
1412
1413
1414
1415
1416
1417
1418
@app.command("spend-bond")
def spend_bond(
    bond_address: Annotated[str, typer.Argument(help="Bond P2WSH address to spend")],
    destination: Annotated[str, typer.Argument(help="Destination address for the funds")],
    fee_rate: Annotated[
        float,
        typer.Option("--fee-rate", "-f", help="Fee rate in sat/vB"),
    ] = 1.0,
    master_fingerprint: Annotated[
        str | None,
        typer.Option(
            "--master-fingerprint",
            "-m",
            help=(
                "Master key fingerprint (4 bytes hex, e.g. 'aabbccdd'). "
                "Found in Sparrow: Settings -> Keystore -> Master fingerprint. "
                "Enables Sparrow and HWI to identify the signing key."
            ),
        ),
    ] = None,
    derivation_path: Annotated[
        str | None,
        typer.Option(
            "--derivation-path",
            "-p",
            help=(
                "BIP32 derivation path of the key used for the bond "
                "(e.g. \"m/84'/0'/0'/0/0\"). "
                "This is the path of the address whose pubkey was used in "
                "'create-bond-address'. Check Sparrow: Addresses tab -> "
                "right-click the address -> Copy -> Derivation Path."
            ),
        ),
    ] = None,
    output_file: Annotated[
        Path | None,
        typer.Option("--output", "-o", help="Save PSBT to file (default: stdout only)"),
    ] = None,
    test_unfunded: Annotated[
        bool,
        typer.Option(
            "--test-unfunded",
            help=(
                "Allow generating a test PSBT even when the bond is unfunded, "
                "using a synthetic UTXO for signer compatibility testing."
            ),
        ),
    ] = False,
    test_utxo_value: Annotated[
        int,
        typer.Option(
            "--test-utxo-value",
            help=("Synthetic UTXO value in sats when using --test-unfunded (default: 100000)."),
        ),
    ] = 100_000,
    data_dir: Annotated[
        Path | None,
        typer.Option(
            "--data-dir",
            help="Data directory (default: ~/.joinmarket-ng or $JOINMARKET_DATA_DIR)",
        ),
    ] = None,
    log_level: Annotated[str, typer.Option("--log-level", "-l")] = "INFO",
) -> None:
    """
    Generate a PSBT to spend a cold storage fidelity bond after locktime expires.

    This creates a Partially Signed Bitcoin Transaction (PSBT) that can be signed
    using HWI (hardware wallet) or the mnemonic signing script (software wallet).

    The PSBT includes the witness script (CLTV timelock) needed to spend the bond.

    REQUIREMENTS:
    - The bond must exist in the registry (created with 'create-bond-address')
    - The bond must be funded (use 'registry-sync' to update UTXO info),
      unless using --test-unfunded for a dry-run signer test
    - The locktime must have expired (or be close enough for your use case)

    SIGNING:

    Most hardware wallets (Trezor, Coldcard, BitBox02, KeepKey) CANNOT sign
    CLTV timelock P2WSH scripts -- their firmware rejects custom witness
    scripts. Ledger and Blockstream Jade DO support arbitrary witness scripts
    and may work via HWI (scripts/sign_bond_psbt.py).

    Option A - Mnemonic signing (works with any device):
    1. Run: python scripts/sign_bond_mnemonic.py <psbt_base64>
    2. Enter your BIP39 mnemonic when prompted (hidden input)
    3. Broadcast: bitcoin-cli sendrawtransaction <signed_hex>

    Option B - HWI signing (Ledger and Jade only):
    1. Install HWI: pip install -U hwi
    2. Connect and unlock your hardware wallet
    3. Run: python scripts/sign_bond_psbt.py <psbt_base64>

    See docs/technical/privacy.md for strategies to reduce mnemonic exposure
    (dedicated BIP39 passphrase, BIP-85 derived keys, air-gapped signing).

    NOTE: Sparrow Wallet also cannot sign CLTV timelock scripts.
    """
    setup_logging(log_level)

    from jmcore.bitcoin import (
        BIP32Derivation,
        PSBTInput,
        TxInput,
        TxOutput,
        address_to_scriptpubkey,
        create_psbt,
        estimate_vsize,
        format_amount,
        get_address_type,
        parse_derivation_path,
        psbt_to_base64,
        script_to_p2wsh_scriptpubkey,
    )
    from jmcore.paths import get_default_data_dir

    from jmwallet.wallet.bond_registry import load_registry

    # Resolve data directory
    resolved_data_dir = data_dir if data_dir else get_default_data_dir()
    registry = load_registry(resolved_data_dir)

    # Find bond in registry
    bond = registry.get_bond_by_address(bond_address)
    if not bond:
        logger.error(f"Bond not found for address: {bond_address}")
        logger.info("Make sure you have created the bond with 'create-bond-address' first")
        logger.info("Use 'jm-wallet registry-list' to see all bonds")
        raise typer.Exit(1)

    # Resolve bond UTXO source (real UTXO or synthetic dry-run UTXO)
    is_test_unfunded_mode = False
    if bond.is_funded:
        assert bond.txid is not None
        assert bond.vout is not None
        assert bond.value is not None
        txid = bond.txid
        vout = bond.vout
        input_value = bond.value
    else:
        if not test_unfunded:
            logger.error("Bond is not funded (no UTXO info)")
            logger.info(
                "Use 'jm-wallet registry-sync' to update UTXO info from the blockchain, "
                "or manually fund the bond address first"
            )
            logger.info(
                "If you want to test signer compatibility before funding, rerun with "
                "--test-unfunded"
            )
            raise typer.Exit(1)

        if test_utxo_value <= 0:
            logger.error("--test-utxo-value must be positive")
            raise typer.Exit(1)

        # Deterministic synthetic outpoint for dry-run signer testing.
        txid = "11" * 32
        vout = 0
        input_value = test_utxo_value
        is_test_unfunded_mode = True
        logger.warning(
            "Generating TEST PSBT for unfunded bond using synthetic UTXO metadata. "
            "This PSBT is for signing-tool validation only and CANNOT be broadcast."
        )

    # Warn if locktime hasn't expired yet
    import time

    current_time = int(time.time())
    if bond.locktime > current_time:
        remaining_days = (bond.locktime - current_time) / 86400
        logger.warning(
            f"Bond locktime has NOT expired yet! "
            f"Expires in {remaining_days:.1f} days "
            f"({datetime.fromtimestamp(bond.locktime).strftime('%Y-%m-%d')})"
        )
        logger.warning(
            "The PSBT will be created anyway, but the transaction CANNOT be "
            "broadcast until the locktime has passed."
        )

    # Validate fee rate
    if fee_rate <= 0:
        logger.error("Fee rate must be positive")
        raise typer.Exit(1)

    # Validate destination address
    try:
        dest_scriptpubkey = address_to_scriptpubkey(destination)
    except ValueError as e:
        logger.error(f"Invalid destination address: {e}")
        raise typer.Exit(1)

    # Estimate transaction size for fee calculation
    # P2WSH input -> single output (no change since we sweep the whole bond)
    try:
        dest_type = get_address_type(destination)
    except ValueError:
        logger.warning(f"Could not determine address type for {destination}, assuming p2wpkh")
        dest_type = "p2wpkh"

    estimated_vsize = estimate_vsize(["p2wsh"], [dest_type])
    estimated_fee = math.ceil(estimated_vsize * fee_rate)

    send_amount = input_value - estimated_fee
    if send_amount <= 0:
        logger.error(
            f"Bond value ({format_amount(input_value)}) is too small to cover "
            f"the fee ({format_amount(estimated_fee)} at {fee_rate:.1f} sat/vB)"
        )
        raise typer.Exit(1)

    if send_amount < 546:
        logger.error(
            f"Output amount ({format_amount(send_amount)}) is below dust threshold (546 sats)"
        )
        raise typer.Exit(1)

    # Reconstruct the witness script and P2WSH scriptPubKey
    witness_script = bytes.fromhex(bond.witness_script_hex)
    p2wsh_scriptpubkey = script_to_p2wsh_scriptpubkey(witness_script)

    # Build BIP32 derivation info if provided (helps signers identify the key)
    bip32_derivations: list[BIP32Derivation] | None = None
    if master_fingerprint or derivation_path:
        if not master_fingerprint or not derivation_path:
            logger.error(
                "--master-fingerprint and --derivation-path must both be provided. "
                "In Sparrow: Settings -> Keystore for the fingerprint, "
                "Addresses tab -> right-click -> Copy -> Derivation Path for the path."
            )
            raise typer.Exit(1)

        # Validate and parse master fingerprint (4 bytes hex)
        fingerprint_clean = master_fingerprint.strip().lower()
        try:
            fp_bytes = bytes.fromhex(fingerprint_clean)
        except ValueError:
            logger.error(f"Invalid master fingerprint hex: {master_fingerprint!r}")
            raise typer.Exit(1)
        if len(fp_bytes) != 4:
            logger.error(
                f"Master fingerprint must be exactly 4 bytes (8 hex chars), "
                f"got {len(fp_bytes)} bytes"
            )
            raise typer.Exit(1)

        # Parse derivation path
        try:
            path_indices = parse_derivation_path(derivation_path)
        except ValueError as e:
            logger.error(f"Invalid derivation path: {e}")
            raise typer.Exit(1)

        pubkey_bytes = bytes.fromhex(bond.pubkey)
        bip32_derivations = [
            BIP32Derivation(
                pubkey=pubkey_bytes,
                fingerprint=fp_bytes,
                path=path_indices,
            )
        ]
        logger.info(
            f"BIP32 derivation included: fingerprint={fingerprint_clean}, path={derivation_path}"
        )

    # Build the unsigned transaction components
    tx_input = TxInput.from_hex(
        txid=txid,
        vout=vout,
        # Sequence must be < 0xFFFFFFFF to enable nLockTime checking
        sequence=0xFFFFFFFE,
        value=input_value,
        scriptpubkey=p2wsh_scriptpubkey.hex(),
    )
    tx_output = TxOutput(value=send_amount, script=dest_scriptpubkey)

    # Create PSBT input metadata
    psbt_input = PSBTInput(
        witness_utxo_value=input_value,
        witness_utxo_script=p2wsh_scriptpubkey,
        witness_script=witness_script,
        sighash_type=1,  # SIGHASH_ALL
        bip32_derivations=bip32_derivations,
    )

    # Create the PSBT
    psbt_bytes = create_psbt(
        version=2,
        inputs=[tx_input],
        outputs=[tx_output],
        locktime=bond.locktime,
        psbt_inputs=[psbt_input],
    )

    psbt_base64 = psbt_to_base64(psbt_bytes)

    # Save to file if requested
    if output_file:
        output_file.parent.mkdir(parents=True, exist_ok=True)
        output_file.write_text(psbt_base64)
        logger.info(f"PSBT saved to: {output_file}")

    # Display results
    locktime_dt = datetime.fromtimestamp(bond.locktime)

    print("\n" + "=" * 80)
    print("SPEND BOND PSBT")
    print("=" * 80)
    if is_test_unfunded_mode:
        print("\nMODE:             TEST-UNFUNDED (synthetic UTXO, not broadcastable)")
    print(f"\nBond Address:     {bond_address}")
    print(f"Bond UTXO:        {txid}:{vout}")
    print(f"Bond Value:       {format_amount(input_value)}")
    print(f"Locktime:         {bond.locktime} ({locktime_dt.strftime('%Y-%m-%d')})")
    print(f"\nDestination:      {destination}")
    print(f"Send Amount:      {format_amount(send_amount)}")
    print(f"Fee:              {format_amount(estimated_fee)} ({fee_rate:.1f} sat/vB)")
    print(f"Estimated vsize:  {estimated_vsize} vB")
    print("\n" + "-" * 80)
    print("PSBT (base64):")
    print("-" * 80)
    print(psbt_base64)
    print("-" * 80)
    if output_file:
        print(f"\nSaved to: {output_file}")

    print("\n" + "=" * 80)
    print("HOW TO SIGN AND BROADCAST:")
    print("=" * 80)
    print()
    if is_test_unfunded_mode:
        print("WARNING: TEST-UNFUNDED mode uses a synthetic input and cannot be broadcast.")
        print("  Use this only to validate your signing workflow before funding the bond.")
        print()
    print("NOTE: Most hardware wallets (Trezor, Coldcard, BitBox02, KeepKey)")
    print("  CANNOT sign CLTV timelock P2WSH scripts. Ledger and Blockstream")
    print("  Jade support arbitrary witness scripts and may work via HWI.")
    print()
    print("Option A - Mnemonic signing (works with any device):")
    if bip32_derivations:
        print("  1. Run: python scripts/sign_bond_mnemonic.py <psbt_base64>")
    else:
        print("  1. Run: python scripts/sign_bond_mnemonic.py <psbt_base64> \\")
        print("       --derivation-path \"m/84'/0'/0'/0/0\"")
    print("  2. Enter your BIP39 mnemonic when prompted (hidden input)")
    print("  3. Broadcast: bitcoin-cli sendrawtransaction <signed_hex>")
    print()
    if bip32_derivations:
        print("Option B - HWI signing (Ledger and Jade only):")
        print("  1. Install HWI: pip install -U hwi")
        print("  2. Connect and unlock your hardware wallet")
        print("  3. Run: python scripts/sign_bond_psbt.py <psbt_base64>")
        print()
    if not bip32_derivations:
        print("TIP: Re-run with --master-fingerprint and --derivation-path to")
        print("  embed BIP32 key origin info (needed for HWI, optional for mnemonic).")
        print()
        print("  Example:")
        print("    jm-wallet spend-bond <bond_addr> <dest_addr> \\")
        print("      --master-fingerprint aabbccdd \\")
        print("      --derivation-path \"m/84'/0'/0'/0/0\"")
        print()
    print("See docs/technical/privacy.md for strategies to reduce mnemonic")
    print("  exposure (dedicated BIP39 passphrase, BIP-85, air-gapped signing).")
    print()
    print("NOTE: Sparrow Wallet also cannot sign CLTV timelock scripts.")
    print()
    if bond.locktime > current_time:
        print("WARNING: The locktime has NOT expired yet!")
        print("  You can sign the PSBT now, but broadcasting will fail until")
        print(f"  {locktime_dt.strftime('%Y-%m-%d %H:%M:%S')} UTC")
        print()
    print("=" * 80 + "\n")