From 1dd7d207e56ad487524f358c75c3d0aa43dcbadd Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Sun, 5 Apr 2026 18:12:12 +0300 Subject: [PATCH 01/10] Add expanded CLI with wallet store, subscriptions, and docs - Rewrites bitcash/cli.py: new, gen, balance, transactions, unspents, send, subscribe commands plus wallet subgroup (new, list, balance, send, export, delete, subscribe) - Wallet store backed by TinyDB with privy-encrypted WIFs; passwords accept --password flag, BITCASH_WALLET_PASSWORD env var, or prompt - WalletRecord dataclass replaces freeform dict; Network enum used throughout instead of raw strings - Adds docs/guide/cli.rst and registers it in docs/index.rst toctree - Adds tests/test_cli.py with 35 tests (CliRunner + isolated in-memory DB) Co-Authored-By: Claude Sonnet 4.6 --- bitcash/cli.py | 451 +++++++++++++++++++++++++++++++++++++- docs/guide/cli.rst | 296 +++++++++++++++++++++++++ docs/index.rst | 1 + tests/test_cli.py | 524 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1269 insertions(+), 3 deletions(-) create mode 100644 docs/guide/cli.rst create mode 100644 tests/test_cli.py diff --git a/bitcash/cli.py b/bitcash/cli.py index 3f9c66b..aa4c234 100644 --- a/bitcash/cli.py +++ b/bitcash/cli.py @@ -1,18 +1,463 @@ +from __future__ import annotations + +import datetime +import os +import threading +from dataclasses import dataclass +from typing import Any, cast + try: import click # pyright: ignore except ImportError: raise ImportError("Please install the 'click' package to use this CLI tool.") +try: + import appdirs # pyright: ignore +except ImportError: + appdirs = None # type: ignore[assignment] + +try: + import privy # pyright: ignore +except ImportError: + privy = None # type: ignore[assignment] + +try: + from tinydb import TinyDB, Query # pyright: ignore +except ImportError: + TinyDB = None # type: ignore[assignment,misc] + Query = None # type: ignore[assignment] + from bitcash.keygen import generate_matching_address +from bitcash.wallet import PrivateKey, wif_to_key +from bitcash.network import NetworkAPI, satoshi_to_currency_cached +from bitcash.types import Network, UserOutput + + +# --------------------------------------------------------------------------- +# Shared option — callback converts the CLI string to a Network enum member +# --------------------------------------------------------------------------- + +def _parse_network( + ctx: click.Context, param: click.Parameter, value: str +) -> Network: + return Network[value] + + +PASSWORD_OPTION = click.option( + "--password", + "-p", + envvar="BITCASH_WALLET_PASSWORD", + default=None, + help="Wallet password (or set BITCASH_WALLET_PASSWORD env var; prompts if omitted)", +) + +NETWORK_OPTION = click.option( + "--network", + "-n", + type=click.Choice([n.name for n in Network]), + default=Network.main.name, + show_default=True, + callback=_parse_network, +) + + +# --------------------------------------------------------------------------- +# Wallet record +# --------------------------------------------------------------------------- + + +@dataclass +class WalletRecord: + name: str + network: Network + address: str + encrypted_wif: str + + def to_dict(self) -> dict[str, str]: + return { + "name": self.name, + "network": self.network.name, + "address": self.address, + "encrypted_wif": self.encrypted_wif, + } + + @classmethod + def from_dict(cls, data: dict[str, str]) -> WalletRecord: + return cls( + name=data["name"], + network=Network[data["network"]], + address=data["address"], + encrypted_wif=data["encrypted_wif"], + ) + + +# --------------------------------------------------------------------------- +# DB helpers +# --------------------------------------------------------------------------- + + +def _get_db() -> Any: # returns TinyDB when available + if TinyDB is None or appdirs is None: + raise click.ClickException( + "Wallet commands require optional dependencies. " + "Install them with: pip install 'bitcash[cli]'" + ) + data_dir: str = appdirs.user_data_dir("bitcash") + os.makedirs(data_dir, exist_ok=True) + return TinyDB(os.path.join(data_dir, "wallets.json")) + + +def _load_key_from_db(name: str) -> WalletRecord: + db = _get_db() + assert Query is not None + results: list[dict[str, str]] = db.search(Query().name == name) + if not results: + raise click.ClickException(f"Wallet '{name}' not found.") + return WalletRecord.from_dict(results[0]) + + +def _prompt_password(password: str | None, *, confirm: bool = False) -> str: + if password is not None: + return password + return click.prompt("Password", hide_input=True, confirmation_prompt=confirm) + + +def _decrypt_wif(record: WalletRecord, password: str) -> str: + if privy is None: + raise click.ClickException( + "Wallet commands require optional dependencies. " + "Install them with: pip install 'bitcash[cli]'" + ) + try: + return privy.peek(record.encrypted_wif, password).decode() + except Exception: + raise click.ClickException("Incorrect password or corrupted wallet data.") + + +# --------------------------------------------------------------------------- +# Root group +# --------------------------------------------------------------------------- @click.group(invoke_without_command=True) -def bitcash(): - pass +@click.pass_context +def bitcash(ctx: click.Context) -> None: + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +# --------------------------------------------------------------------------- +# gen +# --------------------------------------------------------------------------- @bitcash.command() # pyright: ignore @click.argument("prefix") @click.option("--cores", "-c", default="all") -def gen(prefix, cores): +def gen(prefix: str, cores: str) -> None: + """Generate a vanity address matching PREFIX.""" click.echo(generate_matching_address(prefix, cores)) + + +# --------------------------------------------------------------------------- +# new +# --------------------------------------------------------------------------- + + +@bitcash.command(name="new") +@NETWORK_OPTION +def new_cmd(network: Network) -> None: + """Generate a new private key and print WIF + address.""" + key = PrivateKey(network=network.name) + click.echo(f"WIF: {key.to_wif()}") + click.echo(f"Address: {key.address}") + + +# --------------------------------------------------------------------------- +# balance +# --------------------------------------------------------------------------- + + +@bitcash.command() +@click.argument("address") +@click.option("--currency", default="satoshi", show_default=True) +@NETWORK_OPTION +def balance(address: str, currency: str, network: Network) -> None: + """Show the balance of ADDRESS.""" + raw: int = NetworkAPI.get_balance(address, network=network.value) + if currency == "satoshi": + click.echo(f"{raw} satoshi") + else: + click.echo(satoshi_to_currency_cached(raw, currency)) + + +# --------------------------------------------------------------------------- +# transactions +# --------------------------------------------------------------------------- + + +@bitcash.command() +@click.argument("address") +@NETWORK_OPTION +def transactions(address: str, network: Network) -> None: + """List transactions for ADDRESS.""" + txs: list[str] = NetworkAPI.get_transactions(address, network=network.value) + if not txs: + click.echo("No transactions found.") + else: + for txid in txs: + click.echo(txid) + + +# --------------------------------------------------------------------------- +# unspents +# --------------------------------------------------------------------------- + + +@bitcash.command() +@click.argument("address") +@NETWORK_OPTION +def unspents(address: str, network: Network) -> None: + """List unspent outputs for ADDRESS.""" + utxos = NetworkAPI.get_unspent(address, network=network.value) + if not utxos: + click.echo("No unspents found.") + else: + for u in utxos: + click.echo(u) + + +# --------------------------------------------------------------------------- +# subscribe (stateless) +# --------------------------------------------------------------------------- + + +@bitcash.command(name="subscribe") +@click.argument("address") +@click.option("--show-balance", is_flag=True, help="Fetch and print balance on each update") +@NETWORK_OPTION +def subscribe_cmd(address: str, show_balance: bool, network: Network) -> None: + """Watch ADDRESS for real-time transaction activity.""" + stop_event = threading.Event() + + def on_update(addr: str, status_hash: str | None) -> None: + ts = datetime.datetime.now().strftime("%H:%M:%S") + if status_hash is None: + click.echo(f"[{ts}] {addr} (no history)") + elif status_hash.startswith("error:"): + click.echo(f"[{ts}] Error: {status_hash}", err=True) + elif status_hash == "unsubscribed": + click.echo(f"[{ts}] Unsubscribed.") + stop_event.set() + else: + if show_balance: + bal: int = NetworkAPI.get_balance(addr, network=network.value) + click.echo(f"[{ts}] {addr} status={status_hash[:12]}… balance={bal} sat") + else: + click.echo(f"[{ts}] {addr} status={status_hash[:12]}…") + + click.echo(f"Subscribing to {address} on {network.name}. Press Ctrl+C to stop.") + handle = NetworkAPI.subscribe_address(address, on_update, network=network.value) + + try: + stop_event.wait() + except KeyboardInterrupt: + handle.unsubscribe() + click.echo("\nUnsubscribed.") + + +# --------------------------------------------------------------------------- +# send (stateless) +# --------------------------------------------------------------------------- + + +@bitcash.command(name="send") +@click.option("--wif", required=True, help="Sender WIF private key") +@click.argument("to") +@click.argument("amount") +@click.argument("currency") +@click.option("--fee", default=None, type=int, help="Fee in satoshi/byte") +@click.option("--message", default=None, help="OP_RETURN message") +@NETWORK_OPTION +def send_cmd( + wif: str, + to: str, + amount: str, + currency: str, + fee: int | None, + message: str | None, + network: Network, +) -> None: + """Send BCH using a raw WIF key.""" + key = wif_to_key(wif, regtest=(network == Network.regtest)) + # UserOutput types amount as int but the implementation accepts any + # Decimal-compatible value; cast to satisfy the type checker. + txid: str = key.send( + cast(list[UserOutput], [(to, amount, currency)]), + fee=fee, + message=message, + ) + click.echo(f"Transaction ID: {txid}") + + +# --------------------------------------------------------------------------- +# wallet subgroup +# --------------------------------------------------------------------------- + + +@bitcash.group() +def wallet() -> None: + """Manage named, password-protected wallets.""" + + +@wallet.command(name="new") +@click.argument("name") +@click.option("--wif", default=None, help="Import existing WIF (optional)") +@NETWORK_OPTION +@PASSWORD_OPTION +def wallet_new(name: str, wif: str | None, network: Network, password: str | None) -> None: + """Create or import a named wallet.""" + if privy is None: + raise click.ClickException( + "Wallet commands require optional dependencies. " + "Install them with: pip install 'bitcash[cli]'" + ) + + db = _get_db() + assert Query is not None + if db.search(Query().name == name): + raise click.ClickException(f"Wallet '{name}' already exists.") + + if wif: + key = wif_to_key(wif, regtest=(network == Network.regtest)) + if key._network != network: + raise click.ClickException( + f"WIF network '{key._network.name}' does not match --network '{network.name}'." + ) + else: + key = PrivateKey(network=network.name) + + pw: str = _prompt_password(password, confirm=True) + encrypted_wif: str = privy.hide(key.to_wif().encode(), pw) + + record = WalletRecord( + name=name, + network=network, + address=key.address, + encrypted_wif=encrypted_wif, + ) + db.insert(record.to_dict()) + click.echo(f"Wallet '{name}' created.") + click.echo(f"Address: {key.address}") + + +@wallet.command(name="list") +def wallet_list() -> None: + """List all stored wallets.""" + db = _get_db() + records = [WalletRecord.from_dict(r) for r in db.all()] + if not records: + click.echo("No wallets found.") + else: + for r in records: + click.echo(f"{r.name:20s} {r.network.name:8s} {r.address}") + + +@wallet.command(name="subscribe") +@click.argument("name") +@click.option("--show-balance", is_flag=True, help="Fetch and print balance on each update") +def wallet_subscribe(name: str, show_balance: bool) -> None: + """Watch wallet NAME for real-time transaction activity.""" + record = _load_key_from_db(name) + address: str = record.address + stop_event = threading.Event() + + def on_update(addr: str, status_hash: str | None) -> None: + ts = datetime.datetime.now().strftime("%H:%M:%S") + if status_hash is None: + click.echo(f"[{ts}] {addr} (no history)") + elif status_hash.startswith("error:"): + click.echo(f"[{ts}] Error: {status_hash}", err=True) + elif status_hash == "unsubscribed": + click.echo(f"[{ts}] Unsubscribed.") + stop_event.set() + else: + if show_balance: + bal: int = NetworkAPI.get_balance(addr, network=record.network.value) + click.echo(f"[{ts}] {addr} status={status_hash[:12]}… balance={bal} sat") + else: + click.echo(f"[{ts}] {addr} status={status_hash[:12]}…") + + click.echo(f"Subscribing to {name} ({address}) on {record.network.name}. Press Ctrl+C to stop.") + handle = NetworkAPI.subscribe_address(address, on_update, network=record.network.value) + + try: + stop_event.wait() + except KeyboardInterrupt: + handle.unsubscribe() + click.echo("\nUnsubscribed.") + + +@wallet.command(name="balance") +@click.argument("name") +@click.option("--currency", default="satoshi", show_default=True) +def wallet_balance(name: str, currency: str) -> None: + """Show balance for wallet NAME (no password required).""" + record = _load_key_from_db(name) + raw: int = NetworkAPI.get_balance(record.address, network=record.network.value) + if currency == "satoshi": + click.echo(f"{raw} satoshi") + else: + click.echo(satoshi_to_currency_cached(raw, currency)) + + +@wallet.command(name="send") +@click.argument("name") +@click.argument("to") +@click.argument("amount") +@click.argument("currency") +@click.option("--fee", default=None, type=int, help="Fee in satoshi/byte") +@click.option("--message", default=None, help="OP_RETURN message") +@PASSWORD_OPTION +def wallet_send( + name: str, + to: str, + amount: str, + currency: str, + fee: int | None, + message: str | None, + password: str | None, +) -> None: + """Send BCH from wallet NAME.""" + record = _load_key_from_db(name) + wif: str = _decrypt_wif(record, _prompt_password(password)) + key = wif_to_key(wif, regtest=(record.network == Network.regtest)) + txid: str = key.send( + cast(list[UserOutput], [(to, amount, currency)]), + fee=fee, + message=message, + ) + click.echo(f"Transaction ID: {txid}") + + +@wallet.command(name="export") +@click.argument("name") +@PASSWORD_OPTION +def wallet_export(name: str, password: str | None) -> None: + """Decrypt and print the WIF for wallet NAME.""" + record = _load_key_from_db(name) + wif: str = _decrypt_wif(record, _prompt_password(password)) + click.echo(f"WIF: {wif}") + + +@wallet.command(name="delete") +@click.argument("name") +@click.confirmation_option(prompt="Are you sure you want to delete this wallet?") +def wallet_delete(name: str) -> None: + """Delete wallet NAME.""" + db = _get_db() + assert Query is not None + removed = db.remove(Query().name == name) + if not removed: + raise click.ClickException(f"Wallet '{name}' not found.") + click.echo(f"Wallet '{name}' deleted.") diff --git a/docs/guide/cli.rst b/docs/guide/cli.rst new file mode 100644 index 0000000..c1acd01 --- /dev/null +++ b/docs/guide/cli.rst @@ -0,0 +1,296 @@ +.. _cli: + +Command-Line Interface +====================== + +BitCash ships with a command-line interface for common operations. +Install the optional CLI dependencies to enable it: + +.. code-block:: bash + + pip install 'bitcash[cli]' + +All commands are available under the ``bitcash`` entry point: + +.. code-block:: bash + + bitcash --help + +---- + +Stateless Commands +------------------ + +These commands require no stored wallet and make no writes to disk. + +new +^^^ + +Generate a fresh private key and print its WIF and address. + +.. code-block:: bash + + bitcash new [--network main|test|regtest] + +Example: + +.. code-block:: bash + + $ bitcash new + WIF: L4vB5fomsK8L95wQ7GFzvErYGht8aN9KV5CLDnXBFwGLCbcBHEFJ + Address: bitcoincash:qp0hamw9rpyllkmvd8047w9em3yt9fytsunyhutucx + +gen +^^^ + +Generate a vanity address whose address starts with the given prefix. + +.. code-block:: bash + + bitcash gen [--cores N|all] + +Example: + +.. code-block:: bash + + $ bitcash gen q1 + ... + +balance +^^^^^^^ + +Fetch the current balance of any address. + +.. code-block:: bash + + bitcash balance
[--currency satoshi] [--network main|test|regtest] + +Example: + +.. code-block:: bash + + $ bitcash balance bitcoincash:qp0hamw9rpyllkmvd8047w9em3yt9fytsunyhutucx + 493200 satoshi + + $ bitcash balance bitcoincash:qp0hamw9rpyllkmvd8047w9em3yt9fytsunyhutucx --currency usd + 2 USD + +transactions +^^^^^^^^^^^^ + +List all transaction IDs for an address. + +.. code-block:: bash + + bitcash transactions
[--network main|test|regtest] + +Example: + +.. code-block:: bash + + $ bitcash transactions bitcoincash:qp0hamw9rpyllkmvd8047w9em3yt9fytsunyhutucx + 6aea7b1c687d976644a430a87e34c93a8a7fd52d77c30e9cc247fc8228b749ff + fcb45fbe67ae685ac03a1d4ab25b644d57ddaca2e5f4e65ca500c8c4ccea9070 + ... + +unspents +^^^^^^^^ + +List all unspent transaction outputs (UTXOs) for an address. + +.. code-block:: bash + + bitcash unspents
[--network main|test|regtest] + +Example: + +.. code-block:: bash + + $ bitcash unspents bitcoincash:qp0hamw9rpyllkmvd8047w9em3yt9fytsunyhutucx + Unspent(amount=172294, confirmations=2244, ...) + +send +^^^^ + +Broadcast a transaction using a raw WIF key (no wallet store required). + +.. code-block:: bash + + bitcash send --wif \ + [--fee N] [--message TEXT] [--network main|test|regtest] + +Example: + +.. code-block:: bash + + $ bitcash send --wif L4vB5fomsK8L... \ + bitcoincash:qz69e5y8yrtujhsyht7q9xq5zhu4mrklmv0ap7tq5f 1000 satoshi + Transaction ID: 6aea7b1c687d976644a430a87e34c93a8a7fd52d77c30e9cc247fc8228b749ff + +subscribe +^^^^^^^^^ + +Watch an address for real-time transaction activity. Blocks until +``Ctrl+C`` or the server sends an ``unsubscribed`` event. + +Requires the ``subscriptions`` extra: ``pip install 'bitcash[subscriptions]'``. + +.. code-block:: bash + + bitcash subscribe
[--show-balance] [--network main|test|regtest] + +Example: + +.. code-block:: bash + + $ bitcash subscribe bitcoincash:qp0hamw9rpyllkmvd8047w9em3yt9fytsunyhutucx --show-balance + Subscribing to bitcoincash:qp0h... on main. Press Ctrl+C to stop. + [10:23:45] bitcoincash:qp0h... status=abc123def456… balance=493200 sat + +---- + +Wallet Commands +--------------- + +The ``wallet`` subgroup manages a local, password-protected wallet store +(backed by `TinyDB`_). Wallets are stored in the platform user-data directory +(e.g. ``~/.local/share/bitcash/wallets.json`` on Linux). + +Requires the ``cli`` extra: ``pip install 'bitcash[cli]'``. + +Passwords +^^^^^^^^^ + +Commands that need to decrypt the stored WIF accept the password in three ways, +in order of precedence: + +1. ``--password `` flag +2. ``BITCASH_WALLET_PASSWORD`` environment variable +3. Interactive prompt (fallback for human use) + +The environment variable approach is recommended for scripting and AI agents: + +.. code-block:: bash + + export BITCASH_WALLET_PASSWORD=mysecret + bitcash wallet send mykey bitcoincash:qq... 1000 satoshi + +wallet new +^^^^^^^^^^ + +Create a new wallet (generates a fresh key) or import an existing WIF. + +.. code-block:: bash + + bitcash wallet new [--wif WIF] [--network main|test|regtest] [--password PASSWORD] + +Examples: + +.. code-block:: bash + + # Generate a new key + $ bitcash wallet new mykey + Password: •••••••• + Repeat for confirmation: •••••••• + Wallet 'mykey' created. + Address: bitcoincash:qq... + + # Import an existing WIF + $ bitcash wallet new mykey --wif L4vB5fomsK8L... + Password: •••••••• + Wallet 'mykey' created. + Address: bitcoincash:qp0hamw9rpyllkmvd8047w9em3yt9fytsunyhutucx + +wallet list +^^^^^^^^^^^ + +List all stored wallets (no password required). + +.. code-block:: bash + + bitcash wallet list + +Example: + +.. code-block:: bash + + $ bitcash wallet list + mykey main bitcoincash:qp0hamw9rpyllkmvd8047w9em3yt9fytsunyhutucx + +wallet balance +^^^^^^^^^^^^^^ + +Fetch the balance of a stored wallet. No password required — the address +is public information. + +.. code-block:: bash + + bitcash wallet balance [--currency satoshi] + +Example: + +.. code-block:: bash + + $ bitcash wallet balance mykey + 493200 satoshi + +wallet subscribe +^^^^^^^^^^^^^^^^ + +Watch a stored wallet's address for real-time activity. + +.. code-block:: bash + + bitcash wallet subscribe [--show-balance] + +wallet send +^^^^^^^^^^^ + +Send BCH from a stored wallet. + +.. code-block:: bash + + bitcash wallet send \ + [--fee N] [--message TEXT] [--password PASSWORD] + +Example: + +.. code-block:: bash + + $ bitcash wallet send mykey bitcoincash:qz69e5y8yrtujhsyht7q9xq5zhu4mrklmv0ap7tq5f 1000 satoshi --password mysecret + Transaction ID: 6aea7b1c687d976644a430a87e34c93a8a7fd52d77c30e9cc247fc8228b749ff + +wallet export +^^^^^^^^^^^^^ + +Decrypt and print the WIF of a stored wallet. + +.. code-block:: bash + + bitcash wallet export [--password PASSWORD] + +Example: + +.. code-block:: bash + + $ bitcash wallet export mykey --password mysecret + WIF: L4vB5fomsK8L95wQ7GFzvErYGht8aN9KV5CLDnXBFwGLCbcBHEFJ + +wallet delete +^^^^^^^^^^^^^ + +Delete a stored wallet (asks for confirmation unless ``--yes`` is passed). + +.. code-block:: bash + + bitcash wallet delete [--yes] + +Example: + +.. code-block:: bash + + $ bitcash wallet delete mykey + Are you sure you want to delete this wallet? [y/N]: y + Wallet 'mykey' deleted. + +.. _TinyDB: https://tinydb.readthedocs.io/ diff --git a/docs/index.rst b/docs/index.rst index c25911d..846ca5c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -18,6 +18,7 @@ of best practices. guide/intro guide/install + guide/cli guide/keys guide/cashaddr guide/network diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..f7fbf84 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,524 @@ +from unittest.mock import patch, MagicMock +import pytest +from click.testing import CliRunner +from tinydb import TinyDB +from tinydb.storages import MemoryStorage + +from bitcash.cli import bitcash +import bitcash.cli as cli_module +from tests.samples import ( + WALLET_FORMAT_COMPRESSED_MAIN, + WALLET_FORMAT_COMPRESSED_TEST, + BITCOIN_CASHADDRESS_COMPRESSED, + BITCOIN_CASHADDRESS_TEST_COMPRESSED, +) + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def isolated_db(monkeypatch): + """Patch _get_db() to use an in-memory TinyDB.""" + db = TinyDB(storage=MemoryStorage) + + def _fake_get_db(): + return db + + monkeypatch.setattr(cli_module, "_get_db", _fake_get_db) + return db + + +# --------------------------------------------------------------------------- +# TestNew +# --------------------------------------------------------------------------- + + +class TestNew: + def test_new_mainnet(self, runner): + result = runner.invoke(bitcash, ["new"]) + assert result.exit_code == 0 + assert "WIF:" in result.output + assert "Address:" in result.output + assert "bitcoincash:" in result.output + + def test_new_testnet(self, runner): + result = runner.invoke(bitcash, ["new", "--network", "test"]) + assert result.exit_code == 0 + assert "bchtest:" in result.output + + def test_new_regtest(self, runner): + result = runner.invoke(bitcash, ["new", "--network", "regtest"]) + assert result.exit_code == 0 + assert "bchreg:" in result.output + + +# --------------------------------------------------------------------------- +# TestGen +# --------------------------------------------------------------------------- + + +class TestGen: + def test_gen_returns_address(self, runner): + with patch( + "bitcash.cli.generate_matching_address", + return_value=("WIF123", "bitcoincash:qtest"), + ): + result = runner.invoke(bitcash, ["gen", "q"]) + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# TestBalance +# --------------------------------------------------------------------------- + + +class TestBalance: + def test_balance_satoshi(self, runner): + with patch("bitcash.cli.NetworkAPI.get_balance", return_value=500000): + result = runner.invoke( + bitcash, + ["balance", BITCOIN_CASHADDRESS_COMPRESSED], + ) + assert result.exit_code == 0 + assert "500000 satoshi" in result.output + + def test_balance_zero(self, runner): + with patch("bitcash.cli.NetworkAPI.get_balance", return_value=0): + result = runner.invoke( + bitcash, + ["balance", BITCOIN_CASHADDRESS_COMPRESSED], + ) + assert result.exit_code == 0 + assert "0 satoshi" in result.output + + def test_balance_testnet(self, runner): + with patch("bitcash.cli.NetworkAPI.get_balance", return_value=100) as mock_bal: + result = runner.invoke( + bitcash, + ["balance", BITCOIN_CASHADDRESS_TEST_COMPRESSED, "--network", "test"], + ) + assert result.exit_code == 0 + mock_bal.assert_called_once_with( + BITCOIN_CASHADDRESS_TEST_COMPRESSED, network="testnet" + ) + + +# --------------------------------------------------------------------------- +# TestTransactions +# --------------------------------------------------------------------------- + + +class TestTransactions: + def test_transactions_found(self, runner): + with patch( + "bitcash.cli.NetworkAPI.get_transactions", + return_value=["txid1", "txid2"], + ): + result = runner.invoke( + bitcash, ["transactions", BITCOIN_CASHADDRESS_COMPRESSED] + ) + assert result.exit_code == 0 + assert "txid1" in result.output + assert "txid2" in result.output + + def test_transactions_empty(self, runner): + with patch("bitcash.cli.NetworkAPI.get_transactions", return_value=[]): + result = runner.invoke( + bitcash, ["transactions", BITCOIN_CASHADDRESS_COMPRESSED] + ) + assert result.exit_code == 0 + assert "No transactions found." in result.output + + +# --------------------------------------------------------------------------- +# TestUnspents +# --------------------------------------------------------------------------- + + +class TestUnspents: + def test_unspents_found(self, runner): + mock_utxo = MagicMock() + mock_utxo.__str__ = MagicMock(return_value="Unspent(amount=1000, ...)") + with patch( + "bitcash.cli.NetworkAPI.get_unspent", + return_value=[mock_utxo], + ): + result = runner.invoke( + bitcash, ["unspents", BITCOIN_CASHADDRESS_COMPRESSED] + ) + assert result.exit_code == 0 + assert "Unspent" in result.output + + def test_unspents_empty(self, runner): + with patch("bitcash.cli.NetworkAPI.get_unspent", return_value=[]): + result = runner.invoke( + bitcash, ["unspents", BITCOIN_CASHADDRESS_COMPRESSED] + ) + assert result.exit_code == 0 + assert "No unspents found." in result.output + + +# --------------------------------------------------------------------------- +# TestSend +# --------------------------------------------------------------------------- + + +class TestSend: + def test_send_mainnet(self, runner): + with patch("bitcash.cli.wif_to_key") as mock_wif: + mock_key = MagicMock() + mock_key.send.return_value = "deadbeef" + mock_wif.return_value = mock_key + result = runner.invoke( + bitcash, + [ + "send", + "--wif", + WALLET_FORMAT_COMPRESSED_MAIN, + BITCOIN_CASHADDRESS_COMPRESSED, + "1000", + "satoshi", + ], + ) + assert result.exit_code == 0 + assert "deadbeef" in result.output + + def test_send_with_fee_and_message(self, runner): + with patch("bitcash.cli.wif_to_key") as mock_wif: + mock_key = MagicMock() + mock_key.send.return_value = "cafebabe" + mock_wif.return_value = mock_key + result = runner.invoke( + bitcash, + [ + "send", + "--wif", + WALLET_FORMAT_COMPRESSED_MAIN, + BITCOIN_CASHADDRESS_COMPRESSED, + "500", + "satoshi", + "--fee", + "2", + "--message", + "hello", + ], + ) + assert result.exit_code == 0 + mock_key.send.assert_called_once_with( + [(BITCOIN_CASHADDRESS_COMPRESSED, "500", "satoshi")], + fee=2, + message="hello", + ) + + +# --------------------------------------------------------------------------- +# TestSubscribe +# --------------------------------------------------------------------------- + + +class TestSubscribe: + def _make_fake_subscribe(self, *events): + """Returns a subscribe side_effect that fires events synchronously.""" + def fake_subscribe(address, callback, network): + for status_hash in events: + callback(address, status_hash) + return MagicMock() + return fake_subscribe + + def test_subscribe_update(self, runner): + with patch( + "bitcash.cli.NetworkAPI.subscribe_address", + side_effect=self._make_fake_subscribe("abc123def456", "unsubscribed"), + ): + result = runner.invoke(bitcash, ["subscribe", BITCOIN_CASHADDRESS_COMPRESSED]) + assert result.exit_code == 0 + assert "abc123def456"[:12] in result.output + assert "Unsubscribed" in result.output + + def test_subscribe_no_history(self, runner): + with patch( + "bitcash.cli.NetworkAPI.subscribe_address", + side_effect=self._make_fake_subscribe(None, "unsubscribed"), + ): + result = runner.invoke(bitcash, ["subscribe", BITCOIN_CASHADDRESS_COMPRESSED]) + assert result.exit_code == 0 + assert "no history" in result.output + + def test_subscribe_show_balance(self, runner): + with patch( + "bitcash.cli.NetworkAPI.subscribe_address", + side_effect=self._make_fake_subscribe("deadbeef1234", "unsubscribed"), + ), patch("bitcash.cli.NetworkAPI.get_balance", return_value=42000): + result = runner.invoke( + bitcash, + ["subscribe", BITCOIN_CASHADDRESS_COMPRESSED, "--show-balance"], + ) + assert result.exit_code == 0 + assert "42000 sat" in result.output + + def test_subscribe_error_event(self, runner): + with patch( + "bitcash.cli.NetworkAPI.subscribe_address", + side_effect=self._make_fake_subscribe("error:timeout", "unsubscribed"), + ): + result = runner.invoke(bitcash, ["subscribe", BITCOIN_CASHADDRESS_COMPRESSED]) + assert result.exit_code == 0 + assert "Error:" in result.output + + +# --------------------------------------------------------------------------- +# TestWalletSubscribe +# --------------------------------------------------------------------------- + + +class TestWalletSubscribe: + def _make_fake_subscribe(self, *events): + def fake_subscribe(address, callback, network): + for status_hash in events: + callback(address, status_hash) + return MagicMock() + return fake_subscribe + + def test_wallet_subscribe(self, runner, isolated_db): + runner.invoke( + bitcash, + ["wallet", "new", "watcher", "--wif", WALLET_FORMAT_COMPRESSED_MAIN, "--password", "pass"], + ) + with patch( + "bitcash.cli.NetworkAPI.subscribe_address", + side_effect=self._make_fake_subscribe("cafebabe5678", "unsubscribed"), + ): + result = runner.invoke(bitcash, ["wallet", "subscribe", "watcher"]) + assert result.exit_code == 0 + assert "cafebabe5678"[:12] in result.output + + def test_wallet_subscribe_not_found(self, runner, isolated_db): + result = runner.invoke(bitcash, ["wallet", "subscribe", "ghost"]) + assert result.exit_code != 0 + assert "not found" in result.output + + +# --------------------------------------------------------------------------- +# TestWalletNew +# --------------------------------------------------------------------------- + + +class TestWalletNew: + def test_wallet_new_generates_key(self, runner, isolated_db): + result = runner.invoke( + bitcash, + ["wallet", "new", "mywallet", "--password", "secret"], + ) + assert result.exit_code == 0, result.output + assert "Wallet 'mywallet' created." in result.output + assert "bitcoincash:" in result.output + + def test_wallet_new_import_wif(self, runner, isolated_db): + result = runner.invoke( + bitcash, + ["wallet", "new", "imported", "--wif", WALLET_FORMAT_COMPRESSED_MAIN, "--password", "secret"], + ) + assert result.exit_code == 0, result.output + assert BITCOIN_CASHADDRESS_COMPRESSED in result.output + + def test_wallet_new_duplicate_fails(self, runner, isolated_db): + runner.invoke(bitcash, ["wallet", "new", "dupe", "--password", "pass"]) + result = runner.invoke(bitcash, ["wallet", "new", "dupe", "--password", "pass"]) + assert result.exit_code != 0 + assert "already exists" in result.output + + def test_wallet_new_wif_network_mismatch(self, runner, isolated_db): + result = runner.invoke( + bitcash, + [ + "wallet", "new", "bad", + "--wif", WALLET_FORMAT_COMPRESSED_MAIN, + "--network", "test", + "--password", "pass", + ], + ) + assert result.exit_code != 0 + assert "does not match" in result.output + + def test_wallet_new_testnet_wif(self, runner, isolated_db): + result = runner.invoke( + bitcash, + [ + "wallet", + "new", + "testwallet", + "--wif", + WALLET_FORMAT_COMPRESSED_TEST, + "--network", + "test", + "--password", + "pass", + ], + ) + assert result.exit_code == 0, result.output + assert BITCOIN_CASHADDRESS_TEST_COMPRESSED in result.output + + +# --------------------------------------------------------------------------- +# TestWalletList +# --------------------------------------------------------------------------- + + +class TestWalletList: + def test_wallet_list_empty(self, runner, isolated_db): + result = runner.invoke(bitcash, ["wallet", "list"]) + assert result.exit_code == 0 + assert "No wallets found." in result.output + + def test_wallet_list_shows_entries(self, runner, isolated_db): + runner.invoke( + bitcash, + ["wallet", "new", "alpha", "--password", "pass"], + ) + result = runner.invoke(bitcash, ["wallet", "list"]) + assert result.exit_code == 0 + assert "alpha" in result.output + assert "main" in result.output + + +# --------------------------------------------------------------------------- +# TestWalletBalance +# --------------------------------------------------------------------------- + + +class TestWalletBalance: + def test_wallet_balance_no_password(self, runner, isolated_db): + runner.invoke( + bitcash, + ["wallet", "new", "richkey", "--password", "secret"], + ) + with patch("bitcash.cli.NetworkAPI.get_balance", return_value=777): + result = runner.invoke(bitcash, ["wallet", "balance", "richkey"]) + assert result.exit_code == 0 + assert "777 satoshi" in result.output + + def test_wallet_balance_not_found(self, runner, isolated_db): + result = runner.invoke(bitcash, ["wallet", "balance", "ghost"]) + assert result.exit_code != 0 + assert "not found" in result.output + + +# --------------------------------------------------------------------------- +# TestWalletSend +# --------------------------------------------------------------------------- + + +class TestWalletSend: + def test_wallet_send(self, runner, isolated_db): + runner.invoke( + bitcash, + ["wallet", "new", "spender", "--password", "mypassword"], + ) + with patch("bitcash.cli.wif_to_key") as mock_wif: + mock_key = MagicMock() + mock_key.send.return_value = "txhash123" + mock_wif.return_value = mock_key + result = runner.invoke( + bitcash, + [ + "wallet", "send", "spender", + BITCOIN_CASHADDRESS_COMPRESSED, "100", "satoshi", + "--password", "mypassword", + ], + ) + assert result.exit_code == 0, result.output + assert "txhash123" in result.output + + def test_wallet_send_wrong_password(self, runner, isolated_db): + runner.invoke( + bitcash, + ["wallet", "new", "spender2", "--password", "correct"], + ) + result = runner.invoke( + bitcash, + [ + "wallet", "send", "spender2", + BITCOIN_CASHADDRESS_COMPRESSED, "100", "satoshi", + "--password", "wrong", + ], + ) + assert result.exit_code != 0 + assert "Incorrect password" in result.output + + +# --------------------------------------------------------------------------- +# TestWalletExport +# --------------------------------------------------------------------------- + + +class TestWalletExport: + def test_wallet_export(self, runner, isolated_db): + runner.invoke( + bitcash, + ["wallet", "new", "exportme", "--wif", WALLET_FORMAT_COMPRESSED_MAIN, "--password", "mypass"], + ) + result = runner.invoke( + bitcash, + ["wallet", "export", "exportme", "--password", "mypass"], + ) + assert result.exit_code == 0, result.output + assert WALLET_FORMAT_COMPRESSED_MAIN in result.output + + def test_wallet_export_wrong_password(self, runner, isolated_db): + runner.invoke( + bitcash, + ["wallet", "new", "exportme2", "--wif", WALLET_FORMAT_COMPRESSED_MAIN, "--password", "correct"], + ) + result = runner.invoke( + bitcash, + ["wallet", "export", "exportme2", "--password", "wrong"], + ) + assert result.exit_code != 0 + assert "Incorrect password" in result.output + + +# --------------------------------------------------------------------------- +# TestWalletDelete +# --------------------------------------------------------------------------- + + +class TestWalletDelete: + def test_wallet_delete(self, runner, isolated_db): + runner.invoke( + bitcash, + ["wallet", "new", "tobedeleted", "--password", "pass"], + ) + result = runner.invoke( + bitcash, + ["wallet", "delete", "tobedeleted", "--yes"], + ) + assert result.exit_code == 0, result.output + assert "deleted" in result.output + + # Confirm it's gone + result2 = runner.invoke(bitcash, ["wallet", "balance", "tobedeleted"]) + assert result2.exit_code != 0 + assert "not found" in result2.output + + def test_wallet_delete_not_found(self, runner, isolated_db): + result = runner.invoke( + bitcash, + ["wallet", "delete", "nosuchname", "--yes"], + ) + assert result.exit_code != 0 + assert "not found" in result.output + + +# --------------------------------------------------------------------------- +# Root group: no subcommand → help +# --------------------------------------------------------------------------- + + +class TestRootGroup: + def test_no_subcommand_shows_help(self, runner): + result = runner.invoke(bitcash, []) + assert result.exit_code == 0 + assert "Usage:" in result.output From 368bf602e125ca38b47d0513a865070f4c227ac3 Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Sun, 5 Apr 2026 18:30:37 +0300 Subject: [PATCH 02/10] Install cli extras in requirements.txt for CI testing Co-Authored-By: Claude Sonnet 4.6 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d6e1198..49fc201 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ --e . +-e .[cli] From daa4153c115bc1f8314942fd6027a3297dce1f23 Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Sun, 5 Apr 2026 18:31:08 +0300 Subject: [PATCH 03/10] fix lint --- bitcash/cli.py | 33 ++++++++++----- tests/test_cli.py | 101 ++++++++++++++++++++++++++++++++++++---------- 2 files changed, 103 insertions(+), 31 deletions(-) diff --git a/bitcash/cli.py b/bitcash/cli.py index aa4c234..24ac4df 100644 --- a/bitcash/cli.py +++ b/bitcash/cli.py @@ -37,9 +37,8 @@ # Shared option — callback converts the CLI string to a Network enum member # --------------------------------------------------------------------------- -def _parse_network( - ctx: click.Context, param: click.Parameter, value: str -) -> Network: + +def _parse_network(ctx: click.Context, param: click.Parameter, value: str) -> Network: return Network[value] @@ -234,7 +233,9 @@ def unspents(address: str, network: Network) -> None: @bitcash.command(name="subscribe") @click.argument("address") -@click.option("--show-balance", is_flag=True, help="Fetch and print balance on each update") +@click.option( + "--show-balance", is_flag=True, help="Fetch and print balance on each update" +) @NETWORK_OPTION def subscribe_cmd(address: str, show_balance: bool, network: Network) -> None: """Watch ADDRESS for real-time transaction activity.""" @@ -252,7 +253,9 @@ def on_update(addr: str, status_hash: str | None) -> None: else: if show_balance: bal: int = NetworkAPI.get_balance(addr, network=network.value) - click.echo(f"[{ts}] {addr} status={status_hash[:12]}… balance={bal} sat") + click.echo( + f"[{ts}] {addr} status={status_hash[:12]}… balance={bal} sat" + ) else: click.echo(f"[{ts}] {addr} status={status_hash[:12]}…") @@ -315,7 +318,9 @@ def wallet() -> None: @click.option("--wif", default=None, help="Import existing WIF (optional)") @NETWORK_OPTION @PASSWORD_OPTION -def wallet_new(name: str, wif: str | None, network: Network, password: str | None) -> None: +def wallet_new( + name: str, wif: str | None, network: Network, password: str | None +) -> None: """Create or import a named wallet.""" if privy is None: raise click.ClickException( @@ -365,7 +370,9 @@ def wallet_list() -> None: @wallet.command(name="subscribe") @click.argument("name") -@click.option("--show-balance", is_flag=True, help="Fetch and print balance on each update") +@click.option( + "--show-balance", is_flag=True, help="Fetch and print balance on each update" +) def wallet_subscribe(name: str, show_balance: bool) -> None: """Watch wallet NAME for real-time transaction activity.""" record = _load_key_from_db(name) @@ -384,12 +391,18 @@ def on_update(addr: str, status_hash: str | None) -> None: else: if show_balance: bal: int = NetworkAPI.get_balance(addr, network=record.network.value) - click.echo(f"[{ts}] {addr} status={status_hash[:12]}… balance={bal} sat") + click.echo( + f"[{ts}] {addr} status={status_hash[:12]}… balance={bal} sat" + ) else: click.echo(f"[{ts}] {addr} status={status_hash[:12]}…") - click.echo(f"Subscribing to {name} ({address}) on {record.network.name}. Press Ctrl+C to stop.") - handle = NetworkAPI.subscribe_address(address, on_update, network=record.network.value) + click.echo( + f"Subscribing to {name} ({address}) on {record.network.name}. Press Ctrl+C to stop." + ) + handle = NetworkAPI.subscribe_address( + address, on_update, network=record.network.value + ) try: stop_event.wait() diff --git a/tests/test_cli.py b/tests/test_cli.py index f7fbf84..7ad7c49 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -222,10 +222,12 @@ def test_send_with_fee_and_message(self, runner): class TestSubscribe: def _make_fake_subscribe(self, *events): """Returns a subscribe side_effect that fires events synchronously.""" + def fake_subscribe(address, callback, network): for status_hash in events: callback(address, status_hash) return MagicMock() + return fake_subscribe def test_subscribe_update(self, runner): @@ -233,7 +235,9 @@ def test_subscribe_update(self, runner): "bitcash.cli.NetworkAPI.subscribe_address", side_effect=self._make_fake_subscribe("abc123def456", "unsubscribed"), ): - result = runner.invoke(bitcash, ["subscribe", BITCOIN_CASHADDRESS_COMPRESSED]) + result = runner.invoke( + bitcash, ["subscribe", BITCOIN_CASHADDRESS_COMPRESSED] + ) assert result.exit_code == 0 assert "abc123def456"[:12] in result.output assert "Unsubscribed" in result.output @@ -243,15 +247,20 @@ def test_subscribe_no_history(self, runner): "bitcash.cli.NetworkAPI.subscribe_address", side_effect=self._make_fake_subscribe(None, "unsubscribed"), ): - result = runner.invoke(bitcash, ["subscribe", BITCOIN_CASHADDRESS_COMPRESSED]) + result = runner.invoke( + bitcash, ["subscribe", BITCOIN_CASHADDRESS_COMPRESSED] + ) assert result.exit_code == 0 assert "no history" in result.output def test_subscribe_show_balance(self, runner): - with patch( - "bitcash.cli.NetworkAPI.subscribe_address", - side_effect=self._make_fake_subscribe("deadbeef1234", "unsubscribed"), - ), patch("bitcash.cli.NetworkAPI.get_balance", return_value=42000): + with ( + patch( + "bitcash.cli.NetworkAPI.subscribe_address", + side_effect=self._make_fake_subscribe("deadbeef1234", "unsubscribed"), + ), + patch("bitcash.cli.NetworkAPI.get_balance", return_value=42000), + ): result = runner.invoke( bitcash, ["subscribe", BITCOIN_CASHADDRESS_COMPRESSED, "--show-balance"], @@ -264,7 +273,9 @@ def test_subscribe_error_event(self, runner): "bitcash.cli.NetworkAPI.subscribe_address", side_effect=self._make_fake_subscribe("error:timeout", "unsubscribed"), ): - result = runner.invoke(bitcash, ["subscribe", BITCOIN_CASHADDRESS_COMPRESSED]) + result = runner.invoke( + bitcash, ["subscribe", BITCOIN_CASHADDRESS_COMPRESSED] + ) assert result.exit_code == 0 assert "Error:" in result.output @@ -280,12 +291,21 @@ def fake_subscribe(address, callback, network): for status_hash in events: callback(address, status_hash) return MagicMock() + return fake_subscribe def test_wallet_subscribe(self, runner, isolated_db): runner.invoke( bitcash, - ["wallet", "new", "watcher", "--wif", WALLET_FORMAT_COMPRESSED_MAIN, "--password", "pass"], + [ + "wallet", + "new", + "watcher", + "--wif", + WALLET_FORMAT_COMPRESSED_MAIN, + "--password", + "pass", + ], ) with patch( "bitcash.cli.NetworkAPI.subscribe_address", @@ -319,7 +339,15 @@ def test_wallet_new_generates_key(self, runner, isolated_db): def test_wallet_new_import_wif(self, runner, isolated_db): result = runner.invoke( bitcash, - ["wallet", "new", "imported", "--wif", WALLET_FORMAT_COMPRESSED_MAIN, "--password", "secret"], + [ + "wallet", + "new", + "imported", + "--wif", + WALLET_FORMAT_COMPRESSED_MAIN, + "--password", + "secret", + ], ) assert result.exit_code == 0, result.output assert BITCOIN_CASHADDRESS_COMPRESSED in result.output @@ -334,10 +362,15 @@ def test_wallet_new_wif_network_mismatch(self, runner, isolated_db): result = runner.invoke( bitcash, [ - "wallet", "new", "bad", - "--wif", WALLET_FORMAT_COMPRESSED_MAIN, - "--network", "test", - "--password", "pass", + "wallet", + "new", + "bad", + "--wif", + WALLET_FORMAT_COMPRESSED_MAIN, + "--network", + "test", + "--password", + "pass", ], ) assert result.exit_code != 0 @@ -424,9 +457,14 @@ def test_wallet_send(self, runner, isolated_db): result = runner.invoke( bitcash, [ - "wallet", "send", "spender", - BITCOIN_CASHADDRESS_COMPRESSED, "100", "satoshi", - "--password", "mypassword", + "wallet", + "send", + "spender", + BITCOIN_CASHADDRESS_COMPRESSED, + "100", + "satoshi", + "--password", + "mypassword", ], ) assert result.exit_code == 0, result.output @@ -440,9 +478,14 @@ def test_wallet_send_wrong_password(self, runner, isolated_db): result = runner.invoke( bitcash, [ - "wallet", "send", "spender2", - BITCOIN_CASHADDRESS_COMPRESSED, "100", "satoshi", - "--password", "wrong", + "wallet", + "send", + "spender2", + BITCOIN_CASHADDRESS_COMPRESSED, + "100", + "satoshi", + "--password", + "wrong", ], ) assert result.exit_code != 0 @@ -458,7 +501,15 @@ class TestWalletExport: def test_wallet_export(self, runner, isolated_db): runner.invoke( bitcash, - ["wallet", "new", "exportme", "--wif", WALLET_FORMAT_COMPRESSED_MAIN, "--password", "mypass"], + [ + "wallet", + "new", + "exportme", + "--wif", + WALLET_FORMAT_COMPRESSED_MAIN, + "--password", + "mypass", + ], ) result = runner.invoke( bitcash, @@ -470,7 +521,15 @@ def test_wallet_export(self, runner, isolated_db): def test_wallet_export_wrong_password(self, runner, isolated_db): runner.invoke( bitcash, - ["wallet", "new", "exportme2", "--wif", WALLET_FORMAT_COMPRESSED_MAIN, "--password", "correct"], + [ + "wallet", + "new", + "exportme2", + "--wif", + WALLET_FORMAT_COMPRESSED_MAIN, + "--password", + "correct", + ], ) result = runner.invoke( bitcash, From 22b5ab2e83290ad898ef6633d29e551885cd8ab3 Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Mon, 6 Apr 2026 10:04:04 +0300 Subject: [PATCH 04/10] Add CashToken commands to CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `cashtoken-address` command to convert address to CATKN form - Add `--cashtoken` flag (CASHTOKEN_FLAG) to `balance` and `wallet balance` to show token holdings alongside BCH balance - Extend `send` and `wallet send` with `--category-id`, `--nft-capability`, `--nft-commitment` (hex→bytes), `--token-amount` - Add shared decorators: CASHTOKEN_FLAG, CATEGORY_ID_OPTION, NFT_CAPABILITY_OPTION, NFT_COMMITMENT_OPTION, TOKEN_AMOUNT_OPTION - Add _print_cashtoken_balance and _build_output helpers - Add 16 new tests (45 total, all passing) - Update docs/guide/cli.rst with new commands and options Co-Authored-By: Claude Sonnet 4.6 --- bitcash/cli.py | 154 +++++++++++++++++++++++++++-- docs/guide/cli.rst | 71 +++++++++++-- tests/test_cli.py | 242 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 449 insertions(+), 18 deletions(-) diff --git a/bitcash/cli.py b/bitcash/cli.py index 24ac4df..be9dc42 100644 --- a/bitcash/cli.py +++ b/bitcash/cli.py @@ -27,10 +27,12 @@ TinyDB = None # type: ignore[assignment,misc] Query = None # type: ignore[assignment] +from bitcash.cashtoken import Unspents as CashtokenUnspents +from bitcash.format import address_to_cashtokenaddress from bitcash.keygen import generate_matching_address from bitcash.wallet import PrivateKey, wif_to_key from bitcash.network import NetworkAPI, satoshi_to_currency_cached -from bitcash.types import Network, UserOutput +from bitcash.types import Network, NFTCapability, TokenData, UserOutput # --------------------------------------------------------------------------- @@ -60,6 +62,49 @@ def _parse_network(ctx: click.Context, param: click.Parameter, value: str) -> Ne ) +def _parse_nft_commitment( + ctx: click.Context, param: click.Parameter, value: str | None +) -> bytes | None: + if value is None: + return None + try: + return bytes.fromhex(value) + except ValueError: + raise click.BadParameter("must be a valid hex string") + + +CATEGORY_ID_OPTION = click.option( + "--category-id", + default=None, + help="CashToken category ID (hex)", +) + +NFT_CAPABILITY_OPTION = click.option( + "--nft-capability", + type=click.Choice([c.name for c in NFTCapability]), + default=None, + help="NFT capability", +) + +NFT_COMMITMENT_OPTION = click.option( + "--nft-commitment", + default=None, + callback=_parse_nft_commitment, + help="NFT commitment (hex)", +) + +TOKEN_AMOUNT_OPTION = click.option( + "--token-amount", + default=None, + type=int, + help="Fungible token amount", +) + +CASHTOKEN_FLAG = click.option( + "--cashtoken", is_flag=True, help="Also show CashToken holdings" +) + + # --------------------------------------------------------------------------- # Wallet record # --------------------------------------------------------------------------- @@ -133,6 +178,53 @@ def _decrypt_wif(record: WalletRecord, password: str) -> str: raise click.ClickException("Incorrect password or corrupted wallet data.") +# --------------------------------------------------------------------------- +# CashToken helpers +# --------------------------------------------------------------------------- + + +def _print_cashtoken_balance(tokendata: dict[str, TokenData]) -> None: + if not tokendata: + click.echo("No tokens found.") + return + for category_id, data in tokendata.items(): + click.echo(f"Category: {category_id}") + if data.token_amount is not None: + click.echo(f" Fungible amount: {data.token_amount}") + if data.nft: + click.echo(f" NFTs ({len(data.nft)}):") + for nft in data.nft: + commitment = nft.commitment.hex() if nft.commitment else "(none)" + click.echo( + f" capability={nft.capability.name} commitment(hex)={commitment}" + ) + + +def _build_output( + to: str, + amount: str, + currency: str, + category_id: str | None, + nft_capability: str | None, + nft_commitment: bytes | None, + token_amount: int | None, +) -> UserOutput: + if category_id is None: + return cast(UserOutput, (to, amount, currency)) + return cast( + UserOutput, + ( + to, + amount, + currency, + category_id, + nft_capability, + nft_commitment, + token_amount, + ), + ) + + # --------------------------------------------------------------------------- # Root group # --------------------------------------------------------------------------- @@ -158,6 +250,18 @@ def gen(prefix: str, cores: str) -> None: click.echo(generate_matching_address(prefix, cores)) +# --------------------------------------------------------------------------- +# cashtoken-address +# --------------------------------------------------------------------------- + + +@bitcash.command(name="cashtoken-address") +@click.argument("address") +def cashtoken_address_cmd(address: str) -> None: + """Convert ADDRESS to its CashToken-signalling form.""" + click.echo(address_to_cashtokenaddress(address)) + + # --------------------------------------------------------------------------- # new # --------------------------------------------------------------------------- @@ -180,14 +284,19 @@ def new_cmd(network: Network) -> None: @bitcash.command() @click.argument("address") @click.option("--currency", default="satoshi", show_default=True) +@CASHTOKEN_FLAG @NETWORK_OPTION -def balance(address: str, currency: str, network: Network) -> None: - """Show the balance of ADDRESS.""" +def balance(address: str, currency: str, cashtoken: bool, network: Network) -> None: + """Show the BCH balance of ADDRESS, and optionally its CashToken holdings.""" raw: int = NetworkAPI.get_balance(address, network=network.value) if currency == "satoshi": click.echo(f"{raw} satoshi") else: click.echo(satoshi_to_currency_cached(raw, currency)) + if cashtoken: + utxos = NetworkAPI.get_unspent(address, network=network.value) + agg = CashtokenUnspents(utxos) + _print_cashtoken_balance(agg.tokendata) # --------------------------------------------------------------------------- @@ -281,6 +390,10 @@ def on_update(addr: str, status_hash: str | None) -> None: @click.argument("currency") @click.option("--fee", default=None, type=int, help="Fee in satoshi/byte") @click.option("--message", default=None, help="OP_RETURN message") +@CATEGORY_ID_OPTION +@NFT_CAPABILITY_OPTION +@NFT_COMMITMENT_OPTION +@TOKEN_AMOUNT_OPTION @NETWORK_OPTION def send_cmd( wif: str, @@ -289,14 +402,19 @@ def send_cmd( currency: str, fee: int | None, message: str | None, + category_id: str | None, + nft_capability: str | None, + nft_commitment: bytes | None, + token_amount: int | None, network: Network, ) -> None: - """Send BCH using a raw WIF key.""" + """Send BCH (and optionally CashTokens) using a raw WIF key.""" key = wif_to_key(wif, regtest=(network == Network.regtest)) - # UserOutput types amount as int but the implementation accepts any - # Decimal-compatible value; cast to satisfy the type checker. + output = _build_output( + to, amount, currency, category_id, nft_capability, nft_commitment, token_amount + ) txid: str = key.send( - cast(list[UserOutput], [(to, amount, currency)]), + [output], fee=fee, message=message, ) @@ -414,7 +532,8 @@ def on_update(addr: str, status_hash: str | None) -> None: @wallet.command(name="balance") @click.argument("name") @click.option("--currency", default="satoshi", show_default=True) -def wallet_balance(name: str, currency: str) -> None: +@CASHTOKEN_FLAG +def wallet_balance(name: str, currency: str, cashtoken: bool) -> None: """Show balance for wallet NAME (no password required).""" record = _load_key_from_db(name) raw: int = NetworkAPI.get_balance(record.address, network=record.network.value) @@ -422,6 +541,10 @@ def wallet_balance(name: str, currency: str) -> None: click.echo(f"{raw} satoshi") else: click.echo(satoshi_to_currency_cached(raw, currency)) + if cashtoken: + utxos = NetworkAPI.get_unspent(record.address, network=record.network.value) + agg = CashtokenUnspents(utxos) + _print_cashtoken_balance(agg.tokendata) @wallet.command(name="send") @@ -431,6 +554,10 @@ def wallet_balance(name: str, currency: str) -> None: @click.argument("currency") @click.option("--fee", default=None, type=int, help="Fee in satoshi/byte") @click.option("--message", default=None, help="OP_RETURN message") +@CATEGORY_ID_OPTION +@NFT_CAPABILITY_OPTION +@NFT_COMMITMENT_OPTION +@TOKEN_AMOUNT_OPTION @PASSWORD_OPTION def wallet_send( name: str, @@ -439,14 +566,21 @@ def wallet_send( currency: str, fee: int | None, message: str | None, + category_id: str | None, + nft_capability: str | None, + nft_commitment: bytes | None, + token_amount: int | None, password: str | None, ) -> None: - """Send BCH from wallet NAME.""" + """Send BCH (and optionally CashTokens) from wallet NAME.""" record = _load_key_from_db(name) wif: str = _decrypt_wif(record, _prompt_password(password)) key = wif_to_key(wif, regtest=(record.network == Network.regtest)) + output = _build_output( + to, amount, currency, category_id, nft_capability, nft_commitment, token_amount + ) txid: str = key.send( - cast(list[UserOutput], [(to, amount, currency)]), + [output], fee=fee, message=message, ) diff --git a/docs/guide/cli.rst b/docs/guide/cli.rst index c1acd01..0c9cdd7 100644 --- a/docs/guide/cli.rst +++ b/docs/guide/cli.rst @@ -56,14 +56,31 @@ Example: $ bitcash gen q1 ... +cashtoken-address +^^^^^^^^^^^^^^^^^ + +Convert any address to its CashToken-signalling form (``bitcoincash:zz...``). + +.. code-block:: bash + + bitcash cashtoken-address
+ +Example: + +.. code-block:: bash + + $ bitcash cashtoken-address bitcoincash:qp0hamw9rpyllkmvd8047w9em3yt9fytsunyhutucx + bitcoincash:zp0hamw9rpyllkmvd8047w9em3yt9fytsuu5wac7aq + balance ^^^^^^^ -Fetch the current balance of any address. +Fetch the current balance of any address. Pass ``--cashtoken`` to also show +CashToken holdings (fungible amounts and NFTs) alongside the BCH balance. .. code-block:: bash - bitcash balance
[--currency satoshi] [--network main|test|regtest] + bitcash balance
[--currency satoshi] [--cashtoken] [--network main|test|regtest] Example: @@ -75,6 +92,13 @@ Example: $ bitcash balance bitcoincash:qp0hamw9rpyllkmvd8047w9em3yt9fytsunyhutucx --currency usd 2 USD + $ bitcash balance bitcoincash:qp0hamw9rpyllkmvd8047w9em3yt9fytsunyhutucx --cashtoken + 493200 satoshi + Category: aabbcc0011223344556677889900aabbcc0011223344556677889900aabbcc00 + Fungible amount: 1000 + NFTs (1): + capability=minting commitment(hex)=666f6f626172 + transactions ^^^^^^^^^^^^ @@ -113,20 +137,37 @@ send ^^^^ Broadcast a transaction using a raw WIF key (no wallet store required). +CashToken options are optional — omit them for a plain BCH transfer. .. code-block:: bash bitcash send --wif \ - [--fee N] [--message TEXT] [--network main|test|regtest] + [--fee N] [--message TEXT] \ + [--category-id HEX] [--nft-capability none|mutable|minting] \ + [--nft-commitment HEX] [--token-amount N] \ + [--network main|test|regtest] -Example: +Examples: .. code-block:: bash + # Plain BCH transfer $ bitcash send --wif L4vB5fomsK8L... \ bitcoincash:qz69e5y8yrtujhsyht7q9xq5zhu4mrklmv0ap7tq5f 1000 satoshi Transaction ID: 6aea7b1c687d976644a430a87e34c93a8a7fd52d77c30e9cc247fc8228b749ff + # Send fungible tokens + $ bitcash send --wif L4vB5fomsK8L... \ + bitcoincash:zz69e5y8yrtujhsyht7q9xq5zhu4mrklmvxxxxxxx 1000 satoshi \ + --category-id aabbcc00... --token-amount 50 + Transaction ID: 6aea7b1c... + + # Send an NFT with commitment + $ bitcash send --wif L4vB5fomsK8L... \ + bitcoincash:zz69e5y8yrtujhsyht7q9xq5zhu4mrklmvxxxxxxx 1000 satoshi \ + --category-id aabbcc00... --nft-capability none --nft-commitment 666f6f626172 + Transaction ID: 6aea7b1c... + subscribe ^^^^^^^^^ @@ -221,11 +262,11 @@ wallet balance ^^^^^^^^^^^^^^ Fetch the balance of a stored wallet. No password required — the address -is public information. +is public information. Pass ``--cashtoken`` to also show CashToken holdings. .. code-block:: bash - bitcash wallet balance [--currency satoshi] + bitcash wallet balance [--currency satoshi] [--cashtoken] Example: @@ -234,6 +275,13 @@ Example: $ bitcash wallet balance mykey 493200 satoshi + $ bitcash wallet balance mykey --cashtoken + 493200 satoshi + Category: aabbcc0011223344556677889900aabbcc0011223344556677889900aabbcc00 + Fungible amount: 1000 + NFTs (1): + capability=minting commitment(hex)=666f6f626172 + wallet subscribe ^^^^^^^^^^^^^^^^ @@ -246,12 +294,15 @@ Watch a stored wallet's address for real-time activity. wallet send ^^^^^^^^^^^ -Send BCH from a stored wallet. +Send BCH (and optionally CashTokens) from a stored wallet. .. code-block:: bash bitcash wallet send \ - [--fee N] [--message TEXT] [--password PASSWORD] + [--fee N] [--message TEXT] \ + [--category-id HEX] [--nft-capability none|mutable|minting] \ + [--nft-commitment HEX] [--token-amount N] \ + [--password PASSWORD] Example: @@ -260,6 +311,10 @@ Example: $ bitcash wallet send mykey bitcoincash:qz69e5y8yrtujhsyht7q9xq5zhu4mrklmv0ap7tq5f 1000 satoshi --password mysecret Transaction ID: 6aea7b1c687d976644a430a87e34c93a8a7fd52d77c30e9cc247fc8228b749ff + $ bitcash wallet send mykey bitcoincash:zz69e5y8yrtujhsyht7q9xq5zhu4mrklmvxxxxxxx 1000 satoshi \ + --category-id aabbcc00... --token-amount 50 --password mysecret + Transaction ID: 6aea7b1c... + wallet export ^^^^^^^^^^^^^ diff --git a/tests/test_cli.py b/tests/test_cli.py index 7ad7c49..eff107c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,11 +6,17 @@ from bitcash.cli import bitcash import bitcash.cli as cli_module +from bitcash.types import NFTCapability, NFTData, TokenData from tests.samples import ( WALLET_FORMAT_COMPRESSED_MAIN, WALLET_FORMAT_COMPRESSED_TEST, BITCOIN_CASHADDRESS_COMPRESSED, BITCOIN_CASHADDRESS_TEST_COMPRESSED, + BITCOIN_CASHADDRESS_CATKN, + CASHTOKEN_CATAGORY_ID, + CASHTOKEN_CAPABILITY, + CASHTOKEN_COMMITMENT, + CASHTOKEN_AMOUNT, ) @@ -581,3 +587,239 @@ def test_no_subcommand_shows_help(self, runner): result = runner.invoke(bitcash, []) assert result.exit_code == 0 assert "Usage:" in result.output + + +# --------------------------------------------------------------------------- +# TestCashtokenAddress +# --------------------------------------------------------------------------- + + +class TestCashtokenAddress: + def test_converts_to_catkn(self, runner): + result = runner.invoke( + bitcash, ["cashtoken-address", BITCOIN_CASHADDRESS_COMPRESSED] + ) + assert result.exit_code == 0, result.output + # CATKN addresses use the "zz" prefix after the network prefix + assert "CATKN" in result.output or "zz" in result.output + + def test_known_address(self, runner): + with patch( + "bitcash.cli.address_to_cashtokenaddress", + return_value=BITCOIN_CASHADDRESS_CATKN, + ): + result = runner.invoke( + bitcash, ["cashtoken-address", BITCOIN_CASHADDRESS_COMPRESSED] + ) + assert result.exit_code == 0 + assert BITCOIN_CASHADDRESS_CATKN in result.output + + +# --------------------------------------------------------------------------- +# TestBalanceCashtoken +# --------------------------------------------------------------------------- + + +def _make_mock_unspent_with_token(): + """Return a mock Unspent list that CashtokenUnspents can aggregate.""" + from bitcash.network.meta import Unspent + from bitcash.types import CashTokens + + unspent = MagicMock(spec=Unspent) + unspent.amount = 1000 + unspent.txindex = 1 + unspent.has_cashtoken = True + unspent.has_amount = True + unspent.has_nft = True + unspent.cashtoken = CashTokens( + category_id=CASHTOKEN_CATAGORY_ID, + nft_capability=NFTCapability[CASHTOKEN_CAPABILITY], + nft_commitment=CASHTOKEN_COMMITMENT, + token_amount=CASHTOKEN_AMOUNT, + ) + return [unspent] + + +class TestBalanceCashtoken: + def test_cashtoken_flag_shows_token_data(self, runner): + with ( + patch("bitcash.cli.NetworkAPI.get_balance", return_value=1000), + patch( + "bitcash.cli.NetworkAPI.get_unspent", + return_value=_make_mock_unspent_with_token(), + ), + ): + result = runner.invoke( + bitcash, + ["balance", BITCOIN_CASHADDRESS_COMPRESSED, "--cashtoken"], + ) + assert result.exit_code == 0, result.output + assert "1000 satoshi" in result.output + assert CASHTOKEN_CATAGORY_ID in result.output + assert str(CASHTOKEN_AMOUNT) in result.output + + def test_cashtoken_flag_empty(self, runner): + with ( + patch("bitcash.cli.NetworkAPI.get_balance", return_value=0), + patch("bitcash.cli.NetworkAPI.get_unspent", return_value=[]), + ): + result = runner.invoke( + bitcash, + ["balance", BITCOIN_CASHADDRESS_COMPRESSED, "--cashtoken"], + ) + assert result.exit_code == 0 + assert "No tokens found." in result.output + + def test_without_cashtoken_flag_no_token_output(self, runner): + with patch("bitcash.cli.NetworkAPI.get_balance", return_value=500): + result = runner.invoke( + bitcash, + ["balance", BITCOIN_CASHADDRESS_COMPRESSED], + ) + assert result.exit_code == 0 + assert "500 satoshi" in result.output + assert "Category:" not in result.output + + +# --------------------------------------------------------------------------- +# TestSendWithTokens +# --------------------------------------------------------------------------- + + +class TestSendWithTokens: + def test_send_with_token_amount(self, runner): + with patch("bitcash.cli.wif_to_key") as mock_wif: + mock_key = MagicMock() + mock_key.send.return_value = "tokentxid" + mock_wif.return_value = mock_key + result = runner.invoke( + bitcash, + [ + "send", + "--wif", + WALLET_FORMAT_COMPRESSED_MAIN, + BITCOIN_CASHADDRESS_CATKN, + "1000", + "satoshi", + "--category-id", + CASHTOKEN_CATAGORY_ID, + "--token-amount", + str(CASHTOKEN_AMOUNT), + ], + ) + assert result.exit_code == 0, result.output + assert "tokentxid" in result.output + mock_key.send.assert_called_once_with( + [ + ( + BITCOIN_CASHADDRESS_CATKN, + "1000", + "satoshi", + CASHTOKEN_CATAGORY_ID, + None, + None, + CASHTOKEN_AMOUNT, + ) + ], + fee=None, + message=None, + ) + + def test_send_with_nft(self, runner): + commitment_hex = CASHTOKEN_COMMITMENT.hex() + with patch("bitcash.cli.wif_to_key") as mock_wif: + mock_key = MagicMock() + mock_key.send.return_value = "nfttxid" + mock_wif.return_value = mock_key + result = runner.invoke( + bitcash, + [ + "send", + "--wif", + WALLET_FORMAT_COMPRESSED_MAIN, + BITCOIN_CASHADDRESS_CATKN, + "1000", + "satoshi", + "--category-id", + CASHTOKEN_CATAGORY_ID, + "--nft-capability", + CASHTOKEN_CAPABILITY, + "--nft-commitment", + commitment_hex, + ], + ) + assert result.exit_code == 0, result.output + assert "nfttxid" in result.output + mock_key.send.assert_called_once_with( + [ + ( + BITCOIN_CASHADDRESS_CATKN, + "1000", + "satoshi", + CASHTOKEN_CATAGORY_ID, + CASHTOKEN_CAPABILITY, + CASHTOKEN_COMMITMENT, + None, + ) + ], + fee=None, + message=None, + ) + + def test_nft_commitment_bad_hex(self, runner): + with patch("bitcash.cli.wif_to_key"): + result = runner.invoke( + bitcash, + [ + "send", + "--wif", + WALLET_FORMAT_COMPRESSED_MAIN, + BITCOIN_CASHADDRESS_CATKN, + "1000", + "satoshi", + "--nft-commitment", + "notvalidhex", + ], + ) + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# TestWalletBalanceCashtoken +# --------------------------------------------------------------------------- + + +class TestWalletBalanceCashtoken: + def test_wallet_balance_cashtoken(self, runner, isolated_db): + runner.invoke( + bitcash, + ["wallet", "new", "tokenrich", "--password", "pass"], + ) + with ( + patch("bitcash.cli.NetworkAPI.get_balance", return_value=2000), + patch( + "bitcash.cli.NetworkAPI.get_unspent", + return_value=_make_mock_unspent_with_token(), + ), + ): + result = runner.invoke( + bitcash, ["wallet", "balance", "tokenrich", "--cashtoken"] + ) + assert result.exit_code == 0, result.output + assert "2000 satoshi" in result.output + assert CASHTOKEN_CATAGORY_ID in result.output + + def test_wallet_balance_cashtoken_empty(self, runner, isolated_db): + runner.invoke( + bitcash, + ["wallet", "new", "emptywallet", "--password", "pass"], + ) + with ( + patch("bitcash.cli.NetworkAPI.get_balance", return_value=0), + patch("bitcash.cli.NetworkAPI.get_unspent", return_value=[]), + ): + result = runner.invoke( + bitcash, ["wallet", "balance", "emptywallet", "--cashtoken"] + ) + assert result.exit_code == 0 + assert "No tokens found." in result.output From 2dd7f29b997059aea23cdede1efcf245ebb2ee7f Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Mon, 6 Apr 2026 11:37:50 +0300 Subject: [PATCH 05/10] Fix incorrect subscriptions extra note in CLI docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no 'subscriptions' extra — subscription support is part of the base install. Remove the misleading install hint. Co-Authored-By: Claude Sonnet 4.6 --- docs/guide/cli.rst | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/guide/cli.rst b/docs/guide/cli.rst index 0c9cdd7..b61a7a0 100644 --- a/docs/guide/cli.rst +++ b/docs/guide/cli.rst @@ -174,8 +174,6 @@ subscribe Watch an address for real-time transaction activity. Blocks until ``Ctrl+C`` or the server sends an ``unsubscribed`` event. -Requires the ``subscriptions`` extra: ``pip install 'bitcash[subscriptions]'``. - .. code-block:: bash bitcash subscribe
[--show-balance] [--network main|test|regtest] From f6fdf849f3890391de8bfacce576cf9c9c4dbc50 Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Mon, 6 Apr 2026 23:34:13 +0300 Subject: [PATCH 06/10] Enrich CLI docstrings for agent-aware documentation - Add "BitCash: Python Bitcoin Cash Library" to root group - Each command now explicitly describes: what network calls it makes, what it outputs, key constraints (password required, address must be CATKN for token sends, wallet must exist), and when not to use it Co-Authored-By: Claude Sonnet 4.6 --- bitcash/cli.py | 106 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 90 insertions(+), 16 deletions(-) diff --git a/bitcash/cli.py b/bitcash/cli.py index be9dc42..0a12aca 100644 --- a/bitcash/cli.py +++ b/bitcash/cli.py @@ -233,6 +233,7 @@ def _build_output( @click.group(invoke_without_command=True) @click.pass_context def bitcash(ctx: click.Context) -> None: + """BitCash: Python Bitcoin Cash Library.""" if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) @@ -246,7 +247,11 @@ def bitcash(ctx: click.Context) -> None: @click.argument("prefix") @click.option("--cores", "-c", default="all") def gen(prefix: str, cores: str) -> None: - """Generate a vanity address matching PREFIX.""" + """Generate a vanity address whose address starts with PREFIX. + + CPU-intensive and may run for a long time depending on prefix length. + No network calls. Prints a (WIF, address) pair when a match is found. + """ click.echo(generate_matching_address(prefix, cores)) @@ -258,7 +263,11 @@ def gen(prefix: str, cores: str) -> None: @bitcash.command(name="cashtoken-address") @click.argument("address") def cashtoken_address_cmd(address: str) -> None: - """Convert ADDRESS to its CashToken-signalling form.""" + """Convert ADDRESS to its CashToken-signalling form (bitcoincash:zz...). + + Pure local conversion — no network calls. CashToken-signalling addresses + are required as destinations when sending tokens via the send commands. + """ click.echo(address_to_cashtokenaddress(address)) @@ -270,7 +279,11 @@ def cashtoken_address_cmd(address: str) -> None: @bitcash.command(name="new") @NETWORK_OPTION def new_cmd(network: Network) -> None: - """Generate a new private key and print WIF + address.""" + """Generate a new private key and print its WIF and address. + + No network calls. Each invocation produces a unique key — never reuses keys. + Store the WIF securely; it cannot be recovered if lost. + """ key = PrivateKey(network=network.name) click.echo(f"WIF: {key.to_wif()}") click.echo(f"Address: {key.address}") @@ -287,7 +300,12 @@ def new_cmd(network: Network) -> None: @CASHTOKEN_FLAG @NETWORK_OPTION def balance(address: str, currency: str, cashtoken: bool, network: Network) -> None: - """Show the BCH balance of ADDRESS, and optionally its CashToken holdings.""" + """Show the BCH balance of ADDRESS, and optionally its CashToken holdings. + + Always fetches the BCH balance via a network call. With --cashtoken, also + fetches UTXOs to aggregate fungible token amounts and NFTs per category ID. + Output is human-readable; use 'bitcash schema' for machine-readable field names. + """ raw: int = NetworkAPI.get_balance(address, network=network.value) if currency == "satoshi": click.echo(f"{raw} satoshi") @@ -308,7 +326,10 @@ def balance(address: str, currency: str, cashtoken: bool, network: Network) -> N @click.argument("address") @NETWORK_OPTION def transactions(address: str, network: Network) -> None: - """List transactions for ADDRESS.""" + """List all transaction IDs for ADDRESS, one per line. + + Read-only network call. Prints nothing (exit 0) if the address has no history. + """ txs: list[str] = NetworkAPI.get_transactions(address, network=network.value) if not txs: click.echo("No transactions found.") @@ -326,7 +347,11 @@ def transactions(address: str, network: Network) -> None: @click.argument("address") @NETWORK_OPTION def unspents(address: str, network: Network) -> None: - """List unspent outputs for ADDRESS.""" + """List all unspent transaction outputs (UTXOs) for ADDRESS, one per line. + + Read-only network call. Each line includes txid, vout index, amount in + satoshi, and any CashToken prefix data attached to that UTXO. + """ utxos = NetworkAPI.get_unspent(address, network=network.value) if not utxos: click.echo("No unspents found.") @@ -347,7 +372,13 @@ def unspents(address: str, network: Network) -> None: ) @NETWORK_OPTION def subscribe_cmd(address: str, show_balance: bool, network: Network) -> None: - """Watch ADDRESS for real-time transaction activity.""" + """Watch ADDRESS for real-time transaction activity over a persistent connection. + + Blocks until Ctrl+C or the server sends an unsubscribed event. Each update + prints a timestamped status hash. With --show-balance, also fetches and + prints the current BCH balance on each update. Not suitable for scripted + one-shot use — prefer 'balance' or 'transactions' for polling. + """ stop_event = threading.Event() def on_update(addr: str, status_hash: str | None) -> None: @@ -408,7 +439,14 @@ def send_cmd( token_amount: int | None, network: Network, ) -> None: - """Send BCH (and optionally CashTokens) using a raw WIF key.""" + """Sign and broadcast a BCH transaction using a raw WIF private key. + + Amount is converted from currency to satoshi automatically. Prints the + transaction ID on success. To include CashTokens, provide --category-id; + the destination address must be a CashToken-signalling address + (bitcoincash:zz...) — use 'cashtoken-address' to convert if needed. + --nft-commitment expects a hex string. + """ key = wif_to_key(wif, regtest=(network == Network.regtest)) output = _build_output( to, amount, currency, category_id, nft_capability, nft_commitment, token_amount @@ -428,7 +466,13 @@ def send_cmd( @bitcash.group() def wallet() -> None: - """Manage named, password-protected wallets.""" + """Manage named, password-protected wallets stored in a local TinyDB file. + + Wallets are stored at the platform user-data directory + (e.g. ~/.local/share/bitcash/wallets.json on Linux). The private key is + encrypted with privy using the wallet password; the address is stored in + plaintext for read-only commands that don't need the password. + """ @wallet.command(name="new") @@ -439,7 +483,13 @@ def wallet() -> None: def wallet_new( name: str, wif: str | None, network: Network, password: str | None ) -> None: - """Create or import a named wallet.""" + """Create a new wallet or import an existing WIF into the local wallet store. + + Fails if a wallet with NAME already exists. Without --wif, generates a + fresh private key. With --wif, the WIF's network must match --network. + Password is required to encrypt the key; prompts interactively if omitted. + Prints the wallet address on success. + """ if privy is None: raise click.ClickException( "Wallet commands require optional dependencies. " @@ -476,7 +526,10 @@ def wallet_new( @wallet.command(name="list") def wallet_list() -> None: - """List all stored wallets.""" + """List all wallets in the local store — name, network, and address. + + No password required. No network calls. + """ db = _get_db() records = [WalletRecord.from_dict(r) for r in db.all()] if not records: @@ -492,7 +545,11 @@ def wallet_list() -> None: "--show-balance", is_flag=True, help="Fetch and print balance on each update" ) def wallet_subscribe(name: str, show_balance: bool) -> None: - """Watch wallet NAME for real-time transaction activity.""" + """Watch wallet NAME for real-time transaction activity. + + Resolves the address from the local wallet store — no password required. + Otherwise behaves identically to 'subscribe'. Blocks until Ctrl+C. + """ record = _load_key_from_db(name) address: str = record.address stop_event = threading.Event() @@ -534,7 +591,11 @@ def on_update(addr: str, status_hash: str | None) -> None: @click.option("--currency", default="satoshi", show_default=True) @CASHTOKEN_FLAG def wallet_balance(name: str, currency: str, cashtoken: bool) -> None: - """Show balance for wallet NAME (no password required).""" + """Show the BCH balance for wallet NAME, and optionally its CashToken holdings. + + Resolves the address from the local wallet store — no password required. + Otherwise behaves identically to 'balance'. + """ record = _load_key_from_db(name) raw: int = NetworkAPI.get_balance(record.address, network=record.network.value) if currency == "satoshi": @@ -572,7 +633,12 @@ def wallet_send( token_amount: int | None, password: str | None, ) -> None: - """Send BCH (and optionally CashTokens) from wallet NAME.""" + """Decrypt wallet NAME and broadcast a signed BCH transaction. + + Password is required to decrypt the stored WIF; prompts interactively if + omitted, or reads from BITCASH_WALLET_PASSWORD env var. Prints the + transaction ID on success. CashToken behaviour is identical to 'send'. + """ record = _load_key_from_db(name) wif: str = _decrypt_wif(record, _prompt_password(password)) key = wif_to_key(wif, regtest=(record.network == Network.regtest)) @@ -591,7 +657,11 @@ def wallet_send( @click.argument("name") @PASSWORD_OPTION def wallet_export(name: str, password: str | None) -> None: - """Decrypt and print the WIF for wallet NAME.""" + """Decrypt and print the WIF for wallet NAME. + + Password required. Treat the output as a secret — anyone with the WIF + has full control of the funds. + """ record = _load_key_from_db(name) wif: str = _decrypt_wif(record, _prompt_password(password)) click.echo(f"WIF: {wif}") @@ -601,7 +671,11 @@ def wallet_export(name: str, password: str | None) -> None: @click.argument("name") @click.confirmation_option(prompt="Are you sure you want to delete this wallet?") def wallet_delete(name: str) -> None: - """Delete wallet NAME.""" + """Remove wallet NAME from the local store. + + Asks for confirmation unless --yes is passed. Only deletes the local + record — funds on-chain are unaffected. Fails if NAME does not exist. + """ db = _get_db() assert Query is not None removed = db.remove(Query().name == name) From d58680d6d5f4d7f9712a7681283c21c7febe4881 Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Wed, 29 Apr 2026 21:10:37 +0300 Subject: [PATCH 07/10] Add @agent decorator and schema command for agent-aware CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add bitcash/click_agent.py: @agent decorator (MCP ToolAnnotations vocabulary + blocking, secret, local_required, skill), _serialize_command walker, add_schema_command injector - Annotate all commands with agent metadata (read_only, destructive, idempotent, open_world, blocking, secret, local_required) - Replace ad-hoc schema implementation with add_schema_command(bitcash) - Add 5 focused tests: structure, unknown-field rejection, absent-field omission, choices/is_flag serialization, sentinel default → null Co-Authored-By: Claude Sonnet 4.6 --- bitcash/cli.py | 33 ++++++++++- bitcash/click_agent.py | 124 +++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 49 ++++++++++++++++ 3 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 bitcash/click_agent.py diff --git a/bitcash/cli.py b/bitcash/cli.py index 0a12aca..f1a1e15 100644 --- a/bitcash/cli.py +++ b/bitcash/cli.py @@ -28,6 +28,7 @@ Query = None # type: ignore[assignment] from bitcash.cashtoken import Unspents as CashtokenUnspents +from bitcash.click_agent import agent, add_schema_command from bitcash.format import address_to_cashtokenaddress from bitcash.keygen import generate_matching_address from bitcash.wallet import PrivateKey, wif_to_key @@ -233,7 +234,11 @@ def _build_output( @click.group(invoke_without_command=True) @click.pass_context def bitcash(ctx: click.Context) -> None: - """BitCash: Python Bitcoin Cash Library.""" + """BitCash: Python Bitcoin Cash Library. + + Agents: run 'bitcash schema' first. It returns the full command + tree as JSON in one call, including all params, defaults, and choices. + """ if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) @@ -244,6 +249,7 @@ def bitcash(ctx: click.Context) -> None: @bitcash.command() # pyright: ignore +@agent(open_world=False) @click.argument("prefix") @click.option("--cores", "-c", default="all") def gen(prefix: str, cores: str) -> None: @@ -261,6 +267,7 @@ def gen(prefix: str, cores: str) -> None: @bitcash.command(name="cashtoken-address") +@agent(read_only=True, idempotent=True, open_world=False) @click.argument("address") def cashtoken_address_cmd(address: str) -> None: """Convert ADDRESS to its CashToken-signalling form (bitcoincash:zz...). @@ -277,6 +284,7 @@ def cashtoken_address_cmd(address: str) -> None: @bitcash.command(name="new") +@agent(open_world=False) @NETWORK_OPTION def new_cmd(network: Network) -> None: """Generate a new private key and print its WIF and address. @@ -295,6 +303,7 @@ def new_cmd(network: Network) -> None: @bitcash.command() +@agent(read_only=True, idempotent=True, open_world=True) @click.argument("address") @click.option("--currency", default="satoshi", show_default=True) @CASHTOKEN_FLAG @@ -323,6 +332,7 @@ def balance(address: str, currency: str, cashtoken: bool, network: Network) -> N @bitcash.command() +@agent(read_only=True, idempotent=True, open_world=True) @click.argument("address") @NETWORK_OPTION def transactions(address: str, network: Network) -> None: @@ -344,6 +354,7 @@ def transactions(address: str, network: Network) -> None: @bitcash.command() +@agent(read_only=True, idempotent=True, open_world=True) @click.argument("address") @NETWORK_OPTION def unspents(address: str, network: Network) -> None: @@ -366,6 +377,7 @@ def unspents(address: str, network: Network) -> None: @bitcash.command(name="subscribe") +@agent(read_only=True, open_world=True, blocking=True) @click.argument("address") @click.option( "--show-balance", is_flag=True, help="Fetch and print balance on each update" @@ -415,6 +427,7 @@ def on_update(addr: str, status_hash: str | None) -> None: @bitcash.command(name="send") +@agent(read_only=False, destructive=True, idempotent=False, open_world=True) @click.option("--wif", required=True, help="Sender WIF private key") @click.argument("to") @click.argument("amount") @@ -446,6 +459,10 @@ def send_cmd( the destination address must be a CashToken-signalling address (bitcoincash:zz...) — use 'cashtoken-address' to convert if needed. --nft-commitment expects a hex string. + + To genesis a new CashToken, pass the txid of an index-0 UTXO you own + as --category-id. Any index-0 UTXO qualifies regardless of existing + tokens on it. """ key = wif_to_key(wif, regtest=(network == Network.regtest)) output = _build_output( @@ -465,6 +482,7 @@ def send_cmd( @bitcash.group() +@agent(local_required=True) def wallet() -> None: """Manage named, password-protected wallets stored in a local TinyDB file. @@ -476,6 +494,7 @@ def wallet() -> None: @wallet.command(name="new") +@agent(read_only=False, destructive=False, idempotent=False, open_world=False, local_required=True) @click.argument("name") @click.option("--wif", default=None, help="Import existing WIF (optional)") @NETWORK_OPTION @@ -525,6 +544,7 @@ def wallet_new( @wallet.command(name="list") +@agent(read_only=True, idempotent=True, open_world=False, local_required=True) def wallet_list() -> None: """List all wallets in the local store — name, network, and address. @@ -540,6 +560,7 @@ def wallet_list() -> None: @wallet.command(name="subscribe") +@agent(read_only=True, open_world=True, blocking=True, local_required=True) @click.argument("name") @click.option( "--show-balance", is_flag=True, help="Fetch and print balance on each update" @@ -587,6 +608,7 @@ def on_update(addr: str, status_hash: str | None) -> None: @wallet.command(name="balance") +@agent(read_only=True, idempotent=True, open_world=True, local_required=True) @click.argument("name") @click.option("--currency", default="satoshi", show_default=True) @CASHTOKEN_FLAG @@ -609,6 +631,7 @@ def wallet_balance(name: str, currency: str, cashtoken: bool) -> None: @wallet.command(name="send") +@agent(read_only=False, destructive=True, idempotent=False, open_world=True, local_required=True) @click.argument("name") @click.argument("to") @click.argument("amount") @@ -637,7 +660,8 @@ def wallet_send( Password is required to decrypt the stored WIF; prompts interactively if omitted, or reads from BITCASH_WALLET_PASSWORD env var. Prints the - transaction ID on success. CashToken behaviour is identical to 'send'. + transaction ID on success. CashToken behaviour is identical to 'send', + including genesis token creation. """ record = _load_key_from_db(name) wif: str = _decrypt_wif(record, _prompt_password(password)) @@ -654,6 +678,7 @@ def wallet_send( @wallet.command(name="export") +@agent(read_only=True, idempotent=True, open_world=False, secret=True, local_required=True) @click.argument("name") @PASSWORD_OPTION def wallet_export(name: str, password: str | None) -> None: @@ -668,6 +693,7 @@ def wallet_export(name: str, password: str | None) -> None: @wallet.command(name="delete") +@agent(read_only=False, destructive=True, idempotent=False, open_world=False, local_required=True) @click.argument("name") @click.confirmation_option(prompt="Are you sure you want to delete this wallet?") def wallet_delete(name: str) -> None: @@ -682,3 +708,6 @@ def wallet_delete(name: str) -> None: if not removed: raise click.ClickException(f"Wallet '{name}' not found.") click.echo(f"Wallet '{name}' deleted.") + + +add_schema_command(bitcash) diff --git a/bitcash/click_agent.py b/bitcash/click_agent.py new file mode 100644 index 0000000..e41c872 --- /dev/null +++ b/bitcash/click_agent.py @@ -0,0 +1,124 @@ +""" +click_agent.py — lightweight @agent decorator for Click CLIs. + +Attaches agent-readable metadata to Click commands and groups. +Vocabulary borrowed from MCP ToolAnnotations (2025-03-26 spec), extended +with `secret` and `local_required` (Microsoft azmcp) and `blocking` (new). + +Absent fields are omitted from schema output — agents should treat an +absent field as unknown, not as a safe default. +""" + +from __future__ import annotations + +import json +from typing import Any + +import click # pyright: ignore + +_AGENT_FIELDS = { + "read_only", + "destructive", + "idempotent", + "open_world", + "secret", + "local_required", + "blocking", + "skill", +} + + +def agent(**kwargs: Any): + """Attach agent-readable metadata to a Click command or group. + + All fields are optional. Unset fields are omitted from schema output. + + Fields (MCP-compatible unless noted): + read_only — no state is modified anywhere + destructive — modification is irreversible (only when read_only=False) + idempotent — repeating with same args has no additional effect + open_world — makes network calls / reaches external systems + secret — output may contain sensitive data; do not log + local_required — requires local state (wallet store, config files) [azmcp] + blocking — blocks indefinitely until externally interrupted [new] + skill — URL to a SKILL.md document for workflow-level context [new] + """ + unknown = set(kwargs) - _AGENT_FIELDS + if unknown: + raise ValueError(f"Unknown @agent fields: {unknown}") + + def decorator(f: Any) -> Any: + f.__agent_meta__ = kwargs + return f + + return decorator + + +def _safe_default(value: Any) -> Any: + try: + json.dumps(value) + return value + except TypeError: + return None + + +def _serialize_command(cmd: click.BaseCommand) -> dict: + node: dict = {} + + if cmd.help: + node["help"] = cmd.help + + meta = getattr(cmd.callback, "__agent_meta__", {}) + if meta: + node["agent"] = meta + + if isinstance(cmd, click.Group): + node["commands"] = { + name: _serialize_command(sub) for name, sub in cmd.commands.items() + } + else: + params = [] + for p in cmd.params: + if p.name == "help": + continue + if isinstance(p, click.Argument): + params.append( + {"name": p.name, "kind": "argument", "required": p.required} + ) + elif isinstance(p, click.Option): + entry: dict = { + "name": p.name, + "kind": "option", + "required": p.required, + "flags": list(p.opts), + "help": p.help or "", + "default": _safe_default(p.default), + } + if p.is_flag: + entry["is_flag"] = True + if isinstance(p.type, click.Choice): + entry["choices"] = list(p.type.choices) + params.append(entry) + if params: + node["params"] = params + + return node + + +def add_schema_command(group: click.Group) -> None: + """Inject a 'schema' subcommand into a Click group. + + Call this after all commands have been registered on the group. + """ + + @group.command(name="schema") + @click.pass_context + def schema_cmd(ctx: click.Context) -> None: + """Emit the full command tree as JSON for agent consumption. + + Includes help text, agent metadata (read_only, destructive, + idempotent, open_world, secret, local_required, blocking, skill), + parameter names, kinds, flags, defaults, and choices. No network calls. + """ + root = ctx.find_root() + click.echo(json.dumps(_serialize_command(root.command), indent=2)) diff --git a/tests/test_cli.py b/tests/test_cli.py index eff107c..b41daf1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -589,6 +589,55 @@ def test_no_subcommand_shows_help(self, runner): assert "Usage:" in result.output +# --------------------------------------------------------------------------- +# TestSchema +# --------------------------------------------------------------------------- + + +class TestSchema: + def _get_schema(self, runner) -> dict: + import json + + result = runner.invoke(bitcash, ["schema"]) + assert result.exit_code == 0, result.output + return json.loads(result.output) + + def test_schema_is_valid_json_with_full_command_tree(self, runner): + schema = self._get_schema(runner) + # top-level commands present, wallet subcommands nested + for cmd in ("balance", "send", "subscribe", "wallet", "schema"): + assert cmd in schema["commands"] + for sub in ("new", "list", "balance", "send", "export", "delete"): + assert sub in schema["commands"]["wallet"]["commands"] + + def test_agent_decorator_rejects_unknown_fields(self): + from bitcash.click_agent import agent + with pytest.raises(ValueError, match="Unknown @agent fields"): + agent(unknown_field=True) + + def test_agent_metadata_emitted_and_absent_fields_omitted(self, runner): + schema = self._get_schema(runner) + # send is annotated with destructive=True + assert schema["commands"]["send"]["agent"]["destructive"] is True + # balance is read_only — destructive must be absent, not False + assert "destructive" not in schema["commands"]["balance"]["agent"] + + def test_choices_and_is_flag_serialized(self, runner): + schema = self._get_schema(runner) + send_params = {p["name"]: p for p in schema["commands"]["send"]["params"]} + # network is a Choice param + assert send_params["network"]["choices"] == ["main", "test", "regtest"] + # cashtoken is a flag — but send doesn't have it; check balance + balance_params = {p["name"]: p for p in schema["commands"]["balance"]["params"]} + assert balance_params["cashtoken"].get("is_flag") is True + + def test_sentinel_defaults_serialize_to_null(self, runner): + schema = self._get_schema(runner) + send_params = {p["name"]: p for p in schema["commands"]["send"]["params"]} + # --fee has no default set → should be null, not a crash + assert send_params["fee"]["default"] is None + + # --------------------------------------------------------------------------- # TestCashtokenAddress # --------------------------------------------------------------------------- From 7c752e1d757da4f3c34012c2c9bf8b1d7fdf0e19 Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Wed, 29 Apr 2026 21:12:39 +0300 Subject: [PATCH 08/10] Add cashtoken-addresses command Lists addresses holding tokens of a given category ID. Supports --nft-capability, --nft-commitment (hex), --has-amount, and --network filters. Now that get_cashtoken_addresses landed in master (PR #161), the command is fully wired up. Co-Authored-By: Claude Sonnet 4.6 --- bitcash/cli.py | 40 ++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/bitcash/cli.py b/bitcash/cli.py index f1a1e15..67d2751 100644 --- a/bitcash/cli.py +++ b/bitcash/cli.py @@ -278,6 +278,46 @@ def cashtoken_address_cmd(address: str) -> None: click.echo(address_to_cashtokenaddress(address)) +# --------------------------------------------------------------------------- +# cashtoken-addresses +# --------------------------------------------------------------------------- + + +@bitcash.command(name="cashtoken-addresses") +@agent(read_only=True, idempotent=True, open_world=True) +@click.argument("category_id") +@NFT_CAPABILITY_OPTION +@NFT_COMMITMENT_OPTION +@click.option("--has-amount", is_flag=True, help="Only addresses holding fungible tokens") +@NETWORK_OPTION +def cashtoken_addresses_cmd( + category_id: str, + nft_capability: str | None, + nft_commitment: bytes | None, + has_amount: bool, + network: Network, +) -> None: + """List addresses holding unspent tokens of CATEGORY_ID. + + All filters are optional and combinable. --nft-commitment expects a hex + string. Returns addresses sorted alphabetically, one per line. + Read-only network call. + """ + nft_cap = NFTCapability[nft_capability] if nft_capability else None + addresses: set[str] = NetworkAPI.get_cashtoken_addresses( + category_id, + nft_capability=nft_cap, + nft_commitment=nft_commitment, + has_token=has_amount, + network=network.value, + ) + if not addresses: + click.echo("No addresses found.") + else: + for addr in sorted(addresses): + click.echo(addr) + + # --------------------------------------------------------------------------- # new # --------------------------------------------------------------------------- diff --git a/tests/test_cli.py b/tests/test_cli.py index b41daf1..368db4f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -638,6 +638,53 @@ def test_sentinel_defaults_serialize_to_null(self, runner): assert send_params["fee"]["default"] is None +# --------------------------------------------------------------------------- +# TestCashtokenAddresses +# --------------------------------------------------------------------------- + + +class TestCashtokenAddresses: + def test_lists_sorted(self, runner): + addrs = {"bitcoincash:qzzz", "bitcoincash:qaaa", "bitcoincash:qmmm"} + with patch("bitcash.cli.NetworkAPI.get_cashtoken_addresses", return_value=addrs): + result = runner.invoke( + bitcash, ["cashtoken-addresses", CASHTOKEN_CATAGORY_ID] + ) + assert result.exit_code == 0, result.output + lines = result.output.strip().splitlines() + assert lines == sorted(addrs) + + def test_empty(self, runner): + with patch("bitcash.cli.NetworkAPI.get_cashtoken_addresses", return_value=set()): + result = runner.invoke( + bitcash, ["cashtoken-addresses", CASHTOKEN_CATAGORY_ID] + ) + assert result.exit_code == 0 + assert "No addresses found." in result.output + + def test_filters_passed_through(self, runner): + commitment_hex = CASHTOKEN_COMMITMENT.hex() + with patch("bitcash.cli.NetworkAPI.get_cashtoken_addresses", return_value=set()) as mock: + runner.invoke( + bitcash, + [ + "cashtoken-addresses", CASHTOKEN_CATAGORY_ID, + "--nft-capability", CASHTOKEN_CAPABILITY, + "--nft-commitment", commitment_hex, + "--has-amount", + "--network", "test", + ], + ) + from bitcash.types import NFTCapability + mock.assert_called_once_with( + CASHTOKEN_CATAGORY_ID, + nft_capability=NFTCapability[CASHTOKEN_CAPABILITY], + nft_commitment=CASHTOKEN_COMMITMENT, + has_token=True, + network="testnet", + ) + + # --------------------------------------------------------------------------- # TestCashtokenAddress # --------------------------------------------------------------------------- From c60b353f68665fee4136141ec062a811802afc14 Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Wed, 29 Apr 2026 21:30:16 +0300 Subject: [PATCH 09/10] Apply black formatting to cli.py and test_cli.py Co-Authored-By: Claude Sonnet 4.6 --- bitcash/cli.py | 32 +++++++++++++++++++++++++++----- tests/test_cli.py | 26 +++++++++++++++++++------- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/bitcash/cli.py b/bitcash/cli.py index 67d2751..d16fc9d 100644 --- a/bitcash/cli.py +++ b/bitcash/cli.py @@ -288,7 +288,9 @@ def cashtoken_address_cmd(address: str) -> None: @click.argument("category_id") @NFT_CAPABILITY_OPTION @NFT_COMMITMENT_OPTION -@click.option("--has-amount", is_flag=True, help="Only addresses holding fungible tokens") +@click.option( + "--has-amount", is_flag=True, help="Only addresses holding fungible tokens" +) @NETWORK_OPTION def cashtoken_addresses_cmd( category_id: str, @@ -534,7 +536,13 @@ def wallet() -> None: @wallet.command(name="new") -@agent(read_only=False, destructive=False, idempotent=False, open_world=False, local_required=True) +@agent( + read_only=False, + destructive=False, + idempotent=False, + open_world=False, + local_required=True, +) @click.argument("name") @click.option("--wif", default=None, help="Import existing WIF (optional)") @NETWORK_OPTION @@ -671,7 +679,13 @@ def wallet_balance(name: str, currency: str, cashtoken: bool) -> None: @wallet.command(name="send") -@agent(read_only=False, destructive=True, idempotent=False, open_world=True, local_required=True) +@agent( + read_only=False, + destructive=True, + idempotent=False, + open_world=True, + local_required=True, +) @click.argument("name") @click.argument("to") @click.argument("amount") @@ -718,7 +732,9 @@ def wallet_send( @wallet.command(name="export") -@agent(read_only=True, idempotent=True, open_world=False, secret=True, local_required=True) +@agent( + read_only=True, idempotent=True, open_world=False, secret=True, local_required=True +) @click.argument("name") @PASSWORD_OPTION def wallet_export(name: str, password: str | None) -> None: @@ -733,7 +749,13 @@ def wallet_export(name: str, password: str | None) -> None: @wallet.command(name="delete") -@agent(read_only=False, destructive=True, idempotent=False, open_world=False, local_required=True) +@agent( + read_only=False, + destructive=True, + idempotent=False, + open_world=False, + local_required=True, +) @click.argument("name") @click.confirmation_option(prompt="Are you sure you want to delete this wallet?") def wallet_delete(name: str) -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 368db4f..9c68d71 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -612,6 +612,7 @@ def test_schema_is_valid_json_with_full_command_tree(self, runner): def test_agent_decorator_rejects_unknown_fields(self): from bitcash.click_agent import agent + with pytest.raises(ValueError, match="Unknown @agent fields"): agent(unknown_field=True) @@ -646,7 +647,9 @@ def test_sentinel_defaults_serialize_to_null(self, runner): class TestCashtokenAddresses: def test_lists_sorted(self, runner): addrs = {"bitcoincash:qzzz", "bitcoincash:qaaa", "bitcoincash:qmmm"} - with patch("bitcash.cli.NetworkAPI.get_cashtoken_addresses", return_value=addrs): + with patch( + "bitcash.cli.NetworkAPI.get_cashtoken_addresses", return_value=addrs + ): result = runner.invoke( bitcash, ["cashtoken-addresses", CASHTOKEN_CATAGORY_ID] ) @@ -655,7 +658,9 @@ def test_lists_sorted(self, runner): assert lines == sorted(addrs) def test_empty(self, runner): - with patch("bitcash.cli.NetworkAPI.get_cashtoken_addresses", return_value=set()): + with patch( + "bitcash.cli.NetworkAPI.get_cashtoken_addresses", return_value=set() + ): result = runner.invoke( bitcash, ["cashtoken-addresses", CASHTOKEN_CATAGORY_ID] ) @@ -664,18 +669,25 @@ def test_empty(self, runner): def test_filters_passed_through(self, runner): commitment_hex = CASHTOKEN_COMMITMENT.hex() - with patch("bitcash.cli.NetworkAPI.get_cashtoken_addresses", return_value=set()) as mock: + with patch( + "bitcash.cli.NetworkAPI.get_cashtoken_addresses", return_value=set() + ) as mock: runner.invoke( bitcash, [ - "cashtoken-addresses", CASHTOKEN_CATAGORY_ID, - "--nft-capability", CASHTOKEN_CAPABILITY, - "--nft-commitment", commitment_hex, + "cashtoken-addresses", + CASHTOKEN_CATAGORY_ID, + "--nft-capability", + CASHTOKEN_CAPABILITY, + "--nft-commitment", + commitment_hex, "--has-amount", - "--network", "test", + "--network", + "test", ], ) from bitcash.types import NFTCapability + mock.assert_called_once_with( CASHTOKEN_CATAGORY_ID, nft_capability=NFTCapability[CASHTOKEN_CAPABILITY], From 1c040cf324d7f91337bdef3cca3724640a615895 Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Wed, 29 Apr 2026 21:32:29 +0300 Subject: [PATCH 10/10] Fix click ImportError to suggest bitcash[cli] extras Co-Authored-By: Claude Sonnet 4.6 --- bitcash/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitcash/cli.py b/bitcash/cli.py index d16fc9d..dea4f8d 100644 --- a/bitcash/cli.py +++ b/bitcash/cli.py @@ -9,7 +9,7 @@ try: import click # pyright: ignore except ImportError: - raise ImportError("Please install the 'click' package to use this CLI tool.") + raise ImportError("Please install CLI dependencies with: pip install 'bitcash[cli]'") try: import appdirs # pyright: ignore