Skip to content

Commit 7e7f83a

Browse files
committed
Feature: Internal account management + fix on _load_account to handle SolAccount
1 parent cf70462 commit 7e7f83a

File tree

8 files changed

+551
-15
lines changed

8 files changed

+551
-15
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dependencies = [
3434
"aleph-superfluid>=0.2.1",
3535
"eth_typing==4.3.1",
3636
"web3==6.3.0",
37+
"aiofiles>=24.1.0",
3738
]
3839

3940
[project.optional-dependencies]

src/aleph/sdk/account.py

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,99 @@
11
import asyncio
2+
import json
23
import logging
34
from pathlib import Path
4-
from typing import Optional, Type, TypeVar
5+
from typing import Dict, List, Optional, Type, TypeVar, Union, overload
6+
7+
import base58
8+
from aleph_message.models import Chain
59

610
from aleph.sdk.chains.common import get_fallback_private_key
711
from aleph.sdk.chains.ethereum import ETHAccount
812
from aleph.sdk.chains.remote import RemoteAccount
13+
from aleph.sdk.chains.solana import SOLAccount
914
from aleph.sdk.conf import settings
1015
from aleph.sdk.types import AccountFromPrivateKey
16+
from aleph.sdk.utils import parse_solana_private_key, solana_private_key_from_bytes
1117

1218
logger = logging.getLogger(__name__)
1319

1420
T = TypeVar("T", bound=AccountFromPrivateKey)
1521

22+
CHAIN_TO_ACCOUNT_MAP: Dict[Chain, Type[AccountFromPrivateKey]] = {
23+
Chain.ETH: ETHAccount,
24+
Chain.AVAX: ETHAccount,
25+
Chain.SOL: SOLAccount,
26+
Chain.BASE: ETHAccount,
27+
}
28+
29+
30+
def detect_chain_from_private_key(private_key: Union[str, List[int], bytes]) -> Chain:
31+
"""
32+
Detect the blockchain chain based on the private key format.
33+
- Chain.ETH for Ethereum (EVM) private keys
34+
- Chain.SOL for Solana private keys (base58 or uint8 format).
35+
36+
Raises:
37+
ValueError: If the private key format is invalid or not recognized.
38+
"""
39+
if isinstance(private_key, (str, bytes)) and is_valid_private_key(
40+
private_key, ETHAccount
41+
):
42+
return Chain.ETH
43+
44+
elif is_valid_private_key(private_key, SOLAccount):
45+
return Chain.SOL
46+
47+
else:
48+
raise ValueError("Unsupported private key format. Unable to detect chain.")
49+
50+
51+
@overload
52+
def is_valid_private_key(
53+
private_key: Union[str, bytes], account_type: Type[ETHAccount]
54+
) -> bool: ...
55+
56+
57+
@overload
58+
def is_valid_private_key(
59+
private_key: Union[str, List[int], bytes], account_type: Type[SOLAccount]
60+
) -> bool: ...
61+
62+
63+
def is_valid_private_key(
64+
private_key: Union[str, List[int], bytes], account_type: Type[T]
65+
) -> bool:
66+
"""
67+
Check if the private key is valid for either Ethereum or Solana based on the account type.
68+
"""
69+
try:
70+
if account_type == ETHAccount:
71+
# Handle Ethereum private key validation
72+
if isinstance(private_key, str):
73+
if private_key.startswith("0x"):
74+
private_key = private_key[2:]
75+
private_key = bytes.fromhex(private_key)
76+
elif isinstance(private_key, list):
77+
raise ValueError("Ethereum keys cannot be a list of integers")
78+
79+
account_type(private_key)
80+
81+
elif account_type == SOLAccount:
82+
# Handle Solana private key validation
83+
if isinstance(private_key, bytes):
84+
return len(private_key) == 64
85+
elif isinstance(private_key, str):
86+
decoded_key = base58.b58decode(private_key)
87+
return len(decoded_key) == 64
88+
elif isinstance(private_key, list):
89+
return len(private_key) == 64 and all(
90+
isinstance(i, int) and 0 <= i <= 255 for i in private_key
91+
)
92+
93+
return True
94+
except Exception:
95+
return False
96+
1697

1798
def account_from_hex_string(private_key_str: str, account_type: Type[T]) -> T:
1899
if private_key_str.startswith("0x"):
@@ -22,6 +103,11 @@ def account_from_hex_string(private_key_str: str, account_type: Type[T]) -> T:
22103

23104
def account_from_file(private_key_path: Path, account_type: Type[T]) -> T:
24105
private_key = private_key_path.read_bytes()
106+
if account_type == SOLAccount:
107+
private_key = parse_solana_private_key(
108+
solana_private_key_from_bytes(private_key)
109+
)
110+
25111
return account_type(private_key)
26112

27113

@@ -33,10 +119,59 @@ def _load_account(
33119
"""Load private key from a string or a file. takes the string argument in priority"""
34120

35121
if private_key_str:
122+
# Check Account type based on private-key string format (base58 / uint for solana)
123+
private_key_chain = detect_chain_from_private_key(private_key=private_key_str)
124+
if private_key_chain == Chain.SOL:
125+
account_type = SOLAccount
126+
logger.debug("Solana private key is detected")
127+
parsed_key = parse_solana_private_key(private_key_str)
128+
return account_type(parsed_key)
36129
logger.debug("Using account from string")
37130
return account_from_hex_string(private_key_str, account_type)
38131
elif private_key_path and private_key_path.is_file():
39-
logger.debug("Using account from file")
132+
if private_key_path:
133+
try:
134+
# Look for the account by private_key_path in CHAINS_CONFIG_FILE
135+
with open(settings.CHAINS_CONFIG_FILE, "r") as file:
136+
accounts = json.load(file)
137+
138+
matching_account = next(
139+
(
140+
account
141+
for account in accounts
142+
if account["path"] == str(private_key_path)
143+
),
144+
None,
145+
)
146+
147+
if matching_account:
148+
chain = Chain(matching_account["chain"])
149+
account_type = CHAIN_TO_ACCOUNT_MAP.get(chain, ETHAccount)
150+
if account_type is None:
151+
account_type = ETHAccount
152+
logger.debug(
153+
f"Detected {chain} account for path {private_key_path}"
154+
)
155+
else:
156+
logger.warning(
157+
f"No matching account found for path {private_key_path}, defaulting to {account_type.__name__}"
158+
)
159+
160+
except FileNotFoundError:
161+
logger.warning(
162+
f"CHAINS_CONFIG_FILE not found, using default account type {account_type.__name__}"
163+
)
164+
except json.JSONDecodeError:
165+
logger.error(
166+
f"Invalid format in CHAINS_CONFIG_FILE, unable to load account info."
167+
)
168+
raise ValueError(f"Invalid format in {settings.CHAINS_CONFIG_FILE}.")
169+
except Exception as e:
170+
logger.error(f"Error loading accounts from config: {e}")
171+
raise ValueError(
172+
f"Could not find matching account for path {private_key_path}."
173+
)
174+
40175
return account_from_file(private_key_path, account_type)
41176
elif settings.REMOTE_CRYPTO_HOST:
42177
logger.debug("Using remote account")

src/aleph/sdk/chains/common.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from pathlib import Path
44
from typing import Dict, Optional
55

6+
import nacl.signing
67
from coincurve.keys import PrivateKey
78
from typing_extensions import deprecated
89

@@ -149,6 +150,20 @@ def generate_key() -> bytes:
149150
return privkey.secret
150151

151152

153+
def generate_key_solana() -> bytes:
154+
"""
155+
Generate a new Solana private key (32 bytes) using Ed25519.
156+
157+
Returns:
158+
A bytes object representing the 32-byte Solana private key.
159+
"""
160+
# Generate a new signing key (this is the private key part)
161+
private_key = nacl.signing.SigningKey.generate()
162+
163+
# Return only the private key (32 bytes)
164+
return private_key.encode()
165+
166+
152167
def get_fallback_private_key(path: Optional[Path] = None) -> bytes:
153168
path = path or settings.PRIVATE_KEY_FILE
154169
private_key: bytes

src/aleph/sdk/conf.py

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,13 @@ class Settings(BaseSettings):
2525
description="Path to the mnemonic used to create Substrate keypairs",
2626
)
2727

28+
CHAINS_CONFIG_FILE: Path = Field(
29+
default=Path("chains_config.json"),
30+
description="Path to the JSON file containing chain account configurations",
31+
)
32+
2833
PRIVATE_KEY_STRING: Optional[str] = None
29-
API_HOST: str = "https://api2.aleph.im"
34+
API_HOST: str = "https://api2.aleph.im/"
3035
MAX_INLINE_SIZE: int = 50000
3136
API_UNIX_SOCKET: Optional[str] = None
3237
REMOTE_CRYPTO_HOST: Optional[str] = None
@@ -162,13 +167,3 @@ class Config:
162167
settings.PRIVATE_MNEMONIC_FILE = Path(
163168
settings.CONFIG_HOME, "private-keys", "substrate.mnemonic"
164169
)
165-
166-
# Update CHAINS settings and remove placeholders
167-
CHAINS_ENV = [(key[7:], value) for key, value in settings if key.startswith("CHAINS_")]
168-
for fields, value in CHAINS_ENV:
169-
if value:
170-
chain, field = fields.split("_", 1)
171-
chain = chain if chain not in Chain.__members__ else Chain[chain]
172-
field = field.lower()
173-
settings.CHAINS[chain].__dict__[field] = value
174-
settings.__delattr__(f"CHAINS_{fields}")

src/aleph/sdk/types.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from abc import abstractmethod
22
from enum import Enum
3+
from pathlib import Path
34
from typing import Dict, Optional, Protocol, TypeVar
45

56
from pydantic import BaseModel
67

78
__all__ = ("StorageEnum", "Account", "AccountFromPrivateKey", "GenericMessage")
89

9-
from aleph_message.models import AlephMessage
10+
from aleph_message.models import AlephMessage, Chain
1011

1112

1213
class StorageEnum(str, Enum):
@@ -76,3 +77,16 @@ class ChainInfo(BaseModel):
7677
token: str
7778
super_token: Optional[str] = None
7879
active: bool = True
80+
81+
82+
class ChainAccount(BaseModel):
83+
"""
84+
Intern Chain Management with Account.
85+
"""
86+
87+
name: str
88+
path: Path
89+
chain: Chain
90+
91+
class Config:
92+
use_enum_values = True

0 commit comments

Comments
 (0)