iwa 0.0.1a2__py3-none-any.whl → 0.0.1a3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iwa/core/chain/interface.py +51 -61
- iwa/core/chain/models.py +7 -7
- iwa/core/chain/rate_limiter.py +21 -10
- iwa/core/cli.py +27 -2
- iwa/core/constants.py +6 -5
- iwa/core/contracts/contract.py +16 -4
- iwa/core/ipfs.py +149 -0
- iwa/core/keys.py +259 -29
- iwa/core/mnemonic.py +3 -13
- iwa/core/models.py +28 -6
- iwa/core/pricing.py +4 -4
- iwa/core/secrets.py +77 -0
- iwa/core/services/safe.py +3 -3
- iwa/core/utils.py +6 -1
- iwa/core/wallet.py +4 -0
- iwa/plugins/gnosis/safe.py +2 -2
- iwa/plugins/gnosis/tests/test_safe.py +1 -1
- iwa/plugins/olas/constants.py +8 -0
- iwa/plugins/olas/contracts/mech.py +30 -2
- iwa/plugins/olas/plugin.py +2 -2
- iwa/plugins/olas/tests/test_plugin_full.py +3 -3
- iwa/plugins/olas/tests/test_staking_integration.py +2 -2
- iwa/tools/__init__.py +1 -0
- iwa/tools/check_profile.py +6 -5
- iwa/tools/list_contracts.py +136 -0
- iwa/tools/release.py +9 -3
- iwa/tools/reset_env.py +2 -2
- iwa/tools/reset_tenderly.py +26 -24
- iwa/tools/wallet_check.py +150 -0
- iwa/web/dependencies.py +4 -4
- iwa/web/routers/state.py +1 -0
- iwa/web/tests/test_web_endpoints.py +3 -2
- iwa/web/tests/test_web_swap_coverage.py +156 -0
- {iwa-0.0.1a2.dist-info → iwa-0.0.1a3.dist-info}/METADATA +6 -3
- {iwa-0.0.1a2.dist-info → iwa-0.0.1a3.dist-info}/RECORD +50 -43
- iwa-0.0.1a3.dist-info/entry_points.txt +6 -0
- tests/test_chain.py +1 -1
- tests/test_chain_interface_coverage.py +92 -0
- tests/test_contract.py +2 -0
- tests/test_keys.py +58 -15
- tests/test_migration.py +52 -0
- tests/test_mnemonic.py +1 -1
- tests/test_pricing.py +7 -7
- tests/test_safe_coverage.py +1 -1
- tests/test_safe_service.py +3 -3
- tests/test_staking_router.py +13 -1
- tools/verify_drain.py +1 -1
- iwa/core/settings.py +0 -95
- iwa-0.0.1a2.dist-info/entry_points.txt +0 -2
- {iwa-0.0.1a2.dist-info → iwa-0.0.1a3.dist-info}/WHEEL +0 -0
- {iwa-0.0.1a2.dist-info → iwa-0.0.1a3.dist-info}/licenses/LICENSE +0 -0
- {iwa-0.0.1a2.dist-info → iwa-0.0.1a3.dist-info}/top_level.txt +0 -0
iwa/core/keys.py
CHANGED
|
@@ -4,55 +4,113 @@ import base64
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
6
|
import shutil
|
|
7
|
+
import sys
|
|
7
8
|
from datetime import datetime
|
|
8
9
|
from pathlib import Path
|
|
9
|
-
from typing import Dict, Optional, Union
|
|
10
|
-
|
|
10
|
+
from typing import Any, Dict, Optional, Tuple, Union
|
|
11
|
+
|
|
12
|
+
from bip_utils import (
|
|
13
|
+
Bip39MnemonicGenerator,
|
|
14
|
+
Bip39SeedGenerator,
|
|
15
|
+
Bip39WordsNum,
|
|
16
|
+
Bip44,
|
|
17
|
+
Bip44Changes,
|
|
18
|
+
Bip44Coins,
|
|
19
|
+
)
|
|
11
20
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
12
21
|
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
|
13
22
|
from eth_account import Account
|
|
14
23
|
from eth_account.signers.local import LocalAccount
|
|
15
|
-
from pydantic import BaseModel, PrivateAttr
|
|
24
|
+
from pydantic import BaseModel, PrivateAttr, model_validator
|
|
16
25
|
|
|
17
26
|
from iwa.core.constants import WALLET_PATH
|
|
18
|
-
from iwa.core.models import EthereumAddress, StoredAccount, StoredSafeAccount
|
|
19
|
-
from iwa.core.
|
|
27
|
+
from iwa.core.models import EncryptedData, EthereumAddress, StoredAccount, StoredSafeAccount
|
|
28
|
+
from iwa.core.secrets import secrets
|
|
20
29
|
from iwa.core.utils import (
|
|
21
30
|
configure_logger,
|
|
22
31
|
)
|
|
23
32
|
|
|
24
33
|
logger = configure_logger()
|
|
25
34
|
|
|
35
|
+
# Mnemonic constants
|
|
36
|
+
MNEMONIC_WORD_NUMBER = Bip39WordsNum.WORDS_NUM_24
|
|
37
|
+
SCRYPT_N = 2**14
|
|
38
|
+
SCRYPT_R = 8
|
|
39
|
+
SCRYPT_P = 1
|
|
40
|
+
SCRYPT_LEN = 32
|
|
41
|
+
AES_NONCE_LEN = 12
|
|
42
|
+
SALT_LEN = 16
|
|
43
|
+
|
|
26
44
|
|
|
27
|
-
class EncryptedAccount(StoredAccount):
|
|
45
|
+
class EncryptedAccount(StoredAccount, EncryptedData):
|
|
28
46
|
"""EncryptedAccount"""
|
|
29
47
|
|
|
30
|
-
salt
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
# We do NOT define 'salt' here to avoid serialization duplication.
|
|
49
|
+
# Legacy 'salt' is handled in the validator.
|
|
50
|
+
|
|
51
|
+
@model_validator(mode="before")
|
|
52
|
+
@classmethod
|
|
53
|
+
def upgrade_legacy_format(cls, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
54
|
+
"""Upgrade legacy account format to new EncryptedData structure."""
|
|
55
|
+
if isinstance(data, dict):
|
|
56
|
+
# Check if this is a legacy format (has 'salt')
|
|
57
|
+
if "salt" in data:
|
|
58
|
+
# Map to kdf_salt if missing
|
|
59
|
+
if "kdf_salt" not in data:
|
|
60
|
+
data["kdf_salt"] = data["salt"]
|
|
61
|
+
|
|
62
|
+
# Remove 'salt' to avoid "extra fields" error and duplication
|
|
63
|
+
data.pop("salt")
|
|
64
|
+
|
|
65
|
+
# Default KDF params for legacy accounts were:
|
|
66
|
+
# n=2**14 (16384), r=8, p=1, len=32
|
|
67
|
+
data.setdefault("kdf_n", SCRYPT_N)
|
|
68
|
+
data.setdefault("kdf_r", SCRYPT_R)
|
|
69
|
+
data.setdefault("kdf_p", SCRYPT_P)
|
|
70
|
+
data.setdefault("kdf_len", SCRYPT_LEN)
|
|
71
|
+
data.setdefault("kdf", "scrypt")
|
|
72
|
+
data.setdefault("cipher", "aesgcm")
|
|
73
|
+
return data
|
|
33
74
|
|
|
34
75
|
@staticmethod
|
|
35
|
-
def derive_key(
|
|
76
|
+
def derive_key(
|
|
77
|
+
password: str,
|
|
78
|
+
salt: bytes,
|
|
79
|
+
n: int = SCRYPT_N,
|
|
80
|
+
r: int = SCRYPT_R,
|
|
81
|
+
p: int = SCRYPT_P,
|
|
82
|
+
length: int = SCRYPT_LEN,
|
|
83
|
+
) -> bytes:
|
|
36
84
|
"""Derive key"""
|
|
37
85
|
kdf = Scrypt(
|
|
38
86
|
salt=salt,
|
|
39
|
-
length=
|
|
40
|
-
n=
|
|
41
|
-
r=
|
|
42
|
-
p=
|
|
87
|
+
length=length,
|
|
88
|
+
n=n,
|
|
89
|
+
r=r,
|
|
90
|
+
p=p,
|
|
43
91
|
)
|
|
44
92
|
return kdf.derive(password.encode())
|
|
45
93
|
|
|
46
94
|
def decrypt_private_key(self, password: Optional[str] = None) -> str:
|
|
47
95
|
"""decrypt_private_key"""
|
|
48
|
-
if not password and not
|
|
96
|
+
if not password and not secrets.wallet_password:
|
|
49
97
|
raise ValueError("Password must be provided or set in secrets.env (WALLET_PASSWORD)")
|
|
50
98
|
if not password:
|
|
51
|
-
password =
|
|
52
|
-
|
|
99
|
+
password = secrets.wallet_password.get_secret_value()
|
|
100
|
+
|
|
101
|
+
# Use kdf_salt (populated by upgrade_legacy_format if needed)
|
|
102
|
+
salt_bytes = base64.b64decode(self.kdf_salt)
|
|
53
103
|
nonce_bytes = base64.b64decode(self.nonce)
|
|
54
104
|
ciphertext_bytes = base64.b64decode(self.ciphertext)
|
|
55
|
-
|
|
105
|
+
|
|
106
|
+
key = EncryptedAccount.derive_key(
|
|
107
|
+
password,
|
|
108
|
+
salt_bytes,
|
|
109
|
+
n=self.kdf_n,
|
|
110
|
+
r=self.kdf_r,
|
|
111
|
+
p=self.kdf_p,
|
|
112
|
+
length=self.kdf_len,
|
|
113
|
+
)
|
|
56
114
|
aesgcm = AESGCM(key)
|
|
57
115
|
return aesgcm.decrypt(nonce_bytes, ciphertext_bytes, None).decode()
|
|
58
116
|
|
|
@@ -61,19 +119,36 @@ class EncryptedAccount(StoredAccount):
|
|
|
61
119
|
private_key: str, password: str, tag: Optional[str] = None
|
|
62
120
|
) -> "EncryptedAccount":
|
|
63
121
|
"""Encrypt private key"""
|
|
64
|
-
|
|
65
|
-
|
|
122
|
+
# Generate new random salt
|
|
123
|
+
salt = os.urandom(SALT_LEN)
|
|
124
|
+
|
|
125
|
+
# Use standard constants for new encryptions
|
|
126
|
+
kdf_n = SCRYPT_N
|
|
127
|
+
kdf_r = SCRYPT_R
|
|
128
|
+
kdf_p = SCRYPT_P
|
|
129
|
+
kdf_len = SCRYPT_LEN
|
|
130
|
+
|
|
131
|
+
key = EncryptedAccount.derive_key(password, salt, n=kdf_n, r=kdf_r, p=kdf_p, length=kdf_len)
|
|
66
132
|
aesgcm = AESGCM(key)
|
|
67
|
-
nonce = os.urandom(
|
|
133
|
+
nonce = os.urandom(AES_NONCE_LEN)
|
|
68
134
|
ciphertext = aesgcm.encrypt(nonce, private_key.encode(), None)
|
|
69
135
|
|
|
70
136
|
acct = Account.from_key(private_key)
|
|
137
|
+
|
|
71
138
|
return EncryptedAccount(
|
|
72
139
|
address=acct.address,
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
140
|
+
tag=tag or "",
|
|
141
|
+
# EncryptedData fields
|
|
142
|
+
kdf="scrypt",
|
|
143
|
+
kdf_salt=base64.b64encode(salt).decode("utf-8"),
|
|
144
|
+
kdf_n=kdf_n,
|
|
145
|
+
kdf_r=kdf_r,
|
|
146
|
+
kdf_p=kdf_p,
|
|
147
|
+
kdf_len=kdf_len,
|
|
148
|
+
cipher="aesgcm",
|
|
149
|
+
nonce=base64.b64encode(nonce).decode("utf-8"),
|
|
150
|
+
ciphertext=base64.b64encode(ciphertext).decode("utf-8"),
|
|
151
|
+
# NO redundant 'salt' field
|
|
77
152
|
)
|
|
78
153
|
|
|
79
154
|
|
|
@@ -81,8 +156,10 @@ class KeyStorage(BaseModel):
|
|
|
81
156
|
"""KeyStorage"""
|
|
82
157
|
|
|
83
158
|
accounts: Dict[EthereumAddress, Union[EncryptedAccount, StoredSafeAccount]] = {}
|
|
159
|
+
encrypted_mnemonic: Optional[dict] = None # Encrypted BIP-39 mnemonic for master
|
|
84
160
|
_path: Path = PrivateAttr() # not stored nor validated
|
|
85
161
|
_password: str = PrivateAttr()
|
|
162
|
+
_pending_mnemonic: Optional[str] = PrivateAttr(default=None) # Temp storage for display
|
|
86
163
|
|
|
87
164
|
def __init__(self, path: Path = Path(WALLET_PATH), password: Optional[str] = None):
|
|
88
165
|
"""Initialize key storage."""
|
|
@@ -109,7 +186,7 @@ class KeyStorage(BaseModel):
|
|
|
109
186
|
|
|
110
187
|
self._path = path
|
|
111
188
|
if password is None:
|
|
112
|
-
password =
|
|
189
|
+
password = secrets.wallet_password.get_secret_value()
|
|
113
190
|
self._password = password
|
|
114
191
|
|
|
115
192
|
if os.path.exists(path):
|
|
@@ -117,9 +194,12 @@ class KeyStorage(BaseModel):
|
|
|
117
194
|
with open(path, "r") as f:
|
|
118
195
|
data = json.load(f)
|
|
119
196
|
self.accounts = {
|
|
120
|
-
k: EncryptedAccount(**v)
|
|
197
|
+
EthereumAddress(k): EncryptedAccount(**v)
|
|
198
|
+
if "signers" not in v
|
|
199
|
+
else StoredSafeAccount(**v)
|
|
121
200
|
for k, v in data.get("accounts", {}).items()
|
|
122
201
|
}
|
|
202
|
+
self.encrypted_mnemonic = data.get("encrypted_mnemonic")
|
|
123
203
|
except json.JSONDecodeError:
|
|
124
204
|
logger.error(f"Failed to load wallet from {path}: File is corrupted.")
|
|
125
205
|
self.accounts = {}
|
|
@@ -165,21 +245,171 @@ class KeyStorage(BaseModel):
|
|
|
165
245
|
# Enforce read/write only for the owner
|
|
166
246
|
os.chmod(self._path, 0o600)
|
|
167
247
|
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _encrypt_mnemonic(mnemonic: str, password: str) -> dict:
|
|
250
|
+
"""Encrypt a mnemonic with AES-GCM using a scrypt-derived key."""
|
|
251
|
+
password_b = password.encode("utf-8")
|
|
252
|
+
salt = os.urandom(SALT_LEN)
|
|
253
|
+
kdf = Scrypt(salt=salt, length=SCRYPT_LEN, n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P)
|
|
254
|
+
key = kdf.derive(password_b)
|
|
255
|
+
aesgcm = AESGCM(key)
|
|
256
|
+
nonce = os.urandom(AES_NONCE_LEN)
|
|
257
|
+
ct = aesgcm.encrypt(nonce, mnemonic.encode("utf-8"), None)
|
|
258
|
+
return {
|
|
259
|
+
"kdf": "scrypt",
|
|
260
|
+
"kdf_salt": base64.b64encode(salt).decode(),
|
|
261
|
+
"kdf_n": SCRYPT_N,
|
|
262
|
+
"kdf_r": SCRYPT_R,
|
|
263
|
+
"kdf_p": SCRYPT_P,
|
|
264
|
+
"kdf_len": SCRYPT_LEN,
|
|
265
|
+
"cipher": "aesgcm",
|
|
266
|
+
"nonce": base64.b64encode(nonce).decode(),
|
|
267
|
+
"ciphertext": base64.b64encode(ct).decode(),
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
@staticmethod
|
|
271
|
+
def _decrypt_mnemonic(encobj: dict, password: str) -> str:
|
|
272
|
+
"""Decrypt a mnemonic previously created by `_encrypt_mnemonic`."""
|
|
273
|
+
if encobj.get("kdf") != "scrypt":
|
|
274
|
+
raise ValueError(f"Unsupported kdf: {encobj.get('kdf')}")
|
|
275
|
+
if encobj.get("cipher") != "aesgcm":
|
|
276
|
+
raise ValueError(f"Unsupported cipher: {encobj.get('cipher')}")
|
|
277
|
+
|
|
278
|
+
salt = base64.b64decode(encobj["kdf_salt"])
|
|
279
|
+
nonce = base64.b64decode(encobj["nonce"])
|
|
280
|
+
ct = base64.b64decode(encobj["ciphertext"])
|
|
281
|
+
|
|
282
|
+
n = encobj.get("kdf_n", SCRYPT_N)
|
|
283
|
+
r = encobj.get("kdf_r", SCRYPT_R)
|
|
284
|
+
p = encobj.get("kdf_p", SCRYPT_P)
|
|
285
|
+
length = encobj.get("kdf_len", SCRYPT_LEN)
|
|
286
|
+
|
|
287
|
+
kdf = Scrypt(salt=salt, length=length, n=n, r=r, p=p)
|
|
288
|
+
key = kdf.derive(password.encode("utf-8"))
|
|
289
|
+
aesgcm = AESGCM(key)
|
|
290
|
+
pt = aesgcm.decrypt(nonce, ct, None)
|
|
291
|
+
return pt.decode("utf-8")
|
|
292
|
+
|
|
293
|
+
def decrypt_mnemonic(self) -> str:
|
|
294
|
+
"""Decrypt the stored mnemonic using the wallet password."""
|
|
295
|
+
if not self.encrypted_mnemonic:
|
|
296
|
+
raise ValueError("No encrypted mnemonic found in wallet.")
|
|
297
|
+
return self._decrypt_mnemonic(self.encrypted_mnemonic, self._password)
|
|
298
|
+
|
|
299
|
+
@staticmethod
|
|
300
|
+
def _derive_private_key_from_mnemonic(mnemonic: str, index: int = 0) -> str:
|
|
301
|
+
"""Derive ETH private key from mnemonic using BIP-44 path."""
|
|
302
|
+
seed_bytes = Bip39SeedGenerator(mnemonic).Generate()
|
|
303
|
+
bip44_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM)
|
|
304
|
+
addr_ctx = (
|
|
305
|
+
bip44_ctx.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT).AddressIndex(index)
|
|
306
|
+
)
|
|
307
|
+
return addr_ctx.PrivateKey().Raw().ToHex()
|
|
308
|
+
|
|
309
|
+
def _create_master_from_mnemonic(self) -> Tuple[EncryptedAccount, str]:
|
|
310
|
+
"""Create master account from a new BIP-39 mnemonic.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Tuple of (EncryptedAccount, plaintext_mnemonic).
|
|
314
|
+
The mnemonic should be shown to user ONCE and never stored in plaintext.
|
|
315
|
+
|
|
316
|
+
"""
|
|
317
|
+
# Generate 24-word mnemonic
|
|
318
|
+
mnemonic = Bip39MnemonicGenerator().FromWordsNumber(MNEMONIC_WORD_NUMBER)
|
|
319
|
+
mnemonic_str = mnemonic.ToStr()
|
|
320
|
+
|
|
321
|
+
# Derive first account (index 0)
|
|
322
|
+
private_key_hex = self._derive_private_key_from_mnemonic(mnemonic_str, 0)
|
|
323
|
+
|
|
324
|
+
# Encrypt mnemonic for storage
|
|
325
|
+
self.encrypted_mnemonic = self._encrypt_mnemonic(mnemonic_str, self._password)
|
|
326
|
+
|
|
327
|
+
# Encrypt private key and create account
|
|
328
|
+
encrypted_acct = EncryptedAccount.encrypt_private_key(
|
|
329
|
+
private_key_hex, self._password, "master"
|
|
330
|
+
)
|
|
331
|
+
self.accounts[encrypted_acct.address] = encrypted_acct
|
|
332
|
+
self.save()
|
|
333
|
+
|
|
334
|
+
return encrypted_acct, mnemonic_str
|
|
335
|
+
|
|
168
336
|
def create_account(self, tag: str) -> EncryptedAccount:
|
|
169
|
-
"""Create account"""
|
|
337
|
+
"""Create account. Master is derived from mnemonic, others are random."""
|
|
170
338
|
tags = [acct.tag for acct in self.accounts.values()]
|
|
171
339
|
if not tags:
|
|
172
340
|
tag = "master" # First account is always master
|
|
173
341
|
if tag in tags:
|
|
174
342
|
raise ValueError(f"Tag '{tag}' already exists in wallet.")
|
|
175
343
|
|
|
176
|
-
|
|
344
|
+
# Master account: derive from mnemonic
|
|
345
|
+
if tag == "master":
|
|
346
|
+
encrypted_acct, mnemonic = self._create_master_from_mnemonic()
|
|
347
|
+
self._pending_mnemonic = mnemonic # Store temporarily for display
|
|
348
|
+
return encrypted_acct
|
|
177
349
|
|
|
350
|
+
# Non-master: random key as before
|
|
351
|
+
acct = Account.create()
|
|
178
352
|
encrypted = EncryptedAccount.encrypt_private_key(acct.key.hex(), self._password, tag)
|
|
179
353
|
self.accounts[acct.address] = encrypted
|
|
180
354
|
self.save()
|
|
181
355
|
return encrypted
|
|
182
356
|
|
|
357
|
+
def get_pending_mnemonic(self) -> Optional[str]:
|
|
358
|
+
"""Get and clear the pending mnemonic (for one-time display).
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
The mnemonic string if available, None otherwise.
|
|
362
|
+
Clears the pending mnemonic after returning.
|
|
363
|
+
|
|
364
|
+
"""
|
|
365
|
+
mnemonic = self._pending_mnemonic
|
|
366
|
+
self._pending_mnemonic = None
|
|
367
|
+
return mnemonic
|
|
368
|
+
|
|
369
|
+
def display_pending_mnemonic(self) -> bool:
|
|
370
|
+
"""Display the pending mnemonic to the user and wait for confirmation.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
True if a mnemonic was displayed, False otherwise.
|
|
374
|
+
|
|
375
|
+
"""
|
|
376
|
+
# SECURITY: Do NOT print mnemonic if not in an interactive terminal (avoids Docker logs)
|
|
377
|
+
if not sys.stdout.isatty():
|
|
378
|
+
if self._pending_mnemonic:
|
|
379
|
+
print("\n" + "!" * 60)
|
|
380
|
+
print("⚠️ SECURITY WARNING: MASTER ACCOUNT CREATED FROM MNEMONIC")
|
|
381
|
+
print("!" * 60)
|
|
382
|
+
print("\nSince this is a non-interactive terminal (e.g. Docker background),")
|
|
383
|
+
print("the mnemonic is NOT displayed here to prevent it from being")
|
|
384
|
+
print("stored in log files.")
|
|
385
|
+
print("\nTo view and backup your mnemonic, run:")
|
|
386
|
+
print(" just mnemonic")
|
|
387
|
+
print("!" * 60 + "\n")
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
mnemonic = self.get_pending_mnemonic()
|
|
391
|
+
if not mnemonic:
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
print("\n" + "=" * 60)
|
|
395
|
+
print("⚠️ NEW MASTER ACCOUNT CREATED - BACKUP YOUR MNEMONIC!")
|
|
396
|
+
print("=" * 60)
|
|
397
|
+
print("\nWrite down these 24 words and store them in a safe place.")
|
|
398
|
+
print("This is the ONLY time they will be shown.\n")
|
|
399
|
+
print("-" * 60)
|
|
400
|
+
words = mnemonic.split()
|
|
401
|
+
for i in range(0, 24, 4):
|
|
402
|
+
print(
|
|
403
|
+
f" {i + 1:2}. {words[i]:12} {i + 2:2}. {words[i + 1]:12} "
|
|
404
|
+
f"{i + 3:2}. {words[i + 2]:12} {i + 4:2}. {words[i + 3]:12}"
|
|
405
|
+
)
|
|
406
|
+
print("-" * 60)
|
|
407
|
+
print("\n⚠️ If you lose this mnemonic, you CANNOT recover your funds!")
|
|
408
|
+
print("=" * 60)
|
|
409
|
+
input("\nPress ENTER after you have saved your mnemonic...")
|
|
410
|
+
print()
|
|
411
|
+
return True
|
|
412
|
+
|
|
183
413
|
def remove_account(self, address_or_tag: str):
|
|
184
414
|
"""Remove account"""
|
|
185
415
|
account = self.find_stored_account(address_or_tag)
|
iwa/core/mnemonic.py
CHANGED
|
@@ -21,7 +21,7 @@ from eth_account import Account
|
|
|
21
21
|
from pydantic import BaseModel
|
|
22
22
|
|
|
23
23
|
from iwa.core.constants import WALLET_PATH
|
|
24
|
-
from iwa.core.models import EthereumAddress, StoredAccount
|
|
24
|
+
from iwa.core.models import EncryptedData, EthereumAddress, StoredAccount
|
|
25
25
|
|
|
26
26
|
MNEMONIC_WORD_NUMBER = Bip39WordsNum.WORDS_NUM_24
|
|
27
27
|
SCRYPT_N = 2**14
|
|
@@ -32,19 +32,9 @@ AES_NONCE_LEN = 12
|
|
|
32
32
|
SALT_LEN = 16
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
class EncryptedMnemonic(
|
|
35
|
+
class EncryptedMnemonic(EncryptedData):
|
|
36
36
|
"""EncryptedMnemonic"""
|
|
37
37
|
|
|
38
|
-
kdf: str = "scrypt"
|
|
39
|
-
kdf_salt: str
|
|
40
|
-
kdf_n: int = SCRYPT_N
|
|
41
|
-
kdf_r: int = SCRYPT_R
|
|
42
|
-
kdf_p: int = SCRYPT_P
|
|
43
|
-
kdf_len: int = SCRYPT_LEN
|
|
44
|
-
cypher: str = "aesgcm"
|
|
45
|
-
nonce: str
|
|
46
|
-
ciphertext: str
|
|
47
|
-
|
|
48
38
|
def derive_key(self, password: bytes) -> bytes:
|
|
49
39
|
"""Derive a key from a password and salt using scrypt."""
|
|
50
40
|
kdf = Scrypt(
|
|
@@ -61,7 +51,7 @@ class EncryptedMnemonic(BaseModel):
|
|
|
61
51
|
# validate expected algorithms
|
|
62
52
|
if self.kdf != "scrypt":
|
|
63
53
|
raise ValueError(f"Unsupported kdf: {self.kdf}")
|
|
64
|
-
if self.
|
|
54
|
+
if self.cipher != "aesgcm":
|
|
65
55
|
raise ValueError("Unsupported cipher, expected 'aesgcm'")
|
|
66
56
|
|
|
67
57
|
nonce = base64.b64decode(self.nonce)
|
iwa/core/models.py
CHANGED
|
@@ -14,6 +14,20 @@ from iwa.core.types import EthereumAddress # noqa: F401 - re-exported for backw
|
|
|
14
14
|
from iwa.core.utils import singleton
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
class EncryptedData(BaseModel):
|
|
18
|
+
"""Encrypted data structure with explicit KDF parameters."""
|
|
19
|
+
|
|
20
|
+
kdf: str = "scrypt"
|
|
21
|
+
kdf_salt: str
|
|
22
|
+
kdf_n: int = 16384 # 2**14
|
|
23
|
+
kdf_r: int = 8
|
|
24
|
+
kdf_p: int = 1
|
|
25
|
+
kdf_len: int = 32
|
|
26
|
+
cipher: str = "aesgcm"
|
|
27
|
+
nonce: str
|
|
28
|
+
ciphertext: str
|
|
29
|
+
|
|
30
|
+
|
|
17
31
|
class StoredAccount(BaseModel):
|
|
18
32
|
"""StoredAccount representing an EOA or contract account."""
|
|
19
33
|
|
|
@@ -32,12 +46,6 @@ class StoredSafeAccount(StoredAccount):
|
|
|
32
46
|
class CoreConfig(BaseModel):
|
|
33
47
|
"""Core configuration settings."""
|
|
34
48
|
|
|
35
|
-
manual_claim_enabled: bool = Field(
|
|
36
|
-
default=False, description="Enable manual claiming of rewards"
|
|
37
|
-
)
|
|
38
|
-
request_activity_alert_enabled: bool = Field(
|
|
39
|
-
default=True, description="Enable alerts for suspicious activity"
|
|
40
|
-
)
|
|
41
49
|
whitelist: Dict[str, EthereumAddress] = Field(
|
|
42
50
|
default_factory=dict, description="Address whitelist for security"
|
|
43
51
|
)
|
|
@@ -45,6 +53,20 @@ class CoreConfig(BaseModel):
|
|
|
45
53
|
default_factory=dict, description="Custom token definitions per chain"
|
|
46
54
|
)
|
|
47
55
|
|
|
56
|
+
# Web UI Configuration
|
|
57
|
+
web_enabled: bool = Field(default=False, description="Enable Web UI")
|
|
58
|
+
web_port: int = Field(default=8080, description="Web UI port")
|
|
59
|
+
|
|
60
|
+
# IPFS Configuration
|
|
61
|
+
ipfs_api_url: str = Field(default="http://localhost:5001", description="IPFS API URL")
|
|
62
|
+
|
|
63
|
+
# Tenderly Configuration
|
|
64
|
+
tenderly_profile: int = Field(default=1, description="Tenderly profile ID (1, 2, 3)")
|
|
65
|
+
tenderly_native_funds: float = Field(
|
|
66
|
+
default=1000.0, description="Native ETH amount for vNet funding"
|
|
67
|
+
)
|
|
68
|
+
tenderly_olas_funds: float = Field(default=100000.0, description="OLAS amount for vNet funding")
|
|
69
|
+
|
|
48
70
|
|
|
49
71
|
T = TypeVar("T", bound="StorableModel")
|
|
50
72
|
|
iwa/core/pricing.py
CHANGED
|
@@ -7,7 +7,7 @@ from typing import Dict, Optional
|
|
|
7
7
|
import requests
|
|
8
8
|
from loguru import logger
|
|
9
9
|
|
|
10
|
-
from iwa.core.
|
|
10
|
+
from iwa.core.secrets import secrets
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class PriceService:
|
|
@@ -17,12 +17,12 @@ class PriceService:
|
|
|
17
17
|
|
|
18
18
|
def __init__(self, cache_ttl_minutes: int = 5):
|
|
19
19
|
"""Initialize PriceService."""
|
|
20
|
-
self.
|
|
20
|
+
self.secrets = secrets
|
|
21
21
|
self.cache: Dict[str, Dict] = {} # {id_currency: {"price": float, "timestamp": datetime}}
|
|
22
22
|
self.cache_ttl = timedelta(minutes=cache_ttl_minutes)
|
|
23
23
|
self.api_key = (
|
|
24
|
-
self.
|
|
25
|
-
if self.
|
|
24
|
+
self.secrets.coingecko_api_key.get_secret_value()
|
|
25
|
+
if self.secrets.coingecko_api_key
|
|
26
26
|
else None
|
|
27
27
|
)
|
|
28
28
|
|
iwa/core/secrets.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Secrets module - loads sensitive values from environment variables.
|
|
2
|
+
|
|
3
|
+
Secrets are loaded from:
|
|
4
|
+
1. Environment variables (injected by docker-compose env_file in production)
|
|
5
|
+
2. secrets.env file at project root (for local development)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from pydantic import SecretStr, model_validator
|
|
12
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
13
|
+
|
|
14
|
+
# secrets.env is at project root (not in data/)
|
|
15
|
+
SECRETS_FILE = Path("secrets.env")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Secrets(BaseSettings):
|
|
19
|
+
"""Application Secrets loaded from environment variables.
|
|
20
|
+
|
|
21
|
+
In production, these are injected via docker-compose:
|
|
22
|
+
env_file:
|
|
23
|
+
- ./secrets.env
|
|
24
|
+
|
|
25
|
+
For local development, secrets are loaded from secrets.env at project root.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
# Testing mode - when True, uses Tenderly test RPCs; when False, uses production RPCs
|
|
29
|
+
testing: bool = False
|
|
30
|
+
|
|
31
|
+
# RPC endpoints
|
|
32
|
+
# When testing=True, these get overwritten with *_test_rpc values
|
|
33
|
+
gnosis_rpc: Optional[SecretStr] = None
|
|
34
|
+
base_rpc: Optional[SecretStr] = None
|
|
35
|
+
ethereum_rpc: Optional[SecretStr] = None
|
|
36
|
+
|
|
37
|
+
# Test RPCs (Tenderly)
|
|
38
|
+
gnosis_test_rpc: Optional[SecretStr] = None
|
|
39
|
+
ethereum_test_rpc: Optional[SecretStr] = None
|
|
40
|
+
base_test_rpc: Optional[SecretStr] = None
|
|
41
|
+
|
|
42
|
+
coingecko_api_key: Optional[SecretStr] = None
|
|
43
|
+
wallet_password: Optional[SecretStr] = None
|
|
44
|
+
|
|
45
|
+
webui_password: Optional[SecretStr] = None
|
|
46
|
+
|
|
47
|
+
# Load from environment AND secrets.env file (for local dev)
|
|
48
|
+
model_config = SettingsConfigDict(
|
|
49
|
+
env_file=str(SECRETS_FILE) if SECRETS_FILE.exists() else None,
|
|
50
|
+
env_file_encoding="utf-8",
|
|
51
|
+
extra="ignore",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@model_validator(mode="after")
|
|
55
|
+
def load_tenderly_profile_credentials(self) -> "Secrets":
|
|
56
|
+
"""Load Tenderly credentials based on the selected profile."""
|
|
57
|
+
# Note: Logic moved to dynamic loading in tools/reset_tenderly.py
|
|
58
|
+
# using Config().core.tenderly_profile
|
|
59
|
+
|
|
60
|
+
# When in testing mode, override RPCs with test RPCs (Tenderly)
|
|
61
|
+
if self.testing:
|
|
62
|
+
if self.gnosis_test_rpc:
|
|
63
|
+
self.gnosis_rpc = self.gnosis_test_rpc
|
|
64
|
+
if self.ethereum_test_rpc:
|
|
65
|
+
self.ethereum_rpc = self.ethereum_test_rpc
|
|
66
|
+
if self.base_test_rpc:
|
|
67
|
+
self.base_rpc = self.base_test_rpc
|
|
68
|
+
|
|
69
|
+
# Convert empty webui_password to None (no auth required)
|
|
70
|
+
if self.webui_password and not self.webui_password.get_secret_value():
|
|
71
|
+
self.webui_password = None
|
|
72
|
+
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Global secrets instance
|
|
77
|
+
secrets = Secrets()
|
iwa/core/services/safe.py
CHANGED
|
@@ -11,7 +11,7 @@ from safe_eth.safe.safe_tx import SafeTx
|
|
|
11
11
|
from iwa.core.constants import ZERO_ADDRESS
|
|
12
12
|
from iwa.core.db import log_transaction
|
|
13
13
|
from iwa.core.models import StoredSafeAccount
|
|
14
|
-
from iwa.core.
|
|
14
|
+
from iwa.core.secrets import secrets
|
|
15
15
|
from iwa.core.utils import (
|
|
16
16
|
get_safe_master_copy_address,
|
|
17
17
|
get_safe_proxy_factory_address,
|
|
@@ -99,7 +99,7 @@ class SafeService:
|
|
|
99
99
|
return owner_addresses
|
|
100
100
|
|
|
101
101
|
def _get_ethereum_client(self, chain_name: str) -> EthereumClient:
|
|
102
|
-
rpc_secret = getattr(
|
|
102
|
+
rpc_secret = getattr(secrets, f"{chain_name}_rpc")
|
|
103
103
|
return EthereumClient(rpc_secret.get_secret_value())
|
|
104
104
|
|
|
105
105
|
def _deploy_safe_contract(
|
|
@@ -248,7 +248,7 @@ class SafeService:
|
|
|
248
248
|
continue
|
|
249
249
|
|
|
250
250
|
for chain in account.chains:
|
|
251
|
-
rpc_secret = getattr(
|
|
251
|
+
rpc_secret = getattr(secrets, f"{chain}_rpc")
|
|
252
252
|
ethereum_client = EthereumClient(rpc_secret.get_secret_value())
|
|
253
253
|
|
|
254
254
|
code = ethereum_client.w3.eth.get_code(account.address)
|
iwa/core/utils.py
CHANGED
|
@@ -43,10 +43,15 @@ def configure_logger():
|
|
|
43
43
|
if hasattr(configure_logger, "configured"):
|
|
44
44
|
return logger
|
|
45
45
|
|
|
46
|
+
from iwa.core.constants import DATA_DIR
|
|
47
|
+
|
|
46
48
|
logger.remove()
|
|
47
49
|
|
|
50
|
+
# Ensure data directory exists
|
|
51
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
48
53
|
logger.add(
|
|
49
|
-
"iwa.log",
|
|
54
|
+
DATA_DIR / "iwa.log",
|
|
50
55
|
rotation="10 MB",
|
|
51
56
|
level="INFO",
|
|
52
57
|
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}",
|
iwa/core/wallet.py
CHANGED
|
@@ -29,6 +29,10 @@ class Wallet:
|
|
|
29
29
|
def __init__(self):
|
|
30
30
|
"""Initialize wallet."""
|
|
31
31
|
self.key_storage = KeyStorage()
|
|
32
|
+
|
|
33
|
+
# Display mnemonic if a new master account was just created
|
|
34
|
+
self.key_storage.display_pending_mnemonic()
|
|
35
|
+
|
|
32
36
|
self.account_service = AccountService(self.key_storage)
|
|
33
37
|
self.balance_service = BalanceService(self.key_storage, self.account_service)
|
|
34
38
|
self.safe_service = SafeService(self.key_storage, self.account_service)
|
iwa/plugins/gnosis/safe.py
CHANGED
|
@@ -8,7 +8,7 @@ from safe_eth.safe import Safe, SafeOperationEnum
|
|
|
8
8
|
from safe_eth.safe.safe_tx import SafeTx
|
|
9
9
|
|
|
10
10
|
from iwa.core.models import StoredSafeAccount
|
|
11
|
-
from iwa.core.
|
|
11
|
+
from iwa.core.secrets import secrets
|
|
12
12
|
from iwa.core.utils import configure_logger
|
|
13
13
|
|
|
14
14
|
logger = configure_logger()
|
|
@@ -28,7 +28,7 @@ class SafeMultisig:
|
|
|
28
28
|
if chain_name.lower() not in normalized_chains:
|
|
29
29
|
raise ValueError(f"Safe account is not deployed on chain: {chain_name}")
|
|
30
30
|
|
|
31
|
-
rpc_secret = getattr(
|
|
31
|
+
rpc_secret = getattr(secrets, f"{chain_name.lower()}_rpc")
|
|
32
32
|
ethereum_client = EthereumClient(rpc_secret.get_secret_value())
|
|
33
33
|
self.multisig = Safe(safe_account.address, ethereum_client)
|
|
34
34
|
self.ethereum_client = ethereum_client
|
|
@@ -11,7 +11,7 @@ from iwa.plugins.gnosis.safe import SafeMultisig
|
|
|
11
11
|
@pytest.fixture
|
|
12
12
|
def mock_settings():
|
|
13
13
|
"""Mock settings."""
|
|
14
|
-
with patch("iwa.plugins.gnosis.safe.
|
|
14
|
+
with patch("iwa.plugins.gnosis.safe.secrets") as mock:
|
|
15
15
|
mock.gnosis_rpc.get_secret_value.return_value = "http://rpc"
|
|
16
16
|
yield mock
|
|
17
17
|
|
iwa/plugins/olas/constants.py
CHANGED
|
@@ -100,6 +100,14 @@ OLAS_TRADER_STAKING_CONTRACTS: Dict[str, Dict[str, EthereumAddress]] = {
|
|
|
100
100
|
"Expert 16 (10k OLAS)": EthereumAddress("0x6c65430515c70a3f5E62107CC301685B7D46f991"),
|
|
101
101
|
"Expert 17 (10k OLAS)": EthereumAddress("0x1430107A785C3A36a0C1FC0ee09B9631e2E72aFf"),
|
|
102
102
|
"Expert 18 (10k OLAS)": EthereumAddress("0x041e679d04Fc0D4f75Eb937Dea729Df09a58e454"),
|
|
103
|
+
"Expert 3 MM (1k OLAS)": EthereumAddress("0x75eeca6207be98cac3fde8a20ecd7b01e50b3472"),
|
|
104
|
+
"Expert 4 MM (2k OLAS)": EthereumAddress("0x9c7f6103e3a72e4d1805b9c683ea5b370ec1a99f"),
|
|
105
|
+
"Expert 5 MM (10k OLAS)": EthereumAddress("0xcdC603e0Ee55Aae92519f9770f214b2Be4967f7d"),
|
|
106
|
+
"Expert 6 MM (10k OLAS)": EthereumAddress("0x22d6cd3d587d8391c3aae83a783f26c67ab54a85"),
|
|
107
|
+
"Expert 7 MM (10k OLAS)": EthereumAddress("0xaaecdf4d0cbd6ca0622892ac6044472f3912a5f3"),
|
|
108
|
+
"Expert 8 MM (10k OLAS)": EthereumAddress("0x168aed532a0cd8868c22fc77937af78b363652b1"),
|
|
109
|
+
"Expert 9 MM (10k OLAS)": EthereumAddress("0xdda9cd214f12e7c2d58e871404a0a3b1177065c8"),
|
|
110
|
+
"Expert 10 MM (10k OLAS)": EthereumAddress("0x53a38655b4e659ef4c7f88a26fbf5c67932c7156"),
|
|
103
111
|
},
|
|
104
112
|
"ethereum": {},
|
|
105
113
|
"base": {},
|