iwa 0.0.1a2__py3-none-any.whl → 0.0.1a4__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 (67) hide show
  1. iwa/core/chain/interface.py +51 -61
  2. iwa/core/chain/models.py +7 -7
  3. iwa/core/chain/rate_limiter.py +21 -10
  4. iwa/core/cli.py +27 -2
  5. iwa/core/constants.py +6 -5
  6. iwa/core/contracts/abis/erc20.json +930 -0
  7. iwa/core/contracts/abis/multisend.json +24 -0
  8. iwa/core/contracts/abis/multisend_call_only.json +17 -0
  9. iwa/core/contracts/contract.py +16 -4
  10. iwa/core/ipfs.py +149 -0
  11. iwa/core/keys.py +259 -29
  12. iwa/core/mnemonic.py +3 -13
  13. iwa/core/models.py +28 -6
  14. iwa/core/pricing.py +4 -4
  15. iwa/core/secrets.py +77 -0
  16. iwa/core/services/safe.py +3 -3
  17. iwa/core/utils.py +6 -1
  18. iwa/core/wallet.py +4 -0
  19. iwa/plugins/gnosis/safe.py +2 -2
  20. iwa/plugins/gnosis/tests/test_safe.py +1 -1
  21. iwa/plugins/olas/constants.py +8 -0
  22. iwa/plugins/olas/contracts/abis/activity_checker.json +110 -0
  23. iwa/plugins/olas/contracts/abis/mech.json +740 -0
  24. iwa/plugins/olas/contracts/abis/mech_marketplace.json +1293 -0
  25. iwa/plugins/olas/contracts/abis/mech_new.json +954 -0
  26. iwa/plugins/olas/contracts/abis/service_manager.json +1382 -0
  27. iwa/plugins/olas/contracts/abis/service_registry.json +1909 -0
  28. iwa/plugins/olas/contracts/abis/staking.json +1400 -0
  29. iwa/plugins/olas/contracts/abis/staking_token.json +1274 -0
  30. iwa/plugins/olas/contracts/mech.py +30 -2
  31. iwa/plugins/olas/plugin.py +2 -2
  32. iwa/plugins/olas/tests/test_plugin_full.py +3 -3
  33. iwa/plugins/olas/tests/test_staking_integration.py +2 -2
  34. iwa/tools/__init__.py +1 -0
  35. iwa/tools/check_profile.py +6 -5
  36. iwa/tools/list_contracts.py +136 -0
  37. iwa/tools/release.py +9 -3
  38. iwa/tools/reset_env.py +2 -2
  39. iwa/tools/reset_tenderly.py +26 -24
  40. iwa/tools/wallet_check.py +150 -0
  41. iwa/web/dependencies.py +4 -4
  42. iwa/web/routers/state.py +1 -0
  43. iwa/web/static/app.js +3096 -0
  44. iwa/web/static/index.html +543 -0
  45. iwa/web/static/style.css +1443 -0
  46. iwa/web/tests/test_web_endpoints.py +3 -2
  47. iwa/web/tests/test_web_swap_coverage.py +156 -0
  48. {iwa-0.0.1a2.dist-info → iwa-0.0.1a4.dist-info}/METADATA +6 -3
  49. {iwa-0.0.1a2.dist-info → iwa-0.0.1a4.dist-info}/RECORD +64 -44
  50. iwa-0.0.1a4.dist-info/entry_points.txt +6 -0
  51. {iwa-0.0.1a2.dist-info → iwa-0.0.1a4.dist-info}/top_level.txt +0 -1
  52. tests/test_chain.py +1 -1
  53. tests/test_chain_interface_coverage.py +92 -0
  54. tests/test_contract.py +2 -0
  55. tests/test_keys.py +58 -15
  56. tests/test_migration.py +52 -0
  57. tests/test_mnemonic.py +1 -1
  58. tests/test_pricing.py +7 -7
  59. tests/test_safe_coverage.py +1 -1
  60. tests/test_safe_service.py +3 -3
  61. tests/test_staking_router.py +13 -1
  62. tools/verify_drain.py +1 -1
  63. conftest.py +0 -22
  64. iwa/core/settings.py +0 -95
  65. iwa-0.0.1a2.dist-info/entry_points.txt +0 -2
  66. {iwa-0.0.1a2.dist-info → iwa-0.0.1a4.dist-info}/WHEEL +0 -0
  67. {iwa-0.0.1a2.dist-info → iwa-0.0.1a4.dist-info}/licenses/LICENSE +0 -0
iwa/web/static/app.js ADDED
@@ -0,0 +1,3096 @@
1
+ document.addEventListener("DOMContentLoaded", () => {
2
+ const state = {
3
+ activeChain: localStorage.getItem("iwa_active_chain") || "gnosis",
4
+ activeTab: localStorage.getItem("iwa_active_tab") || "dashboard",
5
+ chains: [],
6
+ tokens: {},
7
+ nativeCurrencies: {},
8
+ accounts: [], // Basic account info
9
+ balanceCache: {}, // { address: { native: "1.00", OLAS: "50.00", ... } }
10
+ authToken: sessionStorage.getItem("iwa_auth_token") || "",
11
+ activeTokens: new Set(["native", "OLAS"]), // Default: native and OLAS
12
+ olasServicesCache: {}, // { chain: [services] }
13
+ stakingContractsCache: null, // Cached staking contracts
14
+ olasPriceCache: null, // Cached OLAS price in EUR
15
+ };
16
+
17
+ // Real-time countdown updater for unstake availability
18
+ function updateUnstakeCountdowns() {
19
+ document.querySelectorAll("[data-unstake-at]").forEach((el) => {
20
+ const targetTime = new Date(el.dataset.unstakeAt);
21
+ const diffMs = targetTime - new Date();
22
+ if (diffMs <= 0) {
23
+ el.innerHTML = '<span class="text-success font-bold">AVAILABLE</span>';
24
+ el.removeAttribute("data-unstake-at");
25
+ } else {
26
+ const totalMins = Math.ceil(diffMs / 60000);
27
+ const hours = Math.floor(totalMins / 60);
28
+ const mins = totalMins % 60;
29
+ el.textContent = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
30
+ }
31
+ });
32
+ }
33
+ // Update every minute
34
+ setInterval(updateUnstakeCountdowns, 60000);
35
+
36
+ // DOM Elements
37
+ const tabBtns = document.querySelectorAll(".tab-btn");
38
+ const tabPanes = document.querySelectorAll(".tab-pane");
39
+ const activeChainSelect = document.getElementById("active-chain");
40
+ const refreshBtn = document.getElementById("refresh-btn");
41
+ const createEoaBtn = document.getElementById("create-eoa-btn");
42
+ const createSafeBtn = document.getElementById("create-safe-btn");
43
+ const sendForm = document.getElementById("send-tx-form");
44
+ const tokenTogglesContainer = document.getElementById("token-toggles");
45
+
46
+ // Login modal handling
47
+ let loginResolver = null;
48
+ const loginModal = document.getElementById("login-modal");
49
+ const loginForm = document.getElementById("login-form");
50
+ const loginPasswordInput = document.getElementById("login-password");
51
+
52
+ function showLoginModal() {
53
+ return new Promise((resolve) => {
54
+ loginResolver = resolve;
55
+ loginPasswordInput.value = "";
56
+ loginModal.classList.add("active");
57
+ loginPasswordInput.focus();
58
+ });
59
+ }
60
+
61
+ if (loginForm) {
62
+ loginForm.addEventListener("submit", (e) => {
63
+ e.preventDefault();
64
+ const pwd = loginPasswordInput.value;
65
+ loginModal.classList.remove("active");
66
+ if (loginResolver) {
67
+ loginResolver(pwd);
68
+ loginResolver = null;
69
+ }
70
+ });
71
+ }
72
+
73
+ // Close login modal on backdrop click
74
+ if (loginModal) {
75
+ loginModal.addEventListener("click", (e) => {
76
+ if (e.target === loginModal) {
77
+ loginModal.classList.remove("active");
78
+ if (loginResolver) {
79
+ loginResolver(null);
80
+ loginResolver = null;
81
+ }
82
+ }
83
+ });
84
+ }
85
+
86
+ // Unified Fetch with Auth
87
+ async function authFetch(url, options = {}) {
88
+ if (state.authToken) {
89
+ options.headers = {
90
+ ...options.headers,
91
+ Authorization: `Bearer ${state.authToken}`,
92
+ };
93
+ }
94
+
95
+ const resp = await fetch(url, options);
96
+
97
+ if (resp.status === 401) {
98
+ const pwd = await showLoginModal();
99
+ if (pwd) {
100
+ state.authToken = pwd;
101
+ sessionStorage.setItem("iwa_auth_token", pwd);
102
+ return authFetch(url, options);
103
+ }
104
+ }
105
+ return resp;
106
+ }
107
+
108
+ const escapeHtml = (str) => {
109
+ if (!str) return "";
110
+ const div = document.createElement("div");
111
+ div.textContent = str;
112
+ return div.innerHTML;
113
+ };
114
+
115
+ // Format balance to 2 decimals
116
+ function formatBalance(value) {
117
+ if (value === null || value === undefined || value === "-") return value;
118
+ const num = parseFloat(value);
119
+ if (isNaN(num)) return value;
120
+ return num.toFixed(2);
121
+ }
122
+
123
+ function getNativeCurrencySymbol() {
124
+ return state.nativeCurrencies[state.activeChain] || "Native";
125
+ }
126
+
127
+ function getAllTokenColumns() {
128
+ const nativeSymbol = getNativeCurrencySymbol();
129
+ const chainTokens = state.tokens[state.activeChain] || [];
130
+ return ["native", ...chainTokens];
131
+ }
132
+
133
+ // Initialize
134
+ async function init() {
135
+ try {
136
+ const resp = await authFetch("/api/state");
137
+ const data = await resp.json();
138
+ state.chains = data.chains;
139
+ state.tokens = data.tokens;
140
+ state.nativeCurrencies = data.native_currencies || {};
141
+
142
+ // Update status indicator for testing mode
143
+ const statusText = document.getElementById("status-text");
144
+ if (data.testing) {
145
+ statusText.textContent = "Testing";
146
+ statusText.style.color = "var(--warning-color)";
147
+ } else {
148
+ statusText.textContent = "Connected";
149
+ }
150
+
151
+ // Restore saved chain or use default
152
+ const savedChain = localStorage.getItem("iwa_active_chain");
153
+ state.activeChain =
154
+ savedChain && data.chains.includes(savedChain)
155
+ ? savedChain
156
+ : data.default_chain;
157
+
158
+ populateChainSelect();
159
+ populateTokenToggles();
160
+ updateFormSelectors();
161
+
162
+ // Restore saved tab
163
+ // Restore saved tab or default to "dashboard"
164
+ const savedTab = localStorage.getItem("iwa_active_tab");
165
+ if (savedTab && document.getElementById(savedTab)) {
166
+ activateTab(savedTab);
167
+ } else {
168
+ // Fallback or explicit default
169
+ activateTab("dashboard");
170
+ }
171
+
172
+ // Initial load
173
+ loadAccounts();
174
+ loadTransactions();
175
+ loadOlasServices(); // Preload Olas services
176
+ preloadStakingContracts(); // Preload staking contracts
177
+
178
+ // Setup Safe chains
179
+ populateSafeChains();
180
+ } catch (err) {
181
+ console.error("Init error:", err);
182
+ showToast("Error initializing: " + escapeHtml(err.message), "error");
183
+ }
184
+ }
185
+
186
+ // Preload staking contracts for fast modal opening
187
+ async function preloadStakingContracts() {
188
+ try {
189
+ const resp = await authFetch("/api/olas/staking-contracts?chain=gnosis");
190
+ state.stakingContractsCache = await resp.json();
191
+ } catch (err) {
192
+ console.error("Failed to preload staking contracts:", err);
193
+ }
194
+ }
195
+
196
+ // Tab Switching Logic
197
+ function activateTab(tabId) {
198
+ if (!document.getElementById(tabId)) return;
199
+
200
+ // Update State & Storage
201
+ state.activeTab = tabId;
202
+ localStorage.setItem("iwa_active_tab", tabId);
203
+
204
+ // Update UI
205
+ tabBtns.forEach((b) => b.classList.remove("active"));
206
+ tabPanes.forEach((p) => p.classList.remove("active"));
207
+
208
+ const btn = document.querySelector(`.tab-btn[data-tab="${tabId}"]`);
209
+ if (btn) btn.classList.add("active");
210
+
211
+ const pane = document.getElementById(tabId);
212
+ if (pane) pane.classList.add("active");
213
+
214
+ // Tab-specific data loading
215
+ if (tabId === "rpc") {
216
+ loadRPCStatus(true);
217
+ } else if (tabId === "cowswap") {
218
+ populateSwapForm();
219
+ loadMasterBalanceTable();
220
+ }
221
+ }
222
+
223
+ // Event Listeners for Tabs
224
+ tabBtns.forEach((btn) => {
225
+ btn.addEventListener("click", () => {
226
+ activateTab(btn.dataset.tab);
227
+ });
228
+ });
229
+
230
+ // Chain Change Handling
231
+ activeChainSelect.addEventListener("change", (e) => {
232
+ state.activeChain = e.target.value;
233
+ localStorage.setItem("iwa_active_chain", e.target.value);
234
+ state.balanceCache = {}; // Clear cache on chain change
235
+ populateTokenToggles();
236
+ loadAccounts();
237
+ loadTransactions();
238
+ updateFormSelectors();
239
+ });
240
+
241
+ // Refresh button - forces full reload
242
+ refreshBtn.addEventListener("click", () => {
243
+ showToast(`Refreshing balances...`, "info");
244
+ state.balanceCache = {}; // Clear cache
245
+ loadAccounts();
246
+ fetchBalancesForTokens(Array.from(state.activeTokens));
247
+ });
248
+
249
+ function populateChainSelect() {
250
+ activeChainSelect.innerHTML = state.chains
251
+ .map(
252
+ (c) =>
253
+ `<option value="${c}" ${c === state.activeChain ? "selected" : ""}>${c.charAt(0).toUpperCase() + c.slice(1)}</option>`,
254
+ )
255
+ .join("");
256
+ }
257
+
258
+ function populateTokenToggles() {
259
+ const chainTokens = state.tokens[state.activeChain] || [];
260
+ const nativeSymbol = getNativeCurrencySymbol();
261
+
262
+ let html = `
263
+ <label class="token-toggle ${state.activeTokens.has("native") ? "active" : ""}">
264
+ <input type="checkbox" value="native" ${state.activeTokens.has("native") ? "checked" : ""}>
265
+ ${escapeHtml(nativeSymbol)}
266
+ </label>
267
+ `;
268
+
269
+ for (const token of chainTokens) {
270
+ html += `
271
+ <label class="token-toggle ${state.activeTokens.has(token) ? "active" : ""}">
272
+ <input type="checkbox" value="${escapeHtml(token)}" ${state.activeTokens.has(token) ? "checked" : ""}>
273
+ ${escapeHtml(token.toUpperCase())}
274
+ </label>
275
+ `;
276
+ }
277
+
278
+ tokenTogglesContainer.innerHTML = html;
279
+
280
+ // Add event listeners
281
+ tokenTogglesContainer
282
+ .querySelectorAll('input[type="checkbox"]')
283
+ .forEach((cb) => {
284
+ cb.addEventListener("change", (e) => {
285
+ const tokenName = e.target.value;
286
+ if (e.target.checked) {
287
+ state.activeTokens.add(tokenName);
288
+ e.target.parentElement.classList.add("active");
289
+ // Re-render immediately to show spinners
290
+ renderAccounts();
291
+ // Then fetch balances (will re-render again when done)
292
+ fetchBalancesForTokens([tokenName]);
293
+ } else {
294
+ state.activeTokens.delete(tokenName);
295
+ e.target.parentElement.classList.remove("active");
296
+ renderAccounts(); // Just re-render (hide this column's balances)
297
+ }
298
+ });
299
+ });
300
+ }
301
+
302
+ function isTokenCached(tokenName) {
303
+ // Check if we have balance data for this token
304
+ for (const acc of state.accounts) {
305
+ if (
306
+ state.balanceCache[acc.address] &&
307
+ state.balanceCache[acc.address][tokenName] !== undefined
308
+ ) {
309
+ return true;
310
+ }
311
+ }
312
+ return false;
313
+ }
314
+
315
+ async function loadAccounts() {
316
+ const body = document.getElementById("accounts-body");
317
+ const allTokens = getAllTokenColumns();
318
+ const nativeSymbol = getNativeCurrencySymbol();
319
+
320
+ // Show loading
321
+ body.innerHTML = `<tr><td colspan="${3 + allTokens.length}" class="text-center"><span class="loading-spinner"></span> Loading accounts...</td></tr>`;
322
+
323
+ try {
324
+ const resp = await authFetch(`/api/accounts?chain=${state.activeChain}`);
325
+ const data = await resp.json();
326
+ state.accounts = data;
327
+
328
+ renderAccounts();
329
+ updateFormSelectors();
330
+
331
+ // Fetch balances for active tokens
332
+ fetchBalancesForTokens(Array.from(state.activeTokens));
333
+ } catch (err) {
334
+ console.error(err);
335
+ body.innerHTML = `<tr><td colspan="${3 + allTokens.length}" class="text-center text-error">Error loading accounts</td></tr>`;
336
+ }
337
+ }
338
+
339
+ function renderAccounts() {
340
+ const body = document.getElementById("accounts-body");
341
+ const thead = document.querySelector("#accounts-table thead tr");
342
+ const allTokens = getAllTokenColumns();
343
+ const nativeSymbol = getNativeCurrencySymbol();
344
+
345
+ // Build header with ALL token columns
346
+ let headerHtml = `
347
+ <th>Tag</th>
348
+ <th>Address</th>
349
+ <th>Type</th>
350
+ `;
351
+ allTokens.forEach((t) => {
352
+ const label = t === "native" ? nativeSymbol : t.toUpperCase();
353
+ headerHtml += `<th class="val">${escapeHtml(label)}</th>`;
354
+ });
355
+ thead.innerHTML = headerHtml;
356
+
357
+ if (!state.accounts || state.accounts.length === 0) {
358
+ body.innerHTML = `<tr><td colspan="${3 + allTokens.length}" class="text-center opacity-50">No accounts found for ${escapeHtml(state.activeChain)}</td></tr>`;
359
+ return;
360
+ }
361
+
362
+ body.innerHTML = state.accounts
363
+ .map((acc) => {
364
+ const cached = state.balanceCache[acc.address] || {};
365
+ return `
366
+ <tr data-address="${escapeHtml(acc.address)}">
367
+ <td><span class="tag-badge">${escapeHtml(acc.tag)}</span></td>
368
+ <td class="address-cell" data-action="copy" data-value="${escapeHtml(acc.address)}">${escapeHtml(shortenAddr(acc.address))}</td>
369
+ <td>${escapeHtml(acc.type)}</td>
370
+ ${allTokens
371
+ .map((t) => {
372
+ const isActive = state.activeTokens.has(t);
373
+ if (!isActive) {
374
+ return `<td class="val balance-cell opacity-30" data-token="${t}">-</td>`;
375
+ }
376
+ const bal = cached[t];
377
+ if (bal !== undefined && bal !== null) {
378
+ return `<td class="val balance-cell" data-token="${t}">${escapeHtml(formatBalance(bal))}</td>`;
379
+ }
380
+ return `<td class="val balance-cell" data-token="${t}"><span class="cell-spinner"></span></td>`;
381
+ })
382
+ .join("")}
383
+ </tr>
384
+ `;
385
+ })
386
+ .join("");
387
+ }
388
+
389
+ async function fetchBalancesForTokens(tokensList) {
390
+ if (tokensList.length === 0) return;
391
+
392
+ const tokensParam = tokensList.join(",");
393
+
394
+ try {
395
+ const resp = await authFetch(
396
+ `/api/accounts?chain=${state.activeChain}&tokens=${encodeURIComponent(tokensParam)}`,
397
+ );
398
+ const data = await resp.json();
399
+
400
+ // Update cache
401
+ data.forEach((acc) => {
402
+ if (!state.balanceCache[acc.address]) {
403
+ state.balanceCache[acc.address] = {};
404
+ }
405
+ tokensList.forEach((t) => {
406
+ // Store balance even if null (so we don't keep showing spinner)
407
+ const bal = acc.balances[t];
408
+ state.balanceCache[acc.address][t] =
409
+ bal !== null && bal !== undefined ? bal : "-";
410
+ });
411
+ });
412
+
413
+ // Re-render to show updated balances
414
+ renderAccounts();
415
+ } catch (err) {
416
+ console.error("Error loading balances:", err);
417
+ // On error, set dashes for the failed tokens
418
+ state.accounts.forEach((acc) => {
419
+ if (!state.balanceCache[acc.address]) {
420
+ state.balanceCache[acc.address] = {};
421
+ }
422
+ tokensList.forEach((t) => {
423
+ if (state.balanceCache[acc.address][t] === undefined) {
424
+ state.balanceCache[acc.address][t] = "-";
425
+ }
426
+ });
427
+ });
428
+ renderAccounts();
429
+ }
430
+ }
431
+
432
+ async function loadTransactions() {
433
+ try {
434
+ const resp = await authFetch(
435
+ `/api/transactions?chain=${state.activeChain}`,
436
+ );
437
+ const data = await resp.json();
438
+ const body = document.getElementById("tx-body");
439
+ body.innerHTML = data
440
+ .map(
441
+ (tx) => `
442
+ <tr>
443
+ <td>${escapeHtml(new Date(tx.timestamp).toLocaleString().replace(",", ""))}</td>
444
+ <td>${escapeHtml(tx.chain)}</td>
445
+ <td class="address-cell" title="${escapeHtml(tx.from)}">${escapeHtml(formatAddressOrTag(tx.from))}</td>
446
+ <td class="address-cell" title="${escapeHtml(tx.to)}">${escapeHtml(formatAddressOrTag(tx.to))}</td>
447
+ <td>${escapeHtml(tx.token.toUpperCase())}</td>
448
+ <td class="val">${escapeHtml(formatBalance(tx.amount))}</td>
449
+ <td class="val">${escapeHtml(formatBalance(tx.value_eur))}</td>
450
+ <td><span class="text-success">${escapeHtml(tx.status)}</span></td>
451
+ <td class="address-cell" data-action="copy" data-value="${escapeHtml(tx.hash)}">${escapeHtml(tx.hash.substring(0, 10))}...</td>
452
+ <td>${escapeHtml(tx.gas_cost)}</td>
453
+ <td>${escapeHtml(formatBalance(tx.gas_value_eur))}</td>
454
+ <td class="tags-cell">${(tx.tags || []).map((t) => `<span class="tag-badge">${escapeHtml(t)}</span>`).join("")}</td>
455
+ </tr>
456
+ `,
457
+ )
458
+ .join("");
459
+ } catch (err) {
460
+ console.error(err);
461
+ }
462
+ }
463
+
464
+ async function loadRPCStatus(showLoading = false) {
465
+ const container = document.getElementById("rpc-cards");
466
+
467
+ if (
468
+ showLoading ||
469
+ !container.innerHTML ||
470
+ container.innerHTML.includes("No data")
471
+ ) {
472
+ container.innerHTML = `<div class="rpc-card glass text-center mb-2"><span class="loading-spinner"></span> Loading RPC status...</div>`;
473
+ }
474
+
475
+ try {
476
+ const resp = await authFetch("/api/rpc-status");
477
+ const status = await resp.json();
478
+ container.innerHTML = Object.entries(status)
479
+ .map(
480
+ ([name, data]) => `
481
+ <div class="rpc-card glass">
482
+ <div class="rpc-header">
483
+ <h3>${escapeHtml(name.toUpperCase())}</h3>
484
+ <span class="status-indicator ${escapeHtml(data.status)}"></span>
485
+ </div>
486
+ <div class="rpc-meta">
487
+ <span>Status:</span>
488
+ <span class="${data.status === "online" ? "text-success" : "text-error"}">${escapeHtml(data.status.toUpperCase())}</span>
489
+ </div>
490
+ ${data.block ? `<div class="rpc-meta"><span>Block:</span><span>${escapeHtml(String(data.block))}</span></div>` : ""}
491
+ ${data.latency ? `<div class="rpc-meta"><span>Latency:</span><span class="accent-color">${escapeHtml(data.latency)}</span></div>` : ""}
492
+ </div>
493
+ `,
494
+ )
495
+ .join("");
496
+ } catch (err) {
497
+ console.error(err);
498
+ if (!container.innerHTML || container.innerHTML.includes("Loading")) {
499
+ container.innerHTML = `<div class="rpc-card glass text-center text-error">Error loading RPC status</div>`;
500
+ }
501
+ }
502
+ }
503
+
504
+ function updateFormSelectors(preserveToken = false) {
505
+ const fromSelect = document.getElementById("tx-from");
506
+ const toSelect = document.getElementById("tx-to");
507
+ const tokenSelect = document.getElementById("tx-token");
508
+ const nativeSymbol = getNativeCurrencySymbol();
509
+ const chainTokens = state.tokens[state.activeChain] || [];
510
+
511
+ // Save current selections
512
+ const prevToken = tokenSelect.value;
513
+
514
+ fromSelect.innerHTML = state.accounts
515
+ .map(
516
+ (acc) =>
517
+ `<option value="${escapeHtml(acc.tag)}">${escapeHtml(acc.tag)}</option>`,
518
+ )
519
+ .join("");
520
+
521
+ toSelect.innerHTML = state.accounts
522
+ .map(
523
+ (acc) =>
524
+ `<option value="${escapeHtml(acc.tag)}">${escapeHtml(acc.tag)}</option>`,
525
+ )
526
+ .join("");
527
+
528
+ tokenSelect.innerHTML =
529
+ `<option value="native">${escapeHtml(nativeSymbol)}</option>` +
530
+ chainTokens
531
+ .map(
532
+ (t) =>
533
+ `<option value="${escapeHtml(t)}">${escapeHtml(t.toUpperCase())}</option>`,
534
+ )
535
+ .join("");
536
+
537
+ // Restore token selection if requested and valid
538
+ if (preserveToken && prevToken) {
539
+ const options = Array.from(tokenSelect.options).map((o) => o.value);
540
+ if (options.includes(prevToken)) {
541
+ tokenSelect.value = prevToken;
542
+ }
543
+ }
544
+ }
545
+
546
+ // EOA Modal Logic
547
+ const eoaModal = document.getElementById("eoa-modal");
548
+ const closeEoaModal = document.getElementById("close-eoa-modal");
549
+ const createEoaForm = document.getElementById("create-eoa-form");
550
+
551
+ createEoaBtn.addEventListener("click", () => {
552
+ eoaModal.classList.add("active");
553
+ document.getElementById("eoa-tag").value = "";
554
+ });
555
+
556
+ closeEoaModal.addEventListener("click", () => {
557
+ eoaModal.classList.remove("active");
558
+ });
559
+
560
+ createEoaForm.addEventListener("submit", async (e) => {
561
+ e.preventDefault();
562
+ const btn = createEoaForm.querySelector('button[type="submit"]');
563
+ const originalText = btn.innerText;
564
+ btn.innerText = "Creating...";
565
+ btn.disabled = true;
566
+
567
+ const tag = document.getElementById("eoa-tag").value || null;
568
+
569
+ try {
570
+ const resp = await authFetch("/api/accounts/eoa", {
571
+ method: "POST",
572
+ headers: { "Content-Type": "application/json" },
573
+ body: JSON.stringify({ tag }),
574
+ });
575
+ if (resp.ok) {
576
+ showToast("EOA Created", "success");
577
+ eoaModal.classList.remove("active");
578
+ createEoaForm.reset();
579
+ // Reload accounts list (new account will show spinners until balances load)
580
+ loadAccounts();
581
+ } else {
582
+ const err = await resp.json();
583
+ showToast(`Error: ${err.detail}`, "error");
584
+ }
585
+ } catch (err) {
586
+ showToast("Error creating EOA", "error");
587
+ } finally {
588
+ btn.innerText = originalText;
589
+ btn.disabled = false;
590
+ }
591
+ });
592
+
593
+ // Safe Modal Logic
594
+ const safeModal = document.getElementById("safe-modal");
595
+ const closeSafeModal = document.getElementById("close-safe-modal");
596
+ const createSafeForm = document.getElementById("create-safe-form");
597
+
598
+ createSafeBtn.addEventListener("click", () => {
599
+ safeModal.classList.add("active");
600
+ document.getElementById("safe-tag").value =
601
+ `Safe ${state.accounts.length + 1}`;
602
+ populateSafeOwners();
603
+ });
604
+
605
+ closeSafeModal.addEventListener("click", () => {
606
+ safeModal.classList.remove("active");
607
+ });
608
+
609
+ function populateSafeOwners() {
610
+ const container = document.getElementById("safe-owners-list");
611
+ if (!state.accounts || state.accounts.length === 0) {
612
+ container.innerHTML =
613
+ '<span class="text-muted text-sm">No accounts available</span>';
614
+ return;
615
+ }
616
+ container.innerHTML = state.accounts
617
+ .map(
618
+ (acc) => `
619
+ <label class="checkbox-item">
620
+ <input type="checkbox" name="safe-owner" value="${escapeHtml(acc.tag)}">
621
+ ${escapeHtml(acc.tag)}
622
+ </label>
623
+ `,
624
+ )
625
+ .join("");
626
+ }
627
+
628
+ function populateSafeChains() {
629
+ const container = document.getElementById("safe-chains-list");
630
+ container.innerHTML = state.chains
631
+ .map(
632
+ (c) => `
633
+ <label class="checkbox-item">
634
+ <input type="checkbox" name="safe-chain" value="${c}" ${c === state.activeChain ? "checked" : ""}>
635
+ ${c.toUpperCase()}
636
+ </label>
637
+ `,
638
+ )
639
+ .join("");
640
+ }
641
+
642
+ createSafeForm.addEventListener("submit", async (e) => {
643
+ e.preventDefault();
644
+ const btn = createSafeForm.querySelector('button[type="submit"]');
645
+ const originalText = btn.innerText;
646
+ btn.innerText = "Deploying...";
647
+ btn.disabled = true;
648
+
649
+ const tag = document.getElementById("safe-tag").value;
650
+ const threshold = parseInt(document.getElementById("safe-threshold").value);
651
+
652
+ // Get selected owners from checkboxes
653
+ const owners = Array.from(
654
+ document.querySelectorAll('input[name="safe-owner"]:checked'),
655
+ ).map((cb) => cb.value);
656
+ const selectedChains = Array.from(
657
+ document.querySelectorAll('input[name="safe-chain"]:checked'),
658
+ ).map((cb) => cb.value);
659
+
660
+ if (owners.length === 0) {
661
+ showToast("Select at least one owner", "error");
662
+ btn.innerText = originalText;
663
+ btn.disabled = false;
664
+ return;
665
+ }
666
+
667
+ if (selectedChains.length === 0) {
668
+ showToast("Select at least one chain", "error");
669
+ btn.innerText = originalText;
670
+ btn.disabled = false;
671
+ return;
672
+ }
673
+
674
+ if (threshold > owners.length) {
675
+ showToast("Threshold cannot exceed number of owners", "error");
676
+ btn.innerText = originalText;
677
+ btn.disabled = false;
678
+ return;
679
+ }
680
+
681
+ try {
682
+ const resp = await authFetch("/api/accounts/safe", {
683
+ method: "POST",
684
+ headers: { "Content-Type": "application/json" },
685
+ body: JSON.stringify({
686
+ tag,
687
+ threshold,
688
+ owners,
689
+ chains: selectedChains,
690
+ }),
691
+ });
692
+ if (resp.ok) {
693
+ showToast("Safe Deployment Started", "success");
694
+ safeModal.classList.remove("active");
695
+ createSafeForm.reset();
696
+ // Reload transactions immediately to show deployment
697
+ loadTransactions();
698
+ // Reload accounts after delay
699
+ setTimeout(() => {
700
+ loadAccounts();
701
+ }, 5000);
702
+ } else {
703
+ const err = await resp.json();
704
+ showToast(`Error: ${err.detail}`, "error");
705
+ }
706
+ } catch (err) {
707
+ showToast("Error deploying Safe", "error");
708
+ } finally {
709
+ btn.innerText = originalText;
710
+ btn.disabled = false;
711
+ }
712
+ });
713
+
714
+ sendForm.addEventListener("submit", async (e) => {
715
+ e.preventDefault();
716
+ const btn = sendForm.querySelector("button");
717
+ const originalText = btn.innerText;
718
+ btn.innerText = "Sending...";
719
+ btn.disabled = true;
720
+
721
+ const payload = {
722
+ from_address: document.getElementById("tx-from").value,
723
+ to_address: document.getElementById("tx-to").value,
724
+ amount_eth: parseFloat(document.getElementById("tx-amount").value),
725
+ token: document.getElementById("tx-token").value,
726
+ chain: state.activeChain,
727
+ };
728
+
729
+ try {
730
+ const resp = await authFetch("/api/send", {
731
+ method: "POST",
732
+ headers: { "Content-Type": "application/json" },
733
+ body: JSON.stringify(payload),
734
+ });
735
+ const result = await resp.json();
736
+ if (resp.ok) {
737
+ const hashDisplay = result.hash
738
+ ? `Hash: ${result.hash.substring(0, 10)}...`
739
+ : "Transaction submitted";
740
+ showToast(`Success! ${hashDisplay}`, "success");
741
+ // Reset form but preserve token selection
742
+ const selectedToken = document.getElementById("tx-token").value;
743
+ sendForm.reset();
744
+ document.getElementById("tx-token").value = selectedToken;
745
+ loadTransactions();
746
+ // Refresh balances after transaction
747
+ state.balanceCache = {};
748
+ fetchBalancesForTokens(Array.from(state.activeTokens));
749
+ } else {
750
+ showToast(`Error: ${result.detail}`, "error");
751
+ }
752
+ } catch (err) {
753
+ showToast("Network error", "error");
754
+ } finally {
755
+ btn.innerText = originalText;
756
+ btn.disabled = false;
757
+ }
758
+ });
759
+
760
+ // Utils
761
+ function shortenAddr(addr) {
762
+ if (!addr) return "";
763
+ // Only shorten if it looks like an Ethereum address
764
+ if (addr.startsWith("0x") && addr.length === 42) {
765
+ return addr.substring(0, 6) + "..." + addr.substring(addr.length - 4);
766
+ }
767
+ return addr;
768
+ }
769
+
770
+ // Format address or tag for display
771
+ function formatAddressOrTag(value) {
772
+ if (!value) return "";
773
+ // If it looks like an address, shorten it
774
+ if (value.startsWith("0x") && value.length === 42) {
775
+ return shortenAddr(value);
776
+ }
777
+ // Otherwise it's a tag, show it fully
778
+ return value;
779
+ }
780
+
781
+ function getExplorerUrl(address, chain) {
782
+ if (!address) return "#";
783
+ if (chain === "gnosis") return `https://gnosisscan.io/address/${address}`;
784
+ if (chain === "base") return `https://basescan.org/address/${address}`;
785
+ if (chain === "ethereum") return `https://etherscan.io/address/${address}`;
786
+ return `https://gnosisscan.io/address/${address}`;
787
+ }
788
+
789
+ window.copyToClipboard = (text) => {
790
+ navigator.clipboard.writeText(text).then(() => {
791
+ showToast("Copied to clipboard", "info");
792
+ });
793
+ };
794
+
795
+ function showToast(msg, type = "info", duration = 4000) {
796
+ const container = document.getElementById("toast-container");
797
+ const toast = document.createElement("div");
798
+ toast.className = `toast ${type}`;
799
+ toast.innerText = msg;
800
+ container.appendChild(toast);
801
+ const remove = () => {
802
+ if (toast.parentElement) toast.remove();
803
+ };
804
+ setTimeout(remove, duration);
805
+ return remove;
806
+ }
807
+
808
+ // Custom themed confirm dialog
809
+ function showConfirm(title, message) {
810
+ return new Promise((resolve) => {
811
+ const modal = document.getElementById("confirm-modal");
812
+ const titleEl = document.getElementById("confirm-title");
813
+ const messageEl = document.getElementById("confirm-message");
814
+ const okBtn = document.getElementById("confirm-ok");
815
+ const cancelBtn = document.getElementById("confirm-cancel");
816
+
817
+ titleEl.textContent = title;
818
+ messageEl.textContent = message;
819
+ modal.classList.add("active");
820
+
821
+ const cleanup = () => {
822
+ modal.classList.remove("active");
823
+ okBtn.onclick = null;
824
+ cancelBtn.onclick = null;
825
+ };
826
+
827
+ okBtn.onclick = () => {
828
+ cleanup();
829
+ resolve(true);
830
+ };
831
+
832
+ cancelBtn.onclick = () => {
833
+ cleanup();
834
+ resolve(false);
835
+ };
836
+ });
837
+ }
838
+
839
+ // ===== CowSwap Functions =====
840
+ const swapForm = document.getElementById("swap-form");
841
+ const swapModeRadios = document.querySelectorAll('input[name="swap-mode"]');
842
+ const sellCard = document.getElementById("sell-card");
843
+ const buyCard = document.getElementById("buy-card");
844
+ const sellAmountInput = document.getElementById("swap-sell-amount");
845
+ const buyAmountInput = document.getElementById("swap-buy-amount");
846
+ const swapMaxSellBtn = document.getElementById("swap-max-sell");
847
+ const swapMaxBuyBtn = document.getElementById("swap-max-buy");
848
+ let quoteTimeout = null;
849
+
850
+ function populateSwapForm() {
851
+ const sellTokenSelect = document.getElementById("swap-sell-token");
852
+ const buyTokenSelect = document.getElementById("swap-buy-token");
853
+
854
+ // Populate tokens (CowSwap supports ERC20s like WXDAI, but not native xDAI)
855
+ const chainTokens = state.tokens[state.activeChain] || [];
856
+ const tokenOptions = chainTokens
857
+ .map(
858
+ (t) =>
859
+ `<option value="${escapeHtml(t)}">${escapeHtml(t.toUpperCase())}</option>`,
860
+ )
861
+ .join("");
862
+
863
+ sellTokenSelect.innerHTML = tokenOptions;
864
+ buyTokenSelect.innerHTML = tokenOptions;
865
+
866
+ // Set default values (prefer WXDAI -> OLAS)
867
+ if (chainTokens.includes("WXDAI") && chainTokens.includes("OLAS")) {
868
+ sellTokenSelect.value = "WXDAI";
869
+ buyTokenSelect.value = "OLAS";
870
+ } else if (chainTokens.length >= 2) {
871
+ sellTokenSelect.value = chainTokens[0];
872
+ buyTokenSelect.value = chainTokens[1];
873
+ }
874
+
875
+ // Initialize card states
876
+ updateCardStates();
877
+ }
878
+
879
+ function updateCardStates() {
880
+ const mode = document.querySelector(
881
+ 'input[name="swap-mode"]:checked',
882
+ ).value;
883
+ if (mode === "sell") {
884
+ // Sell mode: sell amount editable, buy amount read-only (no spinners)
885
+ sellAmountInput.removeAttribute("readonly");
886
+ sellAmountInput.classList.remove("no-spinners");
887
+ buyAmountInput.setAttribute("readonly", "true");
888
+ buyAmountInput.classList.add("no-spinners");
889
+ sellCard.classList.add("active");
890
+ buyCard.classList.remove("active");
891
+ if (swapMaxSellBtn) swapMaxSellBtn.style.display = "";
892
+ if (swapMaxBuyBtn) swapMaxBuyBtn.style.display = "none";
893
+ } else {
894
+ // Buy mode: buy amount editable, sell amount read-only (no spinners)
895
+ buyAmountInput.removeAttribute("readonly");
896
+ buyAmountInput.classList.remove("no-spinners");
897
+ sellAmountInput.setAttribute("readonly", "true");
898
+ sellAmountInput.classList.add("no-spinners");
899
+ buyCard.classList.add("active");
900
+ sellCard.classList.remove("active");
901
+ if (swapMaxSellBtn) swapMaxSellBtn.style.display = "none";
902
+ if (swapMaxBuyBtn) swapMaxBuyBtn.style.display = "";
903
+ }
904
+ }
905
+
906
+ // Update card states when mode changes
907
+ swapModeRadios.forEach((radio) => {
908
+ radio.addEventListener("change", () => {
909
+ updateCardStates();
910
+ // Clear amounts
911
+ sellAmountInput.value = "";
912
+ buyAmountInput.value = "";
913
+ });
914
+ });
915
+
916
+ // Swap tokens button handler (click on arrow to swap sell/buy)
917
+ const swapTokensBtn = document.getElementById("swap-tokens-btn");
918
+ if (swapTokensBtn) {
919
+ swapTokensBtn.addEventListener("click", () => {
920
+ const sellTokenSelect = document.getElementById("swap-sell-token");
921
+ const buyTokenSelect = document.getElementById("swap-buy-token");
922
+
923
+ // Swap token values
924
+ const tempToken = sellTokenSelect.value;
925
+ sellTokenSelect.value = buyTokenSelect.value;
926
+ buyTokenSelect.value = tempToken;
927
+
928
+ // Clear amounts since they need to be recalculated
929
+ sellAmountInput.value = "";
930
+ buyAmountInput.value = "";
931
+
932
+ // Clear isMax flag
933
+ delete sellAmountInput.dataset.isMax;
934
+ });
935
+ }
936
+
937
+ // Debounced quote fetching
938
+ async function fetchQuote() {
939
+ const mode = document.querySelector(
940
+ 'input[name="swap-mode"]:checked',
941
+ ).value;
942
+ const account = "master";
943
+ const sellToken = document.getElementById("swap-sell-token").value;
944
+ const buyToken = document.getElementById("swap-buy-token").value;
945
+
946
+ let inputAmount, outputField;
947
+ if (mode === "sell") {
948
+ inputAmount = parseFloat(sellAmountInput.value);
949
+ outputField = buyAmountInput;
950
+ } else {
951
+ inputAmount = parseFloat(buyAmountInput.value);
952
+ outputField = sellAmountInput;
953
+ }
954
+
955
+ if (
956
+ !account ||
957
+ !sellToken ||
958
+ !buyToken ||
959
+ !inputAmount ||
960
+ inputAmount <= 0
961
+ ) {
962
+ outputField.value = "";
963
+ return;
964
+ }
965
+
966
+ // Show loading indicator in output field
967
+ outputField.value = "";
968
+ outputField.placeholder = "Loading...";
969
+
970
+ try {
971
+ const params = new URLSearchParams({
972
+ account,
973
+ sell_token: sellToken,
974
+ buy_token: buyToken,
975
+ amount: inputAmount,
976
+ mode,
977
+ chain: state.activeChain,
978
+ });
979
+ const resp = await authFetch(`/api/swap/quote?${params}`);
980
+ const result = await resp.json();
981
+ if (resp.ok) {
982
+ outputField.value = result.amount.toFixed(2);
983
+ } else {
984
+ outputField.value = "";
985
+ showToast(result.detail || "Error getting quote", "error");
986
+ }
987
+ } catch (err) {
988
+ outputField.value = "";
989
+ } finally {
990
+ outputField.placeholder = "0.00";
991
+ }
992
+ }
993
+
994
+ // Add input listeners for auto-quote
995
+ function setupAmountListeners() {
996
+ const debouncedFetch = () => {
997
+ clearTimeout(quoteTimeout);
998
+ quoteTimeout = setTimeout(fetchQuote, 500);
999
+ };
1000
+
1001
+ if (sellAmountInput) {
1002
+ sellAmountInput.addEventListener("input", () => {
1003
+ // Clear the isMax flag when user manually edits the value
1004
+ delete sellAmountInput.dataset.isMax;
1005
+ debouncedFetch();
1006
+ });
1007
+ }
1008
+ if (buyAmountInput) {
1009
+ buyAmountInput.addEventListener("input", debouncedFetch);
1010
+ }
1011
+
1012
+ // Also trigger quote on token change
1013
+ const sellTokenSelect = document.getElementById("swap-sell-token");
1014
+ const buyTokenSelect = document.getElementById("swap-buy-token");
1015
+ if (sellTokenSelect) {
1016
+ sellTokenSelect.addEventListener("change", debouncedFetch);
1017
+ }
1018
+ if (buyTokenSelect) {
1019
+ buyTokenSelect.addEventListener("change", debouncedFetch);
1020
+ }
1021
+ }
1022
+
1023
+ setupAmountListeners();
1024
+
1025
+ // Handle Max Sell button click
1026
+ async function handleMaxClick(isSellMode) {
1027
+ const account = "master";
1028
+ const sellToken = document.getElementById("swap-sell-token").value;
1029
+ const buyToken = document.getElementById("swap-buy-token").value;
1030
+ const btn = isSellMode ? swapMaxSellBtn : swapMaxBuyBtn;
1031
+ const targetInput = isSellMode ? sellAmountInput : buyAmountInput;
1032
+
1033
+ if (!sellToken || !buyToken) {
1034
+ showToast("Select tokens first", "error");
1035
+ return;
1036
+ }
1037
+
1038
+ btn.disabled = true;
1039
+ btn.innerHTML = '<span class="btn-spinner"></span>';
1040
+
1041
+ try {
1042
+ const params = new URLSearchParams({
1043
+ account,
1044
+ sell_token: sellToken,
1045
+ buy_token: buyToken,
1046
+ mode: isSellMode ? "sell" : "buy",
1047
+ chain: state.activeChain,
1048
+ });
1049
+ const resp = await authFetch(`/api/swap/max-amount?${params}`);
1050
+ const result = await resp.json();
1051
+ if (resp.ok) {
1052
+ // Use up to 6 decimals but remove trailing zeros
1053
+ targetInput.value = parseFloat(result.max_amount.toFixed(6));
1054
+ // Mark that this is a "max" amount to avoid precision loss
1055
+ if (isSellMode) {
1056
+ targetInput.dataset.isMax = "true";
1057
+ }
1058
+ // Trigger quote fetch
1059
+ fetchQuote();
1060
+ } else {
1061
+ showToast(result.detail || "Error getting max amount", "error");
1062
+ }
1063
+ } catch (err) {
1064
+ showToast("Network error fetching max amount", "error");
1065
+ } finally {
1066
+ btn.disabled = false;
1067
+ btn.innerHTML = "Max";
1068
+ }
1069
+ }
1070
+
1071
+ if (swapMaxSellBtn) {
1072
+ swapMaxSellBtn.addEventListener("click", () => handleMaxClick(true));
1073
+ }
1074
+ if (swapMaxBuyBtn) {
1075
+ swapMaxBuyBtn.addEventListener("click", () => handleMaxClick(false));
1076
+ }
1077
+
1078
+ // Handle swap form submission
1079
+ if (swapForm) {
1080
+ swapForm.addEventListener("submit", async (e) => {
1081
+ e.preventDefault();
1082
+ const btn = swapForm.querySelector('button[type="submit"]');
1083
+ const originalText = btn.innerText;
1084
+ btn.innerText = "Swapping...";
1085
+ btn.disabled = true;
1086
+
1087
+ const swapMode = document.querySelector(
1088
+ 'input[name="swap-mode"]:checked',
1089
+ ).value;
1090
+
1091
+ // Check if user used Max button (to avoid float precision loss)
1092
+ const isMaxSell =
1093
+ swapMode === "sell" && sellAmountInput.dataset.isMax === "true";
1094
+
1095
+ const amount = isMaxSell
1096
+ ? null // Send null to use exact wei balance on backend
1097
+ : swapMode === "sell"
1098
+ ? parseFloat(sellAmountInput.value)
1099
+ : parseFloat(buyAmountInput.value);
1100
+
1101
+ const payload = {
1102
+ account: "master",
1103
+ sell_token: document.getElementById("swap-sell-token").value,
1104
+ buy_token: document.getElementById("swap-buy-token").value,
1105
+ amount_eth: amount,
1106
+ order_type: swapMode,
1107
+ chain: state.activeChain,
1108
+ };
1109
+
1110
+ try {
1111
+ const resp = await authFetch("/api/swap", {
1112
+ method: "POST",
1113
+ headers: { "Content-Type": "application/json" },
1114
+ body: JSON.stringify(payload),
1115
+ });
1116
+ const result = await resp.json();
1117
+ if (resp.ok) {
1118
+ let msg = result.message || "Swap executed!";
1119
+ if (result.analytics) {
1120
+ const execPrice =
1121
+ result.analytics.execution_price ||
1122
+ result.analytics.executed_buy_amount /
1123
+ result.analytics.executed_sell_amount;
1124
+ // value_change_pct comes from backend now
1125
+
1126
+ msg += `\nPrice: ${execPrice.toFixed(4)}`;
1127
+
1128
+ const valChange = result.analytics.value_change_pct;
1129
+ if (valChange !== undefined) {
1130
+ if (valChange === "N/A") {
1131
+ msg += `\nValue Change: N/A`;
1132
+ } else {
1133
+ msg += `\nValue Change: ${valChange > 0 ? "+" : ""}${valChange.toFixed(2)}%`;
1134
+ }
1135
+ }
1136
+ }
1137
+ showToast(msg, "success", 7000); // Longer duration for reading
1138
+
1139
+ sellAmountInput.value = "";
1140
+ buyAmountInput.value = "";
1141
+
1142
+ // Refresh orders immediately (balance refresh happens on fulfillment)
1143
+ loadRecentOrders();
1144
+ } else {
1145
+ showToast(`Error: ${result.detail}`, "error");
1146
+ }
1147
+ } catch (err) {
1148
+ showToast("Network error during swap", "error");
1149
+ } finally {
1150
+ btn.innerText = originalText;
1151
+ btn.disabled = false;
1152
+ }
1153
+ });
1154
+ }
1155
+
1156
+ // Populate swap form when switching to CowSwap tab
1157
+ tabBtns.forEach((btn) => {
1158
+ btn.addEventListener("click", () => {
1159
+ if (btn.dataset.tab === "cowswap") {
1160
+ populateSwapForm();
1161
+ populateWrapForm();
1162
+ loadMasterBalanceTable();
1163
+ loadRecentOrders();
1164
+ } else if (btn.dataset.tab === "olas") {
1165
+ loadOlasServices();
1166
+ }
1167
+ });
1168
+ });
1169
+
1170
+ // Token symbol mapping cache
1171
+ const tokenSymbolCache = {};
1172
+
1173
+ async function getTokenSymbol(address, chainTokens) {
1174
+ if (tokenSymbolCache[address]) return tokenSymbolCache[address];
1175
+
1176
+ // Try to find in known tokens
1177
+ for (const [symbol, addr] of Object.entries(chainTokens || {})) {
1178
+ if (addr && addr.toLowerCase() === address.toLowerCase()) {
1179
+ tokenSymbolCache[address] = symbol;
1180
+ return symbol;
1181
+ }
1182
+ }
1183
+
1184
+ // Return truncated address if not found
1185
+ return address.substring(0, 6) + "...";
1186
+ }
1187
+
1188
+ function formatSecondsToTime(seconds) {
1189
+ if (seconds <= 0) return "Expired";
1190
+ const mins = Math.floor(seconds / 60);
1191
+ const secs = seconds % 60;
1192
+ return `${mins}m ${secs}s`;
1193
+ }
1194
+
1195
+ function formatOrderDate(isoString) {
1196
+ if (!isoString) return "-";
1197
+ try {
1198
+ const date = new Date(isoString);
1199
+ const year = date.getFullYear();
1200
+ const month = String(date.getMonth() + 1).padStart(2, "0");
1201
+ const day = String(date.getDate()).padStart(2, "0");
1202
+ const hours = String(date.getHours()).padStart(2, "0");
1203
+ const mins = String(date.getMinutes()).padStart(2, "0");
1204
+ return `${year}-${month}-${day} ${hours}:${mins}`;
1205
+ } catch (e) {
1206
+ return "-";
1207
+ }
1208
+ }
1209
+
1210
+ async function loadRecentOrders() {
1211
+ const tableBody = document.getElementById("recent-orders-body");
1212
+ if (!tableBody) return;
1213
+
1214
+ // Do not show loading spinner if we already have content (prevents flicker)
1215
+ if (
1216
+ tableBody.children.length === 0 ||
1217
+ tableBody.innerHTML.includes("Loading...")
1218
+ ) {
1219
+ // Only show loading on initial load or if empty
1220
+ // tableBody.innerHTML = `<tr><td colspan="4" class="text-center"><span class="cell-spinner"></span> Loading...</td></tr>`;
1221
+ }
1222
+
1223
+ let hasPendingOrders = false;
1224
+
1225
+ try {
1226
+ const resp = await authFetch(
1227
+ `/api/swap/orders?chain=${state.activeChain}`,
1228
+ );
1229
+ if (!resp.ok) throw new Error("Failed to fetch orders");
1230
+
1231
+ const data = await resp.json();
1232
+ const orders = data.orders || [];
1233
+
1234
+ // Detect status transitions to "fulfilled" and refresh balances
1235
+ for (const order of orders) {
1236
+ const prevStatus = previousOrderStatuses[order.uid];
1237
+ if (
1238
+ prevStatus &&
1239
+ prevStatus !== "fulfilled" &&
1240
+ order.status === "fulfilled"
1241
+ ) {
1242
+ // Order just became fulfilled - refresh balances
1243
+ loadMasterBalanceTable(true);
1244
+ break; // Only need to refresh once per poll cycle
1245
+ }
1246
+ previousOrderStatuses[order.uid] = order.status;
1247
+ }
1248
+
1249
+ if (orders.length === 0) {
1250
+ const noOrdersHtml = `<tr><td colspan="6" class="text-center text-muted">No recent orders</td></tr>`;
1251
+ if (tableBody.innerHTML !== noOrdersHtml) {
1252
+ tableBody.innerHTML = noOrdersHtml;
1253
+ }
1254
+ // Stop polling when no orders - will restart when new swap is placed
1255
+ isPolling = false;
1256
+ return;
1257
+ }
1258
+
1259
+ let html = "";
1260
+ for (const order of orders) {
1261
+ // Use backend provided names (which are symbols) and formatted amounts
1262
+ const sellSymbol = order.sellToken;
1263
+ const buySymbol = order.buyToken;
1264
+ const sellAmt = order.sellAmount;
1265
+ const buyAmt = order.buyAmount;
1266
+
1267
+ // Format creation date
1268
+ const dateStr = formatOrderDate(order.created);
1269
+
1270
+ // Status badge class
1271
+ const statusClass = order.status
1272
+ .replace(/([A-Z])/g, "-$1")
1273
+ .toLowerCase();
1274
+
1275
+ // Progress bar for open orders
1276
+ let progressHtml = "-";
1277
+
1278
+ // Detect pending status for adaptive polling
1279
+ if (order.status === "open" || order.status === "presignaturePending") {
1280
+ if (order.timeRemaining > 0) {
1281
+ hasPendingOrders = true;
1282
+ progressHtml = `
1283
+ <div class="order-progress">
1284
+ <div class="progress-bar-container">
1285
+ <div class="progress-bar" style="width: ${order.progressPct}%"></div>
1286
+ </div>
1287
+ <span class="progress-time" data-valid-to="${order.validTo}">${formatSecondsToTime(order.timeRemaining)}</span>
1288
+ </div>
1289
+ `;
1290
+ } else {
1291
+ progressHtml = `<span class="text-muted">Expiring...</span>`;
1292
+ // Even if expiring, we might want to poll fast to catch the "expired" state update
1293
+ hasPendingOrders = true;
1294
+ }
1295
+ }
1296
+
1297
+ // Build CowSwap explorer URL
1298
+ const explorerUrl = `https://explorer.cow.fi/gc/orders/${order.full_uid}`;
1299
+ const shortUid = order.uid;
1300
+
1301
+ html += `
1302
+ <tr>
1303
+ <td class="text-muted">${dateStr}</td>
1304
+ <td><a href="${explorerUrl}" target="_blank" rel="noopener noreferrer" class="order-link">${escapeHtml(shortUid)}</a></td>
1305
+ <td><span class="order-status ${statusClass}">${order.status}</span></td>
1306
+ <td>${sellAmt} ${escapeHtml(sellSymbol)}</td>
1307
+ <td>${buyAmt} ${escapeHtml(buySymbol)}</td>
1308
+ <td>${progressHtml}</td>
1309
+ </tr>
1310
+ `;
1311
+ }
1312
+
1313
+ // Only update DOM if content changed to avoid focus loss/scroll jumps
1314
+ if (tableBody.innerHTML !== html) {
1315
+ tableBody.innerHTML = html;
1316
+ }
1317
+ } catch (err) {
1318
+ console.error("Error loading orders:", err);
1319
+ // Don't wipe table on error, just log
1320
+ } finally {
1321
+ // Only continue polling if there are pending orders
1322
+ if (hasPendingOrders) {
1323
+ scheduleNextPoll(5000);
1324
+ } else {
1325
+ isPolling = false;
1326
+ }
1327
+ }
1328
+ }
1329
+
1330
+ // Client-side countdown timer for smooth updates
1331
+ setInterval(() => {
1332
+ const timers = document.querySelectorAll(".progress-time[data-valid-to]");
1333
+ if (timers.length === 0) return;
1334
+
1335
+ const now = Math.floor(Date.now() / 1000);
1336
+
1337
+ timers.forEach((timer) => {
1338
+ const validTo = parseInt(timer.dataset.validTo);
1339
+ const remaining = validTo - now;
1340
+
1341
+ if (remaining > 0) {
1342
+ timer.textContent = formatSecondsToTime(remaining);
1343
+ } else {
1344
+ timer.textContent = "Expiring...";
1345
+ }
1346
+ });
1347
+ }, 1000);
1348
+
1349
+ // Adaptive polling for orders
1350
+ let ordersTimeoutId = null;
1351
+ let isPolling = false;
1352
+ const previousOrderStatuses = {}; // Track order status for detecting fulfilled transitions
1353
+
1354
+ function scheduleNextPoll(delay) {
1355
+ if (ordersTimeoutId) clearTimeout(ordersTimeoutId);
1356
+
1357
+ ordersTimeoutId = setTimeout(() => {
1358
+ const cowswapTab = document.getElementById("cowswap");
1359
+ if (cowswapTab && cowswapTab.classList.contains("active")) {
1360
+ loadRecentOrders();
1361
+ } else {
1362
+ // If tab not active, stop polling (it will resume on tab click)
1363
+ isPolling = false;
1364
+ }
1365
+ }, delay);
1366
+ isPolling = true;
1367
+ }
1368
+
1369
+ function startOrdersPolling() {
1370
+ // Force immediate load and start cycle
1371
+ loadRecentOrders();
1372
+ }
1373
+
1374
+ // Start polling
1375
+ startOrdersPolling();
1376
+
1377
+ // Load Master Balance Table for CowSwap tab
1378
+ let masterTableLoadedChain = null;
1379
+
1380
+ async function loadMasterBalanceTable(forceRefresh = false) {
1381
+ const tableBody = document.getElementById("cowswap-master-body");
1382
+ const headerRow = document.getElementById("cowswap-master-header");
1383
+
1384
+ // Check if reload needed
1385
+ const currentChain = state.activeChain || "gnosis";
1386
+ if (
1387
+ !forceRefresh &&
1388
+ masterTableLoadedChain === currentChain &&
1389
+ tableBody.children.length > 0 &&
1390
+ !tableBody.innerHTML.includes("Loading")
1391
+ ) {
1392
+ return;
1393
+ }
1394
+
1395
+ // Clear loading state
1396
+ tableBody.innerHTML = `<tr><td colspan="10" class="text-center"><span class="cell-spinner"></span> Loading master balances...</td></tr>`;
1397
+
1398
+ // Determine tokens based on active chain
1399
+ const chain = currentChain;
1400
+ const nativeSymbol = state.nativeCurrencies[chain] || "Native";
1401
+ const erc20s = state.tokens[chain] || [];
1402
+
1403
+ // Header Generation
1404
+ let headerHtml = "<th>Account</th>";
1405
+ headerHtml += `<th>${escapeHtml(nativeSymbol)}</th>`; // Native column
1406
+ erc20s.forEach((t) => {
1407
+ headerHtml += `<th>${escapeHtml(t)}</th>`;
1408
+ });
1409
+ headerRow.innerHTML = headerHtml;
1410
+
1411
+ try {
1412
+ // Fetch balances for master only
1413
+ // Must include 'native' explicitly to get native balance
1414
+ const tokensToFetch = ["native", ...erc20s];
1415
+ const tokensParam = tokensToFetch.join(",");
1416
+
1417
+ const resp = await authFetch(
1418
+ `/api/accounts?chain=${chain}&tokens=${tokensParam}`,
1419
+ );
1420
+ if (!resp.ok) throw new Error("Failed to fetch accounts");
1421
+
1422
+ const accounts = await resp.json();
1423
+ const master = accounts.find((a) => a.tag === "master");
1424
+
1425
+ const colSpan = 2 + erc20s.length; // Account + Native + ERC20s
1426
+
1427
+ if (!master) {
1428
+ tableBody.innerHTML = `<tr><td colspan="${colSpan}" class="text-center">Master account not found</td></tr>`;
1429
+ return;
1430
+ }
1431
+
1432
+ // Render Row
1433
+ let rowHtml = `
1434
+ <tr>
1435
+ <td class="account-cell" title="${master.address}">
1436
+ <span class="tag-badge">${escapeHtml(master.tag)}</span>
1437
+ </td>
1438
+ `;
1439
+
1440
+ // Native Balance (remove color classes)
1441
+ const nativeBalance =
1442
+ master.balances["native"] !== undefined ? master.balances["native"] : 0;
1443
+ rowHtml += `<td class="val font-bold">${formatBalance(nativeBalance)}</td>`;
1444
+
1445
+ // ERC20 Balances (remove color classes)
1446
+ erc20s.forEach((token) => {
1447
+ const balance =
1448
+ master.balances && master.balances[token] !== undefined
1449
+ ? master.balances[token]
1450
+ : 0;
1451
+ rowHtml += `<td class="val">${formatBalance(balance)}</td>`;
1452
+ });
1453
+
1454
+ rowHtml += `</tr>`;
1455
+ tableBody.innerHTML = rowHtml;
1456
+ masterTableLoadedChain = currentChain;
1457
+ } catch (err) {
1458
+ console.error("Error loading master table:", err);
1459
+ tableBody.innerHTML = `<tr><td colspan="10" class="text-center text-error">Error loading balances</td></tr>`;
1460
+ masterTableLoadedChain = null;
1461
+ }
1462
+ }
1463
+
1464
+ // ===== Wrap/Unwrap Functions =====
1465
+ const wrapForm = document.getElementById("wrap-form");
1466
+ const wrapModeRadios = document.querySelectorAll('input[name="wrap-mode"]');
1467
+ const wrapAmountInput = document.getElementById("wrap-amount");
1468
+ const wrapMaxBtn = document.getElementById("wrap-max-btn");
1469
+ const wrapSubmitBtn = document.getElementById("wrap-submit-btn");
1470
+
1471
+ function populateWrapForm() {
1472
+ // Nothing to populate - uses master account
1473
+ }
1474
+
1475
+ function updateWrapButtonText() {
1476
+ if (!wrapSubmitBtn) return;
1477
+ const mode =
1478
+ document.querySelector('input[name="wrap-mode"]:checked')?.value ||
1479
+ "wrap";
1480
+ wrapSubmitBtn.textContent = mode === "wrap" ? "Wrap xDAI" : "Unwrap WXDAI";
1481
+ }
1482
+
1483
+ // Mode change handler
1484
+ if (wrapModeRadios) {
1485
+ wrapModeRadios.forEach((radio) => {
1486
+ radio.addEventListener("change", () => {
1487
+ updateWrapButtonText();
1488
+ if (wrapAmountInput) wrapAmountInput.value = "";
1489
+ });
1490
+ });
1491
+ }
1492
+
1493
+ // Max button handler - fetches balance from API
1494
+ if (wrapMaxBtn) {
1495
+ wrapMaxBtn.addEventListener("click", async () => {
1496
+ const mode =
1497
+ document.querySelector('input[name="wrap-mode"]:checked')?.value ||
1498
+ "wrap";
1499
+
1500
+ wrapMaxBtn.disabled = true;
1501
+ wrapMaxBtn.innerHTML = '<span class="btn-spinner"></span>';
1502
+
1503
+ try {
1504
+ const resp = await authFetch(
1505
+ `/api/swap/wrap/balance?account=master&chain=${state.activeChain}`,
1506
+ );
1507
+ if (resp.ok) {
1508
+ const data = await resp.json();
1509
+ const maxAmount = mode === "wrap" ? data.native : data.wxdai;
1510
+ if (wrapAmountInput && maxAmount > 0) {
1511
+ wrapAmountInput.value = maxAmount.toFixed(2);
1512
+ }
1513
+ }
1514
+ } catch (err) {
1515
+ console.error("Error getting max amount:", err);
1516
+ } finally {
1517
+ wrapMaxBtn.disabled = false;
1518
+ wrapMaxBtn.innerHTML = "Max";
1519
+ }
1520
+ });
1521
+ }
1522
+
1523
+ // Form submission
1524
+ if (wrapForm) {
1525
+ wrapForm.addEventListener("submit", async (e) => {
1526
+ e.preventDefault();
1527
+ if (!wrapSubmitBtn) return;
1528
+
1529
+ const originalText = wrapSubmitBtn.textContent;
1530
+ wrapSubmitBtn.textContent = "Processing...";
1531
+ wrapSubmitBtn.disabled = true;
1532
+
1533
+ const mode =
1534
+ document.querySelector('input[name="wrap-mode"]:checked')?.value ||
1535
+ "wrap";
1536
+ const account = "master";
1537
+ const amount = parseFloat(wrapAmountInput.value);
1538
+
1539
+ if (!account || !amount || amount <= 0) {
1540
+ showToast("Please enter a valid amount", "error");
1541
+ wrapSubmitBtn.textContent = originalText;
1542
+ wrapSubmitBtn.disabled = false;
1543
+ return;
1544
+ }
1545
+
1546
+ const endpoint = mode === "wrap" ? "/api/swap/wrap" : "/api/swap/unwrap";
1547
+ const payload = {
1548
+ account: account,
1549
+ amount_eth: amount,
1550
+ chain: state.activeChain,
1551
+ };
1552
+
1553
+ try {
1554
+ const resp = await authFetch(endpoint, {
1555
+ method: "POST",
1556
+ headers: { "Content-Type": "application/json" },
1557
+ body: JSON.stringify(payload),
1558
+ });
1559
+ const result = await resp.json();
1560
+
1561
+ if (resp.ok) {
1562
+ const hashDisplay = result.hash
1563
+ ? `TX: ${result.hash.substring(0, 10)}...`
1564
+ : "";
1565
+ showToast(`${result.message} ${hashDisplay}`, "success", 5000);
1566
+ wrapAmountInput.value = "";
1567
+
1568
+ // Refresh balances safely (don't fail the whole operation if this fails)
1569
+ try {
1570
+ await Promise.all([
1571
+ loadWrapBalances(),
1572
+ loadMasterBalanceTable(true),
1573
+ ]);
1574
+ } catch (e) {
1575
+ console.error("Error refreshing balances after wrap/unwrap:", e);
1576
+ }
1577
+ } else {
1578
+ showToast(`Error: ${result.detail}`, "error");
1579
+ }
1580
+ } catch (err) {
1581
+ console.error("Wrap/Unwrap error:", err);
1582
+ showToast("Network error during wrap/unwrap", "error");
1583
+ } finally {
1584
+ wrapSubmitBtn.textContent = originalText;
1585
+ wrapSubmitBtn.disabled = false;
1586
+ }
1587
+ });
1588
+ }
1589
+
1590
+ // Update tab handler to include wrap form population
1591
+ const originalTabClickHandler = tabBtns.forEach.bind(tabBtns);
1592
+ tabBtns.forEach((btn) => {
1593
+ btn.addEventListener("click", () => {
1594
+ if (btn.dataset.tab === "cowswap") {
1595
+ populateWrapForm();
1596
+ }
1597
+ });
1598
+ });
1599
+
1600
+ // ===== Olas Services Functions =====
1601
+ const olasRefreshBtn = document.getElementById("refresh-olas-btn");
1602
+ if (olasRefreshBtn) {
1603
+ olasRefreshBtn.addEventListener("click", () => loadOlasServices(true));
1604
+ }
1605
+
1606
+ window.loadOlasServices = async (forceRefresh = false) => {
1607
+ if (!state.activeChain) return;
1608
+
1609
+ const container = document.getElementById("olas-services-container");
1610
+ if (!container) return;
1611
+
1612
+ // Render cached data immediately if available (even on forceRefresh to prevent flash)
1613
+ if (
1614
+ state.olasServicesCache[state.activeChain] &&
1615
+ state.olasServicesCache[state.activeChain].length > 0
1616
+ ) {
1617
+ renderOlasSummaryAndCards(
1618
+ container,
1619
+ state.olasServicesCache[state.activeChain],
1620
+ state.olasPriceCache,
1621
+ );
1622
+ } else if (!state.olasServicesCache[state.activeChain]) {
1623
+ // Only show loading spinner if no data is visible
1624
+ container.innerHTML = `<div class="empty-state glass"><span class="loading-spinner"></span> Loading services...</div>`;
1625
+ }
1626
+
1627
+ // If not force refresh and we have valid cache, we are done
1628
+ if (!forceRefresh && state.olasServicesCache[state.activeChain]) {
1629
+ return;
1630
+ }
1631
+
1632
+ try {
1633
+ // Step 1: Fetch basic data and OLAS price in parallel
1634
+ const [basicResp, priceResp] = await Promise.all([
1635
+ authFetch(`/api/olas/services/basic?chain=${state.activeChain}`),
1636
+ authFetch("/api/olas/price"),
1637
+ ]);
1638
+ const basicServices = await basicResp.json();
1639
+ const priceData = await priceResp.json();
1640
+
1641
+ // Cache price
1642
+ state.olasPriceCache = priceData.price_eur;
1643
+
1644
+ // Handle API error response - if not OK, basicServices is an error object, not an array
1645
+ if (!basicResp.ok) {
1646
+ throw new Error(basicServices.detail || "Failed to load services");
1647
+ }
1648
+
1649
+ if (!Array.isArray(basicServices) || basicServices.length === 0) {
1650
+ container.innerHTML = `<div class="empty-state glass"><p>No Olas services found for ${state.activeChain}.</p></div>`;
1651
+ return;
1652
+ }
1653
+
1654
+ // Render cards immediately with spinners for dynamic fields
1655
+ renderOlasSummaryAndCards(
1656
+ container,
1657
+ basicServices,
1658
+ state.olasPriceCache,
1659
+ true,
1660
+ );
1661
+
1662
+ // Step 2: Fetch full details per service in parallel
1663
+ const detailPromises = basicServices.map(async (service) => {
1664
+ try {
1665
+ const detailResp = await authFetch(
1666
+ `/api/olas/services/${service.key}/details`,
1667
+ );
1668
+ if (detailResp.ok) {
1669
+ const details = await detailResp.json();
1670
+ // Merge details into service
1671
+ return {
1672
+ ...service,
1673
+ state: details.state || service.state,
1674
+ accounts: details.accounts,
1675
+ staking: details.staking,
1676
+ };
1677
+ }
1678
+ } catch (e) {
1679
+ console.error(`Failed to load details for ${service.key}:`, e);
1680
+ }
1681
+ return service;
1682
+ });
1683
+
1684
+ const fullServices = await Promise.all(detailPromises);
1685
+
1686
+ // Cache the full results
1687
+ state.olasServicesCache[state.activeChain] = fullServices;
1688
+
1689
+ // Re-render with full data
1690
+ renderOlasSummaryAndCards(
1691
+ container,
1692
+ fullServices,
1693
+ state.olasPriceCache,
1694
+ false,
1695
+ );
1696
+
1697
+ // Trigger countdown update
1698
+ updateUnstakeCountdowns();
1699
+ } catch (err) {
1700
+ console.error("Error loading Olas services:", err);
1701
+ // If we have cached data, show it with a warning toast instead of breaking UI
1702
+ if (
1703
+ state.olasServicesCache[state.activeChain] &&
1704
+ state.olasServicesCache[state.activeChain].length > 0
1705
+ ) {
1706
+ showToast(`Failed to refresh services: ${err.message}`, "error");
1707
+ return;
1708
+ }
1709
+ container.innerHTML = `<div class="empty-state glass text-error"><p>Error loading services: ${escapeHtml(err.message)}</p></div>`;
1710
+ }
1711
+ };
1712
+
1713
+ // Refresh a single service card without affecting others
1714
+ window.refreshSingleService = async (serviceKey) => {
1715
+ const cardElement = document.querySelector(
1716
+ `.service-card[data-service-key="${serviceKey}"]`,
1717
+ );
1718
+ if (!cardElement) return;
1719
+
1720
+ // Find service in cache
1721
+ const cachedServices = state.olasServicesCache[state.activeChain] || [];
1722
+ const serviceIndex = cachedServices.findIndex((s) => s.key === serviceKey);
1723
+ if (serviceIndex === -1) {
1724
+ // Service not in cache, reload all
1725
+ loadOlasServices(true);
1726
+ return;
1727
+ }
1728
+
1729
+ // Render card with loading state (spinners)
1730
+ const serviceData = cachedServices[serviceIndex];
1731
+ cardElement.outerHTML = renderOlasServiceCard(serviceData, true);
1732
+
1733
+ // Also update summary to show loading
1734
+ renderOlasSummary(cachedServices, state.olasPriceCache, true);
1735
+
1736
+ try {
1737
+ const detailResp = await authFetch(
1738
+ `/api/olas/services/${serviceKey}/details`,
1739
+ );
1740
+ if (detailResp.ok) {
1741
+ const details = await detailResp.json();
1742
+ // Merge details into cached service
1743
+ const updatedService = {
1744
+ ...serviceData,
1745
+ state: details.state,
1746
+ accounts: details.accounts,
1747
+ staking: details.staking,
1748
+ agent_bond: details.agent_bond,
1749
+ };
1750
+ cachedServices[serviceIndex] = updatedService;
1751
+ state.olasServicesCache[state.activeChain] = cachedServices;
1752
+
1753
+ // Re-render card with actual data
1754
+ const newCardElement = document.querySelector(
1755
+ `.service-card[data-service-key="${serviceKey}"]`,
1756
+ );
1757
+ if (newCardElement) {
1758
+ newCardElement.outerHTML = renderOlasServiceCard(
1759
+ updatedService,
1760
+ false,
1761
+ );
1762
+ }
1763
+
1764
+ // Update summary totals
1765
+ renderOlasSummary(cachedServices, state.olasPriceCache, false);
1766
+ } else {
1767
+ showToast(`Failed to refresh service: ${serviceKey}`, "error");
1768
+ // Re-render with old data
1769
+ const newCardElement = document.querySelector(
1770
+ `.service-card[data-service-key="${serviceKey}"]`,
1771
+ );
1772
+ if (newCardElement) {
1773
+ newCardElement.outerHTML = renderOlasServiceCard(serviceData, false);
1774
+ }
1775
+ renderOlasSummary(cachedServices, state.olasPriceCache, false);
1776
+ }
1777
+ } catch (err) {
1778
+ console.error(`Error refreshing service ${serviceKey}:`, err);
1779
+ showToast(`Error refreshing service: ${err.message}`, "error");
1780
+ // Re-render with old data
1781
+ const newCardElement = document.querySelector(
1782
+ `.service-card[data-service-key="${serviceKey}"]`,
1783
+ );
1784
+ if (newCardElement) {
1785
+ newCardElement.outerHTML = renderOlasServiceCard(serviceData, false);
1786
+ }
1787
+ renderOlasSummary(cachedServices, state.olasPriceCache, false);
1788
+ }
1789
+ };
1790
+
1791
+ // Add a newly created service card without reloading all services
1792
+ window.addNewServiceCard = async (serviceId, chain, serviceName) => {
1793
+ const container = document.getElementById("olas-services-container");
1794
+ if (!container) {
1795
+ console.error("Container not found, falling back to full reload");
1796
+ loadOlasServices(true);
1797
+ return;
1798
+ }
1799
+
1800
+ // Create a basic service object for the new card with loading state
1801
+ const serviceKey = `${chain}:${serviceId}`;
1802
+ const newService = {
1803
+ key: serviceKey,
1804
+ service_id: serviceId,
1805
+ chain: chain,
1806
+ name: serviceName || `Service #${serviceId}`,
1807
+ accounts: {},
1808
+ staking: {},
1809
+ };
1810
+
1811
+ // Add to cache
1812
+ if (!state.olasServicesCache[chain]) {
1813
+ state.olasServicesCache[chain] = [];
1814
+ }
1815
+ state.olasServicesCache[chain].push(newService);
1816
+
1817
+ // Clear empty state message if present
1818
+ const emptyState = container.querySelector(".empty-state");
1819
+ if (emptyState) {
1820
+ emptyState.remove();
1821
+ }
1822
+
1823
+ // Append new card with loading state
1824
+ container.insertAdjacentHTML(
1825
+ "beforeend",
1826
+ renderOlasServiceCard(newService, true),
1827
+ );
1828
+
1829
+ // Update summary
1830
+ renderOlasSummary(
1831
+ state.olasServicesCache[chain],
1832
+ state.olasPriceCache,
1833
+ true,
1834
+ );
1835
+
1836
+ // Fetch details for the new service
1837
+ try {
1838
+ const detailResp = await authFetch(
1839
+ `/api/olas/services/${serviceKey}/details`,
1840
+ );
1841
+ if (detailResp.ok) {
1842
+ const details = await detailResp.json();
1843
+ // Update cache with full data
1844
+ const serviceIndex = state.olasServicesCache[chain].findIndex(
1845
+ (s) => s.key === serviceKey,
1846
+ );
1847
+ if (serviceIndex !== -1) {
1848
+ const updatedService = {
1849
+ ...newService,
1850
+ state: details.state,
1851
+ accounts: details.accounts,
1852
+ staking: details.staking,
1853
+ };
1854
+ state.olasServicesCache[chain][serviceIndex] = updatedService;
1855
+
1856
+ // Re-render the card with actual data
1857
+ const cardElement = document.querySelector(
1858
+ `.service-card[data-service-key="${serviceKey}"]`,
1859
+ );
1860
+ if (cardElement) {
1861
+ cardElement.outerHTML = renderOlasServiceCard(
1862
+ updatedService,
1863
+ false,
1864
+ );
1865
+ }
1866
+
1867
+ // Update summary
1868
+ renderOlasSummary(
1869
+ state.olasServicesCache[chain],
1870
+ state.olasPriceCache,
1871
+ false,
1872
+ );
1873
+ }
1874
+ }
1875
+ } catch (err) {
1876
+ console.error(`Error loading new service details: ${err}`);
1877
+ }
1878
+ };
1879
+
1880
+ function renderOlasSummaryAndCards(
1881
+ container,
1882
+ services,
1883
+ olasPrice,
1884
+ isLoading = false,
1885
+ ) {
1886
+ // Render summary
1887
+ renderOlasSummary(services, olasPrice, isLoading);
1888
+
1889
+ // Render cards in services container
1890
+ container.innerHTML = services
1891
+ .map((service) => renderOlasServiceCard(service, isLoading))
1892
+ .join("");
1893
+ }
1894
+
1895
+ function renderOlasSummary(services, olasPrice, isLoading = false) {
1896
+ // Calculate summary
1897
+ const serviceCount = services.length;
1898
+ let totalRewards = 0;
1899
+ services.forEach((s) => {
1900
+ if (s.staking && s.staking.accrued_reward_olas) {
1901
+ totalRewards += parseFloat(s.staking.accrued_reward_olas) || 0;
1902
+ }
1903
+ });
1904
+
1905
+ const priceDisplay = olasPrice
1906
+ ? `€${olasPrice.toFixed(2)}`
1907
+ : '<span class="cell-spinner"></span>';
1908
+ const rewardsDisplay = isLoading
1909
+ ? '<span class="cell-spinner"></span>'
1910
+ : totalRewards.toFixed(2);
1911
+ const valueEur =
1912
+ olasPrice && !isLoading ? (totalRewards * olasPrice).toFixed(2) : null;
1913
+ const valueDisplay = valueEur
1914
+ ? `€${valueEur}`
1915
+ : isLoading
1916
+ ? '<span class="cell-spinner"></span>'
1917
+ : "-";
1918
+
1919
+ // Render summary in separate container
1920
+ const summaryContainer = document.getElementById("olas-summary-container");
1921
+ if (summaryContainer) {
1922
+ summaryContainer.innerHTML = `
1923
+ <div class="olas-summary-header">
1924
+ <div class="olas-summary-grid">
1925
+ <div class="olas-summary-item">
1926
+ <div class="olas-summary-item-label">Services</div>
1927
+ <div class="olas-summary-item-value accent">${serviceCount}</div>
1928
+ </div>
1929
+ <div class="olas-summary-item">
1930
+ <div class="olas-summary-item-label">Rewards</div>
1931
+ <div class="olas-summary-item-value success">${rewardsDisplay} OLAS</div>
1932
+ </div>
1933
+ <div class="olas-summary-item">
1934
+ <div class="olas-summary-item-label">OLAS Price</div>
1935
+ <div class="olas-summary-item-value">${priceDisplay}</div>
1936
+ </div>
1937
+ <div class="olas-summary-item">
1938
+ <div class="olas-summary-item-label">Rewards Value</div>
1939
+ <div class="olas-summary-item-value accent">${valueDisplay}</div>
1940
+ </div>
1941
+ </div>
1942
+ </div>
1943
+ `;
1944
+ }
1945
+ }
1946
+
1947
+ function renderOlasServiceCard(service, isLoading = false) {
1948
+ const staking = service.staking || {};
1949
+ const isStaked = staking.is_staked || false;
1950
+
1951
+ // Format epoch countdown
1952
+ let epochCountdown = "";
1953
+ if (
1954
+ staking.remaining_epoch_seconds !== undefined &&
1955
+ staking.remaining_epoch_seconds !== null
1956
+ ) {
1957
+ const diff = Math.floor(staking.remaining_epoch_seconds);
1958
+ if (diff <= 0) {
1959
+ epochCountdown =
1960
+ '<span class="countdown text-error">Checkpoint pending</span>';
1961
+ } else {
1962
+ const h = Math.floor(diff / 3600);
1963
+ const m = Math.floor((diff % 3600) / 60);
1964
+ epochCountdown = `<span class="countdown" data-end="${staking.epoch_end_utc}">${h}h ${m}m</span>`;
1965
+ }
1966
+ }
1967
+
1968
+ // Build accounts table
1969
+ const roles = ["agent", "safe", "owner"];
1970
+ const accountsHtml = roles
1971
+ .map((role) => {
1972
+ const acc = service.accounts[role];
1973
+ if (!acc || !acc.address) {
1974
+ if (role === "owner") return "";
1975
+ return `
1976
+ <tr>
1977
+ <td>${escapeHtml(role.charAt(0).toUpperCase() + role.slice(1))}</td>
1978
+ <td class="address-cell text-muted">Not deployed</td>
1979
+ <td class="val">-</td>
1980
+ <td class="val">-</td>
1981
+ </tr>
1982
+ `;
1983
+ }
1984
+
1985
+ // Requirement: addresses for 'agent' and 'safe', but only 'tag' for 'owner'
1986
+ const displayText =
1987
+ role === "owner" && acc.tag ? acc.tag : shortenAddr(acc.address);
1988
+ const explorerUrl = getExplorerUrl(acc.address, service.chain);
1989
+
1990
+ // Show spinner if loading, otherwise show balance
1991
+ const nativeDisplay =
1992
+ isLoading || acc.native === null
1993
+ ? '<span class="cell-spinner"></span>'
1994
+ : escapeHtml(formatBalance(acc.native));
1995
+ const olasDisplay =
1996
+ isLoading || acc.olas === null
1997
+ ? '<span class="cell-spinner"></span>'
1998
+ : escapeHtml(formatBalance(acc.olas));
1999
+
2000
+ return `
2001
+ <tr>
2002
+ <td>${escapeHtml(role.charAt(0).toUpperCase() + role.slice(1))}</td>
2003
+ <td class="address-cell">
2004
+ <a href="${explorerUrl}" target="_blank" class="explorer-link" title="${escapeHtml(acc.address)}">
2005
+ ${escapeHtml(displayText)}
2006
+ </a>
2007
+ </td>
2008
+ <td class="val">${nativeDisplay}</td>
2009
+ <td class="val">${olasDisplay}</td>
2010
+ </tr>
2011
+ `;
2012
+ })
2013
+ .join("");
2014
+
2015
+ // Build liveness progress bar
2016
+ let livenessProgressHtml = "";
2017
+ if (isStaked) {
2018
+ if (isLoading) {
2019
+ livenessProgressHtml = `
2020
+ <div class="staking-row">
2021
+ <span class="label">Liveness:</span>
2022
+ <div class="liveness-progress">
2023
+ <div class="progress-bar"></div>
2024
+ <span class="progress-text"><span class="cell-spinner"></span></span>
2025
+ </div>
2026
+ </div>
2027
+ `;
2028
+ } else {
2029
+ const current = staking.mech_requests_this_epoch || 0;
2030
+ const required = staking.required_mech_requests || 1;
2031
+ const percentage = Math.min(
2032
+ 100,
2033
+ Math.round((current / required) * 100),
2034
+ );
2035
+ const progressClass = staking.liveness_ratio_passed
2036
+ ? "progress-success"
2037
+ : "progress-warning";
2038
+ livenessProgressHtml = `
2039
+ <div class="staking-row">
2040
+ <span class="label">Liveness:</span>
2041
+ <div class="liveness-progress">
2042
+ <div class="progress-bar ${progressClass}" style="--width: ${percentage}%"></div>
2043
+ <span class="progress-text">${current}/${required} ${staking.liveness_ratio_passed ? "✓" : ""}</span>
2044
+ </div>
2045
+ </div>
2046
+ `;
2047
+ }
2048
+ }
2049
+
2050
+ // Disable all buttons while loading
2051
+ const loadingDisabled = isLoading ? "disabled" : "";
2052
+ const loadingClass = isLoading ? "opacity-60 not-allowed grayscale" : "";
2053
+
2054
+ return `
2055
+ <div class="service-card glass" data-service-key="${escapeHtml(service.key)}">
2056
+ <div class="service-header">
2057
+ <h3>${escapeHtml(service.name || "Service")} <span class="service-id">#${service.service_id}</span></h3>
2058
+ <div class="flex-center-gap">
2059
+ <span class="chain-badge">${escapeHtml(service.chain)}</span>
2060
+ <button class="btn-icon btn-icon-sm ${loadingClass}" data-action="refresh-service" data-key="${escapeHtml(service.key)}" title="Refresh this service" ${loadingDisabled}>
2061
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2062
+ <polyline points="23 4 23 10 17 10"></polyline>
2063
+ <polyline points="1 20 1 14 7 14"></polyline>
2064
+ <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
2065
+ </svg>
2066
+ </button>
2067
+ </div>
2068
+ </div>
2069
+
2070
+ <table class="service-accounts-table">
2071
+ <thead>
2072
+ <tr>
2073
+ <th>Role</th>
2074
+ <th>Account</th>
2075
+ <th class="val">${escapeHtml(state.nativeCurrencies[service.chain] || "Native")}</th>
2076
+ <th class="val">OLAS</th>
2077
+ </tr>
2078
+ </thead>
2079
+ <tbody>
2080
+ ${accountsHtml}
2081
+ </tbody>
2082
+ </table>
2083
+
2084
+ <div class="staking-info">
2085
+ <div class="staking-row">
2086
+ <span class="label">Status:</span>
2087
+ <span class="value ${
2088
+ isLoading
2089
+ ? ""
2090
+ : isStaked
2091
+ ? "staked"
2092
+ : service.state === "DEPLOYED"
2093
+ ? "deployed"
2094
+ : "not-staked"
2095
+ }">
2096
+ ${isLoading ? '<span class="cell-spinner"></span>' : service.state ? service.state : isStaked ? "✓ STAKED" : "○ NOT STAKED"}
2097
+ </span>
2098
+ </div>
2099
+ <div class="staking-row">
2100
+ <span class="label">Staking contract:</span>
2101
+ <span class="value address-cell">
2102
+ ${
2103
+ isLoading
2104
+ ? '<span class="cell-spinner"></span>'
2105
+ : isStaked && staking.staking_contract_address
2106
+ ? `
2107
+ <a href="${getExplorerUrl(staking.staking_contract_address, service.chain)}" target="_blank" class="explorer-link" title="${escapeHtml(staking.staking_contract_address)}">
2108
+ ${escapeHtml(staking.staking_contract_name || shortenAddr(staking.staking_contract_address))}
2109
+ </a>
2110
+ `
2111
+ : "-"
2112
+ }
2113
+ </span>
2114
+ </div>
2115
+ <div class="staking-row">
2116
+ <span class="label">Rewards:</span>
2117
+ <span class="value rewards">${isLoading ? '<span class="cell-spinner"></span>' : isStaked ? escapeHtml(formatBalance(staking.accrued_reward_olas) || "0") + " OLAS" : "-"}</span>
2118
+ </div>
2119
+ ${
2120
+ isLoading
2121
+ ? `
2122
+ <div class="staking-row">
2123
+ <span class="label">Liveness:</span>
2124
+ <span class="value"><span class="cell-spinner"></span></span>
2125
+ </div>
2126
+ `
2127
+ : isStaked && livenessProgressHtml
2128
+ ? livenessProgressHtml
2129
+ : `
2130
+ <div class="staking-row">
2131
+ <span class="label">Liveness:</span>
2132
+ <span class="value">-</span>
2133
+ </div>
2134
+ `
2135
+ }
2136
+ <div class="staking-row">
2137
+ <span class="label">${isStaked && staking.epoch_number !== undefined ? `Epoch #${staking.epoch_number} ends in:` : "Epoch:"}</span>
2138
+ <span class="value">${isLoading ? '<span class="cell-spinner"></span>' : isStaked ? epochCountdown || "-" : "-"}</span>
2139
+ </div>
2140
+ <div class="staking-row">
2141
+ <span class="label">Unstake available:</span>
2142
+ <span class="value" ${staking.unstake_available_at ? `data-unstake-at="${staking.unstake_available_at}"` : ""}>${
2143
+ isLoading
2144
+ ? '<span class="cell-spinner"></span>'
2145
+ : (() => {
2146
+ if (!isStaked) return "-";
2147
+ if (!staking.unstake_available_at) return "-";
2148
+ const diffMs =
2149
+ new Date(staking.unstake_available_at) -
2150
+ new Date();
2151
+ if (diffMs <= 0)
2152
+ return '<span class="text-success font-bold">AVAILABLE</span>';
2153
+ const diffMins = Math.ceil(diffMs / 60000);
2154
+ const hours = Math.floor(diffMins / 60);
2155
+ const mins = diffMins % 60;
2156
+ return hours > 0
2157
+ ? `${hours}h ${mins}m`
2158
+ : `${mins}m`;
2159
+ })()
2160
+ }</span>
2161
+ </div>
2162
+ </div>
2163
+
2164
+ <div class="service-actions">
2165
+ <button class="btn-primary btn-sm" data-action="fund-service" data-key="${escapeHtml(service.key)}" data-chain="${escapeHtml(service.chain)}" ${loadingDisabled}>
2166
+ Fund
2167
+ </button>
2168
+ ${
2169
+ isStaked
2170
+ ? `
2171
+ ${(() => {
2172
+ const checkpointDisabled =
2173
+ isLoading || staking.remaining_epoch_seconds > 0;
2174
+ let checkpointTitle =
2175
+ "Call checkpoint to close the epoch";
2176
+ if (isLoading) {
2177
+ checkpointTitle = "Loading...";
2178
+ } else if (staking.remaining_epoch_seconds > 0) {
2179
+ const h = Math.floor(
2180
+ staking.remaining_epoch_seconds / 3600,
2181
+ );
2182
+ const m = Math.floor(
2183
+ (staking.remaining_epoch_seconds % 3600) / 60,
2184
+ );
2185
+ checkpointTitle = `Checkpoint not needed yet. Epoch ends in ${h}h ${m}m.`;
2186
+ }
2187
+ return `
2188
+ <button class="btn-primary btn-sm btn-checkpoint ${loadingClass}" data-action="checkpoint" data-key="${escapeHtml(service.key)}" ${checkpointDisabled ? "disabled" : ""} title="${escapeHtml(checkpointTitle)}">
2189
+ Checkpoint
2190
+ </button>
2191
+ `;
2192
+ })()}
2193
+ ${(() => {
2194
+ const canUnstake =
2195
+ !staking.unstake_available_at ||
2196
+ new Date() >=
2197
+ new Date(staking.unstake_available_at);
2198
+ const unstakeLabel = "Unstake";
2199
+ let unstakeDisabled = isLoading ? "disabled" : "";
2200
+ const disabledStyle =
2201
+ "opacity: 0.6; cursor: not-allowed; filter: grayscale(100%);";
2202
+ let timeText = "";
2203
+
2204
+ if (!canUnstake) {
2205
+ unstakeDisabled = "disabled";
2206
+ const diffMs =
2207
+ new Date(staking.unstake_available_at) -
2208
+ new Date();
2209
+ const diffMins = Math.ceil(diffMs / 60000);
2210
+ timeText =
2211
+ diffMins > 60
2212
+ ? `~${Math.ceil(diffMins / 60)}h`
2213
+ : `${diffMins}m`;
2214
+ }
2215
+
2216
+ return `
2217
+ <button class="btn-danger btn-sm" data-action="unstake" data-key="${escapeHtml(service.key)}" ${unstakeDisabled}
2218
+ title="${isLoading ? "Loading..." : !canUnstake ? `Cannot unstake yet. Minimum staking duration (72h) ends in ${timeText}` : "Unstake service"}">
2219
+ ${escapeHtml(unstakeLabel)}
2220
+ </button>
2221
+ `;
2222
+ })()}
2223
+ `
2224
+ : service.state === "DEPLOYED"
2225
+ ? `
2226
+ <button class="btn-danger btn-sm" disabled
2227
+ title="Cannot stake a deployed service. Terminate first to change staking configuration.">
2228
+ Stake
2229
+ </button>
2230
+ `
2231
+ : service.state === "PRE_REGISTRATION"
2232
+ ? `
2233
+ <button class="btn-primary btn-sm ${loadingClass}" data-action="deploy" data-key="${escapeHtml(service.key)}" data-chain="${escapeHtml(service.chain)}" data-name="${escapeHtml(service.name || "")}" data-id="${escapeHtml(service.service_id)}" ${loadingDisabled}>
2234
+ Deploy
2235
+ </button>
2236
+ `
2237
+ : ""
2238
+ }
2239
+ ${
2240
+ service.state !== "PRE_REGISTRATION"
2241
+ ? (() => {
2242
+ // Terminate button - now uses wind_down which handles unstake automatically
2243
+ // Only show if service is not in PRE_REGISTRATION (nothing to wind down)
2244
+ const terminateLabel = "Terminate";
2245
+ let terminateDisabled = isLoading ? "disabled" : "";
2246
+ let terminateStyle = isLoading
2247
+ ? "opacity: 0.6; cursor: not-allowed; filter: grayscale(100%);"
2248
+ : "";
2249
+ let terminateTitle =
2250
+ "Wind down service: unstake (if staked) → terminate → unbond";
2251
+
2252
+ // If staked, check if we can unstake
2253
+ if (isStaked) {
2254
+ const canUnstake =
2255
+ !staking.unstake_available_at ||
2256
+ new Date() >=
2257
+ new Date(staking.unstake_available_at);
2258
+
2259
+ if (!canUnstake) {
2260
+ terminateDisabled = "disabled";
2261
+ terminateStyle =
2262
+ "opacity: 0.6; cursor: not-allowed; filter: grayscale(100%);";
2263
+
2264
+ const diffMs =
2265
+ new Date(staking.unstake_available_at) -
2266
+ new Date();
2267
+ const diffMins = Math.ceil(diffMs / 60000);
2268
+ const timeText =
2269
+ diffMins > 60
2270
+ ? `~${Math.ceil(diffMins / 60)}h`
2271
+ : `${diffMins}m`;
2272
+
2273
+ terminateTitle = `Cannot terminate yet (must unstake first). Minimum staking duration ends in ${timeText}`;
2274
+ }
2275
+ }
2276
+
2277
+ if (isLoading) {
2278
+ terminateTitle = "Loading...";
2279
+ }
2280
+
2281
+ const isDisabled = terminateDisabled === "disabled";
2282
+ return `
2283
+ <button class="btn-danger btn-sm" data-action="terminate" data-key="${escapeHtml(service.key)}" ${terminateDisabled}
2284
+ title="${escapeHtml(terminateTitle)}">
2285
+ ${escapeHtml(terminateLabel)}
2286
+ </button>
2287
+ `;
2288
+ })()
2289
+ : ""
2290
+ }
2291
+ ${(() => {
2292
+ const drainLabel = "Drain";
2293
+ let drainDisabled = isLoading ? "disabled" : "";
2294
+ let drainStyle = isLoading
2295
+ ? "opacity: 0.6; cursor: not-allowed; filter: grayscale(100%);"
2296
+ : "";
2297
+ let drainTitle = isLoading
2298
+ ? "Loading..."
2299
+ : "Drain all service funds to master account";
2300
+
2301
+ // Check if there's anything to drain (Agent or Safe have non-zero balance)
2302
+ const agentBalance = service.accounts?.agent
2303
+ ? (parseFloat(service.accounts.agent.native) || 0) +
2304
+ (parseFloat(service.accounts.agent.olas) || 0)
2305
+ : 0;
2306
+ const safeBalance = service.accounts?.safe
2307
+ ? (parseFloat(service.accounts.safe.native) || 0) +
2308
+ (parseFloat(service.accounts.safe.olas) || 0)
2309
+ : 0;
2310
+ const hasBalanceToDrain = agentBalance > 0 || safeBalance > 0;
2311
+
2312
+ if (!hasBalanceToDrain && !isLoading) {
2313
+ drainDisabled = "disabled";
2314
+ drainStyle =
2315
+ "opacity: 0.6; cursor: not-allowed; filter: grayscale(100%);";
2316
+ drainTitle =
2317
+ "Nothing to drain (Agent and Safe have zero balance)";
2318
+ } else if (isStaked && !isLoading) {
2319
+ // Check if we can unstake yet
2320
+ const canUnstake =
2321
+ !staking.unstake_available_at ||
2322
+ new Date() >= new Date(staking.unstake_available_at);
2323
+ if (!canUnstake) {
2324
+ drainDisabled = "disabled";
2325
+ drainStyle =
2326
+ "opacity: 0.6; cursor: not-allowed; filter: grayscale(100%);";
2327
+ const diffMs =
2328
+ new Date(staking.unstake_available_at) - new Date();
2329
+ const diffMins = Math.ceil(diffMs / 60000);
2330
+ const timeText =
2331
+ diffMins > 60
2332
+ ? `~${Math.ceil(diffMins / 60)}h`
2333
+ : `${diffMins}m`;
2334
+ drainTitle = `Cannot drain while staked. Unstake available in ${timeText}.`;
2335
+ } else {
2336
+ drainTitle =
2337
+ "Service is staked. Will unstake and claim rewards first.";
2338
+ }
2339
+ }
2340
+
2341
+ const isDisabled = drainDisabled === "disabled";
2342
+ return `
2343
+ <button class="btn-danger btn-sm" data-action="drain" data-key="${escapeHtml(service.key)}" ${drainDisabled}
2344
+ title="${escapeHtml(drainTitle)}">
2345
+ ${escapeHtml(drainLabel)}
2346
+ </button>
2347
+ `;
2348
+ })()}
2349
+ ${
2350
+ isStaked
2351
+ ? (() => {
2352
+ const hasRewards =
2353
+ parseFloat(staking.accrued_reward_olas) > 0;
2354
+ const claimDisabled = isLoading || !hasRewards;
2355
+ const claimTitle = isLoading
2356
+ ? "Loading..."
2357
+ : hasRewards
2358
+ ? "Claim staking rewards"
2359
+ : "No rewards available to claim";
2360
+ const claimLabel = hasRewards
2361
+ ? `Claim ${escapeHtml(staking.accrued_reward_olas)} OLAS`
2362
+ : "Claim";
2363
+ return `
2364
+ <button class="btn-primary btn-sm"
2365
+ data-action="claim-rewards"
2366
+ data-key="${escapeHtml(service.key)}"
2367
+ ${claimDisabled ? "disabled" : ""}
2368
+ title="${escapeHtml(claimTitle)}">
2369
+ ${claimLabel}
2370
+ </button>
2371
+ `;
2372
+ })()
2373
+ : ""
2374
+ }
2375
+ </div>
2376
+ </div>
2377
+ `;
2378
+ }
2379
+
2380
+ window.claimOlasRewards = async (serviceKey) => {
2381
+ const confirmed = await showConfirm(
2382
+ "Claim Rewards",
2383
+ "Claim staking rewards for this service?",
2384
+ );
2385
+ if (!confirmed) return;
2386
+
2387
+ showToast("Claiming rewards...", "info");
2388
+ try {
2389
+ const resp = await authFetch(`/api/olas/claim/${serviceKey}`, {
2390
+ method: "POST",
2391
+ });
2392
+ const result = await resp.json();
2393
+ if (resp.ok) {
2394
+ showToast(`Claimed ${result.claimed_olas.toFixed(2)} OLAS!`, "success");
2395
+ refreshSingleService(serviceKey);
2396
+ } else {
2397
+ showToast(`Error: ${result.detail} `, "error");
2398
+ }
2399
+ } catch (err) {
2400
+ showToast("Error claiming rewards", "error");
2401
+ }
2402
+ };
2403
+
2404
+ window.unstakeOlasService = async (serviceKey) => {
2405
+ const confirmed = await showConfirm(
2406
+ "Unstake Service",
2407
+ "Unstake this service? This will withdraw from the staking contract.",
2408
+ );
2409
+ if (!confirmed) return;
2410
+
2411
+ showToast("Unstaking service...", "info");
2412
+ try {
2413
+ const resp = await authFetch(`/api/olas/unstake/${serviceKey}`, {
2414
+ method: "POST",
2415
+ });
2416
+ const result = await resp.json();
2417
+ if (resp.ok) {
2418
+ showToast("Service unstaked successfully!", "success");
2419
+ refreshSingleService(serviceKey);
2420
+ } else {
2421
+ showToast(`Error: ${result.detail} `, "error");
2422
+ }
2423
+ } catch (err) {
2424
+ showToast("Error unstaking service", "error");
2425
+ }
2426
+ };
2427
+
2428
+ window.checkpointOlasService = async (serviceKey) => {
2429
+ showToast("Calling checkpoint...", "info");
2430
+ try {
2431
+ const resp = await authFetch(`/api/olas/checkpoint/${serviceKey}`, {
2432
+ method: "POST",
2433
+ });
2434
+ const result = await resp.json();
2435
+ if (resp.ok) {
2436
+ showToast("Checkpoint successful! Epoch closed.", "success");
2437
+ refreshSingleService(serviceKey);
2438
+ } else {
2439
+ showToast(`Error: ${result.detail} `, "error");
2440
+ }
2441
+ } catch (err) {
2442
+ showToast("Error calling checkpoint", "error");
2443
+ }
2444
+ };
2445
+
2446
+ // Global countdown timer for Olas services
2447
+ setInterval(() => {
2448
+ const countdowns = document.querySelectorAll(
2449
+ ".service-card .countdown[data-end]",
2450
+ );
2451
+ countdowns.forEach((el) => {
2452
+ const endTime = new Date(el.dataset.end).getTime();
2453
+ const now = new Date().getTime();
2454
+ const diff = Math.floor((endTime - now) / 1000);
2455
+
2456
+ const card = el.closest(".service-card");
2457
+ const btn = card.querySelector(".btn-checkpoint");
2458
+
2459
+ if (diff <= 0) {
2460
+ el.innerText = "Checkpoint pending";
2461
+ el.style.color = "#e74c3c";
2462
+ if (btn) btn.disabled = false;
2463
+ } else {
2464
+ const h = Math.floor(diff / 3600);
2465
+ const m = Math.floor((diff % 3600) / 60);
2466
+ el.innerText = `${h}h ${m} m`;
2467
+ el.style.color = "";
2468
+ // Add a small grace period (30s) to avoid race conditions with contract
2469
+ if (btn) btn.disabled = diff > 30;
2470
+ }
2471
+ });
2472
+ }, 1000);
2473
+
2474
+ window.drainOlasService = async (serviceKey) => {
2475
+ const confirmed = await showConfirm(
2476
+ "Drain Service",
2477
+ "This will drain ALL service accounts (Safe, Agent, and Owner) to your master account. If staked, the service will be unstaked and rewards claimed first.",
2478
+ );
2479
+ if (!confirmed) return;
2480
+
2481
+ const removeLoadingToast = showToast(
2482
+ "Draining service... This may take up to 30 seconds.",
2483
+ "info",
2484
+ 60000,
2485
+ );
2486
+
2487
+ try {
2488
+ const resp = await authFetch(`/api/olas/drain/${serviceKey}`, {
2489
+ method: "POST",
2490
+ });
2491
+
2492
+ removeLoadingToast();
2493
+
2494
+ if (resp.ok) {
2495
+ const result = await resp.json();
2496
+ showToast("Service drained successfully!", "success");
2497
+
2498
+ // SRE Fix: Wait for RPC to index new balances before refreshing
2499
+ console.log("Waiting for RPC indexer...");
2500
+ setTimeout(async () => {
2501
+ await refreshSingleService(serviceKey);
2502
+ loadAccounts(); // Refresh main accounts list too
2503
+ }, 5000);
2504
+ } else {
2505
+ const result = await resp.json();
2506
+ showToast(`Drain failed: ${result.detail}`, "error");
2507
+ }
2508
+ } catch (err) {
2509
+ removeLoadingToast();
2510
+ console.error(err);
2511
+ showToast(`Error draining service: ${err.message}`, "error");
2512
+ }
2513
+ };
2514
+
2515
+ // ===== Terminate Modal Functions =====
2516
+ const terminateModal = document.getElementById("terminate-modal");
2517
+ const terminateCancel = document.getElementById("terminate-cancel");
2518
+ const terminateConfirm = document.getElementById("terminate-confirm");
2519
+
2520
+ window.showTerminateModal = (serviceKey) => {
2521
+ document.getElementById("terminate-service-key").value = serviceKey;
2522
+ terminateModal.classList.add("active");
2523
+ };
2524
+
2525
+ if (terminateCancel) {
2526
+ terminateCancel.addEventListener("click", () => {
2527
+ terminateModal.classList.remove("active");
2528
+ });
2529
+ }
2530
+
2531
+ if (terminateConfirm) {
2532
+ terminateConfirm.addEventListener("click", async () => {
2533
+ const serviceKey = document.getElementById("terminate-service-key").value;
2534
+ terminateModal.classList.remove("active");
2535
+
2536
+ showToast("Terminating service...", "info");
2537
+ try {
2538
+ const resp = await authFetch(`/api/olas/terminate/${serviceKey}`, {
2539
+ method: "POST",
2540
+ });
2541
+ const result = await resp.json();
2542
+ if (resp.ok) {
2543
+ showToast("Service terminated successfully!", "success");
2544
+ refreshSingleService(serviceKey);
2545
+ } else {
2546
+ showToast(`Error: ${result.detail} `, "error");
2547
+ }
2548
+ } catch (err) {
2549
+ showToast("Error terminating service", "error");
2550
+ }
2551
+ });
2552
+ }
2553
+
2554
+ // ===== Stake Modal Functions =====
2555
+ window.showStakeModal = async (serviceKey, chain) => {
2556
+ const modal = document.getElementById("stake-modal");
2557
+ const select = document.getElementById("stake-contract-select");
2558
+ const spinnerDiv = document.getElementById("stake-contract-spinner");
2559
+ const keyInput = document.getElementById("stake-service-key");
2560
+ const confirmBtn = document.getElementById("stake-confirm");
2561
+
2562
+ keyInput.value = serviceKey;
2563
+
2564
+ // Show spinner, hide select, disable button
2565
+ select.style.display = "none";
2566
+ spinnerDiv.style.display = "block";
2567
+ confirmBtn.disabled = true;
2568
+ modal.classList.add("active");
2569
+
2570
+ try {
2571
+ const resp = await authFetch(
2572
+ `/api/olas/staking-contracts?chain=${chain}&service_key=${encodeURIComponent(serviceKey)}`,
2573
+ );
2574
+ const data = await resp.json();
2575
+ // Handle new response format with filter_info
2576
+ const contracts = data.contracts || data; // Fallback for backwards compat
2577
+ const filterInfo = data.filter_info;
2578
+
2579
+ // Remove any existing help text
2580
+ const existingHelp = select.parentNode.querySelector(".stake-help-text");
2581
+ if (existingHelp) existingHelp.remove();
2582
+
2583
+ // Create help text element
2584
+ const helpText = document.createElement("small");
2585
+ helpText.className = "stake-help-text";
2586
+ helpText.style.color = "#888";
2587
+ helpText.style.display = "block";
2588
+ helpText.style.marginTop = "8px";
2589
+ helpText.style.fontSize = "12px";
2590
+
2591
+ if (contracts.length === 0) {
2592
+ select.innerHTML = '<option value="">No compatible contracts</option>';
2593
+ if (filterInfo && filterInfo.service_bond_olas !== null) {
2594
+ helpText.innerHTML = `
2595
+ <strong>Why?</strong> Your service bond (<strong>${filterInfo.service_bond_olas.toFixed(0)} OLAS</strong>)
2596
+ is lower than what staking contracts require.<br>
2597
+ <em>Tip: Recreate the service with a higher bond (e.g., 5000 OLAS) to enable staking.</em>
2598
+ `;
2599
+ } else {
2600
+ helpText.textContent =
2601
+ "No staking contracts available for this chain.";
2602
+ }
2603
+ select.parentNode.appendChild(helpText);
2604
+ confirmBtn.disabled = true;
2605
+ } else {
2606
+ select.innerHTML = contracts
2607
+ .map(
2608
+ (c) =>
2609
+ `<option value="${escapeHtml(c.address)}">${escapeHtml(c.name)} (${c.usage?.available_slots ?? "?"} slots)</option>`,
2610
+ )
2611
+ .join("");
2612
+
2613
+ // Show filter info if some contracts were filtered
2614
+ if (
2615
+ filterInfo &&
2616
+ filterInfo.total_contracts > filterInfo.filtered_count
2617
+ ) {
2618
+ const hidden = filterInfo.total_contracts - filterInfo.filtered_count;
2619
+ helpText.innerHTML = `
2620
+ Showing <strong>${filterInfo.filtered_count}</strong> of ${filterInfo.total_contracts} contracts
2621
+ (${hidden} hidden - require higher bond than your <strong>${filterInfo.service_bond_olas?.toFixed(0) || "?"} OLAS</strong>).
2622
+ `;
2623
+ select.parentNode.appendChild(helpText);
2624
+ }
2625
+ confirmBtn.disabled = false;
2626
+ }
2627
+
2628
+ // Show select, hide spinner
2629
+ select.style.display = "";
2630
+ spinnerDiv.style.display = "none";
2631
+ } catch (err) {
2632
+ select.innerHTML = '<option value="">Error loading contracts</option>';
2633
+ select.style.display = "";
2634
+ spinnerDiv.style.display = "none";
2635
+ confirmBtn.disabled = false;
2636
+ }
2637
+ };
2638
+
2639
+ // ===== Deploy Modal Functions =====
2640
+ window.showDeployModal = async (
2641
+ serviceKey,
2642
+ chain,
2643
+ serviceName,
2644
+ serviceId,
2645
+ ) => {
2646
+ const modal = document.getElementById("create-service-modal");
2647
+ const form = document.getElementById("create-service-form");
2648
+ const nameInput = document.getElementById("new-service-name");
2649
+ const chainSelect = document.getElementById("new-service-chain");
2650
+ const agentTypeSelect = document.getElementById("new-service-agent-type");
2651
+ const contractSelect = document.getElementById(
2652
+ "new-service-staking-contract",
2653
+ );
2654
+ const spinnerDiv = document.getElementById("staking-contract-spinner");
2655
+ const submitBtn = form.querySelector('button[type="submit"]');
2656
+ const modalTitle =
2657
+ modal.querySelector(".modal-header h2") ||
2658
+ modal.querySelector("h2") ||
2659
+ modal.querySelector(".modal-header h3") ||
2660
+ modal.querySelector("h3");
2661
+
2662
+ // Store deploy mode info
2663
+ form.dataset.deployMode = "true";
2664
+ form.dataset.deployServiceKey = serviceKey;
2665
+
2666
+ // Show modal immediately
2667
+ modal.classList.add("active");
2668
+
2669
+ // Update modal title
2670
+ if (modalTitle) modalTitle.textContent = "Deploy Olas Service";
2671
+
2672
+ // Pre-fill and disable fields with better styling
2673
+ nameInput.value = serviceName || `Service #${serviceId}`;
2674
+ nameInput.disabled = true;
2675
+ nameInput.style.opacity = "1"; // Full opacity for readability
2676
+ nameInput.style.backgroundColor = "#e9ecef"; // Bootstrap readonly gray
2677
+ nameInput.style.color = "#495057";
2678
+ chainSelect.value = chain;
2679
+ chainSelect.disabled = true;
2680
+ chainSelect.style.opacity = "1";
2681
+ chainSelect.style.backgroundColor = "#e9ecef";
2682
+ chainSelect.style.color = "#495057";
2683
+ agentTypeSelect.disabled = true;
2684
+ agentTypeSelect.style.opacity = "1";
2685
+ agentTypeSelect.style.backgroundColor = "#e9ecef";
2686
+ agentTypeSelect.style.color = "#495057";
2687
+ submitBtn.innerHTML = "Deploy & Stake";
2688
+
2689
+ // Load staking contracts
2690
+ contractSelect.style.display = "none";
2691
+ spinnerDiv.style.display = "block";
2692
+ submitBtn.disabled = true;
2693
+
2694
+ try {
2695
+ const resp = await authFetch(
2696
+ `/api/olas/staking-contracts?chain=${chain}&service_key=${encodeURIComponent(serviceKey)}`,
2697
+ );
2698
+ const data = await resp.json();
2699
+ // Handle new response format with filter_info
2700
+ const contracts = Array.isArray(data) ? data : data.contracts || [];
2701
+ const filterInfo = data.filter_info;
2702
+
2703
+ // Remove any existing help text
2704
+ const existingHelp =
2705
+ contractSelect.parentNode.querySelector(".stake-help-text");
2706
+ if (existingHelp) existingHelp.remove();
2707
+
2708
+ // Create help text element for filter explanation
2709
+ const helpText = document.createElement("small");
2710
+ helpText.className = "stake-help-text";
2711
+ helpText.style.color = "#888";
2712
+ helpText.style.display = "block";
2713
+ helpText.style.marginTop = "8px";
2714
+ helpText.style.fontSize = "12px";
2715
+
2716
+ if (contracts.length === 0) {
2717
+ contractSelect.innerHTML =
2718
+ '<option value="">No compatible contracts</option>';
2719
+ if (filterInfo && filterInfo.service_bond_olas !== null) {
2720
+ helpText.innerHTML = `
2721
+ <strong>Why?</strong> Your service bond (<strong>${filterInfo.service_bond_olas.toFixed(0)} OLAS</strong>)
2722
+ is lower than what staking contracts require.<br>
2723
+ <em>You can still deploy without staking.</em>
2724
+ `;
2725
+ } else {
2726
+ helpText.textContent =
2727
+ "No staking contracts available for this chain.";
2728
+ }
2729
+ contractSelect.parentNode.appendChild(helpText);
2730
+ } else {
2731
+ contractSelect.innerHTML =
2732
+ '<option value="">None (don\'t stake)</option>' +
2733
+ contracts
2734
+ .map((c) => {
2735
+ const usage = c.usage;
2736
+ const slots = usage ? usage.available_slots : null;
2737
+ const isDisabled = slots !== null && slots <= 0;
2738
+ const disabledStr = isDisabled ? "disabled" : "";
2739
+ let slotText = "Status Unknown";
2740
+ if (slots !== null) {
2741
+ slotText = `${slots} slots`;
2742
+ }
2743
+ const text = `${escapeHtml(c.name)} (${slotText})`;
2744
+ const optionClass = isDisabled ? "text-muted" : "";
2745
+ return `<option value="${escapeHtml(c.address)}" ${disabledStr} class="${optionClass}">${text}</option>`;
2746
+ })
2747
+ .join("");
2748
+
2749
+ // Show filter info if some contracts were filtered
2750
+ if (
2751
+ filterInfo &&
2752
+ filterInfo.total_contracts > filterInfo.filtered_count
2753
+ ) {
2754
+ const hidden = filterInfo.total_contracts - filterInfo.filtered_count;
2755
+ helpText.innerHTML = `
2756
+ Showing <strong>${filterInfo.filtered_count}</strong> of ${filterInfo.total_contracts} contracts
2757
+ (${hidden} hidden - require higher bond than your <strong>${filterInfo.service_bond_olas?.toFixed(0) || "?"} OLAS</strong>).
2758
+ `;
2759
+ contractSelect.parentNode.appendChild(helpText);
2760
+ }
2761
+ }
2762
+
2763
+ contractSelect.style.display = "";
2764
+ spinnerDiv.style.display = "none";
2765
+ submitBtn.disabled = false;
2766
+ } catch (err) {
2767
+ contractSelect.innerHTML =
2768
+ '<option value="">None (don\'t stake)</option>';
2769
+ contractSelect.style.display = "";
2770
+ spinnerDiv.style.display = "none";
2771
+ submitBtn.disabled = false;
2772
+ }
2773
+ };
2774
+
2775
+ const stakeModal = document.getElementById("stake-modal");
2776
+ const stakeCancel = document.getElementById("stake-cancel");
2777
+ const stakeConfirm = document.getElementById("stake-confirm");
2778
+
2779
+ if (stakeCancel) {
2780
+ stakeCancel.addEventListener("click", () => {
2781
+ stakeModal.classList.remove("active");
2782
+ });
2783
+ }
2784
+
2785
+ if (stakeConfirm) {
2786
+ stakeConfirm.addEventListener("click", async () => {
2787
+ const serviceKey = document.getElementById("stake-service-key").value;
2788
+ const contractAddress = document.getElementById(
2789
+ "stake-contract-select",
2790
+ ).value;
2791
+
2792
+ if (!contractAddress) {
2793
+ showToast("Please select a staking contract", "error");
2794
+ return;
2795
+ }
2796
+
2797
+ stakeModal.classList.remove("active");
2798
+ showToast("Staking service...", "info");
2799
+
2800
+ try {
2801
+ const resp = await authFetch(
2802
+ `/api/olas/stake/${serviceKey}?staking_contract=${encodeURIComponent(contractAddress)}`,
2803
+ {
2804
+ method: "POST",
2805
+ },
2806
+ );
2807
+ const result = await resp.json();
2808
+
2809
+ if (resp.ok) {
2810
+ showToast("Service staked successfully!", "success");
2811
+ refreshSingleService(serviceKey);
2812
+ } else {
2813
+ showToast(`Error: ${result.detail} `, "error");
2814
+ }
2815
+ } catch (err) {
2816
+ showToast("Error staking service", "error");
2817
+ }
2818
+ });
2819
+ }
2820
+
2821
+ // ===== Create Service Modal Functions =====
2822
+ const createServiceBtn = document.getElementById("create-service-btn");
2823
+ const createServiceModal = document.getElementById("create-service-modal");
2824
+ const closeCreateServiceModal = document.getElementById(
2825
+ "close-create-service-modal",
2826
+ );
2827
+ const createServiceForm = document.getElementById("create-service-form");
2828
+
2829
+ // Helper to render contract options - handles both new {contracts, filter_info} and old array format
2830
+ function renderContractOptions(data) {
2831
+ // Extract contracts array from new format or use directly if array
2832
+ const contracts = Array.isArray(data) ? data : data.contracts || [];
2833
+
2834
+ if (!contracts.length)
2835
+ return '<option value="">No contracts available</option>';
2836
+
2837
+ return (
2838
+ '<option value="">None (don\'t stake)</option>' +
2839
+ contracts
2840
+ .map((c) => {
2841
+ const usage = c.usage;
2842
+ const slots = usage ? usage.available_slots : null;
2843
+
2844
+ const isDisabled = slots !== null && slots <= 0;
2845
+ const disabledStr = isDisabled ? "disabled" : "";
2846
+
2847
+ let slotText = "Status Unknown";
2848
+ if (slots !== null) {
2849
+ slotText = `${slots} slots`;
2850
+ }
2851
+
2852
+ const text = `${escapeHtml(c.name)} (${slotText})`;
2853
+ const optionClass = isDisabled ? "text-muted" : "";
2854
+ return `<option value="${escapeHtml(c.address)}" ${disabledStr} class="${optionClass}">${text}</option>`;
2855
+ })
2856
+ .join("")
2857
+ );
2858
+ }
2859
+
2860
+ if (createServiceBtn) {
2861
+ createServiceBtn.addEventListener("click", () => {
2862
+ createServiceModal.classList.add("active");
2863
+ // Use cached staking contracts for faster loading
2864
+ const contractSelect = document.getElementById(
2865
+ "new-service-staking-contract",
2866
+ );
2867
+ const spinnerDiv = document.getElementById("staking-contract-spinner");
2868
+ if (state.stakingContractsCache) {
2869
+ contractSelect.innerHTML = renderContractOptions(
2870
+ state.stakingContractsCache,
2871
+ );
2872
+ contractSelect.classList.remove("hidden");
2873
+ spinnerDiv.classList.add("hidden");
2874
+ } else {
2875
+ // If cache not ready, show spinner and hide select
2876
+ const submitBtn = createServiceForm.querySelector(
2877
+ 'button[type="submit"]',
2878
+ );
2879
+ contractSelect.style.display = "none";
2880
+ spinnerDiv.style.display = "block";
2881
+ submitBtn.disabled = true;
2882
+ authFetch("/api/olas/staking-contracts?chain=gnosis")
2883
+ .then((resp) => resp.json())
2884
+ .then((contracts) => {
2885
+ state.stakingContractsCache = contracts;
2886
+ contractSelect.innerHTML = renderContractOptions(contracts);
2887
+ contractSelect.style.display = "";
2888
+ spinnerDiv.style.display = "none";
2889
+ submitBtn.disabled = false;
2890
+ })
2891
+ .catch(() => {
2892
+ contractSelect.innerHTML =
2893
+ '<option value="">None (don\'t stake)</option>';
2894
+ contractSelect.style.display = "";
2895
+ spinnerDiv.style.display = "none";
2896
+ submitBtn.disabled = false;
2897
+ });
2898
+ }
2899
+ });
2900
+ }
2901
+
2902
+ if (closeCreateServiceModal) {
2903
+ closeCreateServiceModal.addEventListener("click", () => {
2904
+ createServiceModal.classList.remove("active");
2905
+ });
2906
+ }
2907
+
2908
+ if (createServiceForm) {
2909
+ createServiceForm.addEventListener("submit", async (e) => {
2910
+ e.preventDefault();
2911
+ const btn = createServiceForm.querySelector('button[type="submit"]');
2912
+ const originalText = btn.innerHTML;
2913
+
2914
+ const isDeployMode = createServiceForm.dataset.deployMode === "true";
2915
+ const serviceKey = createServiceForm.dataset.deployServiceKey;
2916
+
2917
+ btn.innerHTML = isDeployMode
2918
+ ? '<span class="loading-spinner spinner-sm"></span>Deploying...'
2919
+ : '<span class="loading-spinner spinner-sm"></span>Creating & Deploying...';
2920
+ btn.disabled = true;
2921
+
2922
+ const stakingContract = document.getElementById(
2923
+ "new-service-staking-contract",
2924
+ ).value;
2925
+
2926
+ try {
2927
+ let resp, result;
2928
+
2929
+ if (isDeployMode) {
2930
+ const url = `/api/olas/deploy/${serviceKey}${stakingContract ? "?staking_contract=" + encodeURIComponent(stakingContract) : ""}`;
2931
+ resp = await authFetch(url, { method: "POST" });
2932
+ result = await resp.json();
2933
+ if (resp.ok) {
2934
+ showToast("Service deployed successfully!", "success");
2935
+ createServiceModal.classList.remove("active");
2936
+ refreshSingleService(serviceKey);
2937
+ } else {
2938
+ showToast(`Error: ${result.detail}`, "error");
2939
+ }
2940
+ } else {
2941
+ const payload = {
2942
+ service_name: document.getElementById("new-service-name").value,
2943
+ chain: document.getElementById("new-service-chain").value,
2944
+ agent_type: document.getElementById("new-service-agent-type").value,
2945
+ token_address: "OLAS",
2946
+ stake_on_create: !!stakingContract,
2947
+ staking_contract: stakingContract || null,
2948
+ };
2949
+ resp = await authFetch("/api/olas/create", {
2950
+ method: "POST",
2951
+ headers: { "Content-Type": "application/json" },
2952
+ body: JSON.stringify(payload),
2953
+ });
2954
+ result = await resp.json();
2955
+ if (resp.ok) {
2956
+ showToast(`Service created! ID: ${result.service_id}`, "success");
2957
+ createServiceModal.classList.remove("active");
2958
+ createServiceForm.reset();
2959
+ addNewServiceCard(
2960
+ result.service_id,
2961
+ payload.chain,
2962
+ payload.service_name,
2963
+ );
2964
+ } else {
2965
+ showToast(`Error: ${result.detail}`, "error");
2966
+ }
2967
+ }
2968
+ } catch (err) {
2969
+ showToast(
2970
+ isDeployMode ? "Error deploying service" : "Error creating service",
2971
+ "error",
2972
+ );
2973
+ } finally {
2974
+ btn.innerHTML = originalText;
2975
+ btn.disabled = false;
2976
+ // Reset modal to create mode
2977
+ const modalTitle =
2978
+ createServiceModal.querySelector("h2") ||
2979
+ createServiceModal.querySelector("h3");
2980
+ if (modalTitle) modalTitle.textContent = "Create Olas Service";
2981
+ const nameInput = document.getElementById("new-service-name");
2982
+ const chainSelect = document.getElementById("new-service-chain");
2983
+ const agentTypeSelect = document.getElementById(
2984
+ "new-service-agent-type",
2985
+ );
2986
+ nameInput.disabled = false;
2987
+ nameInput.style.opacity = "";
2988
+ nameInput.style.backgroundColor = "";
2989
+ nameInput.style.color = "";
2990
+ chainSelect.disabled = false;
2991
+ chainSelect.style.opacity = "";
2992
+ chainSelect.style.backgroundColor = "";
2993
+ chainSelect.style.color = "";
2994
+ agentTypeSelect.disabled = false;
2995
+ agentTypeSelect.style.opacity = "";
2996
+ agentTypeSelect.style.backgroundColor = "";
2997
+ agentTypeSelect.style.color = "";
2998
+ delete createServiceForm.dataset.deployMode;
2999
+ delete createServiceForm.dataset.deployServiceKey;
3000
+ }
3001
+ });
3002
+ }
3003
+
3004
+ // ===== Fund Service Modal Functions =====
3005
+ const fundServiceModal = document.getElementById("fund-service-modal");
3006
+ const closeFundModal = document.getElementById("close-fund-modal");
3007
+ const fundConfirmBtn = document.getElementById("fund-confirm");
3008
+
3009
+ window.showFundServiceModal = (serviceKey, chain) => {
3010
+ document.getElementById("fund-service-key").value = serviceKey;
3011
+ document.getElementById("fund-agent-amount").value = "0";
3012
+ document.getElementById("fund-safe-amount").value = "0";
3013
+ // Update native currency symbol
3014
+ const nativeSymbol = state.nativeCurrencies[chain] || "Native";
3015
+ document.getElementById("fund-native-symbol").textContent = nativeSymbol;
3016
+ document.getElementById("fund-safe-symbol").textContent = nativeSymbol;
3017
+ fundServiceModal.classList.add("active");
3018
+ };
3019
+
3020
+ if (closeFundModal) {
3021
+ closeFundModal.addEventListener("click", () => {
3022
+ fundServiceModal.classList.remove("active");
3023
+ });
3024
+ }
3025
+
3026
+ if (fundConfirmBtn) {
3027
+ fundConfirmBtn.addEventListener("click", async () => {
3028
+ const serviceKey = document.getElementById("fund-service-key").value;
3029
+ const agentAmount =
3030
+ parseFloat(document.getElementById("fund-agent-amount").value) || 0;
3031
+ const safeAmount =
3032
+ parseFloat(document.getElementById("fund-safe-amount").value) || 0;
3033
+
3034
+ if (agentAmount <= 0 && safeAmount <= 0) {
3035
+ showToast("Enter at least one amount", "error");
3036
+ return;
3037
+ }
3038
+
3039
+ fundServiceModal.classList.remove("active");
3040
+ showToast("Funding service...", "info");
3041
+
3042
+ try {
3043
+ const resp = await authFetch(`/api/olas/fund/${serviceKey}`, {
3044
+ method: "POST",
3045
+ headers: { "Content-Type": "application/json" },
3046
+ body: JSON.stringify({
3047
+ agent_amount_eth: agentAmount,
3048
+ safe_amount_eth: safeAmount,
3049
+ }),
3050
+ });
3051
+ const result = await resp.json();
3052
+ if (resp.ok) {
3053
+ const fundedAccounts = Object.keys(result.funded || {});
3054
+ showToast(`Funded ${fundedAccounts.join(", ")} !`, "success");
3055
+ refreshSingleService(serviceKey);
3056
+ } else {
3057
+ showToast(`Error: ${result.detail} `, "error");
3058
+ }
3059
+ } catch (err) {
3060
+ showToast("Error funding service", "error");
3061
+ }
3062
+ });
3063
+ }
3064
+
3065
+ // Global Click listener for delegation
3066
+ document.body.addEventListener("click", (e) => {
3067
+ const btn = e.target.closest("[data-action]");
3068
+ if (!btn) return;
3069
+
3070
+ const action = btn.dataset.action;
3071
+ const key = btn.dataset.key;
3072
+ const chain = btn.dataset.chain;
3073
+
3074
+ if (action === "copy") {
3075
+ copyToClipboard(btn.dataset.value);
3076
+ } else if (action === "refresh-service") {
3077
+ refreshSingleService(key);
3078
+ } else if (action === "fund-service") {
3079
+ showFundServiceModal(key, chain);
3080
+ } else if (action === "checkpoint") {
3081
+ checkpointOlasService(key);
3082
+ } else if (action === "unstake") {
3083
+ unstakeOlasService(key);
3084
+ } else if (action === "deploy") {
3085
+ showDeployModal(key, chain, btn.dataset.name, btn.dataset.id);
3086
+ } else if (action === "terminate") {
3087
+ showTerminateModal(key);
3088
+ } else if (action === "drain") {
3089
+ drainOlasService(key);
3090
+ } else if (action === "claim-rewards") {
3091
+ claimOlasRewards(key);
3092
+ }
3093
+ });
3094
+
3095
+ init();
3096
+ });