diff --git a/README.md b/README.md index 107ea21..728260f 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,13 @@ Then run: kaleido setup ``` -`kaleido setup` walks you through either: +`kaleido setup` creates and starts one mutinynet node with defaults. You can also run: -- a market-only setup that works without Docker -- a local-node setup that creates a Docker environment for you +```bash +kaleido setup signetcustom +``` + +Use `kaleido setup --mode market --defaults` for a market-only setup that works without Docker. ### Alternative installers @@ -99,8 +102,8 @@ If you want to configure manually, use: ```bash kaleido config show # view current config -kaleido config set api-url https://api.kaleidoswap.com -kaleido config set network signet +kaleido config set api-url https://api.signet.kaleidoswap.com/ +kaleido config set network mutinynet kaleido config reset # reset to defaults ``` @@ -108,10 +111,10 @@ Override per-command with flags or environment variables: ```bash kaleido --node-url http://localhost:3001 wallet balance -kaleido --api-url https://api.kaleidoswap.com market pairs +kaleido --api-url https://api.signet.kaleidoswap.com/ market pairs export KALEIDO_NODE_URL=http://localhost:3001 -export KALEIDO_API_URL=https://api.kaleidoswap.com +export KALEIDO_API_URL=https://api.signet.kaleidoswap.com/ ``` Valid config keys: `api-url`, `node-url`, `network`, `spawn-dir` @@ -120,10 +123,11 @@ Valid config keys: `api-url`, `node-url`, `network`, `spawn-dir` ## Node Environments -The CLI uses a **named environment** model. Each environment is an isolated Docker Compose setup with its own compose file and data volumes stored under a base directory (default: `~/.kaleido/spawn/`). +The CLI uses a **named environment** model. Each environment is an isolated Docker Compose setup with its own compose file and data volumes stored under `~/.kaleido/` by default. ``` -~/.kaleido/spawn/ +~/.kaleido/ +├── config.json ├── mainenv/ │ ├── docker-compose.yml │ └── volumes/ @@ -138,17 +142,21 @@ The CLI uses a **named environment** model. Each environment is an isolated Dock ### Creating an environment ```bash +kaleido setup # create/start one mutinynet node with defaults +kaleido setup signetcustom # create/start one node on an explicit network kaleido node create # or give it a name directly: kaleido node create testenv ``` +`mutinynet`, `signetcustom`, and `customsignet` are accepted as aliases for the Kaleidoswap custom signet RLN network. + The wizard prompts for: -1. **Base directory** — where all environments are stored (saved to config as `spawn-dir`) +1. **Base directory** — where all environments are stored (default: `~/.kaleido`, saved to config as `spawn-dir`) 2. **Environment name** — becomes a subdirectory under the base dir 3. **Node count** — number of RGB Lightning Nodes to spin up -4. **Network** — `regtest`, `signet`, or `mainnet` +4. **Network** — `mutinynet` (default), `signetcustom`/`customsignet`, `signet`, `regtest`, or `mainnet` 5. **Node ports** — base daemon API port (3001+) and LDK peer port (9735+) 6. **Start now** — whether to bring containers up immediately @@ -179,9 +187,9 @@ kaleido node use testenv --node 2 # use node 2 of 'testenv' (port 3002) `kaleido node list` marks the currently active node with `●`: ``` -Environments in ~/.kaleido/spawn: +Environments in ~/.kaleido: - testenv → ~/.kaleido/spawn/testenv + testenv → ~/.kaleido/testenv ● node 1: http://localhost:3001 ○ node 2: http://localhost:3002 ``` @@ -211,7 +219,7 @@ kaleido --json market pairs | Command | Description | |-------------------------------------------|-----------------------------------------------------| -| `kaleido setup` | Guided first-run setup for market-only or local use | +| `kaleido setup [network]` | Create/start one node, mutinynet by default | | `kaleido node create [name]` | Wizard: configure and generate a named environment | | `kaleido node list` | List all environments with node URLs | | `kaleido node use [--node N]` | Set node-url to node N in an environment | @@ -224,9 +232,8 @@ kaleido --json market pairs | `kaleido node init` | Initialise node wallet (once after first start) | | `kaleido node unlock` | Unlock wallet (after every restart) | | `kaleido node lock` | Lock the wallet | -| `kaleido node info` | Show detailed node + network info | -| `kaleido node taker pubkey` | Show the node's taker public key | -| `kaleido node taker whitelist ` | Whitelist a swap on the taker side | +| `kaleido node info` | Show node info from `/nodeinfo` | +| `kaleido node network` | Show network info from `/networkinfo` | ### `wallet` — BTC wallet @@ -287,7 +294,7 @@ kaleido market quote BTC/USDT --from-amount 100000 --from-layer BTC_LN --to-laye - `kaleido swap order ...` for maker swap-order flows on the Kaleidoswap server - `kaleido swap atomic ...` for atomic swaps against the Kaleidoswap maker server -- `kaleido swap node ...` for low-level local RLN node swap flows +- `kaleido node swap ...` for low-level local RLN node swap flows | Command | Description | |-------------------------------------|----------------------------------------------------------| @@ -299,11 +306,12 @@ kaleido market quote BTC/USDT --from-amount 100000 --from-layer BTC_LN --to-laye | `kaleido swap atomic init ` | Initialize an atomic swap against the maker server | | `kaleido swap atomic execute` | Execute an atomic swap against the maker server | | `kaleido swap atomic status ` | Check atomic swap status against the maker server | -| `kaleido swap node init` | Initialize a low-level local node swap | -| `kaleido swap node whitelist` | Whitelist a swap on the local taker node | -| `kaleido swap node execute` | Execute a low-level local node swap | -| `kaleido swap node status ` | Check local node swap status by payment hash | -| `kaleido swap node list` | List swaps known to the local RLN node | +| `kaleido node swap pubkey` | Show the local node's taker public key | +| `kaleido node swap init` | Initialize a low-level local node swap | +| `kaleido node swap whitelist` | Whitelist a swap on the local taker node | +| `kaleido node swap execute` | Execute a low-level local node swap | +| `kaleido node swap status ` | Check local node swap status by payment hash | +| `kaleido node swap list` | List swaps known to the local RLN node | ### `config` — CLI configuration @@ -322,17 +330,17 @@ kaleido market quote BTC/USDT --from-amount 100000 --from-layer BTC_LN --to-laye # 1. Install curl -fsSL https://raw.githubusercontent.com/kaleidoswap/kaleido-cli/master/install.sh | sh -# 2. Run the guided setup +# 2. Create/start one mutinynet node kaleido setup -# 3. If you chose a local node, initialise and unlock the wallet +# 3. Initialise and unlock the wallet kaleido node init kaleido node unlock -# 4. If you chose a local node, confirm it is healthy +# 4. Confirm the node is healthy kaleido node info -# 5. If you chose a local node, get a funding address +# 5. Get a funding address kaleido wallet address # 6. Browse available trading pairs @@ -342,10 +350,10 @@ kaleido market pairs kaleido market quote BTC/USDT --from-amount 100000 ``` -For a non-interactive local setup with defaults: +For a non-interactive local setup with mutinynet defaults: ```bash -kaleido setup --mode local --create-node --defaults +kaleido setup ``` ### Working with multiple nodes diff --git a/kaleido_cli/app.py b/kaleido_cli/app.py index 42c403b..9b14139 100644 --- a/kaleido_cli/app.py +++ b/kaleido_cli/app.py @@ -6,7 +6,7 @@ import typer -from .config import load_config +from .config import DEFAULT_API_URL, DEFAULT_NETWORK, load_config from .context import state from .onboarding import SetupMode, run_setup from .output import set_agent_mode, set_json_mode @@ -20,7 +20,8 @@ help=( "Manage RGB Lightning Nodes and interact with the Kaleidoswap protocol.\n\n" "[bold]First time here?[/bold]\n\n" - " [cyan]kaleido setup[/cyan] Guided setup for market-only or local-node use\n\n" + " [cyan]kaleido setup[/cyan] Create/start one mutinynet node with defaults\n" + " [cyan]kaleido setup signetcustom[/cyan] Create/start a node on a specific network\n\n" "[bold]Global flags[/bold] can be placed before any sub-command:\n\n" " [cyan]kaleido --node-url http://localhost:3001 wallet balance[/cyan]\n" " [cyan]kaleido --json market pairs[/cyan]\n" @@ -77,15 +78,23 @@ def _root( "setup", epilog=( "[bold]Examples[/bold]\n\n" - " Interactive first-run setup:\n" + " Create and start one mutinynet node with defaults:\n" " [cyan]kaleido setup[/cyan]\n\n" + " Create and start one node on an explicit network:\n" + " [cyan]kaleido setup signetcustom[/cyan]\n\n" " Market-only defaults without prompts:\n" " [cyan]kaleido setup --mode market --defaults[/cyan]\n\n" - " Create and start a local node environment with defaults:\n" - " [cyan]kaleido setup --mode local --create-node --defaults[/cyan]" + " Use a custom environment name:\n" + " [cyan]kaleido setup mutinynet --env-name taker-1[/cyan]" ), ) def setup_command( + network: Annotated[ + str | None, + typer.Argument( + help="Bitcoin network for the local node. Defaults to mutinynet.", + ), + ] = None, mode: Annotated[ SetupMode | None, typer.Option("--mode", help="Setup profile: 'market' or 'local'."), @@ -101,10 +110,6 @@ def setup_command( str | None, typer.Option("--api-url", help="Kaleidoswap API URL to save in config."), ] = None, - network: Annotated[ - str | None, - typer.Option("--network", help="Bitcoin network to save in config."), - ] = None, node_url: Annotated[ str | None, typer.Option("--node-url", help="RGB Lightning Node URL to save in config."), @@ -133,14 +138,20 @@ def setup_command( typer.Option("--start/--no-start", help="Start the node environment after creating it."), ] = None, ) -> None: - """Guide first-time configuration and optionally create a local node environment.""" + """Create a local mutinynet node by default, or run the selected setup profile.""" + resolved_mode = mode or SetupMode.local + resolved_defaults = defaults or mode is None + resolved_create_node = create_node + if mode is None and resolved_create_node is None: + resolved_create_node = True + run_setup( - mode=mode, - defaults=defaults, - api_url=api_url, - network=network, + mode=resolved_mode, + defaults=resolved_defaults, + api_url=api_url or (DEFAULT_API_URL if resolved_mode == SetupMode.local else None), + network=network or (DEFAULT_NETWORK if resolved_mode == SetupMode.local else None), node_url=node_url, - create_node=create_node, + create_node=resolved_create_node, spawn_dir=spawn_dir, env_name=env_name, node_count=node_count, @@ -162,9 +173,7 @@ def setup_command( from .commands.swap import swap_app # noqa: E402 from .commands.wallet import wallet_app # noqa: E402 -app.add_typer( - node_app, name="node", help="Manage the RLN node via Docker (start, stop, spawn, init…)." -) +app.add_typer(node_app, name="node", help="Manage the RLN node via Docker (start, stop, init…).") app.add_typer( wallet_app, name="wallet", help="BTC wallet — balance, addresses, send, UTXOs, backup, restore." ) @@ -186,7 +195,7 @@ def setup_command( app.add_typer( swap_app, name="swap", - help="Swap flows grouped by scope: maker order flow, maker atomic flow, and local node flow.", + help="Swap flows grouped by scope: maker order flow and maker atomic flow.", ) app.add_typer(config_app, name="config", help="CLI configuration stored in ~/.kaleido/config.json.") diff --git a/kaleido_cli/commands/channel.py b/kaleido_cli/commands/channel.py index b2b90c4..be881ee 100644 --- a/kaleido_cli/commands/channel.py +++ b/kaleido_cli/commands/channel.py @@ -3,19 +3,13 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass -from datetime import datetime, timezone -from typing import Annotated, Any +from typing import Annotated import typer from kaleido_sdk import ( ChannelOrderResponse, - CreateOrderRequest, - EstimateFeesRequest, - EstimateFeesResponse, LspInfoResponse, NetworkInfoResponse, - OrderRequest, RateDecisionRequest, RateDecisionResponse, ) @@ -24,6 +18,8 @@ ListChannelsResponse, OpenChannelRequest, OpenChannelResponse, + SendPaymentRequest, + SendPaymentResponse, ) from kaleido_cli.context import get_client @@ -33,10 +29,26 @@ output_collection, output_model, print_error, + print_info, print_json, - print_panel, print_success, ) +from kaleido_cli.utils.channel_orders import ( + CHANNEL_ORDER_HTTP_TIMEOUT, + _attach_client_asset_quote, + _autofill_refund_address, + _can_pay_channel_order, + _channel_wallet_payment_summary, + _create_channel_order, + _ensure_lsp_peer_connected, + _estimate_channel_order_fees, + _get_channel_order, + _print_channel_order_fees, + _print_lsp_info, + _resolve_channel_fee_estimate_params, + _resolve_channel_order_params, + _timed_step, +) from kaleido_cli.utils.prompts import resolve_required_text channel_app = typer.Typer( @@ -47,7 +59,7 @@ order_app = typer.Typer( no_args_is_help=True, rich_markup_mode="rich", - help="LSP-backed channel order flow: create, inspect, decide, and estimate fees.", + help="LSP-backed channel order flow: create, inspect, pay, decide, and estimate fees.", ) lsp_app = typer.Typer( no_args_is_help=True, @@ -58,366 +70,6 @@ channel_app.add_typer(order_app, name="order") channel_app.add_typer(lsp_app, name="lsp") -CHANNEL_LSP_CREATE_ORDER_PATH = "/api/v1/lsps1/create_order" -CHANNEL_LSP_GET_ORDER_PATH = "/api/v1/lsps1/get_order" - - -@dataclass(slots=True) -class ChannelOrderParams: - client_pubkey: str - lsp_balance_sat: int - client_balance_sat: int - required_channel_confirmations: int - funding_confirms_within_blocks: int - channel_expiry_blocks: int - token: str | None - refund_onchain_address: str | None - announce_channel: bool - asset_id: str | None - lsp_asset_amount: int | None - client_asset_amount: int | None - rfq_id: str | None - email: str | None - - -@dataclass(slots=True) -class ChannelFeeEstimateParams: - lsp_balance_sat: int - client_balance_sat: int - channel_expiry_blocks: int - token: str | None - asset_id: str | None - lsp_asset_amount: int | None - client_asset_amount: int | None - rfq_id: str | None - - -def _parse_iso_datetime(value: str) -> datetime | None: - candidate = value - if candidate.endswith("Z"): - candidate = f"{candidate[:-1]}+00:00" - try: - return datetime.fromisoformat(candidate) - except ValueError: - return None - - -def _normalize_channel_lsp_datetimes(value: Any, key: str | None = None) -> Any: - if isinstance(value, dict): - return {k: _normalize_channel_lsp_datetimes(v, k) for k, v in value.items()} - if isinstance(value, list): - return [_normalize_channel_lsp_datetimes(item, key) for item in value] - if key is not None and key.endswith("_at") and isinstance(value, str): - parsed = _parse_iso_datetime(value) - if parsed is not None and parsed.tzinfo is None: - return parsed.replace(tzinfo=timezone.utc).isoformat() - return value - - -async def _post_channel_lsp(client: Any, path: str, body: Any) -> dict[str, Any]: - data = await client.maker._http.maker_post(path, data=body) - if not isinstance(data, dict): - raise TypeError(f"Unexpected channel LSP response type for {path}: {type(data).__name__}") - return data - - -def _normalize_channel_order_response(data: dict[str, Any]) -> ChannelOrderResponse: - return ChannelOrderResponse.model_validate(_normalize_channel_lsp_datetimes(data)) - - -async def _submit_channel_order(client: Any, body: CreateOrderRequest) -> ChannelOrderResponse: - data = await _post_channel_lsp(client, CHANNEL_LSP_CREATE_ORDER_PATH, body) - return _normalize_channel_order_response(data) - - -async def _fetch_channel_order(client: Any, body: OrderRequest) -> ChannelOrderResponse: - data = await _post_channel_lsp(client, CHANNEL_LSP_GET_ORDER_PATH, body) - return _normalize_channel_order_response(data) - - -def _prompt_optional_text(prompt: str) -> str | None: - raw = typer.prompt(prompt, default="") - return raw.strip() or None - - -def _prompt_optional_int(prompt: str) -> int | None: - raw = typer.prompt(prompt, default="") - if raw.strip() == "": - return None - try: - return int(raw) - except ValueError: - print_error(f"{prompt} must be an integer.") - raise typer.Exit(1) - - -def _normalize_optional_text(value: str | None) -> str | None: - if value is None: - return None - stripped = value.strip() - return stripped or None - - -def _resolve_channel_order_params( - *, - client_pubkey: str | None, - lsp_balance_sat: int | None, - client_balance_sat: int | None, - required_channel_confirmations: int, - funding_confirms_within_blocks: int, - channel_expiry_blocks: int, - token: str | None, - refund_onchain_address: str | None, - announce_channel: bool, - asset_id: str | None, - lsp_asset_amount: int | None, - client_asset_amount: int | None, - rfq_id: str | None, - email: str | None, -) -> ChannelOrderParams: - resolved_client_pubkey: str - if client_pubkey is not None: - resolved_client_pubkey = client_pubkey - elif is_interactive(): - resolved_client_pubkey = typer.prompt("Client Lightning node public key") - else: - print_error("CLIENT_PUBKEY argument is required in non-interactive mode.") - raise typer.Exit(1) - - resolved_lsp_balance_sat: int - if lsp_balance_sat is not None: - resolved_lsp_balance_sat = lsp_balance_sat - elif is_interactive(): - resolved_lsp_balance_sat = typer.prompt("LSP balance in channel (satoshis)", type=int) - else: - print_error("--lsp-balance is required in non-interactive mode.") - raise typer.Exit(1) - - resolved_client_balance_sat: int - if client_balance_sat is not None: - resolved_client_balance_sat = client_balance_sat - elif is_interactive(): - resolved_client_balance_sat = typer.prompt("Client balance in channel (satoshis)", type=int) - else: - print_error("--client-balance is required in non-interactive mode.") - raise typer.Exit(1) - - if is_interactive(): - required_channel_confirmations = typer.prompt( - "Required channel confirmations", - type=int, - default=required_channel_confirmations, - ) - funding_confirms_within_blocks = typer.prompt( - "Funding confirms within blocks", - type=int, - default=funding_confirms_within_blocks, - ) - channel_expiry_blocks = typer.prompt( - "Channel expiry blocks", - type=int, - default=channel_expiry_blocks, - ) - - resolved_token = _normalize_optional_text(token) - resolved_refund_onchain_address = _normalize_optional_text(refund_onchain_address) - resolved_asset_id = _normalize_optional_text(asset_id) - resolved_rfq_id = _normalize_optional_text(rfq_id) - resolved_email = _normalize_optional_text(email) - - if is_interactive(): - if resolved_asset_id is None and typer.confirm( - "Attach an RGB asset to the channel order?", default=False - ): - resolved_asset_id = _prompt_optional_text("Asset ID (rgb:...)") - - if resolved_asset_id is not None: - if lsp_asset_amount is None: - lsp_asset_amount = _prompt_optional_int( - "[OPTIONAL] LSP RGB asset amount (Enter to skip)" - ) - if client_asset_amount is None: - client_asset_amount = _prompt_optional_int( - "[OPTIONAL] Client RGB asset amount (Enter to skip)" - ) - else: - lsp_asset_amount = None - client_asset_amount = None - - announce_channel = typer.confirm("Announce channel publicly?", default=announce_channel) - - if resolved_token is None: - resolved_token = _prompt_optional_text( - "[OPTIONAL] Authentication token (Enter to skip)" - ) - if resolved_refund_onchain_address is None: - resolved_refund_onchain_address = _prompt_optional_text( - "[OPTIONAL] Refund onchain address (Enter to skip)" - ) - if resolved_rfq_id is None: - resolved_rfq_id = _prompt_optional_text("[OPTIONAL] RFQ ID (Enter to skip)") - if resolved_email is None: - resolved_email = _prompt_optional_text("[OPTIONAL] Contact email (Enter to skip)") - - if ( - lsp_asset_amount is not None or client_asset_amount is not None - ) and resolved_asset_id is None: - print_error("--lsp-asset-amount and --client-asset-amount require --asset-id.") - raise typer.Exit(1) - - return ChannelOrderParams( - client_pubkey=resolved_client_pubkey, - lsp_balance_sat=resolved_lsp_balance_sat, - client_balance_sat=resolved_client_balance_sat, - required_channel_confirmations=required_channel_confirmations, - funding_confirms_within_blocks=funding_confirms_within_blocks, - channel_expiry_blocks=channel_expiry_blocks, - token=resolved_token, - refund_onchain_address=resolved_refund_onchain_address, - announce_channel=announce_channel, - asset_id=resolved_asset_id, - lsp_asset_amount=lsp_asset_amount, - client_asset_amount=client_asset_amount, - rfq_id=resolved_rfq_id, - email=resolved_email, - ) - - -def _build_channel_order_request(params: ChannelOrderParams) -> CreateOrderRequest: - return CreateOrderRequest( - client_pubkey=params.client_pubkey, - lsp_balance_sat=params.lsp_balance_sat, - client_balance_sat=params.client_balance_sat, - required_channel_confirmations=params.required_channel_confirmations, - funding_confirms_within_blocks=params.funding_confirms_within_blocks, - channel_expiry_blocks=params.channel_expiry_blocks, - token=params.token, - refund_onchain_address=params.refund_onchain_address, - announce_channel=params.announce_channel, - asset_id=params.asset_id, - lsp_asset_amount=params.lsp_asset_amount, - client_asset_amount=params.client_asset_amount, - rfq_id=params.rfq_id, - email=params.email, - ) - - -async def _create_channel_order(client: Any, params: ChannelOrderParams) -> ChannelOrderResponse: - return await _submit_channel_order(client, _build_channel_order_request(params)) - - -async def _get_channel_order( - client: Any, order_id: str, access_token: str = "" -) -> ChannelOrderResponse: - return await _fetch_channel_order( - client, - OrderRequest(order_id=order_id, access_token=access_token), - ) - - -async def _estimate_channel_order_fees( - client: Any, params: ChannelFeeEstimateParams -) -> EstimateFeesResponse: - body = EstimateFeesRequest( - lsp_balance_sat=params.lsp_balance_sat, - client_balance_sat=params.client_balance_sat, - channel_expiry_blocks=params.channel_expiry_blocks, - token=params.token, - asset_id=params.asset_id, - lsp_asset_amount=params.lsp_asset_amount, - client_asset_amount=params.client_asset_amount, - rfq_id=params.rfq_id, - ) - return await client.maker.estimate_lsp_fees(body) - - -def _print_channel_order_fees(resp: EstimateFeesResponse, *, title: str) -> None: - output_model(resp, title=title) - - -def _resolve_channel_fee_estimate_params( - *, - lsp_balance_sat: int | None, - client_balance_sat: int | None, - channel_expiry_blocks: int, - token: str | None, - asset_id: str | None, - lsp_asset_amount: int | None, - client_asset_amount: int | None, - rfq_id: str | None, -) -> ChannelFeeEstimateParams: - resolved_lsp_balance_sat: int - if lsp_balance_sat is not None: - resolved_lsp_balance_sat = lsp_balance_sat - elif is_interactive(): - resolved_lsp_balance_sat = typer.prompt("LSP balance in channel (satoshis)", type=int) - else: - print_error("--lsp-balance is required in non-interactive mode.") - raise typer.Exit(1) - - resolved_client_balance_sat: int - if client_balance_sat is not None: - resolved_client_balance_sat = client_balance_sat - elif is_interactive(): - resolved_client_balance_sat = typer.prompt("Client balance in channel (satoshis)", type=int) - else: - print_error("--client-balance is required in non-interactive mode.") - raise typer.Exit(1) - - if is_interactive(): - channel_expiry_blocks = typer.prompt( - "Channel expiry blocks", - type=int, - default=channel_expiry_blocks, - ) - - resolved_token = _normalize_optional_text(token) - resolved_asset_id = _normalize_optional_text(asset_id) - resolved_rfq_id = _normalize_optional_text(rfq_id) - - if is_interactive(): - if resolved_asset_id is None and typer.confirm( - "Estimate fees for an RGB-backed channel?", default=False - ): - resolved_asset_id = _prompt_optional_text("Asset ID (rgb:...)") - - if resolved_asset_id is not None: - if lsp_asset_amount is None: - lsp_asset_amount = _prompt_optional_int( - "[OPTIONAL] LSP RGB asset amount (Enter to skip)" - ) - if client_asset_amount is None: - client_asset_amount = _prompt_optional_int( - "[OPTIONAL] Client RGB asset amount (Enter to skip)" - ) - else: - lsp_asset_amount = None - client_asset_amount = None - - if resolved_token is None: - resolved_token = _prompt_optional_text( - "[OPTIONAL] Authentication token (Enter to skip)" - ) - if resolved_rfq_id is None: - resolved_rfq_id = _prompt_optional_text("[OPTIONAL] RFQ ID (Enter to skip)") - - if ( - lsp_asset_amount is not None or client_asset_amount is not None - ) and resolved_asset_id is None: - print_error("--lsp-asset-amount and --client-asset-amount require --asset-id.") - raise typer.Exit(1) - - return ChannelFeeEstimateParams( - lsp_balance_sat=resolved_lsp_balance_sat, - client_balance_sat=resolved_client_balance_sat, - channel_expiry_blocks=channel_expiry_blocks, - token=resolved_token, - asset_id=resolved_asset_id, - lsp_asset_amount=lsp_asset_amount, - client_asset_amount=client_asset_amount, - rfq_id=resolved_rfq_id, - ) - @channel_app.command("list") def channel_list() -> None: @@ -690,19 +342,19 @@ async def _channel_close(channel_id: str, peer_pubkey: str, force: bool) -> None epilog=( "[bold]Examples[/bold]\n\n" " Create a basic channel order:\n" - " [cyan]kaleido channel order create 03abc... --lsp-balance 1000000 --client-balance 500000[/cyan]\n\n" + " [cyan]kaleido channel order create --lsp-balance 1000000 --client-balance 500000[/cyan]\n\n" " RGB colored channel order:\n" - " [cyan]kaleido channel order create 03abc... --lsp-balance 1000000 --client-balance 500000 \\'\n" + " [cyan]kaleido channel order create --lsp-balance 1000000 --client-balance 500000 \\'\n" " --asset-id rgb:xyz... --lsp-asset-amount 5000 --client-asset-amount 2000[/cyan]\n\n" " With custom confirmations and refund address:\n" - " [cyan]kaleido channel order create 03abc... --lsp-balance 1000000 --client-balance 500000 \\'\n" + " [cyan]kaleido channel order create --lsp-balance 1000000 --client-balance 500000 \\'\n" " --confirmations 3 --refund-address bc1q...[/cyan]" ), ) def channel_order_create( client_pubkey: Annotated[ str | None, - typer.Argument(help="Client Lightning node public key."), + typer.Argument(help="Client Lightning node public key. Defaults to local node pubkey."), ] = None, lsp_balance_sat: Annotated[ int | None, @@ -730,10 +382,12 @@ def channel_order_create( int, typer.Option("--expiry-blocks", help="Channel expiry in blocks (must be at least 1)."), ] = 1, - token: Annotated[str | None, typer.Option("--token", help="Authentication token.")] = None, refund_onchain_address: Annotated[ str | None, - typer.Option("--refund-address", help="Bitcoin address for refunds."), + typer.Option( + "--refund-address", + help="Bitcoin address for refunds. Defaults to a local node address.", + ), ] = None, announce_channel: Annotated[ bool, @@ -745,48 +399,98 @@ def channel_order_create( ] = None, lsp_asset_amount: Annotated[ int | None, - typer.Option("--lsp-asset-amount", help="LSP's RGB asset amount."), + typer.Option( + "--lsp-asset-amount", help="LSP's RGB asset amount. Required with --asset-id." + ), ] = None, client_asset_amount: Annotated[ int | None, typer.Option("--client-asset-amount", help="Client's RGB asset amount."), ] = None, - rfq_id: Annotated[ - str | None, - typer.Option("--rfq-id", help="Request for quote ID."), - ] = None, email: Annotated[str | None, typer.Option("--email", help="Contact email.")] = None, + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Accept an automatically fetched RFQ price."), + ] = False, ) -> None: """Create an LSP channel order.""" - params = _resolve_channel_order_params( - client_pubkey=client_pubkey, - lsp_balance_sat=lsp_balance_sat, - client_balance_sat=client_balance_sat, - required_channel_confirmations=required_channel_confirmations, - funding_confirms_within_blocks=funding_confirms_within_blocks, - channel_expiry_blocks=channel_expiry_blocks, - token=token, - refund_onchain_address=refund_onchain_address, - announce_channel=announce_channel, - asset_id=asset_id, - lsp_asset_amount=lsp_asset_amount, - client_asset_amount=client_asset_amount, - rfq_id=rfq_id, - email=email, + asyncio.run( + _channel_order_create_flow( + client_pubkey=client_pubkey, + lsp_balance_sat=lsp_balance_sat, + client_balance_sat=client_balance_sat, + required_channel_confirmations=required_channel_confirmations, + funding_confirms_within_blocks=funding_confirms_within_blocks, + channel_expiry_blocks=channel_expiry_blocks, + refund_onchain_address=refund_onchain_address, + announce_channel=announce_channel, + asset_id=asset_id, + lsp_asset_amount=lsp_asset_amount, + client_asset_amount=client_asset_amount, + email=email, + yes=yes, + ) ) - asyncio.run(_channel_order_create(params)) - -async def _channel_order_create(params) -> None: +async def _channel_order_create_flow( + *, + client_pubkey: str | None, + lsp_balance_sat: int | None, + client_balance_sat: int | None, + required_channel_confirmations: int, + funding_confirms_within_blocks: int, + channel_expiry_blocks: int, + refund_onchain_address: str | None, + announce_channel: bool, + asset_id: str | None, + lsp_asset_amount: int | None, + client_asset_amount: int | None, + email: str | None, + yes: bool, +) -> None: try: - client = get_client() - resp: ChannelOrderResponse = await _create_channel_order(client, params) + client = get_client( + require_node=True, + timeout=CHANNEL_ORDER_HTTP_TIMEOUT, + max_retries=0, + ) + node_info = await _timed_step("Fetching local node info", client.rln.get_node_info()) + lsp_info = await _timed_step("Fetching LSP info", client.maker.get_lsp_info()) + params = _resolve_channel_order_params( + client_pubkey=client_pubkey, + default_client_pubkey=node_info.pubkey, + lsp_info=lsp_info, + lsp_balance_sat=lsp_balance_sat, + client_balance_sat=client_balance_sat, + required_channel_confirmations=required_channel_confirmations, + funding_confirms_within_blocks=funding_confirms_within_blocks, + channel_expiry_blocks=channel_expiry_blocks, + refund_onchain_address=refund_onchain_address, + announce_channel=announce_channel, + asset_id=asset_id, + lsp_asset_amount=lsp_asset_amount, + client_asset_amount=client_asset_amount, + email=email, + ) + await _autofill_refund_address(client, params) + await _attach_client_asset_quote(client, params, yes=yes) + await _ensure_lsp_peer_connected(client, lsp_info) + resp: ChannelOrderResponse = await _timed_step( + "Submitting LSP channel order", + _create_channel_order(client, params), + ) if is_json_mode(): print_json(resp.model_dump()) else: print_success(f"LSP order created: {resp.order_id}") output_model(resp, title="Channel Order") + if _can_pay_channel_order(resp): + print_info( + f"Pay from local wallet funds with: kaleido channel order pay {resp.order_id}" + ) + except typer.Exit: + raise except Exception as e: print_error(f"Error: {e}") raise typer.Exit(1) @@ -804,12 +508,12 @@ def channel_order_get( order_id: Annotated[str | None, typer.Argument(help="LSP order ID.")] = None, access_token: Annotated[ str | None, - typer.Option("--access-token", help="Access token returned for the order."), + typer.Option("--access-token", help="Optional access token returned for the order."), ] = None, ) -> None: """Get the status and details of an LSP channel order.""" resolved_order_id = resolve_required_text(order_id, "LSP order ID", "ORDER_ID argument") - resolved_access_token = resolve_required_text(access_token, "Access token", "--access-token") + resolved_access_token = access_token or "" asyncio.run(_channel_order_get(resolved_order_id, resolved_access_token)) @@ -827,6 +531,101 @@ async def _channel_order_get(order_id: str, access_token: str) -> None: raise typer.Exit(1) +@order_app.command( + "pay", + epilog=( + "[bold]Examples[/bold]\n\n" + " Pay an order from local wallet funds:\n" + " [cyan]kaleido channel order pay [/cyan]\n\n" + " Non-interactive payment:\n" + " [cyan]kaleido channel order pay --yes[/cyan]" + ), +) +def channel_order_pay( + order_id: Annotated[str | None, typer.Argument(help="LSP order ID.")] = None, + access_token: Annotated[ + str | None, + typer.Option("--access-token", help="Optional access token returned for the order."), + ] = None, + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Pay the order invoice without confirmation."), + ] = False, +) -> None: + """Pay an LSP channel order with local wallet funds.""" + resolved_order_id = resolve_required_text(order_id, "LSP order ID", "ORDER_ID argument") + asyncio.run(_channel_order_pay(resolved_order_id, access_token or "", yes=yes)) + + +async def _channel_order_pay(order_id: str, access_token: str, *, yes: bool) -> None: + try: + client = get_client( + require_node=True, + timeout=CHANNEL_ORDER_HTTP_TIMEOUT, + max_retries=0, + ) + order = await _timed_step( + f"Fetching LSP order {order_id}", + _get_channel_order(client, order_id, access_token), + ) + if is_json_mode(): + if not yes: + print_error("--yes is required in JSON mode to pay the order.") + raise typer.Exit(1) + else: + output_model(_channel_wallet_payment_summary(order), title="Wallet Payment") + if not _can_pay_channel_order(order): + if is_json_mode(): + print_json(order.model_dump()) + else: + print_info( + "This order is not awaiting a wallet payment. Current payment state: " + f"{order.payment.bolt11.state}" + ) + output_model(order, title=f"Order {order_id}") + return + if is_interactive() and not yes: + confirmed = typer.confirm( + ( + "Pay this order from local wallet funds " + f"({order.payment.bolt11.order_total_sat} sat + " + f"{order.payment.bolt11.fee_total_sat} sat fee)?" + ), + default=False, + ) + if not confirmed: + print_error("Channel order payment cancelled.") + raise typer.Exit(0) + elif not yes: + print_error("--yes is required in non-interactive mode to pay the order.") + raise typer.Exit(1) + + payment_resp: SendPaymentResponse = await _timed_step( + "Paying order invoice from local wallet funds", + client.rln.send_payment(SendPaymentRequest(invoice=order.payment.bolt11.invoice)), + ) + refreshed_order = await _timed_step( + f"Refreshing LSP order {order_id}", + _get_channel_order(client, order_id, access_token), + ) + if is_json_mode(): + print_json( + { + "payment": payment_resp.model_dump(), + "order": refreshed_order.model_dump(), + } + ) + else: + print_success(f"Wallet payment submitted for order {order_id}") + output_model(payment_resp, title="Payment Result") + output_model(refreshed_order, title=f"Order {order_id}") + except typer.Exit: + raise + except Exception as e: + print_error(f"Error: {e}") + raise typer.Exit(1) + + @order_app.command( "decide", epilog=( @@ -912,7 +711,9 @@ def channel_estimate_fees( ] = None, lsp_asset_amount: Annotated[ int | None, - typer.Option("--lsp-asset-amount", help="LSP's RGB asset amount."), + typer.Option( + "--lsp-asset-amount", help="LSP's RGB asset amount. Required with --asset-id." + ), ] = None, client_asset_amount: Annotated[ int | None, @@ -940,17 +741,19 @@ def channel_estimate_fees( rfq_id=rfq_id, ) - asyncio.run(_channel_estimate_fees(params)) + asyncio.run(_channel_estimate_fees_flow(params)) -async def _channel_estimate_fees(params) -> None: +async def _channel_estimate_fees_flow(params) -> None: try: client = get_client() - resp: EstimateFeesResponse = await _estimate_channel_order_fees(client, params) + resp = await _estimate_channel_order_fees(client, params) if is_json_mode(): print_json(resp.model_dump()) else: _print_channel_order_fees(resp, title="Estimated Fees") + except typer.Exit: + raise except Exception as e: print_error(f"Error: {e}") raise typer.Exit(1) @@ -978,43 +781,6 @@ async def _channel_lsp_info() -> None: raise typer.Exit(1) -def _humanize_key(key: str) -> str: - return key.replace("_", " ").capitalize() - - -def _short_id(value: str | None, *, prefix: int = 16, suffix: int = 8) -> str: - if not value: - return "-" - if len(value) <= prefix + suffix + 1: - return value - return f"{value[:prefix]}…{value[-suffix:]}" - - -def _print_lsp_info(resp: LspInfoResponse) -> None: - print_panel("LSP Connection", resp.lsp_connection_url or "-", style="blue") - - if resp.options is not None: - output_model( - {_humanize_key(key): value for key, value in resp.options.model_dump().items()}, - title="Channel Options", - ) - output_collection( - "LSP Assets", - [ - { - **asset.model_dump(), - "asset_id": _short_id(asset.asset_id), - "client_range": f"{asset.min_initial_client_amount} -> {asset.max_initial_client_amount}", - "lsp_range": f"{asset.min_initial_lsp_amount} -> {asset.max_initial_lsp_amount}", - "channel_range": f"{asset.min_channel_amount} -> {asset.max_channel_amount}", - } - for asset in (resp.assets or []) - ], - item_title="LSP Asset — {index}", - empty_msg="No asset-backed channel options reported.", - ) - - @lsp_app.command( "network-info", epilog=" [cyan]kaleido channel lsp network-info[/cyan] Show LSP network/node information.", diff --git a/kaleido_cli/commands/config_cmd.py b/kaleido_cli/commands/config_cmd.py index 426ee06..20491a4 100644 --- a/kaleido_cli/commands/config_cmd.py +++ b/kaleido_cli/commands/config_cmd.py @@ -9,6 +9,8 @@ from kaleido_cli.config import ( _KEY_ALIASES, CONFIG_FILE, + DEFAULT_API_URL, + DEFAULT_NETWORK, CliConfig, load_config, save_config, @@ -30,9 +32,9 @@ "Manage CLI configuration stored in [green]~/.kaleido/config.json[/green].\n\n" "[bold]Config keys[/bold]\n\n" " [green]node-url[/green] URL of your RGB Lightning Node (default: http://localhost:3001)\n" - " [green]api-url[/green] Kaleidoswap maker API URL (default: https://api.kaleidoswap.com)\n" - " [green]network[/green] Bitcoin network (default: signet)\n" - " [green]spawn-dir[/green] Directory for spawned nodes (default: ~/.kaleido/spawn)\n" + f" [green]api-url[/green] Kaleidoswap maker API URL (default: {DEFAULT_API_URL})\n" + f" [green]network[/green] Bitcoin network (default: {DEFAULT_NETWORK})\n" + " [green]spawn-dir[/green] Directory for node environments (default: ~/.kaleido)\n" ), ) @@ -50,7 +52,7 @@ def config_show() -> None: "api_url": config.api_url, "node_url": config.node_url, "network": config.network, - "spawn_dir": config.spawn_dir or "(default: ~/.kaleido/spawn)", + "spawn_dir": config.spawn_dir or "(default: ~/.kaleido)", }, title=f"Config ({CONFIG_FILE})", ) @@ -61,8 +63,8 @@ def config_show() -> None: epilog=( "[bold]Examples[/bold]\n\n" " [cyan]kaleido config set node-url http://localhost:3001[/cyan]\n" - " [cyan]kaleido config set api-url https://api.kaleidoswap.com[/cyan]\n" - " [cyan]kaleido config set network regtest[/cyan]\n" + f" [cyan]kaleido config set api-url {DEFAULT_API_URL}[/cyan]\n" + " [cyan]kaleido config set network mutinynet[/cyan]\n" " [cyan]kaleido config set spawn-dir ~/kaleido-nodes[/cyan]" ), ) diff --git a/kaleido_cli/commands/node.py b/kaleido_cli/commands/node.py index dcc9e22..23040ae 100644 --- a/kaleido_cli/commands/node.py +++ b/kaleido_cli/commands/node.py @@ -7,8 +7,17 @@ from typing import Annotated import typer -from kaleido_sdk.rln import TakerRequest +from kaleido_cli.commands.node_swap import node_swap_app +from kaleido_cli.config import ( + DEFAULT_BITCOIND_RPC_HOST, + DEFAULT_BITCOIND_RPC_PASSWORD, + DEFAULT_BITCOIND_RPC_PORT, + DEFAULT_BITCOIND_RPC_USERNAME, + DEFAULT_INDEXER_URL, + DEFAULT_NETWORK, + DEFAULT_PROXY_ENDPOINT, +) from kaleido_cli.context import get_client, state from kaleido_cli.docker_manager import ( DEFAULT_BASE_DAEMON_PORT, @@ -21,11 +30,9 @@ ) from kaleido_cli.output import ( is_interactive, - is_json_mode, output_model, print_error, print_info, - print_json, print_success, print_warning, ) @@ -36,6 +43,8 @@ help=( "Manage named RGB Lightning Node environments via Docker.\n\n" "[bold]Creating an environment:[/bold]\n" + " [cyan]kaleido setup[/cyan] — create one mutinynet node with defaults\n" + " [cyan]kaleido setup [/cyan] — create one node on a specific network\n" " [cyan]kaleido node create[/cyan] — wizard: configure ports, network\n\n" "[bold]Managing environments:[/bold]\n" " [cyan]kaleido node list[/cyan] — list all environments with their node URLs\n" @@ -52,24 +61,14 @@ " [cyan]kaleido node init[/cyan] — initialize node wallet (once)\n" " [cyan]kaleido node unlock[/cyan] — unlock wallet after restart\n" " [cyan]kaleido node shutdown[/cyan] — gracefully shut down the node process\n" - " [cyan]kaleido node info[/cyan] — check node reachability and details\n" + " [cyan]kaleido node info[/cyan] — show node details\n" + " [cyan]kaleido node network[/cyan] — show network height/details\n" + " [cyan]kaleido node swap pubkey[/cyan] — show the local taker pubkey\n" + " [cyan]kaleido node swap init[/cyan] — run low-level local node swap flows\n" ), ) -taker_app = typer.Typer( - no_args_is_help=True, - rich_markup_mode="rich", - help="Taker-side swap operations — identity and swap acceptance.", -) - -node_app.add_typer(taker_app, name="taker") - -DEFAULT_BITCOIND_USER = "user" -DEFAULT_BITCOIND_PASS = "password" -DEFAULT_BITCOIND_HOST = "regtest-bitcoind.rgbtools.org" -DEFAULT_BITCOIND_PORT = 80 -DEFAULT_INDEXER_URL = "electrum.rgbtools.org:50041" -DEFAULT_PROXY_ENDPOINT = "rpcs://proxy.iriswallet.com/0.2/json-rpc" +node_app.add_typer(node_swap_app, name="swap") # --------------------------------------------------------------------------- @@ -106,70 +105,6 @@ def _resolve_name(name: str | None) -> str: raise typer.Exit(1) -# --------------------------------------------------------------------------- -# Taker commands -# --------------------------------------------------------------------------- - - -@taker_app.command( - "pubkey", - epilog=" [cyan]kaleido node taker pubkey[/cyan] Print the node's taker public key.", -) -def taker_pubkey() -> None: - """Show the node's taker public key (used in swap operations).""" - asyncio.run(_taker_pubkey()) - - -async def _taker_pubkey() -> None: - try: - client = get_client(require_node=True) - pubkey = await client.rln.get_taker_pubkey() - if is_json_mode(): - print_json({"pubkey": pubkey}) - else: - print_success(f"Taker pubkey: {pubkey}") - except Exception as e: - print_error(f"Error: {e}") - raise typer.Exit(1) - - -@taker_app.command( - "whitelist", - epilog=( - "[bold]Examples[/bold]\n\n" - " Accept a swap offer from a maker:\n" - " [cyan]kaleido node taker whitelist '30/rgb:abc.../10/rgb:def.../...'[/cyan]" - ), -) -def taker_whitelist( - swapstring: Annotated[ - str | None, - typer.Argument(help="Swap string to accept on the taker side."), - ] = None, -) -> None: - """Whitelist (accept) a swap string from a maker on the taker side.""" - resolved: str - if swapstring is not None: - resolved = swapstring - elif is_interactive(): - resolved = typer.prompt("Swapstring") - else: - print_error("SWAPSTRING argument is required in non-interactive mode.") - raise typer.Exit(1) - - asyncio.run(_taker_whitelist(resolved)) - - -async def _taker_whitelist(swapstring: str) -> None: - try: - client = get_client(require_node=True) - await client.rln.whitelist_swap(TakerRequest(swapstring=swapstring)) - print_success("Swap whitelisted — taker accepted this offer.") - except Exception as e: - print_error(f"Error: {e}") - raise typer.Exit(1) - - # --------------------------------------------------------------------------- # Environment management # --------------------------------------------------------------------------- @@ -189,7 +124,9 @@ async def _taker_whitelist(swapstring: str) -> None: def node_create( name: Annotated[ str | None, - typer.Argument(help="Environment name (directory under spawn-dir). Prompted if omitted."), + typer.Argument( + help="Environment name (directory under ~/.kaleido by default). Prompted if omitted." + ), ] = None, ) -> None: """[bold]Wizard:[/bold] configure and generate a named compose environment.""" @@ -198,7 +135,7 @@ def node_create( print_info("\n Kaleido Node Create Wizard") print_info(" " + "─" * 38) - # ── Base spawn directory ───────────────────────────────────────────────── + # ── Base environment directory ─────────────────────────────────────────── default_base = str(state.config.spawn_dir or DEFAULT_SPAWN_DIR) spawn_base_input = typer.prompt( " Base directory for environments", @@ -208,7 +145,7 @@ def node_create( if str(base) != str(Path(default_base).expanduser().resolve()): state.config.spawn_dir = str(base) save_config(state.config) - print_info(f" Saved spawn-dir → {base}") + print_info(f" Saved environment base directory → {base}") # ── Environment name ───────────────────────────────────────────────────── resolved_name: str @@ -236,7 +173,7 @@ def node_create( count = typer.prompt(" How many RGB Lightning Nodes?", default=1, type=int) # ── Network ────────────────────────────────────────────────────────────── - network = typer.prompt(" Bitcoin network", default=state.config.network or "regtest") + network = typer.prompt(" Bitcoin network", default=state.config.network or DEFAULT_NETWORK) # ── Node ports ─────────────────────────────────────────────────────────── print_info(" ── Node ports ────────────────────────────────────────") @@ -538,7 +475,7 @@ def node_clean( @node_app.command("info") def node_info() -> None: - """Display detailed node information.""" + """Display node information from /nodeinfo.""" asyncio.run(_node_info()) @@ -546,8 +483,22 @@ async def _node_info() -> None: try: client = get_client(require_node=True) info = await client.rln.get_node_info() - net = await client.rln.get_network_info() output_model(info, title="Node Info") + except Exception as e: + print_error(f"Error: {e}") + raise typer.Exit(1) + + +@node_app.command("network") +def node_network() -> None: + """Display network information from /networkinfo.""" + asyncio.run(_node_network()) + + +async def _node_network() -> None: + try: + client = get_client(require_node=True) + net = await client.rln.get_network_info() output_model(net, title="Network Info") except Exception as e: print_error(f"Error: {e}") @@ -558,7 +509,7 @@ async def _node_info() -> None: "init", epilog=( "[bold]Examples[/bold]\n\n" - " Create a wallet password interactively:\n" + " Create a new wallet password interactively:\n" " [cyan]kaleido node init[/cyan]\n\n" " Pass password directly:\n" " [cyan]kaleido node init --password mysecret[/cyan]" @@ -570,7 +521,7 @@ def node_init( typer.Option( "--password", "-p", - help="New wallet password. Prompted securely if omitted.", + help="Wallet password. Prompted securely if omitted.", hide_input=True, ), ] = None, @@ -607,7 +558,7 @@ async def _node_init(password: str, mnemonic: str | None) -> None: "unlock", epilog=( "[bold]Examples[/bold]\n\n" - " Simple unlock (uses rgbtools.org defaults):\n" + " Simple unlock (uses Kaleidoswap mutinynet defaults):\n" " [cyan]kaleido node unlock[/cyan]\n\n" " Override bitcoind credentials:\n" " [cyan]kaleido node unlock --bitcoind-user alice --bitcoind-pass hunter2[/cyan]\n\n" @@ -638,19 +589,19 @@ def node_unlock( help="bitcoind RPC password.", hide_input=True, ), - ] = DEFAULT_BITCOIND_PASS, + ] = DEFAULT_BITCOIND_RPC_PASSWORD, bitcoind_user: Annotated[ str, typer.Option("--bitcoind-user", help="bitcoind RPC username."), - ] = DEFAULT_BITCOIND_USER, + ] = DEFAULT_BITCOIND_RPC_USERNAME, bitcoind_host: Annotated[ str, typer.Option("--bitcoind-host", help="bitcoind RPC host."), - ] = DEFAULT_BITCOIND_HOST, + ] = DEFAULT_BITCOIND_RPC_HOST, bitcoind_port: Annotated[ int, typer.Option("--bitcoind-port", help="bitcoind RPC port."), - ] = DEFAULT_BITCOIND_PORT, + ] = DEFAULT_BITCOIND_RPC_PORT, indexer_url: Annotated[ str, typer.Option("--indexer-url", help="Electrs indexer URL."), @@ -680,7 +631,7 @@ def node_unlock( if is_interactive(): use_defaults = typer.confirm( - "Use default rgbtools.org services (bitcoind, indexer, proxy)?", default=True + "Use default Kaleidoswap mutinynet services (bitcoind, indexer, proxy)?", default=True ) if not use_defaults: bitcoind_user = typer.prompt("bitcoind RPC username", default=bitcoind_user) @@ -692,10 +643,10 @@ def node_unlock( indexer_url = typer.prompt("Electrs indexer URL", default=indexer_url) proxy_endpoint = typer.prompt("RGB proxy endpoint", default=proxy_endpoint) else: - bitcoind_user = DEFAULT_BITCOIND_USER - bitcoind_pass = DEFAULT_BITCOIND_PASS - bitcoind_host = DEFAULT_BITCOIND_HOST - bitcoind_port = DEFAULT_BITCOIND_PORT + bitcoind_user = DEFAULT_BITCOIND_RPC_USERNAME + bitcoind_pass = DEFAULT_BITCOIND_RPC_PASSWORD + bitcoind_host = DEFAULT_BITCOIND_RPC_HOST + bitcoind_port = DEFAULT_BITCOIND_RPC_PORT indexer_url = DEFAULT_INDEXER_URL proxy_endpoint = DEFAULT_PROXY_ENDPOINT raw = typer.prompt("[OPTIONAL] Lightning announce alias (Enter to skip)", default="") diff --git a/kaleido_cli/commands/node_swap.py b/kaleido_cli/commands/node_swap.py new file mode 100644 index 0000000..ae25438 --- /dev/null +++ b/kaleido_cli/commands/node_swap.py @@ -0,0 +1,295 @@ +"""Low-level local node swap commands.""" + +from __future__ import annotations + +import asyncio +from typing import Annotated + +import typer +from kaleido_sdk.rln import ( + GetSwapRequest, + GetSwapResponse, + ListSwapsResponse, + MakerExecuteRequest, + MakerInitRequest, + MakerInitResponse, + TakerRequest, +) + +from kaleido_cli.context import get_client +from kaleido_cli.output import ( + is_interactive, + is_json_mode, + output_collection, + output_model, + print_error, + print_info, + print_json, + print_success, +) +from kaleido_cli.utils.prompts import resolve_required_text + +node_swap_app = typer.Typer( + no_args_is_help=True, + rich_markup_mode="rich", + help="Low-level local RLN node swap flow: maker-init, taker whitelist, then maker-execute.", +) + + +@node_swap_app.command( + "pubkey", + epilog=" [cyan]kaleido node swap pubkey[/cyan] Print the local node's taker public key.", +) +def node_pubkey() -> None: + """Show the local node's taker public key used in swap operations.""" + asyncio.run(_node_pubkey()) + + +async def _node_pubkey() -> None: + try: + client = get_client(require_node=True) + pubkey = await client.rln.get_taker_pubkey() + if is_json_mode(): + print_json({"pubkey": pubkey}) + else: + print_success(f"Taker pubkey: {pubkey}") + except Exception as e: + print_error(f"Error: {e}") + raise typer.Exit(1) + + +@node_swap_app.command( + "init", + epilog=( + "[bold]Examples[/bold]\n\n" + " Initialize a local node swap:\n" + " [cyan]kaleido node swap init --qty-from 30 --to-asset rgb:abc... --qty-to 10[/cyan]" + ), +) +def node_init( + from_asset: Annotated[ + str | None, + typer.Option("--from-asset", help="RGB asset ID the maker will send (None = BTC)."), + ] = None, + qty_from: Annotated[ + int | None, typer.Option("--qty-from", help="Amount the maker will send (raw units).") + ] = None, + to_asset: Annotated[ + str | None, + typer.Option("--to-asset", help="RGB asset ID the maker will receive (None = BTC)."), + ] = None, + qty_to: Annotated[ + int | None, typer.Option("--qty-to", help="Amount the maker will receive (raw units).") + ] = None, + timeout_sec: Annotated[ + int, typer.Option("--timeout", help="Swap offer timeout in seconds.") + ] = 100, +) -> None: + """Initialize a low-level local node swap via maker-init.""" + resolved_qty_from: int + if qty_from is not None: + resolved_qty_from = qty_from + elif is_interactive(): + resolved_qty_from = typer.prompt("Quantity from (raw units)", type=int) + else: + print_error("--qty-from is required in non-interactive mode.") + raise typer.Exit(1) + + resolved_qty_to: int + if qty_to is not None: + resolved_qty_to = qty_to + elif is_interactive(): + resolved_qty_to = typer.prompt("Quantity to (raw units)", type=int) + else: + print_error("--qty-to is required in non-interactive mode.") + raise typer.Exit(1) + + asyncio.run(_node_init(from_asset, resolved_qty_from, to_asset, resolved_qty_to, timeout_sec)) + + +async def _node_init( + from_asset: str | None, + qty_from: int, + to_asset: str | None, + qty_to: int, + timeout_sec: int, +) -> None: + try: + client = get_client(require_node=True) + resp: MakerInitResponse = await client.rln.maker_init( + MakerInitRequest( + qty_from=qty_from, + qty_to=qty_to, + from_asset=from_asset, + to_asset=to_asset, + timeout_sec=timeout_sec, + ) + ) + if is_json_mode(): + print_json(resp.model_dump()) + else: + print_success("Node swap initialized") + output_model(resp, title="Node Swap Init") + print_info("Next step: whitelist on the taker side, then execute on the maker side.") + except Exception as e: + print_error(f"Error: {e}") + raise typer.Exit(1) + + +@node_swap_app.command( + "whitelist", + epilog=( + "[bold]Examples[/bold]\n\n" + " Whitelist a swap on the local taker node:\n" + " [cyan]kaleido node swap whitelist --swapstring ''[/cyan]" + ), +) +def node_whitelist( + swapstring: Annotated[ + str | None, + typer.Option("--swapstring", help="Swap string returned by node init or atomic init."), + ] = None, +) -> None: + """Whitelist a swap on the local taker node via /taker.""" + resolved_swapstring = resolve_required_text(swapstring, "Swap string", "--swapstring") + asyncio.run(_node_whitelist(resolved_swapstring)) + + +async def _node_whitelist(swapstring: str) -> None: + try: + client = get_client(require_node=True) + await client.rln.whitelist_swap(TakerRequest(swapstring=swapstring)) + if is_json_mode(): + print_json({"ok": True, "swapstring": swapstring}) + else: + print_success("Swap whitelisted on taker node") + except Exception as e: + print_error(f"Error: {e}") + raise typer.Exit(1) + + +@node_swap_app.command( + "execute", + epilog=( + "[bold]Examples[/bold]\n\n" + " Execute a previously initialized local node swap:\n" + " [cyan]kaleido node swap execute --swapstring '' " + "--payment-secret deadbeef... --taker-pubkey 03ab...[/cyan]" + ), +) +def node_execute( + swapstring: Annotated[ + str | None, typer.Option("--swapstring", help="Swap string returned by node init.") + ] = None, + payment_secret: Annotated[ + str | None, typer.Option("--payment-secret", help="Payment secret returned by node init.") + ] = None, + taker_pubkey: Annotated[ + str | None, + typer.Option("--taker-pubkey", help="Taker node pubkey. Defaults to own node pubkey."), + ] = None, +) -> None: + """Execute a low-level local node swap via maker-execute.""" + resolved_swapstring = resolve_required_text(swapstring, "Swap string", "--swapstring") + resolved_payment_secret = resolve_required_text( + payment_secret, "Payment secret", "--payment-secret" + ) + asyncio.run(_node_execute(resolved_swapstring, resolved_payment_secret, taker_pubkey)) + + +async def _node_execute( + swapstring: str, + payment_secret: str, + taker_pubkey_override: str | None, +) -> None: + try: + client = get_client(require_node=True) + resolved_taker_pubkey = taker_pubkey_override or await client.rln.get_taker_pubkey() + await client.rln.maker_execute( + MakerExecuteRequest( + swapstring=swapstring, + payment_secret=payment_secret, + taker_pubkey=resolved_taker_pubkey, + ) + ) + if is_json_mode(): + print_json( + {"ok": True, "swapstring": swapstring, "taker_pubkey": resolved_taker_pubkey} + ) + else: + print_success("Node swap executed successfully") + except Exception as e: + print_error(f"Error: {e}") + raise typer.Exit(1) + + +@node_swap_app.command( + "status", + epilog=( + "[bold]Examples[/bold]\n\n" + " Check the taker-side swap status:\n" + " [cyan]kaleido node swap status --taker[/cyan]\n\n" + " Check the maker-side swap status:\n" + " [cyan]kaleido node swap status --maker[/cyan]" + ), +) +def node_status( + payment_hash: Annotated[str | None, typer.Argument(help="Swap payment hash.")] = None, + taker: Annotated[bool, typer.Option("--taker", help="Look up the taker-side swap.")] = False, + maker: Annotated[bool, typer.Option("--maker", help="Look up the maker-side swap.")] = False, +) -> None: + """Check a local node swap by payment hash.""" + resolved_payment_hash = resolve_required_text( + payment_hash, "Payment hash", "PAYMENT_HASH argument" + ) + if not taker and not maker: + taker = True + elif taker == maker: + print_error("Must specify at most one of --taker or --maker") + raise typer.Exit(1) + asyncio.run(_node_status(resolved_payment_hash, taker)) + + +async def _node_status(payment_hash: str, taker: bool) -> None: + try: + client = get_client(require_node=True) + resp: GetSwapResponse = await client.rln.get_swap( + GetSwapRequest(payment_hash=payment_hash, taker=taker) + ) + if is_json_mode(): + print_json(resp.model_dump()) + else: + side = "Taker" if taker else "Maker" + output_model(resp, title=f"{side} Node Swap — {payment_hash[:16]}…") + except Exception as e: + print_error(f"Error: {e}") + raise typer.Exit(1) + + +@node_swap_app.command( + "list", + epilog=( + "[bold]Examples[/bold]\n\n List all node swaps:\n [cyan]kaleido node swap list[/cyan]" + ), +) +def node_list() -> None: + """List swaps known to the local RLN node.""" + asyncio.run(_node_list()) + + +async def _node_list() -> None: + try: + client = get_client(require_node=True) + resp: ListSwapsResponse = await client.rln.list_swaps() + if is_json_mode(): + print_json(resp.model_dump()) + return + items = [] + for swap in resp.taker or []: + items.append({**swap.model_dump(), "role": "taker"}) + for swap in resp.maker or []: + items.append({**swap.model_dump(), "role": "maker"}) + output_collection("Node Swaps", items, item_title="Node Swap — {index}") + except Exception as e: + print_error(f"Error: {e}") + raise typer.Exit(1) diff --git a/kaleido_cli/commands/swap.py b/kaleido_cli/commands/swap.py index 16408b4..81aa4a2 100644 --- a/kaleido_cli/commands/swap.py +++ b/kaleido_cli/commands/swap.py @@ -1,4 +1,4 @@ -"""Swap order, maker atomic swap, and local node swap commands.""" +"""Swap order and atomic swap commands.""" from __future__ import annotations @@ -29,12 +29,6 @@ TradingPairsResponse, ) from kaleido_sdk.rln import ( - GetSwapRequest, - GetSwapResponse, - ListSwapsResponse, - MakerExecuteRequest, - MakerInitRequest, - MakerInitResponse, TakerRequest, ) @@ -70,7 +64,10 @@ swap_app = typer.Typer( no_args_is_help=True, rich_markup_mode="rich", - help="Swap operations grouped by scope: maker order, maker atomic, and local node.", + help=( + "Swap operations grouped by scope: maker order and maker atomic flow.\n\n" + "For low-level local node swaps, use [cyan]kaleido node swap ...[/cyan]." + ), ) order_app = typer.Typer( no_args_is_help=True, @@ -82,15 +79,9 @@ rich_markup_mode="rich", help="Atomic swap flow against the Kaleidoswap maker server, using your local node as taker.", ) -node_app = typer.Typer( - no_args_is_help=True, - rich_markup_mode="rich", - help="Local RLN node swap flow: maker-init, taker whitelist, then maker-execute.", -) swap_app.add_typer(order_app, name="order") swap_app.add_typer(atomic_app, name="atomic") -swap_app.add_typer(node_app, name="node") def _resolve_accept_reject(accept: bool, reject: bool, prompt: str) -> bool: @@ -451,7 +442,7 @@ async def _order_history(status: str | None, limit: int) -> None: " Initialize an atomic swap from a live quote:\n" " [cyan]kaleido swap atomic init BTC/USDT --to-amount 5[/cyan]\n\n" "[dim]After init, you can whitelist explicitly, or let execute do it for you:[/dim]\n" - "[cyan]kaleido swap node whitelist --swapstring ''[/cyan]\n" + "[cyan]kaleido node swap whitelist --swapstring ''[/cyan]\n" "[cyan]kaleido swap atomic execute --swapstring '' " "--taker-pubkey --payment-hash [/cyan]\n" "[cyan]kaleido swap atomic execute --auto-whitelist --swapstring '' " @@ -552,7 +543,7 @@ async def _atomic_init( print_info( "Flow 1 (manual): whitelist first on your local taker node, then execute against the maker server." ) - print_info(f" kaleido swap node whitelist --swapstring '{resp.swapstring}'") + print_info(f" kaleido node swap whitelist --swapstring '{resp.swapstring}'") print_info( f" kaleido swap atomic execute --swapstring '{resp.swapstring}' " f"--taker-pubkey --payment-hash {resp.payment_hash}" @@ -579,7 +570,7 @@ async def _atomic_init( " Auto-whitelist before executing:\n" " [cyan]kaleido swap atomic execute --auto-whitelist --swapstring '' " "--taker-pubkey 03ab... --payment-hash deadbeef...[/cyan]\n\n" - "[dim]Use the taker node pubkey from 'kaleido node taker pubkey' or your node's pubkey.[/dim]" + "[dim]Use the taker node pubkey from 'kaleido node swap pubkey' or your node's pubkey.[/dim]" ), ) def atomic_execute( @@ -847,240 +838,3 @@ async def _atomic_run( except Exception as e: print_error(f"Error: {e}") raise typer.Exit(1) - - -@node_app.command( - "init", - epilog=( - "[bold]Examples[/bold]\n\n" - " Initialize a local node swap:\n" - " [cyan]kaleido swap node init --qty-from 30 --to-asset rgb:abc... --qty-to 10[/cyan]" - ), -) -def node_init( - from_asset: Annotated[ - str | None, - typer.Option("--from-asset", help="RGB asset ID the maker will send (None = BTC)."), - ] = None, - qty_from: Annotated[ - int | None, typer.Option("--qty-from", help="Amount the maker will send (raw units).") - ] = None, - to_asset: Annotated[ - str | None, - typer.Option("--to-asset", help="RGB asset ID the maker will receive (None = BTC)."), - ] = None, - qty_to: Annotated[ - int | None, typer.Option("--qty-to", help="Amount the maker will receive (raw units).") - ] = None, - timeout_sec: Annotated[ - int, typer.Option("--timeout", help="Swap offer timeout in seconds.") - ] = 100, -) -> None: - """Initialize a low-level local node swap via maker-init.""" - resolved_qty_from: int - if qty_from is not None: - resolved_qty_from = qty_from - elif is_interactive(): - resolved_qty_from = typer.prompt("Quantity from (raw units)", type=int) - else: - print_error("--qty-from is required in non-interactive mode.") - raise typer.Exit(1) - - resolved_qty_to: int - if qty_to is not None: - resolved_qty_to = qty_to - elif is_interactive(): - resolved_qty_to = typer.prompt("Quantity to (raw units)", type=int) - else: - print_error("--qty-to is required in non-interactive mode.") - raise typer.Exit(1) - - asyncio.run(_node_init(from_asset, resolved_qty_from, to_asset, resolved_qty_to, timeout_sec)) - - -async def _node_init( - from_asset: str | None, - qty_from: int, - to_asset: str | None, - qty_to: int, - timeout_sec: int, -) -> None: - try: - client = get_client(require_node=True) - resp: MakerInitResponse = await client.rln.maker_init( - MakerInitRequest( - qty_from=qty_from, - qty_to=qty_to, - from_asset=from_asset, - to_asset=to_asset, - timeout_sec=timeout_sec, - ) - ) - if is_json_mode(): - print_json(resp.model_dump()) - else: - print_success("Node swap initialized") - output_model(resp, title="Node Swap Init") - print_info("Next step: whitelist on the taker side, then execute on the maker side.") - except Exception as e: - print_error(f"Error: {e}") - raise typer.Exit(1) - - -@node_app.command( - "whitelist", - epilog=( - "[bold]Examples[/bold]\n\n" - " Whitelist a swap on the local taker node:\n" - " [cyan]kaleido swap node whitelist --swapstring ''[/cyan]" - ), -) -def node_whitelist( - swapstring: Annotated[ - str | None, - typer.Option("--swapstring", help="Swap string returned by node init or atomic init."), - ] = None, -) -> None: - """Whitelist a swap on the local taker node via /taker.""" - resolved_swapstring = resolve_required_text(swapstring, "Swap string", "--swapstring") - asyncio.run(_node_whitelist(resolved_swapstring)) - - -async def _node_whitelist(swapstring: str) -> None: - try: - client = get_client(require_node=True) - await client.rln.whitelist_swap(TakerRequest(swapstring=swapstring)) - if is_json_mode(): - print_json({"ok": True, "swapstring": swapstring}) - else: - print_success("Swap whitelisted on taker node") - except Exception as e: - print_error(f"Error: {e}") - raise typer.Exit(1) - - -@node_app.command( - "execute", - epilog=( - "[bold]Examples[/bold]\n\n" - " Execute a previously initialized local node swap:\n" - " [cyan]kaleido swap node execute --swapstring '' " - "--payment-secret deadbeef... --taker-pubkey 03ab...[/cyan]" - ), -) -def node_execute( - swapstring: Annotated[ - str | None, typer.Option("--swapstring", help="Swap string returned by node init.") - ] = None, - payment_secret: Annotated[ - str | None, typer.Option("--payment-secret", help="Payment secret returned by node init.") - ] = None, - taker_pubkey: Annotated[ - str | None, - typer.Option("--taker-pubkey", help="Taker node pubkey. Defaults to own node pubkey."), - ] = None, -) -> None: - """Execute a low-level local node swap via maker-execute.""" - resolved_swapstring = resolve_required_text(swapstring, "Swap string", "--swapstring") - resolved_payment_secret = resolve_required_text( - payment_secret, "Payment secret", "--payment-secret" - ) - asyncio.run(_node_execute(resolved_swapstring, resolved_payment_secret, taker_pubkey)) - - -async def _node_execute( - swapstring: str, - payment_secret: str, - taker_pubkey_override: str | None, -) -> None: - try: - client = get_client(require_node=True) - resolved_taker_pubkey = taker_pubkey_override or await client.rln.get_taker_pubkey() - await client.rln.maker_execute( - MakerExecuteRequest( - swapstring=swapstring, - payment_secret=payment_secret, - taker_pubkey=resolved_taker_pubkey, - ) - ) - if is_json_mode(): - print_json( - {"ok": True, "swapstring": swapstring, "taker_pubkey": resolved_taker_pubkey} - ) - else: - print_success("Node swap executed successfully") - except Exception as e: - print_error(f"Error: {e}") - raise typer.Exit(1) - - -@node_app.command( - "status", - epilog=( - "[bold]Examples[/bold]\n\n" - " Check the taker-side swap status:\n" - " [cyan]kaleido swap node status --taker[/cyan]\n\n" - " Check the maker-side swap status:\n" - " [cyan]kaleido swap node status --maker[/cyan]" - ), -) -def node_status( - payment_hash: Annotated[str | None, typer.Argument(help="Swap payment hash.")] = None, - taker: Annotated[bool, typer.Option("--taker", help="Look up the taker-side swap.")] = False, - maker: Annotated[bool, typer.Option("--maker", help="Look up the maker-side swap.")] = False, -) -> None: - """Check a local node swap by payment hash.""" - resolved_payment_hash = resolve_required_text( - payment_hash, "Payment hash", "PAYMENT_HASH argument" - ) - if not taker and not maker: - taker = True - elif taker == maker: - print_error("Must specify at most one of --taker or --maker") - raise typer.Exit(1) - asyncio.run(_node_status(resolved_payment_hash, taker)) - - -async def _node_status(payment_hash: str, taker: bool) -> None: - try: - client = get_client(require_node=True) - resp: GetSwapResponse = await client.rln.get_swap( - GetSwapRequest(payment_hash=payment_hash, taker=taker) - ) - if is_json_mode(): - print_json(resp.model_dump()) - else: - side = "Taker" if taker else "Maker" - output_model(resp, title=f"{side} Node Swap — {payment_hash[:16]}…") - except Exception as e: - print_error(f"Error: {e}") - raise typer.Exit(1) - - -@node_app.command( - "list", - epilog=( - "[bold]Examples[/bold]\n\n List all node swaps:\n [cyan]kaleido swap node list[/cyan]" - ), -) -def node_list() -> None: - """List swaps known to the local RLN node.""" - asyncio.run(_node_list()) - - -async def _node_list() -> None: - try: - client = get_client(require_node=True) - resp: ListSwapsResponse = await client.rln.list_swaps() - if is_json_mode(): - print_json(resp.model_dump()) - return - items = [] - for swap in resp.taker or []: - items.append({**swap.model_dump(), "role": "taker"}) - for swap in resp.maker or []: - items.append({**swap.model_dump(), "role": "maker"}) - output_collection("Node Swaps", items, item_title="Node Swap — {index}") - except Exception as e: - print_error(f"Error: {e}") - raise typer.Exit(1) diff --git a/kaleido_cli/config.py b/kaleido_cli/config.py index 12118bb..e60d973 100644 --- a/kaleido_cli/config.py +++ b/kaleido_cli/config.py @@ -9,9 +9,27 @@ CONFIG_DIR = Path.home() / ".kaleido" CONFIG_FILE = CONFIG_DIR / "config.json" -DEFAULT_API_URL = "https://api.kaleidoswap.com" +DEFAULT_API_URL = "https://api.signet.kaleidoswap.com/" DEFAULT_NODE_URL = "http://localhost:3001" -DEFAULT_NETWORK = "signet" +DEFAULT_NETWORK = "mutinynet" + +RLN_SIGNET_CUSTOM_NETWORK = "signetcustom" +MUTINYNET_ALIASES = {"mutinynet", "signetcustom", "customsignet"} + +DEFAULT_BITCOIND_RPC_USERNAME = "user" +DEFAULT_BITCOIND_RPC_PASSWORD = "default_password" +DEFAULT_BITCOIND_RPC_HOST = "bitcoind.signet.kaleidoswap.com" +DEFAULT_BITCOIND_RPC_PORT = 38332 +DEFAULT_INDEXER_URL = "electrum.signet.kaleidoswap.com:60601" +DEFAULT_PROXY_ENDPOINT = "rpcs://proxy.iriswallet.com/0.2/json-rpc" + + +def normalize_network_name(network: str) -> str: + """Normalize friendly CLI aliases to the network value expected by RLN.""" + lowered = network.strip().lower() + if lowered in MUTINYNET_ALIASES: + return RLN_SIGNET_CUSTOM_NETWORK + return lowered @dataclass @@ -19,8 +37,8 @@ class CliConfig: api_url: str = DEFAULT_API_URL node_url: str = DEFAULT_NODE_URL network: str = DEFAULT_NETWORK - # Directory used by `kaleido node spawn` to write generated compose files + volumes - spawn_dir: str = "" # default: ~/.kaleido/spawn + # Directory used to write generated compose files + volumes. + spawn_dir: str = "" # default: ~/.kaleido def to_dict(self) -> dict: return asdict(self) diff --git a/kaleido_cli/context.py b/kaleido_cli/context.py index d951702..6a5460d 100644 --- a/kaleido_cli/context.py +++ b/kaleido_cli/context.py @@ -18,7 +18,12 @@ class _State: state = _State() -def get_client(*, require_node: bool = False) -> KaleidoClient: +def get_client( + *, + require_node: bool = False, + timeout: float | None = None, + max_retries: int = 3, +) -> KaleidoClient: """Build a KaleidoClient from current state.""" node_url = state.node_url or state.config.node_url or None api_url = state.api_url or state.config.api_url @@ -31,4 +36,6 @@ def get_client(*, require_node: bool = False) -> KaleidoClient: return KaleidoClient.create( base_url=api_url, node_url=node_url, + timeout=timeout or 30.0, + max_retries=max_retries, ) diff --git a/kaleido_cli/docker_manager.py b/kaleido_cli/docker_manager.py index 0e52088..ad72572 100644 --- a/kaleido_cli/docker_manager.py +++ b/kaleido_cli/docker_manager.py @@ -9,6 +9,7 @@ import yaml +from .config import DEFAULT_NETWORK, normalize_network_name from .output import print_error, print_info, print_success, print_warning COMPOSE_FILE = "docker-compose.yml" @@ -18,7 +19,7 @@ DEFAULT_BASE_DAEMON_PORT = 3001 DEFAULT_BASE_PEER_PORT = 9735 DEFAULT_NETWORK_NAME = "kaleidoswap-network" -DEFAULT_SPAWN_DIR = Path.home() / ".kaleido" / "spawn" +DEFAULT_SPAWN_DIR = Path.home() / ".kaleido" # --------------------------------------------------------------------------- @@ -70,10 +71,10 @@ class SpawnConfig: # Environment identity name: str = "default" - spawn_base_dir: str = "" # "" → ~/.kaleido/spawn (env lives at base/name) + spawn_base_dir: str = "" # "" → ~/.kaleido (env lives at base/name) count: int = 1 - network: str = "regtest" + network: str = DEFAULT_NETWORK # Docker network network_name: str = DEFAULT_NETWORK_NAME network_external: bool = False @@ -265,7 +266,8 @@ def _build_compose_dict(self, spawn_dir: Path) -> dict: ] if cfg.disable_authentication: cmd_parts.append("--disable-authentication") - cmd_parts.append(f"--network {cfg.network}") + rln_network = normalize_network_name(cfg.network) + cmd_parts.append(f"--network {rln_network}") service: dict = { "image": RLN_IMAGE, @@ -279,7 +281,7 @@ def _build_compose_dict(self, spawn_dir: Path) -> dict: "volumes": [f"{host_data}:{container_data}"], "environment": { "APP_ENV": "${APP_ENV:-test}", - "NETWORK": "${NETWORK:-" + cfg.network + "}", + "NETWORK": "${NETWORK:-" + rln_network + "}", "DAEMON_PORT": daemon_port, }, "healthcheck": { diff --git a/kaleido_cli/onboarding.py b/kaleido_cli/onboarding.py index e1871d6..b25ff54 100644 --- a/kaleido_cli/onboarding.py +++ b/kaleido_cli/onboarding.py @@ -9,8 +9,8 @@ import typer from .config import load_config, save_config -from .docker_manager import DEFAULT_SPAWN_DIR, SpawnConfig, SpawnManager -from .output import print_error, print_info, print_panel, print_success +from .docker_manager import COMPOSE_FILE, DEFAULT_SPAWN_DIR, SpawnConfig, SpawnManager +from .output import is_interactive, print_error, print_info, print_panel, print_success class SetupMode(str, Enum): @@ -50,6 +50,15 @@ def _confirm_or_default( return typer.confirm(label, default=default) +def _next_available_env_name(base_dir: Path, env_name: str) -> str: + """Suggest an environment name that is available under the base directory.""" + for suffix in range(2, 100): + candidate = f"{env_name}-{suffix}" + if not (base_dir / candidate / COMPOSE_FILE).exists(): + return candidate + return f"{env_name}-new" + + def run_setup( *, mode: SetupMode | None, @@ -68,7 +77,7 @@ def run_setup( print_panel( "Kaleido Setup", - "Choose a market-only setup or configure a local RGB Lightning Node.\n" + "Set up Kaleidoswap defaults and optionally create or reuse a local RGB Lightning Node.\n" "Your answers are saved to ~/.kaleido/config.json.", ) @@ -111,10 +120,13 @@ def run_setup( ) if should_create_node: + default_base_dir = ( + str(DEFAULT_SPAWN_DIR) if defaults else config.spawn_dir or str(DEFAULT_SPAWN_DIR) + ) base_dir_input = _value_or_prompt( spawn_dir, "Base directory for node environments", - config.spawn_dir or str(DEFAULT_SPAWN_DIR), + default_base_dir, use_defaults=defaults, ) base_dir = Path(base_dir_input).expanduser().resolve() @@ -138,41 +150,56 @@ def run_setup( True, use_defaults=defaults, ) - env_dir = base_dir / resolved_env_name - if (env_dir / "docker-compose.yml").exists(): - if defaults: + while (base_dir / resolved_env_name / COMPOSE_FILE).exists(): + env_dir = base_dir / resolved_env_name + if not is_interactive(): print_error( f"Environment '{resolved_env_name}' already exists at {env_dir}. " - "Choose a different --env-name or reuse it with 'kaleido node use'." + "Choose a different environment name with --env-name to create a new node." ) raise typer.Exit(1) + use_different_path = typer.confirm( + f"Environment '{resolved_env_name}' already exists at {env_dir}. " + "Create the new node in a different folder under the same base directory?", + default=True, + ) + if use_different_path: + resolved_env_name = typer.prompt( + "Choose a different environment folder name", + default=_next_available_env_name(base_dir, resolved_env_name), + ) + created_env_name = resolved_env_name + continue overwrite = typer.confirm( - f"Environment '{resolved_env_name}' already exists at {env_dir}. Overwrite?", + "Overwrite only the compose file? Existing node data may still be reused.", default=False, ) if not overwrite: print_info("Aborted.") raise typer.Exit(0) + print_info(f"Overwriting compose file for '{resolved_env_name}' at {env_dir}.") + break - config.spawn_dir = str(base_dir) - save_config(config) + if should_create_node: + config.spawn_dir = str(base_dir) + save_config(config) - manager = SpawnManager( - SpawnConfig( - name=resolved_env_name, - count=count, - network=config.network, - disable_authentication=True, - spawn_base_dir=str(base_dir), + manager = SpawnManager( + SpawnConfig( + name=resolved_env_name, + count=count, + network=config.network, + disable_authentication=True, + spawn_base_dir=str(base_dir), + ) ) - ) - rc = manager.spawn(start=created_env_started) - if rc != 0: - raise typer.Exit(rc) + rc = manager.spawn(start=created_env_started) + if rc != 0: + raise typer.Exit(rc) - config.node_url = manager.node_urls()[0] - save_config(config) - print_success(f"Active node-url → {config.node_url}") + config.node_url = manager.node_urls()[0] + save_config(config) + print_success(f"Active node-url → {config.node_url}") else: config.node_url = _value_or_prompt( node_url, diff --git a/kaleido_cli/utils/channel_orders.py b/kaleido_cli/utils/channel_orders.py new file mode 100644 index 0000000..6cf5b22 --- /dev/null +++ b/kaleido_cli/utils/channel_orders.py @@ -0,0 +1,815 @@ +"""Channel order and LSP helper utilities.""" + +from __future__ import annotations + +from collections.abc import Awaitable +from dataclasses import dataclass +from datetime import datetime, timezone +from time import perf_counter +from typing import Any, TypeVar + +import typer +from kaleido_sdk import ( + ChannelOrderResponse, + CreateOrderRequest, + EstimateFeesRequest, + EstimateFeesResponse, + Layer, + LspInfoResponse, + OrderRequest, + PairQuoteRequest, + PaymentState, + SwapLegInput, +) +from kaleido_sdk.rln import ConnectPeerRequest + +from kaleido_cli.output import ( + is_interactive, + is_json_mode, + output_collection, + output_model, + print_error, + print_info, + print_panel, + print_success, +) + +CHANNEL_LSP_CREATE_ORDER_PATH = "/api/v1/lsps1/create_order" +CHANNEL_LSP_GET_ORDER_PATH = "/api/v1/lsps1/get_order" +CHANNEL_ORDER_HTTP_TIMEOUT = 30.0 + +T = TypeVar("T") + + +@dataclass(slots=True) +class ChannelOrderParams: + client_pubkey: str + lsp_balance_sat: int + client_balance_sat: int + required_channel_confirmations: int + funding_confirms_within_blocks: int + channel_expiry_blocks: int + token: str | None + refund_onchain_address: str | None + announce_channel: bool + asset_id: str | None + lsp_asset_amount: int | None + client_asset_amount: int | None + rfq_id: str | None + email: str | None + + +@dataclass(slots=True) +class ChannelFeeEstimateParams: + lsp_balance_sat: int + client_balance_sat: int + channel_expiry_blocks: int + token: str | None + asset_id: str | None + lsp_asset_amount: int | None + client_asset_amount: int | None + rfq_id: str | None + + +def _parse_iso_datetime(value: str) -> datetime | None: + candidate = value + if candidate.endswith("Z"): + candidate = f"{candidate[:-1]}+00:00" + try: + return datetime.fromisoformat(candidate) + except ValueError: + return None + + +def _normalize_channel_lsp_datetimes(value: Any, key: str | None = None) -> Any: + if isinstance(value, dict): + return {k: _normalize_channel_lsp_datetimes(v, k) for k, v in value.items()} + if isinstance(value, list): + return [_normalize_channel_lsp_datetimes(item, key) for item in value] + if key is not None and key.endswith("_at") and isinstance(value, str): + parsed = _parse_iso_datetime(value) + if parsed is not None and parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc).isoformat() + return value + + +async def _post_channel_lsp(client: Any, path: str, body: Any) -> dict[str, Any]: + data = await client.maker._http.maker_post(path, data=body) + if not isinstance(data, dict): + raise TypeError(f"Unexpected channel LSP response type for {path}: {type(data).__name__}") + return data + + +def _normalize_channel_order_response(data: dict[str, Any]) -> ChannelOrderResponse: + return ChannelOrderResponse.model_validate(_normalize_channel_lsp_datetimes(data)) + + +async def _submit_channel_order(client: Any, body: CreateOrderRequest) -> ChannelOrderResponse: + data = await _post_channel_lsp(client, CHANNEL_LSP_CREATE_ORDER_PATH, body) + return _normalize_channel_order_response(data) + + +async def _fetch_channel_order(client: Any, body: OrderRequest) -> ChannelOrderResponse: + data = await _post_channel_lsp(client, CHANNEL_LSP_GET_ORDER_PATH, body) + return _normalize_channel_order_response(data) + + +def _prompt_optional_text(prompt: str) -> str | None: + raw = typer.prompt(prompt, default="") + return raw.strip() or None + + +def _prompt_optional_int(prompt: str) -> int | None: + raw = typer.prompt(prompt, default="") + if raw.strip() == "": + return None + try: + return int(raw) + except ValueError: + print_error(f"{prompt} must be an integer.") + raise typer.Exit(1) + + +def _range_text(min_value: int | None, max_value: int | None) -> str: + if min_value is None and max_value is None: + return "any" + if min_value is None: + return f"<= {max_value}" + if max_value is None: + return f">= {min_value}" + return f"{min_value} -> {max_value}" + + +def _validate_int_range( + value: int, + label: str, + *, + min_value: int | None = None, + max_value: int | None = None, +) -> int: + if min_value is not None and value < min_value: + print_error(f"{label} must be at least {min_value}.") + raise typer.Exit(1) + if max_value is not None and value > max_value: + print_error(f"{label} must be at most {max_value}.") + raise typer.Exit(1) + return value + + +def _prompt_int_in_range( + prompt: str, + *, + min_value: int | None = None, + max_value: int | None = None, + default: int | None = None, +) -> int: + suffix = f" ({_range_text(min_value, max_value)})" + prompt_kwargs: dict[str, Any] = {"type": int, "show_default": default is not None} + if default is not None: + prompt_kwargs["default"] = default + value = typer.prompt(f"{prompt}{suffix}", **prompt_kwargs) + return _validate_int_range(value, prompt, min_value=min_value, max_value=max_value) + + +def _lsp_options_limits(lsp_info: LspInfoResponse | None) -> dict[str, int | None]: + options = lsp_info.options if lsp_info is not None else None + return { + "min_lsp_balance_sat": getattr(options, "min_initial_lsp_balance_sat", None), + "max_lsp_balance_sat": getattr(options, "max_initial_lsp_balance_sat", None), + "min_client_balance_sat": getattr(options, "min_initial_client_balance_sat", None), + "max_client_balance_sat": getattr(options, "max_initial_client_balance_sat", None), + "min_channel_balance_sat": getattr(options, "min_channel_balance_sat", None), + "max_channel_balance_sat": getattr(options, "max_channel_balance_sat", None), + "min_required_confirmations": getattr(options, "min_required_channel_confirmations", None), + "min_funding_within_blocks": getattr(options, "min_funding_confirms_within_blocks", None), + "max_expiry_blocks": getattr(options, "max_channel_expiry_blocks", None), + } + + +def _print_lsp_order_limits(lsp_info: LspInfoResponse) -> None: + limits = _lsp_options_limits(lsp_info) + output_model( + { + "lsp_balance_sat": _range_text( + limits["min_lsp_balance_sat"], limits["max_lsp_balance_sat"] + ), + "client_balance_sat": _range_text( + limits["min_client_balance_sat"], limits["max_client_balance_sat"] + ), + "total_channel_balance_sat": _range_text( + limits["min_channel_balance_sat"], limits["max_channel_balance_sat"] + ), + "required_confirmations_min": limits["min_required_confirmations"], + "funding_within_blocks_min": limits["min_funding_within_blocks"], + "expiry_blocks_max": limits["max_expiry_blocks"], + }, + title="LSP Channel Limits", + ) + + +def _find_lsp_asset(lsp_info: LspInfoResponse | None, asset_id_or_ticker: str | None): + if lsp_info is None or not asset_id_or_ticker: + return None + normalized = asset_id_or_ticker.lower() + for asset in lsp_info.assets or []: + if (asset.asset_id or "").lower() == normalized or asset.ticker.lower() == normalized: + return asset + return None + + +def _format_elapsed(seconds: float) -> str: + if seconds < 1: + return f"{seconds * 1000:.0f}ms" + return f"{seconds:.1f}s" + + +async def _timed_step(label: str, awaitable: Awaitable[T]) -> T: + if not is_json_mode(): + print_info(f"{label}...") + started_at = perf_counter() + try: + result = await awaitable + except Exception: + if not is_json_mode(): + print_error(f"{label} failed after {_format_elapsed(perf_counter() - started_at)}") + raise + if not is_json_mode(): + print_success(f"{label} finished in {_format_elapsed(perf_counter() - started_at)}") + return result + + +def _print_lsp_asset_options(lsp_info: LspInfoResponse) -> None: + for idx, asset in enumerate(lsp_info.assets or [], start=1): + print_info( + f"{idx}. {asset.ticker} ({asset.name}) " + f"asset={asset.asset_id} " + f"lsp={asset.min_initial_lsp_amount}->{asset.max_initial_lsp_amount} " + f"client={asset.min_initial_client_amount}->{asset.max_initial_client_amount} " + f"channel={asset.min_channel_amount}->{asset.max_channel_amount}" + ) + + +def _prompt_lsp_asset(lsp_info: LspInfoResponse) -> str | None: + assets = lsp_info.assets or [] + if not assets: + print_info("The LSP did not report asset-backed channel options.") + return _prompt_optional_text("Asset ID (rgb:...)") + _print_lsp_asset_options(lsp_info) + selected = _prompt_int_in_range( + "Select asset option number", min_value=1, max_value=len(assets) + ) + return assets[selected - 1].asset_id + + +def _validate_lsp_amounts( + *, + lsp_info: LspInfoResponse | None, + lsp_balance_sat: int, + client_balance_sat: int, + required_channel_confirmations: int, + funding_confirms_within_blocks: int, + channel_expiry_blocks: int, +) -> None: + limits = _lsp_options_limits(lsp_info) + _validate_int_range( + lsp_balance_sat, + "--lsp-balance", + min_value=limits["min_lsp_balance_sat"], + max_value=limits["max_lsp_balance_sat"], + ) + _validate_int_range( + client_balance_sat, + "--client-balance", + min_value=limits["min_client_balance_sat"], + max_value=limits["max_client_balance_sat"], + ) + _validate_int_range( + lsp_balance_sat + client_balance_sat, + "Total channel balance", + min_value=limits["min_channel_balance_sat"], + max_value=limits["max_channel_balance_sat"], + ) + _validate_int_range( + required_channel_confirmations, + "--confirmations", + min_value=limits["min_required_confirmations"], + ) + _validate_int_range( + funding_confirms_within_blocks, + "--funding-within", + min_value=limits["min_funding_within_blocks"], + ) + _validate_int_range( + channel_expiry_blocks, + "--expiry-blocks", + min_value=1, + max_value=limits["max_expiry_blocks"], + ) + + +def _validate_asset_amounts( + *, + lsp_asset: Any, + lsp_asset_amount: int | None, + client_asset_amount: int | None, +) -> None: + if lsp_asset_amount is None: + print_error("--lsp-asset-amount is required when --asset-id is set.") + raise typer.Exit(1) + _validate_int_range( + lsp_asset_amount, + "--lsp-asset-amount", + min_value=lsp_asset.min_initial_lsp_amount, + max_value=lsp_asset.max_initial_lsp_amount, + ) + if client_asset_amount is not None: + _validate_int_range( + client_asset_amount, + "--client-asset-amount", + min_value=lsp_asset.min_initial_client_amount, + max_value=min(lsp_asset.max_initial_client_amount, lsp_asset_amount), + ) + if client_asset_amount > lsp_asset_amount: + print_error("--client-asset-amount must be less than or equal to --lsp-asset-amount.") + raise typer.Exit(1) + total_asset_amount = lsp_asset_amount + (client_asset_amount or 0) + _validate_int_range( + total_asset_amount, + "Total channel asset amount", + min_value=lsp_asset.min_channel_amount, + max_value=lsp_asset.max_channel_amount, + ) + + +def _normalize_optional_text(value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None + + +def _resolve_channel_fee_estimate_params( + *, + lsp_balance_sat: int | None, + client_balance_sat: int | None, + channel_expiry_blocks: int, + token: str | None, + asset_id: str | None, + lsp_asset_amount: int | None, + client_asset_amount: int | None, + rfq_id: str | None, +) -> ChannelFeeEstimateParams: + resolved_lsp_balance_sat: int + if lsp_balance_sat is not None: + resolved_lsp_balance_sat = lsp_balance_sat + elif is_interactive(): + resolved_lsp_balance_sat = typer.prompt("LSP balance in channel (satoshis)", type=int) + else: + print_error("--lsp-balance is required in non-interactive mode.") + raise typer.Exit(1) + + resolved_client_balance_sat: int + if client_balance_sat is not None: + resolved_client_balance_sat = client_balance_sat + elif is_interactive(): + resolved_client_balance_sat = typer.prompt("Client balance in channel (satoshis)", type=int) + else: + print_error("--client-balance is required in non-interactive mode.") + raise typer.Exit(1) + + if is_interactive(): + channel_expiry_blocks = typer.prompt( + "Channel expiry blocks", + type=int, + default=channel_expiry_blocks, + ) + + resolved_token = _normalize_optional_text(token) + resolved_asset_id = _normalize_optional_text(asset_id) + resolved_rfq_id = _normalize_optional_text(rfq_id) + + if is_interactive(): + if resolved_asset_id is None and typer.confirm( + "Estimate fees for an RGB-backed channel?", default=False + ): + resolved_asset_id = _prompt_optional_text("Asset ID (rgb:...)") + + if resolved_asset_id is not None: + if lsp_asset_amount is None: + lsp_asset_amount = _prompt_optional_int( + "[OPTIONAL] LSP RGB asset amount (Enter to skip)" + ) + if client_asset_amount is None: + client_asset_amount = _prompt_optional_int( + "[OPTIONAL] Client RGB asset amount (Enter to skip)" + ) + else: + lsp_asset_amount = None + client_asset_amount = None + + if resolved_token is None: + resolved_token = _prompt_optional_text( + "[OPTIONAL] Authentication token (Enter to skip)" + ) + if resolved_rfq_id is None: + resolved_rfq_id = _prompt_optional_text("[OPTIONAL] RFQ ID (Enter to skip)") + + if ( + lsp_asset_amount is not None or client_asset_amount is not None + ) and resolved_asset_id is None: + print_error("--lsp-asset-amount and --client-asset-amount require --asset-id.") + raise typer.Exit(1) + + return ChannelFeeEstimateParams( + lsp_balance_sat=resolved_lsp_balance_sat, + client_balance_sat=resolved_client_balance_sat, + channel_expiry_blocks=channel_expiry_blocks, + token=resolved_token, + asset_id=resolved_asset_id, + lsp_asset_amount=lsp_asset_amount, + client_asset_amount=client_asset_amount, + rfq_id=resolved_rfq_id, + ) + + +def _resolve_channel_order_params( + *, + client_pubkey: str | None, + default_client_pubkey: str | None, + lsp_info: LspInfoResponse | None, + lsp_balance_sat: int | None, + client_balance_sat: int | None, + required_channel_confirmations: int, + funding_confirms_within_blocks: int, + channel_expiry_blocks: int, + refund_onchain_address: str | None, + announce_channel: bool, + asset_id: str | None, + lsp_asset_amount: int | None, + client_asset_amount: int | None, + email: str | None, +) -> ChannelOrderParams: + resolved_client_pubkey: str + if client_pubkey is not None: + resolved_client_pubkey = client_pubkey + elif default_client_pubkey is not None: + resolved_client_pubkey = default_client_pubkey + if is_interactive(): + print_info(f"Using local node pubkey: {resolved_client_pubkey}") + elif is_interactive(): + resolved_client_pubkey = typer.prompt("Client Lightning node public key") + else: + print_error("CLIENT_PUBKEY argument is required in non-interactive mode.") + raise typer.Exit(1) + + if is_interactive() and lsp_info is not None: + _print_lsp_order_limits(lsp_info) + + limits = _lsp_options_limits(lsp_info) + resolved_lsp_balance_sat: int + if lsp_balance_sat is not None: + resolved_lsp_balance_sat = _validate_int_range( + lsp_balance_sat, + "--lsp-balance", + min_value=limits["min_lsp_balance_sat"], + max_value=limits["max_lsp_balance_sat"], + ) + elif is_interactive(): + resolved_lsp_balance_sat = _prompt_int_in_range( + "LSP balance in channel (satoshis)", + min_value=limits["min_lsp_balance_sat"], + max_value=limits["max_lsp_balance_sat"], + ) + else: + print_error("--lsp-balance is required in non-interactive mode.") + raise typer.Exit(1) + + resolved_client_balance_sat: int + if client_balance_sat is not None: + resolved_client_balance_sat = _validate_int_range( + client_balance_sat, + "--client-balance", + min_value=limits["min_client_balance_sat"], + max_value=limits["max_client_balance_sat"], + ) + elif is_interactive(): + resolved_client_balance_sat = _prompt_int_in_range( + "Client balance in channel (satoshis)", + min_value=limits["min_client_balance_sat"], + max_value=limits["max_client_balance_sat"], + ) + else: + print_error("--client-balance is required in non-interactive mode.") + raise typer.Exit(1) + + if is_interactive(): + required_channel_confirmations = _prompt_int_in_range( + "Required channel confirmations", + min_value=limits["min_required_confirmations"], + default=required_channel_confirmations, + ) + funding_confirms_within_blocks = _prompt_int_in_range( + "Funding confirms within blocks", + min_value=limits["min_funding_within_blocks"], + default=funding_confirms_within_blocks, + ) + channel_expiry_blocks = _prompt_int_in_range( + "Channel expiry blocks", + min_value=1, + max_value=limits["max_expiry_blocks"], + default=channel_expiry_blocks, + ) + + resolved_refund_onchain_address = _normalize_optional_text(refund_onchain_address) + resolved_asset_id = _normalize_optional_text(asset_id) + resolved_email = _normalize_optional_text(email) + + if is_interactive(): + if resolved_asset_id is None and typer.confirm( + "Attach an RGB asset to the channel order?", default=False + ): + if lsp_info is not None: + resolved_asset_id = _prompt_lsp_asset(lsp_info) + else: + resolved_asset_id = _prompt_optional_text("Asset ID (rgb:...)") + + if resolved_asset_id is not None: + lsp_asset = _find_lsp_asset(lsp_info, resolved_asset_id) + if lsp_info is not None and lsp_asset is None: + print_error(f"Asset {resolved_asset_id!r} is not available from the LSP.") + raise typer.Exit(1) + if lsp_asset_amount is None: + if lsp_asset is not None: + lsp_asset_amount = _prompt_int_in_range( + "LSP RGB asset amount (raw units)", + min_value=lsp_asset.min_initial_lsp_amount, + max_value=lsp_asset.max_initial_lsp_amount, + ) + else: + lsp_asset_amount = typer.prompt("LSP RGB asset amount (raw units)", type=int) + if client_asset_amount is None: + if lsp_asset is not None: + max_client_asset_amount = lsp_asset.max_initial_client_amount + if lsp_asset_amount is not None: + max_client_asset_amount = min(max_client_asset_amount, lsp_asset_amount) + client_asset_amount = _prompt_int_in_range( + "Client RGB asset amount (raw units)", + min_value=lsp_asset.min_initial_client_amount, + max_value=max_client_asset_amount, + default=0, + ) + else: + client_asset_amount = typer.prompt( + "Client RGB asset amount (raw units)", type=int, default=0 + ) + else: + lsp_asset_amount = None + client_asset_amount = None + + announce_channel = typer.confirm("Announce channel publicly?", default=announce_channel) + + if resolved_email is None: + resolved_email = _prompt_optional_text("[OPTIONAL] Contact email (Enter to skip)") + + if ( + lsp_asset_amount is not None or client_asset_amount is not None + ) and resolved_asset_id is None: + print_error("--lsp-asset-amount and --client-asset-amount require --asset-id.") + raise typer.Exit(1) + if resolved_asset_id is not None: + lsp_asset = _find_lsp_asset(lsp_info, resolved_asset_id) + if lsp_info is not None and lsp_asset is None: + print_error(f"Asset {resolved_asset_id!r} is not available from the LSP.") + raise typer.Exit(1) + if lsp_asset is not None: + resolved_asset_id = lsp_asset.asset_id or resolved_asset_id + _validate_asset_amounts( + lsp_asset=lsp_asset, + lsp_asset_amount=lsp_asset_amount, + client_asset_amount=client_asset_amount, + ) + elif lsp_asset_amount is None: + print_error("--lsp-asset-amount is required when --asset-id is set.") + raise typer.Exit(1) + + _validate_lsp_amounts( + lsp_info=lsp_info, + lsp_balance_sat=resolved_lsp_balance_sat, + client_balance_sat=resolved_client_balance_sat, + required_channel_confirmations=required_channel_confirmations, + funding_confirms_within_blocks=funding_confirms_within_blocks, + channel_expiry_blocks=channel_expiry_blocks, + ) + + return ChannelOrderParams( + client_pubkey=resolved_client_pubkey, + lsp_balance_sat=resolved_lsp_balance_sat, + client_balance_sat=resolved_client_balance_sat, + required_channel_confirmations=required_channel_confirmations, + funding_confirms_within_blocks=funding_confirms_within_blocks, + channel_expiry_blocks=channel_expiry_blocks, + token=None, + refund_onchain_address=resolved_refund_onchain_address, + announce_channel=announce_channel, + asset_id=resolved_asset_id, + lsp_asset_amount=lsp_asset_amount, + client_asset_amount=client_asset_amount or None, + rfq_id=None, + email=resolved_email, + ) + + +def _build_channel_order_request(params: ChannelOrderParams) -> CreateOrderRequest: + return CreateOrderRequest( + client_pubkey=params.client_pubkey, + lsp_balance_sat=params.lsp_balance_sat, + client_balance_sat=params.client_balance_sat, + required_channel_confirmations=params.required_channel_confirmations, + funding_confirms_within_blocks=params.funding_confirms_within_blocks, + channel_expiry_blocks=params.channel_expiry_blocks, + token=params.token, + refund_onchain_address=params.refund_onchain_address, + announce_channel=params.announce_channel, + asset_id=params.asset_id, + lsp_asset_amount=params.lsp_asset_amount, + client_asset_amount=params.client_asset_amount, + rfq_id=params.rfq_id, + email=params.email, + ) + + +async def _create_channel_order(client: Any, params: ChannelOrderParams) -> ChannelOrderResponse: + return await _submit_channel_order(client, _build_channel_order_request(params)) + + +def _peer_pubkey_from_connection_url(connection_url: str | None) -> str | None: + if not connection_url: + return None + return connection_url.split("@", 1)[0].strip() or None + + +async def _ensure_lsp_peer_connected(client: Any, lsp_info: LspInfoResponse) -> None: + connection_url = lsp_info.lsp_connection_url + lsp_pubkey = _peer_pubkey_from_connection_url(connection_url) + if not connection_url or not lsp_pubkey: + print_error("LSP did not report a connection URL.") + raise typer.Exit(1) + + peers = await _timed_step( + f"Checking LSP peer connection: {lsp_pubkey}", + client.rln.list_peers(), + ) + connected_pubkeys = {peer.pubkey for peer in (peers.peers or [])} + if lsp_pubkey in connected_pubkeys: + print_info(f"Already connected to LSP peer: {lsp_pubkey}") + return + await _timed_step( + f"Connecting to LSP peer: {connection_url}", + client.rln.connect_peer(ConnectPeerRequest(peer_pubkey_and_addr=connection_url)), + ) + print_success(f"LSP peer connected: {lsp_pubkey}") + + +async def _autofill_refund_address(client: Any, params: ChannelOrderParams) -> None: + if params.refund_onchain_address: + return + address = await _timed_step("Fetching refund onchain address", client.rln.get_address()) + params.refund_onchain_address = address.address + print_info(f"Using refund onchain address from local node: {params.refund_onchain_address}") + + +def _quote_leg_summary(leg: Any) -> str: + ticker = getattr(leg, "ticker", None) or getattr(leg, "asset_id", "asset") + amount = getattr(leg, "amount", None) + if amount is None: + return str(ticker) + return f"{amount} {ticker}" + + +def _quote_amount_summary(quote: Any) -> str: + return ( + f"receive {_quote_leg_summary(quote.to_asset)} for {_quote_leg_summary(quote.from_asset)}" + ) + + +async def _attach_client_asset_quote( + client: Any, + params: ChannelOrderParams, + *, + yes: bool, +) -> None: + if not params.asset_id or not params.client_asset_amount or params.client_asset_amount <= 0: + params.rfq_id = None + return + + quote = await _timed_step( + "Fetching RFQ quote", + client.maker.get_quote( + PairQuoteRequest( + from_asset=SwapLegInput( + asset_id=params.asset_id, + layer=Layer.RGB_LN, + amount=params.client_asset_amount, + ), + to_asset=SwapLegInput(asset_id="BTC", layer=Layer.BTC_LN, amount=None), + ) + ), + ) + if is_json_mode(): + if not yes: + print_error("--yes is required in JSON mode to accept the RFQ price.") + raise typer.Exit(1) + else: + quote_summary = _quote_amount_summary(quote) + print_info(f"Quoted amount: {quote_summary}") + if is_interactive(): + if not typer.confirm(f"Accept quoted amount ({quote_summary})?", default=False): + print_error("Channel order cancelled before creation.") + raise typer.Exit(0) + elif not yes: + print_error("--yes is required in non-interactive mode to accept the RFQ price.") + raise typer.Exit(1) + params.rfq_id = quote.rfq_id + print_info(f"Using RFQ ID: {params.rfq_id}") + + +async def _get_channel_order( + client: Any, order_id: str, access_token: str = "" +) -> ChannelOrderResponse: + return await _fetch_channel_order( + client, + OrderRequest(order_id=order_id, access_token=access_token), + ) + + +def _channel_wallet_payment_summary(order: ChannelOrderResponse) -> dict[str, Any]: + payment = order.payment.bolt11 + return { + "order_id": order.order_id, + "order_state": order.order_state, + "payment_state": payment.state, + "order_total_sat": payment.order_total_sat, + "fee_total_sat": payment.fee_total_sat, + "expires_at": payment.expires_at, + } + + +def _can_pay_channel_order(order: ChannelOrderResponse) -> bool: + return order.payment.bolt11.state == PaymentState.EXPECT_PAYMENT + + +async def _estimate_channel_order_fees( + client: Any, params: ChannelFeeEstimateParams +) -> EstimateFeesResponse: + body = EstimateFeesRequest( + lsp_balance_sat=params.lsp_balance_sat, + client_balance_sat=params.client_balance_sat, + channel_expiry_blocks=params.channel_expiry_blocks, + token=params.token, + asset_id=params.asset_id, + lsp_asset_amount=params.lsp_asset_amount, + client_asset_amount=params.client_asset_amount, + rfq_id=params.rfq_id, + ) + return await client.maker.estimate_lsp_fees(body) + + +def _print_channel_order_fees(resp: EstimateFeesResponse, *, title: str) -> None: + output_model(resp, title=title) + + +def _humanize_key(key: str) -> str: + return key.replace("_", " ").capitalize() + + +def _short_id(value: str | None, *, prefix: int = 16, suffix: int = 8) -> str: + if not value: + return "-" + if len(value) <= prefix + suffix + 1: + return value + return f"{value[:prefix]}…{value[-suffix:]}" + + +def _print_lsp_info(resp: LspInfoResponse) -> None: + print_panel("LSP Connection", resp.lsp_connection_url or "-", style="blue") + + if resp.options is not None: + output_model( + {_humanize_key(key): value for key, value in resp.options.model_dump().items()}, + title="Channel Options", + ) + output_collection( + "LSP Assets", + [ + { + **asset.model_dump(), + "asset_id": _short_id(asset.asset_id), + "client_range": f"{asset.min_initial_client_amount} -> {asset.max_initial_client_amount}", + "lsp_range": f"{asset.min_initial_lsp_amount} -> {asset.max_initial_lsp_amount}", + "channel_range": f"{asset.min_channel_amount} -> {asset.max_channel_amount}", + } + for asset in (resp.assets or []) + ], + item_title="LSP Asset — {index}", + empty_msg="No asset-backed channel options reported.", + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..636caf0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,128 @@ +"""Shared fixtures for kaleido-cli tests.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from typer.testing import CliRunner + +# --------------------------------------------------------------------------- +# CLI runner +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def runner() -> CliRunner: + """Typer CliRunner used by all CLI command tests.""" + return CliRunner() + + +# --------------------------------------------------------------------------- +# Isolated config (never touches ~/.kaleido) +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def isolated_config(tmp_path, monkeypatch): + """Redirect config reads/writes to a temp directory.""" + import kaleido_cli.config as cfg_mod + + config_dir = tmp_path / ".kaleido" + config_file = config_dir / "config.json" + + monkeypatch.setattr(cfg_mod, "CONFIG_DIR", config_dir) + monkeypatch.setattr(cfg_mod, "CONFIG_FILE", config_file) + # Also patch the names imported by config_cmd.py + import kaleido_cli.commands.config_cmd as cmd_mod + + monkeypatch.setattr(cmd_mod, "CONFIG_FILE", config_file) + return config_file + + +# --------------------------------------------------------------------------- +# Output mode reset (json/agent flags are module-level globals) +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def reset_output_flags(): + """Ensure json/agent flags are reset to defaults between tests.""" + import kaleido_cli.output as out + + out.set_json_mode(False) + out.set_agent_mode(False) + yield + out.set_json_mode(False) + out.set_agent_mode(False) + + +# --------------------------------------------------------------------------- +# Mocked KaleidoClient +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def mock_client(mocker): + """ + Return a MagicMock KaleidoClient whose .maker and .rln sub-objects + expose AsyncMock methods. + + Because each command module does `from kaleido_cli.context import get_client`, + we must patch the name in every module that references it, not just in context. + """ + client = MagicMock() + + # ---- maker sub-client ---- + maker = MagicMock() + maker.list_assets = AsyncMock() + maker.list_pairs = AsyncMock() + maker.get_quote = AsyncMock() + maker.get_swap_node_info = AsyncMock() + maker.get_pair_routes = AsyncMock() + maker.get_order_analytics = AsyncMock() + maker.get_order_history = AsyncMock() + maker.get_swap_order_status = AsyncMock() + maker.get_atomic_swap_status = AsyncMock() + maker.create_swap_order = AsyncMock() + maker.init_swap = AsyncMock() + maker.execute_swap = AsyncMock() + client.maker = maker + + # ---- rln sub-client ---- + rln = MagicMock() + rln.get_address = AsyncMock() + rln.get_btc_balance = AsyncMock() + rln.send_btc = AsyncMock() + rln.list_unspents = AsyncMock() + rln.list_transactions = AsyncMock() + rln.estimate_fee = AsyncMock() + rln.shutdown = AsyncMock() + rln.backup = AsyncMock() + rln.restore = AsyncMock() + rln.change_password = AsyncMock() + rln.create_utxos = AsyncMock() + rln.list_swaps = AsyncMock() + rln.get_taker_pubkey = AsyncMock() + rln.maker_init = AsyncMock() + rln.whitelist_swap = AsyncMock() + rln.maker_execute = AsyncMock() + client.rln = rln + + # Patch get_client in the context module AND in every command module that + # imported it locally via `from kaleido_cli.context import get_client`. + _targets = [ + "kaleido_cli.context.get_client", + "kaleido_cli.commands.market.get_client", + "kaleido_cli.commands.wallet.get_client", + "kaleido_cli.commands.swap.get_client", + "kaleido_cli.commands.asset.get_client", + "kaleido_cli.commands.channel.get_client", + "kaleido_cli.commands.node.get_client", + "kaleido_cli.commands.payment.get_client", + "kaleido_cli.commands.peer.get_client", + ] + for target in _targets: + mocker.patch(target, return_value=client) + + return client diff --git a/tests/test_cmd_config.py b/tests/test_cmd_config.py new file mode 100644 index 0000000..dd45397 --- /dev/null +++ b/tests/test_cmd_config.py @@ -0,0 +1,89 @@ +"""Tests for `kaleido config` CLI commands.""" + +from __future__ import annotations + +import json + +from kaleido_cli.app import app +from kaleido_cli.config import DEFAULT_NETWORK + +# --------------------------------------------------------------------------- +# config show +# --------------------------------------------------------------------------- + + +def test_config_show_table(runner, isolated_config): + result = runner.invoke(app, ["config", "show"]) + assert result.exit_code == 0 + assert "api-url" in result.output or "api_url" in result.output + + +def test_config_show_json(runner, isolated_config): + result = runner.invoke(app, ["--json", "config", "show"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert "api_url" in data + assert "node_url" in data + assert "network" in data + + +# --------------------------------------------------------------------------- +# config set +# --------------------------------------------------------------------------- + + +def test_config_set_node_url(runner, isolated_config): + result = runner.invoke(app, ["config", "set", "node-url", "http://mynode:3001"]) + assert result.exit_code == 0 + assert "node-url" in result.output + + # Verify persistence + saved = json.loads(isolated_config.read_text()) + assert saved["node_url"] == "http://mynode:3001" + + +def test_config_set_api_url(runner, isolated_config): + result = runner.invoke(app, ["config", "set", "api-url", "http://myapi"]) + assert result.exit_code == 0 + saved = json.loads(isolated_config.read_text()) + assert saved["api_url"] == "http://myapi" + + +def test_config_set_network(runner, isolated_config): + result = runner.invoke(app, ["config", "set", "network", "mainnet"]) + assert result.exit_code == 0 + saved = json.loads(isolated_config.read_text()) + assert saved["network"] == "mainnet" + + +def test_config_set_unknown_key_exits_1(runner, isolated_config): + result = runner.invoke(app, ["config", "set", "bad-key", "val"]) + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# config reset +# --------------------------------------------------------------------------- + + +def test_config_reset_with_yes_flag(runner, isolated_config): + # First set something custom + runner.invoke(app, ["config", "set", "network", "regtest"]) + # Then reset + result = runner.invoke(app, ["config", "reset", "--yes"]) + assert result.exit_code == 0 + saved = json.loads(isolated_config.read_text()) + assert saved["network"] == DEFAULT_NETWORK + + +# --------------------------------------------------------------------------- +# config path +# --------------------------------------------------------------------------- + + +def test_config_path(runner, isolated_config): + result = runner.invoke(app, ["config", "path"]) + assert result.exit_code == 0 + # Rich may line-wrap long paths — join lines to get the logical string + output_joined = result.output.replace("\n", "") + assert str(isolated_config) in output_joined diff --git a/tests/test_cmd_market.py b/tests/test_cmd_market.py new file mode 100644 index 0000000..a400b79 --- /dev/null +++ b/tests/test_cmd_market.py @@ -0,0 +1,188 @@ +"""Tests for `kaleido market` CLI commands.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from kaleido_cli.app import app + +# --------------------------------------------------------------------------- +# Helpers — build lightweight mock Pydantic-like objects +# --------------------------------------------------------------------------- + + +def _asset(ticker="BTC", name="Bitcoin", protocol_ids=None, precision=8): + m = MagicMock() + m.ticker = ticker + m.name = name + m.protocol_ids = protocol_ids or {"RGB": f"rgb:{ticker.lower()}"} + m.precision = precision + return m + + +def _pair(base_ticker="BTC", quote_ticker="USDT", routes=None, is_active=True): + p = MagicMock() + p.base = _asset(base_ticker, precision=8) + p.quote = _asset(quote_ticker, precision=6) + p.routes = routes or [MagicMock()] + p.is_active = is_active + return p + + +def _quote_response(rfq_id="rfq-1"): + m = MagicMock() + m.model_dump.return_value = {"rfq_id": rfq_id, "from_amount": 100, "to_amount": 50} + return m + + +def _route(from_layer="BTC_LN", to_layer="RGB_LN"): + m = MagicMock() + m.from_layer = from_layer + m.to_layer = to_layer + m.model_dump.return_value = {"from_layer": from_layer, "to_layer": to_layer} + return m + + +# --------------------------------------------------------------------------- +# market assets +# --------------------------------------------------------------------------- + + +def test_market_assets_table(runner, mock_client): + assets_resp = MagicMock() + assets_resp.assets = [_asset()] + mock_client.maker.list_assets.return_value = assets_resp + + result = runner.invoke(app, ["market", "assets"]) + assert result.exit_code == 0 + + +def test_market_assets_empty(runner, mock_client): + assets_resp = MagicMock() + assets_resp.assets = [] + mock_client.maker.list_assets.return_value = assets_resp + + result = runner.invoke(app, ["market", "assets"]) + assert result.exit_code == 0 + + +def test_market_assets_json(runner, mock_client): + assets_resp = MagicMock() + assets_resp.assets = [_asset()] + assets_resp.model_dump.return_value = {"assets": []} + mock_client.maker.list_assets.return_value = assets_resp + + result = runner.invoke(app, ["--json", "market", "assets"]) + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# market pairs +# --------------------------------------------------------------------------- + + +def test_market_pairs_table(runner, mock_client): + pairs_resp = MagicMock() + pairs_resp.pairs = [_pair()] + mock_client.maker.list_pairs.return_value = pairs_resp + + result = runner.invoke(app, ["market", "pairs"]) + assert result.exit_code == 0 + + +def test_market_pairs_empty(runner, mock_client): + pairs_resp = MagicMock() + pairs_resp.pairs = [] + mock_client.maker.list_pairs.return_value = pairs_resp + + result = runner.invoke(app, ["market", "pairs"]) + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# market quote +# --------------------------------------------------------------------------- + + +def test_market_quote_from_amount(runner, mock_client): + pairs_resp = MagicMock() + pairs_resp.pairs = [_pair()] + mock_client.maker.list_pairs.return_value = pairs_resp + mock_client.maker.get_quote.return_value = _quote_response() + + result = runner.invoke(app, ["market", "quote", "BTC/USDT", "--from-amount", "100000"]) + assert result.exit_code == 0 + + +def test_market_quote_both_amounts_exits_1(runner, mock_client): + result = runner.invoke( + app, ["market", "quote", "BTC/USDT", "--from-amount", "100", "--to-amount", "50"] + ) + assert result.exit_code == 1 + + +def test_market_quote_no_amount_agent_mode_exits_1(runner, mock_client): + result = runner.invoke(app, ["--agent", "market", "quote", "BTC/USDT"]) + assert result.exit_code == 1 + + +def test_market_quote_pair_not_found_exits_1(runner, mock_client): + pairs_resp = MagicMock() + pairs_resp.pairs = [_pair()] # only BTC/USDT + mock_client.maker.list_pairs.return_value = pairs_resp + + result = runner.invoke(app, ["market", "quote", "ETH/BTC", "--from-amount", "1"]) + assert result.exit_code == 1 + + +def test_market_quote_no_pair_agent_mode_exits_1(runner, mock_client): + result = runner.invoke(app, ["--agent", "market", "quote", "--from-amount", "100"]) + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# market info +# --------------------------------------------------------------------------- + + +def test_market_info(runner, mock_client): + info = MagicMock() + info.model_dump.return_value = {"node_id": "abc123"} + mock_client.maker.get_swap_node_info.return_value = info + + result = runner.invoke(app, ["market", "info"]) + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# market routes +# --------------------------------------------------------------------------- + + +def test_market_routes(runner, mock_client): + pairs_resp = MagicMock() + pairs_resp.pairs = [_pair()] + mock_client.maker.list_pairs.return_value = pairs_resp + mock_client.maker.get_pair_routes.return_value = [_route(), _route("RGB_LN", "BTC_LN")] + + result = runner.invoke(app, ["market", "routes", "BTC/USDT"]) + assert result.exit_code == 0 + + +def test_market_routes_no_pair_agent_mode_exits_1(runner, mock_client): + result = runner.invoke(app, ["--agent", "market", "routes"]) + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# market analytics +# --------------------------------------------------------------------------- + + +def test_market_analytics(runner, mock_client): + stats = MagicMock() + stats.model_dump.return_value = {"total_orders": 42} + mock_client.maker.get_order_analytics.return_value = stats + + result = runner.invoke(app, ["market", "analytics"]) + assert result.exit_code == 0 diff --git a/tests/test_cmd_swap.py b/tests/test_cmd_swap.py new file mode 100644 index 0000000..8ecc347 --- /dev/null +++ b/tests/test_cmd_swap.py @@ -0,0 +1,308 @@ +"""Tests for `kaleido swap` CLI commands.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from kaleido_cli.app import app + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _pair(base_ticker="BTC", quote_ticker="USDT"): + p = MagicMock() + p.base = MagicMock( + ticker=base_ticker, + protocol_ids={"RGB": f"rgb:{base_ticker.lower()}"}, + precision=8, + ) + p.quote = MagicMock( + ticker=quote_ticker, + protocol_ids={"RGB": f"rgb:{quote_ticker.lower()}"}, + precision=6, + ) + p.routes = [MagicMock()] + p.is_active = True + return p + + +def _pairs_resp(pairs=None): + resp = MagicMock() + resp.pairs = pairs if pairs is not None else [_pair()] + return resp + + +def _quote(rfq_id="rfq-1"): + m = MagicMock() + m.rfq_id = rfq_id + m.from_asset = MagicMock(asset_id="BTC", amount=100000) + m.to_asset = MagicMock(asset_id="rgb:usdt", amount=500) + m.model_dump.return_value = {"rfq_id": rfq_id} + return m + + +def _order(order_id="order-1"): + m = MagicMock() + m.id = order_id + return m + + +def _swap_resp(payment_hash="hash-abc"): + m = MagicMock() + m.payment_hash = payment_hash + m.swapstring = "swapstring" + return m + + +def _confirm_resp(): + m = MagicMock() + m.model_dump.return_value = {"status": "ok"} + return m + + +# --------------------------------------------------------------------------- +# swap atomic init +# --------------------------------------------------------------------------- + + +def test_swap_atomic_init_from_amount(runner, mock_client): + mock_client.maker.list_pairs.return_value = _pairs_resp() + mock_client.maker.get_quote.return_value = _quote() + mock_client.maker.init_swap.return_value = _swap_resp() + + result = runner.invoke( + app, ["swap", "atomic", "init", "BTC/USDT", "--from-amount", "100000", "--yes"] + ) + assert result.exit_code == 0 + + +def test_swap_atomic_init_to_amount(runner, mock_client): + mock_client.maker.list_pairs.return_value = _pairs_resp() + mock_client.maker.get_quote.return_value = _quote() + mock_client.maker.init_swap.return_value = _swap_resp() + + result = runner.invoke( + app, ["swap", "atomic", "init", "BTC/USDT", "--to-amount", "500", "--yes"] + ) + assert result.exit_code == 0 + + +def test_swap_atomic_init_both_amounts_exits_1(runner, mock_client): + result = runner.invoke( + app, ["swap", "atomic", "init", "BTC/USDT", "--from-amount", "100", "--to-amount", "50"] + ) + assert result.exit_code == 1 + + +def test_swap_atomic_init_no_amount_agent_mode_exits_1(runner, mock_client): + result = runner.invoke(app, ["--agent", "swap", "atomic", "init", "BTC/USDT"]) + assert result.exit_code == 1 + + +def test_swap_atomic_init_pair_not_found_exits_1(runner, mock_client): + mock_client.maker.list_pairs.return_value = _pairs_resp([_pair()]) + result = runner.invoke( + app, ["swap", "atomic", "init", "ETH/BTC", "--from-amount", "1", "--yes"] + ) + assert result.exit_code == 1 + + +def test_swap_atomic_init_no_pair_agent_mode_exits_1(runner, mock_client): + result = runner.invoke(app, ["--agent", "swap", "atomic", "init", "--from-amount", "100"]) + assert result.exit_code != 0 + + +def test_swap_atomic_init_json(runner, mock_client): + mock_client.maker.list_pairs.return_value = _pairs_resp() + mock_client.maker.get_quote.return_value = _quote() + mock_client.maker.init_swap.return_value = _swap_resp() + + result = runner.invoke( + app, ["--json", "swap", "atomic", "init", "BTC/USDT", "--from-amount", "1", "--yes"] + ) + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# swap history +# --------------------------------------------------------------------------- + + +def test_swap_history_table(runner, mock_client): + order = MagicMock() + order.id = "abc123456789abcd" + order.status = "FILLED" + order.from_asset = "BTC" + order.to_asset = "USDT" + order.created_at = "2024-01-01" + + resp = MagicMock() + resp.data = [order] + resp.model_dump.return_value = {} + mock_client.maker.get_order_history.return_value = resp + + result = runner.invoke(app, ["swap", "order", "history"]) + assert result.exit_code == 0 + mock_client.maker.get_order_history.assert_awaited_once_with(status=None, limit=20) + + +def test_swap_history_status_filter(runner, mock_client): + resp = MagicMock() + resp.data = [] + mock_client.maker.get_order_history.return_value = resp + + result = runner.invoke(app, ["swap", "order", "history", "--status", "FAILED", "--limit", "5"]) + assert result.exit_code == 0 + mock_client.maker.get_order_history.assert_awaited_once_with(status="FAILED", limit=5) + + +def test_swap_history_json(runner, mock_client): + resp = MagicMock() + resp.data = [] + resp.model_dump.return_value = {"data": []} + mock_client.maker.get_order_history.return_value = resp + + result = runner.invoke(app, ["--json", "swap", "order", "history"]) + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# swap status +# --------------------------------------------------------------------------- + + +def test_swap_status(runner, mock_client): + resp = MagicMock() + resp.model_dump.return_value = {"status": "FILLED"} + mock_client.maker.get_swap_order_status.return_value = resp + + result = runner.invoke(app, ["swap", "order", "status", "abc123456789abcd"]) + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# swap node-swaps +# --------------------------------------------------------------------------- + + +def test_swap_node_swaps_table(runner, mock_client): + swap = MagicMock() + swap.payment_hash = "abcdef1234567890" + swap.status = "PENDING" + + resp = MagicMock() + resp.taker = [swap] + resp.maker = [] + resp.model_dump.return_value = {} + mock_client.rln.list_swaps.return_value = resp + + result = runner.invoke(app, ["swap", "node", "list"]) + assert result.exit_code == 0 + + +def test_swap_node_swaps_empty(runner, mock_client): + resp = MagicMock() + resp.taker = [] + resp.maker = [] + mock_client.rln.list_swaps.return_value = resp + + result = runner.invoke(app, ["swap", "node", "list"]) + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# swap atomic execute +# --------------------------------------------------------------------------- + + +def test_swap_atomic_execute_happy_path(runner, mock_client): + mock_client.maker.execute_swap.return_value = _confirm_resp() + + result = runner.invoke( + app, + [ + "swap", + "atomic", + "execute", + "--swapstring", + "swapstring", + "--taker-pubkey", + "taker-pub-key", + "--payment-hash", + "hash-1", + ], + ) + assert result.exit_code == 0 + mock_client.maker.execute_swap.assert_awaited_once() + + +def test_swap_atomic_execute_missing_swapstring_agent_mode_exits_1(runner, mock_client): + result = runner.invoke( + app, + [ + "--agent", + "swap", + "atomic", + "execute", + "--taker-pubkey", + "taker-pub-key", + "--payment-hash", + "hash-1", + ], + ) + assert result.exit_code != 0 + + +def test_swap_atomic_execute_missing_taker_pubkey_agent_mode_exits_1(runner, mock_client): + result = runner.invoke( + app, + [ + "--agent", + "swap", + "atomic", + "execute", + "--swapstring", + "swapstring", + "--payment-hash", + "hash-1", + ], + ) + assert result.exit_code != 0 + + +def test_swap_atomic_execute_missing_payment_hash_agent_mode_exits_1(runner, mock_client): + result = runner.invoke( + app, + [ + "--agent", + "swap", + "atomic", + "execute", + "--swapstring", + "swapstring", + "--taker-pubkey", + "taker-pub-key", + ], + ) + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# swap atomic status +# --------------------------------------------------------------------------- + + +def test_swap_atomic_status(runner, mock_client): + resp = MagicMock() + resp.model_dump.return_value = {"status": "confirmed"} + mock_client.maker.get_atomic_swap_status.return_value = resp + + result = runner.invoke(app, ["swap", "atomic", "status", "abc123"]) + assert result.exit_code == 0 + + +def test_swap_atomic_status_no_hash_agent_mode_exits_1(runner, mock_client): + result = runner.invoke(app, ["--agent", "swap", "atomic", "status"]) + assert result.exit_code != 0 diff --git a/tests/test_cmd_wallet.py b/tests/test_cmd_wallet.py new file mode 100644 index 0000000..ec63b02 --- /dev/null +++ b/tests/test_cmd_wallet.py @@ -0,0 +1,177 @@ +"""Tests for `kaleido wallet` CLI commands.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from kaleido_cli.app import app + +# --------------------------------------------------------------------------- +# wallet balance +# --------------------------------------------------------------------------- + + +def test_wallet_balance_panel(runner, mock_client): + resp = MagicMock() + resp.model_dump.return_value = {"vanilla": 100000, "colored": 0} + mock_client.rln.get_btc_balance.return_value = resp + + result = runner.invoke(app, ["wallet", "balance"]) + assert result.exit_code == 0 + mock_client.rln.get_btc_balance.assert_awaited_once() + + +def test_wallet_balance_json(runner, mock_client): + resp = MagicMock() + resp.model_dump.return_value = {"vanilla": 100000, "colored": 0} + mock_client.rln.get_btc_balance.return_value = resp + + result = runner.invoke(app, ["--json", "wallet", "balance"]) + assert result.exit_code == 0 + + +def test_wallet_balance_skip_sync(runner, mock_client): + resp = MagicMock() + resp.model_dump.return_value = {} + mock_client.rln.get_btc_balance.return_value = resp + + result = runner.invoke(app, ["wallet", "balance", "--skip-sync"]) + assert result.exit_code == 0 + call_kwargs = mock_client.rln.get_btc_balance.call_args + assert call_kwargs.kwargs.get("skip_sync") is True + + +# --------------------------------------------------------------------------- +# wallet address +# --------------------------------------------------------------------------- + + +def test_wallet_address(runner, mock_client): + resp = MagicMock() + resp.address = "bc1qtest123" + resp.model_dump.return_value = {"address": "bc1qtest123"} + mock_client.rln.get_address.return_value = resp + + result = runner.invoke(app, ["wallet", "address"]) + assert result.exit_code == 0 + assert "bc1qtest123" in result.output + + +def test_wallet_address_json(runner, mock_client): + resp = MagicMock() + resp.address = "bc1qtest123" + resp.model_dump.return_value = {"address": "bc1qtest123"} + mock_client.rln.get_address.return_value = resp + + result = runner.invoke(app, ["--json", "wallet", "address"]) + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# wallet send +# --------------------------------------------------------------------------- + + +def test_wallet_send_happy_path(runner, mock_client): + resp = MagicMock() + resp.txid = "deadbeef" + resp.model_dump.return_value = {"txid": "deadbeef"} + mock_client.rln.send_btc.return_value = resp + + result = runner.invoke(app, ["wallet", "send", "50000", "bc1qdest"]) + assert result.exit_code == 0 + assert "deadbeef" in result.output + + +def test_wallet_send_no_args_agent_mode_exits_1(runner, mock_client): + result = runner.invoke(app, ["--agent", "wallet", "send"]) + assert result.exit_code == 1 + + +def test_wallet_send_no_address_agent_mode_exits_1(runner, mock_client): + result = runner.invoke(app, ["--agent", "wallet", "send", "1000"]) + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# wallet utxos +# --------------------------------------------------------------------------- + + +def test_wallet_utxos_table(runner, mock_client): + utxo = MagicMock() + utxo.utxo = MagicMock(outpoint="txid:0", btc_amount=10000) + utxo.rgb_allocations = [] + + resp = MagicMock() + resp.unspents = [utxo] + resp.model_dump.return_value = {} + mock_client.rln.list_unspents.return_value = resp + + result = runner.invoke(app, ["wallet", "utxos"]) + assert result.exit_code == 0 + + +def test_wallet_utxos_empty(runner, mock_client): + resp = MagicMock() + resp.unspents = [] + mock_client.rln.list_unspents.return_value = resp + + result = runner.invoke(app, ["wallet", "utxos"]) + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# wallet transactions +# --------------------------------------------------------------------------- + + +def test_wallet_transactions_table(runner, mock_client): + tx = MagicMock() + tx.txid = "abc" + tx.received = 5000 + tx.sent = 0 + tx.fee = 100 + tx.confirmation_time = "2024-01-01" + + resp = MagicMock() + resp.transactions = [tx] + mock_client.rln.list_transactions.return_value = resp + + result = runner.invoke(app, ["wallet", "transactions"]) + assert result.exit_code == 0 + + +def test_wallet_transactions_empty(runner, mock_client): + resp = MagicMock() + resp.transactions = [] + mock_client.rln.list_transactions.return_value = resp + + result = runner.invoke(app, ["wallet", "transactions"]) + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# wallet estimate-fee +# --------------------------------------------------------------------------- + + +def test_wallet_estimate_fee(runner, mock_client): + resp = MagicMock() + resp.fee_rate = 4.5 + resp.model_dump.return_value = {"fee_rate": 4.5} + mock_client.rln.estimate_fee.return_value = resp + + result = runner.invoke(app, ["wallet", "estimate-fee", "--blocks", "3"]) + assert result.exit_code == 0 + assert "4.5" in result.output + + +def test_wallet_estimate_fee_json(runner, mock_client): + resp = MagicMock() + resp.fee_rate = 2.0 + resp.model_dump.return_value = {"fee_rate": 2.0} + mock_client.rln.estimate_fee.return_value = resp + + result = runner.invoke(app, ["--json", "wallet", "estimate-fee"]) + assert result.exit_code == 0 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..926b3ae --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,126 @@ +"""Tests for kaleido_cli.config.""" + +from __future__ import annotations + +import json + +import pytest + +from kaleido_cli.config import ( + DEFAULT_API_URL, + DEFAULT_NETWORK, + DEFAULT_NODE_URL, + CliConfig, + load_config, + normalize_network_name, + save_config, + set_config_key, +) + +# --------------------------------------------------------------------------- +# CliConfig dataclass +# --------------------------------------------------------------------------- + + +def test_climconfig_defaults(): + cfg = CliConfig() + assert cfg.api_url == DEFAULT_API_URL + assert cfg.node_url == DEFAULT_NODE_URL + assert cfg.network == DEFAULT_NETWORK + assert cfg.spawn_dir == "" + + +def test_cliconfig_from_dict_roundtrip(): + original = CliConfig( + api_url="http://a", node_url="http://b", network="regtest", spawn_dir="/tmp" + ) + restored = CliConfig.from_dict(original.to_dict()) + assert restored == original + + +def test_cliconfig_from_dict_ignores_unknown_keys(): + data = {"api_url": "http://x", "unknown_field": "ignored"} + cfg = CliConfig.from_dict(data) + assert cfg.api_url == "http://x" + assert not hasattr(cfg, "unknown_field") + + +def test_normalize_network_name_aliases_mutinynet_to_rln_network(): + assert normalize_network_name("mutinynet") == "signetcustom" + assert normalize_network_name("signetcustom") == "signetcustom" + assert normalize_network_name("customsignet") == "signetcustom" + assert normalize_network_name(" SignetCustom ") == "signetcustom" + assert normalize_network_name("signet") == "signet" + + +# --------------------------------------------------------------------------- +# load_config +# --------------------------------------------------------------------------- + + +def test_load_config_returns_defaults_when_file_missing(isolated_config): + assert not isolated_config.exists() + cfg = load_config() + assert cfg.api_url == DEFAULT_API_URL + + +def test_load_config_reads_existing_file(isolated_config): + isolated_config.parent.mkdir(parents=True, exist_ok=True) + isolated_config.write_text(json.dumps({"api_url": "http://custom", "node_url": "http://n"})) + cfg = load_config() + assert cfg.api_url == "http://custom" + assert cfg.node_url == "http://n" + + +def test_load_config_returns_defaults_on_corrupt_file(isolated_config): + isolated_config.parent.mkdir(parents=True, exist_ok=True) + isolated_config.write_text("NOT JSON {{") + cfg = load_config() + assert cfg.api_url == DEFAULT_API_URL + + +# --------------------------------------------------------------------------- +# save_config +# --------------------------------------------------------------------------- + + +def test_save_config_creates_file(isolated_config): + cfg = CliConfig(api_url="http://saved") + save_config(cfg) + assert isolated_config.exists() + data = json.loads(isolated_config.read_text()) + assert data["api_url"] == "http://saved" + + +def test_save_config_creates_parent_dirs(isolated_config): + assert not isolated_config.parent.exists() + save_config(CliConfig()) + assert isolated_config.exists() + + +# --------------------------------------------------------------------------- +# set_config_key +# --------------------------------------------------------------------------- + + +def test_set_config_key_updates_node_url(isolated_config): + set_config_key("node-url", "http://new-node") + cfg = load_config() + assert cfg.node_url == "http://new-node" + + +def test_set_config_key_updates_api_url(isolated_config): + set_config_key("api-url", "http://new-api") + cfg = load_config() + assert cfg.api_url == "http://new-api" + + +def test_set_config_key_updates_network(isolated_config): + set_config_key("network", "mainnet") + cfg = load_config() + assert cfg.network == "mainnet" + + +def test_set_config_key_raises_for_unknown_key(isolated_config): + with pytest.raises(KeyError, match="Unknown config key"): + set_config_key("totally-unknown", "value") diff --git a/tests/test_docker_manager.py b/tests/test_docker_manager.py new file mode 100644 index 0000000..deae74d --- /dev/null +++ b/tests/test_docker_manager.py @@ -0,0 +1,24 @@ +"""Tests for Docker compose generation.""" + +from __future__ import annotations + +import yaml + +from kaleido_cli.docker_manager import SpawnConfig, SpawnManager + + +def test_spawn_manager_writes_mutinynet_as_rln_signetcustom(tmp_path): + manager = SpawnManager( + SpawnConfig( + name="mutiny", + spawn_base_dir=str(tmp_path), + network="mutinynet", + ) + ) + + compose_path = manager.generate_compose() + compose = yaml.safe_load(compose_path.read_text()) + node = compose["services"]["rgb_node_1"] + + assert "--network signetcustom" in node["command"] + assert node["environment"]["NETWORK"] == "${NETWORK:-signetcustom}" diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 0000000..5106a14 --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,85 @@ +"""Tests for kaleido_cli.output helpers.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import kaleido_cli.output as out + +# --------------------------------------------------------------------------- +# JSON / agent mode flags +# --------------------------------------------------------------------------- + + +def test_json_mode_default_is_false(): + assert not out.is_json_mode() + + +def test_set_json_mode_toggles(): + out.set_json_mode(True) + assert out.is_json_mode() + out.set_json_mode(False) + assert not out.is_json_mode() + + +def test_set_agent_mode_affects_is_interactive(): + # In test env stdin/stdout are not TTYs, so is_interactive() is already False, + # but we can confirm agent mode alone is respected too. + out.set_agent_mode(True) + assert not out.is_interactive() + out.set_agent_mode(False) + + +def test_is_interactive_false_in_test_env(): + """Tests run outside a real TTY — is_interactive() must be False.""" + assert not out.is_interactive() + + +# --------------------------------------------------------------------------- +# _flatten_dict +# --------------------------------------------------------------------------- + + +def test_flatten_dict_simple(): + result = out._flatten_dict({"a": 1, "b": 2}) + assert ("a", 1) in result + assert ("b", 2) in result + + +def test_flatten_dict_nested(): + result = out._flatten_dict({"outer": {"inner": 42}}) + assert ("outer.inner", 42) in result + + +def test_flatten_dict_list_of_dicts(): + result = out._flatten_dict({"items": [{"x": 1}, {"x": 2}]}) + assert ("items[0].x", 1) in result + assert ("items[1].x", 2) in result + + +def test_flatten_dict_list_of_scalars(): + result = out._flatten_dict({"tags": ["a", "b"]}) + assert ("tags", ["a", "b"]) in result + + +# --------------------------------------------------------------------------- +# output_model +# --------------------------------------------------------------------------- + + +def test_output_model_json_mode(): + out.set_json_mode(True) + data = MagicMock() + data.model_dump.return_value = {"key": "value"} + with patch.object(out, "print_json") as mock_print_json: + out.output_model(data, title="Test") + mock_print_json.assert_called_once_with({"key": "value"}) + + +def test_output_model_panel_mode(): + out.set_json_mode(False) + data = MagicMock() + data.model_dump.return_value = {"key": "hello"} + with patch.object(out.console, "print") as mock_print: + out.output_model(data, title="Test Panel") + mock_print.assert_called_once()