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.
- conftest.py +22 -0
- iwa/__init__.py +1 -0
- iwa/__main__.py +6 -0
- iwa/core/__init__.py +1 -0
- iwa/core/chain/__init__.py +68 -0
- iwa/core/chain/errors.py +47 -0
- iwa/core/chain/interface.py +514 -0
- iwa/core/chain/manager.py +38 -0
- iwa/core/chain/models.py +128 -0
- iwa/core/chain/rate_limiter.py +193 -0
- iwa/core/cli.py +210 -0
- iwa/core/constants.py +28 -0
- iwa/core/contracts/__init__.py +1 -0
- iwa/core/contracts/contract.py +297 -0
- iwa/core/contracts/erc20.py +79 -0
- iwa/core/contracts/multisend.py +71 -0
- iwa/core/db.py +317 -0
- iwa/core/keys.py +361 -0
- iwa/core/mnemonic.py +385 -0
- iwa/core/models.py +344 -0
- iwa/core/monitor.py +209 -0
- iwa/core/plugins.py +45 -0
- iwa/core/pricing.py +91 -0
- iwa/core/services/__init__.py +17 -0
- iwa/core/services/account.py +57 -0
- iwa/core/services/balance.py +113 -0
- iwa/core/services/plugin.py +88 -0
- iwa/core/services/safe.py +392 -0
- iwa/core/services/transaction.py +172 -0
- iwa/core/services/transfer/__init__.py +166 -0
- iwa/core/services/transfer/base.py +260 -0
- iwa/core/services/transfer/erc20.py +247 -0
- iwa/core/services/transfer/multisend.py +386 -0
- iwa/core/services/transfer/native.py +262 -0
- iwa/core/services/transfer/swap.py +326 -0
- iwa/core/settings.py +95 -0
- iwa/core/tables.py +60 -0
- iwa/core/test.py +27 -0
- iwa/core/tests/test_wallet.py +255 -0
- iwa/core/types.py +59 -0
- iwa/core/ui.py +99 -0
- iwa/core/utils.py +59 -0
- iwa/core/wallet.py +380 -0
- iwa/plugins/__init__.py +1 -0
- iwa/plugins/gnosis/__init__.py +5 -0
- iwa/plugins/gnosis/cow/__init__.py +6 -0
- iwa/plugins/gnosis/cow/quotes.py +148 -0
- iwa/plugins/gnosis/cow/swap.py +403 -0
- iwa/plugins/gnosis/cow/types.py +20 -0
- iwa/plugins/gnosis/cow_utils.py +44 -0
- iwa/plugins/gnosis/plugin.py +68 -0
- iwa/plugins/gnosis/safe.py +157 -0
- iwa/plugins/gnosis/tests/test_cow.py +227 -0
- iwa/plugins/gnosis/tests/test_safe.py +100 -0
- iwa/plugins/olas/__init__.py +5 -0
- iwa/plugins/olas/constants.py +106 -0
- iwa/plugins/olas/contracts/activity_checker.py +93 -0
- iwa/plugins/olas/contracts/base.py +10 -0
- iwa/plugins/olas/contracts/mech.py +49 -0
- iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
- iwa/plugins/olas/contracts/service.py +215 -0
- iwa/plugins/olas/contracts/staking.py +403 -0
- iwa/plugins/olas/importer.py +736 -0
- iwa/plugins/olas/mech_reference.py +135 -0
- iwa/plugins/olas/models.py +110 -0
- iwa/plugins/olas/plugin.py +243 -0
- iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
- iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
- iwa/plugins/olas/service_manager/__init__.py +60 -0
- iwa/plugins/olas/service_manager/base.py +113 -0
- iwa/plugins/olas/service_manager/drain.py +336 -0
- iwa/plugins/olas/service_manager/lifecycle.py +839 -0
- iwa/plugins/olas/service_manager/mech.py +322 -0
- iwa/plugins/olas/service_manager/staking.py +530 -0
- iwa/plugins/olas/tests/conftest.py +30 -0
- iwa/plugins/olas/tests/test_importer.py +128 -0
- iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
- iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
- iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
- iwa/plugins/olas/tests/test_olas_integration.py +561 -0
- iwa/plugins/olas/tests/test_olas_models.py +144 -0
- iwa/plugins/olas/tests/test_olas_view.py +258 -0
- iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
- iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
- iwa/plugins/olas/tests/test_plugin.py +70 -0
- iwa/plugins/olas/tests/test_plugin_full.py +212 -0
- iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
- iwa/plugins/olas/tests/test_service_manager.py +1065 -0
- iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
- iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
- iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
- iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
- iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
- iwa/plugins/olas/tests/test_service_staking.py +342 -0
- iwa/plugins/olas/tests/test_staking_integration.py +269 -0
- iwa/plugins/olas/tests/test_staking_validation.py +109 -0
- iwa/plugins/olas/tui/__init__.py +1 -0
- iwa/plugins/olas/tui/olas_view.py +952 -0
- iwa/tools/check_profile.py +67 -0
- iwa/tools/release.py +111 -0
- iwa/tools/reset_env.py +111 -0
- iwa/tools/reset_tenderly.py +362 -0
- iwa/tools/restore_backup.py +82 -0
- iwa/tui/__init__.py +1 -0
- iwa/tui/app.py +174 -0
- iwa/tui/modals/__init__.py +5 -0
- iwa/tui/modals/base.py +406 -0
- iwa/tui/rpc.py +63 -0
- iwa/tui/screens/__init__.py +1 -0
- iwa/tui/screens/wallets.py +749 -0
- iwa/tui/tests/test_app.py +125 -0
- iwa/tui/tests/test_rpc.py +139 -0
- iwa/tui/tests/test_wallets_refactor.py +30 -0
- iwa/tui/tests/test_widgets.py +123 -0
- iwa/tui/widgets/__init__.py +5 -0
- iwa/tui/widgets/base.py +100 -0
- iwa/tui/workers.py +42 -0
- iwa/web/dependencies.py +76 -0
- iwa/web/models.py +76 -0
- iwa/web/routers/accounts.py +115 -0
- iwa/web/routers/olas/__init__.py +24 -0
- iwa/web/routers/olas/admin.py +169 -0
- iwa/web/routers/olas/funding.py +135 -0
- iwa/web/routers/olas/general.py +29 -0
- iwa/web/routers/olas/services.py +378 -0
- iwa/web/routers/olas/staking.py +341 -0
- iwa/web/routers/state.py +65 -0
- iwa/web/routers/swap.py +617 -0
- iwa/web/routers/transactions.py +153 -0
- iwa/web/server.py +155 -0
- iwa/web/tests/test_web_endpoints.py +713 -0
- iwa/web/tests/test_web_olas.py +430 -0
- iwa/web/tests/test_web_swap.py +103 -0
- iwa-0.0.1a2.dist-info/METADATA +234 -0
- iwa-0.0.1a2.dist-info/RECORD +186 -0
- iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
- iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
- iwa-0.0.1a2.dist-info/top_level.txt +4 -0
- tests/legacy_cow.py +248 -0
- tests/legacy_safe.py +93 -0
- tests/legacy_transaction_retry_logic.py +51 -0
- tests/legacy_tui.py +440 -0
- tests/legacy_wallets_screen.py +554 -0
- tests/legacy_web.py +243 -0
- tests/test_account_service.py +120 -0
- tests/test_balance_service.py +186 -0
- tests/test_chain.py +490 -0
- tests/test_chain_interface.py +210 -0
- tests/test_cli.py +139 -0
- tests/test_contract.py +195 -0
- tests/test_db.py +180 -0
- tests/test_drain_coverage.py +174 -0
- tests/test_erc20.py +95 -0
- tests/test_gnosis_plugin.py +111 -0
- tests/test_keys.py +449 -0
- tests/test_legacy_wallet.py +1285 -0
- tests/test_main.py +13 -0
- tests/test_mnemonic.py +217 -0
- tests/test_modals.py +109 -0
- tests/test_models.py +213 -0
- tests/test_monitor.py +202 -0
- tests/test_multisend.py +84 -0
- tests/test_plugin_service.py +119 -0
- tests/test_pricing.py +143 -0
- tests/test_rate_limiter.py +199 -0
- tests/test_reset_tenderly.py +202 -0
- tests/test_rpc_view.py +73 -0
- tests/test_safe_coverage.py +139 -0
- tests/test_safe_service.py +168 -0
- tests/test_service_manager_integration.py +61 -0
- tests/test_service_manager_structure.py +31 -0
- tests/test_service_transaction.py +176 -0
- tests/test_staking_router.py +71 -0
- tests/test_staking_simple.py +31 -0
- tests/test_tables.py +76 -0
- tests/test_transaction_service.py +161 -0
- tests/test_transfer_multisend.py +179 -0
- tests/test_transfer_native.py +220 -0
- tests/test_transfer_security.py +93 -0
- tests/test_transfer_structure.py +37 -0
- tests/test_transfer_swap_unit.py +155 -0
- tests/test_ui_coverage.py +66 -0
- tests/test_utils.py +53 -0
- tests/test_workers.py +91 -0
- tools/verify_drain.py +183 -0
- __init__.py +0 -2
- hello.py +0 -6
- iwa-0.0.0.dist-info/METADATA +0 -10
- iwa-0.0.0.dist-info/RECORD +0 -6
- iwa-0.0.0.dist-info/top_level.txt +0 -2
- {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)
|