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.
- iwa/core/chain/interface.py +51 -61
- iwa/core/chain/models.py +7 -7
- iwa/core/chain/rate_limiter.py +21 -10
- iwa/core/cli.py +27 -2
- iwa/core/constants.py +6 -5
- iwa/core/contracts/abis/erc20.json +930 -0
- iwa/core/contracts/abis/multisend.json +24 -0
- iwa/core/contracts/abis/multisend_call_only.json +17 -0
- iwa/core/contracts/contract.py +16 -4
- iwa/core/ipfs.py +149 -0
- iwa/core/keys.py +259 -29
- iwa/core/mnemonic.py +3 -13
- iwa/core/models.py +28 -6
- iwa/core/pricing.py +4 -4
- iwa/core/secrets.py +77 -0
- iwa/core/services/safe.py +3 -3
- iwa/core/utils.py +6 -1
- iwa/core/wallet.py +4 -0
- iwa/plugins/gnosis/safe.py +2 -2
- iwa/plugins/gnosis/tests/test_safe.py +1 -1
- iwa/plugins/olas/constants.py +8 -0
- iwa/plugins/olas/contracts/abis/activity_checker.json +110 -0
- iwa/plugins/olas/contracts/abis/mech.json +740 -0
- iwa/plugins/olas/contracts/abis/mech_marketplace.json +1293 -0
- iwa/plugins/olas/contracts/abis/mech_new.json +954 -0
- iwa/plugins/olas/contracts/abis/service_manager.json +1382 -0
- iwa/plugins/olas/contracts/abis/service_registry.json +1909 -0
- iwa/plugins/olas/contracts/abis/staking.json +1400 -0
- iwa/plugins/olas/contracts/abis/staking_token.json +1274 -0
- iwa/plugins/olas/contracts/mech.py +30 -2
- iwa/plugins/olas/plugin.py +2 -2
- iwa/plugins/olas/tests/test_plugin_full.py +3 -3
- iwa/plugins/olas/tests/test_staking_integration.py +2 -2
- iwa/tools/__init__.py +1 -0
- iwa/tools/check_profile.py +6 -5
- iwa/tools/list_contracts.py +136 -0
- iwa/tools/release.py +9 -3
- iwa/tools/reset_env.py +2 -2
- iwa/tools/reset_tenderly.py +26 -24
- iwa/tools/wallet_check.py +150 -0
- iwa/web/dependencies.py +4 -4
- iwa/web/routers/state.py +1 -0
- iwa/web/static/app.js +3096 -0
- iwa/web/static/index.html +543 -0
- iwa/web/static/style.css +1443 -0
- iwa/web/tests/test_web_endpoints.py +3 -2
- iwa/web/tests/test_web_swap_coverage.py +156 -0
- {iwa-0.0.1a2.dist-info → iwa-0.0.1a4.dist-info}/METADATA +6 -3
- {iwa-0.0.1a2.dist-info → iwa-0.0.1a4.dist-info}/RECORD +64 -44
- iwa-0.0.1a4.dist-info/entry_points.txt +6 -0
- {iwa-0.0.1a2.dist-info → iwa-0.0.1a4.dist-info}/top_level.txt +0 -1
- tests/test_chain.py +1 -1
- tests/test_chain_interface_coverage.py +92 -0
- tests/test_contract.py +2 -0
- tests/test_keys.py +58 -15
- tests/test_migration.py +52 -0
- tests/test_mnemonic.py +1 -1
- tests/test_pricing.py +7 -7
- tests/test_safe_coverage.py +1 -1
- tests/test_safe_service.py +3 -3
- tests/test_staking_router.py +13 -1
- tools/verify_drain.py +1 -1
- conftest.py +0 -22
- iwa/core/settings.py +0 -95
- iwa-0.0.1a2.dist-info/entry_points.txt +0 -2
- {iwa-0.0.1a2.dist-info → iwa-0.0.1a4.dist-info}/WHEEL +0 -0
- {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
|
+
});
|