diff --git a/README.md b/README.md index 4a43f3c9..9a43b222 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # aleph-client -Python Client for the [aleph.im network](https://www.aleph.im), next generation network of +Python Client for the [Aleph Cloud network](https://www.aleph.cloud), next generation network of decentralized big data applications. Development follows the [Aleph Whitepaper](https://github.com/aleph-im/aleph-whitepaper). ## Documentation -Documentation can be found on https://docs.aleph.im/tools/aleph-client/ +Documentation can be found on https://docs.aleph.cloud/devhub/sdks-and-tools/aleph-cli/ ## Requirements @@ -15,12 +15,16 @@ Documentation can be found on https://docs.aleph.im/tools/aleph-client/ Some cryptographic functionalities use curve secp256k1 and require installing [libsecp256k1](https://github.com/bitcoin-core/secp256k1). -> apt-get install -y python3-pip libsecp256k1-dev squashfs-tools +```sh +apt-get install -y python3-pip libsecp256k1-dev squashfs-tools +``` ### macOs -> brew tap cuber/homebrew-libsecp256k1 -> brew install libsecp256k1 +```sh +brew tap cuber/homebrew-libsecp256k1 +brew install libsecp256k1 +``` ### Windows @@ -32,13 +36,17 @@ We recommend using [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) Using pip and [PyPI](https://pypi.org/project/aleph-client/): -> pip install aleph-client +```sh +pip install aleph-client +``` ### Using a container Use the Aleph client and it\'s CLI from within Docker or Podman with: -> docker run --rm -ti -v $(pwd)/ ghcr.io/aleph-im/aleph-client/aleph-client:master --help +```sh +docker run --rm -ti -v $(pwd)/ ghcr.io/aleph-im/aleph-client/aleph-client:master --help +``` Warning: This will use an ephemeral key pair that will be discarded when stopping the container @@ -47,40 +55,56 @@ stopping the container We recommend using [hatch](https://hatch.pypa.io/) for development. -Hatch is a modern, extensible Python project manager. +Hatch is a modern, extensible Python project manager. It creates a virtual environment for each project and manages dependencies. -> pip install hatch - +```sh +pip install hatch +``` + ### Running tests -> hatch test +```sh +hatch test +``` or -> hatch run testing:cov +```sh +hatch run testing:cov +``` ### Formatting code -> hatch run linting:fmt +```sh +hatch run linting:fmt +``` ### Checking types -> hatch run linting:typing +``` +hatch run linting:typing +``` ## Publish to PyPI -> hatch build -> hatch upload +```sh +hatch build +hatch upload +``` If you want NULS2 support you will need to install nuls2-python (currently only available on github): -> pip install aleph-sdk-python[nuls2] +``` +pip install aleph-sdk-python[nuls2] +``` To install from source and still be able to modify the source code: -> pip install -e . +```sh +pip install -e . +``` ## Updating the User Documentation @@ -95,4 +119,4 @@ command to generate updated documentation: --output ../aleph-docs/docs/tools/aleph-client/usage.md ``` -Then, open a Pull Request (PR) on the [aleph-docs](https://github.com/aleph-im/aleph-docs/pulls) repository with your changes. \ No newline at end of file +Then, open a Pull Request (PR) on the [aleph-docs](https://github.com/aleph-im/aleph-docs/pulls) repository with your changes. diff --git a/docs/conf.py b/docs/conf.py index f097f948..68ae7201 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -258,7 +258,7 @@ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ("index", "user_guide.tex", "aleph-client Documentation", "Aleph.im", "manual"), + ("index", "user_guide.tex", "aleph-client Documentation", "Aleph Cloud", "manual"), ] # The name of an image file (relative to this directory) to place at the top of diff --git a/docs/content/account.rst b/docs/content/account.rst index c8efaaf9..0d8d673b 100644 --- a/docs/content/account.rst +++ b/docs/content/account.rst @@ -1,7 +1,7 @@ Accounts ======== -To send data to the aleph.im network, you need to have an account. +To send data to the Aleph Cloud network, you need to have an account. This account can be made using any of the supported providers. Common @@ -111,4 +111,4 @@ From a private key: ... bytes.fromhex( ... "0000000000000000000000000000000000000000000000000000000000000001")) >>> account.get_address() - 'NULSd6Hgb53vAd7ZMoA2E17DUTT4C1nGrJVpn' \ No newline at end of file + 'NULSd6Hgb53vAd7ZMoA2E17DUTT4C1nGrJVpn' diff --git a/docs/content/async_notes.rst b/docs/content/async_notes.rst index 2b3747cf..889bc364 100644 --- a/docs/content/async_notes.rst +++ b/docs/content/async_notes.rst @@ -2,7 +2,7 @@ Async vs Sync ============= -At aleph.im we really like coding using asyncio, +At Aleph Cloud we really like coding using asyncio, using async/await construct on Python 3. That being said, we totally understand that you might not @@ -15,7 +15,7 @@ calling the async code behind your back (sneaky!) so you might be careful if you are calling it in an environment where you already have an asyncio loop used. -Most chain specific code is synchronous, and core aleph.im interaction +Most chain specific code is synchronous, and core Aleph Cloud interaction might by async. Sync code have to be imported from :py:mod:`aleph_client.synchronous`, @@ -44,5 +44,4 @@ Example: ... "0x06DE0C46884EbFF46558Cd1a9e7DA6B1c3E9D0a8", ... "profile", session=session) ... - {"bio": "tester", "name": "Moshe on Ethereum"} - + {"bio": "tester", "name": "Moshe on Ethereum"} diff --git a/docs/content/cli.rst b/docs/content/cli.rst index e805e7d7..a86cc1c8 100644 --- a/docs/content/cli.rst +++ b/docs/content/cli.rst @@ -4,7 +4,7 @@ Command-line Interface ======== -Aleph-client can be used as a command-line interface to some Aleph.im +Aleph-client can be used as a command-line interface to some Aleph Cloud functionalities. The following commands are available: @@ -12,7 +12,7 @@ The following commands are available: Post ---- -Post a message on Aleph.im. +Post a message on Aleph Cloud. The content must be JSON encoded and is obtained either from a file or from a user prompt. @@ -21,7 +21,7 @@ or from a user prompt. python3 -m aleph_client post [OPTIONS] - Post a message on Aleph.im. + Post a message on Aleph Cloud. Options: --path TEXT @@ -35,13 +35,13 @@ or from a user prompt. Upload ------ -Upload and store a file on Aleph.im. +Upload and store a file on Aleph Cloud. .. code-block:: bash python3 -m aleph_client upload [OPTIONS] PATH - Upload and store a file on Aleph.im. + Upload and store a file on Aleph Cloud. Arguments: PATH [required] @@ -55,13 +55,13 @@ Upload and store a file on Aleph.im. Pin --- -Persist a file from IPFS on Aleph.im. +Persist a file from IPFS on Aleph Cloud. .. code-block:: bash python3 -m aleph_client pin [OPTIONS] HASH - Persist a file from IPFS on Aleph.im. + Persist a file from IPFS on Aleph Cloud. Arguments: HASH [required] @@ -75,13 +75,13 @@ Persist a file from IPFS on Aleph.im. Program ------- -Register a program to run on Aleph.im virtual machines from a zip archive. +Register a program to run on Aleph Cloud virtual machines from a zip archive. .. code-block:: bash python3 -m aleph_client program [OPTIONS] PATH ENTRYPOINT - Register a program to run on Aleph.im virtual machines from a zip archive. + Register a program to run on Aleph Cloud virtual machines from a zip archive. Arguments: PATH [required] diff --git a/docs/content/introduction.rst b/docs/content/introduction.rst index 4f5df758..cb438d27 100644 --- a/docs/content/introduction.rst +++ b/docs/content/introduction.rst @@ -1,7 +1,7 @@ -Introduction to Aleph.im +Introduction to Aleph Cloud ======================== -The Aleph.im network can be accessed from any API server. +The Aleph Cloud network can be accessed from any API server. To run one yourself, you will need to install `PyAleph `_. @@ -17,7 +17,7 @@ data type). Data structures --------------- -All data transferred over the aleph.im network are aleph messages. +All data transferred over the Aleph Cloud network are aleph messages. .. uml:: @@ -56,8 +56,8 @@ Actual content sent by regular users can currently be of two types: - AGGREGATE: a key-value storage specific to an address - POST: unique data posts (unique data points, events -.. uml:: - +.. uml:: + @startuml object Message { ... diff --git a/docs/content/programs.rst b/docs/content/programs.rst index e5e93420..b6277429 100644 --- a/docs/content/programs.rst +++ b/docs/content/programs.rst @@ -4,9 +4,9 @@ Programs ======== -Programs are special entries that define code to run on Aleph.im virtual machines. +Programs are special entries that define code to run on Aleph Cloud virtual machines. -Aleph.im currently supports programs written in Python that follow the +Aleph Cloud currently supports programs written in Python that follow the `ASGI interface `_. In practice, the easiest approach is to use an @@ -21,7 +21,7 @@ Creating a program Follow the `FastAPI Tutorial `_ to create your first program and test it using uvicorn. -Running on Aleph.im +Running on Aleph Cloud ------------------- Use the :ref:`cli` to upload your program. diff --git a/pyproject.toml b/pyproject.toml index 37282041..1f3dba30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,13 +5,11 @@ requires = [ "hatch-vcs", "hatchling" ] [project] name = "aleph-client" -description = "Python Client library for the Aleph.im network" +description = "Python Client library for the Aleph Cloud network" readme = "README.md" -keywords = [ "Aleph.im", "Client", "Library", "Python" ] +keywords = [ "Aleph Cloud", "Client", "Library", "Python" ] license = { file = "LICENSE.txt" } -authors = [ - { name = "Aleph.im Team", email = "hello@aleph.im" }, -] +authors = [ { name = "Aleph Cloud Team", email = "hello@aleph.cloud" } ] requires-python = ">=3.9" classifiers = [ "Development Status :: 4 - Beta", @@ -31,17 +29,20 @@ dependencies = [ "aiodns==3.2", "aiohttp==3.11.13", "aleph-message>=1.0.5", - "aleph-sdk-python>=2.1", - "base58==2.1.1", # Needed now as default with _load_account changement + #"aleph-sdk-python>=2.1", + "aleph-sdk-python @ git+https://github.com/aleph-im/aleph-sdk-python@andres-feature-implement_ledger_wallet", + "base58==2.1.1", # Needed now as default with _load_account changement "click<8.2", - "py-sr25519-bindings==0.2", # Needed for DOT signatures + "ledgerblue>=0.1.48", + "ledgereth>=0.10", + "py-sr25519-bindings==0.2", # Needed for DOT signatures "pydantic>=2", "pygments==2.19.1", - "pynacl==1.5", # Needed now as default with _load_account changement + "pynacl==1.5", # Needed now as default with _load_account changement "python-magic==0.4.27", "rich==13.9.*", "setuptools>=65.5", - "substrate-interface==1.7.11", # Needed for DOT signatures + "substrate-interface==1.7.11", # Needed for DOT signatures "textual==0.73", "typer==0.15.2", ] @@ -52,8 +53,8 @@ optional-dependencies.nuls2 = [ "aleph-nuls2==0.1" ] optional-dependencies.polkadot = [ "substrate-interface==1.7.11" ] optional-dependencies.solana = [ "base58==2.1.1", "pynacl==1.5" ] optional-dependencies.tezos = [ "aleph-pytezos==3.13.4", "pynacl==1.5" ] -urls.Discussions = "https://community.aleph.im/" -urls.Documentation = "https://docs.aleph.im/tools/aleph-client/" +urls.Discussions = "https://community.aleph.cloud/" +urls.Documentation = "https://docs.aleph.cloud/devhub/sdks-and-tools/aleph-cli/" urls.Issues = "https://github.com/aleph-im/aleph-client/issues" urls.Source = "https://github.com/aleph-im/aleph-client" scripts.aleph = "aleph_client.__main__:app" @@ -63,9 +64,7 @@ readme-content-type = "text/x-rst; charset=UTF-8" allow-direct-references = true [tool.hatch.build.targets.sdist] -include = [ - "src/aleph_client", -] +include = [ "src/aleph_client" ] [tool.hatch.build.targets.wheel] packages = [ "src/aleph_client" ] @@ -105,13 +104,8 @@ dependencies = [ [tool.hatch.envs.testing.scripts] test = "pytest {args:} ./src/aleph_client/ ./tests/" test-cov = "pytest --cov {args:} ./src/aleph_client/ ./tests/ --cov-report=xml --cov-report=term ./tests/" -cov-report = [ - "coverage report", -] -cov = [ - "test-cov", - "cov-report", -] +cov-report = [ "coverage report" ] +cov = [ "test-cov", "cov-report" ] [[tool.hatch.envs.all.matrix]] python = [ "3.9", "3.10", "3.11", "3.12" ] @@ -146,10 +140,7 @@ fmt = [ "pyproject-fmt pyproject.toml", "style", ] -all = [ - "style", - "typing", -] +all = [ "style", "typing" ] [tool.black] line-length = 120 @@ -227,12 +218,8 @@ lint.per-file-ignores."tests/unit/*" = [ "T201" ] lint.per-file-ignores."tests/unit/test_instance.py" = [ "S106", "T201" ] [tool.pytest.ini_options] -pythonpath = [ - "src", -] -testpaths = [ - "tests", -] +pythonpath = [ "src" ] +testpaths = [ "tests" ] asyncio_default_fixture_loop_scope = "function" [tool.coverage.run] @@ -245,11 +232,7 @@ aleph_client = [ "src/aleph_client" ] tests = [ "tests" ] [tool.coverage.report] -exclude_lines = [ - "no cov", - "if __name__ == .__main__.:", - "if TYPE_CHECKING:", -] +exclude_lines = [ "no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:" ] [tool.mypy] python_version = "3.9" diff --git a/src/aleph_client/__main__.py b/src/aleph_client/__main__.py index c3d72259..a7d71cb9 100644 --- a/src/aleph_client/__main__.py +++ b/src/aleph_client/__main__.py @@ -23,18 +23,16 @@ app.add_typer( message.app, name="message", - help="Manage messages (post, amend, watch and forget) on aleph.im & twentysix.cloud", + help="Manage messages (post, amend, watch and forget) on Aleph Cloud", ) -app.add_typer( - aggregate.app, name="aggregate", help="Manage aggregate messages and permissions on aleph.im & twentysix.cloud" -) -app.add_typer(files.app, name="file", help="Manage files (upload and pin on IPFS) on aleph.im & twentysix.cloud") -app.add_typer(program.app, name="program", help="Manage programs (micro-VMs) on aleph.im & twentysix.cloud") -app.add_typer(instance.app, name="instance", help="Manage instances (VMs) on aleph.im & twentysix.cloud") -app.add_typer(credit.app, name="credits", help="Credits commmands on aleph.im") -app.add_typer(domain.app, name="domain", help="Manage custom domain (DNS) on aleph.im & twentysix.cloud") -app.add_typer(node.app, name="node", help="Get node info on aleph.im & twentysix.cloud") -app.add_typer(about.app, name="about", help="Display the informations of Aleph CLI") +app.add_typer(aggregate.app, name="aggregate", help="Manage aggregate messages and permissions on Aleph Cloud") +app.add_typer(files.app, name="file", help="Manage files (upload and pin on IPFS) on Aleph Cloud") +app.add_typer(program.app, name="program", help="Manage programs (micro-VMs) on Aleph Cloud") +app.add_typer(instance.app, name="instance", help="Manage instances (VMs) on Aleph Cloud") +app.add_typer(credit.app, name="credits", help="Credits commands oAleph Cloud") +app.add_typer(domain.app, name="domain", help="Manage custom domain (DNS) on Aleph Cloud") +app.add_typer(node.app, name="node", help="Get node info on Aleph Cloud") +app.add_typer(about.app, name="about", help="Display the information of Aleph CLI") app.command("pricing")(pricing.prices_for_service) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 18157ff4..0d5f7cfb 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -9,11 +9,12 @@ import aiohttp import typer -from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient from aleph.sdk.account import _load_account from aleph.sdk.chains.common import generate_key from aleph.sdk.chains.solana import parse_private_key as parse_solana_private_key +from aleph.sdk.client import AlephHttpClient from aleph.sdk.conf import ( + AccountType, MainConfiguration, load_main_configuration, save_main_configuration, @@ -24,8 +25,11 @@ get_chains_with_super_token, get_compatible_chains, ) +from aleph.sdk.types import AccountFromPrivateKey from aleph.sdk.utils import bytes_from_hex, displayable_amount +from aleph.sdk.wallets.ledger import LedgerETHAccount from aleph_message.models import Chain +from ledgereth.exceptions import LedgerError from rich import box from rich.console import Console from rich.panel import Panel @@ -39,10 +43,18 @@ from aleph_client.commands.utils import ( input_multiline, setup_logging, + validate_non_interactive_args_config, validated_prompt, yes_no_input, ) -from aleph_client.utils import AsyncTyper, list_unlinked_keys +from aleph_client.utils import ( + AsyncTyper, + get_account_and_address, + get_first_ledger_name, + list_unlinked_keys, + load_account, + wait_for_ledger_connection, +) logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -145,26 +157,27 @@ async def create( @app.command(name="address") def display_active_address( - private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = settings.PRIVATE_KEY_STRING, - private_key_file: Annotated[ - Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) - ] = settings.PRIVATE_KEY_FILE, + private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = None, + private_key_file: Annotated[Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE)] = None, ): """ Display your public address(es). """ - if private_key is not None: - 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) + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + account_type = config.type if config else None - 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() + if not account_type or account_type == AccountType.IMPORTED: + 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() + else: + evm_address = config.address if config else "Not available" + sol_address = "Not available (using Ledger device)" + account_type_str = " (Ledger)" if account_type == AccountType.HARDWARE else "" console.print( - "✉ [bold italic blue]Addresses for Active Account[/bold italic blue] ✉\n\n" + f"✉ [bold italic blue]Addresses for Active Account{account_type_str}[/bold italic blue] ✉\n\n" f"[italic]EVM[/italic]: [cyan]{evm_address}[/cyan]\n" f"[italic]SOL[/italic]: [magenta]{sol_address}[/magenta]\n" ) @@ -229,21 +242,36 @@ def export_private_key( """ Display your private key. """ + # Check if we're using a Ledger account + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + + if config and config.type == AccountType.HARDWARE: + typer.secho("Cannot export private key from a Ledger hardware wallet", fg=RED) + typer.secho("The private key remains securely stored on your Ledger device", fg=RED) + raise typer.Exit(code=1) + # Normal private key handling 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) - 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() + eth_account = _load_account(private_key, private_key_file, chain=Chain.ETH) + sol_account = _load_account(private_key, private_key_file, chain=Chain.SOL) + evm_pk = "Not Available" + if isinstance(eth_account, AccountFromPrivateKey): + evm_pk = eth_account.export_private_key() + sol_pk = "Not Available" + if isinstance(sol_account, AccountFromPrivateKey): + sol_pk = sol_account.export_private_key() 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]" + "[bold italic red]Note: Aleph Cloud team will NEVER ask for them.[/bold italic red]" ) @@ -261,7 +289,7 @@ def sign_bytes( setup_logging(debug) - account = _load_account(private_key, private_key_file, chain=chain) + account = load_account(private_key, private_key_file, chain=chain) if not message: message = input_multiline() @@ -296,14 +324,13 @@ async def balance( chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Display your ALEPH balance and basic voucher information.""" - account = _load_account(private_key, private_key_file, chain=chain) - - if account and not address: - address = account.get_address() + account, address = get_account_and_address( + private_key=private_key, private_key_file=private_key_file, chain=chain, address=address + ) if address: try: - async with AlephHttpClient() as client: + async with AlephHttpClient(settings.API_HOST) as client: balance_data = await client.get_balances(address) available = balance_data.balance - balance_data.locked_amount infos = [ @@ -342,7 +369,7 @@ async def balance( ] # Get vouchers and add them to Account Info panel - async with AuthenticatedAlephHttpClient(account=account) as client: + async with AlephHttpClient(api_server=settings.API_HOST) as client: vouchers = await client.voucher.get_vouchers(address=address) if vouchers: voucher_names = [voucher.name for voucher in vouchers] @@ -368,7 +395,9 @@ async def balance( @app.command(name="list") -async def list_accounts(): +async def list_accounts( + ledger_count: Annotated[int, typer.Option(help="Number of ledger account you want to get (default: 5)")] = 5, +): """Display available private keys, along with currenlty active chain and account (from config file).""" config_file_path = Path(settings.CONFIG_FILE) @@ -381,9 +410,23 @@ async def list_accounts(): table.add_column("Active", no_wrap=True) active_chain = None - if config: + if config and config.path and config.path != Path("None"): active_chain = config.chain table.add_row(config.path.stem, str(config.path), "[bold green]*[/bold green]") + elif config and config.address and config.type == AccountType.HARDWARE: + active_chain = config.chain + + ledger_connected = False + try: + ledger_accounts = LedgerETHAccount.get_accounts(count=ledger_count) + if ledger_accounts: + ledger_connected = True + except Exception: + ledger_connected = False + + # Only show the config entry if no Ledger is connected + if not ledger_connected: + table.add_row(f"Ledger ({config.address})", "External (Ledger)", "[bold green]*[/bold green]") else: console.print( "[red]No private key path selected in the config file.[/red]\nTo set it up, use: [bold " @@ -395,13 +438,36 @@ async def list_accounts(): if key_file.stem != "default": table.add_row(key_file.stem, str(key_file), "[bold red]-[/bold red]") + active_ledger_address = None + if config and config.type == AccountType.HARDWARE and config.address: + active_ledger_address = config.address.lower() + + try: + ledger_accounts = LedgerETHAccount.get_accounts(count=ledger_count) + if ledger_accounts: + for idx, ledger_acc in enumerate(ledger_accounts): + if not ledger_acc.address: + continue + + current_address = ledger_acc.address.lower() + is_active = active_ledger_address and current_address == active_ledger_address + status = "[bold green]*[/bold green]" if is_active else "[bold red]-[/bold red]" + + table.add_row(f"Ledger #{idx}", ledger_acc.address, status) + + except Exception: + logger.debug("No ledger detected or error communicating with Ledger") + hold_chains = [*get_chains_with_holding(), Chain.SOL.value] 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() + if config and active_chain: + if config.path: + account = _load_account(private_key_path=config.path, chain=active_chain) + active_address = account.get_address() + elif config.address and config.type == AccountType.HARDWARE: + active_address = config.address console.print( "🌐 [bold italic blue]Chain Infos[/bold italic blue] 🌐\n" @@ -425,14 +491,13 @@ async def vouchers( chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Display detailed information about your vouchers.""" - account = _load_account(private_key, private_key_file, chain=chain) - - if account and not address: - address = account.get_address() + account, address = get_account_and_address( + private_key=private_key, private_key_file=private_key_file, chain=chain, address=address + ) if address: try: - async with AuthenticatedAlephHttpClient(account=account) as client: + async with AlephHttpClient(settings.API_HOST) as client: vouchers = await client.voucher.get_vouchers(address=address) if vouchers: voucher_table = Table(title="", show_header=True, box=box.ROUNDED) @@ -476,11 +541,44 @@ async def vouchers( async def configure( private_key_file: Annotated[Optional[Path], typer.Option(help="New path to the private key file")] = None, chain: Annotated[Optional[Chain], typer.Option(help="New active chain")] = None, + address: Annotated[Optional[str], typer.Option(help="New active address")] = None, + account_type: Annotated[Optional[AccountType], typer.Option(help="Account type")] = None, + derivation_path: Annotated[ + Optional[str], typer.Option(help="Derivation path for ledger (e.g. \"44'/60'/0'/0/0\")") + ] = None, + ledger_count: Annotated[int, typer.Option(help="Number of ledger account you want to fetch (default: 5)")] = 5, + non_it: Annotated[ + bool, typer.Option("--non-it", help="Non-interactive mode. Only apply provided options.") + ] = False, ): """Configure current private key file and active chain (default selection)""" + if settings.CONFIG_HOME: + settings.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + private_keys_dir = Path(settings.CONFIG_HOME, "private-keys") + private_keys_dir.mkdir(parents=True, exist_ok=True) + unlinked_keys, config = await list_unlinked_keys() + if non_it: + validate_non_interactive_args_config(config, account_type, private_key_file, address, chain, derivation_path) + + new_chain = chain or config.chain + new_type = account_type or config.type + new_address = address or config.address + new_key = private_key_file or (Path(config.path) if hasattr(config, "path") else None) + new_derivation_path = derivation_path or getattr(config, "derivation_path", None) + + config = MainConfiguration( + path=new_key, chain=new_chain, address=new_address, type=new_type, derivation_path=new_derivation_path + ) + save_main_configuration(settings.CONFIG_FILE, config) + typer.secho("Configuration updated (non-interactive).", fg=typer.colors.GREEN) + return + + current_device = f"{get_first_ledger_name()}" if config.type == AccountType.HARDWARE else f"File: {config.path}" + current_derivation_path = getattr(config, "derivation_path", None) + # Fixes private key file path if private_key_file: if not private_key_file.name.endswith(".key"): @@ -493,16 +591,45 @@ async def configure( 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)) + console.print(f"Current account type: [bright_cyan]{config.type}[/bright_cyan] - {current_device}") + if current_derivation_path: + console.print(f"Current derivation path: [bright_cyan]{current_derivation_path}[/bright_cyan]") + + if yes_no_input("Do you want to change the account type?", default="n"): + account_type = AccountType( + Prompt.ask("Select new account type", choices=list(AccountType), default=config.type) + ) + else: + account_type = config.type + + address = None + if config.type == AccountType.IMPORTED: + current_key = Path(config.path) if hasattr(config, "path") else None + current_account = _load_account(private_key_str=None, private_key_path=current_key, chain=chain) + address = current_account.get_address() + else: + address = config.address + + console.print(f"Current address: {address}") + + if account_type == AccountType.IMPORTED: + # Determine if we need to ask about keeping or picking a key + current_key = Path(config.path) if getattr(config, "path", None) else None + + if config.type == AccountType.IMPORTED: + change_key = not yes_no_input("[yellow]Keep current private key?[/yellow]", default="y") + else: + console.print( + "[yellow]Switching from a hardware account to an imported one.[/yellow]\n" + "You need to select a private key file to use." + ) + change_key = True + + # If user wants to change key or we must pick one + if change_key: + unlinked_keys = [k for k in unlinked_keys if k.stem != "default"] if not unlinked_keys: - typer.secho("No unlinked private keys found.", fg=typer.colors.GREEN) + typer.secho("No unlinked private keys found.", fg=typer.colors.YELLOW) raise typer.Exit() console.print("[bold cyan]Available unlinked private keys:[/bold cyan]") @@ -511,21 +638,110 @@ async def configure( 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) + idx = int(key_choice) - 1 + if 0 <= idx < len(unlinked_keys): + private_key_file = unlinked_keys[idx] + else: + typer.secho("Invalid index.", fg=typer.colors.RED) + raise typer.Exit() + else: + typer.secho("Invalid input.", fg=typer.colors.RED) raise typer.Exit() - else: # No change - private_key_file = Path(config.path) + else: + private_key_file = current_key + + # Clear derivation path when switching to imported + derivation_path = None + + if account_type == AccountType.HARDWARE: + # Handle derivation path for hardware wallet + if derivation_path: + console.print(f"Using provided derivation path: [bright_cyan]{derivation_path}[/bright_cyan]") + elif current_derivation_path and not yes_no_input( + f"Current derivation path: [bright_cyan]{current_derivation_path}[/bright_cyan]\n" + f"[yellow]Keep current derivation path?[/yellow]", + default="y", + ): + derivation_path = Prompt.ask("Enter new derivation path", default="44'/60'/0'/0/0") + elif not current_derivation_path: + if yes_no_input("Do you want to specify a derivation path?", default="n"): + derivation_path = Prompt.ask("Enter derivation path", default="44'/60'/0'/0/0") + else: + derivation_path = None + else: + derivation_path = current_derivation_path - if not private_key_file: - typer.secho("No private key file provided or found.", fg=typer.colors.RED) - raise typer.Exit() + # If the current config is hardware, show its current address + if config.type == AccountType.HARDWARE and not derivation_path: + change_address = not yes_no_input("[yellow]Keep current Ledger address?[/yellow]", default="y") + else: + # Switching from imported → hardware, must choose an address + console.print( + "[yellow]Switching from an imported account to a hardware one.[/yellow]\n" + "You'll need to select a Ledger address to use." + ) + change_address = True + + if change_address: + try: + # Wait for ledger being UP before continue anythings + wait_for_ledger_connection() + + if derivation_path: + console.print(f"Using derivation path: [bright_cyan]{derivation_path}[/bright_cyan]") + try: + ledger_account = LedgerETHAccount.from_path(derivation_path) + address = ledger_account.get_address() + console.print(f"Derived address: [bright_cyan]{address}[/bright_cyan]") + except Exception as e: + logger.warning(f"Error getting account from path: {e}") + raise typer.Exit(code=1) from e + else: + # Normal flow - show available accounts and let user choose + accounts = LedgerETHAccount.get_accounts(count=ledger_count) + addresses = [acc.address for acc in accounts] + + console.print(f"[bold cyan]Available addresses on {get_first_ledger_name()}:[/bold cyan]") + for idx, addr in enumerate(addresses, start=1): + console.print(f"[{idx}] {addr}") + + key_choice = Prompt.ask("Choose an address by index") + if key_choice.isdigit(): + key_index = int(key_choice) - 1 + if 0 <= key_index < len(addresses): + address = addresses[key_index] + else: + typer.secho("Invalid address index.", fg=typer.colors.RED) + raise typer.Exit() + else: + typer.secho("Invalid input.", fg=typer.colors.RED) + raise typer.Exit() + + except LedgerError as e: + logger.warning(f"Ledger Error: {getattr(e, 'message', str(e))}") + typer.secho( + "Failed to communicate with Ledger device. Make sure it's unlocked with the Ethereum app open.", + fg=RED, + ) + raise typer.Exit(code=1) from e + except OSError as e: + logger.warning(f"OS Error accessing Ledger: {e!s}") + typer.secho( + "Please ensure Udev rules are set to use Ledger and you have proper USB permissions.", fg=RED + ) + raise typer.Exit(code=1) from e + except BaseException as e: + logger.warning(f"Unexpected error with Ledger: {e!s}") + typer.secho("An unexpected error occurred while communicating with the Ledger device.", fg=RED) + typer.secho("Please ensure your device is connected and working correctly.", fg=RED) + raise typer.Exit(code=1) from e + else: + address = config.address - # Configure active chain - if not chain and config and hasattr(config, "chain"): + # If chain is specified via command line, prioritize it + if chain: + pass + elif 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", @@ -544,12 +760,26 @@ async def configure( typer.secho("No chain provided.", fg=typer.colors.RED) raise typer.Exit() + if not account_type: + account_type = AccountType.IMPORTED + try: - config = MainConfiguration(path=private_key_file, chain=chain) + config = MainConfiguration( + path=private_key_file, chain=chain, address=address, type=account_type, derivation_path=derivation_path + ) save_main_configuration(settings.CONFIG_FILE, config) + + # Display appropriate configuration details based on account type + if account_type == AccountType.HARDWARE: + config_details = f"{config.address}" + if derivation_path: + config_details += f" (derivation path: {derivation_path})" + else: + config_details = f"{config.path}" + console.print( - f"New Default Configuration: [italic bright_cyan]{config.path}[/italic bright_cyan] with [italic " - f"bright_cyan]{config.chain}[/italic bright_cyan]", + f"New Default Configuration: [italic bright_cyan]{config_details}" + f"[/italic bright_cyan] with [italic bright_cyan]{config.chain}[/italic bright_cyan]", style=typer.colors.GREEN, ) except ValueError as e: diff --git a/src/aleph_client/commands/aggregate.py b/src/aleph_client/commands/aggregate.py index c2848e33..98d30c9f 100644 --- a/src/aleph_client/commands/aggregate.py +++ b/src/aleph_client/commands/aggregate.py @@ -8,10 +8,8 @@ import typer from aiohttp import ClientResponseError, ClientSession -from aleph.sdk.account import _load_account -from aleph.sdk.client import AuthenticatedAlephHttpClient +from aleph.sdk.client import AlephHttpClient, AuthenticatedAlephHttpClient from aleph.sdk.conf import settings -from aleph.sdk.types import AccountFromPrivateKey from aleph.sdk.utils import extended_json_encoder from aleph_message.models import Chain, MessageType from aleph_message.status import MessageStatus @@ -21,7 +19,13 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AsyncTyper, sanitize_url +from aleph_client.utils import ( + AccountTypes, + AsyncTyper, + get_account_and_address, + load_account, + sanitize_url, +) logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -51,6 +55,7 @@ async def forget( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, print_message: bool = False, verbose: bool = True, debug: bool = False, @@ -59,7 +64,7 @@ async def forget( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) address = account.get_address() if address is None else address if key == "security" and not is_same_context(): @@ -124,6 +129,7 @@ async def post( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, print_message: bool = False, verbose: bool = True, debug: bool = False, @@ -132,7 +138,7 @@ async def post( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) address = account.get_address() if address is None else address if key == "security" and not is_same_context(): @@ -187,6 +193,7 @@ async def get( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, verbose: bool = True, debug: bool = False, ) -> Optional[dict]: @@ -194,10 +201,11 @@ async def get( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) - address = account.get_address() if address is None else address + _, address = get_account_and_address( + private_key=private_key, private_key_file=private_key_file, chain=chain, address=address + ) - async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: + async with AlephHttpClient(api_server=settings.API_HOST) as client: aggregates = None try: aggregates = await client.fetch_aggregate(address=address, key=key) @@ -222,6 +230,7 @@ async def list_aggregates( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, json: Annotated[bool, typer.Option(help="Print as json instead of rich table")] = False, verbose: bool = True, debug: bool = False, @@ -229,9 +238,9 @@ async def list_aggregates( """Display all aggregates associated to an account""" setup_logging(debug) - - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) - address = account.get_address() if address is None else address + _, address = get_account_and_address( + private_key=private_key, private_key_file=private_key_file, chain=chain, address=address + ) aggr_link = f"{sanitize_url(settings.API_HOST)}/api/v0/aggregates/{address}.json" async with ClientSession() as session: @@ -304,7 +313,7 @@ async def authorize( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) data = await get( key="security", @@ -370,6 +379,7 @@ async def revoke( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, print_message: bool = False, verbose: bool = True, debug: bool = False, @@ -378,7 +388,7 @@ async def revoke( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_file=private_key, private_key_str=private_key_file, chain=chain) data = await get( key="security", @@ -425,6 +435,7 @@ async def permissions( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, json: Annotated[bool, typer.Option(help="Print as json instead of rich table")] = False, verbose: bool = True, debug: bool = False, @@ -433,8 +444,9 @@ async def permissions( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) - address = account.get_address() if address is None else address + _, address = get_account_and_address( + private_key=private_key, private_key_file=private_key_file, chain=chain, address=address + ) data = await get( key="security", diff --git a/src/aleph_client/commands/credit.py b/src/aleph_client/commands/credit.py index 54b8dcde..ab3e9f48 100644 --- a/src/aleph_client/commands/credit.py +++ b/src/aleph_client/commands/credit.py @@ -5,10 +5,9 @@ import typer from aiohttp import ClientResponseError from aleph.sdk import AlephHttpClient -from aleph.sdk.account import _load_account from aleph.sdk.conf import settings -from aleph.sdk.types import AccountFromPrivateKey from aleph.sdk.utils import displayable_amount +from aleph_message.models import Chain from rich import box from rich.console import Console from rich.panel import Panel @@ -17,7 +16,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AsyncTyper +from aleph_client.utils import AsyncTyper, get_account_and_address logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -34,6 +33,7 @@ async def show( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, json: Annotated[bool, typer.Option(help="Display as json")] = False, debug: Annotated[bool, typer.Option()] = False, ): @@ -41,10 +41,9 @@ async def show( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) - - if account and not address: - address = account.get_address() + _, address = get_account_and_address( + private_key=private_key, private_key_file=private_key_file, chain=chain, address=address + ) if address: async with AlephHttpClient(api_server=settings.API_HOST) as client: @@ -80,6 +79,7 @@ async def history( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, page_size: Annotated[int, typer.Option(help="Numbers of element per page")] = 100, page: Annotated[int, typer.Option(help="Current Page")] = 1, json: Annotated[bool, typer.Option(help="Display as json")] = False, @@ -87,10 +87,9 @@ async def history( ): setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) - - if account and not address: - address = account.get_address() + _, address = get_account_and_address( + private_key=private_key, private_key_file=private_key_file, chain=chain, address=address + ) try: # Comment the original API call for testing diff --git a/src/aleph_client/commands/domain.py b/src/aleph_client/commands/domain.py index 538bdcbd..1cf12cfc 100644 --- a/src/aleph_client/commands/domain.py +++ b/src/aleph_client/commands/domain.py @@ -6,7 +6,6 @@ from typing import Annotated, Optional, cast import typer -from aleph.sdk.account import _load_account from aleph.sdk.client import AlephHttpClient, AuthenticatedAlephHttpClient from aleph.sdk.conf import settings from aleph.sdk.domain import ( @@ -18,8 +17,7 @@ ) from aleph.sdk.exceptions import DomainConfigurationError from aleph.sdk.query.filters import MessageFilter -from aleph.sdk.types import AccountFromPrivateKey -from aleph_message.models import AggregateMessage +from aleph_message.models import AggregateMessage, Chain from aleph_message.models.base import MessageType from rich.console import Console from rich.prompt import Confirm, Prompt @@ -27,7 +25,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import is_environment_interactive -from aleph_client.utils import AsyncTyper +from aleph_client.utils import AccountTypes, AsyncTyper, load_account logger = logging.getLogger(__name__) @@ -65,7 +63,7 @@ async def check_domain_records(fqdn, target, owner): async def attach_resource( - account: AccountFromPrivateKey, + account, fqdn: Hostname, item_hash: Optional[str] = None, catch_all_path: Optional[str] = None, @@ -133,11 +131,11 @@ async def attach_resource( console.log("[green bold]Resource attached!") console.log( - f"Visualise on: https://explorer.aleph.im/address/ETH/{account.get_address()}/message/AGGREGATE/{aggregate_message.item_hash}" + f"Visualise on: https://explorer.aleph.cloud/address/ETH/{account.get_address()}/message/AGGREGATE/{aggregate_message.item_hash}" ) -async def detach_resource(account: AccountFromPrivateKey, fqdn: Hostname, interactive: Optional[bool] = None): +async def detach_resource(account: AccountTypes, fqdn: Hostname, interactive: Optional[bool] = None): domain_info = await get_aggregate_domain_info(account, fqdn) interactive = is_environment_interactive() if interactive is None else interactive @@ -170,7 +168,7 @@ async def detach_resource(account: AccountFromPrivateKey, fqdn: Hostname, intera console.log("[green bold]Resource detached!") console.log( - f"Visualise on: https://explorer.aleph.im/address/ETH/{account.get_address()}/message/AGGREGATE/{aggregate_message.item_hash}" + f"Visualise on: https://explorer.aleph.cloud/address/ETH/{account.get_address()}/message/AGGREGATE/{aggregate_message.item_hash}" ) @@ -185,9 +183,10 @@ async def add( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Add and link a Custom Domain.""" - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) interactive = False if (not ask) else is_environment_interactive() console = Console() @@ -270,9 +269,10 @@ async def attach( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Attach resource to a Custom Domain.""" - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) await attach_resource( account, @@ -292,9 +292,10 @@ async def detach( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Unlink Custom Domain.""" - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) await detach_resource(account, Hostname(fqdn), interactive=False if (not ask) else None) raise typer.Exit() @@ -307,9 +308,10 @@ async def info( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Show Custom Domain Details.""" - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) console = Console() domain_validator = DomainValidator() diff --git a/src/aleph_client/commands/files.py b/src/aleph_client/commands/files.py index bad66bcb..581b74a2 100644 --- a/src/aleph_client/commands/files.py +++ b/src/aleph_client/commands/files.py @@ -10,11 +10,10 @@ import typer from aiohttp import ClientResponseError from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient -from aleph.sdk.account import _load_account from aleph.sdk.conf import settings -from aleph.sdk.types import AccountFromPrivateKey, StorageEnum, StoredContent +from aleph.sdk.types import StorageEnum, StoredContent from aleph.sdk.utils import safe_getattr -from aleph_message.models import ItemHash, StoreMessage +from aleph_message.models import Chain, ItemHash, StoreMessage from aleph_message.status import MessageStatus from pydantic import BaseModel, Field from rich import box @@ -23,7 +22,12 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AsyncTyper +from aleph_client.utils import ( + AccountTypes, + AsyncTyper, + get_account_and_address, + load_account, +) logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -31,20 +35,21 @@ @app.command() async def pin( - item_hash: Annotated[str, typer.Argument(help="IPFS hash to pin on aleph.im")], + item_hash: Annotated[str, typer.Argument(help="IPFS hash to pin on Aleph Cloud")], channel: Annotated[Optional[str], typer.Option(help=help_strings.CHANNEL)] = settings.DEFAULT_CHANNEL, private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = settings.PRIVATE_KEY_STRING, private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ref: Annotated[Optional[str], typer.Option(help=help_strings.REF)] = None, debug: Annotated[bool, typer.Option()] = False, ): - """Persist a file from IPFS on aleph.im.""" + """Persist a file from IPFS on Aleph Cloud.""" setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: result: StoreMessage @@ -68,14 +73,15 @@ async def upload( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ref: Annotated[Optional[str], typer.Option(help=help_strings.REF)] = None, debug: Annotated[bool, typer.Option()] = False, ): - """Upload and store a file on aleph.im.""" + """Upload and store a file on Aleph Cloud.""" setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: if not path.is_file(): @@ -127,7 +133,7 @@ async def download( verbose: Annotated[bool, typer.Option()] = True, debug: Annotated[bool, typer.Option()] = False, ) -> Optional[StoredContent]: - """Download a file from aleph.im or display related infos.""" + """Download a file from Aleph Cloud or display related infos.""" setup_logging(debug) @@ -175,13 +181,14 @@ async def forget( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, debug: Annotated[bool, typer.Option()] = False, ): - """forget a file and his message on aleph.im.""" + """Forget a file and his message on Aleph Cloud.""" setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) hashes = [ItemHash(item_hash) for item_hash in item_hash.split(",")] @@ -258,6 +265,7 @@ async def list_files( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, pagination: Annotated[int, typer.Option(help="Maximum number of files to return.")] = 100, page: Annotated[int, typer.Option(help="Offset in pages.")] = 1, sort_order: Annotated[ @@ -270,10 +278,9 @@ async def list_files( json: Annotated[bool, typer.Option(help="Print as json instead of rich table")] = False, ): """List all files for a given address""" - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) - - if account and not address: - address = account.get_address() + account, address = get_account_and_address( + private_key=private_key, private_key_file=private_key_file, address=address, chain=chain + ) if address: # Build the query parameters diff --git a/src/aleph_client/commands/help_strings.py b/src/aleph_client/commands/help_strings.py index de6a862a..ee5d2faa 100644 --- a/src/aleph_client/commands/help_strings.py +++ b/src/aleph_client/commands/help_strings.py @@ -1,17 +1,17 @@ IPFS_HASH = "IPFS Content identifier (CID)" -CHANNEL = "Aleph.im network channel where the message is or will be broadcasted" +CHANNEL = "Aleph Cloud network channel where the message is or will be broadcasted" PRIVATE_KEY = "Your private key. Cannot be used with --private-key-file" PRIVATE_KEY_FILE = "Path to your private key file" REF = "Item hash of the message to update" SIGNABLE_MESSAGE = "Message to sign" CUSTOM_DOMAIN_TARGET_TYPES = "IPFS|PROGRAM|INSTANCE" CUSTOM_DOMAIN_OWNER_ADDRESS = "Owner address. Defaults to current account address" -CUSTOM_DOMAIN_NAME = "Domain name. ex: aleph.im" +CUSTOM_DOMAIN_NAME = "Domain name. ex: aleph.cloud" CUSTOM_DOMAIN_ITEM_HASH = "Item hash" SKIP_VOLUME = "Skip prompt to attach more volumes" -PERSISTENT_VOLUME = "Persistent volumes are allocated on the host machine and are not deleted when the VM is stopped.\nRequires at least `name`, `mount` path, and `size_mib`. To add multiple, reuse the same argument.\nExample: --persistent-volume name=data,mount=/opt/data,size_mib=1000.\nFor more info, see the docs: https://docs.aleph.im/computing/volumes/persistent/" +PERSISTENT_VOLUME = "Persistent volumes are allocated on the host machine and are not deleted when the VM is stopped.\nRequires at least `name`, `mount` path, and `size_mib`. To add multiple, reuse the same argument.\nExample: --persistent-volume name=data,mount=/opt/data,size_mib=1000.\nFor more info, see the docs: https://docs.aleph.cloud/devhub/building-applications/data-storage/types-of-storage/persistent-storage.html" EPHEMERAL_VOLUME = "Ephemeral volumes are allocated on the host machine when the VM is started and deleted when the VM is stopped.\nRequires at least `mount` path and `size_mib`. To add multiple, reuse the same argument.\nExample: --ephemeral-volume mount=/opt/tmp,size_mib=100" -IMMUTABLE_VOLUME = "Immutable volumes are pinned on the network and can be used by multiple VMs at the same time. They are read-only and useful for setting up libraries or other dependencies.\nRequires at least `mount` path and `ref` (volume message hash). `use_latest` is True by default, to use the latest version of the volume, if it has been amended. To add multiple, reuse the same argument.\nExample: --immutable-volume mount=/opt/packages,ref=25a3...8d94.\nFor more info, see the docs: https://docs.aleph.im/computing/volumes/immutable/" +IMMUTABLE_VOLUME = "Immutable volumes are pinned on the network and can be used by multiple VMs at the same time. They are read-only and useful for setting up libraries or other dependencies.\nRequires at least `mount` path and `ref` (volume message hash). `use_latest` is True by default, to use the latest version of the volume, if it has been amended. To add multiple, reuse the same argument.\nExample: --immutable-volume mount=/opt/packages,ref=25a3...8d94.\nFor more info, see the docs: https://docs.aleph.cloud/devhub/building-applications/data-storage/types-of-storage/immutable-volume.html" SKIP_ENV_VAR = "Skip prompt to set environment variables" ENVIRONMENT_VARIABLES = "Environment variables to pass. They will be public and visible in the message, so don't include secrets. Must be a comma separated list. Example: `KEY=value` or `KEY=value,KEY=value`" ASK_FOR_CONFIRMATION = "Prompt user for confirmation" @@ -53,7 +53,7 @@ PAYMENT_CHAIN_PROGRAM_USED = "Chain you are using to pay for your program" ORIGIN_CHAIN = "Chain of origin of your private key (ensuring correct parsing)" ADDRESS_CHAIN = "Chain for the address" -ADDRESS_PAYER = "Address of the payer. In order to delegate the payment, your account must be authorized beforehand to publish on the behalf of this address. See the docs for more info: https://docs.aleph.im/protocol/permissions/" +ADDRESS_PAYER = "Address of the payer. In order to delegate the payment, your account must be authorized beforehand to publish on the behalf of this address. See the docs for more info: https://docs.aleph.cloud/devhub/building-applications/messaging/permissions.html#message-permissions" CREATE_REPLACE = "Overwrites private key file if it already exists" CREATE_ACTIVE = "Loads the new private key after creation" PROMPT_CRN_URL = "URL of the CRN (Compute node) on which the instance is running" diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index bed8b2d5..fe725cf4 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -11,8 +11,7 @@ import aiohttp import typer from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient -from aleph.sdk.account import _load_account -from aleph.sdk.chains.ethereum import ETHAccount +from aleph.sdk.chains.ethereum import BaseEthAccount from aleph.sdk.client.services.crn import NetworkGPUS from aleph.sdk.client.services.pricing import Price from aleph.sdk.client.vm_client import VmClient @@ -82,7 +81,13 @@ yes_no_input, ) from aleph_client.models import CRNInfo -from aleph_client.utils import AsyncTyper, sanitize_url +from aleph_client.utils import ( + AccountTypes, + AsyncTyper, + get_account_and_address, + load_account, + sanitize_url, +) logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -150,10 +155,15 @@ async def create( verbose: Annotated[bool, typer.Option(help="Display additional information")] = True, debug: Annotated[bool, typer.Option(help="Enable debug logging")] = False, ) -> tuple[ItemHash, Optional[str], Chain]: - """Create and register a new instance on aleph.im""" + """Create and register a new instance on aleph.cloud""" setup_logging(debug) console = Console() + # Start CRN list fetch as a background task + crn_list_future = call_program_crn_list() + crn_list_future.set_name("crn-list") + await asyncio.sleep(0.0) # Yield control to let the task start + # Loads ssh pubkey try: ssh_pubkey_file = validate_ssh_pubkey_file(ssh_pubkey_file) @@ -167,13 +177,9 @@ async def create( ssh_pubkey: str = ssh_pubkey_file.read_text(encoding="utf-8").strip() # Populates account / address - account = _load_account(private_key, private_key_file, chain=payment_chain) - address = address or settings.ADDRESS_TO_USE or account.get_address() + account: AccountTypes = load_account(private_key, private_key_file, chain=payment_chain) - # Start the fetch in the background (async_lru_cache already returns a future) - # We'll await this at the point we need it - crn_list_future = call_program_crn_list() - crn_list = None + address = address or settings.ADDRESS_TO_USE or account.get_address() # Loads default configuration if no chain is set if payment_chain is None: @@ -295,9 +301,9 @@ async def create( try: rootfs_message = await client.get_message(item_hash=rootfs, message_type=StoreMessage) except MessageNotFoundError: - echo(f"Given rootfs volume {rootfs} does not exist on aleph.im") + echo(f"Given rootfs volume {rootfs} does not exist on aleph.cloud") except ForgottenMessageError: - echo(f"Given rootfs volume {rootfs} has been deleted on aleph.im") + echo(f"Given rootfs volume {rootfs} has been deleted on aleph.cloud") if not rootfs_message: raise typer.Exit(code=1) @@ -310,14 +316,17 @@ async def create( try: firmware_message = await client.get_message(item_hash=confidential_firmware, message_type=StoreMessage) except MessageNotFoundError: - echo("Confidential Firmware hash does not exist on aleph.im") + echo("Confidential Firmware hash does not exist on aleph.cloud") except ForgottenMessageError: - echo("Confidential Firmware hash has been deleted on aleph.im") + echo("Confidential Firmware hash has been deleted on aleph.cloud") if not firmware_message: raise typer.Exit(code=1) - if not crn_list: - crn_list = await crn_list_future + # Now we need the CRN list data, so await the future + if crn_list_future.done(): + crn_list = crn_list_future.result() + else: + crn_list = await asyncio.wait_for(crn_list_future, timeout=None) # Filter and prepare the list of available GPUs found_gpu_models: Optional[NetworkGPUS] = None @@ -390,12 +399,13 @@ async def create( name = name or validated_prompt("Instance name", lambda x: x and len(x) < 65) specs = pricing.data[pricing_entity].get_services_specs(tier) - vcpus = specs.vcpus - memory = specs.memory_mib - disk_size = specs.disk_mib + vcpus = vcpus if vcpus and payment_type != PaymentType.hold else specs.vcpus + memory = memory if memory and payment_type != PaymentType.hold else specs.memory_mib + disk_size = rootfs_size if rootfs_size and payment_type != PaymentType.hold else specs.disk_mib + gpu_model = specs.gpu_model - disk_size_info = f"Rootfs Size: {round(disk_size/1024, 2)} GiB (defaulted to included storage in tier)" + disk_size_info = f"Rootfs Size: {round(disk_size / 1024, 2)} GiB (defaulted to included storage in tier)" if not isinstance(rootfs_size, int): rootfs_size = validated_int_prompt( "Custom Rootfs Size (MiB)", @@ -405,7 +415,7 @@ async def create( ) if rootfs_size > disk_size: disk_size = rootfs_size - disk_size_info = f"Rootfs Size: {round(rootfs_size/1024, 2)} GiB (extended from included storage in tier)" + disk_size_info = f"Rootfs Size: {round(rootfs_size / 1024, 2)} GiB (extended from included storage in tier)" echo(disk_size_info) volumes = [] if any([persistent_volume, ephemeral_volume, immutable_volume]) or not skip_volume: @@ -421,12 +431,13 @@ async def create( async with AlephHttpClient(api_server=settings.API_HOST) as client: balance_response = await client.get_balances(address) available_amount = balance_response.balance - balance_response.locked_amount - available_funds = Decimal(0 if is_stream else available_amount) + available_funds = Decimal(available_amount) try: # Get compute_unit price from PricingPerEntity - if is_stream and isinstance(account, ETHAccount): + if is_stream and isinstance(account, BaseEthAccount): if account.CHAIN != payment_chain: account.switch_chain(payment_chain) + if safe_getattr(account, "superfluid_connector"): if isinstance(compute_unit_price, Price) and compute_unit_price.payg: payg_price = Decimal(str(compute_unit_price.payg)) * tier.compute_units @@ -478,11 +489,6 @@ async def create( raise typer.Exit(1) from e if crn_url or crn_hash: - if not gpu: - echo("Fetching compute resource node's list...") - if not crn_list: - crn_list = await crn_list_future - crn = crn_list.find_crn( address=crn_url, crn_hash=crn_hash, @@ -504,8 +510,6 @@ async def create( raise typer.Exit(1) while not crn_info: - if not crn_list: - crn_list = await crn_list_future filtered_crns = crn_list.filter_crn( latest_crn_version=True, @@ -535,11 +539,11 @@ async def create( elif crn_url or crn_hash: logger.debug( "`--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." + "instances are scheduled automatically on available CRNs by the Aleph Cloud network." ) requirements, trusted_execution, gpu_requirement, tac_accepted = None, None, None, None - if crn and crn_info: + if crn_info: if is_stream and not crn_info.stream_reward_address: echo("Selected CRN does not have a defined or valid receiver address.") raise typer.Exit(1) @@ -639,7 +643,7 @@ async def create( raise typer.Exit(code=1) from e try: - if is_stream and isinstance(account, ETHAccount): + if is_stream and isinstance(account, BaseEthAccount): account.can_start_flow(required_tokens) elif available_funds < required_tokens: raise InsufficientFundsError(TokenType.ALEPH, float(required_tokens), float(available_funds)) @@ -690,7 +694,7 @@ async def create( await wait_for_processed_instance(session, item_hash) # Pay-As-You-Go - if is_stream and isinstance(account, ETHAccount): + if is_stream and isinstance(account, BaseEthAccount): # Start the flows echo("Starting the flows...") fetched_settings = await fetch_settings() @@ -719,9 +723,9 @@ async def create( f"[orange3]{key}[/orange3]: {value}" for key, value in { "$ALEPH": f"[violet]{displayable_amount(required_tokens, decimals=8)}/sec" - f" | {displayable_amount(3600*required_tokens, decimals=3)}/hour" - f" | {displayable_amount(86400*required_tokens, decimals=3)}/day" - f" | {displayable_amount(2628000*required_tokens, decimals=3)}/month[/violet]", + f" | {displayable_amount(3600 * required_tokens, decimals=3)}/hour" + f" | {displayable_amount(86400 * required_tokens, decimals=3)}/day" + f" | {displayable_amount(2628000 * required_tokens, decimals=3)}/month[/violet]", "Flow Distribution": "\n[bright_cyan]80% ➜ CRN wallet[/bright_cyan]" f"\n Address: {crn_info.stream_reward_address}\n Tx: {flow_hash_crn}" f"\n[bright_cyan]20% ➜ Community wallet[/bright_cyan]" @@ -747,7 +751,9 @@ async def create( return item_hash, crn_url, payment_chain infos += [ - Text.from_markup(f"Your instance [bright_cyan]{item_hash}[/bright_cyan] has been deployed on aleph.im.") + Text.from_markup( + f"Your instance [bright_cyan]{item_hash}[/bright_cyan] has been deployed on aleph.cloud." + ) ] if verbose: # PAYG-tier non-confidential instances @@ -781,7 +787,8 @@ async def create( else: infos += [ Text.from_markup( - f"Your instance [bright_cyan]{item_hash}[/bright_cyan] is registered to be deployed on aleph.im.\n" + f"Your instance [bright_cyan]{item_hash}[/bright_cyan] is registered" + "to be deployed on aleph.cloud.\n" "The scheduler usually takes a few minutes to set it up and start it.\n" ) ] @@ -830,7 +837,7 @@ async def delete( setup_logging(debug) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = 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( @@ -882,7 +889,12 @@ async def delete( echo("No CRN information available for this instance. Skipping VM erasure.") # Check for streaming payment and eventually stop it - if payment and payment.type == PaymentType.superfluid and payment.receiver and isinstance(account, ETHAccount): + if ( + payment + and payment.type == PaymentType.superfluid + and payment.receiver + and isinstance(account, BaseEthAccount) + ): if account.CHAIN != payment.chain: account.switch_chain(payment.chain) if safe_getattr(account, "superfluid_connector") and price: @@ -942,8 +954,7 @@ async def list_instances( setup_logging(debug) - account = _load_account(private_key, private_key_file, chain=chain) - address = address or settings.ADDRESS_TO_USE or account.get_address() + account, address = get_account_and_address(private_key, private_key_file, address, chain) async with AlephHttpClient(api_server=settings.API_HOST) as client: instances: list[InstanceMessage] = await client.instance.get_instances(address=address) @@ -979,7 +990,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, chain=chain) + account: AccountTypes = 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) @@ -1012,7 +1023,7 @@ 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, chain=chain) + account: AccountTypes = 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) @@ -1040,7 +1051,7 @@ async def logs( domain = (domain and sanitize_url(domain)) or await find_crn_of_vm(vm_id) or Prompt.ask(help_strings.PROMPT_CRN_URL) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain=chain) async with VmClient(account, domain) as manager: try: @@ -1071,7 +1082,7 @@ async def stop( domain = (domain and sanitize_url(domain)) or await find_crn_of_vm(vm_id) or Prompt.ask(help_strings.PROMPT_CRN_URL) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = 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) @@ -1110,7 +1121,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, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain=chain) sevctl_path = find_sevctl_or_exit() @@ -1187,7 +1198,7 @@ async def confidential_start( session_dir.mkdir(exist_ok=True, parents=True) vm_hash = ItemHash(vm_id) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain=chain) sevctl_path = find_sevctl_or_exit() domain = ( @@ -1470,7 +1481,7 @@ async def gpu_create( verbose: Annotated[bool, typer.Option(help="Display additional information")] = True, debug: Annotated[bool, typer.Option(help="Enable debug logging")] = False, ): - """Create and register a new GPU instance on aleph.im + """Create and register a new GPU instance on aleph.cloud Only compatible with Pay-As-You-Go""" diff --git a/src/aleph_client/commands/instance/display.py b/src/aleph_client/commands/instance/display.py index 28919711..3cd512cd 100644 --- a/src/aleph_client/commands/instance/display.py +++ b/src/aleph_client/commands/instance/display.py @@ -251,7 +251,9 @@ async def _prepare_instance_column(self): name = Text.assemble(name, "\t", status_badge) # Item hash with explorer link - link = f"https://explorer.aleph.im/address/ETH/{self.message.sender}/message/INSTANCE/{self.message.item_hash}" + link = ( + f"https://explorer.aleph.cloud/address/ETH/{self.message.sender}/message/INSTANCE/{self.message.item_hash}" + ) item_hash_link = Text.from_markup(f"[link={link}]{self.message.item_hash}[/link]", style="bright_cyan") # Payment information diff --git a/src/aleph_client/commands/instance/network.py b/src/aleph_client/commands/instance/network.py index 8c274e93..db3459a3 100644 --- a/src/aleph_client/commands/instance/network.py +++ b/src/aleph_client/commands/instance/network.py @@ -23,18 +23,17 @@ latest_crn_version_link = "https://api.github.com/repos/aleph-im/aleph-vm/releases/latest" settings_link = ( - f"{sanitize_url(settings.API_HOST)}" - "/api/v0/aggregates/0xFba561a84A537fCaa567bb7A2257e7142701ae2A.json?keys=settings" + f"{sanitize_url(settings.API_HOST)}/api/v0/aggregates/0xFba561a84A537fCaa567bb7A2257e7142701ae2A.json?keys=settings" ) @async_lru_cache -async def call_program_crn_list() -> CrnList: +async def call_program_crn_list(only_active: bool = False) -> CrnList: """Call program to fetch the compute resource node list.""" error = None try: async with AlephHttpClient() as client: - return await client.crn.get_crns_list(False) + return await client.crn.get_crns_list(only_active) except InvalidURL as e: error = f"Invalid URL: {settings.CRN_LIST_URL}: {e}" except TimeoutError as e: @@ -152,9 +151,9 @@ async def find_crn_of_vm(vm_id: str) -> Optional[str]: # This is InstanceWithScheduler return info.allocations.node.url except MessageNotFoundError: - echo("Instance does not exist on aleph.im") + echo("Instance does not exist on aleph.cloud") except ForgottenMessageError: - echo("Instance has been deleted on aleph.im") + echo("Instance has been deleted on aleph.cloud") return None diff --git a/src/aleph_client/commands/instance/port_forwarder.py b/src/aleph_client/commands/instance/port_forwarder.py index 58421402..cceb266b 100644 --- a/src/aleph_client/commands/instance/port_forwarder.py +++ b/src/aleph_client/commands/instance/port_forwarder.py @@ -7,7 +7,6 @@ import typer from aiohttp import ClientResponseError from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient -from aleph.sdk.account import _load_account from aleph.sdk.conf import settings from aleph.sdk.exceptions import MessageNotProcessed, NotAuthorize from aleph.sdk.types import InstanceManual, PortFlags, Ports @@ -21,7 +20,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AsyncTyper +from aleph_client.utils import AccountTypes, AsyncTyper, load_account logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -42,7 +41,7 @@ async def list_ports( setup_logging(debug) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain=chain) address = address or settings.ADDRESS_TO_USE or account.get_address() async with AlephHttpClient(api_server=settings.API_HOST) as client: @@ -160,7 +159,7 @@ async def create( typer.echo("Error: Port must be between 1 and 65535") raise typer.Exit(code=1) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain) # Create the port flags port_flags = PortFlags(tcp=tcp, udp=udp) @@ -213,7 +212,7 @@ async def update( typer.echo("Error: Port must be between 1 and 65535") raise typer.Exit(code=1) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain) # First check if the port forward exists async with AlephHttpClient(api_server=settings.API_HOST) as client: @@ -293,7 +292,7 @@ async def delete( typer.echo("Error: Port must be between 1 and 65535") raise typer.Exit(code=1) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain) # First check if the port forward exists async with AlephHttpClient(api_server=settings.API_HOST) as client: @@ -376,7 +375,7 @@ async def refresh( setup_logging(debug) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain) try: async with AuthenticatedAlephHttpClient(api_server=settings.API_HOST, account=account) as client: diff --git a/src/aleph_client/commands/message.py b/src/aleph_client/commands/message.py index a7a48d60..326c6d84 100644 --- a/src/aleph_client/commands/message.py +++ b/src/aleph_client/commands/message.py @@ -11,7 +11,6 @@ import typer from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient -from aleph.sdk.account import _load_account from aleph.sdk.conf import settings from aleph.sdk.exceptions import ( ForgottenMessageError, @@ -20,9 +19,9 @@ ) from aleph.sdk.query.filters import MessageFilter from aleph.sdk.query.responses import MessagesResponse -from aleph.sdk.types import AccountFromPrivateKey, StorageEnum +from aleph.sdk.types import StorageEnum from aleph.sdk.utils import extended_json_encoder -from aleph_message.models import AlephMessage, ProgramMessage +from aleph_message.models import AlephMessage, Chain, ProgramMessage from aleph_message.models.base import MessageType from aleph_message.models.item_hash import ItemHash from aleph_message.status import MessageStatus @@ -35,7 +34,7 @@ setup_logging, str_to_datetime, ) -from aleph_client.utils import AsyncTyper +from aleph_client.utils import AccountTypes, AsyncTyper, load_account app = AsyncTyper(no_args_is_help=True) @@ -49,11 +48,11 @@ async def get( try: message, status = await client.get_message(item_hash=ItemHash(item_hash), with_status=True) except MessageNotFoundError: - typer.echo("Message does not exist on aleph.im") + typer.echo("Message does not exist on aleph.cloud") except ForgottenMessageError: - typer.echo("Message has been forgotten on aleph.im") + typer.echo("Message has been forgotten on aleph.cloud") except RemovedMessageError: - typer.echo("Message has been removed on aleph.im") + typer.echo("Message has been removed on aleph.cloud") if message: typer.echo(f"Message Status: {colorized_status(status)}") if status == MessageStatus.REJECTED: @@ -132,13 +131,14 @@ async def post( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, debug: Annotated[bool, typer.Option()] = False, ): - """Post a message on aleph.im.""" + """Post a message on aleph.cloud.""" setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) storage_engine: StorageEnum content: dict @@ -182,22 +182,23 @@ async def amend( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, debug: Annotated[bool, typer.Option()] = False, ): - """Amend an existing aleph.im message.""" + """Amend an existing aleph.cloud message.""" setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) async with AlephHttpClient(api_server=settings.API_HOST) as client: existing_message: Optional[AlephMessage] = None try: existing_message = await client.get_message(item_hash=item_hash) except MessageNotFoundError: - typer.echo("Message does not exist on aleph.im") + typer.echo("Message does not exist on aleph.cloud") except ForgottenMessageError: - typer.echo("Message has been forgotten on aleph.im") + typer.echo("Message has been forgotten on aleph.cloud") if existing_message: editor: str = os.getenv("EDITOR", default="nano") with tempfile.NamedTemporaryFile(suffix="json") as fd: @@ -245,15 +246,16 @@ async def forget( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, debug: Annotated[bool, typer.Option()] = False, ): - """Forget an existing aleph.im message.""" + """Forget an existing aleph.cloud message.""" setup_logging(debug) hash_list: list[ItemHash] = [ItemHash(h) for h in hashes.split(",")] - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: await client.forget(hashes=hash_list, reason=reason, channel=channel) @@ -273,9 +275,9 @@ async def watch( try: original = await client.get_message(item_hash=ref) except MessageNotFoundError: - typer.echo("Message does not exist on aleph.im") + typer.echo("Message does not exist on aleph.cloud") except ForgottenMessageError: - typer.echo("Message has been forgotten on aleph.im") + typer.echo("Message has been forgotten on aleph.cloud") if original: async for message in client.watch_messages( message_filter=MessageFilter(refs=[ref], addresses=[original.content.address]) @@ -290,13 +292,14 @@ def sign( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, debug: Annotated[bool, typer.Option()] = False, ): """Sign an aleph message with a private key. If no --message is provided, the message will be read from stdin.""" setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) if message is None: message = input_multiline() diff --git a/src/aleph_client/commands/pricing.py b/src/aleph_client/commands/pricing.py index 06a0789d..e333e70c 100644 --- a/src/aleph_client/commands/pricing.py +++ b/src/aleph_client/commands/pricing.py @@ -86,7 +86,7 @@ def build_storage_and_website( infos.append( Text.from_markup( "Service & Availability (Holding): [orange1]" - f"{displayable_amount(price_dict.get('fixed'),decimals=3)}" + f"{displayable_amount(price_dict.get('fixed'), decimals=3)}" " tokens[/orange1]\n" ) ) @@ -172,7 +172,7 @@ def fill_tier( row = [ tier_id, str(tier.compute_units), - str(entity_info.compute_unit.vcpus), + str(entity_info.compute_unit.vcpus * tier.compute_units), f"{entity_info.compute_unit.memory_mib * tier.compute_units / 1024:.0f}", f"{entity_info.compute_unit.disk_mib * tier.compute_units / 1024:.0f}", ] @@ -406,7 +406,7 @@ async def prices_for_service( ] = False, debug: bool = False, ): - """Display pricing for services available on aleph.im & twentysix.cloud""" + """Display pricing for services available on aleph.cloud""" setup_logging(debug) @@ -416,7 +416,6 @@ async def prices_for_service( # Fetch Current availibity network_gpu = None if (service in [GroupEntity.GPU, GroupEntity.ALL]) and with_current_availability: - crn_lists = await call_program_crn_list() network_gpu = crn_lists.find_gpu_on_network() if json: diff --git a/src/aleph_client/commands/program.py b/src/aleph_client/commands/program.py index 4105656f..21db2324 100644 --- a/src/aleph_client/commands/program.py +++ b/src/aleph_client/commands/program.py @@ -24,7 +24,7 @@ ) from aleph.sdk.query.filters import MessageFilter from aleph.sdk.query.responses import PriceResponse -from aleph.sdk.types import AccountFromPrivateKey, StorageEnum, TokenType +from aleph.sdk.types import StorageEnum, TokenType from aleph.sdk.utils import displayable_amount, make_program_content, safe_getattr from aleph_message.models import ( Chain, @@ -58,7 +58,12 @@ validated_prompt, yes_no_input, ) -from aleph_client.utils import AsyncTyper, create_archive, sanitize_url +from aleph_client.utils import ( + AsyncTyper, + create_archive, + get_account_and_address, + sanitize_url, +) logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -110,9 +115,9 @@ async def upload( verbose: Annotated[bool, typer.Option(help="Display additional information")] = True, debug: Annotated[bool, typer.Option(help="Enable debug logging")] = False, ) -> Optional[str]: - """Register a program to run on aleph.im (create/upload are aliases) + """Register a program to run on aleph.cloud (create/upload are aliases) - For more information, see https://docs.aleph.im/computing""" + For more information, see https://docs.aleph.cloud/devhub/compute-resources/""" setup_logging(debug) console = Console() @@ -127,7 +132,7 @@ async def upload( typer.echo("No such file or directory") raise typer.Exit(code=4) from error - account: AccountFromPrivateKey = _load_account(private_key, private_key_file, chain=payment_chain) + account = _load_account(private_key, private_key_file, chain=payment_chain) address = address or settings.ADDRESS_TO_USE or account.get_address() # Loads default configuration if no chain is set @@ -280,7 +285,9 @@ async def upload( func_url_1 = f"{settings.VM_URL_PATH.format(hash=item_hash)}" func_url_2 = f"{settings.VM_URL_HOST.format(hash_base32=hash_base32)}" infos = [ - Text.from_markup(f"Your program [bright_cyan]{item_hash}[/bright_cyan] has been uploaded on aleph.im."), + Text.from_markup( + f"Your program [bright_cyan]{item_hash}[/bright_cyan] has been uploaded on aleph.cloud." + ), Text.assemble( "\n\nAvailable on:\n", Text.from_markup( @@ -293,7 +300,7 @@ async def upload( ), "\n\nVisualise on:\n", Text.from_markup( - f"[blue]https://explorer.aleph.im/address/{message.chain.value}/{message.sender}/message/PROGRAM/{item_hash}[/blue]" + f"[blue]https://explorer.aleph.cloud/address/{message.chain.value}/{message.sender}/message/PROGRAM/{item_hash}[/blue]" ), ), ] @@ -339,16 +346,16 @@ async def update( typer.echo("No such file or directory") raise typer.Exit(code=4) from error - account: AccountFromPrivateKey = _load_account(private_key, private_key_file, chain=chain) + account = _load_account(private_key, private_key_file, chain=chain) async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: try: program_message: ProgramMessage = await client.get_message(item_hash=item_hash, message_type=ProgramMessage) except MessageNotFoundError: - typer.echo("Program does not exist on aleph.im") + typer.echo("Program does not exist on aleph.cloud") return 1 except ForgottenMessageError: - typer.echo("Program has been deleted on aleph.im") + typer.echo("Program has been deleted on aleph.cloud") return 1 if program_message.sender != account.get_address(): typer.echo("You are not the owner of this program") @@ -358,10 +365,10 @@ async def update( try: code_message: StoreMessage = await client.get_message(item_hash=code_ref, message_type=StoreMessage) except MessageNotFoundError: - typer.echo("Code volume does not exist on aleph.im") + typer.echo("Code volume does not exist on aleph.cloud") return 1 except ForgottenMessageError: - typer.echo("Code volume has been deleted on aleph.im") + typer.echo("Code volume has been deleted on aleph.cloud") return 1 if encoding != program_message.content.code.encoding: logger.error( @@ -449,10 +456,10 @@ async def delete( item_hash=item_hash, message_type=ProgramMessage ) except MessageNotFoundError: - typer.echo("Program does not exist on aleph.im") + typer.echo("Program does not exist on aleph.cloud") return 1 except ForgottenMessageError: - typer.echo("Program has been already deleted on aleph.im") + typer.echo("Program has been already deleted on aleph.cloud") return 1 if existing_message.sender != account.get_address(): typer.echo("You are not the owner of this program") @@ -502,8 +509,14 @@ async def list_programs( setup_logging(debug) - account = _load_account(private_key, private_key_file, chain=chain) - address = address or settings.ADDRESS_TO_USE or account.get_address() + account, address = get_account_and_address( + private_key=private_key, private_key_file=private_key_file, address=address, chain=chain + ) + + # Ensure we have an address to query + if not address: + typer.echo("Error: No address found. Please provide an address or use a private key.") + raise typer.Exit(code=1) async with AlephHttpClient(api_server=settings.API_HOST) as client: resp = await client.get_messages( @@ -542,7 +555,7 @@ async def list_programs( ), style="magenta3", ) - msg_link = f"https://explorer.aleph.im/address/ETH/{message.sender}/message/PROGRAM/{message.item_hash}" + msg_link = f"https://explorer.aleph.cloud/address/ETH/{message.sender}/message/PROGRAM/{message.item_hash}" item_hash_link = Text.from_markup(f"[link={msg_link}]{message.item_hash}[/link]", style="bright_cyan") payment_type = safe_getattr(message.content, "payment.type", PaymentType.hold) payment = Text.assemble( @@ -615,7 +628,7 @@ async def list_programs( f"Updatable: {'[green]Yes[/green]' if message.content.allow_amend else '[orange3]Code only[/orange3]'}", ] specifications = Text.from_markup("".join(specs)) - config = Text.assemble( + config_info = Text.assemble( Text.from_markup( f"Runtime: [bright_cyan][link={settings.API_HOST}/api/v0/messages/{message.content.runtime.ref}]" f"{message.content.runtime.ref}[/link][/bright_cyan]\n" @@ -625,7 +638,7 @@ async def list_programs( ), Text.from_markup(display_mounted_volumes(message)), ) - table.add_row(program, specifications, config) + table.add_row(program, specifications, config_info) table.add_section() console = Console() @@ -673,10 +686,10 @@ async def persist( try: message: ProgramMessage = await client.get_message(item_hash=item_hash, message_type=ProgramMessage) except MessageNotFoundError: - typer.echo("Program does not exist on aleph.im") + typer.echo("Program does not exist on aleph.cloud") return None except ForgottenMessageError: - typer.echo("Program has been deleted on aleph.im") + typer.echo("Program has been deleted on aleph.cloud") return None if message.sender != account.get_address(): typer.echo("You are not the owner of this program") @@ -770,10 +783,10 @@ async def unpersist( try: message: ProgramMessage = await client.get_message(item_hash=item_hash, message_type=ProgramMessage) except MessageNotFoundError: - typer.echo("Program does not exist on aleph.im") + typer.echo("Program does not exist on alepcloud") return None except ForgottenMessageError: - typer.echo("Program has been deleted on aleph.im") + typer.echo("Program has been deleted on aleph.cloud") return None if message.sender != account.get_address(): typer.echo("You are not the owner of this program") diff --git a/src/aleph_client/commands/utils.py b/src/aleph_client/commands/utils.py index b4174724..52a63c1e 100644 --- a/src/aleph_client/commands/utils.py +++ b/src/aleph_client/commands/utils.py @@ -10,14 +10,21 @@ from pathlib import Path from typing import Any, Callable, Optional, TypeVar, Union, get_args +import typer from aiohttp import ClientSession from aleph.sdk import AlephHttpClient from aleph.sdk.chains.ethereum import ETHAccount -from aleph.sdk.conf import settings +from aleph.sdk.conf import AccountType, settings from aleph.sdk.exceptions import ForgottenMessageError, MessageNotFoundError from aleph.sdk.types import GenericMessage from aleph.sdk.utils import safe_getattr -from aleph_message.models import AlephMessage, InstanceMessage, ItemHash, ProgramMessage +from aleph_message.models import ( + AlephMessage, + Chain, + InstanceMessage, + ItemHash, + ProgramMessage, +) from aleph_message.models.execution.volume import ( EphemeralVolumeSize, PersistentVolumeSizeMib, @@ -406,3 +413,103 @@ def find_sevctl_or_exit() -> Path: echo("Instructions for setup https://docs.aleph.im/computing/confidential/requirements/") raise Exit(code=1) return Path(sevctl_path) + + +def validate_non_interactive_args_config( + config, + account_type: Optional[AccountType], + private_key_file: Optional[Path], + address: Optional[str], + chain: Optional[Chain], + derivation_path: Optional[str] = None, +) -> None: + """ + Validate argument combinations when running in non-interactive (--no) mode. + + This function enforces logical consistency for non-interactive configuration + updates, ensuring that only valid combinations of arguments are accepted + when prompts are disabled. + + Validation Rules + ---------------- + 1. Hardware accounts require an address OR a derivation path. + `--account-type hardware --address 0xABC --no` + `--account-type hardware --derivation-path "44'/60'/0'/0/0" --no` + + 2. Imported accounts require a private key file. + `--account-type imported --no` + `--account-type imported --private-key-file my.key --no` + + 3. Private key file and address cannot be combined. + `--address 0xABC --private-key-file key.key --no` + + 4. Private key files are invalid for hardware accounts. + Applies both when the *new* or *existing* account type is hardware. + + 5. Addresses are invalid for imported accounts. + Applies both when the *new* or *existing* account type is imported. + + 6. Derivation paths are invalid for imported accounts. + Applies both when the *new* or *existing* account type is imported. + + 7. Chain updates are always allowed. + `--chain ETH --no` + + 8. If no arguments are provided with `--no`, the command performs no changes + and simply keeps the existing configuration. + + Parameters + ---------- + config : MainConfiguration + The currently loaded configuration object. + account_type : Optional[AccountType] + The new account type to set (e.g. HARDWARE, IMPORTED). + private_key_file : Optional[Path] + A path to a private key file (for imported accounts only). + address : Optional[str] + The account address (for hardware accounts only). + chain : Optional[Chain] + The blockchain chain to switch to. + derivation_path : Optional[str] + The derivation path for ledger hardware wallets. + + Raises + ------ + typer.Exit + If an invalid argument combination is detected. + """ + + # 1. Hardware requires address or derivation path + if account_type == AccountType.HARDWARE and not (address or derivation_path): + typer.secho("--no mode: hardware accounts require either --address or --derivation-path.", fg=typer.colors.RED) + raise typer.Exit(1) + + # 2. Imported requires private key file + if account_type == AccountType.IMPORTED and not private_key_file: + typer.secho("--no mode: imported accounts require --private-key-file.", fg=typer.colors.RED) + raise typer.Exit(1) + + # 3. Both address + private key provided + if private_key_file and address: + typer.secho("Cannot specify both --address and --private-key-file.", fg=typer.colors.RED) + raise typer.Exit(1) + + # 4. Private key invalid for hardware + if private_key_file and (account_type == AccountType.HARDWARE or (config and config.type == AccountType.HARDWARE)): + typer.secho("Cannot use private key file for hardware accounts.", fg=typer.colors.RED) + raise typer.Exit(1) + + # 5. Address invalid for imported + if address and (account_type == AccountType.IMPORTED or (config and config.type == AccountType.IMPORTED)): + typer.secho("Cannot use address for imported accounts.", fg=typer.colors.RED) + raise typer.Exit(1) + + # 6. Derivation path invalid for imported + if derivation_path and (account_type == AccountType.IMPORTED or (config and config.type == AccountType.IMPORTED)): + typer.secho("Cannot use derivation path for imported accounts.", fg=typer.colors.RED) + raise typer.Exit(1) + + # 8. No arguments provided = no-op + if not any([private_key_file, chain, address, account_type, derivation_path]): + typer.secho("No changes provided. Keeping existing configuration.", fg=typer.colors.YELLOW) + raise typer.Exit(0) diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index cc3c5aaa..791489d3 100644 --- a/src/aleph_client/utils.py +++ b/src/aleph_client/utils.py @@ -7,6 +7,7 @@ import re import subprocess import sys +import time from asyncio import ensure_future from functools import lru_cache, partial, wraps from pathlib import Path @@ -16,14 +17,25 @@ from zipfile import BadZipFile, ZipFile import aiohttp +import hid import typer from aiohttp import ClientSession -from aleph.sdk.conf import MainConfiguration, load_main_configuration, settings +from aleph.sdk.account import AccountTypes, _load_account +from aleph.sdk.conf import ( + AccountType, + MainConfiguration, + load_main_configuration, + settings, +) from aleph.sdk.types import GenericMessage +from aleph.sdk.wallets.ledger import LedgerETHAccount +from aleph_message.models import Chain from aleph_message.models.base import MessageType from aleph_message.models.execution.base import Encoding +from ledgereth.exceptions import LedgerError logger = logging.getLogger(__name__) +LEDGER_VENDOR_ID = 0x2C97 try: import magic @@ -190,3 +202,217 @@ def cached_async_function(*args, **kwargs): return ensure_future(async_function(*args, **kwargs)) return cached_async_function + + +def load_account( + private_key_str: Optional[str], private_key_file: Optional[Path], chain: Optional[Chain] = None +) -> AccountTypes: + """ + Two Case Possible + - Account from private key + - Hardware account (ledger) + + We first try to load configurations, if no configurations we fallback to private_key_str / private_key_file. + """ + + # 1st Check for configurations + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + + # If no config we try to load private_key_str / private_key_file + if not config: + logger.warning("No config detected fallback to private key") + if private_key_str is not None: + private_key_file = None + + elif private_key_file and not private_key_file.exists(): + logger.error("No account could be retrieved please use `aleph account create` or `aleph account configure`") + raise typer.Exit(code=1) + + if not chain and config: + chain = config.chain + + if config and config.type and config.type == AccountType.HARDWARE: + try: + wait_for_ledger_connection() + return _load_account(None, None, chain=chain) + except LedgerError as err: + raise typer.Exit(code=1) from err + except OSError as err: + raise typer.Exit(code=1) from err + else: + return _load_account(private_key_str, private_key_file, chain=chain) + + +def list_ledger_dongles(unique_only: bool = True): + """ + Enumerate Ledger devices, optionally filtering duplicates (multi-interface entries). + Returns list of dicts with 'path' and 'product_string'. + """ + devices = [] + seen_serials = set() + + for dev in hid.enumerate(): + if dev.get("vendor_id") != LEDGER_VENDOR_ID: + continue + + product = dev.get("product_string") or "Ledger" + path = dev.get("path") + serial = dev.get("serial_number") or f"{dev.get('vendor_id')}:{dev.get('product_id')}" + + # Filter out duplicate interfaces + if unique_only and serial in seen_serials: + continue + + seen_serials.add(serial) + devices.append( + { + "path": path, + "product_string": product, + "vendor_id": dev.get("vendor_id"), + "product_id": dev.get("product_id"), + "serial_number": serial, + } + ) + + # Prefer :1.0 interface if multiple + devices = [d for d in devices if not str(d["path"]).endswith(":1.1")] + + return devices + + +def get_ledger_name(device_info: dict) -> str: + """ + Return a human-readable name for a Ledger dongle. + Example: "Ledger Nano X (0001:0023)" or "Ledger (unknown)". + """ + if not device_info: + return "Unknown Ledger" + + name = device_info.get("product_string") or "Ledger" + raw_path = device_info.get("path") + if isinstance(raw_path, bytes): + raw_path = raw_path.decode(errors="ignore") + + # derive a short, friendly ID + short_id = None + if raw_path: + short_id = raw_path.split("#")[-1][:8] if "#" in raw_path else raw_path[-8:] + return f"{name} ({short_id})" if short_id else name + + +def get_first_ledger_name() -> str: + """Return the name of the first connected Ledger, or 'No Ledger found'.""" + devices = list_ledger_dongles() + if not devices: + return "No Ledger found" + return get_ledger_name(devices[0]) + + +def get_account_and_address( + private_key: Optional[str], + private_key_file: Optional[Path], + address: Optional[str] = None, + chain: Optional[Chain] = None, +) -> tuple[Optional[AccountTypes], Optional[str]]: + """ + Gets the account and address based on configuration and provided parameters. + + This utility function handles the common pattern of loading an account and address + from either a configuration file or private key/file, avoiding ledger connections + when not needed. + + Args: + private_key: Optional private key string + private_key_file: Optional private key file path + address: Optional address (will be returned if provided) + chain: Optional chain for account loading + + Returns: + A tuple of (account, address) where either or both may be None + """ + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + account_type = config.type if config else None + + account = None + + # Avoid connecting to ledger + if not account_type or account_type == AccountType.IMPORTED: + account = load_account(private_key, private_key_file, chain=chain) + if account and not address: + address = account.get_address() + elif not address and config and config.address: + address = config.address + + return account, address + + +def wait_for_ledger_connection(poll_interval: float = 1.0) -> None: + """ + Wait until a Ledger device is connected and ready. + + Uses HID to detect physical connection, then confirms communication + by calling LedgerETHAccount.get_accounts(). Handles permission errors + gracefully and allows the user to cancel (Ctrl+C). + + Parameters + ---------- + poll_interval : float + Seconds between checks (default: 1). + """ + + vendor_id = 0x2C97 # Ledger vendor ID + + # Check if ledger is already connected and ready + try: + accounts = LedgerETHAccount.get_accounts() + if accounts: + typer.secho("Ledger connected and ready!", fg=typer.colors.GREEN) + return + except Exception as e: + # Continue with the normal flow if not ready + logger.debug(f"Ledger not ready: {e}") + + typer.secho("\nPlease connect your Ledger device and unlock it.", fg=typer.colors.CYAN) + typer.echo(" (Open the Ethereum app if required.)") + typer.echo(" Press Ctrl+C to cancel.\n") + + # No longer using this variable, removed + while True: + try: + # Detect via HID + devices = hid.enumerate(vendor_id, 0) + if not devices: + typer.echo("Waiting for Ledger device connection...", err=True) + time.sleep(poll_interval) + continue + + # Try to communicate (device connected but may be locked) + try: + accounts = LedgerETHAccount.get_accounts() + if accounts: + typer.secho("Ledger connected and ready!", fg=typer.colors.GREEN) + return + except LedgerError: + typer.echo("Ledger detected but locked or wrong app open.", err=True) + time.sleep(poll_interval) + continue + except BaseException as e: + typer.echo(f"Communication error with Ledger: {str(e)[:50]}... Retrying...", err=True) + time.sleep(poll_interval) + continue + + except OSError as err: + # Typically means missing permissions or udev rules + typer.secho( + f"OS error while accessing Ledger ({err}).\n" + "Please ensure you have proper USB permissions (udev rules).", + fg=typer.colors.RED, + ) + raise typer.Exit(1) from err + except KeyboardInterrupt as err: + typer.secho("\nCancelled by user.", fg=typer.colors.YELLOW) + raise typer.Exit(1) from err + + time.sleep(poll_interval) diff --git a/tests/unit/test_account_transact.py b/tests/unit/test_account_transact.py index 81a59b1b..3faba2da 100644 --- a/tests/unit/test_account_transact.py +++ b/tests/unit/test_account_transact.py @@ -26,7 +26,7 @@ def test_account_can_transact_success(mock_account): assert mock_account.can_transact() is True -@patch("aleph_client.commands.account._load_account") +@patch("aleph_client.commands.account.load_account") def test_account_can_transact_error_handling(mock_load_account): """Test that error is handled properly when account.can_transact() fails.""" # Setup mock account that will raise InsufficientFundsError diff --git a/tests/unit/test_aggregate.py b/tests/unit/test_aggregate.py index dc03988f..05bf2efb 100644 --- a/tests/unit/test_aggregate.py +++ b/tests/unit/test_aggregate.py @@ -52,6 +52,15 @@ def create_mock_auth_client(return_fetch=FAKE_AGGREGATE_DATA): return mock_auth_client_class, mock_auth_client +def create_mock_client(return_fetch=FAKE_AGGREGATE_DATA): + mock_auth_client = AsyncMock( + fetch_aggregate=AsyncMock(return_value=return_fetch), + ) + mock_auth_client_class = MagicMock() + mock_auth_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_auth_client) + return mock_auth_client_class, mock_auth_client + + @pytest.mark.parametrize( ids=["by_key_only", "by_key_and_subkey", "by_key_and_subkeys"], argnames="args", @@ -67,7 +76,7 @@ async def test_forget(capsys, args): mock_list_aggregates = AsyncMock(return_value=FAKE_AGGREGATE_DATA) mock_auth_client_class, mock_auth_client = create_mock_auth_client() - @patch("aleph_client.commands.aggregate._load_account", mock_load_account) + @patch("aleph_client.commands.aggregate.load_account", mock_load_account) @patch("aleph_client.commands.aggregate.list_aggregates", mock_list_aggregates) @patch("aleph_client.commands.aggregate.AuthenticatedAlephHttpClient", mock_auth_client_class) async def run_forget(aggr_spec): @@ -101,7 +110,7 @@ async def test_post(capsys, args): mock_load_account = create_mock_load_account() mock_auth_client_class, mock_auth_client = create_mock_auth_client() - @patch("aleph_client.commands.aggregate._load_account", mock_load_account) + @patch("aleph_client.commands.aggregate.load_account", mock_load_account) @patch("aleph_client.commands.aggregate.AuthenticatedAlephHttpClient", mock_auth_client_class) async def run_post(aggr_spec): print() # For better display when pytest -v -s @@ -133,33 +142,77 @@ async def run_post(aggr_spec): @pytest.mark.asyncio async def test_get(capsys, args, expected): mock_load_account = create_mock_load_account() - mock_auth_client_class, mock_auth_client = create_mock_auth_client(return_fetch=FAKE_AGGREGATE_DATA["AI"]) + mock_auth_class, mock__client = create_mock_auth_client(return_fetch=FAKE_AGGREGATE_DATA["AI"]) - @patch("aleph_client.commands.aggregate._load_account", mock_load_account) - @patch("aleph_client.commands.aggregate.AuthenticatedAlephHttpClient", mock_auth_client_class) - async def run_get(aggr_spec): + @patch( + "aleph_client.commands.aggregate.get_account_and_address", + return_value=(mock_load_account.return_value, "test_address"), + ) + @patch("aleph_client.commands.aggregate.AlephHttpClient", mock_auth_class) + async def run_get(aggr_spec, mock_get_account): print() # For better display when pytest -v -s return await get(**aggr_spec) aggregate = await run_get(args) - mock_load_account.assert_called_once() - mock_auth_client.fetch_aggregate.assert_called_once() + mock__client.fetch_aggregate.assert_called_once() captured = capsys.readouterr() assert aggregate == expected and expected == json.loads(captured.out) +@pytest.mark.asyncio +async def test_get_with_ledger(): + """Test get aggregate using a Ledger hardware wallet.""" + # Mock configuration for Ledger device + ledger_address = "0xdeadbeef1234567890123456789012345678beef" + + mock_client_class, mock_client = create_mock_client(return_fetch=FAKE_AGGREGATE_DATA["AI"]) + + async def run_get_with_ledger(): + with patch("aleph_client.commands.aggregate.get_account_and_address", return_value=(None, ledger_address)): + with patch("aleph_client.commands.aggregate.AlephHttpClient", mock_client_class): + return await get(key="AI") + + # Call the function + aggregate = await run_get_with_ledger() + + # Verify result + assert aggregate == FAKE_AGGREGATE_DATA["AI"] + # Verify that fetch_aggregate was called with the correct ledger address + mock_client.fetch_aggregate.assert_called_with(address=ledger_address, key="AI") + + @pytest.mark.asyncio async def test_list_aggregates(): mock_load_account = create_mock_load_account() - @patch("aleph_client.commands.aggregate._load_account", mock_load_account) + @patch( + "aleph_client.commands.aggregate.get_account_and_address", + return_value=(mock_load_account.return_value, FAKE_ADDRESS_EVM), + ) @patch.object(aiohttp.ClientSession, "get", mock_client_session_get) - async def run_list_aggregates(): + async def run_list_aggregates(mock_get_account): print() # For better display when pytest -v -s return await list_aggregates(address=FAKE_ADDRESS_EVM) aggregates = await run_list_aggregates() - mock_load_account.assert_called_once() + assert aggregates == FAKE_AGGREGATE_DATA + + +@pytest.mark.asyncio +async def test_list_aggregates_with_ledger(): + """Test listing aggregates using a Ledger hardware wallet.""" + # Mock configuration for Ledger device + ledger_address = "0xdeadbeef1234567890123456789012345678beef" + + async def run_list_aggregates_with_ledger(): + with patch("aleph_client.commands.aggregate.get_account_and_address", return_value=(None, ledger_address)): + with patch.object(aiohttp.ClientSession, "get", mock_client_session_get): + return await list_aggregates() + + # Call the function + aggregates = await run_list_aggregates_with_ledger() + + # Verify result assert aggregates == FAKE_AGGREGATE_DATA @@ -169,7 +222,7 @@ async def test_authorize(capsys): mock_get = AsyncMock(return_value=FAKE_AGGREGATE_DATA["security"]) mock_post = AsyncMock(return_value=True) - @patch("aleph_client.commands.aggregate._load_account", mock_load_account) + @patch("aleph_client.commands.aggregate.load_account", mock_load_account) @patch("aleph_client.commands.aggregate.get", mock_get) @patch("aleph_client.commands.aggregate.post", mock_post) async def run_authorize(): @@ -190,7 +243,7 @@ async def test_revoke(capsys): mock_get = AsyncMock(return_value=FAKE_AGGREGATE_DATA["security"]) mock_post = AsyncMock(return_value=True) - @patch("aleph_client.commands.aggregate._load_account", mock_load_account) + @patch("aleph_client.commands.aggregate.load_account", mock_load_account) @patch("aleph_client.commands.aggregate.get", mock_get) @patch("aleph_client.commands.aggregate.post", mock_post) async def run_revoke(): @@ -210,13 +263,15 @@ async def test_permissions(): mock_load_account = create_mock_load_account() mock_get = AsyncMock(return_value=FAKE_AGGREGATE_DATA["security"]) - @patch("aleph_client.commands.aggregate._load_account", mock_load_account) + @patch( + "aleph_client.commands.aggregate.get_account_and_address", + return_value=(mock_load_account.return_value, FAKE_ADDRESS_EVM), + ) @patch("aleph_client.commands.aggregate.get", mock_get) - async def run_permissions(): + async def run_permissions(mock_get_account): print() # For better display when pytest -v -s return await permissions(address=FAKE_ADDRESS_EVM, json=True) authorizations = await run_permissions() - mock_load_account.assert_called_once() mock_get.assert_called_once() assert authorizations == FAKE_AGGREGATE_DATA["security"]["authorizations"] # type: ignore diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index 6199d343..58fd560b 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -6,7 +6,7 @@ import pytest from aleph.sdk.chains.ethereum import ETHAccount -from aleph.sdk.conf import settings +from aleph.sdk.conf import AccountType, MainConfiguration, settings from aleph.sdk.exceptions import ( ForgottenMessageError, MessageNotFoundError, @@ -14,7 +14,7 @@ ) from aleph.sdk.query.responses import MessagesResponse from aleph.sdk.types import StorageEnum, StoredContent -from aleph_message.models import PostMessage, StoreMessage +from aleph_message.models import Chain, PostMessage, StoreMessage from typer.testing import CliRunner from aleph_client.__main__ import app @@ -202,12 +202,35 @@ def test_account_import_sol(env_files): assert new_key != old_key -def test_account_address(env_files): +@patch("aleph.sdk.wallets.ledger.ethereum.LedgerETHAccount.get_accounts") +def test_account_address(mock_get_accounts, 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("✉ Addresses for Active Account ✉\n\nEVM: 0x") + # Test with ledger device + mock_ledger_account = MagicMock() + mock_ledger_account.address = "0xdeadbeef1234567890123456789012345678beef" + mock_ledger_account.get_address.return_value = "0xdeadbeef1234567890123456789012345678beef" + mock_get_accounts.return_value = [mock_ledger_account] + + # Create a ledger config + ledger_config = MainConfiguration( + path=None, chain=Chain.ETH, type=AccountType.HARDWARE, address=mock_ledger_account.address + ) + + with patch("aleph_client.commands.account.load_main_configuration", return_value=ledger_config): + with patch( + "aleph_client.commands.account.load_account", + side_effect=lambda _, __, chain: ( + mock_ledger_account if chain == Chain.ETH else Exception("Ledger doesn't support SOL") + ), + ): + result = runner.invoke(app, ["account", "address"]) + assert result.exit_code == 0 + assert result.stdout.startswith("✉ Addresses for Active Account (Ledger) ✉\n\nEVM: 0x") + def test_account_chain(env_files): settings.CONFIG_FILE = env_files[1] @@ -236,6 +259,22 @@ def test_account_export_private_key(env_files): assert result.stdout.startswith("⚠️ Private Keys for Active Account ⚠️\n\nEVM: 0x") +def test_account_export_private_key_ledger(): + """Test that export-private-key fails for Ledger devices.""" + # Create a ledger config + ledger_config = MainConfiguration( + path=None, chain=Chain.ETH, type=AccountType.HARDWARE, address="0xdeadbeef1234567890123456789012345678beef" + ) + + with patch("aleph_client.commands.account.load_main_configuration", return_value=ledger_config): + result = runner.invoke(app, ["account", "export-private-key"]) + + # Command should fail with appropriate message + assert result.exit_code == 1 + assert "Cannot export private key from a Ledger hardware wallet" in result.stdout + assert "The private key remains securely stored on your Ledger device" in result.stdout + + def test_account_list(env_files): settings.CONFIG_FILE = env_files[1] result = runner.invoke(app, ["account", "list"]) @@ -243,6 +282,43 @@ def test_account_list(env_files): assert result.stdout.startswith("🌐 Chain Infos 🌐") +@patch("aleph.sdk.wallets.ledger.ethereum.LedgerETHAccount.get_accounts") +def test_account_list_with_ledger(mock_get_accounts): + """Test that account list shows Ledger devices when available.""" + # Create mock Ledger accounts + mock_account1 = MagicMock() + mock_account1.address = "0xdeadbeef1234567890123456789012345678beef" + mock_account2 = MagicMock() + mock_account2.address = "0xcafebabe5678901234567890123456789012cafe" + mock_get_accounts.return_value = [mock_account1, mock_account2] + + # Test with no configuration first + with patch("aleph_client.commands.account.load_main_configuration", return_value=None): + result = runner.invoke(app, ["account", "list"]) + assert result.exit_code == 0 + + # Check that the ledger accounts are listed + assert "Ledger #0" in result.stdout + assert "Ledger #1" in result.stdout + assert mock_account1.address in result.stdout + assert mock_account2.address in result.stdout + + # Test with a ledger account that's active in configuration + ledger_config = MainConfiguration( + path=None, chain=Chain.ETH, type=AccountType.HARDWARE, address=mock_account1.address + ) + + with patch("aleph_client.commands.account.load_main_configuration", return_value=ledger_config): + result = runner.invoke(app, ["account", "list"]) + assert result.exit_code == 0 + + # Check that the active ledger account is marked + assert "Ledger" in result.stdout + assert mock_account1.address in result.stdout + # Just check for asterisk since rich formatting tags may not be visible in test output + assert "*" in result.stdout + + def test_account_sign_bytes(env_files): settings.CONFIG_FILE = env_files[1] result = runner.invoke(app, ["account", "sign-bytes", "--message", "test", "--chain", "ETH"]) @@ -260,9 +336,7 @@ def test_account_balance(mocker, env_files, mock_voucher_service, mock_get_balan mock_client.voucher = mock_voucher_service - # Replace both client types with our mock implementation mocker.patch("aleph_client.commands.account.AlephHttpClient", mock_client_class) - mocker.patch("aleph_client.commands.account.AuthenticatedAlephHttpClient", mock_client_class) result = runner.invoke( app, ["account", "balance", "--address", "0xCAfEcAfeCAfECaFeCaFecaFecaFECafECafeCaFe", "--chain", "ETH"] @@ -275,24 +349,20 @@ def test_account_balance(mocker, env_files, mock_voucher_service, mock_get_balan assert "EVM Test Voucher" in result.stdout -def test_account_balance_error(mocker, env_files, mock_voucher_empty): +def test_account_balance_error(mocker, env_files, mock_voucher_empty, mock_get_balances): """Test error handling in the account balance command when API returns an error.""" settings.CONFIG_FILE = env_files[1] - mock_client_class = MagicMock() - mock_client = MagicMock() - mock_client.__aenter__.return_value = mock_client - mock_client.__aexit__.return_value = None + mock_client_class, mock_client = create_mock_client(None, None, mock_get_balances=mock_get_balances) + mock_client.get_balances = AsyncMock( side_effect=Exception( "Failed to retrieve balance for address 0xCAfEcAfeCAfECaFeCaFecaFecaFECafECafeCaFe. Status code: 404" ) ) mock_client.voucher = mock_voucher_empty - mock_client_class.return_value = mock_client mocker.patch("aleph_client.commands.account.AlephHttpClient", mock_client_class) - mocker.patch("aleph_client.commands.account.AuthenticatedAlephHttpClient", mock_client_class) # Test with an address directly result = runner.invoke( @@ -311,7 +381,7 @@ def test_account_vouchers_display(mocker, env_files, mock_voucher_service): # Mock the HTTP client mock_client = mocker.AsyncMock() mock_client.voucher = mock_voucher_service - mocker.patch("aleph_client.commands.account.AuthenticatedAlephHttpClient.__aenter__", return_value=mock_client) + mocker.patch("aleph_client.commands.account.AlephHttpClient.__aenter__", return_value=mock_client) # Create a test address test_address = "0xCAfEcAfeCAfECaFeCaFecaFecaFECafECafeCaFe" @@ -355,7 +425,7 @@ def test_account_vouchers_no_vouchers(mocker, env_files): # Mock the HTTP client mock_client = mocker.AsyncMock() mock_client.voucher = mock_voucher_service - mocker.patch("aleph_client.commands.account.AuthenticatedAlephHttpClient.__aenter__", return_value=mock_client) + mocker.patch("aleph_client.commands.account.AlephHttpClient.__aenter__", return_value=mock_client) # Create a test address test_address = "0xCAfEcAfeCAfECaFeCaFecaFecaFECafECafeCaFe" @@ -371,10 +441,58 @@ def test_account_vouchers_no_vouchers(mocker, env_files): 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: ") + with patch("aleph_client.commands.account.save_main_configuration") as mock_save_config: + # Make sure the config can be saved + mock_save_config.return_value = None + + settings.CONFIG_FILE = env_files[1] + result = runner.invoke( + app, ["account", "config", "--private-key-file", str(env_files[0]), "--chain", "ETH", "--non-it"] + ) + assert result.exit_code == 0 + + +@patch("aleph.sdk.wallets.ledger.ethereum.LedgerETHAccount.get_accounts") +def test_account_config_with_ledger(mock_get_accounts): + """Test configuring account with a Ledger device.""" + # Create mock Ledger accounts + mock_account1 = MagicMock() + mock_account1.address = "0xdeadbeef1234567890123456789012345678beef" + mock_account2 = MagicMock() + mock_account2.address = "0xcafebabe5678901234567890123456789012cafe" + mock_get_accounts.return_value = [mock_account1, mock_account2] + + # Create a temporary config file + with runner.isolated_filesystem(): + config_dir = Path("test_config") + config_dir.mkdir() + config_file = config_dir / "config.json" + + with ( + patch("aleph.sdk.conf.settings.CONFIG_FILE", config_file), + patch("aleph.sdk.conf.settings.CONFIG_HOME", str(config_dir)), + patch("aleph_client.commands.account.Prompt.ask", return_value="1"), + patch("aleph_client.commands.account.yes_no_input", return_value=True), + patch("aleph_client.commands.account.save_main_configuration"), + patch("aleph_client.utils.list_unlinked_keys", return_value=([], None)), + ): + # Use --no to skip interactive mode + result = runner.invoke( + app, + [ + "account", + "config", + "--account-type", + "hardware", + "--chain", + "ETH", + "--address", + "0xdeadbeef1234567890123456789012345678beef", + "--non-it", + ], + ) + + assert result.exit_code == 0 def test_message_get(mocker, store_message_fixture): @@ -440,7 +558,7 @@ def test_message_get_with_forgotten(mocker): # Verify output matches expected response for forgotten messages assert result.exit_code == 0 - assert "Message has been forgotten on aleph.im" in result.stdout + assert "Message has been forgotten on aleph.cloud" in result.stdout def test_message_get_not_found(mocker): @@ -461,7 +579,7 @@ def test_message_get_not_found(mocker): # Verify output matches expected response for not found messages assert result.exit_code == 0 - assert "Message does not exist on aleph.im" in result.stdout + assert "Message does not exist on aleph.cloud" in result.stdout def test_message_get_removed(mocker): @@ -483,7 +601,7 @@ def test_message_get_removed(mocker): # Verify output matches expected response for removed messages assert result.exit_code == 0 - assert "Message has been removed on aleph.im" in result.stdout + assert "Message has been removed on aleph.cloud" in result.stdout def test_message_find(mocker, store_message_fixture): diff --git a/tests/unit/test_credits.py b/tests/unit/test_credits.py index e1bfe87e..9228a642 100644 --- a/tests/unit/test_credits.py +++ b/tests/unit/test_credits.py @@ -133,11 +133,11 @@ async def run(mock_get): @pytest.mark.asyncio -async def test_show_with_account(mock_credit_balance_response): +async def test_show_with_account(mock_credit_balance_response, capsys): """Test the show command using account-derived address.""" @patch("aiohttp.ClientSession.get") - @patch("aleph_client.commands.credit._load_account") + @patch("aleph_client.commands.credit.load_account") async def run(mock_load_account, mock_get): mock_get.return_value = mock_credit_balance_response @@ -154,35 +154,8 @@ async def run(mock_load_account, mock_get): json=False, debug=False, ) - - # Verify the account was loaded and its address used - mock_load_account.assert_called_once() - mock_account.get_address.assert_called_once() - - await run() - - -@pytest.mark.asyncio -async def test_show_no_address_no_account(capsys): - """Test the show command with no address and no account.""" - - @patch("aleph_client.commands.credit._load_account") - async def run(mock_load_account): - # Setup the mock account to return None (no account found) - mock_load_account.return_value = None - - # Run the show command without address and without account - await show( - address="", - private_key=None, - private_key_file=None, - json=False, - debug=False, - ) - - await run() - captured = capsys.readouterr() - assert "Error: Please provide either a private key, private key file, or an address." in captured.out + captured = capsys.readouterr() + assert "0x1234567890123456789012345678901234567890" in captured.out @pytest.mark.asyncio diff --git a/tests/unit/test_instance.py b/tests/unit/test_instance.py index a050c1fd..3f5fbb5e 100644 --- a/tests/unit/test_instance.py +++ b/tests/unit/test_instance.py @@ -531,7 +531,7 @@ async def test_create_instance(args, expected, mock_crn_list_obj, mock_pricing_i # Setup all required patches with ( patch("aleph_client.commands.instance.validate_ssh_pubkey_file", mock_validate_ssh_pubkey_file), - patch("aleph_client.commands.instance._load_account", mock_load_account), + patch("aleph_client.commands.instance.load_account", mock_load_account), patch("aleph_client.commands.instance.AlephHttpClient", mock_client_class), patch("aleph_client.commands.pricing.AlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.AuthenticatedAlephHttpClient", mock_auth_client_class), @@ -620,7 +620,7 @@ async def test_list_instances(mock_crn_list_obj, mock_pricing_info_response, moc ) # Setup all patches - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.network.fetch_latest_crn_version", mock_fetch_latest_crn_version) @patch("aleph_client.commands.files.AlephHttpClient", mock_client_class) @patch("aleph_client.commands.instance.AlephHttpClient", mock_auth_client_class) @@ -657,7 +657,7 @@ async def test_delete_instance(mock_api_response): # We need to mock that there is no CRN information to skip VM erasure mock_auth_client.instance = MagicMock(get_instances_allocations=AsyncMock(return_value=MagicMock(root={}))) - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.AuthenticatedAlephHttpClient", mock_auth_client_class) @patch("aleph_client.commands.instance.VmClient", mock_vm_client_class) @patch("aleph_client.commands.instance.fetch_settings", mock_fetch_settings) @@ -709,7 +709,7 @@ async def test_delete_instance_with_insufficient_funds(): } ) - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.AuthenticatedAlephHttpClient", mock_auth_client_class) @patch("aleph_client.commands.instance.VmClient", mock_vm_client_class) @patch("aleph_client.commands.instance.fetch_settings", mock_fetch_settings) @@ -753,7 +753,7 @@ async def test_delete_instance_with_detailed_insufficient_funds_error(capsys, mo } ) - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.AuthenticatedAlephHttpClient", mock_auth_client_class) @patch("aleph_client.commands.instance.VmClient", mock_vm_client_class) @patch("aleph_client.commands.instance.fetch_settings", mock_fetch_settings) @@ -794,7 +794,7 @@ async def test_reboot_instance(): # Add the mock to the auth client mock_auth_client.instance = MagicMock(get_instances_allocations=AsyncMock(return_value=mock_allocation)) - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.network.AlephHttpClient", mock_auth_client_class) @patch("aleph_client.commands.instance.VmClient", mock_vm_client_class) async def reboot_instance(): @@ -826,7 +826,7 @@ async def test_allocate_instance(): # Add the mock to the auth client mock_auth_client.instance = MagicMock(get_instances_allocations=AsyncMock(return_value=mock_allocation)) - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.network.AlephHttpClient", mock_auth_client_class) @patch("aleph_client.commands.instance.VmClient", mock_vm_client_class) async def allocate_instance(): @@ -858,7 +858,7 @@ async def test_logs_instance(capsys): # Add the mock to the auth client mock_auth_client.instance = MagicMock(get_instances_allocations=AsyncMock(return_value=mock_allocation)) - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.network.AlephHttpClient", mock_auth_client_class) @patch("aleph_client.commands.instance.VmClient", mock_vm_client_class) async def logs_instance(): @@ -892,7 +892,7 @@ async def test_stop_instance(): # Add the mock to the auth client mock_auth_client.instance = MagicMock(get_instances_allocations=AsyncMock(return_value=mock_allocation)) - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.network.AlephHttpClient", mock_auth_client_class) @patch("aleph_client.commands.instance.VmClient", mock_vm_client_class) async def stop_instance(): @@ -925,7 +925,7 @@ async def test_confidential_init_session(): # Add the mock to the auth client mock_auth_client.instance = MagicMock(get_instances_allocations=AsyncMock(return_value=mock_allocation)) - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.network.AlephHttpClient", mock_auth_client_class) @patch("aleph_client.commands.utils.shutil", mock_shutil) @patch("aleph_client.commands.instance.shutil", mock_shutil) @@ -967,7 +967,7 @@ async def test_confidential_start(): # Add the mock to the auth client mock_auth_client.instance = MagicMock(get_instances_allocations=AsyncMock(return_value=mock_allocation)) - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.utils.shutil", mock_shutil) @patch("aleph_client.commands.instance.network.AlephHttpClient", mock_auth_client_class) @patch.object(Path, "exists", MagicMock(return_value=True)) @@ -1076,7 +1076,9 @@ async def gpu_instance(): @pytest.mark.asyncio -async def test_gpu_create_no_gpus_available(mock_crn_list_obj, mock_pricing_info_response, mock_settings_info): +async def test_gpu_create_no_gpus_available( + mock_crn_list_obj, mock_pricing_info_response, mock_settings_info, mock_get_balances +): """Test creating a GPU instance when no GPUs are available on the network. This test verifies that typer.Exit is raised when no GPUs are available, @@ -1085,12 +1087,12 @@ async def test_gpu_create_no_gpus_available(mock_crn_list_obj, mock_pricing_info mock_load_account = create_mock_load_account() mock_validate_ssh_pubkey_file = create_mock_validate_ssh_pubkey_file() mock_client_class, mock_client = create_mock_client( - mock_crn_list_obj, mock_pricing_info_response, mock_settings_info, payment_type="superfluid" + mock_crn_list_obj, mock_pricing_info_response, mock_get_balances, payment_type="superfluid" ) mock_fetch_latest_crn_version = create_mock_fetch_latest_crn_version() mock_validated_prompt = MagicMock(return_value="1") - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.validate_ssh_pubkey_file", mock_validate_ssh_pubkey_file) @patch("aleph_client.commands.instance.AlephHttpClient", mock_client_class) @patch("aleph_client.commands.pricing.AlephHttpClient", mock_client_class) diff --git a/tests/unit/test_ledger_utils.py b/tests/unit/test_ledger_utils.py new file mode 100644 index 00000000..035233f5 --- /dev/null +++ b/tests/unit/test_ledger_utils.py @@ -0,0 +1,316 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import typer +from aleph.sdk.conf import AccountType, MainConfiguration +from aleph_message.models import Chain +from ledgereth.exceptions import LedgerError + +from aleph_client.utils import ( + get_first_ledger_name, + list_ledger_dongles, + load_account, + wait_for_ledger_connection, +) + +PATCH_LEDGER_ACCOUNTS = "aleph.sdk.wallets.ledger.ethereum.LedgerETHAccount.get_accounts" +PATCH_HID_ENUM = "hid.enumerate" +PATCH_SLEEP = "time.sleep" +PATCH_WAIT_LEDGER = "aleph_client.utils.wait_for_ledger_connection" +PATCH_LOAD_CONFIG = "aleph_client.utils.load_main_configuration" +PATCH_LOAD_ACCOUNT_INTERNAL = "aleph_client.utils._load_account" + + +@pytest.fixture +def mock_get_accounts(): + with patch(PATCH_LEDGER_ACCOUNTS) as p: + yield p + + +@pytest.fixture +def mock_hid_enum(): + with patch(PATCH_HID_ENUM) as p: + yield p + + +@pytest.fixture +def no_sleep(): + with patch(PATCH_SLEEP): + yield + + +@pytest.fixture +def mock_ledger_config(): + """Create a mock ledger hardware wallet configuration.""" + return MainConfiguration( + path=None, + chain=Chain.ETH, + address="0xdeadbeef1234567890123456789012345678beef", + type=AccountType.HARDWARE, + ) + + +@pytest.fixture +def mock_ledger_config_with_path(): + """Create a mock ledger hardware wallet configuration with derivation path.""" + return MainConfiguration( + path=None, + chain=Chain.ETH, + address="0xdeadbeef1234567890123456789012345678beef", + type=AccountType.HARDWARE, + derivation_path="44'/60'/0'/0/0", + ) + + +@pytest.fixture +def mock_imported_config(): + """Create a mock imported wallet configuration.""" + return MainConfiguration( + path=Path("/home/user/.aleph/private-keys/test.key"), + chain=Chain.ETH, + address=None, + type=AccountType.IMPORTED, + ) + + +@pytest.fixture +def mock_ledger_accounts(): + """Create mock ledger accounts.""" + mock_account1 = MagicMock() + mock_account1.address = "0xdeadbeef1234567890123456789012345678beef" + mock_account1.get_address = MagicMock(return_value=mock_account1.address) + mock_account2 = MagicMock() + mock_account2.address = "0xcafebabe5678901234567890123456789012cafe" + mock_account2.get_address = MagicMock(return_value=mock_account2.address) + return [mock_account1, mock_account2] + + +def test_load_account_with_ledger(mock_get_accounts, mock_ledger_config, mock_ledger_accounts): + mock_get_accounts.return_value = mock_ledger_accounts + + with ( + patch(PATCH_LOAD_CONFIG, return_value=mock_ledger_config), + patch(PATCH_LOAD_ACCOUNT_INTERNAL, return_value=mock_ledger_accounts[0]), + patch(PATCH_WAIT_LEDGER), + ): + account = load_account(None, None) + + assert account.get_address() == mock_ledger_accounts[0].address + + +@patch("aleph.sdk.wallets.ledger.ethereum.LedgerETHAccount.get_accounts") +def test_list_ledger_dongles(mock_get_accounts, mock_ledger_accounts): + """Test listing Ledger devices.""" + mock_get_accounts.return_value = mock_ledger_accounts + + with patch("hid.enumerate") as mock_enumerate: + # Set up mock HID devices + mock_enumerate.return_value = [ + { + "vendor_id": 0x2C97, + "product_id": 0x0001, + "path": b"usb-123", + "product_string": "Ledger Nano X", + "serial_number": "1234567890", + }, + { + "vendor_id": 0x2C97, + "product_id": 0x0001, + "path": b"usb-123:1.0", + "product_string": "Ledger Nano X", + "serial_number": "1234567890", + }, + { + "vendor_id": 0x2C97, + "product_id": 0x0002, + "path": b"usb-456", + "product_string": "Ledger Nano S", + "serial_number": "0987654321", + }, + { + "vendor_id": 0x1234, # Non-Ledger device + "product_id": 0x5678, + "path": b"usb-789", + "product_string": "Not a Ledger", + "serial_number": "11223344", + }, + ] + + # Test with unique_only=True (default) + dongles = list_ledger_dongles() + assert len(dongles) == 2 # Should filter out duplicates and non-Ledger devices + assert dongles[0]["product_string"] == "Ledger Nano X" + assert dongles[1]["product_string"] == "Ledger Nano S" + + # Test with unique_only=False + dongles = list_ledger_dongles(unique_only=False) + assert len(dongles) == 3 # Should include duplicates but not non-Ledger devices + + +@patch("aleph.sdk.wallets.ledger.ethereum.LedgerETHAccount.get_accounts") +def test_get_first_ledger_name(mock_get_accounts, mock_ledger_accounts): + """Test getting the name of the first connected Ledger device.""" + mock_get_accounts.return_value = mock_ledger_accounts + + with patch("aleph_client.utils.list_ledger_dongles") as mock_list_dongles: + # Test with a connected device + mock_list_dongles.return_value = [ + { + "path": b"usb-123", + "product_string": "Ledger Nano X", + } + ] + name = get_first_ledger_name() + assert name == "Ledger Nano X (usb-123)" + + # Test with no connected devices + mock_list_dongles.return_value = [] + name = get_first_ledger_name() + assert name == "No Ledger found" + + +def test_wait_for_ledger_already_connected(mock_get_accounts, mock_hid_enum, no_sleep): + """ + Ledger already connected & have eth app open + + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.return_value = ["0xabc"] + + wait_for_ledger_connection() + + mock_get_accounts.assert_called_once() + mock_hid_enum.assert_not_called() + + +def test_wait_for_ledger_device_appears(mock_get_accounts, mock_hid_enum, no_sleep): + """ + No device detected -> continue loop -> device appears -> success + + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.side_effect = [ + Exception("not ready"), # top-level + Exception("still no"), # first loop + ["0xabc"], # second loop -> success + ] + + mock_hid_enum.side_effect = [ + [], # first iteration -> no device + [{}], # second iteration -> device present + [{}], # third iteration (just in case) + ] + + wait_for_ledger_connection() + + assert mock_get_accounts.call_count == 3 + + +def test_wait_for_ledger_locked_then_ready(mock_get_accounts, mock_hid_enum, no_sleep): + """ + Ledger locked -> LedgerError -> retry -> success + + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.side_effect = [ + Exception("not ready"), # top-level + LedgerError("locked"), # first loop + ["0xabc"], # next loop -> success + ] + + mock_hid_enum.return_value = [{"id": 1}] # device always present + + wait_for_ledger_connection() + + assert mock_get_accounts.call_count == 3 + + +def test_wait_for_ledger_comm_error_then_ready(mock_get_accounts, mock_hid_enum, no_sleep): + """ + Generic communication error -> retry -> success + + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.side_effect = [ + Exception("top-level fail"), + Exception("comm error"), + ["0xabc"], + ] + + mock_hid_enum.return_value = [{"id": 1}] + + wait_for_ledger_connection() + + assert mock_get_accounts.call_count == 3 + + +def test_wait_for_ledger_oserror(mock_get_accounts, mock_hid_enum, no_sleep): + """ + OS error from hid.enumerate -> should exit via typer.Exit(1) + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.side_effect = Exception("not ready") + mock_hid_enum.side_effect = OSError("permission denied") + + with pytest.raises(typer.Exit) as exc: + wait_for_ledger_connection() + + assert exc.value.exit_code == 1 + + +def test_wait_for_ledger_keyboard_interrupt(mock_get_accounts, mock_hid_enum, no_sleep): + """ + KeyboardInterrupt raised inside loop -> should exit via typer.Exit(1) + + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.side_effect = Exception("not ready") + mock_hid_enum.side_effect = KeyboardInterrupt + + with pytest.raises(typer.Exit) as exc: + wait_for_ledger_connection() + + assert exc.value.exit_code == 1 + + +def test_wait_for_ledger_locked_once_then_ready(mock_get_accounts, mock_hid_enum, no_sleep): + """ + Device present immediately, but first get_accounts raises LedgerError + (wrong app), then success next iteration + + + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.side_effect = [ + Exception("not ready"), # top-level + LedgerError("locked"), # loop 1 + ["0xabc"], # loop 2 -> success + ] + + mock_hid_enum.return_value = [{"id": 1}] + + wait_for_ledger_connection() + + assert mock_get_accounts.call_count == 3 diff --git a/tests/unit/test_load_account.py b/tests/unit/test_load_account.py new file mode 100644 index 00000000..4d0ab627 --- /dev/null +++ b/tests/unit/test_load_account.py @@ -0,0 +1,171 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import typer +from aleph.sdk.conf import AccountType, MainConfiguration +from aleph_message.models import Chain +from ledgereth.exceptions import LedgerError + +from aleph_client.utils import load_account + + +@pytest.fixture +def mock_config_internal(): + """Create a mock internal configuration.""" + return MainConfiguration(path=Path("/fake/path.key"), chain=Chain.ETH) + + +@pytest.fixture +def mock_config_external(): + """Create a mock external (ledger) configuration.""" + return MainConfiguration(path=None, chain=Chain.ETH, address="0xdeadbeef1234567890123456789012345678beef") + + +@pytest.fixture +def mock_config_hardware(): + """Create a mock hardware (ledger) configuration.""" + return MainConfiguration( + path=None, + chain=Chain.ETH, + address="0xdeadbeef1234567890123456789012345678beef", + type=AccountType.HARDWARE, + ) + + +@patch("aleph_client.utils.load_main_configuration") +@patch("aleph_client.utils._load_account") +def test_load_account_with_internal_config(mock_load_account, mock_load_config, mock_config_internal): + """Test load_account with an internal configuration.""" + mock_load_config.return_value = mock_config_internal + + load_account(None, None) + + # Verify _load_account was called with the correct parameters for internal account + mock_load_account.assert_called_with(None, None, chain=Chain.ETH) + + +@patch("aleph_client.utils.load_main_configuration") +@patch("aleph_client.utils.wait_for_ledger_connection") +@patch("aleph_client.utils._load_account") +def test_load_account_with_external_config(mock_load_account, mock_load_config, mock_config_external): + """Test load_account with an external (ledger) configuration.""" + mock_load_config.return_value = mock_config_external + + load_account(None, None) + + # Verify _load_account was called with some chain parameter + assert mock_load_account.call_args is not None + + # For this test, we don't need to validate the exact mock object identity + # Just make sure the method was called with the proper args + mock_load_account.assert_called_once() + + +@patch("aleph_client.utils.load_main_configuration") +@patch("aleph_client.utils._load_account") +def test_load_account_with_override_chain(mock_load_account, mock_load_config, mock_config_internal): + """Test load_account with an explicit chain parameter that overrides the config.""" + mock_load_config.return_value = mock_config_internal + + load_account(None, None, chain=Chain.SOL) + + # Verify explicit chain was used instead of config chain + mock_load_account.assert_called_with(None, None, chain=Chain.SOL) + + +@patch("aleph_client.utils.load_main_configuration") +@patch("aleph_client.utils._load_account") +def test_load_account_fallback_to_private_key(mock_load_account, mock_load_config): + """Test load_account falling back to private key when no config exists.""" + mock_load_config.return_value = None + + load_account("0xdeadbeef", None) + + # Verify private key string was used + mock_load_account.assert_called_with("0xdeadbeef", None, chain=None) + + +@patch("aleph_client.utils.load_main_configuration") +@patch("aleph_client.utils._load_account") +def test_load_account_fallback_to_private_key_file(mock_load_account, mock_load_config): + """Test load_account falling back to private key file when no config exists.""" + mock_load_config.return_value = None + + private_key_file = MagicMock() + private_key_file.exists.return_value = True + + load_account(None, private_key_file) + + # Verify private key file was used + mock_load_account.assert_called_with(None, private_key_file, chain=None) + + +@patch("aleph_client.utils.load_main_configuration") +@patch("aleph_client.utils._load_account") +def test_load_account_nonexistent_file_raises_error(mock_load_account, mock_load_config): + """Test that load_account raises an error when file doesn't exist and no config exists.""" + mock_load_config.return_value = None + + private_key_file = MagicMock() + private_key_file.exists.return_value = False + + with pytest.raises(typer.Exit): + load_account(None, private_key_file) + + +@patch("aleph_client.utils.load_main_configuration") +@patch("aleph_client.utils.wait_for_ledger_connection") +@patch("aleph_client.utils._load_account") +def test_ledger_config(mock_load_account, mock_wait_for_ledger, mock_load_config, mock_config_hardware): + """Test load_account with a hardware ledger configuration.""" + mock_load_config.return_value = mock_config_hardware + mock_wait_for_ledger.return_value = None + + load_account(None, None) + + # Verify wait_for_ledger_connection was called + mock_wait_for_ledger.assert_called_once() + # Verify _load_account was called with the correct parameters for hardware account + mock_load_account.assert_called_with(None, None, chain=Chain.ETH) + + +@patch("aleph_client.utils.load_main_configuration") +@patch("aleph_client.utils.wait_for_ledger_connection") +@patch("aleph_client.utils._load_account") +def test_ledger_failure(mock_load_account, mock_wait_for_ledger, mock_load_config, mock_config_hardware): + """Test load_account with a hardware ledger configuration when connection fails.""" + + mock_load_config.return_value = mock_config_hardware + + mock_wait_for_ledger.side_effect = LedgerError("Cannot connect to ledger") + + # Check that typer.Exit is raised + with pytest.raises(typer.Exit): + load_account(None, None) + + # Verify wait_for_ledger_connection was called + mock_wait_for_ledger.assert_called_once() + + # Verify _load_account was not called + mock_load_account.assert_not_called() + + +@patch("aleph_client.utils.load_main_configuration") +@patch("aleph_client.utils.wait_for_ledger_connection") +@patch("aleph_client.utils._load_account") +def test_ledger_os_error(mock_load_account, mock_wait_for_ledger, mock_load_config, mock_config_hardware): + """Test load_account with a hardware ledger configuration when an OS error occurs.""" + mock_load_config.return_value = mock_config_hardware + + # Simulate an OS error (permission issues, etc) + mock_wait_for_ledger.side_effect = OSError("Permission denied") + + # Check that typer.Exit is raised + with pytest.raises(typer.Exit): + load_account(None, None) + + # Verify wait_for_ledger_connection was called + mock_wait_for_ledger.assert_called_once() + # Verify _load_account was not called + mock_load_account.assert_not_called() diff --git a/tests/unit/test_port_forwarder.py b/tests/unit/test_port_forwarder.py index a7387e64..5c782313 100644 --- a/tests/unit/test_port_forwarder.py +++ b/tests/unit/test_port_forwarder.py @@ -98,7 +98,7 @@ async def test_list_ports(mock_auth_setup): mock_console = MagicMock() with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.Console", return_value=mock_console), ): @@ -118,7 +118,7 @@ async def test_list_ports(mock_auth_setup): ) with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, patch("aleph_client.commands.instance.port_forwarder.typer.Exit", side_effect=SystemExit), @@ -142,7 +142,7 @@ async def test_create_port(mock_auth_setup): mock_client_class = mock_auth_setup["mock_client_class"] with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, ): @@ -177,7 +177,7 @@ async def test_update_port(mock_auth_setup): mock_client.port_forwarder.get_ports.return_value = mock_existing_ports with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, @@ -211,7 +211,7 @@ async def test_delete_port(mock_auth_setup): mock_client.port_forwarder.get_ports.return_value = mock_existing_ports with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, @@ -236,7 +236,7 @@ async def test_delete_port(mock_auth_setup): mock_client.port_forwarder.delete_ports.reset_mock() with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, @@ -268,7 +268,7 @@ async def test_delete_port_last_port(mock_auth_setup): mock_client.port_forwarder.update_ports = None with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, @@ -310,7 +310,7 @@ async def test_refresh_port(mock_auth_setup): mock_client.instance.get_instance_allocation_info.return_value = (None, mock_allocation) with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, ): @@ -340,7 +340,7 @@ async def test_refresh_port_no_allocation(mock_auth_setup): mock_client.instance.get_instance_allocation_info.return_value = (None, None) with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, patch("aleph_client.commands.instance.port_forwarder.typer.Exit", side_effect=SystemExit), @@ -376,7 +376,7 @@ async def test_refresh_port_scheduler_allocation(mock_auth_setup): mock_client.instance.get_instance_allocation_info.return_value = (None, mock_allocation) with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, ): @@ -415,7 +415,7 @@ async def test_non_processed_message_statuses(): mock_http_client.port_forwarder.get_ports = AsyncMock(return_value=mock_existing_ports) with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AlephHttpClient", mock_http_client_class), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_auth_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, @@ -432,7 +432,7 @@ async def test_non_processed_message_statuses(): mock_echo.reset_mock() with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AlephHttpClient", mock_http_client_class), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_auth_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, @@ -450,7 +450,7 @@ async def test_non_processed_message_statuses(): mock_echo.reset_mock() with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AlephHttpClient", mock_http_client_class), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_auth_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 996a390d..f599f45e 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,5 +1,11 @@ +from pathlib import Path + +import pytest +import typer +from aleph.sdk.conf import AccountType, MainConfiguration from aleph_message.models import ( AggregateMessage, + Chain, ForgetMessage, PostMessage, ProgramMessage, @@ -7,6 +13,7 @@ ) from aleph_message.models.base import MessageType +from aleph_client.commands.utils import validate_non_interactive_args_config from aleph_client.utils import get_message_type_value @@ -16,3 +23,173 @@ def test_get_message_type_value(): assert get_message_type_value(StoreMessage) == MessageType.store assert get_message_type_value(ProgramMessage) == MessageType.program assert get_message_type_value(ForgetMessage) == MessageType.forget + + +@pytest.fixture +def hardware_config(): + return MainConfiguration( + path=None, + chain=Chain.ETH, + address="0xHARDWARE", + type=AccountType.HARDWARE, + ) + + +@pytest.fixture +def imported_config(): + return MainConfiguration( + path=Path("/tmp/existing.key"), # noqa: S108 + chain=Chain.ETH, + address=None, + type=AccountType.IMPORTED, + ) + + +@pytest.mark.parametrize( + "kwargs,exit_code", + [ + # RULE 1: hardware requires address or derivation path + ( + { + "config": None, + "account_type": AccountType.HARDWARE, + "private_key_file": None, + "address": None, + "chain": None, + "derivation_path": None, + }, + 1, + ), + # RULE 2: imported requires private key + ( + { + "config": None, + "account_type": AccountType.IMPORTED, + "private_key_file": None, + "address": None, + "chain": None, + "derivation_path": None, + }, + 1, + ), + # RULE 3: cannot specify address + private key + ( + { + "config": None, + "account_type": None, + "private_key_file": Path("fake.key"), + "address": "0x123", + "chain": None, + "derivation_path": None, + }, + 1, + ), + # RULE 8: no args - exit(0) + ( + { + "config": None, + "account_type": None, + "private_key_file": None, + "address": None, + "chain": None, + "derivation_path": None, + }, + 0, + ), + ], +) +def test_validate_non_interactive_negative_cases(kwargs, exit_code): + with pytest.raises(typer.Exit) as exc: + validate_non_interactive_args_config(**kwargs) + assert exc.value.exit_code == exit_code + + +@pytest.mark.parametrize( + "override_kwargs,exit_code", + [ + # RULE 4: private key invalid for hardware (existing HW config) + ({"private_key_file": Path("k.key")}, 1), + # RULE 5: address invalid for imported config + ({"address": "0x123"}, 1), + # RULE 6: derivation path invalid for imported config + ({"derivation_path": "44'/60'/0'/0/0"}, 1), + ], +) +def test_validate_non_interactive_invalid_with_existing_config( + override_kwargs, exit_code, hardware_config, imported_config +): + """ + This test runs twice: + - once with hardware_config + - once with imported_config + + And applies the override on top. + """ + + # HW-config cases: only RULE 4 applies + if override_kwargs.get("private_key_file"): + config = hardware_config + else: + config = imported_config + + base_kwargs = { + "config": config, + "account_type": None, + "private_key_file": None, + "address": None, + "chain": None, + "derivation_path": None, + } + + kwargs = {**base_kwargs, **override_kwargs} + + with pytest.raises(typer.Exit) as exc: + validate_non_interactive_args_config(**kwargs) + + assert exc.value.exit_code == exit_code + + +@pytest.mark.parametrize( + "kwargs", + [ + # Hardware OK with address + { + "config": None, + "account_type": AccountType.HARDWARE, + "private_key_file": None, + "address": "0x123", + "chain": None, + "derivation_path": None, + }, + # Hardware OK with derivation path + { + "config": None, + "account_type": AccountType.HARDWARE, + "private_key_file": None, + "address": None, + "chain": None, + "derivation_path": "44'/60'/0'/0/0", + }, + # Imported OK with private key + { + "config": None, + "account_type": AccountType.IMPORTED, + "private_key_file": Path("/tmp/key.key"), # noqa: S108 + "address": None, + "chain": None, + "derivation_path": None, + }, + # Chain updates always allowed + { + "config": None, + "account_type": None, + "private_key_file": None, + "address": None, + "chain": Chain.ETH, + "derivation_path": None, + }, + ], +) +def test_validate_non_interactive_valid_cases(kwargs): + """These should not raise.""" + validate_non_interactive_args_config(**kwargs)