diff --git a/pyproject.toml b/pyproject.toml index db4ba615..c5b79000 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,10 +32,12 @@ classifiers = [ dynamic = [ "version" ] dependencies = [ "aiodns==3.2", - "aiohttp==3.9.*", + "aiohttp==3.10.6", "aleph-message>=0.4.9", - "aleph-sdk-python>=1.0.1,<2", + "aleph-sdk-python>=1.1,<2", + "base58==2.1.1", # Needed now as default with _load_account changement "pygments==2.18", + "pynacl==1.5", # Needed now as default with _load_account changement "python-magic==0.4.27", "rich==13.8.1", "setuptools>=65.5", diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index de63689c..0e2d8db1 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -1,9 +1,7 @@ from __future__ import annotations import asyncio -import base64 import logging -import sys from pathlib import Path from typing import Optional @@ -11,62 +9,116 @@ import typer from aleph.sdk.account import _load_account from aleph.sdk.chains.common import generate_key -from aleph.sdk.chains.ethereum import ETHAccount -from aleph.sdk.conf import settings -from aleph.sdk.types import AccountFromPrivateKey -from typer.colors import RED +from aleph.sdk.chains.solana import parse_private_key as parse_solana_private_key +from aleph.sdk.conf import ( + MainConfiguration, + load_main_configuration, + save_main_configuration, + settings, +) +from aleph.sdk.evm_utils import get_chains_with_holding, get_chains_with_super_token +from aleph.sdk.utils import bytes_from_hex +from aleph_message.models import Chain +from rich.console import Console +from rich.prompt import Prompt +from rich.table import Table +from typer.colors import GREEN, RED from aleph_client.commands import help_strings -from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AsyncTyper +from aleph_client.commands.utils import ( + input_multiline, + setup_logging, + validated_prompt, + yes_no_input, +) +from aleph_client.utils import AsyncTyper, list_unlinked_keys logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) +console = Console() @app.command() -def create( +async def create( private_key: Optional[str] = typer.Option(None, help=help_strings.PRIVATE_KEY), private_key_file: Optional[Path] = typer.Option(None, help=help_strings.PRIVATE_KEY_FILE), - replace: bool = False, + chain: Optional[Chain] = typer.Option(default=None, help=help_strings.ORIGIN_CHAIN), + replace: bool = typer.Option(default=False, help=help_strings.CREATE_REPLACE), + active: bool = typer.Option(default=True, help=help_strings.CREATE_ACTIVE), debug: bool = False, ): """Create or import a private key.""" setup_logging(debug) - if private_key_file is None: - private_key_file = Path(typer.prompt("Enter file in which to save the key", settings.PRIVATE_KEY_FILE)) - + # Prepares new private key file + if not private_key_file: + private_key_file = Path( + validated_prompt("Enter a name or path for your private key", lambda path: len(path) > 0) + ) + if not private_key_file.name.endswith(".key"): + private_key_file = private_key_file.with_suffix(".key") + if private_key_file.parent.as_posix() == ".": + private_key_file = Path(settings.CONFIG_HOME or ".", "private-keys", private_key_file) if private_key_file.exists() and not replace: - typer.secho(f"Error: key already exists: '{private_key_file}'", fg=RED) - + typer.secho(f"Error: private key file already exists: '{private_key_file}'", fg=RED) raise typer.Exit(1) + # Prepares new private key private_key_bytes: bytes - if private_key is not None: - # Validate the private key bytes by instantiating an account. - _load_account(private_key_str=private_key, account_type=ETHAccount) - private_key_bytes = bytes.fromhex(private_key) + if private_key: + if not chain: + chain = Chain( + Prompt.ask( + "Select the origin chain of your new private key: ", + choices=list(Chain), + default=Chain.ETH.value, + ) + ) + if chain == Chain.SOL: + private_key_bytes = parse_solana_private_key(private_key) + else: + private_key_bytes = bytes_from_hex(private_key) else: private_key_bytes = generate_key() - if not private_key_bytes: typer.secho("An unexpected error occurred!", fg=RED) raise typer.Exit(2) + # Saves new private key private_key_file.parent.mkdir(parents=True, exist_ok=True) private_key_file.write_bytes(private_key_bytes) - typer.secho(f"Private key stored in {private_key_file}", fg=RED) + typer.secho(f"Private key stored in {private_key_file}", fg=GREEN) + + # Changes default configuration + if active: + if not chain: + chain = Chain( + Prompt.ask( + "Select the active chain: ", + choices=list(Chain), + default=Chain.ETH.value, + ) + ) + try: + new_config = MainConfiguration(path=private_key_file, chain=chain) + save_main_configuration(settings.CONFIG_FILE, new_config) + typer.secho( + f"Private key {new_config.path} on chain {new_config.chain} is now your default configuration.", + fg=GREEN, + ) + except ValueError as e: + typer.secho(f"Error: {e}", fg=typer.colors.RED) -@app.command() -def address( + +@app.command(name="address") +def display_active_address( private_key: Optional[str] = typer.Option(settings.PRIVATE_KEY_STRING, help=help_strings.PRIVATE_KEY), private_key_file: Optional[Path] = typer.Option(settings.PRIVATE_KEY_FILE, help=help_strings.PRIVATE_KEY_FILE), ): """ - Display your public address. + Display your public address(es). """ if private_key is not None: @@ -75,37 +127,86 @@ def address( typer.secho("No private key available", fg=RED) raise typer.Exit(code=1) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) - typer.echo(account.get_address()) + evm_address = _load_account(private_key, private_key_file, chain=Chain.ETH).get_address() + sol_address = _load_account(private_key, private_key_file, chain=Chain.SOL).get_address() + + console.print( + "✉ [bold italic blue]Addresses for Active Account[/bold italic blue] ✉\n\n" + + f"[italic]EVM[/italic]: [cyan]{evm_address}[/cyan]\n" + + f"[italic]SOL[/italic]: [magenta]{sol_address}[/magenta]\n" + ) + + +@app.command(name="chain") +def display_active_chain(): + """ + Display the currently active chain. + """ + + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + active_chain = None + if config and config.chain: + active_chain = config.chain + + hold_chains = get_chains_with_holding() + ["SOL"] + payg_chains = get_chains_with_super_token() + + chain = f"[bold green]{active_chain}[/bold green]" if active_chain else "[red]Not Selected[/red]" + active_chain_compatibility, compatibility = [], "" + if active_chain in hold_chains: + active_chain_compatibility.append("HOLD") + if active_chain in payg_chains: + active_chain_compatibility.append("PAYG") + if active_chain_compatibility: + compatibility = f"[magenta]{' / '.join(active_chain_compatibility)}[/magenta]" + else: + compatibility = "[red]Only Signing[/red]" + + console.print(f"[italic]Active Chain[/italic]: {chain}\t" + f"[italic]Compatibility[/italic]: {compatibility}") + + +@app.command(name="path") +def path_directory(): + """Display the directory path where your private keys, config file, and other settings are stored.""" + console.print(f"Aleph Home directory: [yellow]{settings.CONFIG_HOME}[/yellow]") @app.command() -def export_private_key( +def show( private_key: Optional[str] = typer.Option(settings.PRIVATE_KEY_STRING, help=help_strings.PRIVATE_KEY), private_key_file: Optional[Path] = typer.Option(settings.PRIVATE_KEY_FILE, help=help_strings.PRIVATE_KEY_FILE), +): + """Display current configuration.""" + + display_active_address(private_key=private_key, private_key_file=private_key_file) + display_active_chain() + + +@app.command() +def export_private_key( + private_key: Optional[str] = typer.Option(None, help=help_strings.PRIVATE_KEY), + private_key_file: Optional[Path] = typer.Option(None, help=help_strings.PRIVATE_KEY_FILE), ): """ Display your private key. """ - if private_key is not None: + if private_key: private_key_file = None elif private_key_file and not private_key_file.exists(): typer.secho("No private key available", fg=RED) raise typer.Exit(code=1) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) - if hasattr(account, "private_key"): - private_key_hex: str = base64.b16encode(account.private_key).decode().lower() - typer.echo(f"0x{private_key_hex}") - else: - typer.secho(f"Private key cannot be read for {account}", fg=RED) - + evm_pk = _load_account(private_key, private_key_file, chain=Chain.ETH).export_private_key() + sol_pk = _load_account(private_key, private_key_file, chain=Chain.SOL).export_private_key() -@app.command() -def path(): - if settings.PRIVATE_KEY_FILE: - typer.echo(settings.PRIVATE_KEY_FILE) + console.print( + "⚠️ [bold italic red]Private Keys for Active Account[/bold italic red] ⚠️\n\n" + + f"[italic]EVM[/italic]: [cyan]{evm_pk}[/cyan]\n" + + f"[italic]SOL[/italic]: [magenta]{sol_pk}[/magenta]\n\n" + + "[bold italic red]Note: Aleph.im team will NEVER ask for them.[/bold italic red]" + ) @app.command("sign-bytes") @@ -113,21 +214,21 @@ def sign_bytes( message: Optional[str] = typer.Option(None, help="Message to sign"), private_key: Optional[str] = typer.Option(settings.PRIVATE_KEY_STRING, help=help_strings.PRIVATE_KEY), private_key_file: Optional[Path] = typer.Option(settings.PRIVATE_KEY_FILE, help=help_strings.PRIVATE_KEY_FILE), + chain: Optional[Chain] = typer.Option(None, help=help_strings.ADDRESS_CHAIN), debug: bool = False, ): """Sign a message using your private key.""" setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file, chain=chain) - if message is None: - # take from stdin - message = "\n".join(sys.stdin.readlines()) + if not message: + message = input_multiline() coroutine = account.sign_raw(message.encode()) signature = asyncio.run(coroutine) - typer.echo(signature.hex()) + typer.echo("\nSignature: " + signature.hex()) @app.command() @@ -135,8 +236,10 @@ async def balance( address: Optional[str] = typer.Option(None, help="Address"), private_key: Optional[str] = typer.Option(settings.PRIVATE_KEY_STRING, help=help_strings.PRIVATE_KEY), private_key_file: Optional[Path] = typer.Option(settings.PRIVATE_KEY_FILE, help=help_strings.PRIVATE_KEY_FILE), + chain: Optional[Chain] = typer.Option(None, help=help_strings.ADDRESS_CHAIN), ): - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + """Display your ALEPH balance.""" + account = _load_account(private_key, private_key_file, chain=chain) if account and not address: address = account.get_address() @@ -163,3 +266,133 @@ async def balance( typer.echo(f"Failed to retrieve balance for address {address}. Status code: {response.status}") else: typer.echo("Error: Please provide either a private key, private key file, or an address.") + + +@app.command(name="list") +async def list_accounts(): + """Display available private keys, along with currenlty active chain and account (from config file).""" + + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + unlinked_keys, _ = await list_unlinked_keys() + + table = Table(title="\n🔑 Found Private Keys 🔑", title_justify="left", show_lines=True) + table.add_column("Name", style="cyan", no_wrap=True) + table.add_column("Path", style="green") + table.add_column("Active", no_wrap=True) + + active_chain = None + if config: + active_chain = config.chain + table.add_row(config.path.stem, str(config.path), "[bold green]*[/bold green]") + else: + console.print( + "[red]No private key path selected in the config file.[/red]\nTo set it up, use: [bold italic cyan]aleph account config[/bold italic cyan]\n" + ) + + if unlinked_keys: + for key_file in unlinked_keys: + if key_file.stem != "default": + table.add_row(key_file.stem, str(key_file), "[bold red]-[/bold red]") + + hold_chains = get_chains_with_holding() + ["SOL"] + payg_chains = get_chains_with_super_token() + + active_address = None + if config and config.path and active_chain: + account = _load_account(private_key_path=config.path, chain=active_chain) + active_address = account.get_address() + + console.print( + "🌐 [bold italic blue]Chain Infos[/bold italic blue] 🌐\n" + + f"[italic]Chains with Signing[/italic]: [blue]{', '.join(list(Chain))}[/blue]\n" + + f"[italic]Chains with Hold-tier[/italic]: [blue]{', '.join(hold_chains)}[/blue]\n" + + f"[italic]Chains with Pay-As-You-Go[/italic]: [blue]{', '.join(payg_chains)}[/blue]\n\n" + + "🗃️ [bold italic green]Current Configuration[/bold italic green] 🗃️\n" + + (f"[italic]Active Address[/italic]: [bright_cyan]{active_address}[/bright_cyan]" if active_address else "") + ) + display_active_chain() + console.print(table) + + +@app.command(name="config") +async def configure( + private_key_file: Optional[Path] = typer.Option(None, help="New path to the private key file"), + chain: Optional[Chain] = typer.Option(None, help="New active chain"), +): + """Configure current private key file and active chain (default selection)""" + + unlinked_keys, config = await list_unlinked_keys() + + # Fixes private key file path + if private_key_file: + if not private_key_file.name.endswith(".key"): + private_key_file = private_key_file.with_suffix(".key") + if private_key_file.parent.as_posix() == ".": + private_key_file = Path(settings.CONFIG_HOME or ".", "private-keys", private_key_file) + + # Checks if private key file exists + if private_key_file and not private_key_file.exists(): + typer.secho(f"Private key file not found: {private_key_file}", fg=typer.colors.RED) + raise typer.Exit() + + # Configures active private key file + if not private_key_file and config and hasattr(config, "path") and Path(config.path).exists(): + if not yes_no_input( + f"Active private key file: [bright_cyan]{config.path}[/bright_cyan]\n[yellow]Keep current active private key?[/yellow]", + default="y", + ): + unlinked_keys = list(filter(lambda key_file: key_file.stem != "default", unlinked_keys)) + if not unlinked_keys: + typer.secho("No unlinked private keys found.", fg=typer.colors.GREEN) + raise typer.Exit() + + console.print("[bold cyan]Available unlinked private keys:[/bold cyan]") + for idx, key in enumerate(unlinked_keys, start=1): + console.print(f"[{idx}] {key}") + + key_choice = Prompt.ask("Choose a private key by index") + if key_choice.isdigit(): + key_index = int(key_choice) - 1 + if 0 <= key_index < len(unlinked_keys): + private_key_file = unlinked_keys[key_index] + if not private_key_file: + typer.secho("Invalid file index.", fg=typer.colors.RED) + raise typer.Exit() + else: # No change + private_key_file = Path(config.path) + + if not private_key_file: + typer.secho("No private key file provided or found.", fg=typer.colors.RED) + raise typer.Exit() + + # Configure active chain + if not chain and config and hasattr(config, "chain"): + if not yes_no_input( + f"Active chain: [bright_cyan]{config.chain}[/bright_cyan]\n[yellow]Keep current active chain?[/yellow]", + default="y", + ): + chain = Chain( + Prompt.ask( + "Select the active chain: ", + choices=list(Chain), + default=Chain.ETH.value, + ) + ) + else: # No change + chain = Chain(config.chain) + + if not chain: + typer.secho("No chain provided.", fg=typer.colors.RED) + raise typer.Exit() + + try: + config = MainConfiguration(path=private_key_file, chain=chain) + save_main_configuration(settings.CONFIG_FILE, config) + console.print( + f"New Default Configuration: [italic bright_cyan]{config.path}[/italic bright_cyan] with [italic bright_cyan]{config.chain}[/italic bright_cyan]", + style=typer.colors.GREEN, + ) + except ValueError as e: + typer.secho(f"Error: {e}", fg=typer.colors.RED) + raise typer.Exit(1) diff --git a/src/aleph_client/commands/help_strings.py b/src/aleph_client/commands/help_strings.py index f8502371..a556062e 100644 --- a/src/aleph_client/commands/help_strings.py +++ b/src/aleph_client/commands/help_strings.py @@ -32,8 +32,8 @@ MEMORY = "Maximum memory (RAM) allocation on VM in MiB" TIMEOUT_SECONDS = "If vm is not called after [timeout_seconds] it will shutdown" SSH_PUBKEY_FILE = "Path to a public ssh key to be added to the instance" -CRN_HASH = "Hash of the CRN to deploy to" -CRN_URL = "URL of the CRN to deploy to" +CRN_HASH = "Hash of the CRN to deploy to (only applicable for confidential and/or Pay-As-You-Go instances)" +CRN_URL = "URL of the CRN to deploy to (only applicable for confidential and/or Pay-As-You-Go instances)" CONFIDENTIAL_OPTION = "Launch a confidential instance (requires creating an encrypted volume)" CONFIDENTIAL_FIRMWARE = "Hash to UEFI Firmware to launch confidential instance" CONFIDENTIAL_FIRMWARE_HASH = "Hash of the UEFI Firmware content, to validate measure (ignored if path is provided)" @@ -49,3 +49,8 @@ ALLOCATION_AUTO = "Auto - Scheduler" ALLOCATION_MANUAL = "Manual - Selection" PAYMENT_CHAIN = "Chain you want to use to pay for your instance" +PAYMENT_CHAIN_USED = "Chain you are using to pay for your instance" +ORIGIN_CHAIN = "Chain of origin of your private key (ensuring correct parsing)" +ADDRESS_CHAIN = "Chain for the address" +CREATE_REPLACE = "Overwrites private key file if it already exists" +CREATE_ACTIVE = "Loads the new private key after creation" diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index 9ea19b5c..4189698e 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -16,8 +16,8 @@ from aleph.sdk.chains.ethereum import ETHAccount from aleph.sdk.client.vm_client import VmClient from aleph.sdk.client.vm_confidential_client import VmConfidentialClient -from aleph.sdk.conf import settings -from aleph.sdk.evm_utils import get_chains_with_super_token +from aleph.sdk.conf import load_main_configuration, settings +from aleph.sdk.evm_utils import get_chains_with_holding, get_chains_with_super_token from aleph.sdk.exceptions import ( ForgottenMessageError, InsufficientFundsError, @@ -25,7 +25,7 @@ ) from aleph.sdk.query.filters import MessageFilter from aleph.sdk.query.responses import PriceResponse -from aleph.sdk.types import AccountFromPrivateKey, StorageEnum +from aleph.sdk.types import StorageEnum from aleph.sdk.utils import calculate_firmware_hash from aleph_message.models import InstanceMessage, StoreMessage from aleph_message.models.base import Chain, MessageType @@ -59,6 +59,8 @@ get_or_prompt_volumes, safe_getattr, setup_logging, + str_to_datetime, + validate_ssh_pubkey_file, validated_int_prompt, validated_prompt, wait_for_confirmed_flow, @@ -76,11 +78,12 @@ async def create( payment_type: Optional[str] = typer.Option( None, help=help_strings.PAYMENT_TYPE, - callback=lambda pt: None if pt is None else PaymentType.hold if pt == "nft" else PaymentType(pt), + callback=lambda pt: None if pt is None else pt.lower(), + # callback=lambda pt: None if pt is None else PaymentType.hold if pt == "nft" else PaymentType(pt), metavar=f"[{'|'.join(PaymentType)}|nft]", ), payment_chain: Optional[Chain] = typer.Option( - None, help=help_strings.PAYMENT_CHAIN, metavar=f"[{'|'.join([Chain.ETH, Chain.AVAX, Chain.BASE])}]" + None, help=help_strings.PAYMENT_CHAIN, metavar=f"[{'|'.join([Chain.ETH, Chain.AVAX, Chain.BASE, Chain.SOL])}]" ), hypervisor: Optional[HypervisorType] = typer.Option(None, help=help_strings.HYPERVISOR), name: Optional[str] = typer.Option(None, help=help_strings.INSTANCE_NAME), @@ -115,45 +118,63 @@ async def create( print_messages: bool = typer.Option(False), verbose: bool = typer.Option(True), debug: bool = False, -) -> Tuple[ItemHash, Optional[str]]: - """Register a new instance on aleph.im""" +) -> Tuple[ItemHash, Optional[str], Chain]: + """Create and register a new instance on aleph.im""" setup_logging(debug) + console = Console() - def validate_ssh_pubkey_file(file: Union[str, Path]) -> Path: - if isinstance(file, str): - file = Path(file).expanduser() - if not file.exists(): - raise ValueError(f"{file} does not exist") - if not file.is_file(): - raise ValueError(f"{file} is not a file") - return file - + # Loads ssh pubkey try: ssh_pubkey_file = validate_ssh_pubkey_file(ssh_pubkey_file) except ValueError: ssh_pubkey_file = Path( validated_prompt( - f"{ssh_pubkey_file} does not exist. Please enter a path to a public ssh key to be added to the instance.", + f"{ssh_pubkey_file} does not exist.\nPlease enter the path to a ssh pubkey to access your instance", validate_ssh_pubkey_file, ) ) + ssh_pubkey: str = ssh_pubkey_file.read_text(encoding="utf-8").strip() - ssh_pubkey: str = ssh_pubkey_file.read_text().strip() - - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + # Loads default configuration if no chain is set + if payment_chain is None: + config = load_main_configuration(settings.CONFIG_FILE) + if config is not None: + payment_chain = config.chain + else: + console.print("No active chain selected in configuration.") - if payment_type is None: + # Populates payment type if not set + if not payment_type: payment_type = Prompt.ask( "Which payment type do you want to use?", choices=[ptype.value for ptype in PaymentType] + ["nft"], default=PaymentType.superfluid.value, ) - payment_type = PaymentType.hold if payment_type == "nft" else PaymentType(payment_type) - is_stream = payment_type != PaymentType.hold + # Force-switches if NFT payment-type + if payment_type == "nft": + payment_chain = Chain.AVAX + payment_type = PaymentType.hold + console.print( + "[yellow]NFT[/yellow] payment-type selected: Auto-switch to [cyan]AVAX[/cyan] with [red]HOLD[/red]" + ) + elif payment_type in [ptype.value for ptype in PaymentType]: + payment_type = PaymentType(payment_type) + else: + raise ValueError(f"Invalid payment-type: {payment_type}") + + is_stream = payment_type != PaymentType.hold + hold_chains = get_chains_with_holding() + [Chain.SOL] super_token_chains = get_chains_with_super_token() + + # Checks if payment-chain is compatible with PAYG if is_stream: - if payment_chain is None or payment_chain not in super_token_chains: + if payment_chain == Chain.SOL: + console.print( + "[yellow]SOL[/yellow] chain selected: [red]Not compatible yet with Pay-As-You-Go.[/red]\nChange your configuration or provide another chain using arguments (but EVM address will be used)." + ) + raise typer.Exit(code=1) + elif payment_chain is None or payment_chain not in super_token_chains: payment_chain = Chain( Prompt.ask( "Which chain do you want to use for Pay-As-You-Go?", @@ -161,17 +182,34 @@ def validate_ssh_pubkey_file(file: Union[str, Path]) -> Path: default=Chain.AVAX.value, ) ) - if isinstance(account, ETHAccount): + # Fallback for Hold-tier if no config / no chain is set + elif payment_chain is None: + payment_chain = Chain( + Prompt.ask( + "Which chain do you want to use for Hold-tier?", + choices=hold_chains, + default=Chain.ETH.value, + ) + ) + + # Populates account + account = _load_account(private_key, private_key_file, chain=payment_chain) + + # Checks required balances (Gas + Aleph ERC20) for superfluid payment + if is_stream and isinstance(account, ETHAccount): + if account.CHAIN != payment_chain: account.switch_chain(payment_chain) - if account.superfluid_connector: # Quick check with theoretical min price - try: - account.superfluid_connector.can_start_flow(Decimal(0.000031)) # 0.11/h - except Exception as e: - echo(e) - raise typer.Exit(code=1) - else: - payment_chain = Chain.ETH # Hold chain for all balances + if account.superfluid_connector and hasattr(account.superfluid_connector, "can_start_flow"): + try: # Quick check with theoretical min price + account.superfluid_connector.can_start_flow(Decimal(0.000031)) # 0.11/h + except Exception as e: + echo(e) + raise typer.Exit(code=1) + else: + echo("Superfluid connector not available on this chain.") + raise typer.Exit(code=1) + # Checks if Hypervisor is compatible with confidential if confidential: if hypervisor and hypervisor != HypervisorType.qemu: echo("Only QEMU is supported as an hypervisor for confidential") @@ -270,41 +308,42 @@ def validate_ssh_pubkey_file(file: Union[str, Path]) -> Path: stream_reward_address = None crn = None - if crn_url and crn_hash: - crn_url = sanitize_url(crn_url) - try: - crn_name, score, reward_addr = "?", 0, "" - nodes: NodeInfo = await _fetch_nodes() - for node in nodes.nodes: - if node["address"].rstrip("/") == crn_url: - crn_name = node["name"] - score = node["score"] - reward_addr = node["stream_reward"] - break - crn_info = await fetch_crn_info(crn_url) - if crn_info: - crn = CRNInfo( - hash=ItemHash(crn_hash), - name=crn_name or "?", - url=crn_url, - version=crn_info.get("version", ""), - score=score, - stream_reward_address=str(crn_info.get("payment", {}).get("PAYMENT_RECEIVER_ADDRESS")) - or reward_addr - or "", - machine_usage=crn_info.get("machine_usage"), - qemu_support=bool(crn_info.get("computing", {}).get("ENABLE_QEMU_SUPPORT", False)), - confidential_computing=bool( - crn_info.get("computing", {}).get("ENABLE_CONFIDENTIAL_COMPUTING", False) - ), - ) - echo("\n* Selected CRN *") - crn.display_crn_specs() - echo() - except Exception as e: - echo(f"Unable to fetch CRN config: {e}") - raise typer.Exit(1) if is_stream or confidential: + if crn_url and crn_hash: + crn_url = sanitize_url(crn_url) + try: + crn_name, score, reward_addr = "?", 0, "" + nodes: NodeInfo = await _fetch_nodes() + for node in nodes.nodes: + if node["address"].rstrip("/") == crn_url: + crn_name = node["name"] + score = node["score"] + reward_addr = node["stream_reward"] + break + crn_info = await fetch_crn_info(crn_url) + if crn_info: + crn = CRNInfo( + hash=ItemHash(crn_hash), + name=crn_name or "?", + url=crn_url, + version=crn_info.get("version", ""), + score=score, + stream_reward_address=str(crn_info.get("payment", {}).get("PAYMENT_RECEIVER_ADDRESS")) + or reward_addr + or "", + machine_usage=crn_info.get("machine_usage"), + qemu_support=bool(crn_info.get("computing", {}).get("ENABLE_QEMU_SUPPORT", False)), + confidential_computing=bool( + crn_info.get("computing", {}).get("ENABLE_CONFIDENTIAL_COMPUTING", False) + ), + ) + echo("\n* Selected CRN *") + crn.display_crn_specs() + echo() + except Exception as e: + echo(f"Unable to fetch CRN config: {e}") + raise typer.Exit(1) + while not crn: crn_table = CRNTable(only_reward_address=is_stream, only_qemu=is_qemu, only_confidentials=confidential) crn = await crn_table.run_async() @@ -316,6 +355,10 @@ def validate_ssh_pubkey_file(file: Union[str, Path]) -> Path: if not Confirm.ask("\nDeploy on this node ?"): crn = None continue + elif crn_url or crn_hash: + logger.debug( + f"`--crn-url` and/or `--crn-hash` arguments have been ignored.\nHold-tier regular instances are scheduled automatically on available CRNs by the Aleph.im network." + ) if crn: stream_reward_address = crn.stream_reward_address if hasattr(crn, "stream_reward_address") else "" @@ -367,15 +410,13 @@ def validate_ssh_pubkey_file(file: Union[str, Path]) -> Path: item_hash: ItemHash = message.item_hash item_hash_text = Text(item_hash, style="bright_cyan") - console = Console() - # Instances that need to be started by notifying a specific CRN crn_url = crn.url if crn and crn.url else None if crn and (is_stream or confidential): if not crn_url: # Not the ideal solution logger.debug(f"Cannot allocate {item_hash}: no CRN url") - return item_hash, crn_url + return item_hash, crn_url, payment_chain # Wait for the instance message to be processed async with aiohttp.ClientSession() as session: @@ -411,7 +452,7 @@ def validate_ssh_pubkey_file(file: Union[str, Path]) -> Path: logger.debug(status, result) if int(status) != 200: echo(f"Could not allocate instance {item_hash} on CRN.") - return item_hash, crn_url + return item_hash, crn_url, payment_chain console.print(f"Your instance {item_hash_text} has been deployed on aleph.im.") if verbose: # PAYG-tier non-confidential instances @@ -427,18 +468,18 @@ def validate_ssh_pubkey_file(file: Union[str, Path]) -> Path: else: console.print( "\n\nInitialize a confidential session using:\n\n", - Text.assemble( - " aleph instance confidential-init-session ", - item_hash_text, - style="italic", - ), - "\n\nThen start it using:\n\n", - Text.assemble( - " aleph instance confidential-start ", - item_hash_text, - style="italic", - ), - "\n\nOr just use the all-in-one command:\n\n", + # Text.assemble( + # " aleph instance confidential-init-session ", + # item_hash_text, + # style="italic", + # ), + # "\n\nThen start it using:\n\n", + # Text.assemble( + # " aleph instance confidential-start ", + # item_hash_text, + # style="italic", + # ), + # "\n\nOr just use the all-in-one command:\n\n", Text.assemble( " aleph instance confidential ", item_hash_text, @@ -460,13 +501,14 @@ def validate_ssh_pubkey_file(file: Union[str, Path]) -> Path: style="italic", ), ) - return item_hash, crn_url + return item_hash, crn_url, payment_chain @app.command() async def delete( item_hash: str = typer.Argument(..., help="Instance item hash to forget"), reason: str = typer.Option("User deletion", help="Reason for deleting the instance"), + chain: Optional[Chain] = typer.Option(None, help=help_strings.ADDRESS_CHAIN), crn_url: Optional[str] = typer.Option(None, help=help_strings.CRN_URL_VM_DELETION), private_key: Optional[str] = settings.PRIVATE_KEY_STRING, private_key_file: Optional[Path] = settings.PRIVATE_KEY_FILE, @@ -477,7 +519,7 @@ async def delete( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file, chain=chain) async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: try: existing_message: InstanceMessage = await client.get_message( @@ -500,6 +542,9 @@ async def delete( if safe_getattr(payment, "type") == PaymentType.superfluid: price = await client.get_program_price(item_hash) + # Ensure correct chain + chain = existing_message.content.payment.chain # type: ignore + # Check status of the instance and eventually erase associated VM node_list: NodeInfo = await _fetch_nodes() _, info = await fetch_vm_info(existing_message, node_list) @@ -507,17 +552,27 @@ async def delete( crn_url = str(info["crn_url"]) if not auto_scheduled and crn_url: try: - status = await erase(item_hash, crn_url, private_key, private_key_file, True, debug) + status = await erase( + vm_id=item_hash, + domain=crn_url, + chain=chain, + private_key=private_key, + private_key_file=private_key_file, + silent=True, + debug=debug, + ) if status == 1: echo(f"No associated VM on {crn_url}. Skipping...") - except Exception: + except Exception as e: + logger.debug(f"Error while deleting associated VM on {crn_url}: {str(e)}") echo(f"Failed to erase associated VM on {crn_url}. Skipping...") else: echo(f"Instance {item_hash} was auto-scheduled, VM will be erased automatically.") # Check for streaming payment and eventually stop it if payment and payment.type == PaymentType.superfluid and payment.receiver and isinstance(account, ETHAccount): - account.switch_chain(payment.chain) + if account.CHAIN != payment.chain: + account.switch_chain(payment.chain) if account.superfluid_connector and price: flow_hash = await update_flow( account, payment.receiver, Decimal(price.required_tokens), FlowUpdate.REDUCE @@ -578,7 +633,18 @@ async def _show_instances(messages: List[InstanceMessage], node_list: NodeInfo): if info["confidential"] else Text.assemble("Type: ", Text("Regular", style="grey50")) ) - chain = Text.assemble("Chain: ", Text(str(info["chain"]), style="cyan")) + chain_label, chain_color = str(info["chain"]), "steel_blue" + if chain_label == "AVAX": + chain_label, chain_color = "AVAX", "bright_red" + elif chain_label == "BASE": + chain_label, chain_color = "BASE", "blue3" + elif chain_label == "SOL": + chain_label, chain_color = "SOL ", "medium_spring_green" + else: # ETH + chain_label += " " + chain = Text.assemble("Chain: ", Text(chain_label, style=chain_color)) + created_at_parsed = str(str_to_datetime(str(info["created_at"]))).split(".")[0] + created_at = Text.assemble("\t Created at: ", Text(created_at_parsed, style="magenta")) instance = Text.assemble( "Item Hash ↓\t Name: ", name, @@ -591,6 +657,7 @@ async def _show_instances(messages: List[InstanceMessage], node_list: NodeInfo): "\n", cost, chain, + created_at, ) specifications = ( f"vCPUs: {message.content.resources.vcpus}\n" @@ -628,21 +695,20 @@ async def _show_instances(messages: List[InstanceMessage], node_list: NodeInfo): console.print(table) if uninitialized_confidential_found: item_hash_field = Text("", style="bright_cyan") - crn_url_field = Text("", style="blue") console.print( "To start uninitialized confidential instance(s), use:\n\n", - Text.assemble( - " aleph instance confidential-init-session ", - item_hash_field, - "\n", - style="italic", - ), - Text.assemble( - " aleph instance confidential-start ", - item_hash_field, - style="italic", - ), - "\n\nOr just use the all-in-one command:\n\n", + # Text.assemble( + # " aleph instance confidential-init-session ", + # item_hash_field, + # "\n", + # style="italic", + # ), + # Text.assemble( + # " aleph instance confidential-start ", + # item_hash_field, + # style="italic", + # ), + # "\n\nOr just use the all-in-one command:\n\n", Text.assemble( " aleph instance confidential ", item_hash_field, @@ -668,6 +734,7 @@ async def list( address: Optional[str] = typer.Option(None, help="Owner address of the instance"), private_key: Optional[str] = typer.Option(settings.PRIVATE_KEY_STRING, help=help_strings.PRIVATE_KEY), private_key_file: Optional[Path] = typer.Option(settings.PRIVATE_KEY_FILE, help=help_strings.PRIVATE_KEY_FILE), + chain: Optional[Chain] = typer.Option(None, help=help_strings.ADDRESS_CHAIN), json: bool = typer.Option(default=False, help="Print as json instead of rich table"), debug: bool = False, ): @@ -676,7 +743,7 @@ async def list( setup_logging(debug) if address is None: - account = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file, chain=chain) address = account.get_address() async with AlephHttpClient(api_server=settings.API_HOST) as client: @@ -704,6 +771,7 @@ async def list( async def expire( vm_id: str = typer.Argument(..., help="VM item hash to expire"), domain: Optional[str] = typer.Option(None, help="CRN domain on which the VM is running"), + chain: Optional[Chain] = typer.Option(None, help=help_strings.PAYMENT_CHAIN_USED), private_key: Optional[str] = typer.Option(settings.PRIVATE_KEY_STRING, help=help_strings.PRIVATE_KEY), private_key_file: Optional[Path] = typer.Option(settings.PRIVATE_KEY_FILE, help=help_strings.PRIVATE_KEY_FILE), debug: bool = False, @@ -718,7 +786,7 @@ async def expire( or Prompt.ask("URL of the CRN (Compute node) on which the VM is running") ) - account = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file, chain=chain) async with VmClient(account, domain) as manager: status, result = await manager.expire_instance(vm_id=vm_id) @@ -732,6 +800,7 @@ async def expire( async def erase( vm_id: str = typer.Argument(..., help="VM item hash to erase"), domain: Optional[str] = typer.Option(None, help="CRN domain on which the VM is stored or running"), + chain: Optional[Chain] = typer.Option(None, help=help_strings.PAYMENT_CHAIN_USED), private_key: Optional[str] = typer.Option(settings.PRIVATE_KEY_STRING, help=help_strings.PRIVATE_KEY), private_key_file: Optional[Path] = typer.Option(settings.PRIVATE_KEY_FILE, help=help_strings.PRIVATE_KEY_FILE), silent: bool = False, @@ -747,7 +816,7 @@ async def erase( or Prompt.ask("URL of the CRN (Compute node) on which the VM is stored or running") ) - account = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file, chain=chain) async with VmClient(account, domain) as manager: status, result = await manager.erase_instance(vm_id=vm_id) @@ -762,6 +831,7 @@ async def erase( async def reboot( vm_id: str = typer.Argument(..., help="VM item hash to reboot"), domain: Optional[str] = typer.Option(None, help="CRN domain on which the VM is running"), + chain: Optional[Chain] = typer.Option(None, help=help_strings.PAYMENT_CHAIN_USED), private_key: Optional[str] = typer.Option(settings.PRIVATE_KEY_STRING, help=help_strings.PRIVATE_KEY), private_key_file: Optional[Path] = typer.Option(settings.PRIVATE_KEY_FILE, help=help_strings.PRIVATE_KEY_FILE), debug: bool = False, @@ -776,7 +846,7 @@ async def reboot( or Prompt.ask("URL of the CRN (Compute node) on which the VM is running") ) - account = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file, chain=chain) async with VmClient(account, domain) as manager: status, result = await manager.reboot_instance(vm_id=vm_id) @@ -790,6 +860,7 @@ async def reboot( async def allocate( vm_id: str = typer.Argument(..., help="VM item hash to allocate"), domain: Optional[str] = typer.Option(None, help="CRN domain on which the VM will be allocated"), + chain: Optional[Chain] = typer.Option(None, help=help_strings.PAYMENT_CHAIN_USED), private_key: Optional[str] = typer.Option(settings.PRIVATE_KEY_STRING, help=help_strings.PRIVATE_KEY), private_key_file: Optional[Path] = typer.Option(settings.PRIVATE_KEY_FILE, help=help_strings.PRIVATE_KEY_FILE), debug: bool = False, @@ -804,12 +875,12 @@ async def allocate( or Prompt.ask("URL of the CRN (Compute node) on which the VM will be allocated") ) - account = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file, chain=chain) async with VmClient(account, domain) as manager: status, result = await manager.start_instance(vm_id=vm_id) if status != 200: - echo(f"Status: {status}") + echo(f"Status: {status}\n{result}") return 1 echo(f"VM allocated on CRN: {domain}") @@ -818,6 +889,7 @@ async def allocate( async def logs( vm_id: str = typer.Argument(..., help="VM item hash to retrieve the logs from"), domain: Optional[str] = typer.Option(None, help="CRN domain on which the VM is running"), + chain: Optional[Chain] = typer.Option(None, help=help_strings.PAYMENT_CHAIN_USED), private_key: Optional[str] = typer.Option(settings.PRIVATE_KEY_STRING, help=help_strings.PRIVATE_KEY), private_key_file: Optional[Path] = typer.Option(settings.PRIVATE_KEY_FILE, help=help_strings.PRIVATE_KEY_FILE), debug: bool = False, @@ -831,7 +903,7 @@ async def logs( or Prompt.ask("URL of the CRN (Compute node) on which the instance is running") ) - account = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file, chain=chain) async with VmClient(account, domain) as manager: try: @@ -849,6 +921,7 @@ async def logs( async def stop( vm_id: str = typer.Argument(..., help="VM item hash to stop"), domain: Optional[str] = typer.Option(None, help="CRN domain on which the VM is running"), + chain: Optional[Chain] = typer.Option(None, help=help_strings.PAYMENT_CHAIN_USED), private_key: Optional[str] = typer.Option(settings.PRIVATE_KEY_STRING, help=help_strings.PRIVATE_KEY), private_key_file: Optional[Path] = typer.Option(settings.PRIVATE_KEY_FILE, help=help_strings.PRIVATE_KEY_FILE), debug: bool = False, @@ -863,7 +936,7 @@ async def stop( or Prompt.ask("URL of the CRN (Compute node) on which the instance is running") ) - account = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file, chain=chain) async with VmClient(account, domain) as manager: status, result = await manager.stop_instance(vm_id=vm_id) @@ -877,6 +950,7 @@ async def stop( async def confidential_init_session( vm_id: str = typer.Argument(..., help="VM item hash to initialize the session for"), domain: Optional[str] = typer.Option(None, help="CRN domain on which the session will be initialized"), + chain: Optional[Chain] = typer.Option(None, help=help_strings.PAYMENT_CHAIN_USED), policy: int = typer.Option(default=0x1), keep_session: bool = typer.Option(None, help=help_strings.KEEP_SESSION), private_key: Optional[str] = typer.Option(settings.PRIVATE_KEY_STRING, help=help_strings.PRIVATE_KEY), @@ -897,7 +971,7 @@ async def confidential_init_session( or Prompt.ask("URL of the CRN (Compute node) on which the session will be initialized") ) - account = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file, chain=chain) sevctl_path = find_sevctl_or_exit() @@ -953,6 +1027,7 @@ def find_sevctl_or_exit() -> Path: async def confidential_start( vm_id: str = typer.Argument(..., help="VM item hash to start"), domain: Optional[str] = typer.Option(None, help="CRN domain on which the VM will be started"), + chain: Optional[Chain] = typer.Option(None, help=help_strings.PAYMENT_CHAIN_USED), firmware_hash: str = typer.Option( settings.DEFAULT_CONFIDENTIAL_FIRMWARE_HASH, help=help_strings.CONFIDENTIAL_FIRMWARE_HASH ), @@ -968,7 +1043,7 @@ async def confidential_start( session_dir.mkdir(exist_ok=True, parents=True) setup_logging(debug) - account = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file, chain=chain) sevctl_path = find_sevctl_or_exit() domain = ( @@ -996,7 +1071,7 @@ async def confidential_start( if firmware_file: firmware_path = Path(firmware_file) if not firmware_path.exists(): - raise Exception("Firmware path does not exist") + raise FileNotFoundError("Firmware path does not exist") firmware_hash = calculate_firmware_hash(firmware_path) logger.info(f"Calculated Firmware hash: {firmware_hash}") logger.info(sev_data) @@ -1027,8 +1102,8 @@ async def confidential_start( ) -@app.command() -async def confidential( +@app.command(name="confidential") +async def confidential_create( vm_id: Optional[str] = typer.Argument(default=None, help=help_strings.VM_ID), crn_url: Optional[str] = typer.Option(default=None, help=help_strings.CRN_URL), crn_hash: Optional[str] = typer.Option(default=None, help=help_strings.CRN_HASH), @@ -1045,11 +1120,12 @@ async def confidential( payment_type: Optional[str] = typer.Option( None, help=help_strings.PAYMENT_TYPE, - callback=lambda pt: None if pt is None else PaymentType.hold if pt == "nft" else PaymentType(pt), + callback=lambda pt: None if pt is None else pt.lower(), + # callback=lambda pt: None if pt is None else PaymentType.hold if pt == "nft" else PaymentType(pt), metavar=f"[{'|'.join(PaymentType)}|nft]", ), payment_chain: Optional[Chain] = typer.Option( - None, help=help_strings.PAYMENT_CHAIN, metavar=f"[{'|'.join([Chain.ETH, Chain.AVAX, Chain.BASE])}]" + None, help=help_strings.PAYMENT_CHAIN, metavar=f"[{'|'.join([Chain.ETH, Chain.AVAX, Chain.BASE, Chain.SOL])}]" ), name: Optional[str] = typer.Option(None, help=help_strings.INSTANCE_NAME), rootfs: Optional[str] = typer.Option(None, help=help_strings.ROOTFS), @@ -1076,7 +1152,7 @@ async def confidential( private_key_file: Optional[Path] = typer.Option(settings.PRIVATE_KEY_FILE, help=help_strings.PRIVATE_KEY_FILE), debug: bool = False, ): - """Create, start and unlock a confidential VM (all-in-one command) + """Create (optional), start and unlock a confidential VM (all-in-one command) This command combines the following commands: \n\t- create (unless vm_id is passed) @@ -1089,7 +1165,7 @@ async def confidential( find_sevctl_or_exit() allocated = False if not vm_id or len(vm_id) != 64: - vm_id, crn_url = await create( + vm_id, crn_url, payment_chain = await create( payment_type=payment_type, payment_chain=payment_chain, hypervisor=HypervisorType.qemu, @@ -1119,6 +1195,19 @@ async def confidential( echo("Could not create the VM") return 1 allocated = vm_id is not None + elif vm_id and not payment_chain: + async with AlephHttpClient(api_server=settings.API_HOST) as client: + try: + existing_message: InstanceMessage = await client.get_message( + item_hash=ItemHash(vm_id), message_type=InstanceMessage + ) + payment_chain = existing_message.content.payment.chain # type: ignore + except MessageNotFoundError: + echo("Instance does not exist") + raise typer.Exit(code=1) + except ForgottenMessageError: + echo("Instance already forgotten") + raise typer.Exit(code=1) crn_url = ( (crn_url and sanitize_url(crn_url)) @@ -1140,6 +1229,7 @@ async def confidential( await confidential_init_session( vm_id=vm_id, domain=crn_url, + chain=payment_chain, policy=policy, keep_session=keep_session, private_key=private_key, @@ -1154,6 +1244,7 @@ async def confidential( await confidential_start( vm_id=vm_id, domain=crn_url, + chain=payment_chain, firmware_hash=firmware_hash, firmware_file=firmware_file, vm_secret=vm_secret, diff --git a/src/aleph_client/commands/instance/network.py b/src/aleph_client/commands/instance/network.py index 0f5460ca..48ae984b 100644 --- a/src/aleph_client/commands/instance/network.py +++ b/src/aleph_client/commands/instance/network.py @@ -119,12 +119,14 @@ async def fetch_vm_info(message: InstanceMessage, node_list: NodeInfo) -> tuple[ async with aiohttp.ClientSession() as session: hold = not message.content.payment or message.content.payment.type == PaymentType["hold"] crn_hash = safe_getattr(message, "content.requirements.node.node_hash") + created_at = safe_getattr(message, "content.time") firmware = safe_getattr(message, "content.environment.trusted_execution.firmware") confidential = firmware and len(firmware) == 64 info = dict( crn_hash=str(crn_hash) if crn_hash else "", + created_at=str(created_at), payment="hold\t " if hold else str(safe_getattr(message, "content.payment.type.value")), - chain="Any" if hold else str(safe_getattr(message, "content.payment.chain.value")), + chain=str(safe_getattr(message, "content.payment.chain.value")), confidential=confidential, allocation_type="", ipv6_logs="", diff --git a/src/aleph_client/commands/message.py b/src/aleph_client/commands/message.py index db5e62fd..00ee2bbf 100644 --- a/src/aleph_client/commands/message.py +++ b/src/aleph_client/commands/message.py @@ -134,7 +134,7 @@ async def post( file_size = os.path.getsize(path) storage_engine = StorageEnum.ipfs if file_size > 4 * 1024 * 1024 else StorageEnum.storage - with open(path) as fd: + with open(path, "r", encoding="utf-8") as fd: content = json.load(fd) else: @@ -262,9 +262,13 @@ def sign( account: AccountFromPrivateKey = _load_account(private_key, private_key_file) if message is None: - # take from stdin - message = "\n".join(sys.stdin.readlines()) - - coroutine = account.sign_message(json.loads(message)) + message = input_multiline() + try: + data = json.loads(message) + except json.JSONDecodeError: + typer.echo(f"Error: Message isn't a valid JSON") + raise typer.Exit(code=1) + + coroutine = account.sign_message(data) signed_message = asyncio.run(coroutine) typer.echo(json.dumps(signed_message, indent=4, default=extended_json_encoder)) diff --git a/src/aleph_client/commands/program.py b/src/aleph_client/commands/program.py index fd1953e7..0d2111d8 100644 --- a/src/aleph_client/commands/program.py +++ b/src/aleph_client/commands/program.py @@ -150,7 +150,7 @@ async def upload( f" {settings.VM_URL_PATH.format(hash=item_hash)}\n" f" {settings.VM_URL_HOST.format(hash_base32=hash_base32)}\n" "Visualise on:\n https://explorer.aleph.im/address/" - f"{message.chain}/{message.sender}/message/PROGRAM/{item_hash}\n" + f"{message.chain.value}/{message.sender}/message/PROGRAM/{item_hash}\n" ) diff --git a/src/aleph_client/commands/utils.py b/src/aleph_client/commands/utils.py index 60fb47ea..99ae5224 100644 --- a/src/aleph_client/commands/utils.py +++ b/src/aleph_client/commands/utils.py @@ -5,6 +5,7 @@ import os import sys from datetime import datetime +from pathlib import Path from typing import Any, Callable, Dict, List, Optional, TypeVar, Union from aiohttp import ClientSession @@ -70,8 +71,11 @@ def setup_logging(debug: bool = False): logging.basicConfig(level=level) -def yes_no_input(text: str, default: bool) -> bool: - return Prompt.ask(text, choices=["y", "n"], default=default) == "y" +def yes_no_input(text: str, default: str | bool) -> bool: + return ( + Prompt.ask(text, choices=["y", "n"], default=default if isinstance(default, str) else ("y" if default else "n")) + == "y" + ) def prompt_for_volumes(): @@ -164,11 +168,16 @@ def validated_prompt( validator: Callable[[str], Any], default: Optional[str] = None, ) -> str: + value = "" while True: try: - value = Prompt.ask( - prompt, - default=default, + value = ( + Prompt.ask( + prompt, + default=default, + ) + if default is not None + else Prompt.ask(prompt) ) except PromptError: echo(f"Invalid input: {value}\nTry again.") @@ -186,6 +195,7 @@ def validated_int_prompt( min_value: Optional[int] = None, max_value: Optional[int] = None, ) -> int: + value = None while True: try: value = IntPrompt.ask( @@ -271,3 +281,13 @@ async def filter_only_valid_messages(messages: List[AlephMessage]): except ForgottenMessageError: logger.debug("Message not found %s", item_hash) return filtered_messages + + +def validate_ssh_pubkey_file(file: Union[str, Path]) -> Path: + if isinstance(file, str): + file = Path(file).expanduser() + if not file.exists(): + raise ValueError(f"{file} does not exist") + if not file.is_file(): + raise ValueError(f"{file} is not a file") + return file diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index 9f8694e5..5ad07b1e 100644 --- a/src/aleph_client/utils.py +++ b/src/aleph_client/utils.py @@ -6,12 +6,12 @@ from functools import partial, wraps from pathlib import Path from shutil import make_archive -from typing import Tuple, Type +from typing import List, Optional, Tuple, Type, Union from zipfile import BadZipFile, ZipFile import typer from aiohttp import ClientSession -from aleph.sdk.conf import settings +from aleph.sdk.conf import MainConfiguration, load_main_configuration, settings from aleph.sdk.types import GenericMessage from aleph_message.models.base import MessageType from aleph_message.models.execution.base import Encoding @@ -100,3 +100,33 @@ def extract_valid_eth_address(address: str) -> str: if match: return match.group(0) return "" + + +async def list_unlinked_keys() -> Tuple[List[Path], Optional[MainConfiguration]]: + """ + List private key files that are not linked to any chain type and return the active MainConfiguration. + + Returns: + - A tuple containing: + - A list of unlinked private key files as Path objects. + - The active MainConfiguration object (the single account in the config file). + """ + config_home: Union[str, Path] = settings.CONFIG_HOME if settings.CONFIG_HOME else Path.home() + private_key_dir = Path(config_home, "private-keys") + + if not private_key_dir.exists(): + return [], None + + all_private_key_files = list(private_key_dir.glob("*.key")) + + config: Optional[MainConfiguration] = load_main_configuration(Path(settings.CONFIG_FILE)) + + if not config: + logger.warning("No config file found.") + return all_private_key_files, None + + active_key_path = config.path + + unlinked_keys: List[Path] = [key_file for key_file in all_private_key_files if key_file != active_key_path] + + return unlinked_keys, config diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index ad43487e..3de3007a 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -14,14 +14,20 @@ from aleph.sdk.chains.common import generate_key +@pytest.fixture +def new_config_file() -> Generator[Path, None, None]: + with NamedTemporaryFile(suffix=".json") as config_file: + yield Path(config_file.name) + + @pytest.fixture def empty_account_file() -> Generator[Path, None, None]: - with NamedTemporaryFile() as key_file: + with NamedTemporaryFile(suffix=".key") as key_file: yield Path(key_file.name) @pytest.fixture -def account_file(empty_account_file: Path) -> Path: - private_key = generate_key() - empty_account_file.write_bytes(private_key) - return empty_account_file +def env_files(new_config_file: Path, empty_account_file: Path) -> Generator[Path, None, None]: + new_config_file.write_text(f'{{"path": "{empty_account_file}", "chain": "ETH"}}') + empty_account_file.write_bytes(generate_key()) + yield empty_account_file, new_config_file diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index acc186a1..a8d3cf2c 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -3,6 +3,7 @@ from tempfile import NamedTemporaryFile from aleph.sdk.chains.ethereum import ETHAccount +from aleph.sdk.conf import settings from typer.testing import CliRunner from aleph_client.__main__ import app @@ -25,31 +26,126 @@ def get_test_message(account: ETHAccount): } -def test_account_create(account_file: Path): - old_key = account_file.read_bytes() - result = runner.invoke(app, ["account", "create", "--replace", "--private-key-file", str(account_file)]) +def test_account_create(env_files): + settings.CONFIG_FILE = env_files[1] + old_key = env_files[0].read_bytes() + result = runner.invoke( + app, + ["account", "create", "--replace", "--private-key-file", str(env_files[0]), "--chain", "ETH"], + ) + assert result.exit_code == 0, result.stdout + new_key = env_files[0].read_bytes() + assert new_key != old_key + + +def test_account_import_evm(env_files): + settings.CONFIG_FILE = env_files[1] + old_key = env_files[0].read_bytes() + result = runner.invoke( + app, + [ + "account", + "create", + "--replace", + "--private-key-file", + str(env_files[0]), + "--chain", + "ETH", + "--private-key", + "0x5f5da4cee72286b9aec06fffe130e04e4b35583c1bf28b4d1992f6d69df1e076", + ], + ) assert result.exit_code == 0, result.stdout - new_key = account_file.read_bytes() + new_key = env_files[0].read_bytes() assert new_key != old_key -def test_account_address(account_file: Path): - result = runner.invoke(app, ["account", "address", "--private-key-file", str(account_file)]) +def test_account_import_sol(env_files): + settings.CONFIG_FILE = env_files[1] + old_key = env_files[0].read_bytes() + result = runner.invoke( + app, + [ + "account", + "create", + "--replace", + "--private-key-file", + str(env_files[0]), + "--chain", + "SOL", + "--private-key", + "2ub2ka8FFjDtfz5m9i2N6HvurgHaHDPD1nwVdmWy7ZhvMvGWbxaAMaPn8RECCerzo9Au2AToPXHzE6jsjjWscnHt", + ], + ) + assert result.exit_code == 0, result.stdout + new_key = env_files[0].read_bytes() + assert new_key != old_key + + +def test_account_address(env_files): + settings.CONFIG_FILE = env_files[1] + result = runner.invoke(app, ["account", "address", "--private-key-file", str(env_files[0])]) assert result.exit_code == 0 - assert result.stdout.startswith("0x") - assert len(result.stdout.strip()) == 42 + assert result.stdout.startswith("✉ Addresses for Active Account ✉\n\nEVM: 0x") -def test_account_export_private_key(account_file: Path): - result = runner.invoke(app, ["account", "export-private-key", "--private-key-file", str(account_file)]) +def test_account_chain(env_files): + settings.CONFIG_FILE = env_files[1] + result = runner.invoke(app, ["account", "chain"]) assert result.exit_code == 0 - assert result.stdout.startswith("0x") - assert len(result.stdout.strip()) == 66 + assert result.stdout.startswith("Active Chain:") def test_account_path(): result = runner.invoke(app, ["account", "path"]) - assert result.stdout.startswith("/") + assert result.exit_code == 0 + assert result.stdout.startswith("Aleph Home directory: ") + + +def test_account_show(env_files): + settings.CONFIG_FILE = env_files[1] + result = runner.invoke(app, ["account", "show", "--private-key-file", str(env_files[0])]) + assert result.exit_code == 0 + assert result.stdout.startswith("✉ Addresses for Active Account ✉\n\nEVM: 0x") + + +def test_account_export_private_key(env_files): + settings.CONFIG_FILE = env_files[1] + result = runner.invoke(app, ["account", "export-private-key", "--private-key-file", str(env_files[0])]) + assert result.exit_code == 0 + assert result.stdout.startswith("⚠️ Private Keys for Active Account ⚠️\n\nEVM: 0x") + + +def test_account_list(env_files): + settings.CONFIG_FILE = env_files[1] + result = runner.invoke(app, ["account", "list"]) + assert result.exit_code == 0 + assert result.stdout.startswith("🌐 Chain Infos 🌐") + + +def test_account_sign_bytes(env_files): + settings.CONFIG_FILE = env_files[1] + result = runner.invoke(app, ["account", "sign-bytes", "--message", "test", "--chain", "ETH"]) + assert result.exit_code == 0 + assert result.stdout.startswith("\nSignature:") + + +def test_account_balance(env_files): + settings.CONFIG_FILE = env_files[1] + result = runner.invoke( + app, ["account", "balance", "--address", "0xCAfEcAfeCAfECaFeCaFecaFecaFECafECafeCaFe", "--chain", "ETH"] + ) + assert result.exit_code == 0 + assert result.stdout.startswith( + "Failed to retrieve balance for address 0xCAfEcAfeCAfECaFeCaFecaFecaFECafECafeCaFe. Status code: 404" + ) + + +def test_account_config(env_files): + settings.CONFIG_FILE = env_files[1] + result = runner.invoke(app, ["account", "config", "--private-key-file", str(env_files[0]), "--chain", "ETH"]) + assert result.exit_code == 0 + assert result.stdout.startswith("New Default Configuration: ") def test_message_get(): @@ -86,7 +182,8 @@ def test_message_find(): assert "bd79839bf96e595a06da5ac0b6ba51dea6f7e2591bb913deccded04d831d29f4" in result.stdout -def test_post_message(account_file): +def test_post_message(env_files): + settings.CONFIG_FILE = env_files[1] test_file_path = Path(__file__).parent.parent / "test_post.json" result = runner.invoke( app, @@ -94,7 +191,7 @@ def test_post_message(account_file): "message", "post", "--private-key-file", - str(account_file), + str(env_files[0]), "--path", str(test_file_path), ], @@ -103,8 +200,9 @@ def test_post_message(account_file): assert "item_hash" in result.stdout -def test_sign_message(account_file): - account = get_account(account_file) +def test_sign_message(env_files): + settings.CONFIG_FILE = env_files[1] + account = get_account(env_files[0]) message = get_test_message(account) result = runner.invoke( app, @@ -112,7 +210,7 @@ def test_sign_message(account_file): "message", "sign", "--private-key-file", - str(account_file), + str(env_files[0]), "--message", json.dumps(message), ], @@ -122,8 +220,9 @@ def test_sign_message(account_file): assert "signature" in result.stdout -def test_sign_message_stdin(account_file): - account = get_account(account_file) +def test_sign_message_stdin(env_files): + settings.CONFIG_FILE = env_files[1] + account = get_account(env_files[0]) message = get_test_message(account) result = runner.invoke( app, @@ -131,7 +230,7 @@ def test_sign_message_stdin(account_file): "message", "sign", "--private-key-file", - str(account_file), + str(env_files[0]), ], input=json.dumps(message), )