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.
Files changed (52) hide show
  1. iwa/core/chain/interface.py +51 -61
  2. iwa/core/chain/models.py +7 -7
  3. iwa/core/chain/rate_limiter.py +21 -10
  4. iwa/core/cli.py +27 -2
  5. iwa/core/constants.py +6 -5
  6. iwa/core/contracts/contract.py +16 -4
  7. iwa/core/ipfs.py +149 -0
  8. iwa/core/keys.py +259 -29
  9. iwa/core/mnemonic.py +3 -13
  10. iwa/core/models.py +28 -6
  11. iwa/core/pricing.py +4 -4
  12. iwa/core/secrets.py +77 -0
  13. iwa/core/services/safe.py +3 -3
  14. iwa/core/utils.py +6 -1
  15. iwa/core/wallet.py +4 -0
  16. iwa/plugins/gnosis/safe.py +2 -2
  17. iwa/plugins/gnosis/tests/test_safe.py +1 -1
  18. iwa/plugins/olas/constants.py +8 -0
  19. iwa/plugins/olas/contracts/mech.py +30 -2
  20. iwa/plugins/olas/plugin.py +2 -2
  21. iwa/plugins/olas/tests/test_plugin_full.py +3 -3
  22. iwa/plugins/olas/tests/test_staking_integration.py +2 -2
  23. iwa/tools/__init__.py +1 -0
  24. iwa/tools/check_profile.py +6 -5
  25. iwa/tools/list_contracts.py +136 -0
  26. iwa/tools/release.py +9 -3
  27. iwa/tools/reset_env.py +2 -2
  28. iwa/tools/reset_tenderly.py +26 -24
  29. iwa/tools/wallet_check.py +150 -0
  30. iwa/web/dependencies.py +4 -4
  31. iwa/web/routers/state.py +1 -0
  32. iwa/web/tests/test_web_endpoints.py +3 -2
  33. iwa/web/tests/test_web_swap_coverage.py +156 -0
  34. {iwa-0.0.1a2.dist-info → iwa-0.0.1a3.dist-info}/METADATA +6 -3
  35. {iwa-0.0.1a2.dist-info → iwa-0.0.1a3.dist-info}/RECORD +50 -43
  36. iwa-0.0.1a3.dist-info/entry_points.txt +6 -0
  37. tests/test_chain.py +1 -1
  38. tests/test_chain_interface_coverage.py +92 -0
  39. tests/test_contract.py +2 -0
  40. tests/test_keys.py +58 -15
  41. tests/test_migration.py +52 -0
  42. tests/test_mnemonic.py +1 -1
  43. tests/test_pricing.py +7 -7
  44. tests/test_safe_coverage.py +1 -1
  45. tests/test_safe_service.py +3 -3
  46. tests/test_staking_router.py +13 -1
  47. tools/verify_drain.py +1 -1
  48. iwa/core/settings.py +0 -95
  49. iwa-0.0.1a2.dist-info/entry_points.txt +0 -2
  50. {iwa-0.0.1a2.dist-info → iwa-0.0.1a3.dist-info}/WHEEL +0 -0
  51. {iwa-0.0.1a2.dist-info → iwa-0.0.1a3.dist-info}/licenses/LICENSE +0 -0
  52. {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.settings import settings
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: str
31
- nonce: str
32
- ciphertext: str
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(password: str, salt: bytes) -> bytes:
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=32,
40
- n=2**14,
41
- r=8,
42
- p=1,
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 settings.wallet_password:
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 = settings.wallet_password.get_secret_value()
52
- salt_bytes = base64.b64decode(self.salt)
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
- key = EncryptedAccount.derive_key(password, salt_bytes)
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
- salt = os.urandom(16)
65
- key = EncryptedAccount.derive_key(password, salt)
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(12)
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
- salt=base64.b64encode(salt).decode(),
74
- nonce=base64.b64encode(nonce).decode(),
75
- ciphertext=base64.b64encode(ciphertext).decode(),
76
- tag=tag,
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 = settings.wallet_password.get_secret_value()
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) if "signers" not in v else StoredSafeAccount(**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
- acct = Account.create()
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(BaseModel):
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.cypher != "aesgcm":
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.settings import settings
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.settings = settings
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.settings.coingecko_api_key.get_secret_value()
25
- if self.settings.coingecko_api_key
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.settings import settings
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(settings, f"{chain_name}_rpc")
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(settings, f"{chain}_rpc")
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)
@@ -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.settings import settings
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(settings, f"{chain_name.lower()}_rpc")
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.settings") as mock:
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
 
@@ -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": {},