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,952 @@
1
+ """Olas Services TUI View."""
2
+
3
+ from typing import TYPE_CHECKING, List, Optional
4
+
5
+ from textual import work
6
+ from textual.app import ComposeResult
7
+ from textual.containers import Container, Horizontal, ScrollableContainer, Vertical
8
+ from textual.widgets import Button, DataTable, Label, Select, Static
9
+
10
+ if TYPE_CHECKING:
11
+ from iwa.core.wallet import Wallet
12
+
13
+ from iwa.core.types import EthereumAddress
14
+
15
+
16
+ class OlasView(Static):
17
+ """Olas services view for TUI."""
18
+
19
+ DEFAULT_CSS = """
20
+ OlasView {
21
+ height: auto;
22
+ min-height: 10;
23
+ }
24
+
25
+ .olas-header {
26
+ height: 3;
27
+ margin-bottom: 1;
28
+ }
29
+
30
+ .services-container {
31
+ height: auto;
32
+ min-height: 5;
33
+ }
34
+
35
+ .service-card {
36
+ border: solid $primary;
37
+ padding: 0 1;
38
+ margin-bottom: 1;
39
+ height: auto;
40
+ }
41
+
42
+ .service-title {
43
+ text-style: bold;
44
+ color: $accent;
45
+ margin-bottom: 0;
46
+ }
47
+
48
+ .staking-section {
49
+ margin-top: 0;
50
+ padding: 0 1;
51
+ background: $surface;
52
+ height: auto;
53
+ }
54
+
55
+ .staking-label {
56
+ color: $text-muted;
57
+ height: 1;
58
+ }
59
+
60
+ .staking-value {
61
+ color: $success;
62
+ height: 1;
63
+ }
64
+
65
+ .staking-value.not-staked {
66
+ color: $text-muted;
67
+ }
68
+
69
+ .rewards-value {
70
+ color: $accent;
71
+ text-style: bold;
72
+ height: 1;
73
+ }
74
+
75
+ .action-buttons {
76
+ margin-top: 0;
77
+ height: 3;
78
+ }
79
+
80
+ .action-buttons Button {
81
+ margin-right: 1;
82
+ }
83
+
84
+ .accounts-table {
85
+ height: auto;
86
+ max-height: 6;
87
+ }
88
+
89
+ .empty-state {
90
+ text-align: center;
91
+ color: $text-muted;
92
+ padding: 2;
93
+ }
94
+
95
+ .olas-price {
96
+ text-align: center;
97
+ color: $success;
98
+ padding: 1 0;
99
+ text-style: bold;
100
+ }
101
+ """
102
+
103
+ def __init__(self, wallet: Optional["Wallet"] = None):
104
+ """Initialize the Olas view."""
105
+ super().__init__()
106
+ self._wallet = wallet
107
+ self._chain = "gnosis"
108
+ self._services_data = []
109
+ self._loading = False # Guard against duplicate worker execution
110
+
111
+ def compose(self) -> ComposeResult:
112
+ """Compose the Olas view."""
113
+ # Header with chain selector and refresh
114
+ with Horizontal(classes="olas-header"):
115
+ yield Label("Chain: ", classes="label")
116
+ yield Select(
117
+ [(c, c) for c in ["gnosis", "ethereum", "base"]],
118
+ value="gnosis",
119
+ id="olas-chain-select",
120
+ )
121
+ yield Button("Refresh", id="olas-refresh-btn", variant="default")
122
+
123
+ # Services container
124
+ yield Label("OLAS: --", id="olas-price-label", classes="olas-price")
125
+ yield ScrollableContainer(id="services-container")
126
+
127
+ def on_mount(self) -> None:
128
+ """Load services when mounted."""
129
+ self.load_services()
130
+
131
+ def on_button_pressed(self, event: Button.Pressed) -> None:
132
+ """Handle button presses."""
133
+ button_id = event.button.id
134
+ if not button_id:
135
+ return
136
+
137
+ if button_id == "olas-refresh-btn":
138
+ self.load_services()
139
+ elif button_id == "olas-create-service-btn":
140
+ self.show_create_service_modal()
141
+ else:
142
+ self._handle_service_action_button(button_id)
143
+
144
+ def _handle_service_action_button(self, button_id: str) -> None:
145
+ """Handle service-specific action buttons.
146
+
147
+ Maps button ID prefixes to handler methods.
148
+ """
149
+ # Map prefixes to handler methods
150
+ handlers = {
151
+ "claim-": self.claim_rewards,
152
+ "unstake-": self.unstake_service,
153
+ "stake-": self.stake_service,
154
+ "drain-": self.drain_service,
155
+ "fund-": self.show_fund_service_modal,
156
+ "terminate-": self.terminate_service,
157
+ "checkpoint-": self.checkpoint_service,
158
+ "deploy-": self.deploy_service,
159
+ }
160
+
161
+ for prefix, handler in handlers.items():
162
+ if button_id.startswith(prefix):
163
+ # Convert sanitized ID back to original key format
164
+ # e.g. gnosis_2594 -> gnosis:2594
165
+ service_key = button_id.replace(prefix, "").replace("_", ":", 1)
166
+ handler(service_key)
167
+ return
168
+
169
+ def on_select_changed(self, event: Select.Changed) -> None:
170
+ """Handle chain selection change."""
171
+ if event.select.id == "olas-chain-select":
172
+ self._chain = str(event.value)
173
+ self.load_services()
174
+
175
+ @work(thread=True)
176
+ def load_services(self) -> None:
177
+ """Load Olas services for the selected chain in background thread."""
178
+ # Prevent duplicate execution
179
+ if self._loading:
180
+ return
181
+ self._loading = True
182
+
183
+ if not self._wallet:
184
+ self.app.call_from_thread(self._mount_error, "Wallet not available")
185
+ self._loading = False
186
+ return
187
+
188
+ try:
189
+ from iwa.core.models import Config
190
+ from iwa.plugins.olas.models import OlasConfig
191
+ from iwa.plugins.olas.service_manager import ServiceManager
192
+
193
+ config = Config()
194
+
195
+ if "olas" not in config.plugins:
196
+ self.app.call_from_thread(
197
+ self._mount_error, f"No Olas services configured for {self._chain}"
198
+ )
199
+ self._loading = False
200
+ return
201
+
202
+ olas_config = OlasConfig.model_validate(config.plugins["olas"])
203
+
204
+ services = [
205
+ (key, svc)
206
+ for key, svc in olas_config.services.items()
207
+ if svc.chain_name == self._chain
208
+ ]
209
+
210
+ if not services:
211
+ self.app.call_from_thread(
212
+ self._mount_error, f"No Olas services found for {self._chain}"
213
+ )
214
+ self._loading = False
215
+ return
216
+
217
+ # Fetch data in background thread (network calls)
218
+ services_data = []
219
+ for service_key, service in services:
220
+ manager = ServiceManager(self._wallet)
221
+ manager.service = service
222
+ staking_status = manager.get_staking_status()
223
+ service_state = manager.get_service_state()
224
+ services_data.append((service_key, service, staking_status, service_state))
225
+
226
+ # Fetch OLAS price
227
+ olas_price = None
228
+ try:
229
+ from iwa.core.pricing import PriceService
230
+
231
+ price_service = PriceService()
232
+ olas_price = price_service.get_token_price("autonolas", "eur")
233
+ except Exception:
234
+ pass # Price fetch is optional
235
+
236
+ self.app.call_from_thread(self._render_services, services_data, olas_price)
237
+
238
+ except Exception as e:
239
+ import traceback
240
+
241
+ traceback.print_exc()
242
+ self.app.call_from_thread(self._mount_error, f"Error loading services: {e}")
243
+ finally:
244
+ self._loading = False
245
+
246
+ async def _render_services(
247
+ self, services_data: list, olas_price: Optional[float] = None
248
+ ) -> None:
249
+ """Create and mount service cards in UI thread."""
250
+ try:
251
+ # Update OLAS price label
252
+ try:
253
+ price_label = self.query_one("#olas-price-label", Label)
254
+ if olas_price:
255
+ price_label.update(f"OLAS: €{olas_price:.2f}")
256
+ else:
257
+ price_label.update("OLAS: --")
258
+ except Exception:
259
+ pass # Label might not exist yet
260
+
261
+ container = self.query_one("#services-container", ScrollableContainer)
262
+ # Synchronously (as much as possible) clear children
263
+ # Querying and removing is safer than remove_children() when mounting immediately
264
+ children = container.query(".service-card")
265
+ if children:
266
+ await children.remove()
267
+
268
+ # Use a slightly different approach: clear everything and wait
269
+ await container.query("*").remove()
270
+
271
+ if not services_data:
272
+ await container.mount(
273
+ Label("No services found on this chain.", classes="empty-state")
274
+ )
275
+ else:
276
+ for service_key, service, staking_status, service_state in services_data:
277
+ card = self._create_service_card(
278
+ service_key, service, staking_status, service_state
279
+ )
280
+ await container.mount(card)
281
+
282
+ await container.mount(
283
+ Button("Create New Service", id="olas-create-service-btn", variant="primary")
284
+ )
285
+ except Exception:
286
+ import traceback
287
+
288
+ traceback.print_exc()
289
+
290
+ def _mount_cards(self, cards: list) -> None:
291
+ """Mount service cards (called from UI thread)."""
292
+ try:
293
+ container = self.query_one("#services-container", ScrollableContainer)
294
+ container.remove_children()
295
+ for card in cards:
296
+ container.mount(card)
297
+ container.mount(
298
+ Button("Create New Service", id="olas-create-service-btn", variant="primary")
299
+ )
300
+ except Exception:
301
+ pass
302
+
303
+ def _mount_error(self, message: str) -> None:
304
+ """Mount error message (called from UI thread)."""
305
+ try:
306
+ container = self.query_one("#services-container", ScrollableContainer)
307
+ container.remove_children()
308
+ container.mount(Label(message, classes="empty-state"))
309
+ except Exception:
310
+ pass
311
+
312
+ def _create_service_card(
313
+ self, service_key: str, service, staking_status, service_state: str = "UNKNOWN"
314
+ ) -> Container:
315
+ """Create a service card widget."""
316
+ from iwa.plugins.olas.models import Service
317
+
318
+ service: Service = service # type hint
319
+
320
+ # Sanitize service_key for use in widget IDs (colons not allowed)
321
+ safe_key = service_key.replace(":", "_")
322
+
323
+ # Build accounts logic
324
+ accounts_data = self._build_accounts_data(service)
325
+
326
+ # Build staking logic
327
+ staking_info = self._build_staking_info(staking_status)
328
+ is_staked = staking_info["is_staked"]
329
+ rewards = staking_info["rewards"]
330
+ checkpoint_pending = staking_info["checkpoint_pending"]
331
+
332
+ # Calculate countdowns
333
+ epoch_text = self._get_epoch_text(staking_status)
334
+ unstake_text = self._get_unstake_text(staking_status)
335
+
336
+ # Build accounts table
337
+ table = DataTable(classes="accounts-table")
338
+ table.add_columns("Role", "Account", "Native", "OLAS")
339
+ for row in accounts_data:
340
+ table.add_row(*row)
341
+
342
+ # Build staking labels
343
+ staking_widgets = [
344
+ Label(
345
+ f"Status: {'✓ STAKED' if is_staked else '○ NOT STAKED'}",
346
+ classes="staking-value" if is_staked else "staking-value not-staked",
347
+ )
348
+ ]
349
+ if is_staked:
350
+ contract_name = staking_status.staking_contract_name if staking_status else "-"
351
+ staking_widgets.append(
352
+ Label(f"Contract: {contract_name or '-'}", classes="staking-label")
353
+ )
354
+ staking_widgets.append(Label(f"Rewards: {rewards:.4f} OLAS", classes="rewards-value"))
355
+ liveness = staking_status.mech_requests_this_epoch
356
+ required = staking_status.required_mech_requests
357
+ passed = "✓" if staking_status.liveness_ratio_passed else "⚠"
358
+ staking_widgets.append(
359
+ Label(f"Liveness: {liveness}/{required} {passed}", classes="staking-label")
360
+ )
361
+ epoch_num = staking_status.epoch_number if staking_status else "?"
362
+ staking_widgets.append(
363
+ Label(f"Epoch #{epoch_num} ends in: {epoch_text}", classes="staking-label")
364
+ )
365
+ staking_widgets.append(
366
+ Label(f"Unstake available: {unstake_text}", classes="staking-label")
367
+ )
368
+ staking_section = Vertical(*staking_widgets, classes="staking-section")
369
+
370
+ # Build action buttons based on service state
371
+ is_pre_registration = service_state == "PRE_REGISTRATION"
372
+ is_deployed = service_state == "DEPLOYED"
373
+
374
+ button_list = []
375
+
376
+ # Deploy button for PRE_REGISTRATION services
377
+ if is_pre_registration:
378
+ button_list.append(Button("Deploy", id=f"deploy-{safe_key}", variant="success"))
379
+ else:
380
+ button_list.append(Button("Fund", id=f"fund-{safe_key}", variant="primary"))
381
+
382
+ if is_staked and checkpoint_pending:
383
+ button_list.append(Button("Checkpoint", id=f"checkpoint-{safe_key}", variant="warning"))
384
+ if is_staked and rewards > 0:
385
+ button_list.append(
386
+ Button(f"Claim {rewards:.2f} OLAS", id=f"claim-{safe_key}", variant="primary")
387
+ )
388
+ if is_deployed and is_staked:
389
+ button_list.append(Button("Unstake", id=f"unstake-{safe_key}", variant="primary"))
390
+ elif is_deployed and not is_staked:
391
+ button_list.append(Button("Stake", id=f"stake-{safe_key}", variant="primary"))
392
+
393
+ if not is_pre_registration:
394
+ button_list.append(Button("Drain", id=f"drain-{safe_key}", variant="warning"))
395
+ # Can only terminate if not staked
396
+ if not is_staked:
397
+ button_list.append(Button("Terminate", id=f"terminate-{safe_key}", variant="error"))
398
+
399
+ buttons = Horizontal(*button_list, classes="action-buttons")
400
+
401
+ # Build card
402
+ card = Vertical(
403
+ Label(
404
+ f"{service.service_name or 'Service'} #{service.service_id}",
405
+ classes="service-title",
406
+ ),
407
+ table,
408
+ staking_section,
409
+ buttons,
410
+ classes="service-card",
411
+ id=f"card-{safe_key}",
412
+ )
413
+
414
+ return card
415
+
416
+ def _get_balance(self, address: EthereumAddress, token: str) -> str:
417
+ """Get balance for an address."""
418
+ if not self._wallet:
419
+ return "-"
420
+ try:
421
+ if token == "native":
422
+ bal = self._wallet.get_native_balance_eth(address, self._chain)
423
+ return f"{bal:.4f}" if bal else "0.0000"
424
+ else:
425
+ bal = self._wallet.balance_service.get_erc20_balance_wei(
426
+ address, token, self._chain
427
+ )
428
+ return f"{bal / 1e18:.4f}" if bal else "0.0000"
429
+ except Exception:
430
+ return "-"
431
+
432
+ def _get_tag(self, address: EthereumAddress) -> Optional[str]:
433
+ """Get tag for an address if it exists."""
434
+ if not self._wallet:
435
+ return None
436
+ try:
437
+ stored = self._wallet.key_storage.find_stored_account(address)
438
+ return stored.tag if stored else None
439
+ except Exception:
440
+ return None
441
+
442
+ def claim_rewards(self, service_key: str) -> None:
443
+ """Claim rewards for a service."""
444
+ self.notify("Claiming rewards...", severity="information")
445
+ try:
446
+ from iwa.core.models import Config
447
+ from iwa.plugins.olas.contracts.staking import StakingContract
448
+ from iwa.plugins.olas.models import OlasConfig
449
+ from iwa.plugins.olas.service_manager import ServiceManager
450
+
451
+ config = Config()
452
+ olas_config = OlasConfig.model_validate(config.plugins["olas"])
453
+ service = olas_config.services[service_key]
454
+
455
+ manager = ServiceManager(self._wallet)
456
+ manager.service = service
457
+
458
+ staking = StakingContract(service.staking_contract_address, service.chain_name)
459
+ success, amount = manager.claim_rewards(staking_contract=staking)
460
+
461
+ if success:
462
+ self.notify(f"Claimed {amount / 1e18:.4f} OLAS!", severity="information")
463
+ self.load_services()
464
+ else:
465
+ self.notify("Claim failed", severity="error")
466
+ except Exception as e:
467
+ self.notify(f"Error: {e}", severity="error")
468
+
469
+ def stake_service(self, service_key: str) -> None:
470
+ """Stake a service with bond-compatible contracts only."""
471
+ from iwa.plugins.olas.constants import OLAS_TRADER_STAKING_CONTRACTS
472
+
473
+ contracts_dict = OLAS_TRADER_STAKING_CONTRACTS.get(self._chain, {})
474
+ if not contracts_dict:
475
+ self.notify(f"No staking contracts for {self._chain}", severity="error")
476
+ return
477
+
478
+ # 1. Get service bond (security_deposit)
479
+ service_bond = self._get_service_bond(service_key)
480
+ service_bond_olas = service_bond / 10**18 if service_bond else 0
481
+
482
+ # 2. Filter contracts
483
+ filtered_contracts = self._get_compatible_staking_contracts(contracts_dict, service_bond)
484
+
485
+ if not filtered_contracts:
486
+ if service_bond_olas is not None:
487
+ self.notify(
488
+ f"No compatible contracts! Your service bond ({service_bond_olas:.0f} OLAS) "
489
+ "is lower than what staking contracts require.",
490
+ severity="warning",
491
+ timeout=10,
492
+ )
493
+ else:
494
+ self.notify("No staking contracts available", severity="error")
495
+ return
496
+
497
+ # 3. Show info if some contracts were filtered
498
+ total_contracts = len(contracts_dict)
499
+ if len(filtered_contracts) < total_contracts:
500
+ hidden = total_contracts - len(filtered_contracts)
501
+ self.notify(
502
+ f"Showing {len(filtered_contracts)} of {total_contracts} contracts "
503
+ f"({hidden} hidden - require higher bond)",
504
+ severity="information",
505
+ )
506
+
507
+ # 4. Show modal
508
+ self._show_stake_contracts_modal(filtered_contracts, service_key)
509
+
510
+ def _get_service_bond(self, service_key: str) -> Optional[int]:
511
+ """Fetch the security deposit (bond) for a service."""
512
+ from iwa.plugins.olas.service_manager import ServiceManager
513
+
514
+ try:
515
+ manager = ServiceManager(self._wallet, service_key=service_key)
516
+ if manager.service:
517
+ service_info = manager.registry.get_service(manager.service.service_id)
518
+ return service_info.get("security_deposit", 0)
519
+ except Exception as e:
520
+ self.notify(f"Warning: Could not fetch service bond: {e}", severity="warning")
521
+ return None
522
+
523
+ def _get_compatible_staking_contracts(
524
+ self, contracts_dict: dict, service_bond: Optional[int]
525
+ ) -> List[tuple]:
526
+ """Filter staking contracts based on bond requirements and slots."""
527
+ import json
528
+
529
+ from iwa.core.chain import ChainInterface
530
+ from iwa.plugins.olas.contracts.base import OLAS_ABI_PATH
531
+
532
+ w3 = ChainInterface(self._chain).web3
533
+ with open(OLAS_ABI_PATH / "staking.json", "r") as f:
534
+ abi = json.load(f)
535
+
536
+ filtered_contracts = []
537
+ for name, addr in contracts_dict.items():
538
+ try:
539
+ contract = w3.eth.contract(address=str(addr), abi=abi)
540
+ min_deposit = contract.functions.minStakingDeposit().call()
541
+
542
+ # Skip if service bond is too low
543
+ if service_bond is not None and service_bond < min_deposit:
544
+ continue
545
+
546
+ # Check available slots
547
+ service_ids = contract.functions.getServiceIds().call()
548
+ max_services = contract.functions.maxNumServices().call()
549
+ available_slots = max_services - len(service_ids)
550
+
551
+ if available_slots > 0:
552
+ filtered_contracts.append((f"{name} ({available_slots} slots)", str(addr)))
553
+ except Exception:
554
+ # If we can't check, include it
555
+ filtered_contracts.append((name, str(addr)))
556
+ return filtered_contracts
557
+
558
+ def _show_stake_contracts_modal(
559
+ self, filtered_contracts: List[tuple], service_key: str
560
+ ) -> None:
561
+ """Show the modal to select a staking contract."""
562
+ from iwa.tui.modals.base import StakeServiceModal
563
+
564
+ def on_modal_result(contract_address: Optional[str]) -> None:
565
+ if not contract_address:
566
+ return
567
+ self._execute_stake_service(service_key, contract_address)
568
+
569
+ self.app.push_screen(StakeServiceModal(filtered_contracts), on_modal_result)
570
+
571
+ def _execute_stake_service(self, service_key: str, contract_address: str) -> None:
572
+ """Execute the staking transaction."""
573
+ self.notify("Staking...", severity="information")
574
+ try:
575
+ from iwa.plugins.olas.contracts.staking import StakingContract
576
+ from iwa.plugins.olas.service_manager import ServiceManager
577
+
578
+ manager = ServiceManager(self._wallet, service_key=service_key)
579
+ staking = StakingContract(contract_address, self._chain)
580
+ success = manager.stake(staking)
581
+
582
+ if success:
583
+ self.notify("Service staked!", severity="information")
584
+ self.load_services()
585
+ else:
586
+ self.notify("Stake failed", severity="error")
587
+ except Exception as e:
588
+ self.notify(f"Error: {e}", severity="error")
589
+
590
+ def unstake_service(self, service_key: str) -> None:
591
+ """Unstake a service."""
592
+ self.notify("Unstaking...", severity="information")
593
+ try:
594
+ from iwa.core.models import Config
595
+ from iwa.plugins.olas.contracts.staking import StakingContract
596
+ from iwa.plugins.olas.models import OlasConfig
597
+ from iwa.plugins.olas.service_manager import ServiceManager
598
+
599
+ config = Config()
600
+ olas_config = OlasConfig.model_validate(config.plugins["olas"])
601
+ service = olas_config.services[service_key]
602
+
603
+ manager = ServiceManager(self._wallet)
604
+ manager.service = service
605
+
606
+ staking = StakingContract(service.staking_contract_address, service.chain_name)
607
+ success = manager.unstake(staking)
608
+
609
+ if success:
610
+ self.notify("Service unstaked!", severity="information")
611
+ self.load_services()
612
+ else:
613
+ self.notify("Unstake failed", severity="error")
614
+ except Exception as e:
615
+ self.notify(f"Error: {e}", severity="error")
616
+
617
+ def drain_service(self, service_key: str) -> None:
618
+ """Drain all service accounts."""
619
+ self.notify("Draining service...", severity="information")
620
+ try:
621
+ from iwa.plugins.olas.service_manager import ServiceManager
622
+
623
+ manager = ServiceManager(self._wallet, service_key=service_key)
624
+ drained = manager.drain_service()
625
+
626
+ # Format summary
627
+ accounts = list(drained.keys()) if drained else []
628
+ self.notify(
629
+ f"Drained accounts: {', '.join(accounts) or 'none'}", severity="information"
630
+ )
631
+ self.load_services()
632
+ except Exception as e:
633
+ self.notify(f"Error: {e}", severity="error")
634
+
635
+ def show_create_service_modal(self) -> None:
636
+ """Show modal to create a new service."""
637
+ from iwa.tui.modals.base import CreateServiceModal
638
+
639
+ chains = ["gnosis"] # Only gnosis has staking contracts
640
+
641
+ # 1. Fetch staking contracts with available slots
642
+ staking_contracts = self._fetch_create_service_options()
643
+
644
+ # 2. Define callback
645
+ def on_modal_result(result) -> None:
646
+ if not result:
647
+ return
648
+ self._handle_create_service_result(result)
649
+
650
+ # 3. Show modal
651
+ self.app.push_screen(
652
+ CreateServiceModal(chains, self._chain, staking_contracts), on_modal_result
653
+ )
654
+
655
+ def _fetch_create_service_options(self) -> List[tuple]:
656
+ """Fetch staking contracts with available slots for creation modal."""
657
+ staking_contracts = []
658
+ try:
659
+ import json
660
+
661
+ from iwa.core.chain import ChainInterface
662
+ from iwa.plugins.olas.constants import OLAS_TRADER_STAKING_CONTRACTS
663
+ from iwa.plugins.olas.contracts.base import OLAS_ABI_PATH
664
+
665
+ contracts_dict = OLAS_TRADER_STAKING_CONTRACTS.get(self._chain, {})
666
+
667
+ # Load ABI and check slots for each contract
668
+ w3 = ChainInterface(self._chain).web3
669
+ with open(OLAS_ABI_PATH / "staking.json", "r") as f:
670
+ abi = json.load(f)
671
+
672
+ for name, addr in contracts_dict.items():
673
+ try:
674
+ contract = w3.eth.contract(address=str(addr), abi=abi)
675
+ service_ids = contract.functions.getServiceIds().call()
676
+ max_services = contract.functions.maxNumServices().call()
677
+ available_slots = max_services - len(service_ids)
678
+
679
+ if available_slots > 0:
680
+ staking_contracts.append((f"{name} ({available_slots} slots)", str(addr)))
681
+ except Exception:
682
+ # If we can't check, include it without slot info
683
+ staking_contracts.append((name, str(addr)))
684
+ except Exception:
685
+ pass # If fetch fails, just use empty list
686
+ return staking_contracts
687
+
688
+ def _handle_create_service_result(self, result: dict) -> None:
689
+ """Handle the result from the create service modal."""
690
+ self.notify("Creating and deploying service...", severity="information")
691
+ try:
692
+ from iwa.plugins.olas.service_manager import ServiceManager
693
+
694
+ manager = ServiceManager(self._wallet)
695
+ service_id = manager.create(
696
+ chain_name=result["chain"],
697
+ service_name=result["name"],
698
+ )
699
+
700
+ if not service_id:
701
+ self.notify("Failed to create service", severity="error")
702
+ return
703
+
704
+ # Spin up to fully deploy
705
+ spin_up_success = manager.spin_up()
706
+
707
+ if spin_up_success:
708
+ # If staking contract was selected, stake the service
709
+ if result.get("staking_contract"):
710
+ try:
711
+ manager.stake(result["staking_contract"])
712
+ self.notify(
713
+ f"Service deployed and staked! ID: {service_id}",
714
+ severity="information",
715
+ )
716
+ except Exception as e:
717
+ self.notify(
718
+ f"Service deployed (ID: {service_id}) but staking failed: {e}",
719
+ severity="warning",
720
+ )
721
+ else:
722
+ self.notify(f"Service deployed! ID: {service_id}", severity="information")
723
+ else:
724
+ self.notify(
725
+ f"Service created (ID: {service_id}) but deployment failed",
726
+ severity="warning",
727
+ )
728
+ self.load_services()
729
+ except Exception as e:
730
+ self.notify(f"Error: {e}", severity="error")
731
+
732
+ def show_fund_service_modal(self, service_key: str) -> None:
733
+ """Show modal to fund a service."""
734
+ from web3 import Web3
735
+
736
+ from iwa.tui.modals.base import FundServiceModal
737
+
738
+ # Get native symbol for current chain
739
+ native_symbol = "xDAI" if self._chain == "gnosis" else "ETH"
740
+
741
+ def on_modal_result(result) -> None:
742
+ if not result:
743
+ return
744
+
745
+ self.notify("Funding service...", severity="information")
746
+ try:
747
+ from iwa.core.models import Config
748
+ from iwa.plugins.olas.models import OlasConfig
749
+
750
+ config = Config()
751
+ olas_config = OlasConfig.model_validate(config.plugins["olas"])
752
+ service = olas_config.services[service_key]
753
+
754
+ # Fund agent
755
+ if result["agent_amount"] > 0 and service.agent_address:
756
+ self._wallet.send(
757
+ from_address_or_tag="master",
758
+ to_address_or_tag=service.agent_address,
759
+ amount_wei=Web3.to_wei(result["agent_amount"], "ether"),
760
+ token_address_or_name="native",
761
+ chain_name=service.chain_name,
762
+ )
763
+
764
+ # Fund safe
765
+ if result["safe_amount"] > 0 and service.multisig_address:
766
+ self._wallet.send(
767
+ from_address_or_tag="master",
768
+ to_address_or_tag=str(service.multisig_address),
769
+ amount_wei=Web3.to_wei(result["safe_amount"], "ether"),
770
+ token_address_or_name="native",
771
+ chain_name=service.chain_name,
772
+ )
773
+
774
+ self.notify("Service funded!", severity="information")
775
+ self.load_services()
776
+ except Exception as e:
777
+ self.notify(f"Error: {e}", severity="error")
778
+
779
+ self.app.push_screen(FundServiceModal(service_key, native_symbol), on_modal_result)
780
+
781
+ def terminate_service(self, service_key: str) -> None:
782
+ """Terminate (wind down) a service."""
783
+ self.notify("Terminating service...", severity="information")
784
+ try:
785
+ from iwa.core.models import Config
786
+ from iwa.plugins.olas.contracts.staking import StakingContract
787
+ from iwa.plugins.olas.models import OlasConfig
788
+ from iwa.plugins.olas.service_manager import ServiceManager
789
+
790
+ config = Config()
791
+ olas_config = OlasConfig.model_validate(config.plugins["olas"])
792
+ service = olas_config.services[service_key]
793
+
794
+ manager = ServiceManager(self._wallet)
795
+ manager.service = service
796
+
797
+ # Get staking contract if staked
798
+ staking_contract = None
799
+ if service.staking_contract_address:
800
+ staking_contract = StakingContract(
801
+ service.staking_contract_address, service.chain_name
802
+ )
803
+
804
+ success = manager.wind_down(staking_contract=staking_contract)
805
+
806
+ if success:
807
+ self.notify("Service terminated!", severity="information")
808
+ self.load_services()
809
+ else:
810
+ self.notify("Failed to terminate service", severity="error")
811
+ except Exception as e:
812
+ self.notify(f"Error: {e}", severity="error")
813
+
814
+ def checkpoint_service(self, service_key: str) -> None:
815
+ """Call checkpoint on a staking contract to close the epoch."""
816
+ self.notify("Calling checkpoint...", severity="information")
817
+ try:
818
+ from iwa.core.models import Config
819
+ from iwa.plugins.olas.contracts.staking import StakingContract
820
+ from iwa.plugins.olas.models import OlasConfig
821
+ from iwa.plugins.olas.service_manager import ServiceManager
822
+
823
+ config = Config()
824
+ olas_config = OlasConfig.model_validate(config.plugins["olas"])
825
+ service = olas_config.services[service_key]
826
+
827
+ manager = ServiceManager(self._wallet)
828
+ manager.service = service
829
+
830
+ staking = StakingContract(service.staking_contract_address, service.chain_name)
831
+ success = manager.call_checkpoint(staking)
832
+
833
+ if success:
834
+ self.notify("Checkpoint successful! Epoch closed.", severity="information")
835
+ self.load_services()
836
+ else:
837
+ self.notify("Checkpoint failed", severity="error")
838
+ except Exception as e:
839
+ self.notify(f"Error: {e}", severity="error")
840
+
841
+ def deploy_service(self, service_key: str) -> None:
842
+ """Deploy a service from PRE_REGISTRATION to DEPLOYED state."""
843
+ from iwa.core.models import Config
844
+ from iwa.plugins.olas.models import OlasConfig
845
+ from iwa.plugins.olas.service_manager import ServiceManager
846
+
847
+ self.notify("Deploying service...", severity="information")
848
+
849
+ try:
850
+ config = Config()
851
+ if "olas" not in config.plugins:
852
+ self.notify("Olas plugin not configured", severity="error")
853
+ return
854
+
855
+ olas_config = OlasConfig.model_validate(config.plugins["olas"])
856
+ if service_key not in olas_config.services:
857
+ self.notify(f"Service {service_key} not found", severity="error")
858
+ return
859
+
860
+ service = olas_config.services[service_key]
861
+
862
+ manager = ServiceManager(self._wallet)
863
+ manager.service = service
864
+
865
+ success = manager.spin_up()
866
+
867
+ if success:
868
+ self.notify(
869
+ "Service deployed successfully! State: DEPLOYED",
870
+ severity="information",
871
+ )
872
+ self.load_services()
873
+ else:
874
+ self.notify("Deployment failed", severity="error")
875
+ except Exception as e:
876
+ self.notify(f"Deployment error: {e}", severity="error")
877
+
878
+ def _build_accounts_data(self, service) -> List[tuple]:
879
+ """Build accounts data list."""
880
+ accounts_data = []
881
+
882
+ if service.agent_address:
883
+ native = self._get_balance(service.agent_address, "native")
884
+ olas = self._get_balance(service.agent_address, "OLAS")
885
+ tag = self._get_tag(service.agent_address)
886
+ accounts_data.append(("Agent", tag or service.agent_address[:10] + "...", native, olas))
887
+
888
+ if service.multisig_address:
889
+ safe_addr = str(service.multisig_address)
890
+ native = self._get_balance(safe_addr, "native")
891
+ olas = self._get_balance(safe_addr, "OLAS")
892
+ tag = self._get_tag(safe_addr)
893
+ accounts_data.append(("Safe", tag or safe_addr[:10] + "...", native, olas))
894
+
895
+ if service.service_owner_address:
896
+ native = self._get_balance(str(service.service_owner_address), "native")
897
+ olas = self._get_balance(str(service.service_owner_address), "OLAS")
898
+ tag = self._get_tag(str(service.service_owner_address))
899
+ accounts_data.append(
900
+ ("Owner", tag or str(service.service_owner_address)[:10] + "...", native, olas)
901
+ )
902
+ return accounts_data
903
+
904
+ def _build_staking_info(self, staking_status) -> dict:
905
+ """Build staking info dict."""
906
+ is_staked = staking_status and staking_status.is_staked
907
+ rewards = staking_status.accrued_reward_wei / 1e18 if staking_status else 0
908
+ checkpoint_pending = (
909
+ staking_status
910
+ and staking_status.remaining_epoch_seconds is not None
911
+ and staking_status.remaining_epoch_seconds <= 0
912
+ )
913
+ return {
914
+ "is_staked": is_staked,
915
+ "rewards": rewards,
916
+ "checkpoint_pending": checkpoint_pending,
917
+ }
918
+
919
+ def _get_epoch_text(self, staking_status) -> str:
920
+ """Get epoch countdown text."""
921
+ epoch_text = "-"
922
+ if staking_status and staking_status.remaining_epoch_seconds is not None:
923
+ if staking_status.remaining_epoch_seconds <= 0:
924
+ epoch_text = "Checkpoint pending"
925
+ else:
926
+ hours = int(staking_status.remaining_epoch_seconds // 3600)
927
+ mins = int((staking_status.remaining_epoch_seconds % 3600) // 60)
928
+ epoch_text = f"{hours}h {mins}m"
929
+ return epoch_text
930
+
931
+ def _get_unstake_text(self, staking_status) -> str:
932
+ """Get unstake countdown text."""
933
+ unstake_text = "-"
934
+ if staking_status and staking_status.unstake_available_at:
935
+ from datetime import datetime, timezone
936
+
937
+ try:
938
+ # Parse ISO format string
939
+ unstake_dt = datetime.fromisoformat(
940
+ staking_status.unstake_available_at.replace("Z", "+00:00")
941
+ )
942
+ now = datetime.now(timezone.utc)
943
+ diff = (unstake_dt - now).total_seconds()
944
+ if diff <= 0:
945
+ unstake_text = "AVAILABLE"
946
+ else:
947
+ hours = int(diff // 3600)
948
+ mins = int((diff % 3600) // 60)
949
+ unstake_text = f"{hours}h {mins}m"
950
+ except Exception:
951
+ unstake_text = "-"
952
+ return unstake_text