Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,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",
]
Expand Down
152 changes: 120 additions & 32 deletions src/aleph_client/commands/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
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.conf import (
AccountType,
MainConfiguration,
load_main_configuration,
save_main_configuration,
Expand All @@ -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
Expand All @@ -42,7 +46,7 @@
validated_prompt,
yes_no_input,
)
from aleph_client.utils import AsyncTyper, list_unlinked_keys
from aleph_client.utils import AsyncTyper, list_unlinked_keys, load_account

logger = logging.getLogger(__name__)
app = AsyncTyper(no_args_is_help=True)
Expand Down Expand Up @@ -145,26 +149,31 @@ 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).
"""
# For regular accounts and Ledger accounts
evm_account = load_account(private_key, private_key_file, chain=Chain.ETH)
evm_address = evm_account.get_address()

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)
# For Ledger accounts, the SOL address might not be available
try:
sol_address = load_account(private_key, private_key_file, chain=Chain.SOL).get_address()
except Exception:
sol_address = "Not available (using Ledger device)"

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()
# Detect if it's a Ledger account
config_file_path = Path(settings.CONFIG_FILE)
config = load_main_configuration(config_file_path)
account_type = config.type if config else None

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"
)
Expand Down Expand Up @@ -229,16 +238,31 @@ 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"
Expand All @@ -261,7 +285,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()
Expand Down Expand Up @@ -296,7 +320,7 @@ 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)
account = load_account(private_key, private_key_file, chain=chain)

if account and not address:
address = account.get_address()
Expand Down Expand Up @@ -381,9 +405,12 @@ async def list_accounts():
table.add_column("Active", no_wrap=True)

active_chain = None
if config:
if config and config.path:
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
table.add_row(f"Ledger ({config.address[:8]}...)", "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 "
Expand All @@ -395,13 +422,27 @@ async def list_accounts():
if key_file.stem != "default":
table.add_row(key_file.stem, str(key_file), "[bold red]-[/bold red]")

# Try to detect Ledger devices
try:
ledger_accounts = LedgerETHAccount.get_accounts()
if ledger_accounts:
for idx, ledger_acc in enumerate(ledger_accounts):
is_active = config and config.type == AccountType.HARDWARE and config.address == ledger_acc.address
status = "[bold green]*[/bold green]" if is_active else "[bold red]-[/bold red]"
table.add_row(f"Ledger #{idx}", f"{ledger_acc.address}", status)
except Exception:
logger.info("No ledger detected")

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"
Expand All @@ -425,7 +466,7 @@ 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)
account = load_account(private_key, private_key_file, chain=chain)

if account and not address:
address = account.get_address()
Expand Down Expand Up @@ -476,9 +517,16 @@ 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,
):
"""Configure current private key file and active chain (default selection)"""

if settings.CONFIG_HOME:
settings.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) # Ensure config file is created
private_keys_dir = Path(settings.CONFIG_HOME, "private-keys") # ensure private-keys folder created
private_keys_dir.mkdir(parents=True, exist_ok=True)

unlinked_keys, config = await list_unlinked_keys()

# Fixes private key file path
Expand All @@ -493,8 +541,12 @@ 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 private_key_file is specified via command line, prioritize it
if private_key_file:
pass
elif not account_type or (
account_type == AccountType.IMPORTED 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]",
Expand All @@ -520,12 +572,45 @@ async def configure(
else: # No change
private_key_file = Path(config.path)

if not private_key_file:
typer.secho("No private key file provided or found.", fg=typer.colors.RED)
raise typer.Exit()
if not private_key_file and account_type == AccountType.HARDWARE:
if yes_no_input(
"[bright_cyan]Loading External keys.[/bright_cyan] [yellow]Do you want to import from Ledger?[/yellow]",
default="y",
):
try:

accounts = LedgerETHAccount.get_accounts()
account_addresses = [acc.address for acc in accounts]

console.print("[bold cyan]Available addresses on Ledger:[/bold cyan]")
for idx, account_address in enumerate(account_addresses, start=1):
console.print(f"[{idx}] {account_address}")

key_choice = Prompt.ask("Choose a address by index")
if key_choice.isdigit():
key_index = int(key_choice) - 1
selected_address = account_addresses[key_index]

if not selected_address:
typer.secho("No valid address selected.", fg=typer.colors.RED)
raise typer.Exit()

address = selected_address
account_type = AccountType.HARDWARE
except LedgerError as e:
logger.warning(f"Ledger Error : {e.message}")
raise typer.Exit(code=1) from e
except OSError as err:
logger.warning("Please ensure Udev rules are set to use Ledger")
raise typer.Exit(code=1) from err
else:
typer.secho("No private key file provided or found.", fg=typer.colors.RED)
raise typer.Exit()

# Configure active chain
if not chain and config and hasattr(config, "chain"):
# If 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",
Expand All @@ -544,12 +629,15 @@ 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)
save_main_configuration(settings.CONFIG_FILE, config)
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.path or config.address}"
f"[/italic bright_cyan] with [italic bright_cyan]{config.chain}[/italic bright_cyan]",
style=typer.colors.GREEN,
)
except ValueError as e:
Expand Down
18 changes: 8 additions & 10 deletions src/aleph_client/commands/aggregate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.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
Expand All @@ -21,7 +19,7 @@

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, load_account, sanitize_url

logger = logging.getLogger(__name__)
app = AsyncTyper(no_args_is_help=True)
Expand Down Expand Up @@ -59,7 +57,7 @@ async def forget(

setup_logging(debug)

account: AccountFromPrivateKey = _load_account(private_key, private_key_file)
account: AccountTypes = load_account(private_key, private_key_file)
address = account.get_address() if address is None else address

if key == "security" and not is_same_context():
Expand Down Expand Up @@ -132,7 +130,7 @@ async def post(

setup_logging(debug)

account: AccountFromPrivateKey = _load_account(private_key, private_key_file)
account: AccountTypes = load_account(private_key, private_key_file)
address = account.get_address() if address is None else address

if key == "security" and not is_same_context():
Expand Down Expand Up @@ -194,7 +192,7 @@ async def get(

setup_logging(debug)

account: AccountFromPrivateKey = _load_account(private_key, private_key_file)
account: AccountTypes = load_account(private_key, private_key_file)
address = account.get_address() if address is None else address

async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client:
Expand Down Expand Up @@ -230,7 +228,7 @@ async def list_aggregates(

setup_logging(debug)

account: AccountFromPrivateKey = _load_account(private_key, private_key_file)
account: AccountTypes = load_account(private_key, private_key_file)
address = account.get_address() if address is None else address

aggr_link = f"{sanitize_url(settings.API_HOST)}/api/v0/aggregates/{address}.json"
Expand Down Expand Up @@ -304,7 +302,7 @@ async def authorize(

setup_logging(debug)

account: AccountFromPrivateKey = _load_account(private_key, private_key_file)
account: AccountTypes = load_account(private_key, private_key_file)

data = await get(
key="security",
Expand Down Expand Up @@ -378,7 +376,7 @@ async def revoke(

setup_logging(debug)

account: AccountFromPrivateKey = _load_account(private_key, private_key_file)
account: AccountTypes = load_account(private_key, private_key_file)

data = await get(
key="security",
Expand Down Expand Up @@ -433,7 +431,7 @@ async def permissions(

setup_logging(debug)

account: AccountFromPrivateKey = _load_account(private_key, private_key_file)
account: AccountTypes = load_account(private_key, private_key_file)
address = account.get_address() if address is None else address

data = await get(
Expand Down
Loading
Loading