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,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
|