From d8b2c33c77c5e87629d1c6819689b0d8a74d89cc Mon Sep 17 00:00:00 2001 From: bitwalt Date: Tue, 7 Apr 2026 18:23:50 +0200 Subject: [PATCH 1/9] Default CLI node setup to mutinynet --- README.md | 18 +++--- kaleido_cli/commands/config_cmd.py | 10 +-- kaleido_cli/commands/node.py | 97 ++++++++++++++++++++++++------ kaleido_cli/config.py | 22 ++++++- kaleido_cli/docker_manager.py | 8 ++- 5 files changed, 121 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 107ea21..e4d56c9 100644 --- a/README.md +++ b/README.md @@ -99,8 +99,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 +108,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` @@ -138,17 +138,20 @@ The CLI uses a **named environment** model. Each environment is an isolated Dock ### Creating an environment ```bash +kaleido node setup # create/start one mutinynet node with defaults 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`) 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 @@ -212,6 +215,7 @@ kaleido --json market pairs | Command | Description | |-------------------------------------------|-----------------------------------------------------| | `kaleido setup` | Guided first-run setup for market-only or local use | +| `kaleido node setup` | Create/start one mutinynet node with defaults | | `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 | @@ -342,10 +346,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 node setup ``` ### Working with multiple nodes diff --git a/kaleido_cli/commands/config_cmd.py b/kaleido_cli/commands/config_cmd.py index 426ee06..4dfb228 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,8 +32,8 @@ "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" + 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 spawned nodes (default: ~/.kaleido/spawn)\n" ), ) @@ -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 a7a76c8..542fcd8 100644 --- a/kaleido_cli/commands/node.py +++ b/kaleido_cli/commands/node.py @@ -9,6 +9,16 @@ import typer from kaleido_sdk.rln import TakerRequest +from kaleido_cli.config import ( + DEFAULT_API_URL, + 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, @@ -19,6 +29,7 @@ SpawnManager, list_spawn_names, ) +from kaleido_cli.onboarding import SetupMode, run_setup from kaleido_cli.output import ( is_interactive, is_json_mode, @@ -36,6 +47,7 @@ help=( "Manage named RGB Lightning Node environments via Docker.\n\n" "[bold]Creating an environment:[/bold]\n" + " [cyan]kaleido node setup[/cyan] — create one mutinynet node with defaults\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" @@ -64,13 +76,6 @@ 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" - # --------------------------------------------------------------------------- # Helpers @@ -175,6 +180,62 @@ async def _taker_whitelist(swapstring: str) -> None: # --------------------------------------------------------------------------- +@node_app.command( + "setup", + epilog=( + "[bold]Examples[/bold]\n\n" + " Create and start one mutinynet node with defaults:\n" + " [cyan]kaleido node setup[/cyan]\n" + " [cyan]kaleido node setup --mutinynet[/cyan]\n\n" + " Use a custom environment name:\n" + " [cyan]kaleido node setup --env-name taker-1[/cyan]" + ), +) +def node_setup( + mutinynet: Annotated[ + bool, + typer.Option( + "--mutinynet", + help="Use Kaleidoswap custom signet/mutinynet defaults. This is also the default.", + ), + ] = False, + network: Annotated[ + str | None, + typer.Option("--network", help="Bitcoin network to pass to RLN (default: mutinynet)."), + ] = None, + spawn_dir: Annotated[ + str | None, + typer.Option("--spawn-dir", help="Base directory for local node environments."), + ] = None, + env_name: Annotated[ + str | None, + typer.Option("--env-name", help="Environment name when creating the local node."), + ] = None, + node_count: Annotated[ + int | None, + typer.Option("--node-count", min=1, help="Number of nodes to create."), + ] = None, + start: Annotated[ + bool, + typer.Option("--start/--no-start", help="Start the node environment after creating it."), + ] = True, +) -> None: + """Create a local RLN environment using Kaleidoswap mutinynet defaults.""" + resolved_network = DEFAULT_NETWORK if mutinynet or network is None else network + run_setup( + mode=SetupMode.local, + defaults=True, + api_url=DEFAULT_API_URL, + network=resolved_network, + node_url=None, + create_node=True, + spawn_dir=spawn_dir, + env_name=env_name, + node_count=node_count, + start=start, + ) + + @node_app.command( "create", epilog=( @@ -236,7 +297,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 ────────────────────────────────────────") @@ -607,7 +668,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 +699,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 +741,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 +753,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/config.py b/kaleido_cli/config.py index 12118bb..1dc0ce9 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 diff --git a/kaleido_cli/docker_manager.py b/kaleido_cli/docker_manager.py index 0e52088..1c444ec 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" @@ -73,7 +74,7 @@ class SpawnConfig: spawn_base_dir: str = "" # "" → ~/.kaleido/spawn (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": { From 3e13725f8b8354e45d4a2fd36bb6eb34b48fa357 Mon Sep 17 00:00:00 2001 From: bitwalt Date: Tue, 7 Apr 2026 18:54:36 +0200 Subject: [PATCH 2/9] Move node setup shortcut to top-level setup --- README.md | 25 ++++++++------- kaleido_cli/app.py | 41 +++++++++++++++--------- kaleido_cli/commands/node.py | 61 ++---------------------------------- 3 files changed, 42 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index e4d56c9..bb08348 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 @@ -138,7 +141,8 @@ The CLI uses a **named environment** model. Each environment is an isolated Dock ### Creating an environment ```bash -kaleido node setup # create/start one mutinynet node with defaults +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 @@ -214,8 +218,7 @@ kaleido --json market pairs | Command | Description | |-------------------------------------------|-----------------------------------------------------| -| `kaleido setup` | Guided first-run setup for market-only or local use | -| `kaleido node setup` | Create/start one mutinynet node with defaults | +| `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 | @@ -326,17 +329,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 @@ -349,7 +352,7 @@ kaleido market quote BTC/USDT --from-amount 100000 For a non-interactive local setup with mutinynet defaults: ```bash -kaleido node setup +kaleido setup ``` ### Working with multiple nodes diff --git a/kaleido_cli/app.py b/kaleido_cli/app.py index 42c403b..0a508d8 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, diff --git a/kaleido_cli/commands/node.py b/kaleido_cli/commands/node.py index 542fcd8..6cdaee5 100644 --- a/kaleido_cli/commands/node.py +++ b/kaleido_cli/commands/node.py @@ -10,7 +10,6 @@ from kaleido_sdk.rln import TakerRequest from kaleido_cli.config import ( - DEFAULT_API_URL, DEFAULT_BITCOIND_RPC_HOST, DEFAULT_BITCOIND_RPC_PASSWORD, DEFAULT_BITCOIND_RPC_PORT, @@ -29,7 +28,6 @@ SpawnManager, list_spawn_names, ) -from kaleido_cli.onboarding import SetupMode, run_setup from kaleido_cli.output import ( is_interactive, is_json_mode, @@ -47,7 +45,8 @@ help=( "Manage named RGB Lightning Node environments via Docker.\n\n" "[bold]Creating an environment:[/bold]\n" - " [cyan]kaleido node setup[/cyan] — create one mutinynet node with defaults\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" @@ -180,62 +179,6 @@ async def _taker_whitelist(swapstring: str) -> None: # --------------------------------------------------------------------------- -@node_app.command( - "setup", - epilog=( - "[bold]Examples[/bold]\n\n" - " Create and start one mutinynet node with defaults:\n" - " [cyan]kaleido node setup[/cyan]\n" - " [cyan]kaleido node setup --mutinynet[/cyan]\n\n" - " Use a custom environment name:\n" - " [cyan]kaleido node setup --env-name taker-1[/cyan]" - ), -) -def node_setup( - mutinynet: Annotated[ - bool, - typer.Option( - "--mutinynet", - help="Use Kaleidoswap custom signet/mutinynet defaults. This is also the default.", - ), - ] = False, - network: Annotated[ - str | None, - typer.Option("--network", help="Bitcoin network to pass to RLN (default: mutinynet)."), - ] = None, - spawn_dir: Annotated[ - str | None, - typer.Option("--spawn-dir", help="Base directory for local node environments."), - ] = None, - env_name: Annotated[ - str | None, - typer.Option("--env-name", help="Environment name when creating the local node."), - ] = None, - node_count: Annotated[ - int | None, - typer.Option("--node-count", min=1, help="Number of nodes to create."), - ] = None, - start: Annotated[ - bool, - typer.Option("--start/--no-start", help="Start the node environment after creating it."), - ] = True, -) -> None: - """Create a local RLN environment using Kaleidoswap mutinynet defaults.""" - resolved_network = DEFAULT_NETWORK if mutinynet or network is None else network - run_setup( - mode=SetupMode.local, - defaults=True, - api_url=DEFAULT_API_URL, - network=resolved_network, - node_url=None, - create_node=True, - spawn_dir=spawn_dir, - env_name=env_name, - node_count=node_count, - start=start, - ) - - @node_app.command( "create", epilog=( From 5519d40a9958a3737983fdfb999b06dedcef6403 Mon Sep 17 00:00:00 2001 From: bitwalt Date: Tue, 7 Apr 2026 19:14:29 +0200 Subject: [PATCH 3/9] Reuse existing default setup environment --- kaleido_cli/onboarding.py | 71 +++++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/kaleido_cli/onboarding.py b/kaleido_cli/onboarding.py index e1871d6..8e5eb61 100644 --- a/kaleido_cli/onboarding.py +++ b/kaleido_cli/onboarding.py @@ -9,7 +9,7 @@ import typer from .config import load_config, save_config -from .docker_manager import DEFAULT_SPAWN_DIR, SpawnConfig, SpawnManager +from .docker_manager import DEFAULT_SPAWN_DIR, DockerManager, SpawnConfig, SpawnManager from .output import print_error, print_info, print_panel, print_success @@ -68,7 +68,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.", ) @@ -139,24 +139,6 @@ def run_setup( use_defaults=defaults, ) env_dir = base_dir / resolved_env_name - if (env_dir / "docker-compose.yml").exists(): - if defaults: - print_error( - f"Environment '{resolved_env_name}' already exists at {env_dir}. " - "Choose a different --env-name or reuse it with 'kaleido node use'." - ) - raise typer.Exit(1) - overwrite = typer.confirm( - f"Environment '{resolved_env_name}' already exists at {env_dir}. Overwrite?", - default=False, - ) - if not overwrite: - print_info("Aborted.") - raise typer.Exit(0) - - config.spawn_dir = str(base_dir) - save_config(config) - manager = SpawnManager( SpawnConfig( name=resolved_env_name, @@ -166,13 +148,50 @@ def run_setup( spawn_base_dir=str(base_dir), ) ) - rc = manager.spawn(start=created_env_started) - if rc != 0: - raise typer.Exit(rc) + if (env_dir / "docker-compose.yml").exists(): + if defaults: + existing_manager = DockerManager(str(env_dir)) + print_info(f"Reusing existing environment '{resolved_env_name}' at {env_dir}.") + if created_env_started: + if not existing_manager._validate(): + raise typer.Exit(1) + print_info(f"Starting environment '{resolved_env_name}' …") + rc = existing_manager._run(["up", "-d"]) + if rc != 0: + raise typer.Exit(rc) + print_success(f"Environment '{resolved_env_name}' is up.") + + config.spawn_dir = str(base_dir) + urls = existing_manager.node_urls() + if not urls: + print_error( + f"Could not find any node URLs in existing environment '{resolved_env_name}'." + ) + raise typer.Exit(1) + config.node_url = urls[0] + save_config(config) + print_success(f"Active node-url → {config.node_url}") + should_create_node = False + else: + overwrite = typer.confirm( + f"Environment '{resolved_env_name}' already exists at {env_dir}. Overwrite?", + default=False, + ) + if not overwrite: + print_info("Aborted.") + raise typer.Exit(0) - config.node_url = manager.node_urls()[0] - save_config(config) - print_success(f"Active node-url → {config.node_url}") + if should_create_node: + config.spawn_dir = str(base_dir) + save_config(config) + + 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}") else: config.node_url = _value_or_prompt( node_url, From 35380f1246b459a510fe1cf4441c2d2c2f755665 Mon Sep 17 00:00:00 2001 From: bitwalt Date: Tue, 7 Apr 2026 19:17:16 +0200 Subject: [PATCH 4/9] Prompt before replacing default setup env --- kaleido_cli/commands/node.py | 4 +- kaleido_cli/onboarding.py | 84 ++++++++++++++++++------------------ 2 files changed, 43 insertions(+), 45 deletions(-) diff --git a/kaleido_cli/commands/node.py b/kaleido_cli/commands/node.py index 6cdaee5..c0a1335 100644 --- a/kaleido_cli/commands/node.py +++ b/kaleido_cli/commands/node.py @@ -562,7 +562,7 @@ async def _node_info() -> None: "init", epilog=( "[bold]Examples[/bold]\n\n" - " Interactive prompt:\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]" @@ -589,7 +589,7 @@ def node_init( resolved_password = password else: resolved_password = typer.prompt( - "Wallet password", hide_input=True, confirmation_prompt=True + "Create a new wallet password", hide_input=True, confirmation_prompt=True ) asyncio.run(_node_init(resolved_password, mnemonic)) diff --git a/kaleido_cli/onboarding.py b/kaleido_cli/onboarding.py index 8e5eb61..aa53b23 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, DockerManager, 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_base_dir(base_dir: Path, env_name: str) -> Path: + """Suggest a sibling base directory where the requested env name is available.""" + for suffix in range(2, 100): + candidate = base_dir.parent / f"{base_dir.name}-{suffix}" + if not (candidate / env_name / COMPOSE_FILE).exists(): + return candidate + return base_dir.parent / f"{base_dir.name}-new" + + def run_setup( *, mode: SetupMode | None, @@ -138,53 +147,42 @@ def run_setup( True, use_defaults=defaults, ) - env_dir = base_dir / resolved_env_name - manager = SpawnManager( - SpawnConfig( - name=resolved_env_name, - count=count, - network=config.network, - disable_authentication=True, - spawn_base_dir=str(base_dir), - ) - ) - if (env_dir / "docker-compose.yml").exists(): - if defaults: - existing_manager = DockerManager(str(env_dir)) - print_info(f"Reusing existing environment '{resolved_env_name}' at {env_dir}.") - if created_env_started: - if not existing_manager._validate(): - raise typer.Exit(1) - print_info(f"Starting environment '{resolved_env_name}' …") - rc = existing_manager._run(["up", "-d"]) - if rc != 0: - raise typer.Exit(rc) - print_success(f"Environment '{resolved_env_name}' is up.") - - config.spawn_dir = str(base_dir) - urls = existing_manager.node_urls() - if not urls: - print_error( - f"Could not find any node URLs in existing environment '{resolved_env_name}'." - ) - raise typer.Exit(1) - config.node_url = urls[0] - save_config(config) - print_success(f"Active node-url → {config.node_url}") - should_create_node = False - else: - overwrite = typer.confirm( - f"Environment '{resolved_env_name}' already exists at {env_dir}. Overwrite?", - default=False, + 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}. " + "Run interactively to overwrite it or choose a different path with --spawn-dir." ) - if not overwrite: - print_info("Aborted.") - raise typer.Exit(0) + raise typer.Exit(1) + overwrite = typer.confirm( + f"Environment '{resolved_env_name}' already exists at {env_dir}. " + "Overwrite its compose file?", + default=False, + ) + if overwrite: + print_info(f"Overwriting compose file for '{resolved_env_name}' at {env_dir}.") + break + suggested_base_dir = _next_available_base_dir(base_dir, resolved_env_name) + base_dir_input = typer.prompt( + "Choose a different base directory for node environments", + default=str(suggested_base_dir), + ) + base_dir = Path(base_dir_input).expanduser().resolve() 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), + ) + ) rc = manager.spawn(start=created_env_started) if rc != 0: raise typer.Exit(rc) From f56444e51aff4983dc96cf982dc4dd5bbdc840da Mon Sep 17 00:00:00 2001 From: bitwalt Date: Tue, 7 Apr 2026 19:20:31 +0200 Subject: [PATCH 5/9] Prefer new setup path when env exists --- kaleido_cli/onboarding.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/kaleido_cli/onboarding.py b/kaleido_cli/onboarding.py index aa53b23..770b2b8 100644 --- a/kaleido_cli/onboarding.py +++ b/kaleido_cli/onboarding.py @@ -152,23 +152,31 @@ def run_setup( if not is_interactive(): print_error( f"Environment '{resolved_env_name}' already exists at {env_dir}. " - "Run interactively to overwrite it or choose a different path with --spawn-dir." + "Choose a different path with --spawn-dir to create a new node." ) raise typer.Exit(1) - overwrite = typer.confirm( + use_different_path = typer.confirm( f"Environment '{resolved_env_name}' already exists at {env_dir}. " - "Overwrite its compose file?", - default=False, + "Create the new node in a different base directory?", + default=True, ) - if overwrite: - print_info(f"Overwriting compose file for '{resolved_env_name}' at {env_dir}.") - break - suggested_base_dir = _next_available_base_dir(base_dir, resolved_env_name) - base_dir_input = typer.prompt( - "Choose a different base directory for node environments", - default=str(suggested_base_dir), + if use_different_path: + suggested_base_dir = _next_available_base_dir(base_dir, resolved_env_name) + base_dir_input = typer.prompt( + "Choose a different base directory for node environments", + default=str(suggested_base_dir), + ) + base_dir = Path(base_dir_input).expanduser().resolve() + continue + overwrite = typer.confirm( + "Overwrite only the compose file? Existing node data may still be reused.", + default=False, ) - base_dir = Path(base_dir_input).expanduser().resolve() + if not overwrite: + print_info("Aborted.") + raise typer.Exit(0) + print_info(f"Overwriting compose file for '{resolved_env_name}' at {env_dir}.") + break if should_create_node: config.spawn_dir = str(base_dir) From 784f44d9380c16d89dd307b3676257ad4424b854 Mon Sep 17 00:00:00 2001 From: bitwalt Date: Tue, 7 Apr 2026 19:43:31 +0200 Subject: [PATCH 6/9] Store node environments under kaleido config dir --- README.md | 11 ++++++----- kaleido_cli/app.py | 4 +--- kaleido_cli/commands/config_cmd.py | 4 ++-- kaleido_cli/commands/node.py | 8 +++++--- kaleido_cli/config.py | 4 ++-- kaleido_cli/docker_manager.py | 4 ++-- kaleido_cli/onboarding.py | 28 +++++++++++++++------------- 7 files changed, 33 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index bb08348..6022538 100644 --- a/README.md +++ b/README.md @@ -123,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/ @@ -152,7 +153,7 @@ kaleido node create testenv 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** — `mutinynet` (default), `signetcustom`/`customsignet`, `signet`, `regtest`, or `mainnet` @@ -186,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 ``` diff --git a/kaleido_cli/app.py b/kaleido_cli/app.py index 0a508d8..faeebb6 100644 --- a/kaleido_cli/app.py +++ b/kaleido_cli/app.py @@ -173,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." ) diff --git a/kaleido_cli/commands/config_cmd.py b/kaleido_cli/commands/config_cmd.py index 4dfb228..20491a4 100644 --- a/kaleido_cli/commands/config_cmd.py +++ b/kaleido_cli/commands/config_cmd.py @@ -34,7 +34,7 @@ " [green]node-url[/green] URL of your RGB Lightning Node (default: http://localhost:3001)\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 spawned nodes (default: ~/.kaleido/spawn)\n" + " [green]spawn-dir[/green] Directory for node environments (default: ~/.kaleido)\n" ), ) @@ -52,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})", ) diff --git a/kaleido_cli/commands/node.py b/kaleido_cli/commands/node.py index c0a1335..a35ce1f 100644 --- a/kaleido_cli/commands/node.py +++ b/kaleido_cli/commands/node.py @@ -193,7 +193,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.""" @@ -202,7 +204,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", @@ -212,7 +214,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 diff --git a/kaleido_cli/config.py b/kaleido_cli/config.py index 1dc0ce9..e60d973 100644 --- a/kaleido_cli/config.py +++ b/kaleido_cli/config.py @@ -37,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/docker_manager.py b/kaleido_cli/docker_manager.py index 1c444ec..ad72572 100644 --- a/kaleido_cli/docker_manager.py +++ b/kaleido_cli/docker_manager.py @@ -19,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" # --------------------------------------------------------------------------- @@ -71,7 +71,7 @@ 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 = DEFAULT_NETWORK diff --git a/kaleido_cli/onboarding.py b/kaleido_cli/onboarding.py index 770b2b8..b25ff54 100644 --- a/kaleido_cli/onboarding.py +++ b/kaleido_cli/onboarding.py @@ -50,13 +50,13 @@ def _confirm_or_default( return typer.confirm(label, default=default) -def _next_available_base_dir(base_dir: Path, env_name: str) -> Path: - """Suggest a sibling base directory where the requested env name is available.""" +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 = base_dir.parent / f"{base_dir.name}-{suffix}" - if not (candidate / env_name / COMPOSE_FILE).exists(): + candidate = f"{env_name}-{suffix}" + if not (base_dir / candidate / COMPOSE_FILE).exists(): return candidate - return base_dir.parent / f"{base_dir.name}-new" + return f"{env_name}-new" def run_setup( @@ -120,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() @@ -152,21 +155,20 @@ def run_setup( if not is_interactive(): print_error( f"Environment '{resolved_env_name}' already exists at {env_dir}. " - "Choose a different path with --spawn-dir to create a new node." + "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 base directory?", + "Create the new node in a different folder under the same base directory?", default=True, ) if use_different_path: - suggested_base_dir = _next_available_base_dir(base_dir, resolved_env_name) - base_dir_input = typer.prompt( - "Choose a different base directory for node environments", - default=str(suggested_base_dir), + resolved_env_name = typer.prompt( + "Choose a different environment folder name", + default=_next_available_env_name(base_dir, resolved_env_name), ) - base_dir = Path(base_dir_input).expanduser().resolve() + created_env_name = resolved_env_name continue overwrite = typer.confirm( "Overwrite only the compose file? Existing node data may still be reused.", From 503f4dc261fec9ed527e6835590cedd49b6f00ee Mon Sep 17 00:00:00 2001 From: bitwalt Date: Tue, 7 Apr 2026 19:45:43 +0200 Subject: [PATCH 7/9] Add CLI test coverage --- tests/__init__.py | 0 tests/conftest.py | 128 +++++++++++++++ tests/test_cmd_config.py | 89 ++++++++++ tests/test_cmd_market.py | 188 +++++++++++++++++++++ tests/test_cmd_swap.py | 308 +++++++++++++++++++++++++++++++++++ tests/test_cmd_wallet.py | 177 ++++++++++++++++++++ tests/test_config.py | 126 ++++++++++++++ tests/test_docker_manager.py | 24 +++ tests/test_output.py | 85 ++++++++++ 9 files changed, 1125 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_cmd_config.py create mode 100644 tests/test_cmd_market.py create mode 100644 tests/test_cmd_swap.py create mode 100644 tests/test_cmd_wallet.py create mode 100644 tests/test_config.py create mode 100644 tests/test_docker_manager.py create mode 100644 tests/test_output.py 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() From 3cc7eedabbdef5ce5ba33b1811444375814966fb Mon Sep 17 00:00:00 2001 From: bitwalt Date: Tue, 7 Apr 2026 22:34:49 +0200 Subject: [PATCH 8/9] Improve LSP channel order flow --- README.md | 6 +- kaleido_cli/commands/channel.py | 761 ++++++++++++++++++++++++++++---- kaleido_cli/commands/node.py | 96 +--- kaleido_cli/commands/swap.py | 24 +- kaleido_cli/context.py | 9 +- 5 files changed, 717 insertions(+), 179 deletions(-) diff --git a/README.md b/README.md index 6022538..460fb62 100644 --- a/README.md +++ b/README.md @@ -232,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 @@ -307,6 +306,7 @@ 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 pubkey` | Show the local node's taker public key | | `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 | diff --git a/kaleido_cli/commands/channel.py b/kaleido_cli/commands/channel.py index 03d0d23..b6114a6 100644 --- a/kaleido_cli/commands/channel.py +++ b/kaleido_cli/commands/channel.py @@ -3,26 +3,35 @@ from __future__ import annotations import asyncio +from collections.abc import Awaitable from dataclasses import dataclass from datetime import datetime, timezone -from typing import Annotated, Any +from time import perf_counter +from typing import Annotated, Any, TypeVar import typer from kaleido_sdk import ( ChannelFees, ChannelOrderResponse, CreateOrderRequest, + Layer, LspInfoResponse, NetworkInfoResponse, OrderRequest, + PairQuoteRequest, + PaymentState, RateDecisionRequest, RateDecisionResponse, + SwapLegInput, ) from kaleido_sdk.rln import ( CloseChannelRequest, + ConnectPeerRequest, ListChannelsResponse, OpenChannelRequest, OpenChannelResponse, + SendPaymentRequest, + SendPaymentResponse, ) from kaleido_cli.context import get_client @@ -32,6 +41,7 @@ output_collection, output_model, print_error, + print_info, print_json, print_panel, print_success, @@ -46,7 +56,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, @@ -59,6 +69,9 @@ 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) @@ -138,6 +151,217 @@ def _prompt_optional_int(prompt: str) -> int | None: 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 @@ -148,101 +372,139 @@ def _normalize_optional_text(value: str | None) -> str | None: 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, - 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 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 = lsp_balance_sat + 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 = typer.prompt("LSP balance in channel (satoshis)", type=int) + 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 = client_balance_sat + 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 = typer.prompt("Client balance in channel (satoshis)", type=int) + 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 = typer.prompt( + required_channel_confirmations = _prompt_int_in_range( "Required channel confirmations", - type=int, + min_value=limits["min_required_confirmations"], default=required_channel_confirmations, ) - funding_confirms_within_blocks = typer.prompt( + funding_confirms_within_blocks = _prompt_int_in_range( "Funding confirms within blocks", - type=int, + min_value=limits["min_funding_within_blocks"], default=funding_confirms_within_blocks, ) - channel_expiry_blocks = typer.prompt( + channel_expiry_blocks = _prompt_int_in_range( "Channel expiry blocks", - type=int, + min_value=1, + max_value=limits["max_expiry_blocks"], 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 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: - lsp_asset_amount = _prompt_optional_int( - "[OPTIONAL] LSP RGB asset amount (Enter to skip)" - ) + 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: - client_asset_amount = _prompt_optional_int( - "[OPTIONAL] Client RGB asset amount (Enter to skip)" - ) + 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_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)") @@ -251,6 +513,30 @@ def _resolve_channel_order_params( ) 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, @@ -259,13 +545,13 @@ def _resolve_channel_order_params( required_channel_confirmations=required_channel_confirmations, funding_confirms_within_blocks=funding_confirms_within_blocks, channel_expiry_blocks=channel_expiry_blocks, - token=resolved_token, + 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, - rfq_id=resolved_rfq_id, + client_asset_amount=client_asset_amount or None, + rfq_id=None, email=resolved_email, ) @@ -293,6 +579,97 @@ async def _create_channel_order(client: Any, params: ChannelOrderParams) -> Chan 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: @@ -302,6 +679,22 @@ async def _get_channel_order( ) +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: ChannelOrderParams) -> ChannelFees: return await client.maker.estimate_lsp_fees(_build_channel_order_request(params)) @@ -581,19 +974,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, @@ -621,10 +1014,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, @@ -636,48 +1031,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) @@ -695,12 +1140,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)) @@ -718,6 +1163,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=( @@ -782,13 +1322,13 @@ async def _channel_order_decide(order_id: str, accept: bool, access_token: str) epilog=( "[bold]Examples[/bold]\n\n" " Estimate fees for a channel:\n" - " [cyan]kaleido channel order estimate-fees 03abc... --lsp-balance 1000000 --client-balance 500000[/cyan]" + " [cyan]kaleido channel order estimate-fees --lsp-balance 1000000 --client-balance 500000[/cyan]" ), ) def channel_estimate_fees( 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, @@ -804,7 +1344,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, @@ -826,7 +1368,6 @@ def channel_estimate_fees( 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."), @@ -835,41 +1376,69 @@ def channel_estimate_fees( bool, typer.Option("--announce/--private", help="Announce channel publicly."), ] = True, - 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, ) -> None: """Estimate fees for opening an LSP channel.""" - 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_estimate_fees_flow( + client_pubkey=client_pubkey, + lsp_balance_sat=lsp_balance_sat, + client_balance_sat=client_balance_sat, + asset_id=asset_id, + lsp_asset_amount=lsp_asset_amount, + client_asset_amount=client_asset_amount, + 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, + email=email, + ) ) - asyncio.run(_channel_estimate_fees(params)) - -async def _channel_estimate_fees(params) -> None: +async def _channel_estimate_fees_flow( + *, + client_pubkey: str | None, + lsp_balance_sat: int | None, + client_balance_sat: int | None, + asset_id: str | None, + lsp_asset_amount: int | None, + client_asset_amount: int | None, + required_channel_confirmations: int, + funding_confirms_within_blocks: int, + channel_expiry_blocks: int, + refund_onchain_address: str | None, + announce_channel: bool, + email: str | None, +) -> None: try: - client = get_client() + client = get_client(require_node=True) + node_info = await client.rln.get_node_info() + lsp_info = await 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, + ) resp: ChannelFees = 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) diff --git a/kaleido_cli/commands/node.py b/kaleido_cli/commands/node.py index a35ce1f..30ed86a 100644 --- a/kaleido_cli/commands/node.py +++ b/kaleido_cli/commands/node.py @@ -7,7 +7,6 @@ from typing import Annotated import typer -from kaleido_sdk.rln import TakerRequest from kaleido_cli.config import ( DEFAULT_BITCOIND_RPC_HOST, @@ -30,11 +29,9 @@ ) from kaleido_cli.output import ( is_interactive, - is_json_mode, output_model, print_error, print_info, - print_json, print_success, print_warning, ) @@ -63,18 +60,11 @@ " [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" ), ) -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") - # --------------------------------------------------------------------------- # Helpers @@ -110,70 +100,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 # --------------------------------------------------------------------------- @@ -544,7 +470,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()) @@ -552,8 +478,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}") diff --git a/kaleido_cli/commands/swap.py b/kaleido_cli/commands/swap.py index 16408b4..1f1091c 100644 --- a/kaleido_cli/commands/swap.py +++ b/kaleido_cli/commands/swap.py @@ -579,7 +579,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 swap node pubkey' or your node's pubkey.[/dim]" ), ) def atomic_execute( @@ -849,6 +849,28 @@ async def _atomic_run( raise typer.Exit(1) +@node_app.command( + "pubkey", + epilog=" [cyan]kaleido swap node 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_app.command( "init", epilog=( 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, ) From 4719dc12df0d1010503aacda2909d67117f96e52 Mon Sep 17 00:00:00 2001 From: bitwalt Date: Thu, 9 Apr 2026 09:57:41 +0200 Subject: [PATCH 9/9] Address CLI review feedback --- README.md | 14 +- kaleido_cli/app.py | 2 +- kaleido_cli/commands/channel.py | 701 +-------------------------- kaleido_cli/commands/node.py | 5 + kaleido_cli/commands/node_swap.py | 295 ++++++++++++ kaleido_cli/commands/swap.py | 284 +---------- kaleido_cli/utils/channel_orders.py | 706 ++++++++++++++++++++++++++++ 7 files changed, 1038 insertions(+), 969 deletions(-) create mode 100644 kaleido_cli/commands/node_swap.py create mode 100644 kaleido_cli/utils/channel_orders.py diff --git a/README.md b/README.md index 460fb62..728260f 100644 --- a/README.md +++ b/README.md @@ -294,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 | |-------------------------------------|----------------------------------------------------------| @@ -306,12 +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 pubkey` | Show the local node's taker public key | -| `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 diff --git a/kaleido_cli/app.py b/kaleido_cli/app.py index faeebb6..9b14139 100644 --- a/kaleido_cli/app.py +++ b/kaleido_cli/app.py @@ -195,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 b6114a6..7812b94 100644 --- a/kaleido_cli/commands/channel.py +++ b/kaleido_cli/commands/channel.py @@ -3,30 +3,19 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable -from dataclasses import dataclass -from datetime import datetime, timezone -from time import perf_counter -from typing import Annotated, Any, TypeVar +from typing import Annotated import typer from kaleido_sdk import ( ChannelFees, ChannelOrderResponse, - CreateOrderRequest, - Layer, LspInfoResponse, NetworkInfoResponse, - OrderRequest, - PairQuoteRequest, - PaymentState, RateDecisionRequest, RateDecisionResponse, - SwapLegInput, ) from kaleido_sdk.rln import ( CloseChannelRequest, - ConnectPeerRequest, ListChannelsResponse, OpenChannelRequest, OpenChannelResponse, @@ -43,9 +32,23 @@ 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_order_params, + _timed_step, +) from kaleido_cli.utils.prompts import resolve_required_text channel_app = typer.Typer( @@ -67,641 +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" -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 - - -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_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: ChannelOrderParams) -> ChannelFees: - return await client.maker.estimate_lsp_fees(_build_channel_order_request(params)) - - -def _print_channel_order_fees(resp: ChannelFees, *, title: str) -> None: - output_model(resp, title=title) - @channel_app.command("list") def channel_list() -> None: @@ -1466,43 +834,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/node.py b/kaleido_cli/commands/node.py index 30ed86a..23040ae 100644 --- a/kaleido_cli/commands/node.py +++ b/kaleido_cli/commands/node.py @@ -8,6 +8,7 @@ import typer +from kaleido_cli.commands.node_swap import node_swap_app from kaleido_cli.config import ( DEFAULT_BITCOIND_RPC_HOST, DEFAULT_BITCOIND_RPC_PASSWORD, @@ -62,9 +63,13 @@ " [cyan]kaleido node shutdown[/cyan] — gracefully shut down the node process\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" ), ) +node_app.add_typer(node_swap_app, name="swap") + # --------------------------------------------------------------------------- # Helpers 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 1f1091c..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 swap node 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,262 +838,3 @@ async def _atomic_run( except Exception as e: print_error(f"Error: {e}") raise typer.Exit(1) - - -@node_app.command( - "pubkey", - epilog=" [cyan]kaleido swap node 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_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/utils/channel_orders.py b/kaleido_cli/utils/channel_orders.py new file mode 100644 index 0000000..fd7a5c7 --- /dev/null +++ b/kaleido_cli/utils/channel_orders.py @@ -0,0 +1,706 @@ +"""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 ( + ChannelFees, + ChannelOrderResponse, + CreateOrderRequest, + 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 + + +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_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: ChannelOrderParams) -> ChannelFees: + return await client.maker.estimate_lsp_fees(_build_channel_order_request(params)) + + +def _print_channel_order_fees(resp: ChannelFees, *, 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.", + )