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
@@ -0,0 +1,749 @@
1
+ """Wallets Screen for the IWA TUI."""
2
+
3
+ import datetime
4
+ import json
5
+ import time
6
+ from typing import TYPE_CHECKING, List
7
+
8
+ from loguru import logger
9
+ from rich.markup import escape
10
+ from rich.text import Text
11
+ from textual import on, work
12
+ from textual.app import ComposeResult
13
+ from textual.containers import (
14
+ Center,
15
+ Horizontal,
16
+ HorizontalScroll,
17
+ VerticalScroll,
18
+ )
19
+ from textual.widgets import (
20
+ Button,
21
+ Checkbox,
22
+ DataTable,
23
+ Input,
24
+ Label,
25
+ Select,
26
+ )
27
+
28
+ from iwa.tui.workers import MonitorWorker
29
+
30
+ if TYPE_CHECKING:
31
+ pass
32
+
33
+ from iwa.core.chain import ChainInterfaces
34
+ from iwa.core.models import Config, StoredSafeAccount
35
+ from iwa.core.monitor import EventMonitor
36
+ from iwa.core.pricing import PriceService
37
+ from iwa.core.utils import configure_logger
38
+ from iwa.core.wallet import Wallet
39
+ from iwa.tui.modals import CreateEOAModal, CreateSafeModal
40
+ from iwa.tui.widgets import AccountTable, ChainSelector, TransactionTable
41
+
42
+ # configure_logger() already configures loguru.logger, so we just use that.
43
+ configure_logger()
44
+
45
+
46
+ class WalletsScreen(VerticalScroll):
47
+ """View for managing wallets (EOAs and Safes), viewing balances, and sending transactions.
48
+
49
+ This screen handles:
50
+ - Displaying account balances (native and tokens).
51
+ - Managing chain selection.
52
+ - Creating new accounts (EOA and Safe).
53
+ - Sending transactions.
54
+ - Monitoring transaction history.
55
+ """
56
+
57
+ BINDINGS = [
58
+ ("r", "refresh", "Refresh Balances"),
59
+ ]
60
+
61
+ def __init__(self, wallet: Wallet):
62
+ """Initialize WalletsView."""
63
+ super().__init__()
64
+ self.wallet = wallet
65
+ self.active_chain = "gnosis"
66
+ self.monitor_workers = [] # List of MonitorWorker instances
67
+ # Stores set of checked tokens (names) per chain
68
+ self.chain_token_states: dict[str, set[str]] = {
69
+ "gnosis": set(),
70
+ "ethereum": set(),
71
+ "base": set(),
72
+ }
73
+ self.balance_cache = {} # chain -> address -> balances
74
+ self.price_service = PriceService()
75
+
76
+ async def on_mount(self) -> None:
77
+ """Called when view is mounted.
78
+
79
+ Initializes UI state, loads accounts, starts the event monitor,
80
+ and sets up the transaction table columns.
81
+ """
82
+ # Initialize UI state
83
+ await self.refresh_ui_for_chain()
84
+ self.refresh_accounts()
85
+ self.start_monitor()
86
+
87
+ # Initial column setup
88
+ self.query_one(TransactionTable).setup_columns()
89
+
90
+ # Load recent txs
91
+ self.load_recent_txs()
92
+
93
+ def compose(self) -> ComposeResult:
94
+ """Compose the WalletsView UI."""
95
+ yield ChainSelector(active_chain=self.active_chain)
96
+
97
+ yield Label(
98
+ f"Accounts ({self.active_chain.capitalize()})",
99
+ classes="header",
100
+ id="accounts_header",
101
+ )
102
+
103
+ # Token Selection (Checkboxes)
104
+ yield Label("Track Tokens:", classes="label")
105
+ with HorizontalScroll(id="tokens_row"):
106
+ yield Horizontal(id="token_toggles")
107
+
108
+ # Accounts Table
109
+ yield AccountTable(id="accounts_table")
110
+
111
+ # Buttons for creating new wallets
112
+ with Center():
113
+ yield Horizontal(
114
+ Button(
115
+ "Create EOA",
116
+ id="create_eoa_btn",
117
+ variant="primary",
118
+ classes="create-btn",
119
+ ),
120
+ Button(
121
+ "Create Safe",
122
+ id="create_safe_btn",
123
+ variant="warning",
124
+ classes="create-btn",
125
+ ),
126
+ classes="btn-group",
127
+ )
128
+
129
+ yield Label("Send Transaction", classes="header")
130
+
131
+ # Transaction Form
132
+ with Horizontal(classes="form-row", id="tx_form_container"):
133
+ # Initial placeholder, will be cleared/replaced
134
+ yield Label("Loading form...", id="form_loading_lbl")
135
+
136
+ yield Label("Recent Transactions", classes="header")
137
+ yield TransactionTable(id="tx_table")
138
+
139
+ def action_refresh(self) -> None:
140
+ """Manual refresh action."""
141
+ self.notify("Refreshing accounts...", severity="info")
142
+ self.refresh_accounts(force=True)
143
+
144
+ def refresh_accounts(self, force: bool = False) -> None:
145
+ """Refreshes table data.
146
+
147
+ Args:
148
+ force: If True, clears the local balance cache before refreshing.
149
+
150
+ """
151
+ if force:
152
+ if self.active_chain in self.balance_cache:
153
+ self.balance_cache[self.active_chain] = {}
154
+
155
+ self.refresh_table_structure_and_data()
156
+ self.load_recent_txs()
157
+
158
+ def _build_account_row(
159
+ self, account, current_chain: str, token_names: list
160
+ ) -> tuple[list, bool]:
161
+ """Build a row for a single account. Returns (cells, needs_fetch)."""
162
+ needs_fetch = False
163
+
164
+ if isinstance(account, StoredSafeAccount):
165
+ if current_chain not in account.chains:
166
+ return [], False # Skip this account
167
+ acct_type = "Safe"
168
+ else:
169
+ acct_type = "EOA"
170
+
171
+ if account.address not in self.balance_cache[current_chain]:
172
+ self.balance_cache[current_chain][account.address] = {}
173
+
174
+ cached_native = self.balance_cache[current_chain][account.address].get("NATIVE")
175
+ if cached_native:
176
+ native_cell = cached_native
177
+ else:
178
+ native_cell = "Loading..."
179
+ needs_fetch = True
180
+
181
+ cells = [
182
+ Text(account.tag, style="green"),
183
+ escape(account.address),
184
+ acct_type,
185
+ native_cell,
186
+ ]
187
+
188
+ for token in token_names:
189
+ if token in self.chain_token_states.get(current_chain, set()):
190
+ cached_token = self.balance_cache[current_chain][account.address].get(token)
191
+ if cached_token:
192
+ cells.append(cached_token)
193
+ else:
194
+ cells.append("Loading...")
195
+ needs_fetch = True
196
+ else:
197
+ cells.append("")
198
+
199
+ return cells, needs_fetch
200
+
201
+ def refresh_table_structure_and_data(self) -> None:
202
+ """Rebuild the accounts table structure and data."""
203
+ table = self.query_one(AccountTable)
204
+ chain_interface = ChainInterfaces().get(self.active_chain)
205
+ native_symbol = chain_interface.chain.native_currency if chain_interface else "Native"
206
+ token_names = list(chain_interface.tokens.keys()) if chain_interface else []
207
+
208
+ table.setup_columns(self.active_chain, native_symbol, token_names)
209
+
210
+ current_chain = self.active_chain
211
+ if current_chain not in self.balance_cache:
212
+ self.balance_cache[current_chain] = {}
213
+
214
+ needs_fetch = False
215
+ for account in self.wallet.account_service.get_account_data().values():
216
+ try:
217
+ cells, row_needs_fetch = self._build_account_row(
218
+ account, current_chain, token_names
219
+ )
220
+ if not cells:
221
+ continue # Account skipped (e.g., Safe not on this chain)
222
+ needs_fetch = needs_fetch or row_needs_fetch
223
+ table.add_row(*cells, key=account.address)
224
+ except Exception as e:
225
+ logger.error(f"Error processing account {account.address}: {e}")
226
+
227
+ if needs_fetch:
228
+ self.fetch_all_balances(current_chain, token_names)
229
+ self.set_timer(3.0, lambda: self.check_balance_loading_status(current_chain))
230
+
231
+ def check_balance_loading_status(self, chain_name_checked: str) -> None:
232
+ """Verify if balances are fully loaded for a chain."""
233
+ if self.active_chain != chain_name_checked:
234
+ return
235
+
236
+ needs_retry = False
237
+ active_tokens = self.chain_token_states.get(chain_name_checked, set())
238
+
239
+ for account in self.wallet.account_service.get_account_data().values():
240
+ addr = account.address
241
+ if chain_name_checked not in self.balance_cache:
242
+ needs_retry = True
243
+ break
244
+ if addr not in self.balance_cache[chain_name_checked]:
245
+ needs_retry = True
246
+ break
247
+
248
+ native_val = self.balance_cache[chain_name_checked][addr].get("NATIVE")
249
+ if not native_val or native_val == "Loading...":
250
+ needs_retry = True
251
+ break
252
+
253
+ for t in active_tokens:
254
+ t_val = self.balance_cache[chain_name_checked][addr].get(t)
255
+ if not t_val or t_val == "Loading...":
256
+ needs_retry = True
257
+ break
258
+ if needs_retry:
259
+ break
260
+
261
+ if needs_retry:
262
+ interface = ChainInterfaces().get(chain_name_checked)
263
+ token_names = list(interface.tokens.keys()) if interface else []
264
+ self.fetch_all_balances(chain_name_checked, token_names)
265
+
266
+ @work(exclusive=False, thread=True)
267
+ def fetch_all_balances(self, chain_name: str, token_names: List[str]) -> None:
268
+ """Fetch all balances for the chain sequentially in a background thread.
269
+
270
+ Iterates through all accounts and triggers fetch for native and token balances.
271
+ """
272
+ accounts = list(self.wallet.account_service.get_account_data().values())
273
+ for account in accounts:
274
+ if self.active_chain != chain_name:
275
+ return
276
+ self._fetch_account_all_balances(account.address, chain_name, token_names)
277
+ time.sleep(0.01)
278
+
279
+ def _fetch_account_all_balances(
280
+ self, address: str, chain_name: str, token_names: List[str]
281
+ ) -> None:
282
+ """Fetch native and token balances for a single account."""
283
+ self._fetch_account_native_balance(address, chain_name)
284
+ self._fetch_account_token_balances(address, chain_name, token_names)
285
+
286
+ def _fetch_account_native_balance(self, address: str, chain_name: str) -> None:
287
+ """Fetch native balance for a single account."""
288
+ cached_native = self.balance_cache.get(chain_name, {}).get(address, {}).get("NATIVE")
289
+ should_fetch_native = not cached_native or cached_native in ["Loading...", "Error"]
290
+
291
+ val_native = cached_native if not should_fetch_native else "Error"
292
+ if should_fetch_native:
293
+ try:
294
+ balance = self.wallet.balance_service.get_native_balance_eth(
295
+ address, chain_name=chain_name
296
+ )
297
+ val_native = f"{balance:.4f}" if balance is not None else "Error"
298
+ if chain_name not in self.balance_cache:
299
+ self.balance_cache[chain_name] = {}
300
+ if address not in self.balance_cache[chain_name]:
301
+ self.balance_cache[chain_name][address] = {}
302
+ self.balance_cache[chain_name][address]["NATIVE"] = val_native
303
+ except Exception as e:
304
+ from loguru import logger
305
+
306
+ logger.error(f"Failed native {address}: {e}")
307
+
308
+ self.app.call_from_thread(
309
+ self.update_table_cell, address, 3, Text(val_native, justify="right")
310
+ )
311
+
312
+ def _fetch_account_token_balances(
313
+ self, address: str, chain_name: str, token_names: List[str]
314
+ ) -> None:
315
+ """Fetch token balances for a single account."""
316
+ interface = ChainInterfaces().get(chain_name)
317
+ all_chain_tokens = list(interface.tokens.keys()) if interface else []
318
+ for token in token_names:
319
+ if token not in self.chain_token_states.get(chain_name, set()):
320
+ continue
321
+ try:
322
+ col_idx = 4 + all_chain_tokens.index(token)
323
+ except ValueError:
324
+ continue
325
+
326
+ val_token = self._fetch_single_token_balance(address, token, chain_name)
327
+ self.app.call_from_thread(
328
+ self.update_table_cell, address, col_idx, Text(val_token, justify="right")
329
+ )
330
+
331
+ def _fetch_single_token_balance(self, address: str, token: str, chain_name: str) -> str:
332
+ """Fetch a single token balance using BalanceService."""
333
+ val_token = self.wallet.balance_service.get_erc20_balance_with_retry(
334
+ address, token, chain_name
335
+ )
336
+ val_token_str = f"{val_token:.4f}" if val_token is not None else "-"
337
+
338
+ if val_token is not None:
339
+ if chain_name not in self.balance_cache:
340
+ self.balance_cache[chain_name] = {}
341
+ if address not in self.balance_cache[chain_name]:
342
+ self.balance_cache[chain_name][address] = {}
343
+ self.balance_cache[chain_name][address][token] = val_token_str
344
+
345
+ return val_token_str
346
+
347
+ def add_tx_history_row(self, f, t, token, amt, status, tx_hash=""):
348
+ """Add a new row to the transaction history table at the top."""
349
+ from_str = self.resolve_tag(f)
350
+ to_str = self.resolve_tag(t)
351
+ table = self.query_one(TransactionTable)
352
+ table.add_row(
353
+ datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
354
+ self.active_chain.capitalize(),
355
+ from_str,
356
+ to_str,
357
+ token,
358
+ amt,
359
+ "", # Value
360
+ f"[green]{status}[/green]",
361
+ (tx_hash if tx_hash.startswith("0x") else f"0x{tx_hash}")[:10] + "..."
362
+ if tx_hash
363
+ else "",
364
+ "?", # Gas cost
365
+ "?", # Gas value
366
+ "", # Tags
367
+ key=tx_hash,
368
+ )
369
+ try:
370
+ table.sort("Time", reverse=True)
371
+ except Exception:
372
+ # Table sorting might fail in some contexts if ColumnKey is used internally
373
+ pass
374
+
375
+ def on_unmount(self) -> None:
376
+ """Stop the monitor when the view is unmounted."""
377
+ self.stop_monitor()
378
+
379
+ def start_monitor(self) -> None:
380
+ """Start background transaction monitor."""
381
+ self.stop_monitor()
382
+ addresses = [acc.address for acc in self.wallet.key_storage.accounts.values()]
383
+ for chain_name, interface in ChainInterfaces().items():
384
+ if interface.chain.rpc:
385
+ monitor = EventMonitor(addresses, self.monitor_callback, chain_name)
386
+
387
+ # Worker wrapper
388
+ worker = MonitorWorker(monitor, self.app)
389
+ self.monitor_workers.append(worker)
390
+
391
+ # Launch as a Textual Worker
392
+ self.run_worker(worker.run(), group="monitors", thread=False)
393
+
394
+ def stop_monitor(self) -> None:
395
+ """Stop background transaction monitor."""
396
+ for worker in self.monitor_workers:
397
+ worker.stop()
398
+ self.monitor_workers.clear()
399
+ # Cancel Textual workers in the 'monitors' group
400
+ # self.workers.cancel_group("monitors") # Helper not always available, rely on worker.stop() setting flag
401
+
402
+ def monitor_callback(self, txs: List[dict]) -> None:
403
+ """Handle new transactions."""
404
+ self.app.call_from_thread(self.handle_new_txs, txs)
405
+
406
+ def handle_new_txs(self, txs: List[dict]) -> None:
407
+ """Process new transactions."""
408
+ self.refresh_accounts()
409
+ for tx in txs:
410
+ raw_ts = tx.get("timestamp")
411
+ if raw_ts is None:
412
+ raw_ts = time.time()
413
+ f, t = tx["from"], tx["to"]
414
+ token = tx.get("token", "NATIVE")
415
+ amt = f"{float(tx.get('value', 0)) / 10**18:.4f}"
416
+ tx_hash = tx["hash"]
417
+ self.add_tx_history_row(f, t, token, amt, "Detected", tx_hash)
418
+ if not any(
419
+ acc.address.lower() == str(tx["from"]).lower()
420
+ for acc in self.wallet.account_service.get_account_data().values()
421
+ ):
422
+ self.notify(f"New transaction detected! {tx['hash'][:6]}...", severity="info")
423
+ self.enrich_and_log_txs(txs)
424
+
425
+ def resolve_tag(self, address: str) -> str:
426
+ """Resolve address to tag."""
427
+ for acc in self.wallet.account_service.get_account_data().values():
428
+ if acc.address.lower() == address.lower():
429
+ return acc.tag
430
+ config = Config()
431
+ if config.core and config.core.whitelist:
432
+ for name, addr in config.core.whitelist.items():
433
+ if addr.lower() == address.lower():
434
+ return name
435
+ return f"{address[:6]}...{address[-4:]}"
436
+
437
+ @on(Button.Pressed)
438
+ def on_button_pressed(self, event: Button.Pressed) -> None:
439
+ """Handle button press events."""
440
+ if event.button.id == "create_eoa_btn":
441
+
442
+ def handler(tag):
443
+ if tag is not None:
444
+ tag = tag or f"Account {len(self.wallet.key_storage.accounts) + 1}"
445
+ self.wallet.key_storage.create_account(tag)
446
+ self.notify(f"Created new EOA: {escape(tag)}")
447
+ self.refresh_accounts()
448
+
449
+ self.app.push_screen(CreateEOAModal(), handler)
450
+ elif event.button.id == "create_safe_btn":
451
+ accs = [(acc.tag, acc.address) for acc in self.wallet.key_storage.accounts.values()]
452
+
453
+ def handler(result):
454
+ if result:
455
+ tag = result.get("tag") or f"Safe {len(self.wallet.key_storage.accounts) + 1}"
456
+ if not result.get("owners") or not result.get("chains"):
457
+ self.notify("Missing owners or chains", severity="error")
458
+ return
459
+ self.create_safe_worker(
460
+ tag, result["threshold"], result["owners"], result["chains"]
461
+ )
462
+
463
+ self.app.push_screen(CreateSafeModal(accs), handler)
464
+ elif event.button.id == "send_btn":
465
+ self.send_transaction()
466
+
467
+ @work(exclusive=False, thread=True)
468
+ def create_safe_worker(
469
+ self, tag: str, threshold: int, owners: List[str], chains: List[str]
470
+ ) -> None:
471
+ """Worker to deploy a Safe multisig on multiple chains."""
472
+ salt_nonce = int(time.time() * 1000)
473
+ for chain_name in chains:
474
+ try:
475
+ self.app.call_from_thread(
476
+ self.notify,
477
+ f"Deploying Safe '{escape(tag)}' on {chain_name}...",
478
+ severity="info",
479
+ )
480
+ self.wallet.safe_service.create_safe(
481
+ "master", owners, threshold, chain_name, tag, salt_nonce
482
+ )
483
+ self.app.call_from_thread(
484
+ self.notify,
485
+ f"Safe '{escape(tag)}' created on {chain_name}!",
486
+ severity="success",
487
+ )
488
+ except Exception as e:
489
+ logger.error(f"Failed to create Safe on {chain_name}: {e}")
490
+ self.app.call_from_thread(
491
+ self.notify, f"Error on {chain_name}: {escape(str(e))}", severity="error"
492
+ )
493
+ self.app.call_from_thread(self.refresh_accounts)
494
+
495
+ @on(Select.Changed, "#chain_select")
496
+ async def on_chain_changed(self, event: Select.Changed) -> None:
497
+ """Handle blockchain selection changes."""
498
+ if event.value and event.value != self.active_chain:
499
+ interface = ChainInterfaces().get(event.value)
500
+ if not interface or not interface.chain.rpc:
501
+ self.notify(f"No RPC for {event.value}", severity="warning")
502
+ event.control.value = self.active_chain
503
+ return
504
+ self.active_chain = event.value
505
+ await self.refresh_ui_for_chain()
506
+ self.start_monitor()
507
+
508
+ async def refresh_ui_for_chain(self) -> None:
509
+ """Update UI elements for the currently selected chain."""
510
+ self.query_one("#accounts_header", Label).update(
511
+ f"Accounts ({self.active_chain.capitalize()})"
512
+ )
513
+ self.refresh_accounts()
514
+ scroll = self.query_one("#token_toggles", Horizontal)
515
+ interface = ChainInterfaces().get(self.active_chain)
516
+ desired = set(interface.tokens.keys()) if interface else set()
517
+ for child in list(scroll.children):
518
+ if child.id and child.id.startswith("cb_") and child.id[3:] not in desired:
519
+ child.remove()
520
+ if interface:
521
+ for token_name in interface.tokens.keys():
522
+ cb_id = f"cb_{token_name}"
523
+ is_checked = token_name in self.chain_token_states.get(self.active_chain, set())
524
+ try:
525
+ cb = self.query_one(f"#{cb_id}", Checkbox)
526
+ cb.value = is_checked
527
+ except Exception:
528
+ scroll.mount(Checkbox(token_name.upper(), value=is_checked, id=cb_id))
529
+
530
+ form_container = self.query_one("#tx_form_container", Horizontal)
531
+ await form_container.remove_children()
532
+ native_symbol = interface.chain.native_currency if interface else "Native"
533
+ token_options = [(native_symbol, "native")] + [
534
+ (t.upper(), t) for t in (interface.tokens.keys() if interface else [])
535
+ ]
536
+ from_options = [(a.tag, a.address) for a in self.wallet.key_storage.accounts.values()]
537
+ to_options = list(from_options)
538
+ config = Config()
539
+ if config.core and config.core.whitelist:
540
+ for n, a in config.core.whitelist.items():
541
+ to_options.append((n, a))
542
+
543
+ form_container.mount(
544
+ Select(from_options, prompt="From Address", id="from_addr"),
545
+ Select(to_options, prompt="To Address", id="to_addr"),
546
+ Input(placeholder="Amount", id="amount"),
547
+ Select(token_options, value="native", id="token", allow_blank=False),
548
+ Button("Send", id="send_btn", variant="primary"),
549
+ )
550
+
551
+ @on(Checkbox.Changed)
552
+ def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
553
+ """Handle token track checkbox changes."""
554
+ if not event.checkbox.id or not event.checkbox.id.startswith("cb_"):
555
+ return
556
+ token_name = event.checkbox.id[3:]
557
+ if self.active_chain not in self.chain_token_states:
558
+ self.chain_token_states[self.active_chain] = set()
559
+ if event.value:
560
+ self.chain_token_states[self.active_chain].add(token_name)
561
+ self.fetch_all_balances(self.active_chain, [token_name])
562
+ else:
563
+ self.chain_token_states[self.active_chain].discard(token_name)
564
+ self.refresh_table_structure_and_data()
565
+
566
+ def update_table_cell(self, row_key: str, col_index: int, value: str | Text) -> None:
567
+ """Update a specific cell in the accounts table."""
568
+ try:
569
+ table = self.query_one(AccountTable)
570
+ col_key = list(table.columns.keys())[col_index]
571
+ table.update_cell(str(row_key), col_key, value)
572
+ except Exception:
573
+ pass
574
+
575
+ @on(DataTable.CellSelected, "#accounts_table")
576
+ def on_account_cell_selected(self, event: DataTable.CellSelected) -> None:
577
+ """Handle account cell selection (copy address)."""
578
+ if event.coordinate.column == 1:
579
+ # event.value may be a Rich Text object, convert to plain string
580
+ value = str(event.value.plain) if hasattr(event.value, "plain") else str(event.value)
581
+ self.app.copy_to_clipboard(value)
582
+ self.notify("Copied address to clipboard")
583
+
584
+ @on(DataTable.CellSelected, "#tx_table")
585
+ def on_tx_cell_selected(self, event: DataTable.CellSelected) -> None:
586
+ """Handle transaction cell selection (copy hash)."""
587
+ try:
588
+ columns = list(event.data_table.columns.values())
589
+ if "Hash" in str(columns[event.coordinate.column].label):
590
+ # The row key contains the full hash
591
+ full_hash = str(event.cell_key.row_key.value)
592
+ if full_hash and full_hash.startswith("0x"):
593
+ self.app.copy_to_clipboard(full_hash)
594
+ self.notify("Copied hash to clipboard")
595
+ except Exception:
596
+ pass
597
+
598
+ def send_transaction(self) -> None:
599
+ """Initiate a transaction from the UI form."""
600
+ try:
601
+ f = self.query_one("#from_addr", Select).value
602
+ t = self.query_one("#to_addr", Select).value
603
+ amt = self.query_one("#amount", Input).value
604
+ tok = self.query_one("#token", Select).value
605
+ if not f or not t or not amt or not tok:
606
+ self.notify("Missing fields", severity="error")
607
+ return
608
+ self.send_tx_worker(f, t, tok, float(amt))
609
+ except Exception:
610
+ pass
611
+
612
+ @work(exclusive=True, thread=True)
613
+ def send_tx_worker(self, f, t, token, amount) -> None:
614
+ """Background worker for sending transactions."""
615
+ try:
616
+ # Let Wallet.send handle the conversion - it knows the token's decimals
617
+ # For native currency, use standard 18 decimals
618
+ # For ERC20, Wallet.send should handle it based on the token
619
+ from web3 import Web3
620
+
621
+ if token == "native":
622
+ amount_wei = Web3.to_wei(amount, "ether")
623
+ else:
624
+ # For ERC20, get the token's decimals
625
+ from iwa.core.chain import ChainInterfaces
626
+ from iwa.core.contracts.erc20 import ERC20Contract
627
+
628
+ chain_interface = ChainInterfaces().get(self.active_chain)
629
+ token_address = chain_interface.chain.get_token_address(token)
630
+ if token_address:
631
+ erc20 = ERC20Contract(token_address, self.active_chain)
632
+ amount_wei = int(amount * (10**erc20.decimals))
633
+ else:
634
+ # Fallback to 18 decimals if token not found
635
+ amount_wei = Web3.to_wei(amount, "ether")
636
+
637
+ tx_hash = self.wallet.send(f, t, amount_wei, token, self.active_chain)
638
+ self.app.call_from_thread(self.notify, "Transaction sent!", severity="success")
639
+ self.app.call_from_thread(
640
+ self.add_tx_history_row, f, t, token, amount, "Pending", tx_hash
641
+ )
642
+ except Exception as e:
643
+ self.app.call_from_thread(self.notify, f"Error: {escape(str(e))}", severity="error")
644
+
645
+ def load_recent_txs(self):
646
+ """Load recent transactions from the database."""
647
+ try:
648
+ from iwa.core.db import SentTransaction
649
+
650
+ recent = (
651
+ SentTransaction.select()
652
+ .where(
653
+ (SentTransaction.chain == self.active_chain)
654
+ & (
655
+ SentTransaction.timestamp
656
+ > (datetime.datetime.now() - datetime.timedelta(hours=24))
657
+ )
658
+ )
659
+ .order_by(SentTransaction.timestamp.desc())
660
+ )
661
+ table = self.query_one(TransactionTable)
662
+ table.clear()
663
+ for tx in recent:
664
+ ts = tx.timestamp.strftime("%Y-%m-%d %H:%M:%S")
665
+ f, t = tx.from_tag or tx.from_address, tx.to_tag or tx.to_address
666
+ symbol = tx.token
667
+ if symbol and symbol.upper() in ["NATIVE", "NATIVE CURRENCY"]:
668
+ interface = ChainInterfaces().get(tx.chain)
669
+ symbol = interface.chain.native_currency if interface else "Native"
670
+ token_decimals = 18 # Native always 18
671
+ else:
672
+ # Get token decimals for proper display
673
+ token_decimals = 18 # Default
674
+ try:
675
+ interface = ChainInterfaces().get(tx.chain)
676
+ if interface and tx.token:
677
+ token_address = interface.chain.get_token_address(tx.token)
678
+ if token_address:
679
+ from iwa.core.contracts.erc20 import ERC20Contract
680
+
681
+ erc20 = ERC20Contract(token_address, tx.chain)
682
+ token_decimals = erc20.decimals
683
+ except Exception:
684
+ pass # Default to 18
685
+
686
+ amt = f"{float(tx.amount_wei or 0) / (10**token_decimals):.4f}"
687
+ val_eur = f"€{(tx.value_eur or 0.0):.2f}"
688
+ gas_eur = f"€{tx.gas_value_eur:.4f}" if tx.gas_value_eur else "?"
689
+ table.add_row(
690
+ ts,
691
+ str(tx.chain).capitalize(),
692
+ f,
693
+ t,
694
+ symbol,
695
+ amt,
696
+ val_eur,
697
+ "[green]Confirmed[/green]",
698
+ (tx.tx_hash if tx.tx_hash.startswith("0x") else f"0x{tx.tx_hash}")[:10] + "...",
699
+ str(tx.gas_cost or "0"),
700
+ gas_eur,
701
+ ", ".join(json.loads(tx.tags)) if tx.tags else "",
702
+ key=tx.tx_hash if tx.tx_hash.startswith("0x") else f"0x{tx.tx_hash}",
703
+ )
704
+ except Exception as e:
705
+ logger.error(f"Failed to load txs: {e}")
706
+
707
+ @work(thread=True)
708
+ def enrich_and_log_txs(self, txs: List[dict]) -> None:
709
+ """Enrich transaction data and log to database."""
710
+ from iwa.core.db import log_transaction
711
+
712
+ price_cache = {}
713
+ for tx in txs:
714
+ try:
715
+ tx_chain = tx.get("chain", self.active_chain)
716
+ interface = ChainInterfaces().get(tx_chain)
717
+ if not interface:
718
+ continue
719
+ cg_id = {"ethereum": "ethereum", "gnosis": "dai", "base": "ethereum"}.get(
720
+ tx_chain, "ethereum"
721
+ )
722
+ if cg_id not in price_cache:
723
+ price_cache[cg_id] = self.price_service.get_token_price(cg_id, "eur")
724
+
725
+ # Simplified resolution for now
726
+ v_wei = int(tx.get("value", 0))
727
+ v_eth = v_wei / 10**18
728
+ v_eur = v_eth * price_cache[cg_id] if price_cache[cg_id] else None
729
+
730
+ # Use the native currency symbol for this chain
731
+ native_symbol = interface.chain.native_currency
732
+
733
+ # Check if we should even send price data (only if we are confident it's a native tx)
734
+ # Or let log_transaction handle the smart merging
735
+ log_transaction(
736
+ tx_hash=tx["hash"],
737
+ from_addr=tx["from"],
738
+ from_tag=self.resolve_tag(tx["from"]),
739
+ to_addr=tx["to"],
740
+ to_tag=self.resolve_tag(tx["to"]),
741
+ token=native_symbol,
742
+ amount_wei=str(v_wei),
743
+ chain=tx_chain,
744
+ price_eur=price_cache[cg_id],
745
+ value_eur=v_eur,
746
+ )
747
+ except Exception as e:
748
+ logger.error(f"Failed enrichment: {e}")
749
+ self.app.call_from_thread(self.load_recent_txs)