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/db.py ADDED
@@ -0,0 +1,317 @@
1
+ """Database models and utilities."""
2
+
3
+ import json
4
+ from datetime import datetime
5
+
6
+ from loguru import logger
7
+ from peewee import (
8
+ CharField,
9
+ DateTimeField,
10
+ FloatField,
11
+ Model,
12
+ SqliteDatabase,
13
+ )
14
+ from playhouse.migrate import SqliteMigrator, migrate
15
+
16
+ from iwa.core.constants import DATA_DIR
17
+
18
+ # Database stored in data directory alongside other data files
19
+ DB_PATH = DATA_DIR / "activity.db"
20
+
21
+ db = SqliteDatabase(
22
+ str(DB_PATH),
23
+ pragmas={
24
+ "journal_mode": "wal",
25
+ "cache_size": -1 * 64000,
26
+ "foreign_keys": 1,
27
+ "ignore_check_constraints": 0,
28
+ "synchronous": 0,
29
+ "busy_timeout": 5000,
30
+ },
31
+ )
32
+
33
+
34
+ class BaseModel(Model):
35
+ """Base Peewee model."""
36
+
37
+ class Meta:
38
+ """Meta configuration."""
39
+
40
+ database = db
41
+
42
+
43
+ class SentTransaction(BaseModel):
44
+ """Model for sent transactions."""
45
+
46
+ tx_hash = CharField(primary_key=True)
47
+ from_address = CharField(index=True)
48
+ from_tag = CharField(null=True)
49
+ to_address = CharField(index=True)
50
+ to_tag = CharField(null=True)
51
+ token = CharField() # Contract Address (ERC20) or Symbol (Native)
52
+ amount_wei = CharField() # Store as string to avoid precision loss
53
+ chain = CharField()
54
+ timestamp = DateTimeField(default=datetime.now)
55
+ status = CharField(default="Pending")
56
+ # Pricing info
57
+ price_eur = FloatField(null=True)
58
+ value_eur = FloatField(null=True)
59
+ gas_cost = CharField(null=True) # Wei
60
+ gas_value_eur = FloatField(null=True)
61
+ tags = CharField(null=True) # JSON-encoded list of strings
62
+ extra_data = CharField(null=True) # JSON-encoded dictionary for arbitrary metadata
63
+
64
+
65
+ def _migration_drop_deprecated_columns(migrator: SqliteMigrator, columns: list[str]) -> None:
66
+ """Drop deprecated columns."""
67
+ if "token_symbol" in columns:
68
+ try:
69
+ migrate(migrator.drop_column("senttransaction", "token_symbol"))
70
+ except Exception as e:
71
+ logger.warning(f"Migration (drop token_symbol) failed: {e}")
72
+
73
+
74
+ def _migration_add_tag_columns(migrator: SqliteMigrator, columns: list[str]) -> None:
75
+ """Add from_tag, to_tag, and token_symbol columns."""
76
+ if "from_tag" not in columns:
77
+ try:
78
+ migrate(
79
+ migrator.add_column("senttransaction", "from_tag", CharField(null=True)),
80
+ migrator.add_column("senttransaction", "to_tag", CharField(null=True)),
81
+ migrator.add_column("senttransaction", "token_symbol", CharField(null=True)),
82
+ )
83
+ except Exception as e:
84
+ logger.warning(f"Migration (tags/symbol) failed: {e}")
85
+
86
+
87
+ def _migration_add_pricing_columns(migrator: SqliteMigrator, columns: list[str]) -> None:
88
+ """Add pricing related columns."""
89
+ if "price_eur" not in columns:
90
+ try:
91
+ migrate(
92
+ migrator.add_column("senttransaction", "price_eur", FloatField(null=True)),
93
+ migrator.add_column("senttransaction", "value_eur", FloatField(null=True)),
94
+ migrator.add_column("senttransaction", "gas_cost", CharField(null=True)),
95
+ migrator.add_column("senttransaction", "gas_value_eur", FloatField(null=True)),
96
+ )
97
+ except Exception as e:
98
+ logger.warning(f"Migration (pricing) failed: {e}")
99
+
100
+
101
+ def _migration_add_tags_column(migrator: SqliteMigrator, columns: list[str]) -> None:
102
+ """Add tags column."""
103
+ if "tags" not in columns:
104
+ try:
105
+ migrate(migrator.add_column("senttransaction", "tags", CharField(null=True)))
106
+ except Exception as e:
107
+ logger.warning(f"Migration (tags) failed: {e}")
108
+
109
+
110
+ def _migration_add_extra_data_column(migrator: SqliteMigrator, columns: list[str]) -> None:
111
+ """Add extra_data column."""
112
+ if "extra_data" not in columns:
113
+ try:
114
+ migrate(migrator.add_column("senttransaction", "extra_data", CharField(null=True)))
115
+ except Exception as e:
116
+ logger.warning(f"Migration (extra_data) failed: {e}")
117
+
118
+
119
+ def run_migrations(columns: list[str]) -> None:
120
+ """Run database migrations."""
121
+ migrator = SqliteMigrator(db)
122
+
123
+ migrations = [
124
+ _migration_drop_deprecated_columns,
125
+ _migration_add_tag_columns,
126
+ _migration_add_pricing_columns,
127
+ _migration_add_tags_column,
128
+ _migration_add_extra_data_column,
129
+ ]
130
+
131
+ for migration in migrations:
132
+ migration(migrator, columns)
133
+
134
+
135
+ def init_db():
136
+ """Initialize the database."""
137
+ if db.is_closed():
138
+ db.connect()
139
+ db.create_tables([SentTransaction], safe=True)
140
+
141
+ # Simple migration: check if columns exist, if not add them
142
+ try:
143
+ columns = [c.name for c in db.get_columns("senttransaction")]
144
+ run_migrations(columns)
145
+ except Exception:
146
+ pass
147
+
148
+ if not db.is_closed():
149
+ db.close()
150
+
151
+
152
+ def _get_existing_transaction_data(tx_hash: str) -> tuple[SentTransaction | None, list, dict]:
153
+ """Retrieve existing transaction and parse its tags/extra_data."""
154
+ if not tx_hash.startswith("0x"):
155
+ tx_hash = "0x" + tx_hash
156
+
157
+ existing = SentTransaction.get_or_none(SentTransaction.tx_hash == tx_hash)
158
+ existing_tags = []
159
+ existing_extra = {}
160
+
161
+ if existing:
162
+ if existing.tags:
163
+ try:
164
+ existing_tags = json.loads(existing.tags)
165
+ except Exception:
166
+ existing_tags = []
167
+ if existing.extra_data:
168
+ try:
169
+ existing_extra = json.loads(existing.extra_data)
170
+ except Exception:
171
+ existing_extra = {}
172
+
173
+ return existing, existing_tags, existing_extra
174
+
175
+
176
+ def _merge_transaction_tags(existing_tags: list, new_tags: list | None) -> list:
177
+ """Merge existing and new tags."""
178
+ tags_to_add = list(new_tags) if new_tags else []
179
+ return list(set(existing_tags + tags_to_add))
180
+
181
+
182
+ def _merge_transaction_extra_data(existing_extra: dict, new_extra: dict | None) -> dict:
183
+ """Merge existing and new extra_data."""
184
+ extra_to_add = new_extra if new_extra else {}
185
+ return {**existing_extra, **extra_to_add}
186
+
187
+
188
+ def _resolve_final_token_and_amount(
189
+ existing: SentTransaction | None,
190
+ token: str,
191
+ amount_wei: int,
192
+ price_eur: float | None,
193
+ value_eur: float | None,
194
+ ) -> tuple[str, str, float | None, float | None]:
195
+ """Resolve token name and amount, preserving ERC20 info over native currency if needed."""
196
+ final_token = token
197
+ final_amount_wei = str(amount_wei)
198
+ final_price_eur = price_eur
199
+ final_value_eur = value_eur
200
+
201
+ # Native currency names that should not overwrite ERC20 tokens
202
+ native_tokens = {"TOKEN", "NATIVE", "xDAI", "ETH", "XDAI"}
203
+
204
+ if existing and existing.token:
205
+ # If existing token is a real ERC20 (not native), preserve it
206
+ if existing.token.upper() not in native_tokens and token.upper() in native_tokens:
207
+ final_token = existing.token
208
+ # Force preservation of price and value even if new ones are passed
209
+ final_price_eur = existing.price_eur
210
+ final_value_eur = existing.value_eur
211
+ # Only preserve amount if the new one is 0
212
+ if int(amount_wei) == 0:
213
+ final_amount_wei = existing.amount_wei
214
+
215
+ return final_token, final_amount_wei, final_price_eur, final_value_eur
216
+
217
+
218
+ def _prepare_transaction_record(
219
+ tx_hash: str,
220
+ from_addr: str,
221
+ from_tag: str | None,
222
+ to_addr: str,
223
+ to_tag: str | None,
224
+ chain: str,
225
+ gas_cost: str | None,
226
+ gas_value_eur: float | None,
227
+ existing: SentTransaction | None,
228
+ final_token: str,
229
+ final_amount_wei: str,
230
+ final_price_eur: float | None,
231
+ final_value_eur: float | None,
232
+ merged_tags: list,
233
+ merged_extra: dict,
234
+ ) -> dict:
235
+ """Prepare the dictionary for database insertion."""
236
+ if not tx_hash.startswith("0x"):
237
+ tx_hash = "0x" + tx_hash
238
+
239
+ return {
240
+ "tx_hash": tx_hash,
241
+ "from_address": from_addr,
242
+ "from_tag": from_tag or (existing.from_tag if existing else None),
243
+ "to_address": to_addr,
244
+ "to_tag": to_tag or (existing.to_tag if existing else None),
245
+ "token": final_token,
246
+ "status": "Confirmed",
247
+ "amount_wei": final_amount_wei,
248
+ "chain": chain,
249
+ "price_eur": final_price_eur
250
+ if final_price_eur is not None
251
+ else (existing.price_eur if existing else None),
252
+ "value_eur": final_value_eur
253
+ if final_value_eur is not None
254
+ else (existing.value_eur if existing else None),
255
+ "gas_cost": str(gas_cost)
256
+ if gas_cost is not None
257
+ else (existing.gas_cost if existing else None),
258
+ "gas_value_eur": gas_value_eur
259
+ if gas_value_eur is not None
260
+ else (existing.gas_value_eur if existing else None),
261
+ "tags": json.dumps(merged_tags) if merged_tags else (existing.tags if existing else None),
262
+ "extra_data": json.dumps(merged_extra)
263
+ if merged_extra
264
+ else (existing.extra_data if existing else None),
265
+ }
266
+
267
+
268
+ def log_transaction(
269
+ tx_hash,
270
+ from_addr,
271
+ to_addr,
272
+ token,
273
+ amount_wei,
274
+ chain,
275
+ from_tag=None,
276
+ to_tag=None,
277
+ price_eur=None,
278
+ value_eur=None,
279
+ gas_cost=None,
280
+ gas_value_eur=None,
281
+ tags=None,
282
+ extra_data=None,
283
+ ):
284
+ """Log a transaction to the database (create or update)."""
285
+ try:
286
+ with db:
287
+ existing, existing_tags, existing_extra = _get_existing_transaction_data(tx_hash)
288
+
289
+ merged_tags = _merge_transaction_tags(existing_tags, tags)
290
+ merged_extra = _merge_transaction_extra_data(existing_extra, extra_data)
291
+
292
+ final_token, final_amount_wei, final_price, final_value = (
293
+ _resolve_final_token_and_amount(existing, token, amount_wei, price_eur, value_eur)
294
+ )
295
+
296
+ data = _prepare_transaction_record(
297
+ tx_hash,
298
+ from_addr,
299
+ from_tag,
300
+ to_addr,
301
+ to_tag,
302
+ chain,
303
+ gas_cost,
304
+ gas_value_eur,
305
+ existing,
306
+ final_token,
307
+ final_amount_wei,
308
+ final_price,
309
+ final_value,
310
+ merged_tags,
311
+ merged_extra,
312
+ )
313
+
314
+ SentTransaction.insert(**data).on_conflict_replace().execute()
315
+
316
+ except Exception as e:
317
+ logger.error(f"Failed to log transaction: {e}")
iwa/core/keys.py ADDED
@@ -0,0 +1,361 @@
1
+ """Wallet management"""
2
+
3
+ import base64
4
+ import json
5
+ import os
6
+ import shutil
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Dict, Optional, Union
10
+
11
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
12
+ from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
13
+ from eth_account import Account
14
+ from eth_account.signers.local import LocalAccount
15
+ from pydantic import BaseModel, PrivateAttr
16
+
17
+ from iwa.core.constants import WALLET_PATH
18
+ from iwa.core.models import EthereumAddress, StoredAccount, StoredSafeAccount
19
+ from iwa.core.settings import settings
20
+ from iwa.core.utils import (
21
+ configure_logger,
22
+ )
23
+
24
+ logger = configure_logger()
25
+
26
+
27
+ class EncryptedAccount(StoredAccount):
28
+ """EncryptedAccount"""
29
+
30
+ salt: str
31
+ nonce: str
32
+ ciphertext: str
33
+
34
+ @staticmethod
35
+ def derive_key(password: str, salt: bytes) -> bytes:
36
+ """Derive key"""
37
+ kdf = Scrypt(
38
+ salt=salt,
39
+ length=32,
40
+ n=2**14,
41
+ r=8,
42
+ p=1,
43
+ )
44
+ return kdf.derive(password.encode())
45
+
46
+ def decrypt_private_key(self, password: Optional[str] = None) -> str:
47
+ """decrypt_private_key"""
48
+ if not password and not settings.wallet_password:
49
+ raise ValueError("Password must be provided or set in secrets.env (WALLET_PASSWORD)")
50
+ if not password:
51
+ password = settings.wallet_password.get_secret_value()
52
+ salt_bytes = base64.b64decode(self.salt)
53
+ nonce_bytes = base64.b64decode(self.nonce)
54
+ ciphertext_bytes = base64.b64decode(self.ciphertext)
55
+ key = EncryptedAccount.derive_key(password, salt_bytes)
56
+ aesgcm = AESGCM(key)
57
+ return aesgcm.decrypt(nonce_bytes, ciphertext_bytes, None).decode()
58
+
59
+ @staticmethod
60
+ def encrypt_private_key(
61
+ private_key: str, password: str, tag: Optional[str] = None
62
+ ) -> "EncryptedAccount":
63
+ """Encrypt private key"""
64
+ salt = os.urandom(16)
65
+ key = EncryptedAccount.derive_key(password, salt)
66
+ aesgcm = AESGCM(key)
67
+ nonce = os.urandom(12)
68
+ ciphertext = aesgcm.encrypt(nonce, private_key.encode(), None)
69
+
70
+ acct = Account.from_key(private_key)
71
+ return EncryptedAccount(
72
+ address=acct.address,
73
+ salt=base64.b64encode(salt).decode(),
74
+ nonce=base64.b64encode(nonce).decode(),
75
+ ciphertext=base64.b64encode(ciphertext).decode(),
76
+ tag=tag,
77
+ )
78
+
79
+
80
+ class KeyStorage(BaseModel):
81
+ """KeyStorage"""
82
+
83
+ accounts: Dict[EthereumAddress, Union[EncryptedAccount, StoredSafeAccount]] = {}
84
+ _path: Path = PrivateAttr() # not stored nor validated
85
+ _password: str = PrivateAttr()
86
+
87
+ def __init__(self, path: Path = Path(WALLET_PATH), password: Optional[str] = None):
88
+ """Initialize key storage."""
89
+ super().__init__()
90
+
91
+ # PROTECTION: Prevent tests from accidentally using real wallet.json
92
+ import sys
93
+
94
+ is_test = "pytest" in sys.modules or "unittest" in sys.modules
95
+ if is_test:
96
+ real_wallet = Path(WALLET_PATH).resolve()
97
+ given_path = Path(path).resolve()
98
+ # Block if path points to the real wallet (even if mocked)
99
+ if given_path == real_wallet or str(given_path).endswith("wallet.json"):
100
+ # Check if we're in a temp directory (allowed)
101
+ import tempfile
102
+
103
+ temp_base = Path(tempfile.gettempdir()).resolve()
104
+ if not str(given_path).startswith(str(temp_base)):
105
+ raise RuntimeError(
106
+ f"SECURITY: Tests cannot use real wallet path '{path}'. "
107
+ f"Use tmp_path fixture instead: KeyStorage(tmp_path / 'wallet.json')"
108
+ )
109
+
110
+ self._path = path
111
+ if password is None:
112
+ password = settings.wallet_password.get_secret_value()
113
+ self._password = password
114
+
115
+ if os.path.exists(path):
116
+ try:
117
+ with open(path, "r") as f:
118
+ data = json.load(f)
119
+ self.accounts = {
120
+ k: EncryptedAccount(**v) if "signers" not in v else StoredSafeAccount(**v)
121
+ for k, v in data.get("accounts", {}).items()
122
+ }
123
+ except json.JSONDecodeError:
124
+ logger.error(f"Failed to load wallet from {path}: File is corrupted.")
125
+ self.accounts = {}
126
+ else:
127
+ self.accounts = {}
128
+
129
+ # Ensure 'master' account exists
130
+ if not self.get_address_by_tag("master"):
131
+ logger.info("Master account not found. Creating new 'master' account...")
132
+ try:
133
+ self.create_account("master")
134
+ except Exception as e:
135
+ logger.error(f"Failed to create master account: {e}")
136
+
137
+ @property
138
+ def master_account(self) -> EncryptedAccount:
139
+ """Get the master account"""
140
+ master_account = self.get_account("master")
141
+
142
+ if not master_account:
143
+ return list(self.accounts.values())[0]
144
+
145
+ return master_account
146
+
147
+ def save(self):
148
+ """Save with automatic backup."""
149
+ # Backup existing file before overwriting
150
+ if self._path.exists():
151
+ # Use backup directory relative to wallet path (supports tests with tmp_path)
152
+ backup_dir = self._path.parent / "backup"
153
+ backup_dir.mkdir(parents=True, exist_ok=True)
154
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
155
+ backup_path = backup_dir / f"wallet.json.{timestamp}.bkp"
156
+ shutil.copy2(self._path, backup_path)
157
+ logger.debug(f"Backed up wallet to {backup_path}")
158
+
159
+ # Ensure directory exists
160
+ self._path.parent.mkdir(parents=True, exist_ok=True)
161
+
162
+ with open(self._path, "w", encoding="utf-8") as f:
163
+ json.dump(self.model_dump(), f, indent=4)
164
+
165
+ # Enforce read/write only for the owner
166
+ os.chmod(self._path, 0o600)
167
+
168
+ def create_account(self, tag: str) -> EncryptedAccount:
169
+ """Create account"""
170
+ tags = [acct.tag for acct in self.accounts.values()]
171
+ if not tags:
172
+ tag = "master" # First account is always master
173
+ if tag in tags:
174
+ raise ValueError(f"Tag '{tag}' already exists in wallet.")
175
+
176
+ acct = Account.create()
177
+
178
+ encrypted = EncryptedAccount.encrypt_private_key(acct.key.hex(), self._password, tag)
179
+ self.accounts[acct.address] = encrypted
180
+ self.save()
181
+ return encrypted
182
+
183
+ def remove_account(self, address_or_tag: str):
184
+ """Remove account"""
185
+ account = self.find_stored_account(address_or_tag)
186
+ if not account:
187
+ return
188
+
189
+ del self.accounts[account.address]
190
+ self.save()
191
+
192
+ def _get_private_key(self, address: str) -> Optional[str]:
193
+ """Get private key (Internal)"""
194
+ account = self.accounts.get(EthereumAddress(address))
195
+ if not account:
196
+ return None
197
+ if isinstance(account, StoredSafeAccount):
198
+ raise ValueError(f"Cannot get private key for Safe account {address}")
199
+
200
+ return account.decrypt_private_key(self._password)
201
+
202
+ # NOTE: get_private_key_unsafe() was removed for security reasons.
203
+ # Use sign_transaction(), sign_message(), or get_signer() instead.
204
+
205
+ def sign_message(self, message: bytes, signer_address_or_tag: str) -> bytes:
206
+ """Sign a message internally without exposing the private key.
207
+
208
+ Args:
209
+ message: The message bytes to sign
210
+ signer_address_or_tag: The address or tag of the signer
211
+
212
+ Returns:
213
+ The signature bytes
214
+
215
+ """
216
+ signer_account = self.find_stored_account(signer_address_or_tag)
217
+ if not signer_account:
218
+ raise ValueError(f"Signer account '{signer_address_or_tag}' not found.")
219
+
220
+ if isinstance(signer_account, StoredSafeAccount):
221
+ raise ValueError("Direct message signing not supported for Safe accounts.")
222
+
223
+ private_key = self._get_private_key(signer_account.address)
224
+ if not private_key:
225
+ raise ValueError(f"Private key not found for {signer_address_or_tag}")
226
+
227
+ from eth_account.messages import encode_defunct
228
+
229
+ message_hash = encode_defunct(primitive=message)
230
+ signed = Account.sign_message(message_hash, private_key=private_key)
231
+ return signed.signature
232
+
233
+ def sign_typed_data(self, typed_data: dict, signer_address_or_tag: str) -> bytes:
234
+ """Sign EIP-712 typed data internally without exposing the private key.
235
+
236
+ Args:
237
+ typed_data: EIP-712 typed data dictionary
238
+ signer_address_or_tag: The address or tag of the signer
239
+
240
+ Returns:
241
+ The signature bytes
242
+
243
+ """
244
+ signer_account = self.find_stored_account(signer_address_or_tag)
245
+ if not signer_account:
246
+ raise ValueError(f"Signer account '{signer_address_or_tag}' not found.")
247
+
248
+ if isinstance(signer_account, StoredSafeAccount):
249
+ raise ValueError("Direct message signing not supported for Safe accounts.")
250
+
251
+ private_key = self._get_private_key(signer_account.address)
252
+ if not private_key:
253
+ raise ValueError(f"Private key not found for {signer_address_or_tag}")
254
+
255
+ signed = Account.sign_typed_data(private_key=private_key, full_message=typed_data)
256
+ return signed.signature
257
+
258
+ def get_signer(self, address_or_tag: str) -> Optional[LocalAccount]:
259
+ """Get a LocalAccount signer for the address or tag.
260
+
261
+ ⚠️ SECURITY WARNING: This method returns a LocalAccount object which
262
+ encapsulates the private key. The private key is accessible via the
263
+ .key property on the returned object.
264
+
265
+ USE CASES:
266
+ - Only use this when an external library requires a signer object
267
+ (e.g., CowSwap SDK, safe-eth-py for certain operations)
268
+
269
+ DO NOT:
270
+ - Log or serialize the returned LocalAccount object
271
+ - Store the returned object longer than necessary
272
+ - Pass the .key property to any external system
273
+
274
+ ALTERNATIVES:
275
+ - For signing transactions: use sign_transaction() instead
276
+ - For message signing: use sign_message() or sign_typed_data()
277
+
278
+ Args:
279
+ address_or_tag: Address or tag of the account to get signer for.
280
+
281
+ Returns:
282
+ LocalAccount if found and is an EOA, None otherwise.
283
+ Returns None for Safe accounts (they cannot sign directly).
284
+
285
+ """
286
+ account = self.find_stored_account(address_or_tag)
287
+ if not account:
288
+ return None
289
+
290
+ # Safe accounts cannot be signers directly in this context (usually)
291
+ if isinstance(account, StoredSafeAccount):
292
+ return None
293
+
294
+ private_key = self._get_private_key(account.address)
295
+ if not private_key:
296
+ return None
297
+
298
+ return Account.from_key(private_key)
299
+
300
+ def sign_transaction(self, transaction: dict, signer_address_or_tag: str):
301
+ """Sign a transaction"""
302
+ signer_account = self.find_stored_account(signer_address_or_tag)
303
+ if not signer_account:
304
+ raise ValueError(f"Signer account '{signer_address_or_tag}' not found.")
305
+
306
+ if isinstance(signer_account, StoredSafeAccount):
307
+ raise ValueError("Direct transaction signing not supported for Safe accounts.")
308
+
309
+ private_key = self._get_private_key(signer_account.address)
310
+ if not private_key:
311
+ raise ValueError(f"Private key not found for {signer_address_or_tag}")
312
+
313
+ signed = Account.sign_transaction(transaction, private_key)
314
+ return signed
315
+
316
+ # ... (create_safe omitted for brevity, but I should log there too if needed)
317
+
318
+ def find_stored_account(
319
+ self, address_or_tag: str
320
+ ) -> Optional[Union[EncryptedAccount, StoredSafeAccount]]:
321
+ """Find a stored account by address or tag."""
322
+ # Try tag first
323
+ for acc in self.accounts.values():
324
+ if acc.tag == address_or_tag:
325
+ return acc
326
+
327
+ # Then try address
328
+ try:
329
+ addr = EthereumAddress(address_or_tag)
330
+ return self.accounts.get(addr)
331
+ except ValueError:
332
+ return None
333
+
334
+ def get_account(self, address_or_tag: str) -> Optional[Union[StoredAccount, StoredSafeAccount]]:
335
+ """Get basic account info without exposing any possibility of private key access."""
336
+ stored = self.find_stored_account(address_or_tag)
337
+ if not stored:
338
+ return None
339
+ if isinstance(stored, StoredSafeAccount):
340
+ return stored
341
+ return StoredAccount(address=stored.address, tag=stored.tag)
342
+
343
+ def get_account_info(
344
+ self, address_or_tag: str
345
+ ) -> Optional[Union[StoredAccount, StoredSafeAccount]]:
346
+ """Alias for get_account for clarity when specifically requesting metadata."""
347
+ return self.get_account(address_or_tag)
348
+
349
+ def get_tag_by_address(self, address: EthereumAddress) -> Optional[str]:
350
+ """Get tag by address"""
351
+ account = self.accounts.get(EthereumAddress(address))
352
+ if account:
353
+ return account.tag
354
+ return None
355
+
356
+ def get_address_by_tag(self, tag: str) -> Optional[EthereumAddress]:
357
+ """Get address by tag"""
358
+ for account in self.accounts.values():
359
+ if account.tag == tag:
360
+ return EthereumAddress(account.address)
361
+ return None