diff --git a/bitcash/cli.py b/bitcash/cli.py index 3f9c66b..dea4f8d 100644 --- a/bitcash/cli.py +++ b/bitcash/cli.py @@ -1,18 +1,775 @@ +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.") + raise ImportError("Please install CLI dependencies with: pip install 'bitcash[cli]'") + +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.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 +from bitcash.network import NetworkAPI, satoshi_to_currency_cached +from bitcash.types import Network, NFTCapability, TokenData, 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, +) + + +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 +# --------------------------------------------------------------------------- + + +@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.") + + +# --------------------------------------------------------------------------- +# 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 +# --------------------------------------------------------------------------- @click.group(invoke_without_command=True) -def bitcash(): - pass +@click.pass_context +def bitcash(ctx: click.Context) -> None: + """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()) + + +# --------------------------------------------------------------------------- +# gen +# --------------------------------------------------------------------------- @bitcash.command() # pyright: ignore +@agent(open_world=False) @click.argument("prefix") @click.option("--cores", "-c", default="all") -def gen(prefix, cores): +def gen(prefix: str, cores: str) -> None: + """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)) + + +# --------------------------------------------------------------------------- +# cashtoken-address +# --------------------------------------------------------------------------- + + +@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...). + + 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)) + + +# --------------------------------------------------------------------------- +# 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 +# --------------------------------------------------------------------------- + + +@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. + + 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}") + + +# --------------------------------------------------------------------------- +# balance +# --------------------------------------------------------------------------- + + +@bitcash.command() +@agent(read_only=True, idempotent=True, open_world=True) +@click.argument("address") +@click.option("--currency", default="satoshi", show_default=True) +@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. + + 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") + 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) + + +# --------------------------------------------------------------------------- +# transactions +# --------------------------------------------------------------------------- + + +@bitcash.command() +@agent(read_only=True, idempotent=True, open_world=True) +@click.argument("address") +@NETWORK_OPTION +def transactions(address: str, network: Network) -> None: + """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.") + else: + for txid in txs: + click.echo(txid) + + +# --------------------------------------------------------------------------- +# unspents +# --------------------------------------------------------------------------- + + +@bitcash.command() +@agent(read_only=True, idempotent=True, open_world=True) +@click.argument("address") +@NETWORK_OPTION +def unspents(address: str, network: Network) -> None: + """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.") + else: + for u in utxos: + click.echo(u) + + +# --------------------------------------------------------------------------- +# subscribe (stateless) +# --------------------------------------------------------------------------- + + +@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" +) +@NETWORK_OPTION +def subscribe_cmd(address: str, show_balance: bool, network: Network) -> None: + """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: + 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") +@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") +@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, + to: str, + amount: str, + 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: + """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. + + 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( + to, amount, currency, category_id, nft_capability, nft_commitment, token_amount + ) + txid: str = key.send( + [output], + fee=fee, + message=message, + ) + click.echo(f"Transaction ID: {txid}") + + +# --------------------------------------------------------------------------- +# wallet subgroup +# --------------------------------------------------------------------------- + + +@bitcash.group() +@agent(local_required=True) +def wallet() -> None: + """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") +@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 +@PASSWORD_OPTION +def wallet_new( + name: str, wif: str | None, network: Network, password: str | None +) -> None: + """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. " + "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") +@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. + + No password required. No network calls. + """ + 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") +@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" +) +def wallet_subscribe(name: str, show_balance: bool) -> None: + """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() + + 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") +@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 +def wallet_balance(name: str, currency: str, cashtoken: bool) -> None: + """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": + 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") +@agent( + read_only=False, + destructive=True, + idempotent=False, + open_world=True, + local_required=True, +) +@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") +@CATEGORY_ID_OPTION +@NFT_CAPABILITY_OPTION +@NFT_COMMITMENT_OPTION +@TOKEN_AMOUNT_OPTION +@PASSWORD_OPTION +def wallet_send( + name: str, + to: str, + amount: str, + 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: + """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', + including genesis token creation. + """ + 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( + [output], + fee=fee, + message=message, + ) + click.echo(f"Transaction ID: {txid}") + + +@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: + """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}") + + +@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: + """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) + 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/docs/guide/cli.rst b/docs/guide/cli.rst new file mode 100644 index 0000000..b61a7a0 --- /dev/null +++ b/docs/guide/cli.rst @@ -0,0 +1,349 @@ +.. _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 + ... + +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. Pass ``--cashtoken`` to also show +CashToken holdings (fungible amounts and NFTs) alongside the BCH balance. + +.. code-block:: bash + + bitcash balance
[--currency satoshi] [--cashtoken] [--network main|test|regtest] + +Example: + +.. code-block:: bash + + $ bitcash balance bitcoincash:qp0hamw9rpyllkmvd8047w9em3yt9fytsunyhutucx + 493200 satoshi + + $ 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 +^^^^^^^^^^^^ + +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). +CashToken options are optional — omit them for a plain BCH transfer. + +.. code-block:: bash + + bitcash send --wif \ + [--fee N] [--message TEXT] \ + [--category-id HEX] [--nft-capability none|mutable|minting] \ + [--nft-commitment HEX] [--token-amount N] \ + [--network main|test|regtest] + +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 +^^^^^^^^^ + +Watch an address for real-time transaction activity. Blocks until +``Ctrl+C`` or the server sends an ``unsubscribed`` event. + +.. 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. Pass ``--cashtoken`` to also show CashToken holdings. + +.. code-block:: bash + + bitcash wallet balance [--currency satoshi] [--cashtoken] + +Example: + +.. code-block:: bash + + $ 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 +^^^^^^^^^^^^^^^^ + +Watch a stored wallet's address for real-time activity. + +.. code-block:: bash + + bitcash wallet subscribe [--show-balance] + +wallet send +^^^^^^^^^^^ + +Send BCH (and optionally CashTokens) from a stored wallet. + +.. code-block:: bash + + bitcash wallet send \ + [--fee N] [--message TEXT] \ + [--category-id HEX] [--nft-capability none|mutable|minting] \ + [--nft-commitment HEX] [--token-amount N] \ + [--password PASSWORD] + +Example: + +.. code-block:: bash + + $ 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 +^^^^^^^^^^^^^ + +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/requirements.txt b/requirements.txt index d6e1198..49fc201 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ --e . +-e .[cli] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..9c68d71 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,933 @@ +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 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, +) + + +@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 + + +# --------------------------------------------------------------------------- +# 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 + + +# --------------------------------------------------------------------------- +# 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 +# --------------------------------------------------------------------------- + + +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