Skip to content

Commit b79a11c

Browse files
nesitorAndres D. Molins1yamodesenfans
authored
Implement Ledger (#231)
* Problem: Ledger wallet users cannot use Aleph to send transactions. Solution: Implement Ledger use on SDK to allow using them. * Fix: Solved linting and types issues for code quality. * Fix: Solved issue calling Ledger for supervisor. * Fix: Try to not pass the private_key bytes to not sign automatically the messages. * Fix: Solve enum values issue. * Fix: Solve enum values issue again. * Fix: Specified enum type to serialize. * Fix: Solved wrong signing address when a derivation_path is used. * fix: linting issue * fix: remove commented old code for ledger account loading * fix: `CHAIN` and `CURVE` on LedgerETHAccount aren't needed * Fix: handle common error using ledger (ledgerError / OsError) * fix: linting issue * fix: re enable use_enum_values for MainConfiguration * Refactor: AccountType have now imported / hardware, and new field / model validator to ensure retro compatibility * Feature: New HardwareAccount account protocol * Refactor: Split logic from ETHAccount to BaseEthAccount, EthAccount is the Account using Private key * Refactor: LedgerETHAccount use BaseEthAccount instead of ETHAccount * Refactor: superfluid connectors to be compatible either with EthAccount and LedgerEthAccount * Refactor: account.py to be able to handle more Account type than AccountFromPrivateKey * Fix: make Account Protocol runtime-checkable to differentiate between protocols * fix: rename AccountLike to AccountTypes * fix: ensure provider is set for get_eth_balance * fix: on superfluid.py force rpc to be present or raise ValueError * fix: allow AccountFromPrivateKey and HardwareAccount to be checkable on runtime * Update src/aleph/sdk/wallets/ledger/ethereum.py Co-authored-by: Olivier Desenfans <[email protected]> * Fix: use Type2Transaction for ledger * fix: chainId and gasprice in _get_populated_transaction_request isn't needed * Feature: allow user to setup derivation_path for ledger account * Fix: avoid storing path as "None" on account config * fix: linting issue --------- Co-authored-by: Andres D. Molins <[email protected]> Co-authored-by: 1yam <[email protected]> Co-authored-by: 1yam <[email protected]> Co-authored-by: Olivier Desenfans <[email protected]>
1 parent 3ade431 commit b79a11c

File tree

8 files changed

+469
-164
lines changed

8 files changed

+469
-164
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ dependencies = [
3838
"eth-abi>=5.0.1; python_version>='3.9'",
3939
"eth-typing>=5.0.1",
4040
"jwcrypto==1.5.6",
41+
"ledgerblue>=0.1.48",
42+
"ledgereth>=0.10",
4143
"pydantic>=2,<3",
4244
"pydantic-settings>=2",
4345
"pynacl==1.5", # Needed now as default with _load_account changement

src/aleph/sdk/account.py

Lines changed: 89 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,26 @@
1-
import asyncio
21
import logging
32
from pathlib import Path
4-
from typing import Dict, Optional, Type, TypeVar
3+
from typing import Dict, Literal, Optional, Type, TypeVar, Union, overload
54

65
from aleph_message.models import Chain
6+
from ledgereth.exceptions import LedgerError
7+
from typing_extensions import TypeAlias
78

89
from aleph.sdk.chains.common import get_fallback_private_key
910
from aleph.sdk.chains.ethereum import ETHAccount
1011
from aleph.sdk.chains.evm import EVMAccount
11-
from aleph.sdk.chains.remote import RemoteAccount
1212
from aleph.sdk.chains.solana import SOLAccount
1313
from aleph.sdk.chains.substrate import DOTAccount
1414
from aleph.sdk.chains.svm import SVMAccount
15-
from aleph.sdk.conf import load_main_configuration, settings
15+
from aleph.sdk.conf import AccountType, load_main_configuration, settings
1616
from aleph.sdk.evm_utils import get_chains_with_super_token
17-
from aleph.sdk.types import AccountFromPrivateKey
17+
from aleph.sdk.types import AccountFromPrivateKey, HardwareAccount
18+
from aleph.sdk.wallets.ledger import LedgerETHAccount
1819

1920
logger = logging.getLogger(__name__)
2021

2122
T = TypeVar("T", bound=AccountFromPrivateKey)
23+
AccountTypes: TypeAlias = Union["AccountFromPrivateKey", "HardwareAccount"]
2224

2325
chain_account_map: Dict[Chain, Type[T]] = { # type: ignore
2426
Chain.ARBITRUM: EVMAccount,
@@ -56,7 +58,7 @@ def load_chain_account_type(chain: Chain) -> Type[AccountFromPrivateKey]:
5658

5759
def account_from_hex_string(
5860
private_key_str: str,
59-
account_type: Optional[Type[T]],
61+
account_type: Optional[Type[AccountFromPrivateKey]],
6062
chain: Optional[Chain] = None,
6163
) -> AccountFromPrivateKey:
6264
if private_key_str.startswith("0x"):
@@ -78,7 +80,7 @@ def account_from_hex_string(
7880

7981
def account_from_file(
8082
private_key_path: Path,
81-
account_type: Optional[Type[T]],
83+
account_type: Optional[Type[AccountFromPrivateKey]],
8284
chain: Optional[Chain] = None,
8385
) -> AccountFromPrivateKey:
8486
private_key = private_key_path.read_bytes()
@@ -97,13 +99,60 @@ def account_from_file(
9799
return account
98100

99101

102+
@overload
103+
def _load_account(
104+
private_key_str: str,
105+
private_key_path: None = None,
106+
account_type: Type[AccountFromPrivateKey] = ...,
107+
chain: Optional[Chain] = None,
108+
) -> AccountFromPrivateKey: ...
109+
110+
111+
@overload
112+
def _load_account(
113+
private_key_str: Literal[None],
114+
private_key_path: Path,
115+
account_type: Type[AccountFromPrivateKey] = ...,
116+
chain: Optional[Chain] = None,
117+
) -> AccountFromPrivateKey: ...
118+
119+
120+
@overload
121+
def _load_account(
122+
private_key_str: Literal[None],
123+
private_key_path: Literal[None],
124+
account_type: Type[HardwareAccount],
125+
chain: Optional[Chain] = None,
126+
) -> HardwareAccount: ...
127+
128+
129+
@overload
100130
def _load_account(
101131
private_key_str: Optional[str] = None,
102132
private_key_path: Optional[Path] = None,
103-
account_type: Optional[Type[AccountFromPrivateKey]] = None,
133+
account_type: Optional[Type[AccountTypes]] = None,
104134
chain: Optional[Chain] = None,
105-
) -> AccountFromPrivateKey:
106-
"""Load an account from a private key string or file, or from the configuration file."""
135+
) -> AccountTypes: ...
136+
137+
138+
def _load_account(
139+
private_key_str: Optional[str] = None,
140+
private_key_path: Optional[Path] = None,
141+
account_type: Optional[Type[AccountTypes]] = None,
142+
chain: Optional[Chain] = None,
143+
) -> AccountTypes:
144+
"""Load an account from a private key string or file, or from the configuration file.
145+
146+
This function can return different types of accounts based on the input:
147+
- AccountFromPrivateKey: When a private key is provided (string or file)
148+
- HardwareAccount: When config has AccountType.HARDWARE and a Ledger device is connected
149+
150+
The function will attempt to load an account in the following order:
151+
1. From provided private key string
152+
2. From provided private key file
153+
3. From Ledger device (if config.type is HARDWARE)
154+
4. Generate a fallback private key
155+
"""
107156

108157
config = load_main_configuration(settings.CONFIG_FILE)
109158
default_chain = settings.DEFAULT_CHAIN
@@ -129,27 +178,36 @@ def _load_account(
129178

130179
# Loads private key from a string
131180
if private_key_str:
132-
return account_from_hex_string(private_key_str, account_type, chain)
181+
return account_from_hex_string(private_key_str, None, chain)
182+
133183
# Loads private key from a file
134184
elif private_key_path and private_key_path.is_file():
135-
return account_from_file(private_key_path, account_type, chain)
136-
# For ledger keys
137-
elif settings.REMOTE_CRYPTO_HOST:
138-
logger.debug("Using remote account")
139-
loop = asyncio.get_event_loop()
140-
return loop.run_until_complete(
141-
RemoteAccount.from_crypto_host(
142-
host=settings.REMOTE_CRYPTO_HOST,
143-
unix_socket=settings.REMOTE_CRYPTO_UNIX_SOCKET,
144-
)
145-
)
185+
return account_from_file(private_key_path, account_type, chain) # type: ignore
186+
elif config and config.address and config.type == AccountType.HARDWARE:
187+
logger.debug("Using ledger account")
188+
try:
189+
ledger_account = None
190+
if config.derivation_path:
191+
ledger_account = LedgerETHAccount.from_path(config.derivation_path)
192+
else:
193+
ledger_account = LedgerETHAccount.from_address(config.address)
194+
195+
if ledger_account:
196+
# Connect provider to the chain
197+
# Only valid for EVM chain sign we sign TX using device
198+
# and then use Superfluid logic to publish it to BASE / AVAX
199+
if chain:
200+
ledger_account.connect_chain(chain)
201+
return ledger_account
202+
except LedgerError as e:
203+
logger.warning(f"Ledger Error : {e.message}")
204+
raise e
205+
except OSError as e:
206+
logger.warning("Please ensure Udev rules are set to use Ledger")
207+
raise e
208+
146209
# Fallback: config.path if set, else generate a new private key
147-
else:
148-
new_private_key = get_fallback_private_key()
149-
account = account_from_hex_string(
150-
bytes.hex(new_private_key), account_type, chain
151-
)
152-
logger.info(
153-
f"Generated fallback private key with address {account.get_address()}"
154-
)
155-
return account
210+
new_private_key = get_fallback_private_key()
211+
account = account_from_hex_string(bytes.hex(new_private_key), None, chain)
212+
logger.info(f"Generated fallback private key with address {account.get_address()}")
213+
return account

src/aleph/sdk/chains/ethereum.py

Lines changed: 95 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import base64
3+
from abc import abstractmethod
34
from decimal import Decimal
45
from pathlib import Path
56
from typing import Awaitable, Dict, Optional, Union
@@ -36,65 +37,30 @@
3637
from .common import BaseAccount, get_fallback_private_key, get_public_key
3738

3839

39-
class ETHAccount(BaseAccount):
40-
"""Interact with an Ethereum address or key pair on EVM blockchains"""
40+
class BaseEthAccount(BaseAccount):
41+
"""Base logic to interact with EVM blockchains"""
4142

4243
CHAIN = "ETH"
4344
CURVE = "secp256k1"
44-
_account: LocalAccount
45+
4546
_provider: Optional[Web3]
4647
chain: Optional[Chain]
4748
chain_id: Optional[int]
4849
rpc: Optional[str]
4950
superfluid_connector: Optional[Superfluid]
5051

51-
def __init__(
52-
self,
53-
private_key: bytes,
54-
chain: Optional[Chain] = None,
55-
):
56-
self.private_key = private_key
57-
self._account: LocalAccount = Account.from_key(self.private_key)
52+
def __init__(self, chain: Optional[Chain] = None):
53+
self.chain = chain
5854
self.connect_chain(chain=chain)
5955

60-
@staticmethod
61-
def from_mnemonic(mnemonic: str, chain: Optional[Chain] = None) -> "ETHAccount":
62-
Account.enable_unaudited_hdwallet_features()
63-
return ETHAccount(
64-
private_key=Account.from_mnemonic(mnemonic=mnemonic).key, chain=chain
65-
)
66-
67-
def export_private_key(self) -> str:
68-
"""Export the private key using standard format."""
69-
return f"0x{base64.b16encode(self.private_key).decode().lower()}"
70-
71-
def get_address(self) -> str:
72-
return self._account.address
73-
74-
def get_public_key(self) -> str:
75-
return "0x" + get_public_key(private_key=self._account.key).hex()
76-
77-
async def sign_raw(self, buffer: bytes) -> bytes:
78-
"""Sign a raw buffer."""
79-
msghash = encode_defunct(text=buffer.decode("utf-8"))
80-
sig = self._account.sign_message(msghash)
81-
return sig["signature"]
82-
83-
async def sign_message(self, message: Dict) -> Dict:
56+
@abstractmethod
57+
async def _sign_and_send_transaction(self, tx_params: TxParams) -> str:
8458
"""
85-
Returns a signed message from an aleph.im message.
86-
Args:
87-
message: Message to sign
88-
Returns:
89-
Dict: Signed message
59+
Sign and broadcast a transaction using the provided ETHAccount
60+
@param tx_params - Transaction parameters
61+
@returns - str - Transaction hash
9062
"""
91-
signed_message = await super().sign_message(message)
92-
93-
# Apply that fix as seems that sometimes the .hex() method doesn't add the 0x str at the beginning
94-
if not str(signed_message["signature"]).startswith("0x"):
95-
signed_message["signature"] = "0x" + signed_message["signature"]
96-
97-
return signed_message
63+
raise NotImplementedError
9864

9965
def connect_chain(self, chain: Optional[Chain] = None):
10066
self.chain = chain
@@ -150,36 +116,13 @@ def can_transact(self, tx: TxParams, block=True) -> bool:
150116
)
151117
return valid
152118

153-
async def _sign_and_send_transaction(self, tx_params: TxParams) -> str:
154-
"""
155-
Sign and broadcast a transaction using the provided ETHAccount
156-
@param tx_params - Transaction parameters
157-
@returns - str - Transaction hash
158-
"""
159-
160-
def sign_and_send() -> TxReceipt:
161-
if self._provider is None:
162-
raise ValueError("Provider not connected")
163-
signed_tx = self._provider.eth.account.sign_transaction(
164-
tx_params, self._account.key
165-
)
166-
167-
tx_hash = self._provider.eth.send_raw_transaction(signed_tx.raw_transaction)
168-
tx_receipt = self._provider.eth.wait_for_transaction_receipt(
169-
tx_hash, settings.TX_TIMEOUT
119+
def get_eth_balance(self) -> Decimal:
120+
if not self._provider:
121+
raise ValueError(
122+
"Provider not set. Please configure a provider before checking balance."
170123
)
171-
return tx_receipt
172124

173-
loop = asyncio.get_running_loop()
174-
tx_receipt = await loop.run_in_executor(None, sign_and_send)
175-
return tx_receipt["transactionHash"].hex()
176-
177-
def get_eth_balance(self) -> Decimal:
178-
return Decimal(
179-
self._provider.eth.get_balance(self._account.address)
180-
if self._provider
181-
else 0
182-
)
125+
return Decimal(self._provider.eth.get_balance(self.get_address()))
183126

184127
def get_token_balance(self) -> Decimal:
185128
if self.chain and self._provider:
@@ -247,6 +190,84 @@ def manage_flow(
247190
)
248191

249192

193+
class ETHAccount(BaseEthAccount):
194+
"""Interact with an Ethereum address or key pair on EVM blockchains"""
195+
196+
_account: LocalAccount
197+
198+
def __init__(
199+
self,
200+
private_key: bytes,
201+
chain: Optional[Chain] = None,
202+
):
203+
self.private_key = private_key
204+
self._account = Account.from_key(self.private_key)
205+
super().__init__(chain=chain)
206+
207+
@staticmethod
208+
def from_mnemonic(mnemonic: str, chain: Optional[Chain] = None) -> "ETHAccount":
209+
Account.enable_unaudited_hdwallet_features()
210+
return ETHAccount(
211+
private_key=Account.from_mnemonic(mnemonic=mnemonic).key, chain=chain
212+
)
213+
214+
def export_private_key(self) -> str:
215+
"""Export the private key using standard format."""
216+
return f"0x{base64.b16encode(self.private_key).decode().lower()}"
217+
218+
def get_address(self) -> str:
219+
return self._account.address
220+
221+
def get_public_key(self) -> str:
222+
return "0x" + get_public_key(private_key=self._account.key).hex()
223+
224+
async def sign_raw(self, buffer: bytes) -> bytes:
225+
"""Sign a raw buffer."""
226+
msghash = encode_defunct(text=buffer.decode("utf-8"))
227+
sig = self._account.sign_message(msghash)
228+
return sig["signature"]
229+
230+
async def sign_message(self, message: Dict) -> Dict:
231+
"""
232+
Returns a signed message from an aleph Cloud message.
233+
Args:
234+
message: Message to sign
235+
Returns:
236+
Dict: Signed message
237+
"""
238+
signed_message = await super().sign_message(message)
239+
240+
# Apply that fix as seems that sometimes the .hex() method doesn't add the 0x str at the beginning
241+
if not str(signed_message["signature"]).startswith("0x"):
242+
signed_message["signature"] = "0x" + signed_message["signature"]
243+
244+
return signed_message
245+
246+
async def _sign_and_send_transaction(self, tx_params: TxParams) -> str:
247+
"""
248+
Sign and broadcast a transaction using the provided ETHAccount
249+
@param tx_params - Transaction parameters
250+
@returns - str - Transaction hash
251+
"""
252+
253+
def sign_and_send() -> TxReceipt:
254+
if self._provider is None:
255+
raise ValueError("Provider not connected")
256+
signed_tx = self._provider.eth.account.sign_transaction(
257+
tx_params, self._account.key
258+
)
259+
260+
tx_hash = self._provider.eth.send_raw_transaction(signed_tx.raw_transaction)
261+
tx_receipt = self._provider.eth.wait_for_transaction_receipt(
262+
tx_hash, settings.TX_TIMEOUT
263+
)
264+
return tx_receipt
265+
266+
loop = asyncio.get_running_loop()
267+
tx_receipt = await loop.run_in_executor(None, sign_and_send)
268+
return tx_receipt["transactionHash"].hex()
269+
270+
250271
def get_fallback_account(
251272
path: Optional[Path] = None, chain: Optional[Chain] = None
252273
) -> ETHAccount:

0 commit comments

Comments
 (0)