Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f099f66
Problem: Ledger wallet users cannot use Aleph to send transactions.
Sep 10, 2025
5f0c7b5
Fix: Solved linting and types issues for code quality.
Sep 10, 2025
3ef428d
Fix: Solved issue calling Ledger for supervisor.
Sep 10, 2025
7be46a8
Fix: Try to not pass the private_key bytes to not sign automatically …
Sep 10, 2025
ebf0b38
Fix: Solve enum values issue.
Sep 10, 2025
33512f0
Fix: Solve enum values issue again.
Sep 10, 2025
0ce04b8
Fix: Specified enum type to serialize.
Sep 10, 2025
9b8d8b6
Fix: Solved wrong signing address when a derivation_path is used.
nesitor Oct 1, 2025
3315348
fix: linting issue
1yam Oct 31, 2025
6167e13
fix: remove commented old code for ledger account loading
1yam Nov 3, 2025
db87e56
fix: `CHAIN` and `CURVE` on LedgerETHAccount aren't needed
1yam Nov 3, 2025
3a37793
Fix: handle common error using ledger (ledgerError / OsError)
1yam Nov 4, 2025
d6ad01a
fix: linting issue
1yam Nov 4, 2025
b45932f
fix: re enable use_enum_values for MainConfiguration
1yam Nov 4, 2025
c652161
Refactor: AccountType have now imported / hardware, and new field / m…
1yam Nov 7, 2025
36a8dd4
Feature: New HardwareAccount account protocol
1yam Nov 7, 2025
6d2a07b
Refactor: Split logic from ETHAccount to BaseEthAccount,
1yam Nov 7, 2025
e723858
Refactor: LedgerETHAccount use BaseEthAccount instead of ETHAccount
1yam Nov 7, 2025
a6bfe10
Refactor: superfluid connectors to be compatible either with EthAccou…
1yam Nov 7, 2025
ef82a59
Refactor: account.py to be able to handle more Account type than Acco…
1yam Nov 7, 2025
2bc3e9f
Fix: make Account Protocol runtime-checkable to differentiate between…
1yam Nov 7, 2025
f08f1c2
fix: rename AccountLike to AccountTypes
1yam Nov 7, 2025
ec0039d
fix: ensure provider is set for get_eth_balance
1yam Nov 7, 2025
526962e
fix: on superfluid.py force rpc to be present or raise ValueError
1yam Nov 7, 2025
5cf37ff
fix: allow AccountFromPrivateKey and HardwareAccount to be checkable …
1yam Nov 7, 2025
bc04c00
Update src/aleph/sdk/wallets/ledger/ethereum.py
1yam Nov 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ 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
Expand Down
115 changes: 84 additions & 31 deletions src/aleph/sdk/account.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import asyncio
import logging
from pathlib import Path
from typing import Dict, Optional, Type, TypeVar
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
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
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)
AccountTypes: TypeAlias = Union["AccountFromPrivateKey", "HardwareAccount"]
Copy link
Contributor

Choose a reason for hiding this comment

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

Nitpick: should be AccountType as it's either one or the other.

Copy link
Member

Choose a reason for hiding this comment

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

Well we have already an AccountType i put AccountTypes since it's 2 accounts Union to don't have 2 time AccountType


chain_account_map: Dict[Chain, Type[T]] = { # type: ignore
Chain.ARBITRUM: EVMAccount,
Expand Down Expand Up @@ -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"):
Expand All @@ -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()
Expand All @@ -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[AccountTypes]] = None,
chain: Optional[Chain] = None,
) -> AccountFromPrivateKey:
"""Load an account from a private key string or file, or from the configuration file."""
) -> AccountTypes: ...


def _load_account(
private_key_str: Optional[str] = None,
private_key_path: Optional[Path] = None,
account_type: Optional[Type[AccountTypes]] = None,
chain: Optional[Chain] = None,
) -> 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:
- 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
Expand All @@ -129,27 +178,31 @@ 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)
# 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,
)
)
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}")
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
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), None, chain)
logger.info(f"Generated fallback private key with address {account.get_address()}")
return account
169 changes: 95 additions & 74 deletions src/aleph/sdk/chains/ethereum.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -150,36 +116,13 @@ 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
def get_eth_balance(self) -> Decimal:
if not self._provider:
raise ValueError(
"Provider not set. Please configure a provider before checking balance."
)
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
)
return Decimal(self._provider.eth.get_balance(self.get_address()))

def get_token_balance(self) -> Decimal:
if self.chain and self._provider:
Expand Down Expand Up @@ -247,6 +190,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:
Expand Down
Loading
Loading