iwa 0.0.0__py3-none-any.whl → 0.0.1a2__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 (191) hide show
  1. conftest.py +22 -0
  2. iwa/__init__.py +1 -0
  3. iwa/__main__.py +6 -0
  4. iwa/core/__init__.py +1 -0
  5. iwa/core/chain/__init__.py +68 -0
  6. iwa/core/chain/errors.py +47 -0
  7. iwa/core/chain/interface.py +514 -0
  8. iwa/core/chain/manager.py +38 -0
  9. iwa/core/chain/models.py +128 -0
  10. iwa/core/chain/rate_limiter.py +193 -0
  11. iwa/core/cli.py +210 -0
  12. iwa/core/constants.py +28 -0
  13. iwa/core/contracts/__init__.py +1 -0
  14. iwa/core/contracts/contract.py +297 -0
  15. iwa/core/contracts/erc20.py +79 -0
  16. iwa/core/contracts/multisend.py +71 -0
  17. iwa/core/db.py +317 -0
  18. iwa/core/keys.py +361 -0
  19. iwa/core/mnemonic.py +385 -0
  20. iwa/core/models.py +344 -0
  21. iwa/core/monitor.py +209 -0
  22. iwa/core/plugins.py +45 -0
  23. iwa/core/pricing.py +91 -0
  24. iwa/core/services/__init__.py +17 -0
  25. iwa/core/services/account.py +57 -0
  26. iwa/core/services/balance.py +113 -0
  27. iwa/core/services/plugin.py +88 -0
  28. iwa/core/services/safe.py +392 -0
  29. iwa/core/services/transaction.py +172 -0
  30. iwa/core/services/transfer/__init__.py +166 -0
  31. iwa/core/services/transfer/base.py +260 -0
  32. iwa/core/services/transfer/erc20.py +247 -0
  33. iwa/core/services/transfer/multisend.py +386 -0
  34. iwa/core/services/transfer/native.py +262 -0
  35. iwa/core/services/transfer/swap.py +326 -0
  36. iwa/core/settings.py +95 -0
  37. iwa/core/tables.py +60 -0
  38. iwa/core/test.py +27 -0
  39. iwa/core/tests/test_wallet.py +255 -0
  40. iwa/core/types.py +59 -0
  41. iwa/core/ui.py +99 -0
  42. iwa/core/utils.py +59 -0
  43. iwa/core/wallet.py +380 -0
  44. iwa/plugins/__init__.py +1 -0
  45. iwa/plugins/gnosis/__init__.py +5 -0
  46. iwa/plugins/gnosis/cow/__init__.py +6 -0
  47. iwa/plugins/gnosis/cow/quotes.py +148 -0
  48. iwa/plugins/gnosis/cow/swap.py +403 -0
  49. iwa/plugins/gnosis/cow/types.py +20 -0
  50. iwa/plugins/gnosis/cow_utils.py +44 -0
  51. iwa/plugins/gnosis/plugin.py +68 -0
  52. iwa/plugins/gnosis/safe.py +157 -0
  53. iwa/plugins/gnosis/tests/test_cow.py +227 -0
  54. iwa/plugins/gnosis/tests/test_safe.py +100 -0
  55. iwa/plugins/olas/__init__.py +5 -0
  56. iwa/plugins/olas/constants.py +106 -0
  57. iwa/plugins/olas/contracts/activity_checker.py +93 -0
  58. iwa/plugins/olas/contracts/base.py +10 -0
  59. iwa/plugins/olas/contracts/mech.py +49 -0
  60. iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
  61. iwa/plugins/olas/contracts/service.py +215 -0
  62. iwa/plugins/olas/contracts/staking.py +403 -0
  63. iwa/plugins/olas/importer.py +736 -0
  64. iwa/plugins/olas/mech_reference.py +135 -0
  65. iwa/plugins/olas/models.py +110 -0
  66. iwa/plugins/olas/plugin.py +243 -0
  67. iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
  68. iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
  69. iwa/plugins/olas/service_manager/__init__.py +60 -0
  70. iwa/plugins/olas/service_manager/base.py +113 -0
  71. iwa/plugins/olas/service_manager/drain.py +336 -0
  72. iwa/plugins/olas/service_manager/lifecycle.py +839 -0
  73. iwa/plugins/olas/service_manager/mech.py +322 -0
  74. iwa/plugins/olas/service_manager/staking.py +530 -0
  75. iwa/plugins/olas/tests/conftest.py +30 -0
  76. iwa/plugins/olas/tests/test_importer.py +128 -0
  77. iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
  78. iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
  79. iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
  80. iwa/plugins/olas/tests/test_olas_integration.py +561 -0
  81. iwa/plugins/olas/tests/test_olas_models.py +144 -0
  82. iwa/plugins/olas/tests/test_olas_view.py +258 -0
  83. iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
  84. iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
  85. iwa/plugins/olas/tests/test_plugin.py +70 -0
  86. iwa/plugins/olas/tests/test_plugin_full.py +212 -0
  87. iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
  88. iwa/plugins/olas/tests/test_service_manager.py +1065 -0
  89. iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
  90. iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
  91. iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
  92. iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
  93. iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
  94. iwa/plugins/olas/tests/test_service_staking.py +342 -0
  95. iwa/plugins/olas/tests/test_staking_integration.py +269 -0
  96. iwa/plugins/olas/tests/test_staking_validation.py +109 -0
  97. iwa/plugins/olas/tui/__init__.py +1 -0
  98. iwa/plugins/olas/tui/olas_view.py +952 -0
  99. iwa/tools/check_profile.py +67 -0
  100. iwa/tools/release.py +111 -0
  101. iwa/tools/reset_env.py +111 -0
  102. iwa/tools/reset_tenderly.py +362 -0
  103. iwa/tools/restore_backup.py +82 -0
  104. iwa/tui/__init__.py +1 -0
  105. iwa/tui/app.py +174 -0
  106. iwa/tui/modals/__init__.py +5 -0
  107. iwa/tui/modals/base.py +406 -0
  108. iwa/tui/rpc.py +63 -0
  109. iwa/tui/screens/__init__.py +1 -0
  110. iwa/tui/screens/wallets.py +749 -0
  111. iwa/tui/tests/test_app.py +125 -0
  112. iwa/tui/tests/test_rpc.py +139 -0
  113. iwa/tui/tests/test_wallets_refactor.py +30 -0
  114. iwa/tui/tests/test_widgets.py +123 -0
  115. iwa/tui/widgets/__init__.py +5 -0
  116. iwa/tui/widgets/base.py +100 -0
  117. iwa/tui/workers.py +42 -0
  118. iwa/web/dependencies.py +76 -0
  119. iwa/web/models.py +76 -0
  120. iwa/web/routers/accounts.py +115 -0
  121. iwa/web/routers/olas/__init__.py +24 -0
  122. iwa/web/routers/olas/admin.py +169 -0
  123. iwa/web/routers/olas/funding.py +135 -0
  124. iwa/web/routers/olas/general.py +29 -0
  125. iwa/web/routers/olas/services.py +378 -0
  126. iwa/web/routers/olas/staking.py +341 -0
  127. iwa/web/routers/state.py +65 -0
  128. iwa/web/routers/swap.py +617 -0
  129. iwa/web/routers/transactions.py +153 -0
  130. iwa/web/server.py +155 -0
  131. iwa/web/tests/test_web_endpoints.py +713 -0
  132. iwa/web/tests/test_web_olas.py +430 -0
  133. iwa/web/tests/test_web_swap.py +103 -0
  134. iwa-0.0.1a2.dist-info/METADATA +234 -0
  135. iwa-0.0.1a2.dist-info/RECORD +186 -0
  136. iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
  137. iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
  138. iwa-0.0.1a2.dist-info/top_level.txt +4 -0
  139. tests/legacy_cow.py +248 -0
  140. tests/legacy_safe.py +93 -0
  141. tests/legacy_transaction_retry_logic.py +51 -0
  142. tests/legacy_tui.py +440 -0
  143. tests/legacy_wallets_screen.py +554 -0
  144. tests/legacy_web.py +243 -0
  145. tests/test_account_service.py +120 -0
  146. tests/test_balance_service.py +186 -0
  147. tests/test_chain.py +490 -0
  148. tests/test_chain_interface.py +210 -0
  149. tests/test_cli.py +139 -0
  150. tests/test_contract.py +195 -0
  151. tests/test_db.py +180 -0
  152. tests/test_drain_coverage.py +174 -0
  153. tests/test_erc20.py +95 -0
  154. tests/test_gnosis_plugin.py +111 -0
  155. tests/test_keys.py +449 -0
  156. tests/test_legacy_wallet.py +1285 -0
  157. tests/test_main.py +13 -0
  158. tests/test_mnemonic.py +217 -0
  159. tests/test_modals.py +109 -0
  160. tests/test_models.py +213 -0
  161. tests/test_monitor.py +202 -0
  162. tests/test_multisend.py +84 -0
  163. tests/test_plugin_service.py +119 -0
  164. tests/test_pricing.py +143 -0
  165. tests/test_rate_limiter.py +199 -0
  166. tests/test_reset_tenderly.py +202 -0
  167. tests/test_rpc_view.py +73 -0
  168. tests/test_safe_coverage.py +139 -0
  169. tests/test_safe_service.py +168 -0
  170. tests/test_service_manager_integration.py +61 -0
  171. tests/test_service_manager_structure.py +31 -0
  172. tests/test_service_transaction.py +176 -0
  173. tests/test_staking_router.py +71 -0
  174. tests/test_staking_simple.py +31 -0
  175. tests/test_tables.py +76 -0
  176. tests/test_transaction_service.py +161 -0
  177. tests/test_transfer_multisend.py +179 -0
  178. tests/test_transfer_native.py +220 -0
  179. tests/test_transfer_security.py +93 -0
  180. tests/test_transfer_structure.py +37 -0
  181. tests/test_transfer_swap_unit.py +155 -0
  182. tests/test_ui_coverage.py +66 -0
  183. tests/test_utils.py +53 -0
  184. tests/test_workers.py +91 -0
  185. tools/verify_drain.py +183 -0
  186. __init__.py +0 -2
  187. hello.py +0 -6
  188. iwa-0.0.0.dist-info/METADATA +0 -10
  189. iwa-0.0.0.dist-info/RECORD +0 -6
  190. iwa-0.0.0.dist-info/top_level.txt +0 -2
  191. {iwa-0.0.0.dist-info → iwa-0.0.1a2.dist-info}/WHEEL +0 -0
iwa/core/mnemonic.py ADDED
@@ -0,0 +1,385 @@
1
+ """BIP-39 mnemonic generator, encrypt/decrypt, ETH account derivation and keystore saving."""
2
+
3
+ import base64
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Dict
8
+
9
+ from bip_utils import (
10
+ Bip39MnemonicGenerator,
11
+ Bip39SeedGenerator,
12
+ Bip39WordsNum,
13
+ Bip44,
14
+ Bip44Changes,
15
+ Bip44Coins,
16
+ )
17
+ from cryptography.exceptions import InvalidTag
18
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
19
+ from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
20
+ from eth_account import Account
21
+ from pydantic import BaseModel
22
+
23
+ from iwa.core.constants import WALLET_PATH
24
+ from iwa.core.models import EthereumAddress, StoredAccount
25
+
26
+ MNEMONIC_WORD_NUMBER = Bip39WordsNum.WORDS_NUM_24
27
+ SCRYPT_N = 2**14
28
+ SCRYPT_R = 8
29
+ SCRYPT_P = 1
30
+ SCRYPT_LEN = 32
31
+ AES_NONCE_LEN = 12
32
+ SALT_LEN = 16
33
+
34
+
35
+ class EncryptedMnemonic(BaseModel):
36
+ """EncryptedMnemonic"""
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
+ def derive_key(self, password: bytes) -> bytes:
49
+ """Derive a key from a password and salt using scrypt."""
50
+ kdf = Scrypt(
51
+ salt=base64.b64decode(self.kdf_salt),
52
+ length=self.kdf_len,
53
+ n=self.kdf_n,
54
+ r=self.kdf_r,
55
+ p=self.kdf_p,
56
+ )
57
+ return kdf.derive(password)
58
+
59
+ def decrypt(self, password: str) -> str:
60
+ """Decrypt an object."""
61
+ # validate expected algorithms
62
+ if self.kdf != "scrypt":
63
+ raise ValueError(f"Unsupported kdf: {self.kdf}")
64
+ if self.cypher != "aesgcm":
65
+ raise ValueError("Unsupported cipher, expected 'aesgcm'")
66
+
67
+ nonce = base64.b64decode(self.nonce)
68
+ ct = base64.b64decode(self.ciphertext)
69
+
70
+ # derive key using the parameters from the file
71
+ key = self.derive_key(password.encode("utf-8"))
72
+ aesgcm = AESGCM(key)
73
+ pt = aesgcm.decrypt(nonce, ct, None)
74
+ return pt.decode("utf-8")
75
+
76
+ @classmethod
77
+ def encrypt(cls, mnemonic: str, password: str) -> dict:
78
+ """Encrypt a mnemonic with AES-GCM using a scrypt-derived key."""
79
+ password_b = password.encode("utf-8")
80
+ salt = os.urandom(SALT_LEN)
81
+ # Create a temporary instance to use derive_key or use the class method if refactored
82
+ # But derive_key is an instance method using instance attributes.
83
+ # We should probably just use Scrypt directly here or refactor derive_key.
84
+ # Since derive_key uses self.kdf_n etc, we can use the constants.
85
+
86
+ kdf = Scrypt(
87
+ salt=salt,
88
+ length=SCRYPT_LEN,
89
+ n=SCRYPT_N,
90
+ r=SCRYPT_R,
91
+ p=SCRYPT_P,
92
+ )
93
+ key = kdf.derive(password_b)
94
+
95
+ aesgcm = AESGCM(key)
96
+ nonce = os.urandom(AES_NONCE_LEN)
97
+ ct = aesgcm.encrypt(nonce, mnemonic.encode("utf-8"), None)
98
+ return {
99
+ "kdf": "scrypt",
100
+ "kdf_salt": base64.b64encode(salt).decode(),
101
+ "kdf_n": SCRYPT_N,
102
+ "kdf_r": SCRYPT_R,
103
+ "kdf_p": SCRYPT_P,
104
+ "kdf_len": SCRYPT_LEN,
105
+ "cipher": "aesgcm",
106
+ "nonce": base64.b64encode(nonce).decode(),
107
+ "ciphertext": base64.b64encode(ct).decode(),
108
+ }
109
+
110
+
111
+ class MnemonicStorage(BaseModel):
112
+ """MnemonicStorage"""
113
+
114
+ encrypted_mnemonic: EncryptedMnemonic
115
+ accounts: Dict[EthereumAddress, StoredAccount] = {}
116
+
117
+ @staticmethod
118
+ def load(file_path: Path = WALLET_PATH) -> "MnemonicStorage":
119
+ """Load"""
120
+ with open(file_path, "r", encoding="utf-8") as f:
121
+ data = json.load(f)
122
+ data["encrypted_mnemonic"] = EncryptedMnemonic(**data["encrypted_mnemonic"])
123
+ data["accounts"] = {k: StoredAccount(**v) for k, v in data.get("accounts", {}).items()}
124
+ return MnemonicStorage(**data)
125
+
126
+ def save(self, file_path: Path = WALLET_PATH) -> None:
127
+ """Save mnemonic storage to file with secure permissions."""
128
+ with open(file_path, "w", encoding="utf-8") as f:
129
+ json.dump(self.model_dump(), f, indent=4)
130
+ # SECURITY: Restrict file permissions to owner only
131
+ os.chmod(file_path, 0o600)
132
+
133
+
134
+ class MnemonicManager:
135
+ """Manager for BIP-39 mnemonics and keystore operations.
136
+
137
+ Provides methods to generate mnemonics, encrypt/decrypt them using
138
+ scrypt + AES-GCM, derive Ethereum accounts (BIP-44), and save
139
+ keystores to disk.
140
+
141
+ Attributes:
142
+ mnemonic_file (str): Default file path for the encrypted mnemonic.
143
+ mnemonic_word_number (Bip39WordsNum): Number of words in the mnemonic.
144
+ scrypt_*: Parameters for the scrypt KDF.
145
+ aes_nonce_len (int): Nonce length for AES-GCM.
146
+ salt_len (int): Salt length for scrypt.
147
+
148
+ """
149
+
150
+ def __init__(
151
+ self,
152
+ mnemonic_file: str = WALLET_PATH,
153
+ mnemonic_word_number: Bip39WordsNum = MNEMONIC_WORD_NUMBER,
154
+ scrypt_n: int = SCRYPT_N,
155
+ scrypt_r: int = SCRYPT_R,
156
+ scrypt_p: int = SCRYPT_P,
157
+ scrypt_len: int = SCRYPT_LEN,
158
+ aes_nonce_len: int = AES_NONCE_LEN,
159
+ salt_len: int = SALT_LEN,
160
+ ):
161
+ """Initialize MnemonicManager with configuration parameters."""
162
+ self.mnemonic_file = mnemonic_file
163
+ self.mnemonic_word_number = mnemonic_word_number
164
+ self.scrypt_n = scrypt_n
165
+ self.scrypt_r = scrypt_r
166
+ self.scrypt_p = scrypt_p
167
+ self.scrypt_len = scrypt_len
168
+ self.aes_nonce_len = aes_nonce_len
169
+ self.salt_len = salt_len
170
+
171
+ def derive_key(
172
+ self,
173
+ password: bytes,
174
+ salt: bytes,
175
+ n: int | None = None,
176
+ r: int | None = None,
177
+ p: int | None = None,
178
+ length: int | None = None,
179
+ ) -> bytes:
180
+ """Derive a key from a password and salt using scrypt.
181
+
182
+ Args:
183
+ password (bytes): The password in bytes.
184
+ salt (bytes): A random salt.
185
+ n (int | None): CPU/memory cost factor.
186
+ r (int | None): Block size parameter.
187
+ p (int | None): Parallelization parameter.
188
+ length (int | None): Desired key length in bytes.
189
+
190
+ Returns:
191
+ bytes: The derived key of length `self.scrypt_len`.
192
+
193
+ """
194
+ # use provided parameters or fall back to instance defaults
195
+ n = n if n is not None else self.scrypt_n
196
+ r = r if r is not None else self.scrypt_r
197
+ p = p if p is not None else self.scrypt_p
198
+ length = length if length is not None else self.scrypt_len
199
+
200
+ kdf = Scrypt(
201
+ salt=salt,
202
+ length=length,
203
+ n=n,
204
+ r=r,
205
+ p=p,
206
+ )
207
+ return kdf.derive(password)
208
+
209
+ def encrypt_mnemonic(self, mnemonic: str, password: str) -> dict:
210
+ """Encrypt a mnemonic with AES-GCM using a scrypt-derived key.
211
+
212
+ Args:
213
+ mnemonic (str): The mnemonic as plain text.
214
+ password (str): Password used to derive the encryption key.
215
+
216
+ Returns:
217
+ dict: JSON-serializable object containing KDF params, nonce,
218
+ and ciphertext (all base64-encoded).
219
+
220
+ """
221
+ password_b = password.encode("utf-8")
222
+ salt = os.urandom(self.salt_len)
223
+ key = self.derive_key(password_b, salt)
224
+ aesgcm = AESGCM(key)
225
+ nonce = os.urandom(self.aes_nonce_len)
226
+ ct = aesgcm.encrypt(nonce, mnemonic.encode("utf-8"), None)
227
+ return {
228
+ "kdf": "scrypt",
229
+ "kdf_salt": base64.b64encode(salt).decode(),
230
+ "kdf_n": self.scrypt_n,
231
+ "kdf_r": self.scrypt_r,
232
+ "kdf_p": self.scrypt_p,
233
+ "kdf_len": self.scrypt_len,
234
+ "cipher": "aesgcm",
235
+ "nonce": base64.b64encode(nonce).decode(),
236
+ "ciphertext": base64.b64encode(ct).decode(),
237
+ }
238
+
239
+ def decrypt_mnemonic(self, encobj: dict, password: str) -> str:
240
+ """Decrypt an object previously created by `encrypt_mnemonic`.
241
+
242
+ Args:
243
+ encobj (dict): Object with KDF params, nonce and ciphertext
244
+ encoded in base64.
245
+ password (str): Password to derive the decryption key.
246
+
247
+ Returns:
248
+ str: The mnemonic in plain text.
249
+
250
+ """
251
+ # validate expected algorithms
252
+ kdf_name = encobj.get("kdf", "scrypt")
253
+ if kdf_name != "scrypt":
254
+ raise ValueError(f"Unsupported kdf: {kdf_name}")
255
+ if encobj.get("cipher", "aesgcm") != "aesgcm":
256
+ raise ValueError("Unsupported cipher, expected 'aesgcm'")
257
+
258
+ salt = base64.b64decode(encobj["kdf_salt"])
259
+ nonce = base64.b64decode(encobj["nonce"])
260
+ ct = base64.b64decode(encobj["ciphertext"])
261
+
262
+ # read kdf params from the encoded object, falling back to defaults
263
+ n = int(encobj.get("kdf_n", self.scrypt_n))
264
+ r = int(encobj.get("kdf_r", self.scrypt_r))
265
+ p = int(encobj.get("kdf_p", self.scrypt_p))
266
+ length = int(encobj.get("kdf_len", self.scrypt_len))
267
+
268
+ # derive key using the parameters from the file
269
+ key = self.derive_key(password.encode("utf-8"), salt, n=n, r=r, p=p, length=length)
270
+ aesgcm = AESGCM(key)
271
+ pt = aesgcm.decrypt(nonce, ct, None)
272
+ return pt.decode("utf-8")
273
+
274
+ def generate_and_store_mnemonic(
275
+ self,
276
+ password: str,
277
+ out_file: str = None,
278
+ ) -> str:
279
+ """Generate a BIP-39 mnemonic, encrypt it and save to disk.
280
+
281
+ Args:
282
+ password (str): Password to encrypt the mnemonic.
283
+ out_file (str): Destination file. Optional; if None this method
284
+ uses `self.mnemonic_file`.
285
+
286
+ Returns:
287
+ str: The plaintext mnemonic (returned so data is available).
288
+
289
+ """
290
+ out_file = out_file or self.mnemonic_file
291
+ mnemonic = Bip39MnemonicGenerator().FromWordsNumber(self.mnemonic_word_number)
292
+ mnemonic_str = mnemonic.ToStr()
293
+ enc = self.encrypt_mnemonic(mnemonic_str, password)
294
+ with open(out_file, "w", encoding="utf-8") as f:
295
+ json.dump(enc, f, indent=2)
296
+ os.chmod(out_file, 0o600)
297
+ return mnemonic_str
298
+
299
+ def load_and_decrypt_mnemonic(
300
+ self,
301
+ password: str,
302
+ in_file: str = None,
303
+ ) -> str:
304
+ """Load and decrypt a mnemonic from a file.
305
+
306
+ Args:
307
+ password (str): Password to decrypt the mnemonic.
308
+ in_file (str): File path with the encrypted object. Optional;
309
+ if None `self.mnemonic_file` is used.
310
+
311
+ Returns:
312
+ str: The plaintext mnemonic.
313
+
314
+ """
315
+ in_file = in_file or self.mnemonic_file
316
+ with open(in_file, "r", encoding="utf-8") as f:
317
+ enc = json.load(f)
318
+ try:
319
+ mnemonic = self.decrypt_mnemonic(enc, password)
320
+ return mnemonic
321
+ except InvalidTag as e:
322
+ raise ValueError("Incorrect password") from e
323
+
324
+ def derive_eth_accounts_from_mnemonic(
325
+ self,
326
+ password: str,
327
+ n_accounts: int = 5,
328
+ ):
329
+ """Derive Ethereum accounts (BIP-44) from a BIP-39 mnemonic.
330
+
331
+ Args:
332
+ password (str): Password to decrypt the mnemonic.
333
+ n_accounts (int): Number of accounts to derive.
334
+
335
+ Returns:
336
+ list: Dicts with keys 'index', 'address' and 'private_key_hex'.
337
+
338
+ """
339
+ mnemonic = self.load_and_decrypt_mnemonic(password)
340
+
341
+ if mnemonic is None:
342
+ return None
343
+
344
+ accounts = []
345
+ for i in range(n_accounts):
346
+ # obtain private key (hex) for this index using helper
347
+ priv_hex = self.derive_private_key_hex_from_mnemonic(mnemonic, i)
348
+ priv_bytes = bytes.fromhex(priv_hex)
349
+ acct = Account.from_key(priv_bytes)
350
+ accounts.append(
351
+ {
352
+ "index": i,
353
+ "address": acct.address,
354
+ "private_key_hex": priv_hex,
355
+ }
356
+ )
357
+ del priv_bytes, acct, priv_hex # clean up sensitive data
358
+ return accounts
359
+
360
+ def derive_private_key_hex_from_mnemonic(
361
+ self,
362
+ mnemonic: str,
363
+ index: int,
364
+ account: int = 0,
365
+ change: Bip44Changes = Bip44Changes.CHAIN_EXT,
366
+ ) -> str:
367
+ """Derive the private key (hex) for a given account index from a mnemonic.
368
+
369
+ Args:
370
+ mnemonic (str): The plaintext BIP-39 mnemonic.
371
+ index (int): Address index to derive.
372
+ account (int): BIP-44 account index (default 0).
373
+ change (Bip44Changes): Change chain (external/internal).
374
+
375
+ Returns:
376
+ str: Private key as a hex string (no 0x prefix).
377
+
378
+ """
379
+ seed_bytes = Bip39SeedGenerator(mnemonic).Generate()
380
+ bip44_mst = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM)
381
+ # Build the context step by step to avoid long lines
382
+ ctx = bip44_mst.Purpose().Coin().Account(account)
383
+ ctx = ctx.Change(change)
384
+ addr_ctx = ctx.AddressIndex(index)
385
+ return addr_ctx.PrivateKey().Raw().ToHex()