Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: account managment and sol #281

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ dependencies = [
"rich==13.7.1",
"aiodns==3.2.0",
"textual==0.73.0",
"base58==2.1.1", # Needed now as default with _load_account changement
"pynacl==1.5.0" # Needed now as default with _load_account changement
]
[project.optional-dependencies]
nuls2 = ["nuls2-sdk==0.1.0"]
Expand Down
195 changes: 185 additions & 10 deletions src/aleph_client/commands/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,54 +2,100 @@

import asyncio
import base64
import json
import logging
import sys
from pathlib import Path
from typing import Optional

import aiohttp
import typer
from aleph.sdk.account import _load_account
from aleph.sdk.chains.common import generate_key
from aleph.sdk.account import (
CHAIN_TO_ACCOUNT_MAP,
_load_account,
detect_chain_from_private_key,
)
from aleph.sdk.chains.common import generate_key, generate_key_solana
from aleph.sdk.chains.ethereum import ETHAccount
from aleph.sdk.chains.solana import parse_solana_private_key
from aleph.sdk.conf import settings
from aleph.sdk.types import AccountFromPrivateKey
from typer.colors import RED
from aleph.sdk.types import AccountFromPrivateKey, ChainAccount
from aleph.sdk.utils import load_account_key_context, upsert_chain_account
from aleph_message.models import Chain
from rich.console import Console
from rich.prompt import Prompt
from rich.table import Table
from typer.colors import GREEN, RED

from aleph_client.commands import help_strings
from aleph_client.commands.utils import setup_logging
from aleph_client.utils import AsyncTyper
from aleph_client.utils import AsyncTyper, list_unlinked_keys

logger = logging.getLogger(__name__)
app = AsyncTyper(no_args_is_help=True)
console = Console()


@app.command()
def create(
async def create(
private_key: Optional[str] = typer.Option(None, help=help_strings.PRIVATE_KEY),
private_key_file: Optional[Path] = typer.Option(None, help=help_strings.PRIVATE_KEY_FILE),
chain_type: Optional[Chain] = typer.Option(default=None, help=help_strings.ACCOUNT_CHAIN),
replace: bool = False,
debug: bool = False,
):
"""Create or import a private key."""

setup_logging(debug)

try:
if settings.CONFIG_FILE.exists() and settings.CONFIG_FILE.stat().st_size > 0:
with open(settings.CONFIG_FILE, "r") as f:
chain_accounts = json.load(f)
else:
chain_accounts = []
except (FileNotFoundError, json.JSONDecodeError) as e:
typer.secho(f"Error loading config file: {e}", fg=RED)
raise typer.Exit(1)

if private_key_file is None:
private_key_file = Path(typer.prompt("Enter file in which to save the key", settings.PRIVATE_KEY_FILE))

if private_key_file.exists() and not replace:
typer.secho(f"Error: key already exists: '{private_key_file}'", fg=RED)
raise typer.Exit(1)

existing_account = next((acc for acc in chain_accounts if acc["path"] == str(private_key_file)), None)
if existing_account and not replace:
philogicae marked this conversation as resolved.
Show resolved Hide resolved
typer.secho(f"Error: key already exists: '{private_key_file}'", fg=RED)
raise typer.Exit(1)

private_key_bytes: bytes
if private_key is not None:
# Validate the private key bytes by instantiating an account.
_load_account(private_key_str=private_key, account_type=ETHAccount)
private_key_bytes = bytes.fromhex(private_key)

private_key_type: Chain = detect_chain_from_private_key(private_key)
account_class = CHAIN_TO_ACCOUNT_MAP.get(private_key_type, ETHAccount)

_load_account(private_key_str=private_key, account_type=account_class)
if private_key_type == Chain.ETH:
private_key_bytes = bytes.fromhex(private_key)
else:
private_key_bytes = parse_solana_private_key(private_key)

else:
private_key_bytes = generate_key()
if not chain_type:
chain_type = Chain(
Prompt.ask(
"Which chain u want to be loaded as: ",
choices=[Chain.ETH, Chain.SOL, Chain.AVAX, Chain.BASE],
default=Chain.ETH.value,
)
)

if chain_type.SOL:
private_key_bytes = generate_key_solana()
else:
private_key_bytes = generate_key()

if not private_key_bytes:
typer.secho("An unexpected error occurred!", fg=RED)
Expand All @@ -58,6 +104,10 @@ def create(
private_key_file.parent.mkdir(parents=True, exist_ok=True)
private_key_file.write_bytes(private_key_bytes)
typer.secho(f"Private key stored in {private_key_file}", fg=RED)
account = ChainAccount(path=private_key_file, chain=chain_type if chain_type else Chain.ETH)
if replace:
await upsert_chain_account(account, private_key_file)
typer.secho(f"Private : {account.path} on chain {account.chain} is now Default", fg=GREEN)


@app.command()
Expand Down Expand Up @@ -163,3 +213,128 @@ async def balance(
typer.echo(f"Failed to retrieve balance for address {address}. Status code: {response.status}")
else:
typer.echo("Error: Please provide either a private key, private key file, or an address.")


@app.command()
async def list():
"""List the current chain account and unlinked keys from the config file."""

config_file_path = Path(settings.CONFIG_FILE)
active_account = load_account_key_context(config_file_path)

unlinked_keys, _ = await list_unlinked_keys()

table = Table(title="Chain Accounts", show_lines=True)
table.add_column("Name", justify="left", style="cyan", no_wrap=True)
table.add_column("Path", justify="left", style="green")
table.add_column("Chain", justify="left", style="magenta", no_wrap=True)

if active_account:
table.add_row(
active_account.path.stem, str(active_account.path), f"[bold green]{active_account.chain}[/bold green]"
)
else:
console.print("[bold red]No active account found in the config file.[/bold red]")

if unlinked_keys:
for key_file in unlinked_keys:
table.add_row(key_file.stem, str(key_file), "-")

console.print(table)


@app.command()
async def config(
private_key_file: Optional[Path] = typer.Option(None, help="Path to the private key file"),
chain_type: Optional[str] = typer.Option(None, help="Type of blockchain (ETH, SOL, etc.)"),
):
"""
Async command to link private keys to a blockchain, interactively or non-interactively.
"""

if private_key_file is None:
unlinked_keys, _ = await list_unlinked_keys()

if not unlinked_keys:
typer.secho("No unlinked private keys found.", fg=typer.colors.GREEN)
raise typer.Exit()

console.print("[bold cyan]Available unlinked private keys:[/bold cyan]")
for idx, key in enumerate(unlinked_keys, start=1):
console.print(f"[{idx}] {key}")

key_choice = Prompt.ask("Choose a private key by entering the number")

if key_choice.isdigit():
key_index = int(key_choice) - 1
if 0 <= key_index < len(unlinked_keys):
private_key_file = unlinked_keys[key_index]
else:
typer.secho("Invalid key index selected.", fg=typer.colors.RED)
raise typer.Exit()
else:
matching_keys = [key for key in unlinked_keys if key.name == key_choice]
if matching_keys:
private_key_file = matching_keys[0]
else:
typer.secho("No matching key found with the provided name.", fg=typer.colors.RED)
raise typer.Exit()

if chain_type is None:
chain_type = Prompt.ask(
"Which chain type do you want to link the key to?",
choices=["ETH", "SOL", "AVAX", "BASE", "BSC"],
default="ETH",
)

typer.secho(f"Private key file: {private_key_file}", fg=typer.colors.YELLOW)
typer.secho(f"Chain type: {chain_type}", fg=typer.colors.YELLOW)

new_account = ChainAccount(path=private_key_file, chain=Chain(chain_type))

try:
await upsert_chain_account(new_account, settings.CONFIG_FILE)
typer.secho(f"Key file {private_key_file} linked to {chain_type} successfully.", fg=typer.colors.GREEN)
except ValueError as e:
typer.secho(f"Error: {e}", fg=typer.colors.RED)


@app.command()
async def update(
private_key_file: Optional[Path] = typer.Option(None, help="The new path to the private key file"),
chain_type: Optional[str] = typer.Option(None, help="The new blockchain type (ETH, SOL, etc.)"),
):
"""
Command to update an existing chain account.
"""

try:
existing_account = load_account_key_context(settings.CONFIG_FILE)

if private_key_file:
new_key_file = private_key_file
elif existing_account and existing_account.path:
new_key_file = existing_account.path
else:
typer.secho("No private key file or account path available", fg=typer.colors.RED)
typer.Exit(1)

if chain_type:
new_chain_type = chain_type
elif existing_account and existing_account.chain:
new_chain_type = existing_account.chain
else:
typer.secho("No chain type available", fg=typer.colors.RED)
typer.Exit(1)

updated_account = ChainAccount(path=new_key_file, chain=Chain(new_chain_type))

await upsert_chain_account(updated_account, settings.CONFIG_FILE)

typer.secho(
f"Account {updated_account.path} Chain : {updated_account.chain} updated successfully!",
fg=typer.colors.GREEN,
)

except ValueError as e:
typer.secho(f"Error: {e}", fg=typer.colors.RED)
1 change: 1 addition & 0 deletions src/aleph_client/commands/help_strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,4 @@
ALLOCATION_AUTO = "Auto - Scheduler"
ALLOCATION_MANUAL = "Manual - Selection"
PAYMENT_CHAIN = "Chain you want to use to pay for your instance"
ACCOUNT_CHAIN = "Type of account you want to use (ETH / SOL / Avax / BASE)"
14 changes: 13 additions & 1 deletion src/aleph_client/commands/instance/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from aleph.sdk.query.filters import MessageFilter
from aleph.sdk.query.responses import PriceResponse
from aleph.sdk.types import AccountFromPrivateKey, StorageEnum
from aleph.sdk.utils import calculate_firmware_hash
from aleph.sdk.utils import calculate_firmware_hash, load_account_key_context
from aleph_message.models import InstanceMessage, StoreMessage
from aleph_message.models.base import Chain, MessageType
from aleph_message.models.execution.base import Payment, PaymentType
Expand Down Expand Up @@ -142,6 +142,17 @@ def validate_ssh_pubkey_file(file: Union[str, Path]) -> Path:

account: AccountFromPrivateKey = _load_account(private_key, private_key_file)


try:
if payment_chain is None:
key_context = load_account_key_context(settings.CONFIG_FILE)

if key_context is not None:
payment_chain = key_context.chain

except Exception as e:
pass

if payment_type is None:
payment_type = Prompt.ask(
"Which payment type do you want to use?",
Expand All @@ -161,6 +172,7 @@ def validate_ssh_pubkey_file(file: Union[str, Path]) -> Path:
default=Chain.AVAX.value,
)
)

if isinstance(account, ETHAccount):
account.switch_chain(payment_chain)
if account.superfluid_connector: # Quick check with theoretical min price
Expand Down
37 changes: 34 additions & 3 deletions src/aleph_client/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
from functools import partial, wraps
from pathlib import Path
from shutil import make_archive
from typing import Tuple, Type
from typing import List, Optional, Tuple, Type
from zipfile import BadZipFile, ZipFile

import typer
from aiohttp import ClientSession
from aleph.sdk.conf import settings
from aleph.sdk.types import GenericMessage
from aleph_message.models.base import MessageType
from aleph.sdk.types import ChainAccount, GenericMessage
from aleph.sdk.utils import load_account_key_context
from aleph_message.models.base import Chain, MessageType
from aleph_message.models.execution.base import Encoding

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -100,3 +101,33 @@ def extract_valid_eth_address(address: str) -> str:
if match:
return match.group(0)
return ""


async def list_unlinked_keys() -> Tuple[List[Path], Optional[ChainAccount]]:
"""
List private key files that are not linked to any chain type and return the active ChainAccount.

Returns:
- A tuple containing:
- A list of unlinked private key files as Path objects.
- The active ChainAccount object (the single account in the config file).
"""
config_home = settings.CONFIG_HOME if settings.CONFIG_HOME else str(Path.home())
philogicae marked this conversation as resolved.
Show resolved Hide resolved
private_key_dir = Path(config_home, "private-keys")

if not private_key_dir.exists():
return [], None

all_private_key_files = list(private_key_dir.glob("*.key"))

account_data: Optional[ChainAccount] = load_account_key_context(Path(settings.CONFIG_FILE))

if not account_data:
logger.warning("No account data found in the config file.")
return all_private_key_files, None

active_key_path = account_data.path

unlinked_keys: List[Path] = [key_file for key_file in all_private_key_files if key_file != active_key_path]

return unlinked_keys, account_data
Loading