Skip to content

Conversation

@nesitor
Copy link
Member

@nesitor nesitor commented Sep 10, 2025

Problem: Ledger wallet users cannot use Aleph to send transactions.

Solution: Implement Ledger use on CLI to allow using them. Do it importing a specific branch of the SDK.

Self proofreading checklist

  • The new code clear, easy to read and well commented.
  • New code does not duplicate the functions of builtin or popular libraries.
  • An LLM was used to review the new code and look for simplifications.
  • New classes and functions contain docstrings explaining what they provide.
  • All new code is covered by relevant tests.

@nesitor nesitor self-assigned this Sep 10, 2025
@1yam 1yam force-pushed the andres-feature-implement_ledger branch from dfd8a03 to b994c79 Compare October 31, 2025 09:50
@1yam 1yam assigned 1yam and unassigned nesitor Oct 31, 2025
@1yam 1yam marked this pull request as ready for review October 31, 2025 10:27
@1yam 1yam requested a review from odesenfans October 31, 2025 10:27
@codecov
Copy link

codecov bot commented Oct 31, 2025

Codecov Report

❌ Patch coverage is 63.88889% with 156 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.25%. Comparing base (e64d162) to head (a5a83b6).
⚠️ Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
src/aleph_client/commands/account.py 38.33% 102 Missing and 9 partials ⚠️
src/aleph_client/commands/program.py 38.09% 12 Missing and 1 partial ⚠️
src/aleph_client/utils.py 90.99% 4 Missing and 6 partials ⚠️
src/aleph_client/commands/instance/__init__.py 75.75% 6 Missing and 2 partials ⚠️
src/aleph_client/commands/message.py 53.84% 6 Missing ⚠️
src/aleph_client/commands/domain.py 33.33% 4 Missing ⚠️
src/aleph_client/commands/files.py 66.66% 2 Missing ⚠️
src/aleph_client/commands/instance/network.py 50.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #402      +/-   ##
==========================================
+ Coverage   61.07%   61.25%   +0.18%     
==========================================
  Files          20       20              
  Lines        3712     3962     +250     
  Branches      533      581      +48     
==========================================
+ Hits         2267     2427     +160     
- Misses       1168     1262      +94     
+ Partials      277      273       -4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@odesenfans
Copy link
Contributor

@1yam how do you use this? I tried aleph account config --account-type external with a Ledger Nano S plugged in via USB but I get the following error:

$ aleph account config --account-type external
No config file found.
Loading External keys. Do you want to import from Ledger? [y/n] (y): y
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Traceback (most recent call last) ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ /home/olivier/git/aleph/aleph-client/src/aleph_client/utils.py:94 in runner                                                                                                                                                                                                                             │
│                                                                                                                                                                                                                                                                                                         │
│    91 │   │   │                                                                                ╭──────────────────────────────────────────────────────── locals ─────────────────────────────────────────────────────────╮                                                                              │
│    92 │   │   │   @wraps(f)                                                                    │   args = ()                                                                                                             │                                                                              │
│    93 │   │   │   def runner(*args, **kwargs):                                                 │ kwargs = {'private_key_file': None, 'chain': None, 'address': None, 'account_type': <AccountType.EXTERNAL: 'external'>} │                                                                              │
│ ❱  94 │   │   │   │   return asyncio.run(f(*args, **kwargs))                                   ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯                                                                              │
│    95 │   │   │                                                                                                                                                                                                                                                                                         │
│    96 │   │   │   decorator(runner)                                                                                                                                                                                                                                                                     │
│    97 │   │   else:                                                                                                                                                                                                                                                                                     │
│                                                                                                                                                                                                                                                                                                         │
│ /usr/lib/python3.13/asyncio/runners.py:195 in run                                                                                                                                                                                                                                                       │
│                                                                                                                                                                                                                                                                                                         │
│   192 │   │   │   "asyncio.run() cannot be called from a running event loop")                  ╭───────────────────────────── locals ─────────────────────────────╮                                                                                                                                     │
│   193 │                                                                                        │        debug = None                                              │                                                                                                                                     │
│   194 │   with Runner(debug=debug, loop_factory=loop_factory) as runner:                       │ loop_factory = None                                              │                                                                                                                                     │
│ ❱ 195 │   │   return runner.run(main)                                                          │         main = <coroutine object configure at 0x74340e754b40>    │                                                                                                                                     │
│   196                                                                                          │       runner = <asyncio.runners.Runner object at 0x74340ebefe00> │                                                                                                                                     │
│   197                                                                                          ╰──────────────────────────────────────────────────────────────────╯                                                                                                                                     │
│   198 def _cancel_all_tasks(loop):                                                                                                                                                                                                                                                                      │
│                                                                                                                                                                                                                                                                                                         │
│ /usr/lib/python3.13/asyncio/runners.py:118 in run                                                                                                                                                                                                                                                       │
│                                                                                                                                                                                                                                                                                                         │
│   115 │   │                                                                                                                                                                                                                                                                                             │
│   116 │   │   self._interrupt_count = 0                                                                                                                                                                                                                                                                 │
│   117 │   │   try:                                                                                                                                                                                                                                                                                      │
│ ❱ 118 │   │   │   return self._loop.run_until_complete(task)                                                                                                                                                                                                                                            │
│   119 │   │   except exceptions.CancelledError:                                                                                                                                                                                                                                                         │
│   120 │   │   │   if self._interrupt_count > 0:                                                                                                                                                                                                                                                         │
│   121 │   │   │   │   uncancel = getattr(task, "uncancel", None)                                                                                                                                                                                                                                        │
│                                                                                                                                                                                                                                                                                                         │
│ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── locals ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │
│ │        context = <_contextvars.Context object at 0x74340e391d80>                                                                                                                                                                                                                                    │ │
│ │           coro = <coroutine object configure at 0x74340e754b40>                                                                                                                                                                                                                                     │ │
│ │           self = <asyncio.runners.Runner object at 0x74340ebefe00>                                                                                                                                                                                                                                  │ │
│ │ sigint_handler = functools.partial(<bound method Runner._on_sigint of <asyncio.runners.Runner object at 0x74340ebefe00>>, main_task=<Task finished name='Task-1' coro=<configure() done, defined at /home/olivier/git/aleph/aleph-client/src/aleph_client/commands/account.py:533>                  │ │
│ │                  exception=OSError('open failed')>)                                                                                                                                                                                                                                                 │ │
│ │           task = <Task finished name='Task-1' coro=<configure() done, defined at /home/olivier/git/aleph/aleph-client/src/aleph_client/commands/account.py:533> exception=OSError('open failed')>                                                                                                   │ │
│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │
│                                                                                                                                                                                                                                                                                                         │
│ /usr/lib/python3.13/asyncio/base_events.py:719 in run_until_complete                                                                                                                                                                                                                                    │
│                                                                                                                                                                                                                                                                                                         │
│    716 │   │   if not future.done():                                                            ╭────────────────────────────────────────────────────────────────────────────────────────── locals ───────────────────────────────────────────────────────────────────────────────────────────╮         │
│    717 │   │   │   raise RuntimeError('Event loop stopped before Future completed.')            │   future = <Task finished name='Task-1' coro=<configure() done, defined at /home/olivier/git/aleph/aleph-client/src/aleph_client/commands/account.py:533> exception=OSError('open failed')> │         │
│    718 │   │                                                                                    │ new_task = False                                                                                                                                                                            │         │
│ ❱  719 │   │   return future.result()                                                           │     self = <_UnixSelectorEventLoop running=False closed=True debug=False>                                                                                                                   │         │
│    720 │                                                                                        ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯         │
│    721 │   def stop(self):                                                                                                                                                                                                                                                                              │
│    722 │   │   """Stop running the event loop.                                                                                                                                                                                                                                                          │
│                                                                                                                                                                                                                                                                                                         │
│ /home/olivier/git/aleph/aleph-client/src/aleph_client/commands/account.py:592 in configure                                                                                                                                                                                                              │
│                                                                                                                                                                                                                                                                                                         │
│   589 │   │   │   "[bright_cyan]Loading External keys.[/bright_cyan] [yellow]Do you want to im ╭────────────────────────────────────────────────────────────────────── locals ──────────────────────────────────────────────────────────────────────╮                                                   │
│   590 │   │   │   default="y",                                                                 │     account_type = <AccountType.EXTERNAL: 'external'>                                                                                              │                                                   │
│   591 │   │   ):                                                                               │          address = None                                                                                                                            │                                                   │
│ ❱ 592 │   │   │   accounts = LedgerETHAccount.get_accounts()                                   │            chain = None                                                                                                                            │                                                   │
│   593 │   │   │   account_addresses = [acc.address for acc in accounts]                        │           config = None                                                                                                                            │                                                   │
│   594 │   │   │                                                                                │ private_key_file = None                                                                                                                            │                                                   │
│   595 │   │   │   console.print("[bold cyan]Available addresses on Ledger:[/bold cyan]")       │    unlinked_keys = [PosixPath('/home/olivier/.aleph-im/private-keys/ethereum.key'), PosixPath('/home/olivier/.aleph-im/private-keys/default.key')] │                                                   │
│                                                                                                ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯                                                   │
│                                                                                                                                                                                                                                                                                                         │
│ /home/olivier/git/aleph/aleph-client/venv/lib/python3.13/site-packages/aleph/sdk/wallets/ledger/ethereum.py:46 in get_accounts                                                                                                                                                                          │
│                                                                                                                                                                                                                                                                                                         │
│    43 │   │   """Initialize an aleph.im account from a LedgerHQ device from                    ╭─── locals ────╮                                                                                                                                                                                        │
│    44 │   │   a known wallet address.                                                          │  count = 5    │                                                                                                                                                                                        │
│    45 │   │   """                                                                              │ device = None │                                                                                                                                                                                        │
│ ❱  46 │   │   device = device or init_dongle()                                                 ╰───────────────╯                                                                                                                                                                                        │
│    47 │   │   accounts: List[LedgerAccount] = get_accounts(dongle=device, count=count)                                                                                                                                                                                                                  │
│    48 │   │   return accounts                                                                                                                                                                                                                                                                           │
│    49                                                                                                                                                                                                                                                                                                   │
│                                                                                                                                                                                                                                                                                                         │
│ /home/olivier/git/aleph/aleph-client/venv/lib/python3.13/site-packages/ledgereth/comms.py:179 in init_dongle                                                                                                                                                                                            │
│                                                                                                                                                                                                                                                                                                         │
│   176 │   # If not given, use cache if available                                               ╭──── locals ────╮                                                                                                                                                                                       │
│   177 │   if dongle is None and DONGLE_CACHE is None:                                          │  debug = False │                                                                                                                                                                                       │
│   178 │   │   try:                                                                             │ dongle = None  │                                                                                                                                                                                       │
│ ❱ 179 │   │   │   DONGLE_CACHE = getDongle(debug)  # type: ignore                              ╰────────────────╯                                                                                                                                                                                       │
│   180 │   │   except CommException as err:                                                                                                                                                                                                                                                              │
│   181 │   │   │   raise LedgerError.transalate_comm_exception(err) from err                                                                                                                                                                                                                             │
│   182                                                                                                                                                                                                                                                                                                   │
│                                                                                                                                                                                                                                                                                                         │
│ /home/olivier/git/aleph/aleph-client/venv/lib/python3.13/site-packages/ledgerblue/comm.py:361 in getDongle                                                                                                                                                                                              │
│                                                                                                                                                                                                                                                                                                         │
│   358 │   │   │   │   hidDevicePath = hidDevice['path']                                                                                                                                                                                                                                                 │
│   359 │   if hidDevicePath is not None:                                                                                                                                                                                                                                                                 │
│   360 │   │   dev = hid.device()                                                                                                                                                                                                                                                                        │
│ ❱ 361 │   │   dev.open_path(hidDevicePath)                                                                                                                                                                                                                                                              │
│   362 │   │   dev.set_nonblocking(True)                                                                                                                                                                                                                                                                 │
│   363 │   │   return HIDDongleHIDAPI(dev, ledger, debug)                                                                                                                                                                                                                                                │
│   364 │   if PCSC:                                                                                                                                                                                                                                                                                      │
│                                                                                                                                                                                                                                                                                                         │
│ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────── locals ────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮                                                               │
│ │         debug = False                                                                                                                                                                                                                 │                                                               │
│ │           dev = <hid.device object at 0x74340fbb5000>                                                                                                                                                                                 │                                                               │
│ │     hidDevice = {'path': b'1-10:1.1', 'vendor_id': 6940, 'product_id': 6985, 'serial_number': '', 'release_number': 804, 'manufacturer_string': '', 'product_string': '', 'usage_page': 0, 'usage': 0, 'interface_number': 1, ... +1} │                                                               │
│ │ hidDevicePath = b'1-2.2.1:1.0'                                                                                                                                                                                                        │                                                               │
│ │        ledger = True                                                                                                                                                                                                                  │                                                               │
│ │ selectCommand = None                                                                                                                                                                                                                  │                                                               │
│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯                                                               │
│                                                                                                                                                                                                                                                                                                         │
│ in hid.device.open_path:158                                                                                                                                                                                                                                                                             │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
OSError: open failed

1yam added 23 commits November 20, 2025 16:59
setup_logging(debug)

account: AccountFromPrivateKey = _load_account(private_key, private_key_file)
account: AccountTypes = load_account(private_key, private_key_file)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above the chain param

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here the chain args is used to specify a chain where u want to grant permissions

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, and maybe we want to give permissions for Solana wallets.

setup_logging(debug)

account: AccountFromPrivateKey = _load_account(private_key, private_key_file)
account: AccountTypes = load_account(private_key, private_key_file)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above the chain param

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here the chain args is used to specify a chain where u want to grant permissions

Copy link
Member Author

@nesitor nesitor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, I think that ALWAYS we need to take into account the chain parameter as we support non-EVM chains like Solana.

Comment on lines 25 to 27
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"
)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: I think that will be better to put that URL on Settings class

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can let it for now, gonna get removed soon, i started to move that to a custom Services
on sdk side, but didn't got time to finish it yet:

aleph-im/aleph-sdk-python#248

raise typer.Exit(code=1) from e
else:
# Normal flow - show available accounts and let user choose
accounts = LedgerETHAccount.get_accounts()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we need to allow also the user to specify how many accounts to get

derivation_path: Annotated[
Optional[str], typer.Option(help="Derivation path for ledger (e.g. \"44'/60'/0'/0/0\")")
] = None,
no: Annotated[bool, typer.Option("--no", help="Non-interactive mode. Only apply provided options.")] = False,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: I think this param's name can be a bit confusing, maybe something like --non-it or something more explicit.

setup_logging(debug)

account: AccountFromPrivateKey = _load_account(private_key, private_key_file)
account: AccountTypes = load_account(private_key, private_key_file)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but if we allow to use Solana instance on holding tier, and a user wants to add forwarded ports for example, this chain param will be needed.

setup_logging(debug)

account: AccountFromPrivateKey = _load_account(private_key, private_key_file)
account: AccountTypes = load_account(private_key, private_key_file)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, and maybe we want to give permissions for Solana wallets.

setup_logging(debug)

account: AccountFromPrivateKey = _load_account(private_key, private_key_file)
account: AccountTypes = load_account(private_key, private_key_file)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For FORGET messages we will need to use also the chain argument to forget Solana messages for example.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants