From f099f66d2ac2d559aaa7c0c1aa1a4b7dc64ac17b Mon Sep 17 00:00:00 2001 From: "Andres D. Molins" Date: Wed, 10 Sep 2025 14:16:06 +0200 Subject: [PATCH 01/26] Problem: Ledger wallet users cannot use Aleph to send transactions. Solution: Implement Ledger use on SDK to allow using them. --- pyproject.toml | 2 ++ src/aleph/sdk/account.py | 23 ++++++++++++++--------- src/aleph/sdk/conf.py | 18 ++++++++++++++---- src/aleph/sdk/wallets/ledger/ethereum.py | 19 +++++++++++++++++-- 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3b2d3d16..7a142e9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,8 @@ dependencies = [ "python-magic", "typing-extensions", "web3>=7.10", + "ledgerblue>=0.1.48", + "ledgereth>=0.10.0", ] optional-dependencies.all = [ diff --git a/src/aleph/sdk/account.py b/src/aleph/sdk/account.py index 6af5e32c..83410ec3 100644 --- a/src/aleph/sdk/account.py +++ b/src/aleph/sdk/account.py @@ -12,9 +12,10 @@ from aleph.sdk.chains.solana import SOLAccount from aleph.sdk.chains.substrate import DOTAccount from aleph.sdk.chains.svm import SVMAccount -from aleph.sdk.conf import load_main_configuration, settings +from aleph.sdk.conf import load_main_configuration, settings, AccountType from aleph.sdk.evm_utils import get_chains_with_super_token from aleph.sdk.types import AccountFromPrivateKey +from aleph.sdk.wallets.ledger import LedgerETHAccount logger = logging.getLogger(__name__) @@ -134,15 +135,19 @@ def _load_account( elif private_key_path and private_key_path.is_file(): return account_from_file(private_key_path, account_type, chain) # For ledger keys - elif settings.REMOTE_CRYPTO_HOST: + # elif settings.REMOTE_CRYPTO_HOST: + # logger.debug("Using remote account") + # loop = asyncio.get_event_loop() + # return loop.run_until_complete( + # RemoteAccount.from_crypto_host( + # host=settings.REMOTE_CRYPTO_HOST, + # unix_socket=settings.REMOTE_CRYPTO_UNIX_SOCKET, + # ) + # ) + # New Ledger Implementation + elif config.type == AccountType.EXTERNAL.value: logger.debug("Using remote account") - loop = asyncio.get_event_loop() - return loop.run_until_complete( - RemoteAccount.from_crypto_host( - host=settings.REMOTE_CRYPTO_HOST, - unix_socket=settings.REMOTE_CRYPTO_UNIX_SOCKET, - ) - ) + return LedgerETHAccount.from_address(config.address) # Fallback: config.path if set, else generate a new private key else: new_private_key = get_fallback_private_key() diff --git a/src/aleph/sdk/conf.py b/src/aleph/sdk/conf.py index 02e8ec85..8b404c2e 100644 --- a/src/aleph/sdk/conf.py +++ b/src/aleph/sdk/conf.py @@ -1,6 +1,7 @@ import json import logging import os +from enum import Enum from pathlib import Path from shutil import which from typing import ClassVar, Dict, List, Optional, Union @@ -286,13 +287,20 @@ class Settings(BaseSettings): ) +class AccountType(Enum): + INTERNAL: str = 'internal' + EXTERNAL: str = 'external' + + class MainConfiguration(BaseModel): """ Intern Chain Management with Account. """ - path: Path + path: Optional[Path] + type: Optional[AccountType] = AccountType.INTERNAL chain: Chain + address: Optional[str] = None model_config = SettingsConfigDict(use_enum_values=True) @@ -328,8 +336,11 @@ class MainConfiguration(BaseModel): with open(settings.CONFIG_FILE, "r", encoding="utf-8") as f: config_data = json.load(f) - if "path" in config_data: + if ("path" in config_data and + ("type" not in config_data or config_data['type'] == AccountType.INTERNAL.value)): settings.PRIVATE_KEY_FILE = Path(config_data["path"]) + else: + settings.PRIVATE_KEY_FILE = None except json.JSONDecodeError: pass @@ -367,8 +378,7 @@ def load_main_configuration(file_path: Path) -> Optional[MainConfiguration]: try: with file_path.open("rb") as file: content = file.read() - data = json.loads(content.decode("utf-8")) - return MainConfiguration(**data) + return MainConfiguration.model_validate_json(content.decode("utf-8")) except UnicodeDecodeError as e: logger.error(f"Unable to decode {file_path} as UTF-8: {e}") except json.JSONDecodeError: diff --git a/src/aleph/sdk/wallets/ledger/ethereum.py b/src/aleph/sdk/wallets/ledger/ethereum.py index 18712a0a..7706e412 100644 --- a/src/aleph/sdk/wallets/ledger/ethereum.py +++ b/src/aleph/sdk/wallets/ledger/ethereum.py @@ -9,11 +9,12 @@ from ledgereth.messages import sign_message from ledgereth.objects import LedgerAccount, SignedMessage -from ...chains.common import BaseAccount, get_verification_buffer +from ...chains.common import get_verification_buffer +from ...chains.ethereum import ETHAccount from ...utils import bytes_from_hex -class LedgerETHAccount(BaseAccount): +class LedgerETHAccount(ETHAccount): """Account using the Ethereum app on Ledger hardware wallets.""" CHAIN = "ETH" @@ -31,6 +32,20 @@ def __init__(self, account: LedgerAccount, device: Dongle): self._account = account self._device = device + @staticmethod + def get_accounts( + device: Optional[Dongle] = None, count: int = 5 + ) -> List[LedgerAccount]: + """Initialize an aleph.im account from a LedgerHQ device from + a known wallet address. + """ + device = device or init_dongle() + accounts: List[LedgerAccount] = get_accounts( + dongle=device, count=count + ) + return accounts + + @staticmethod def from_address( address: str, device: Optional[Dongle] = None From 5f0c7b55a2b28148b49a2386bafad6bd0dec6278 Mon Sep 17 00:00:00 2001 From: "Andres D. Molins" Date: Wed, 10 Sep 2025 14:33:41 +0200 Subject: [PATCH 02/26] Fix: Solved linting and types issues for code quality. --- pyproject.toml | 4 ++-- src/aleph/sdk/account.py | 28 ++++++++++-------------- src/aleph/sdk/conf.py | 14 ++++++------ src/aleph/sdk/wallets/ledger/ethereum.py | 5 +---- 4 files changed, 22 insertions(+), 29 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7a142e9f..24012413 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,14 +38,14 @@ dependencies = [ "eth-abi>=5.0.1; python_version>='3.9'", "eth-typing>=5.0.1", "jwcrypto==1.5.6", + "ledgerblue>=0.1.48", + "ledgereth>=0.10", "pydantic>=2,<3", "pydantic-settings>=2", "pynacl==1.5", # Needed now as default with _load_account changement "python-magic", "typing-extensions", "web3>=7.10", - "ledgerblue>=0.1.48", - "ledgereth>=0.10.0", ] optional-dependencies.all = [ diff --git a/src/aleph/sdk/account.py b/src/aleph/sdk/account.py index 83410ec3..068da572 100644 --- a/src/aleph/sdk/account.py +++ b/src/aleph/sdk/account.py @@ -1,18 +1,16 @@ -import asyncio import logging from pathlib import Path -from typing import Dict, Optional, Type, TypeVar +from typing import Dict, Optional, Type, TypeVar, Union from aleph_message.models import Chain from aleph.sdk.chains.common import get_fallback_private_key from aleph.sdk.chains.ethereum import ETHAccount from aleph.sdk.chains.evm import EVMAccount -from aleph.sdk.chains.remote import RemoteAccount from aleph.sdk.chains.solana import SOLAccount from aleph.sdk.chains.substrate import DOTAccount from aleph.sdk.chains.svm import SVMAccount -from aleph.sdk.conf import load_main_configuration, settings, AccountType +from aleph.sdk.conf import AccountType, load_main_configuration, settings from aleph.sdk.evm_utils import get_chains_with_super_token from aleph.sdk.types import AccountFromPrivateKey from aleph.sdk.wallets.ledger import LedgerETHAccount @@ -103,7 +101,7 @@ def _load_account( private_key_path: Optional[Path] = None, account_type: Optional[Type[AccountFromPrivateKey]] = None, chain: Optional[Chain] = None, -) -> AccountFromPrivateKey: +) -> Union[AccountFromPrivateKey, LedgerETHAccount]: """Load an account from a private key string or file, or from the configuration file.""" config = load_main_configuration(settings.CONFIG_FILE) @@ -145,16 +143,14 @@ def _load_account( # ) # ) # New Ledger Implementation - elif config.type == AccountType.EXTERNAL.value: + elif config and config.address and config.type == AccountType.EXTERNAL.value: logger.debug("Using remote account") - return LedgerETHAccount.from_address(config.address) + ledger_account = LedgerETHAccount.from_address(config.address) + if ledger_account: + return ledger_account + # Fallback: config.path if set, else generate a new private key - else: - new_private_key = get_fallback_private_key() - account = account_from_hex_string( - bytes.hex(new_private_key), account_type, chain - ) - logger.info( - f"Generated fallback private key with address {account.get_address()}" - ) - return account + new_private_key = get_fallback_private_key() + account = account_from_hex_string(bytes.hex(new_private_key), account_type, chain) + logger.info(f"Generated fallback private key with address {account.get_address()}") + return account diff --git a/src/aleph/sdk/conf.py b/src/aleph/sdk/conf.py index 8b404c2e..0d294f3d 100644 --- a/src/aleph/sdk/conf.py +++ b/src/aleph/sdk/conf.py @@ -288,8 +288,8 @@ class Settings(BaseSettings): class AccountType(Enum): - INTERNAL: str = 'internal' - EXTERNAL: str = 'external' + INTERNAL: str = "internal" + EXTERNAL: str = "external" class MainConfiguration(BaseModel): @@ -297,7 +297,7 @@ class MainConfiguration(BaseModel): Intern Chain Management with Account. """ - path: Optional[Path] + path: Optional[Path] = None type: Optional[AccountType] = AccountType.INTERNAL chain: Chain address: Optional[str] = None @@ -336,11 +336,11 @@ class MainConfiguration(BaseModel): with open(settings.CONFIG_FILE, "r", encoding="utf-8") as f: config_data = json.load(f) - if ("path" in config_data and - ("type" not in config_data or config_data['type'] == AccountType.INTERNAL.value)): + if "path" in config_data and ( + "type" not in config_data + or config_data["type"] == AccountType.INTERNAL.value + ): settings.PRIVATE_KEY_FILE = Path(config_data["path"]) - else: - settings.PRIVATE_KEY_FILE = None except json.JSONDecodeError: pass diff --git a/src/aleph/sdk/wallets/ledger/ethereum.py b/src/aleph/sdk/wallets/ledger/ethereum.py index 7706e412..1b66001f 100644 --- a/src/aleph/sdk/wallets/ledger/ethereum.py +++ b/src/aleph/sdk/wallets/ledger/ethereum.py @@ -40,12 +40,9 @@ def get_accounts( a known wallet address. """ device = device or init_dongle() - accounts: List[LedgerAccount] = get_accounts( - dongle=device, count=count - ) + accounts: List[LedgerAccount] = get_accounts(dongle=device, count=count) return accounts - @staticmethod def from_address( address: str, device: Optional[Dongle] = None From 3ef428d64081caef7f90f5cfd972e98be2e0d590 Mon Sep 17 00:00:00 2001 From: "Andres D. Molins" Date: Wed, 10 Sep 2025 14:54:45 +0200 Subject: [PATCH 03/26] Fix: Solved issue calling Ledger for supervisor. --- src/aleph/sdk/wallets/ledger/ethereum.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/aleph/sdk/wallets/ledger/ethereum.py b/src/aleph/sdk/wallets/ledger/ethereum.py index 1b66001f..b96a40e5 100644 --- a/src/aleph/sdk/wallets/ledger/ethereum.py +++ b/src/aleph/sdk/wallets/ledger/ethereum.py @@ -2,6 +2,7 @@ from typing import Dict, List, Optional +from aleph_message.models import Chain from eth_typing import HexStr from ledgerblue.Dongle import Dongle from ledgereth import find_account, get_account_by_path, get_accounts @@ -22,13 +23,16 @@ class LedgerETHAccount(ETHAccount): _account: LedgerAccount _device: Dongle - def __init__(self, account: LedgerAccount, device: Dongle): + def __init__( + self, account: LedgerAccount, device: Dongle, chain: Optional[Chain] = None + ): """Initialize an aleph.im account instance that relies on a LedgerHQ device and the Ethereum Ledger application for signatures. See the static methods `self.from_address(...)` and `self.from_path(...)` for an easier method of instantiation. """ + super().__init__(bytes(), chain) self._account = account self._device = device From 7be46a8099d9dcb7cf9e80993c0a632b13d71044 Mon Sep 17 00:00:00 2001 From: "Andres D. Molins" Date: Wed, 10 Sep 2025 15:11:39 +0200 Subject: [PATCH 04/26] Fix: Try to not pass the private_key bytes to not sign automatically the messages. --- src/aleph/sdk/wallets/ledger/ethereum.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aleph/sdk/wallets/ledger/ethereum.py b/src/aleph/sdk/wallets/ledger/ethereum.py index b96a40e5..a2a2c7b8 100644 --- a/src/aleph/sdk/wallets/ledger/ethereum.py +++ b/src/aleph/sdk/wallets/ledger/ethereum.py @@ -32,9 +32,9 @@ def __init__( See the static methods `self.from_address(...)` and `self.from_path(...)` for an easier method of instantiation. """ - super().__init__(bytes(), chain) self._account = account self._device = device + self.connect_chain(chain=chain) @staticmethod def get_accounts( From ebf0b383f73cc2e18ccdb4d4d06ad97ac0185b30 Mon Sep 17 00:00:00 2001 From: "Andres D. Molins" Date: Wed, 10 Sep 2025 16:13:07 +0200 Subject: [PATCH 05/26] Fix: Solve enum values issue. --- src/aleph/sdk/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aleph/sdk/conf.py b/src/aleph/sdk/conf.py index 0d294f3d..edfd1024 100644 --- a/src/aleph/sdk/conf.py +++ b/src/aleph/sdk/conf.py @@ -302,7 +302,7 @@ class MainConfiguration(BaseModel): chain: Chain address: Optional[str] = None - model_config = SettingsConfigDict(use_enum_values=True) + # model_config = SettingsConfigDict(use_enum_values=True) # Settings singleton From 33512f0af2d8f9818c33d0b65573ddf0a83021da Mon Sep 17 00:00:00 2001 From: "Andres D. Molins" Date: Wed, 10 Sep 2025 16:16:01 +0200 Subject: [PATCH 06/26] Fix: Solve enum values issue again. --- src/aleph/sdk/account.py | 2 +- src/aleph/sdk/conf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/aleph/sdk/account.py b/src/aleph/sdk/account.py index 068da572..eb6f4092 100644 --- a/src/aleph/sdk/account.py +++ b/src/aleph/sdk/account.py @@ -143,7 +143,7 @@ def _load_account( # ) # ) # New Ledger Implementation - elif config and config.address and config.type == AccountType.EXTERNAL.value: + elif config and config.address and config.type == AccountType.EXTERNAL: logger.debug("Using remote account") ledger_account = LedgerETHAccount.from_address(config.address) if ledger_account: diff --git a/src/aleph/sdk/conf.py b/src/aleph/sdk/conf.py index edfd1024..0923c7bd 100644 --- a/src/aleph/sdk/conf.py +++ b/src/aleph/sdk/conf.py @@ -338,7 +338,7 @@ class MainConfiguration(BaseModel): if "path" in config_data and ( "type" not in config_data - or config_data["type"] == AccountType.INTERNAL.value + or config_data["type"] == AccountType.INTERNAL ): settings.PRIVATE_KEY_FILE = Path(config_data["path"]) except json.JSONDecodeError: From 0ce04b8e15779cb526762a5bddd205c1c55c7785 Mon Sep 17 00:00:00 2001 From: "Andres D. Molins" Date: Wed, 10 Sep 2025 16:20:33 +0200 Subject: [PATCH 07/26] Fix: Specified enum type to serialize. --- src/aleph/sdk/conf.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/aleph/sdk/conf.py b/src/aleph/sdk/conf.py index 0923c7bd..288ffb11 100644 --- a/src/aleph/sdk/conf.py +++ b/src/aleph/sdk/conf.py @@ -287,7 +287,7 @@ class Settings(BaseSettings): ) -class AccountType(Enum): +class AccountType(str, Enum): INTERNAL: str = "internal" EXTERNAL: str = "external" @@ -337,8 +337,7 @@ class MainConfiguration(BaseModel): config_data = json.load(f) if "path" in config_data and ( - "type" not in config_data - or config_data["type"] == AccountType.INTERNAL + "type" not in config_data or config_data["type"] == AccountType.INTERNAL ): settings.PRIVATE_KEY_FILE = Path(config_data["path"]) except json.JSONDecodeError: From 9b8d8b67667101313399b5a47376390a137897d3 Mon Sep 17 00:00:00 2001 From: "Andres D. Molins" Date: Wed, 1 Oct 2025 13:02:16 +0200 Subject: [PATCH 08/26] Fix: Solved wrong signing address when a derivation_path is used. --- src/aleph/sdk/wallets/ledger/ethereum.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/aleph/sdk/wallets/ledger/ethereum.py b/src/aleph/sdk/wallets/ledger/ethereum.py index a2a2c7b8..8fe91759 100644 --- a/src/aleph/sdk/wallets/ledger/ethereum.py +++ b/src/aleph/sdk/wallets/ledger/ethereum.py @@ -84,9 +84,7 @@ async def sign_message(self, message: Dict) -> Dict: # TODO: Check why the code without a wallet uses `encode_defunct`. msghash: bytes = get_verification_buffer(message) - sig: SignedMessage = sign_message( - msghash, dongle=self._device, sender_path=self._account.path - ) + sig: SignedMessage = sign_message(msghash, dongle=self._device, sender_path=self._account.path) signature: HexStr = sig.signature @@ -95,9 +93,7 @@ async def sign_message(self, message: Dict) -> Dict: async def sign_raw(self, buffer: bytes) -> bytes: """Sign a raw buffer.""" - sig: SignedMessage = sign_message( - buffer, dongle=self._device, sender_path=self._account.path - ) + sig: SignedMessage = sign_message(buffer, dongle=self._device, sender_path=self._account.path) signature: HexStr = sig.signature return bytes_from_hex(signature) From 3315348507a9a3b50209529bea87abdef37b964a Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 31 Oct 2025 10:47:56 +0100 Subject: [PATCH 09/26] fix: linting issue --- src/aleph/sdk/wallets/ledger/ethereum.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/aleph/sdk/wallets/ledger/ethereum.py b/src/aleph/sdk/wallets/ledger/ethereum.py index 8fe91759..a2a2c7b8 100644 --- a/src/aleph/sdk/wallets/ledger/ethereum.py +++ b/src/aleph/sdk/wallets/ledger/ethereum.py @@ -84,7 +84,9 @@ async def sign_message(self, message: Dict) -> Dict: # TODO: Check why the code without a wallet uses `encode_defunct`. msghash: bytes = get_verification_buffer(message) - sig: SignedMessage = sign_message(msghash, dongle=self._device, sender_path=self._account.path) + sig: SignedMessage = sign_message( + msghash, dongle=self._device, sender_path=self._account.path + ) signature: HexStr = sig.signature @@ -93,7 +95,9 @@ async def sign_message(self, message: Dict) -> Dict: async def sign_raw(self, buffer: bytes) -> bytes: """Sign a raw buffer.""" - sig: SignedMessage = sign_message(buffer, dongle=self._device, sender_path=self._account.path) + sig: SignedMessage = sign_message( + buffer, dongle=self._device, sender_path=self._account.path + ) signature: HexStr = sig.signature return bytes_from_hex(signature) From 6167e1320d7cdd58da6be1d411e20f2e5934692b Mon Sep 17 00:00:00 2001 From: 1yam Date: Mon, 3 Nov 2025 13:10:14 +0100 Subject: [PATCH 10/26] fix: remove commented old code for ledger account loading --- src/aleph/sdk/account.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/aleph/sdk/account.py b/src/aleph/sdk/account.py index eb6f4092..b3741e04 100644 --- a/src/aleph/sdk/account.py +++ b/src/aleph/sdk/account.py @@ -132,17 +132,6 @@ def _load_account( # Loads private key from a file elif private_key_path and private_key_path.is_file(): return account_from_file(private_key_path, account_type, chain) - # For ledger keys - # elif settings.REMOTE_CRYPTO_HOST: - # logger.debug("Using remote account") - # loop = asyncio.get_event_loop() - # return loop.run_until_complete( - # RemoteAccount.from_crypto_host( - # host=settings.REMOTE_CRYPTO_HOST, - # unix_socket=settings.REMOTE_CRYPTO_UNIX_SOCKET, - # ) - # ) - # New Ledger Implementation elif config and config.address and config.type == AccountType.EXTERNAL: logger.debug("Using remote account") ledger_account = LedgerETHAccount.from_address(config.address) From db87e564884502a7bab8d3bd51f120948fa20889 Mon Sep 17 00:00:00 2001 From: 1yam Date: Mon, 3 Nov 2025 13:10:43 +0100 Subject: [PATCH 11/26] fix: `CHAIN` and `CURVE` on LedgerETHAccount aren't needed --- src/aleph/sdk/wallets/ledger/ethereum.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/aleph/sdk/wallets/ledger/ethereum.py b/src/aleph/sdk/wallets/ledger/ethereum.py index a2a2c7b8..911743e5 100644 --- a/src/aleph/sdk/wallets/ledger/ethereum.py +++ b/src/aleph/sdk/wallets/ledger/ethereum.py @@ -18,8 +18,6 @@ class LedgerETHAccount(ETHAccount): """Account using the Ethereum app on Ledger hardware wallets.""" - CHAIN = "ETH" - CURVE = "secp256k1" _account: LedgerAccount _device: Dongle From 3a37793780e440ab12c77f407c6d77ce612334e6 Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 4 Nov 2025 13:33:04 +0100 Subject: [PATCH 12/26] Fix: handle common error using ledger (ledgerError / OsError) --- src/aleph/sdk/account.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/aleph/sdk/account.py b/src/aleph/sdk/account.py index b3741e04..06822e1b 100644 --- a/src/aleph/sdk/account.py +++ b/src/aleph/sdk/account.py @@ -14,6 +14,7 @@ from aleph.sdk.evm_utils import get_chains_with_super_token from aleph.sdk.types import AccountFromPrivateKey from aleph.sdk.wallets.ledger import LedgerETHAccount +from ledgereth.exceptions import LedgerError logger = logging.getLogger(__name__) @@ -134,9 +135,16 @@ def _load_account( return account_from_file(private_key_path, account_type, chain) elif config and config.address and config.type == AccountType.EXTERNAL: logger.debug("Using remote account") - ledger_account = LedgerETHAccount.from_address(config.address) - if ledger_account: - return ledger_account + try: + ledger_account = LedgerETHAccount.from_address(config.address) + if ledger_account: + return ledger_account + except LedgerError as e: + logger.warning(f"Ledger Error : {e.message}") + raise e + except OSError as e: + logger.warning("Please ensure Udev rules are set to use Ledger") + raise e # Fallback: config.path if set, else generate a new private key new_private_key = get_fallback_private_key() From d6ad01a12026fde399b31e4ca191bf17f8711ade Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 4 Nov 2025 13:38:37 +0100 Subject: [PATCH 13/26] fix: linting issue --- src/aleph/sdk/account.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aleph/sdk/account.py b/src/aleph/sdk/account.py index 06822e1b..efcd96f0 100644 --- a/src/aleph/sdk/account.py +++ b/src/aleph/sdk/account.py @@ -3,6 +3,7 @@ from typing import Dict, Optional, Type, TypeVar, Union from aleph_message.models import Chain +from ledgereth.exceptions import LedgerError from aleph.sdk.chains.common import get_fallback_private_key from aleph.sdk.chains.ethereum import ETHAccount @@ -14,7 +15,6 @@ from aleph.sdk.evm_utils import get_chains_with_super_token from aleph.sdk.types import AccountFromPrivateKey from aleph.sdk.wallets.ledger import LedgerETHAccount -from ledgereth.exceptions import LedgerError logger = logging.getLogger(__name__) From b45932f033cb7ad8e71698a7daeeda1103f83671 Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 4 Nov 2025 13:51:32 +0100 Subject: [PATCH 14/26] fix: re enable use_enum_values for MainConfiguration --- src/aleph/sdk/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aleph/sdk/conf.py b/src/aleph/sdk/conf.py index 288ffb11..7268854b 100644 --- a/src/aleph/sdk/conf.py +++ b/src/aleph/sdk/conf.py @@ -302,7 +302,7 @@ class MainConfiguration(BaseModel): chain: Chain address: Optional[str] = None - # model_config = SettingsConfigDict(use_enum_values=True) + model_config = SettingsConfigDict(use_enum_values=True) # Settings singleton From c6521613743976e25fb9e56cdf1f71f8b1518a42 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 14:17:39 +0100 Subject: [PATCH 15/26] Refactor: AccountType have now imported / hardware, and new field / model validator to ensure retro compatibility --- src/aleph/sdk/conf.py | 65 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/src/aleph/sdk/conf.py b/src/aleph/sdk/conf.py index 7268854b..a86d83c6 100644 --- a/src/aleph/sdk/conf.py +++ b/src/aleph/sdk/conf.py @@ -8,7 +8,7 @@ from aleph_message.models import Chain from aleph_message.models.execution.environment import HypervisorType -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from aleph.sdk.types import ChainInfo @@ -288,8 +288,8 @@ class Settings(BaseSettings): class AccountType(str, Enum): - INTERNAL: str = "internal" - EXTERNAL: str = "external" + IMPORTED: str = "imported" + HARDWARE: str = "hardware" class MainConfiguration(BaseModel): @@ -298,12 +298,65 @@ class MainConfiguration(BaseModel): """ path: Optional[Path] = None - type: Optional[AccountType] = AccountType.INTERNAL + type: AccountType = AccountType.IMPORTED chain: Chain address: Optional[str] = None - model_config = SettingsConfigDict(use_enum_values=True) + @field_validator("type", mode="before") + def normalize_type(cls, v): + """Handle legacy 'internal'/'external' and accept both strings or enums.""" + if v is None: + return v + if isinstance(v, AccountType): + return v + v_str = str(v).lower().strip() + if v_str == "internal": + return AccountType.IMPORTED + elif v_str == "external": + return AccountType.HARDWARE + elif v_str in ("imported", "hardware"): + return AccountType(v_str) + raise ValueError(f"Unknown account type: {v}") + + @model_validator(mode="before") + def infer_type(cls, values: dict): + """ + Previously, the `type` field was optional to maintain backward compatibility + for users with older configurations (e.g., using a private key). + + We now enforce `type` as required, but still handle legacy cases where it may + be missing by inferring its value automatically. + + Inference logic: + - If `type` is explicitly set, it is left unchanged. + - If `type` is missing: + - If `path` is provided → assume `imported` + - If only `address` is provided → assume `hardware` (Ledger) + (This scenario should not normally occur, but is handled for safety.) + - If both `path` and `address` are present → trust `path` (imported) + """ + + t = values.get("type") + path = values.get("path") + address = values.get("address") + + # If type already given , keep it + if t is not None: + return values + + # Infer if missing + if path: + values["type"] = AccountType.IMPORTED + elif address: + values["type"] = AccountType.HARDWARE + else: + raise ValueError( + "Cannot infer account type: please provide 'type', or 'path' (imported), or 'address' (hardware)." + ) + + return values + # Settings singleton settings = Settings() @@ -337,7 +390,7 @@ class MainConfiguration(BaseModel): config_data = json.load(f) if "path" in config_data and ( - "type" not in config_data or config_data["type"] == AccountType.INTERNAL + "type" not in config_data or config_data["type"] == AccountType.IMPORTED ): settings.PRIVATE_KEY_FILE = Path(config_data["path"]) except json.JSONDecodeError: From 36a8dd4ac5cecf7a691d929ceffb36761b92cb28 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 14:18:09 +0100 Subject: [PATCH 16/26] Feature: New HardwareAccount account protocol --- src/aleph/sdk/types.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/aleph/sdk/types.py b/src/aleph/sdk/types.py index b839a14b..b2e877ba 100644 --- a/src/aleph/sdk/types.py +++ b/src/aleph/sdk/types.py @@ -24,7 +24,13 @@ field_validator, ) -__all__ = ("StorageEnum", "Account", "AccountFromPrivateKey", "GenericMessage") +__all__ = ( + "StorageEnum", + "Account", + "AccountFromPrivateKey", + "HardwareAccount", + "GenericMessage", +) from aleph_message.models import AlephMessage, Chain @@ -64,6 +70,26 @@ def export_private_key(self) -> str: ... def switch_chain(self, chain: Optional[str] = None) -> None: ... +class HardwareAccount(Account, Protocol): + """Account using hardware wallet.""" + + @staticmethod + def from_address( + address: str, device: Optional[Any] = None + ) -> Optional["HardwareAccount"]: ... + + @staticmethod + def from_path(path: str, device: Optional[Any] = None) -> "HardwareAccount": ... + + def get_address(self) -> str: ... + + def switch_chain(self, chain: Optional[str] = None) -> None: ... + + async def sign_message(self, message: Dict) -> Dict: ... + + async def sign_raw(self, buffer: bytes) -> bytes: ... + + GenericMessage = TypeVar("GenericMessage", bound=AlephMessage) From 6d2a07b5dbee84957f20be7ed54d060caebd6a61 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 14:20:55 +0100 Subject: [PATCH 17/26] Refactor: Split logic from ETHAccount to BaseEthAccount, EthAccount is the Account using Private key --- src/aleph/sdk/chains/ethereum.py | 164 +++++++++++++++++-------------- 1 file changed, 91 insertions(+), 73 deletions(-) diff --git a/src/aleph/sdk/chains/ethereum.py b/src/aleph/sdk/chains/ethereum.py index 02bebd8f..9212b2b3 100644 --- a/src/aleph/sdk/chains/ethereum.py +++ b/src/aleph/sdk/chains/ethereum.py @@ -1,5 +1,6 @@ import asyncio import base64 +from abc import abstractmethod from decimal import Decimal from pathlib import Path from typing import Awaitable, Dict, Optional, Union @@ -36,65 +37,30 @@ from .common import BaseAccount, get_fallback_private_key, get_public_key -class ETHAccount(BaseAccount): - """Interact with an Ethereum address or key pair on EVM blockchains""" +class BaseEthAccount(BaseAccount): + """Base logic to interact with EVM blockchains""" CHAIN = "ETH" CURVE = "secp256k1" - _account: LocalAccount + _provider: Optional[Web3] chain: Optional[Chain] chain_id: Optional[int] rpc: Optional[str] superfluid_connector: Optional[Superfluid] - def __init__( - self, - private_key: bytes, - chain: Optional[Chain] = None, - ): - self.private_key = private_key - self._account: LocalAccount = Account.from_key(self.private_key) + def __init__(self, chain: Optional[Chain] = None): + self.chain = chain self.connect_chain(chain=chain) - @staticmethod - def from_mnemonic(mnemonic: str, chain: Optional[Chain] = None) -> "ETHAccount": - Account.enable_unaudited_hdwallet_features() - return ETHAccount( - private_key=Account.from_mnemonic(mnemonic=mnemonic).key, chain=chain - ) - - def export_private_key(self) -> str: - """Export the private key using standard format.""" - return f"0x{base64.b16encode(self.private_key).decode().lower()}" - - def get_address(self) -> str: - return self._account.address - - def get_public_key(self) -> str: - return "0x" + get_public_key(private_key=self._account.key).hex() - - async def sign_raw(self, buffer: bytes) -> bytes: - """Sign a raw buffer.""" - msghash = encode_defunct(text=buffer.decode("utf-8")) - sig = self._account.sign_message(msghash) - return sig["signature"] - - async def sign_message(self, message: Dict) -> Dict: + @abstractmethod + async def _sign_and_send_transaction(self, tx_params: TxParams) -> str: """ - Returns a signed message from an aleph.im message. - Args: - message: Message to sign - Returns: - Dict: Signed message + Sign and broadcast a transaction using the provided ETHAccount + @param tx_params - Transaction parameters + @returns - str - Transaction hash """ - signed_message = await super().sign_message(message) - - # Apply that fix as seems that sometimes the .hex() method doesn't add the 0x str at the beginning - if not str(signed_message["signature"]).startswith("0x"): - signed_message["signature"] = "0x" + signed_message["signature"] - - return signed_message + raise NotImplementedError def connect_chain(self, chain: Optional[Chain] = None): self.chain = chain @@ -150,35 +116,9 @@ def can_transact(self, tx: TxParams, block=True) -> bool: ) return valid - async def _sign_and_send_transaction(self, tx_params: TxParams) -> str: - """ - Sign and broadcast a transaction using the provided ETHAccount - @param tx_params - Transaction parameters - @returns - str - Transaction hash - """ - - def sign_and_send() -> TxReceipt: - if self._provider is None: - raise ValueError("Provider not connected") - signed_tx = self._provider.eth.account.sign_transaction( - tx_params, self._account.key - ) - - tx_hash = self._provider.eth.send_raw_transaction(signed_tx.raw_transaction) - tx_receipt = self._provider.eth.wait_for_transaction_receipt( - tx_hash, settings.TX_TIMEOUT - ) - return tx_receipt - - loop = asyncio.get_running_loop() - tx_receipt = await loop.run_in_executor(None, sign_and_send) - return tx_receipt["transactionHash"].hex() - def get_eth_balance(self) -> Decimal: return Decimal( - self._provider.eth.get_balance(self._account.address) - if self._provider - else 0 + self._provider.eth.get_balance(self.get_address()) if self._provider else 0 ) def get_token_balance(self) -> Decimal: @@ -247,6 +187,84 @@ def manage_flow( ) +class ETHAccount(BaseEthAccount): + """Interact with an Ethereum address or key pair on EVM blockchains""" + + _account: LocalAccount + + def __init__( + self, + private_key: bytes, + chain: Optional[Chain] = None, + ): + self.private_key = private_key + self._account = Account.from_key(self.private_key) + super().__init__(chain=chain) + + @staticmethod + def from_mnemonic(mnemonic: str, chain: Optional[Chain] = None) -> "ETHAccount": + Account.enable_unaudited_hdwallet_features() + return ETHAccount( + private_key=Account.from_mnemonic(mnemonic=mnemonic).key, chain=chain + ) + + def export_private_key(self) -> str: + """Export the private key using standard format.""" + return f"0x{base64.b16encode(self.private_key).decode().lower()}" + + def get_address(self) -> str: + return self._account.address + + def get_public_key(self) -> str: + return "0x" + get_public_key(private_key=self._account.key).hex() + + async def sign_raw(self, buffer: bytes) -> bytes: + """Sign a raw buffer.""" + msghash = encode_defunct(text=buffer.decode("utf-8")) + sig = self._account.sign_message(msghash) + return sig["signature"] + + async def sign_message(self, message: Dict) -> Dict: + """ + Returns a signed message from an aleph Cloud message. + Args: + message: Message to sign + Returns: + Dict: Signed message + """ + signed_message = await super().sign_message(message) + + # Apply that fix as seems that sometimes the .hex() method doesn't add the 0x str at the beginning + if not str(signed_message["signature"]).startswith("0x"): + signed_message["signature"] = "0x" + signed_message["signature"] + + return signed_message + + async def _sign_and_send_transaction(self, tx_params: TxParams) -> str: + """ + Sign and broadcast a transaction using the provided ETHAccount + @param tx_params - Transaction parameters + @returns - str - Transaction hash + """ + + def sign_and_send() -> TxReceipt: + if self._provider is None: + raise ValueError("Provider not connected") + signed_tx = self._provider.eth.account.sign_transaction( + tx_params, self._account.key + ) + + tx_hash = self._provider.eth.send_raw_transaction(signed_tx.raw_transaction) + tx_receipt = self._provider.eth.wait_for_transaction_receipt( + tx_hash, settings.TX_TIMEOUT + ) + return tx_receipt + + loop = asyncio.get_running_loop() + tx_receipt = await loop.run_in_executor(None, sign_and_send) + return tx_receipt["transactionHash"].hex() + + def get_fallback_account( path: Optional[Path] = None, chain: Optional[Chain] = None ) -> ETHAccount: From e723858d48c29cd9f7d72223f84dee7760b5ca74 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 14:21:44 +0100 Subject: [PATCH 18/26] Refactor: LedgerETHAccount use BaseEthAccount instead of ETHAccount --- src/aleph/sdk/wallets/ledger/ethereum.py | 60 ++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/src/aleph/sdk/wallets/ledger/ethereum.py b/src/aleph/sdk/wallets/ledger/ethereum.py index 911743e5..2f8c21d1 100644 --- a/src/aleph/sdk/wallets/ledger/ethereum.py +++ b/src/aleph/sdk/wallets/ledger/ethereum.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio +import logging from typing import Dict, List, Optional from aleph_message.models import Chain @@ -9,13 +11,17 @@ from ledgereth.comms import init_dongle from ledgereth.messages import sign_message from ledgereth.objects import LedgerAccount, SignedMessage +from ledgereth.transactions import sign_transaction +from web3.types import TxReceipt from ...chains.common import get_verification_buffer -from ...chains.ethereum import ETHAccount +from ...chains.ethereum import BaseEthAccount from ...utils import bytes_from_hex +logger = logging.getLogger(__name__) -class LedgerETHAccount(ETHAccount): + +class LedgerETHAccount(BaseEthAccount): """Account using the Ethereum app on Ledger hardware wallets.""" _account: LedgerAccount @@ -30,9 +36,12 @@ def __init__( See the static methods `self.from_address(...)` and `self.from_path(...)` for an easier method of instantiation. """ + super().__init__(chain=None) + self._account = account self._device = device - self.connect_chain(chain=chain) + if chain: + self.connect_chain(chain=chain) @staticmethod def get_accounts( @@ -82,6 +91,9 @@ async def sign_message(self, message: Dict) -> Dict: # TODO: Check why the code without a wallet uses `encode_defunct`. msghash: bytes = get_verification_buffer(message) + logger.warning( + "Please Sign messages using ledger" + ) # allow to propagate it to cli sig: SignedMessage = sign_message( msghash, dongle=self._device, sender_path=self._account.path ) @@ -93,12 +105,54 @@ async def sign_message(self, message: Dict) -> Dict: async def sign_raw(self, buffer: bytes) -> bytes: """Sign a raw buffer.""" + logger.warning( + "Please Sign messages using ledger" + ) # allow to propagate it to cli sig: SignedMessage = sign_message( buffer, dongle=self._device, sender_path=self._account.path ) signature: HexStr = sig.signature return bytes_from_hex(signature) + async def _sign_and_send_transaction(self, tx_params: dict) -> str: + """ + Sign and broadcast a transaction using the Ledger hardware wallet. + Equivalent of the software _sign_and_send_transaction(). + + @param tx_params: dict - Transaction parameters + @returns: str - Transaction hash + """ + if self._provider is None: + raise ValueError("Provider not connected") + + def sign_and_send() -> TxReceipt: + logger.warning( + "Please Sign messages using ledger" + ) # allow to propagate it to cli + signed_tx = sign_transaction( + tx=tx_params, + sender_path=self._account.path, + dongle=self._device, + ) + + provider = self._provider + if provider is None: + raise ValueError("Provider not connected") + + tx_hash = provider.eth.send_raw_transaction(bytes_from_hex(signed_tx)) + + tx_receipt = provider.eth.wait_for_transaction_receipt( + tx_hash, + timeout=getattr(self, "TX_TIMEOUT", 120), # optional custom timeout + ) + + return tx_receipt + + loop = asyncio.get_running_loop() + tx_receipt = await loop.run_in_executor(None, sign_and_send) + + return tx_receipt["transactionHash"].hex() + def get_address(self) -> str: return self._account.address From a6bfe1074897a8e7d574ccd1b1270b32fbce6a99 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 14:25:20 +0100 Subject: [PATCH 19/26] Refactor: superfluid connectors to be compatible either with EthAccount and LedgerEthAccount --- src/aleph/sdk/connectors/superfluid.py | 64 ++++++++++++++++------- tests/unit/test_gas_estimation.py | 70 +++++++++++++++----------- 2 files changed, 89 insertions(+), 45 deletions(-) diff --git a/src/aleph/sdk/connectors/superfluid.py b/src/aleph/sdk/connectors/superfluid.py index cd971b74..27df30a7 100644 --- a/src/aleph/sdk/connectors/superfluid.py +++ b/src/aleph/sdk/connectors/superfluid.py @@ -3,8 +3,8 @@ from decimal import Decimal from typing import TYPE_CHECKING, Optional -from eth_utils import to_normalized_address from superfluid import CFA_V1, Operation, Web3FlowInfo +from web3 import Web3 from web3.exceptions import ContractCustomError from aleph.sdk.evm_utils import ( @@ -17,7 +17,7 @@ from aleph.sdk.types import TokenType if TYPE_CHECKING: - from aleph.sdk.chains.ethereum import ETHAccount + from aleph.sdk.chains.ethereum import BaseEthAccount class Superfluid: @@ -25,32 +25,62 @@ class Superfluid: Wrapper around the Superfluid APIs in order to CRUD Superfluid flows between two accounts. """ - account: ETHAccount + account: BaseEthAccount normalized_address: str super_token: str cfaV1Instance: CFA_V1 MIN_4_HOURS = 60 * 60 * 4 - def __init__(self, account: ETHAccount): + def __init__(self, account: BaseEthAccount): self.account = account - self.normalized_address = to_normalized_address(account.get_address()) + self.normalized_address = Web3.to_checksum_address(account.get_address()) if account.chain: self.super_token = str(get_super_token_address(account.chain)) self.cfaV1Instance = CFA_V1(account.rpc, account.chain_id) + # Helpers Functions + def _get_populated_transaction_request(self, operation, rpc: Optional[str]): + """ + Prepares the transaction to be signed by either imported / hardware wallets + @param operation - on chain operations + @param rpc - RPC URL + @param address - address from Ledger account + @returns - TxParams - The transaction object + """ + call = ( + operation.forwarder_call + if operation.forwarder_call + else operation.agreement_call + ) + populated_transaction = call.build_transaction( + {"from": self.normalized_address} + ) + + if rpc is None: + raise ValueError("RPC URL cannot be None") + + web3 = Web3(Web3.HTTPProvider(rpc)) + nonce = web3.eth.get_transaction_count(self.normalized_address) + + populated_transaction["nonce"] = nonce + populated_transaction["chainId"] = web3.eth.chain_id + populated_transaction["gasPrice"] = web3.eth.gas_price + + return populated_transaction + def _simulate_create_tx_flow(self, flow: Decimal, block=True) -> bool: try: operation = self.cfaV1Instance.create_flow( sender=self.normalized_address, - receiver=to_normalized_address( + receiver=Web3.to_checksum_address( "0x0000000000000000000000000000000000000001" ), # Fake Address we do not sign/send this transactions super_token=self.super_token, flow_rate=int(to_wei_token(flow)), ) - - populated_transaction = operation._get_populated_transaction_request( - self.account.rpc, self.account._account.key + populated_transaction = self._get_populated_transaction_request( + operation=operation, + rpc=self.account.rpc, ) return self.account.can_transact(tx=populated_transaction, block=block) except ContractCustomError as e: @@ -66,12 +96,12 @@ def _simulate_create_tx_flow(self, flow: Decimal, block=True) -> bool: async def _execute_operation_with_account(self, operation: Operation) -> str: """ - Execute an operation using the provided ETHAccount + Execute an operation using the provided account @param operation - Operation instance from the library @returns - str - Transaction hash """ - populated_transaction = operation._get_populated_transaction_request( - self.account.rpc, self.account._account.key + populated_transaction = self._get_populated_transaction_request( + operation=operation, rpc=self.account.rpc ) self.account.can_transact(tx=populated_transaction) @@ -86,7 +116,7 @@ async def create_flow(self, receiver: str, flow: Decimal) -> str: return await self._execute_operation_with_account( operation=self.cfaV1Instance.create_flow( sender=self.normalized_address, - receiver=to_normalized_address(receiver), + receiver=Web3.to_checksum_address(receiver), super_token=self.super_token, flow_rate=int(to_wei_token(flow)), ), @@ -95,8 +125,8 @@ async def create_flow(self, receiver: str, flow: Decimal) -> str: async def get_flow(self, sender: str, receiver: str) -> Web3FlowInfo: """Fetch information about the Superfluid flow between two addresses.""" return self.cfaV1Instance.get_flow( - sender=to_normalized_address(sender), - receiver=to_normalized_address(receiver), + sender=Web3.to_checksum_address(sender), + receiver=Web3.to_checksum_address(receiver), super_token=self.super_token, ) @@ -105,7 +135,7 @@ async def delete_flow(self, receiver: str) -> str: return await self._execute_operation_with_account( operation=self.cfaV1Instance.delete_flow( sender=self.normalized_address, - receiver=to_normalized_address(receiver), + receiver=Web3.to_checksum_address(receiver), super_token=self.super_token, ), ) @@ -115,7 +145,7 @@ async def update_flow(self, receiver: str, flow: Decimal) -> str: return await self._execute_operation_with_account( operation=self.cfaV1Instance.update_flow( sender=self.normalized_address, - receiver=to_normalized_address(receiver), + receiver=Web3.to_checksum_address(receiver), super_token=self.super_token, flow_rate=int(to_wei_token(flow)), ), diff --git a/tests/unit/test_gas_estimation.py b/tests/unit/test_gas_estimation.py index abbd8c5c..7db391ad 100644 --- a/tests/unit/test_gas_estimation.py +++ b/tests/unit/test_gas_estimation.py @@ -36,8 +36,8 @@ def mock_superfluid(mock_eth_account): superfluid = Superfluid(mock_eth_account) superfluid.cfaV1Instance = MagicMock() superfluid.cfaV1Instance.create_flow = MagicMock() - superfluid.super_token = "0xsupertokenaddress" - superfluid.normalized_address = "0xsenderaddress" + superfluid.super_token = "0x0000000000000000000000000000000000000000" + superfluid.normalized_address = "0x0000000000000000000000000000000000000000" # Mock the operation operation = MagicMock() @@ -109,16 +109,20 @@ class TestSuperfluidFlowEstimation: async def test_simulate_create_tx_flow_success( self, mock_superfluid, mock_eth_account ): - # Patch the can_transact method to simulate a successful transaction - with patch.object(mock_eth_account, "can_transact", return_value=True): - result = mock_superfluid._simulate_create_tx_flow(Decimal("0.00000005")) - assert result is True - - # Verify the flow was correctly simulated but not executed - mock_superfluid.cfaV1Instance.create_flow.assert_called_once() - assert "0x0000000000000000000000000000000000000001" in str( - mock_superfluid.cfaV1Instance.create_flow.call_args - ) + # Patch both the _get_populated_transaction_request and can_transact methods + mock_tx = {"value": 0, "gas": 100000, "gasPrice": 20_000_000_000} + with patch.object( + mock_superfluid, "_get_populated_transaction_request", return_value=mock_tx + ): + with patch.object(mock_eth_account, "can_transact", return_value=True): + result = mock_superfluid._simulate_create_tx_flow(Decimal("0.00000005")) + assert result is True + + # Verify the flow was correctly simulated but not executed + mock_superfluid.cfaV1Instance.create_flow.assert_called_once() + assert "0x0000000000000000000000000000000000000001" in str( + mock_superfluid.cfaV1Instance.create_flow.call_args + ) @pytest.mark.asyncio async def test_simulate_create_tx_flow_contract_error( @@ -128,17 +132,22 @@ async def test_simulate_create_tx_flow_contract_error( error = ContractCustomError("Insufficient deposit") error.data = "0xea76c9b3" # This is the specific error code checked in the code - # Mock can_transact to throw the error - with patch.object(mock_eth_account, "can_transact", side_effect=error): - # Also mock get_super_token_balance for the error case - with patch.object( - mock_eth_account, "get_super_token_balance", return_value=0 - ): - # Should raise InsufficientFundsError for ALEPH token - with pytest.raises(InsufficientFundsError) as exc_info: - mock_superfluid._simulate_create_tx_flow(Decimal("0.00000005")) - - assert exc_info.value.token_type == TokenType.ALEPH + # Mock _get_populated_transaction_request and can_transact + mock_tx = {"value": 0, "gas": 100000, "gasPrice": 20_000_000_000} + with patch.object( + mock_superfluid, "_get_populated_transaction_request", return_value=mock_tx + ): + # Mock can_transact to throw the error + with patch.object(mock_eth_account, "can_transact", side_effect=error): + # Also mock get_super_token_balance for the error case + with patch.object( + mock_eth_account, "get_super_token_balance", return_value=0 + ): + # Should raise InsufficientFundsError for ALEPH token + with pytest.raises(InsufficientFundsError) as exc_info: + mock_superfluid._simulate_create_tx_flow(Decimal("0.00000005")) + + assert exc_info.value.token_type == TokenType.ALEPH @pytest.mark.asyncio async def test_simulate_create_tx_flow_other_error( @@ -148,11 +157,16 @@ async def test_simulate_create_tx_flow_other_error( error = ContractCustomError("Other error") error.data = "0xsomeothercode" - # Mock can_transact to throw the error - with patch.object(mock_eth_account, "can_transact", side_effect=error): - # Should return False for other errors - result = mock_superfluid._simulate_create_tx_flow(Decimal("0.00000005")) - assert result is False + # Mock _get_populated_transaction_request and can_transact + mock_tx = {"value": 0, "gas": 100000, "gasPrice": 20_000_000_000} + with patch.object( + mock_superfluid, "_get_populated_transaction_request", return_value=mock_tx + ): + # Mock can_transact to throw the error + with patch.object(mock_eth_account, "can_transact", side_effect=error): + # Should return False for other errors + result = mock_superfluid._simulate_create_tx_flow(Decimal("0.00000005")) + assert result is False @pytest.mark.asyncio async def test_can_start_flow_uses_simulation(self, mock_superfluid): From ef82a59cf087bcf93926e27c860f017cfb7e562d Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 14:26:14 +0100 Subject: [PATCH 20/26] Refactor: account.py to be able to handle more Account type than AccountFromPrivateKey --- src/aleph/sdk/account.py | 79 ++++++++++++++++++++++++++++++++++------ 1 file changed, 67 insertions(+), 12 deletions(-) diff --git a/src/aleph/sdk/account.py b/src/aleph/sdk/account.py index efcd96f0..fdf962df 100644 --- a/src/aleph/sdk/account.py +++ b/src/aleph/sdk/account.py @@ -1,9 +1,10 @@ import logging from pathlib import Path -from typing import Dict, Optional, Type, TypeVar, Union +from typing import Dict, Literal, Optional, Type, TypeVar, Union, overload from aleph_message.models import Chain from ledgereth.exceptions import LedgerError +from typing_extensions import TypeAlias from aleph.sdk.chains.common import get_fallback_private_key from aleph.sdk.chains.ethereum import ETHAccount @@ -13,12 +14,13 @@ from aleph.sdk.chains.svm import SVMAccount from aleph.sdk.conf import AccountType, load_main_configuration, settings from aleph.sdk.evm_utils import get_chains_with_super_token -from aleph.sdk.types import AccountFromPrivateKey +from aleph.sdk.types import AccountFromPrivateKey, HardwareAccount from aleph.sdk.wallets.ledger import LedgerETHAccount logger = logging.getLogger(__name__) T = TypeVar("T", bound=AccountFromPrivateKey) +AccountLike: TypeAlias = Union["AccountFromPrivateKey", "HardwareAccount"] chain_account_map: Dict[Chain, Type[T]] = { # type: ignore Chain.ARBITRUM: EVMAccount, @@ -56,7 +58,7 @@ def load_chain_account_type(chain: Chain) -> Type[AccountFromPrivateKey]: def account_from_hex_string( private_key_str: str, - account_type: Optional[Type[T]], + account_type: Optional[Type[AccountFromPrivateKey]], chain: Optional[Chain] = None, ) -> AccountFromPrivateKey: if private_key_str.startswith("0x"): @@ -78,7 +80,7 @@ def account_from_hex_string( def account_from_file( private_key_path: Path, - account_type: Optional[Type[T]], + account_type: Optional[Type[AccountFromPrivateKey]], chain: Optional[Chain] = None, ) -> AccountFromPrivateKey: private_key = private_key_path.read_bytes() @@ -97,13 +99,60 @@ def account_from_file( return account +@overload +def _load_account( + private_key_str: str, + private_key_path: None = None, + account_type: Type[AccountFromPrivateKey] = ..., + chain: Optional[Chain] = None, +) -> AccountFromPrivateKey: ... + + +@overload +def _load_account( + private_key_str: Literal[None], + private_key_path: Path, + account_type: Type[AccountFromPrivateKey] = ..., + chain: Optional[Chain] = None, +) -> AccountFromPrivateKey: ... + + +@overload +def _load_account( + private_key_str: Literal[None], + private_key_path: Literal[None], + account_type: Type[HardwareAccount], + chain: Optional[Chain] = None, +) -> HardwareAccount: ... + + +@overload def _load_account( private_key_str: Optional[str] = None, private_key_path: Optional[Path] = None, - account_type: Optional[Type[AccountFromPrivateKey]] = None, + account_type: Optional[Type[AccountLike]] = None, chain: Optional[Chain] = None, -) -> Union[AccountFromPrivateKey, LedgerETHAccount]: - """Load an account from a private key string or file, or from the configuration file.""" +) -> AccountLike: ... + + +def _load_account( + private_key_str: Optional[str] = None, + private_key_path: Optional[Path] = None, + account_type: Optional[Type[AccountLike]] = None, + chain: Optional[Chain] = None, +) -> AccountLike: + """Load an account from a private key string or file, or from the configuration file. + + This function can return different types of accounts based on the input: + - AccountFromPrivateKey: When a private key is provided (string or file) + - HardwareAccount: When config has AccountType.HARDWARE and a Ledger device is connected + + The function will attempt to load an account in the following order: + 1. From provided private key string + 2. From provided private key file + 3. From Ledger device (if config.type is HARDWARE) + 4. Generate a fallback private key + """ config = load_main_configuration(settings.CONFIG_FILE) default_chain = settings.DEFAULT_CHAIN @@ -129,15 +178,21 @@ def _load_account( # Loads private key from a string if private_key_str: - return account_from_hex_string(private_key_str, account_type, chain) + return account_from_hex_string(private_key_str, None, chain) + # Loads private key from a file elif private_key_path and private_key_path.is_file(): - return account_from_file(private_key_path, account_type, chain) - elif config and config.address and config.type == AccountType.EXTERNAL: - logger.debug("Using remote account") + return account_from_file(private_key_path, account_type, chain) # type: ignore + elif config and config.address and config.type == AccountType.HARDWARE: + logger.debug("Using ledger account") try: ledger_account = LedgerETHAccount.from_address(config.address) if ledger_account: + # Connect provider to the chain + # Only valid for EVM chain sign we sign TX using device + # and then use Superfluid logic to publish it to BASE / AVAX + if chain: + ledger_account.connect_chain(chain) return ledger_account except LedgerError as e: logger.warning(f"Ledger Error : {e.message}") @@ -148,6 +203,6 @@ def _load_account( # Fallback: config.path if set, else generate a new private key new_private_key = get_fallback_private_key() - account = account_from_hex_string(bytes.hex(new_private_key), account_type, chain) + account = account_from_hex_string(bytes.hex(new_private_key), None, chain) logger.info(f"Generated fallback private key with address {account.get_address()}") return account From 2bc3e9fdca8250baa463f491edf6050a09c5c004 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 14:37:27 +0100 Subject: [PATCH 21/26] Fix: make Account Protocol runtime-checkable to differentiate between protocols --- src/aleph/sdk/types.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/aleph/sdk/types.py b/src/aleph/sdk/types.py index b2e877ba..a87198b1 100644 --- a/src/aleph/sdk/types.py +++ b/src/aleph/sdk/types.py @@ -23,6 +23,7 @@ TypeAdapter, field_validator, ) +from typing_extensions import runtime_checkable __all__ = ( "StorageEnum", @@ -41,6 +42,7 @@ class StorageEnum(str, Enum): # Use a protocol to avoid importing crypto libraries +@runtime_checkable class Account(Protocol): CHAIN: str CURVE: str From f08f1c2b5a5c9f12ee7a480b88bfb7a347009a16 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 16:57:34 +0100 Subject: [PATCH 22/26] fix: rename AccountLike to AccountTypes --- src/aleph/sdk/account.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/aleph/sdk/account.py b/src/aleph/sdk/account.py index fdf962df..cc2a6959 100644 --- a/src/aleph/sdk/account.py +++ b/src/aleph/sdk/account.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) T = TypeVar("T", bound=AccountFromPrivateKey) -AccountLike: TypeAlias = Union["AccountFromPrivateKey", "HardwareAccount"] +AccountTypes: TypeAlias = Union["AccountFromPrivateKey", "HardwareAccount"] chain_account_map: Dict[Chain, Type[T]] = { # type: ignore Chain.ARBITRUM: EVMAccount, @@ -130,17 +130,17 @@ def _load_account( def _load_account( private_key_str: Optional[str] = None, private_key_path: Optional[Path] = None, - account_type: Optional[Type[AccountLike]] = None, + account_type: Optional[Type[AccountTypes]] = None, chain: Optional[Chain] = None, -) -> AccountLike: ... +) -> AccountTypes: ... def _load_account( private_key_str: Optional[str] = None, private_key_path: Optional[Path] = None, - account_type: Optional[Type[AccountLike]] = None, + account_type: Optional[Type[AccountTypes]] = None, chain: Optional[Chain] = None, -) -> AccountLike: +) -> AccountTypes: """Load an account from a private key string or file, or from the configuration file. This function can return different types of accounts based on the input: From ec0039dd6bc67823f007d81b975ae6f0789e1efc Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 16:57:57 +0100 Subject: [PATCH 23/26] fix: ensure provider is set for get_eth_balance --- src/aleph/sdk/chains/ethereum.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/aleph/sdk/chains/ethereum.py b/src/aleph/sdk/chains/ethereum.py index 9212b2b3..22601897 100644 --- a/src/aleph/sdk/chains/ethereum.py +++ b/src/aleph/sdk/chains/ethereum.py @@ -117,9 +117,12 @@ def can_transact(self, tx: TxParams, block=True) -> bool: return valid def get_eth_balance(self) -> Decimal: - return Decimal( - self._provider.eth.get_balance(self.get_address()) if self._provider else 0 - ) + if not self._provider: + raise ValueError( + "Provider not set. Please configure a provider before checking balance." + ) + + return Decimal(self._provider.eth.get_balance(self.get_address())) def get_token_balance(self) -> Decimal: if self.chain and self._provider: From 526962ed00eea8b56084ef2434c0298d6967c41d Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 16:58:51 +0100 Subject: [PATCH 24/26] fix: on superfluid.py force rpc to be present or raise ValueError --- src/aleph/sdk/connectors/superfluid.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/aleph/sdk/connectors/superfluid.py b/src/aleph/sdk/connectors/superfluid.py index 27df30a7..94ec957c 100644 --- a/src/aleph/sdk/connectors/superfluid.py +++ b/src/aleph/sdk/connectors/superfluid.py @@ -39,7 +39,7 @@ def __init__(self, account: BaseEthAccount): self.cfaV1Instance = CFA_V1(account.rpc, account.chain_id) # Helpers Functions - def _get_populated_transaction_request(self, operation, rpc: Optional[str]): + def _get_populated_transaction_request(self, operation, rpc: str): """ Prepares the transaction to be signed by either imported / hardware wallets @param operation - on chain operations @@ -56,9 +56,6 @@ def _get_populated_transaction_request(self, operation, rpc: Optional[str]): {"from": self.normalized_address} ) - if rpc is None: - raise ValueError("RPC URL cannot be None") - web3 = Web3(Web3.HTTPProvider(rpc)) nonce = web3.eth.get_transaction_count(self.normalized_address) @@ -78,6 +75,10 @@ def _simulate_create_tx_flow(self, flow: Decimal, block=True) -> bool: super_token=self.super_token, flow_rate=int(to_wei_token(flow)), ) + if not self.account.rpc: + raise ValueError( + f"RPC endpoint is required but not set for this chain {self.account.chain}." + ) populated_transaction = self._get_populated_transaction_request( operation=operation, rpc=self.account.rpc, @@ -100,6 +101,11 @@ async def _execute_operation_with_account(self, operation: Operation) -> str: @param operation - Operation instance from the library @returns - str - Transaction hash """ + if not self.account.rpc: + raise ValueError( + f"RPC endpoint is required but not set for this chain {self.account.chain}." + ) + populated_transaction = self._get_populated_transaction_request( operation=operation, rpc=self.account.rpc ) From 5cf37ff33fecfc81f60b6f1bf773b7d696ddcaa6 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 16:59:24 +0100 Subject: [PATCH 25/26] fix: allow AccountFromPrivateKey and HardwareAccount to be checkable on runtime --- src/aleph/sdk/types.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/aleph/sdk/types.py b/src/aleph/sdk/types.py index a87198b1..8d952b18 100644 --- a/src/aleph/sdk/types.py +++ b/src/aleph/sdk/types.py @@ -60,6 +60,7 @@ def get_address(self) -> str: ... def get_public_key(self) -> str: ... +@runtime_checkable class AccountFromPrivateKey(Account, Protocol): """Only accounts that are initialized from a private key string are supported.""" @@ -72,6 +73,7 @@ def export_private_key(self) -> str: ... def switch_chain(self, chain: Optional[str] = None) -> None: ... +@runtime_checkable class HardwareAccount(Account, Protocol): """Account using hardware wallet.""" From bc04c00c32748b20052b9101a6610db8fdbc9adb Mon Sep 17 00:00:00 2001 From: 1yam <40899431+1yam@users.noreply.github.com> Date: Fri, 7 Nov 2025 18:12:00 +0100 Subject: [PATCH 26/26] Update src/aleph/sdk/wallets/ledger/ethereum.py Co-authored-by: Olivier Desenfans --- src/aleph/sdk/wallets/ledger/ethereum.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aleph/sdk/wallets/ledger/ethereum.py b/src/aleph/sdk/wallets/ledger/ethereum.py index 2f8c21d1..b9b2c797 100644 --- a/src/aleph/sdk/wallets/ledger/ethereum.py +++ b/src/aleph/sdk/wallets/ledger/ethereum.py @@ -106,7 +106,7 @@ async def sign_message(self, message: Dict) -> Dict: async def sign_raw(self, buffer: bytes) -> bytes: """Sign a raw buffer.""" logger.warning( - "Please Sign messages using ledger" + "Please sign the message on your Ledger device" ) # allow to propagate it to cli sig: SignedMessage = sign_message( buffer, dongle=self._device, sender_path=self._account.path