iuna-ui.js (144211B)
1 const IUNA_DOWNLOADS_URL = "https://getiuna.org/downloads/"; 2 const IUNA_RELEASE_METADATA_URL = "https://getiuna.org/downloads/latest.json"; 3 const IUNA_RELEASE_CHECK_INTERVAL_MS = 30 * 60 * 1000; 4 5 window.iunaApp = function iunaApp() { 6 return { 7 tab: "dashboard", 8 status: {}, 9 blocks: [], 10 selectedBlock: null, 11 selectedByteBlock: null, 12 selectedBurnBundleBlock: null, 13 selectedTransaction: null, 14 selectedBurnLeaderBlock: null, 15 loadingInitialBlocks: false, 16 loadingOlder: false, 17 hasMoreBlocks: true, 18 walletTxs: [], 19 walletUtxos: [], 20 mempool: [], 21 peers: [], 22 selectedPeerAddress: null, 23 p2pMetrics: {}, 24 blockchainMetrics: { enabled: false, latest: null, charts: [] }, 25 loadingMetrics: false, 26 metricsRequestSeq: 0, 27 metricHover: null, 28 leaderboardTab: "balances", 29 metricsRange: (() => { 30 try { 31 const stored = localStorage.getItem("iunaMetricsRange"); 32 if (stored === "1000") return 1000; 33 if (stored === "all") return "all"; 34 } catch { 35 // Ignore storage failures; the in-memory default is enough. 36 } 37 return 100; 38 })(), 39 networkHealth: {}, 40 networkHealthLoaded: false, 41 uiMode: (() => { 42 try { 43 return localStorage.getItem("iunaUiMode") === "advanced" ? "advanced" : "basic"; 44 } catch { 45 return "basic"; 46 } 47 })(), 48 latestRelease: null, 49 releaseCheckState: "idle", 50 releaseCheckError: null, 51 desktopUpdateModalOpen: false, 52 desktopUpdateBusy: false, 53 config: { setup_complete: false }, 54 auth: { configured: false, authenticated: false }, 55 authLoaded: false, 56 authPassword: "", 57 authPasswordConfirm: "", 58 loginPassword: "", 59 authFeedback: null, 60 settingsOldPassword: "", 61 settingsNewPassword: "", 62 settingsPasswordConfirm: "", 63 settingsFeedback: null, 64 keepTrackOfMetrics: false, 65 addressBook: {}, 66 addressBookVersion: 0, 67 addressBookModalOpen: false, 68 addressBookPickerOpen: false, 69 addressBookStandalone: false, 70 addressBookEditingAddress: null, 71 addressBookDraftAddress: "", 72 addressBookDraftName: "", 73 p2pAcceptInbound: false, 74 p2pBindPort: 9444, 75 p2pBindPortDirty: false, 76 p2pAnnounceAddr: "", 77 p2pAnnounceDirty: false, 78 stratumEnabled: false, 79 stratumBindPort: 3333, 80 stratumBindPortDirty: false, 81 walletEndpointEnabled: false, 82 walletEndpointBindPort: 18662, 83 walletEndpointBindPortDirty: false, 84 setupWallet: { address: null, seed_phrase: null, dev_verify_bypass: false, requires_peer: false }, 85 setupNodeMode: "wallet", 86 setupWalletMode: "create", 87 setupSeedStep: "write", 88 generatedSeedPhrase: "", 89 verifyChallenges: [], 90 verifyAnswers: {}, 91 importSeedPhrase: "", 92 walletVerified: false, 93 setupFeedback: null, 94 migrationBusy: false, 95 burnAmount: 100, 96 burnAmountDraft: "0.0001", 97 burnFee: 1, 98 burnFeeDraft: "0.000001", 99 miningEnabled: false, 100 powMiningEnabled: false, 101 powMiningWorkers: 1, 102 maxPowMiningWorkers: 32, 103 recoveryVdfTopRankPercent: 50, 104 burnAmountDirty: false, 105 miningEvents: [], 106 miningEventLimit: 1000, 107 miningEventState: {}, 108 miningEventCounter: 0, 109 transferTo: "", 110 transferAmount: null, 111 transferFee: "0.000001", 112 sendPreparing: false, 113 sendConfirmModalOpen: false, 114 sendConfirmBusy: false, 115 pendingTransfer: null, 116 feeEstimates: { transfer: null, burn: null, mine: null }, 117 feeEstimateTimer: null, 118 showSendAdvanced: false, 119 optimizeOpen: false, 120 optimizeBusy: false, 121 optimizeRunning: false, 122 optimizeFee: "0.000001", 123 optimizeMergeRoots: false, 124 optimizePlan: null, 125 optimizeMessage: "", 126 optimizeError: "", 127 optimizeDismissed: false, 128 quantumMigrationFee: "0.000001", 129 quantumMigrationBusy: false, 130 quantumMigrationSubmitting: false, 131 quantumMigrationPreview: null, 132 quantumMigrationError: "", 133 selectedTransferUtxos: [], 134 selectedTransferUtxoAmounts: {}, 135 lastSelectedTransferUtxo: null, 136 walletTxFilters: { transfer: true, mine: true, burn: true, reward: true }, 137 setupPeerAddress: "iuna.jhx.app:9444", 138 peerAddress: "", 139 flash: null, 140 flashTimer: null, 141 chainResetModalOpen: false, 142 chainResetConfirm: "", 143 chainResetBusy: false, 144 showWalletUtxos: false, 145 showWalletAddresses: false, 146 showPowDifficultyInfo: false, 147 lastUpdated: null, 148 pollHandle: null, 149 releaseCheckTimer: null, 150 refreshPromise: null, 151 shellRefreshPromise: null, 152 networkHealthPromise: null, 153 requestTimeoutMs: 12000, 154 hashListenerInstalled: false, 155 newBlockHashes: new Set(), 156 newBlockTimer: null, 157 lastBlockMempoolHeight: null, 158 mempoolFirstSeenHeights: {}, 159 mempoolFirstSeenAt: {}, 160 mempoolSeenInitialized: false, 161 blockPageSize: 20, 162 datasetPageSize: 25, 163 walletTxPage: { offset: 0, total: 0, hasMore: true, loading: false, backgroundLoading: false }, 164 walletUtxoPage: { offset: 0, total: 0, hasMore: true, loading: false, backgroundLoading: false }, 165 mempoolPage: { offset: 0, total: 0, hasMore: true, loading: false, backgroundLoading: false }, 166 peerPage: { offset: 0, total: 0, hasMore: true, loading: false, backgroundLoading: false }, 167 168 init() { 169 this.bootstrap(); 170 }, 171 172 async bootstrap() { 173 await this.refreshAuth(); 174 if (this.showingAuth()) return; 175 await this.bootstrapAuthenticated(); 176 }, 177 178 async bootstrapAuthenticated() { 179 await this.refreshConfig(); 180 if (!this.config.setup_complete) { 181 await this.refreshWalletSetup(); 182 } 183 this.tab = this.tabFromHash(); 184 if (!this.hashListenerInstalled) { 185 window.addEventListener("hashchange", () => { 186 this.setTab(this.tabFromHash()); 187 }); 188 this.hashListenerInstalled = true; 189 } 190 await this.refresh(); 191 this.checkLatestRelease(); 192 this.scheduleReleaseCheck(); 193 this.schedulePoll(); 194 }, 195 196 canUseProtectedApi() { 197 return this.authLoaded && this.auth.configured === true && this.auth.authenticated === true; 198 }, 199 200 stopPolling() { 201 this.stopReleaseCheck(); 202 if (!this.pollHandle) return; 203 clearTimeout(this.pollHandle); 204 this.pollHandle = null; 205 }, 206 207 stopReleaseCheck() { 208 if (!this.releaseCheckTimer) return; 209 clearTimeout(this.releaseCheckTimer); 210 this.releaseCheckTimer = null; 211 }, 212 213 scheduleReleaseCheck() { 214 if (this.releaseCheckTimer || !this.canUseProtectedApi()) return; 215 this.releaseCheckTimer = setTimeout(async () => { 216 this.releaseCheckTimer = null; 217 if (!this.canUseProtectedApi()) return; 218 await this.checkLatestRelease(); 219 this.scheduleReleaseCheck(); 220 }, IUNA_RELEASE_CHECK_INTERVAL_MS); 221 }, 222 223 schedulePoll() { 224 if (this.pollHandle || !this.canUseProtectedApi()) return; 225 const delay = this.syncingNode() ? 1000 : 5000; 226 this.pollHandle = setTimeout(async () => { 227 this.pollHandle = null; 228 await this.refresh({ silent: true }); 229 this.schedulePoll(); 230 }, delay); 231 }, 232 233 tabFromHash() { 234 const hash = window.location.hash.replace(/^#\/?/, ""); 235 return this.allowedTabs().includes(hash) ? hash : "dashboard"; 236 }, 237 238 setTab(tab) { 239 if (!this.allowedTabs().includes(tab)) return; 240 const alreadyActive = this.tab === tab; 241 this.tab = tab; 242 if (window.location.hash !== `#${tab}`) { 243 window.location.hash = tab; 244 } 245 if (alreadyActive) return; 246 this.refresh({ silent: true }); 247 }, 248 249 allowedTabs() { 250 const tabs = this.advancedMode() 251 ? ["dashboard", "wallet", "mining", "p2p", "chain", "settings"] 252 : ["dashboard", "wallet", "p2p", "chain", "settings"]; 253 if (this.developmentMode()) { 254 tabs.splice(tabs.indexOf("chain") + 1, 0, "metrics", "leaderboards"); 255 } 256 return tabs; 257 }, 258 259 developmentMode() { 260 return this.keepTrackOfMetrics === true || this.config.keep_track_of_metrics === true; 261 }, 262 263 basicMode() { 264 return this.uiMode !== "advanced"; 265 }, 266 267 advancedMode() { 268 return this.uiMode === "advanced"; 269 }, 270 271 setUiMode(mode) { 272 this.uiMode = mode === "advanced" ? "advanced" : "basic"; 273 try { 274 localStorage.setItem("iunaUiMode", this.uiMode); 275 } catch {} 276 if (!this.allowedTabs().includes(this.tab)) { 277 this.setTab("dashboard"); 278 } 279 }, 280 281 toggleUiMode() { 282 this.setUiMode(this.advancedMode() ? "basic" : "advanced"); 283 }, 284 285 pageTitle() { 286 return { 287 dashboard: "Dashboard", 288 wallet: "iuna", 289 mining: "Mining", 290 p2p: "P2P", 291 chain: "Chain", 292 metrics: "Metrics", 293 leaderboards: "Leaderboards", 294 settings: "Settings", 295 }[this.tab] || "iuna"; 296 }, 297 298 appVersionLabel() { 299 return `v${this.normalizeVersion(this.status.app_version || "0.0.0")}`; 300 }, 301 302 latestReleaseLabel() { 303 return this.latestRelease?.tag || ""; 304 }, 305 306 updateAvailable() { 307 const current = this.normalizeVersion(this.status.app_version); 308 const latest = this.normalizeVersion(this.latestRelease?.tag); 309 if (!current || !latest) return false; 310 if (current === latest) return false; 311 return this.compareVersions(latest, current) > 0; 312 }, 313 314 versionPanelTitle() { 315 if (this.desktopUpdateBusy) return "Installing update"; 316 if (this.updateAvailable()) return `Update available: ${this.latestReleaseLabel()}`; 317 if (this.releaseCheckState === "failed") return this.releaseCheckError || "Could not check latest release"; 318 if (this.releaseCheckState === "checking") return "Checking latest release"; 319 return "iuna is up to date"; 320 }, 321 322 async openLatestRelease() { 323 const url = this.latestRelease?.url || IUNA_DOWNLOADS_URL; 324 const invoke = window.__TAURI__?.core?.invoke; 325 if ( 326 this.updateAvailable() 327 && this.latestRelease?.desktopReady === true 328 && typeof invoke === "function" 329 ) { 330 this.desktopUpdateModalOpen = true; 331 return; 332 } 333 try { 334 const tauriOpen = window.__TAURI__?.shell?.open; 335 if (typeof tauriOpen === "function") { 336 await tauriOpen(url); 337 return; 338 } 339 } catch {} 340 window.open(url, "_blank", "noopener,noreferrer"); 341 }, 342 343 closeDesktopUpdateModal() { 344 if (this.desktopUpdateBusy) return; 345 this.desktopUpdateModalOpen = false; 346 }, 347 348 async installDesktopUpdate() { 349 const invoke = window.__TAURI__?.core?.invoke; 350 if (typeof invoke !== "function") return; 351 this.desktopUpdateBusy = true; 352 try { 353 await invoke("install_desktop_update"); 354 } catch (error) { 355 this.desktopUpdateBusy = false; 356 this.desktopUpdateModalOpen = false; 357 this.showFlash(error?.message || String(error) || "Desktop update failed", "error"); 358 } 359 }, 360 361 showingSetup() { 362 return this.authLoaded && !this.showingAuth() && !this.showingNetworkMigration() && !this.config.setup_complete; 363 }, 364 365 showingNetworkMigration() { 366 return this.authLoaded && !this.showingAuth() && this.status.network_migration?.required === true; 367 }, 368 369 migrationNetworkLabel() { 370 return this.status.network_migration?.to_network || "the new Iuna network"; 371 }, 372 373 async finishNetworkMigration(setUpDifferentWallet) { 374 this.migrationBusy = true; 375 try { 376 await this.submitForm("/api/settings/chain-reset", { confirm: "RESET" }); 377 if (setUpDifferentWallet) { 378 const response = await this.fetchWithTimeout("/api/config", { 379 method: "POST", 380 headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, 381 body: new URLSearchParams({ setup_complete: "false", peer: "" }), 382 }); 383 const payload = await response.json(); 384 if (!response.ok || !payload.ok) throw new Error(payload.error || "Could not open wallet setup"); 385 this.config.setup_complete = false; 386 } 387 await this.refresh({ force: true }); 388 this.showFlash(setUpDifferentWallet ? "Local chain reset. Choose or import a wallet." : "Local chain reset. Sync requested from peers.", "success"); 389 } catch (error) { 390 this.showFlash(error.message, "error"); 391 } finally { 392 this.migrationBusy = false; 393 } 394 }, 395 396 showingAuth() { 397 return this.authLoaded && (!this.auth.configured || !this.auth.authenticated); 398 }, 399 400 setupRequiresPeer() { 401 return this.setupWallet.requires_peer === true; 402 }, 403 404 setupHasPeer() { 405 return this.setupPeerAddress.trim().length > 0 || this.outboundPeers().length > 0; 406 }, 407 408 setupCanContinue() { 409 return this.walletVerified && (!this.setupRequiresPeer() || this.setupHasPeer()); 410 }, 411 412 selectSetupNodeMode(mode) { 413 this.setupNodeMode = ["wallet", "non-listening", "listening"].includes(mode) 414 ? mode 415 : "wallet"; 416 this.setupFeedback = null; 417 }, 418 419 setupNodeModeCopy() { 420 if (this.setupNodeMode === "listening") { 421 return "Listening node shows mining and P2P controls and accepts inbound P2P connections when TCP port 9444 is reachable."; 422 } 423 if (this.setupNodeMode === "non-listening") { 424 return "Non-listening node shows mining and P2P controls, connects out to peers, and keeps inbound P2P closed."; 425 } 426 return "Wallet mode keeps the interface focused on your wallet and chain, while this node only connects out to peers."; 427 }, 428 429 async refreshAuth() { 430 this.auth = await this.fetchJson("/api/auth/status"); 431 this.authLoaded = true; 432 }, 433 434 async setupPassword() { 435 try { 436 this.authFeedback = null; 437 if (this.authPassword !== this.authPasswordConfirm) { 438 throw new Error("Passwords do not match"); 439 } 440 await this.postAuth("/api/auth/setup", this.authPassword); 441 this.authPassword = ""; 442 this.authPasswordConfirm = ""; 443 await this.refreshAuth(); 444 await this.bootstrapAuthenticated(); 445 this.showFlash("Password set", "success"); 446 } catch (error) { 447 this.showAuthFeedback(error.message, "error"); 448 } 449 }, 450 451 async login() { 452 try { 453 this.authFeedback = null; 454 await this.postAuth("/api/auth/login", this.loginPassword); 455 this.loginPassword = ""; 456 await this.refreshAuth(); 457 await this.bootstrapAuthenticated(); 458 this.showFlash("Logged in", "success"); 459 } catch (error) { 460 this.showAuthFeedback(error.message, "error"); 461 } 462 }, 463 464 async postAuth(path, password) { 465 const response = await this.fetchWithTimeout(path, { 466 method: "POST", 467 headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, 468 body: new URLSearchParams({ password }), 469 }); 470 const text = await response.text(); 471 let payload = { ok: response.ok, error: null }; 472 if (text) { 473 try { 474 payload = JSON.parse(text); 475 } catch { 476 payload = { ok: false, error: text }; 477 } 478 } 479 if (!response.ok || !payload.ok) { 480 throw new Error(payload.error || `${path} returned ${response.status}`); 481 } 482 return payload; 483 }, 484 485 async logout() { 486 try { 487 await this.postAuth("/api/auth/logout", ""); 488 this.stopPolling(); 489 await this.refreshAuth(); 490 this.showFlash("Locked", "success"); 491 } catch (error) { 492 this.showFlash(error.message, "error"); 493 } 494 }, 495 496 async changePassword() { 497 try { 498 this.settingsFeedback = null; 499 if (this.settingsNewPassword !== this.settingsPasswordConfirm) { 500 throw new Error("New passwords do not match"); 501 } 502 const body = new URLSearchParams({ 503 old_password: this.settingsOldPassword, 504 new_password: this.settingsNewPassword, 505 }); 506 const response = await this.fetchWithTimeout("/api/auth/change-password", { 507 method: "POST", 508 headers: { 509 Accept: "application/json", 510 "Content-Type": "application/x-www-form-urlencoded", 511 }, 512 body, 513 }); 514 const payload = await response.json(); 515 if (!response.ok || !payload.ok) { 516 throw new Error(payload.error || `/api/auth/change-password returned ${response.status}`); 517 } 518 this.settingsOldPassword = ""; 519 this.settingsNewPassword = ""; 520 this.settingsPasswordConfirm = ""; 521 await this.refreshAuth(); 522 this.showSettingsFeedback("Password changed", "success"); 523 this.showFlash("Password changed", "success"); 524 } catch (error) { 525 this.showSettingsFeedback(error.message, "error"); 526 } 527 }, 528 529 async refreshConfig() { 530 this.config = await this.fetchJson("/api/config"); 531 this.syncConfigState({ addressBookVersion: this.addressBookVersion }); 532 }, 533 534 syncConfigState(options = {}) { 535 this.keepTrackOfMetrics = this.config.keep_track_of_metrics === true; 536 this.recoveryVdfTopRankPercent = Number( 537 this.config.recovery_vdf_top_rank_percent ?? 538 this.config.recoveryVdfTopRankPercent ?? 539 this.recoveryVdfTopRankPercent 540 ); 541 this.p2pAcceptInbound = this.config.p2p_accept_inbound === true; 542 if (!this.p2pBindPortDirty) { 543 this.p2pBindPort = Number(this.config.p2p_bind_port || 9444); 544 } 545 this.stratumEnabled = this.config.stratum_enabled === true; 546 if (!this.stratumBindPortDirty) { 547 this.stratumBindPort = Number(this.config.stratum_bind_port || 3333); 548 } 549 this.walletEndpointEnabled = this.config.wallet_endpoint_enabled === true; 550 if (!this.walletEndpointBindPortDirty) { 551 this.walletEndpointBindPort = Number(this.config.wallet_endpoint_bind_port || 18662); 552 } 553 if ( 554 options.addressBookVersion === undefined || 555 options.addressBookVersion >= this.addressBookVersion 556 ) { 557 this.addressBook = this.config.address_book || this.config.addressBook || {}; 558 } 559 if (!this.p2pAnnounceDirty) { 560 this.p2pAnnounceAddr = this.config.p2p_announce_addr || ""; 561 } 562 }, 563 564 async refreshWalletSetup() { 565 const payload = await this.fetchJson("/api/wallet/setup"); 566 if (!payload.ok) { 567 throw new Error(payload.error || "Could not load wallet setup"); 568 } 569 this.setupWallet = payload; 570 if ( 571 payload.seed_phrase && 572 payload.seed_phrase !== this.generatedSeedPhrase && 573 this.setupWalletMode === "create" && 574 !this.walletVerified 575 ) { 576 this.generatedSeedPhrase = payload.seed_phrase; 577 this.walletVerified = false; 578 this.setupSeedStep = "write"; 579 this.verifyChallenges = []; 580 this.verifyAnswers = {}; 581 } 582 }, 583 584 setupSeedWords() { 585 return this.generatedSeedPhrase ? this.generatedSeedPhrase.split(/\s+/) : []; 586 }, 587 588 setupAddress() { 589 return this.setupWallet.address || this.status.wallet_receive_address || "-"; 590 }, 591 592 receiveAddress() { 593 return this.status.wallet_receive_address || this.setupWallet.address || "-"; 594 }, 595 596 fundedWalletAddresses() { 597 return Array.isArray(this.status.funded_wallet_addresses) 598 ? this.status.funded_wallet_addresses 599 : []; 600 }, 601 602 walletAddressSummary() { 603 const count = this.fundedWalletAddresses().length; 604 return `Funds are held across ${count} wallet address${count === 1 ? "" : "es"}`; 605 }, 606 607 walletAddressHasPendingSpend(entry) { 608 return Number(entry?.spendable_utxos || 0) < Number(entry?.utxos || 0); 609 }, 610 611 walletAddressState(entry) { 612 if (this.walletAddressHasPendingSpend(entry)) return "Pending spend"; 613 if (entry?.address === this.receiveAddress()) return "Current"; 614 if (entry?.legacy === true) return "Legacy funds"; 615 return "Funded"; 616 }, 617 618 walletAddressUtxoLabel(entry) { 619 const total = Number(entry?.utxos || 0); 620 const available = Number(entry?.spendable_utxos || 0); 621 const pending = Math.max(0, total - available); 622 const parts = [`${total} UTXO${total === 1 ? "" : "s"}`]; 623 if (pending > 0) parts.push(`${available} available`, `${pending} pending`); 624 return parts.join(" · "); 625 }, 626 627 selectSetupWalletMode(mode) { 628 this.setupWalletMode = mode; 629 this.walletVerified = mode === "import" ? this.walletVerified && !this.generatedSeedPhrase : false; 630 this.setupFeedback = null; 631 }, 632 633 async generateSetupSeed() { 634 try { 635 this.setupFeedback = null; 636 const payload = await this.postWalletSetup("/api/wallet/generate", {}); 637 this.setupWallet = payload; 638 this.generatedSeedPhrase = payload.seed_phrase || ""; 639 this.setupWalletMode = "create"; 640 this.setupSeedStep = "write"; 641 this.walletVerified = false; 642 this.verifyChallenges = []; 643 this.verifyAnswers = {}; 644 await this.refresh({ force: true }); 645 } catch (error) { 646 this.showSetupFeedback(error.message, "error"); 647 } 648 }, 649 650 beginSeedVerification() { 651 this.setupFeedback = null; 652 const words = this.setupSeedWords(); 653 if (words.length < 4) { 654 this.showSetupFeedback("Generate a recovery phrase first", "error"); 655 return; 656 } 657 const positions = words.map((_, index) => index); 658 for (let index = positions.length - 1; index > 0; index -= 1) { 659 const swapIndex = Math.floor(Math.random() * (index + 1)); 660 [positions[index], positions[swapIndex]] = [positions[swapIndex], positions[index]]; 661 } 662 this.verifyChallenges = positions 663 .slice(0, 4) 664 .sort((left, right) => left - right) 665 .map((index) => ({ index, position: index + 1 })); 666 this.verifyAnswers = {}; 667 for (const challenge of this.verifyChallenges) { 668 this.verifyAnswers[challenge.index] = ""; 669 } 670 this.setupSeedStep = "verify"; 671 }, 672 673 verifyGeneratedSeed() { 674 const words = this.setupSeedWords(); 675 const ok = this.verifyChallenges.every((challenge) => { 676 const expected = words[challenge.index] || ""; 677 const actual = (this.verifyAnswers[challenge.index] || "").trim().toLowerCase(); 678 return actual === expected; 679 }); 680 if (!ok) { 681 this.showSetupFeedback("Seed word check failed", "error"); 682 return; 683 } 684 this.walletVerified = true; 685 this.setupSeedStep = "verified"; 686 this.showSetupFeedback("Recovery phrase verified", "success"); 687 }, 688 689 skipSeedVerificationForDev() { 690 if (!this.setupWallet.dev_verify_bypass) return; 691 this.walletVerified = true; 692 this.setupSeedStep = "verified"; 693 this.showSetupFeedback("Recovery phrase verification skipped", "success"); 694 }, 695 696 async importSetupSeed() { 697 try { 698 this.setupFeedback = null; 699 const payload = await this.postWalletSetup("/api/wallet/import", { 700 seed_phrase: this.importSeedPhrase, 701 }); 702 this.setupWallet = payload; 703 this.generatedSeedPhrase = ""; 704 this.verifyChallenges = []; 705 this.verifyAnswers = {}; 706 this.walletVerified = true; 707 this.setupSeedStep = "verified"; 708 await this.refresh({ force: true }); 709 this.showSetupFeedback("Recovery phrase imported", "success"); 710 } catch (error) { 711 this.showSetupFeedback(error.message, "error"); 712 } 713 }, 714 715 async postWalletSetup(path, fields) { 716 const body = new URLSearchParams(); 717 for (const [key, value] of Object.entries(fields)) { 718 body.set(key, value); 719 } 720 const response = await this.fetchWithTimeout(path, { 721 method: "POST", 722 headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, 723 body, 724 }); 725 const payload = await response.json(); 726 if (!response.ok || !payload.ok) { 727 throw new Error(payload.error || `${path} returned ${response.status}`); 728 } 729 return payload; 730 }, 731 732 async completeSetup() { 733 try { 734 if (!this.walletVerified) { 735 throw new Error("Verify or import a recovery phrase first"); 736 } 737 if (this.setupRequiresPeer() && !this.setupHasPeer()) { 738 throw new Error("Add a bootstrap peer before continuing"); 739 } 740 await this.applySetupNodeMode(); 741 const response = await this.fetchWithTimeout("/api/config", { 742 method: "POST", 743 headers: { 744 Accept: "application/json", 745 "Content-Type": "application/x-www-form-urlencoded", 746 }, 747 body: new URLSearchParams({ 748 setup_complete: "true", 749 peer: this.setupPeerAddress.trim(), 750 }), 751 }); 752 const payload = await response.json(); 753 if (!response.ok || !payload.ok) { 754 throw new Error(payload.error || `/api/config returned ${response.status}`); 755 } 756 await this.refresh({ force: true }); 757 this.setupFeedback = null; 758 this.generatedSeedPhrase = ""; 759 this.importSeedPhrase = ""; 760 this.setupPeerAddress = ""; 761 this.verifyChallenges = []; 762 this.verifyAnswers = {}; 763 this.showFlash("Setup complete", "success"); 764 this.setTab("dashboard"); 765 } catch (error) { 766 this.showSetupFeedback(error.message, "error"); 767 } 768 }, 769 770 async applySetupNodeMode() { 771 const mode = ["wallet", "non-listening", "listening"].includes(this.setupNodeMode) 772 ? this.setupNodeMode 773 : "wallet"; 774 const acceptInbound = mode === "listening"; 775 if (this.p2pAcceptInbound !== acceptInbound) { 776 await this.submitForm("/api/settings/p2p-inbound", { 777 enabled: acceptInbound, 778 bind_port: this.p2pBindPortValue(), 779 }); 780 this.p2pAcceptInbound = acceptInbound; 781 } 782 this.setUiMode(mode === "wallet" ? "basic" : "advanced"); 783 }, 784 785 async refresh(options = {}) { 786 if (this.refreshPromise) { 787 if (options.force === true) { 788 try { 789 await this.refreshPromise; 790 } catch { 791 // The forced refresh below should report the current state. 792 } 793 } else { 794 return this.refreshPromise; 795 } 796 } 797 this.refreshPromise = this.refreshNow(options).finally(() => { 798 this.refreshPromise = null; 799 }); 800 return this.refreshPromise; 801 }, 802 803 async refreshNow(options = {}) { 804 if (!this.canUseProtectedApi()) return; 805 const addressBookVersion = this.addressBookVersion; 806 const tab = this.tab; 807 const shouldLoadBlocks = tab === "chain" || tab === "mining"; 808 const shouldLoadP2pMetrics = tab === "p2p" && this.developmentMode(); 809 const shouldLoadMetrics = tab === "metrics" || tab === "leaderboards"; 810 if (shouldLoadMetrics) { 811 await this.refreshMetrics(options); 812 this.refreshShellState({ addressBookVersion, silent: true }); 813 return; 814 } 815 if (shouldLoadBlocks && this.blocks.length === 0) this.loadingInitialBlocks = true; 816 const pagedDatasets = []; 817 if (tab === "wallet") pagedDatasets.push("walletTx", "walletUtxo"); 818 if (tab === "chain") pagedDatasets.push("mempool"); 819 if (tab === "p2p") pagedDatasets.push("peer"); 820 try { 821 const [config, status, networkHealth, blocks, p2pMetrics, blockchainMetrics] = await Promise.all([ 822 this.fetchJson("/api/config"), 823 this.fetchJson("/api/status"), 824 this.refreshNetworkHealth({ silent: options.silent === true }), 825 shouldLoadBlocks ? this.fetchJson("/api/blocks?limit=30") : Promise.resolve(null), 826 shouldLoadP2pMetrics ? this.fetchJson("/api/p2p/metrics") : Promise.resolve(this.p2pMetrics), 827 Promise.resolve(this.blockchainMetrics), 828 ]); 829 const previousChainHeight = this.status.chain?.height; 830 this.status = status; 831 if (networkHealth) { 832 this.networkHealth = networkHealth; 833 this.networkHealthLoaded = true; 834 } 835 this.config = config; 836 this.syncConfigState({ addressBookVersion }); 837 if (!this.allowedTabs().includes(this.tab)) { 838 this.setTab("dashboard"); 839 } 840 if (!this.config.setup_complete || status.network_migration?.required === true) { 841 await this.refreshWalletSetup(); 842 } 843 this.syncMempoolBlockMarker(previousChainHeight, status.chain?.height); 844 if (blocks) this.mergeFreshBlocks(blocks, { animateHead: true }); 845 this.pruneSelectedTransferUtxos(); 846 this.p2pMetrics = p2pMetrics; 847 this.blockchainMetrics = blockchainMetrics; 848 this.burnAmount = status.mining?.burn_per_block ?? this.burnAmount; 849 this.burnFee = status.mining?.automatic_burn_fee ?? this.burnFee; 850 this.miningEnabled = status.mining?.automatic ?? this.miningEnabled; 851 this.powMiningEnabled = status.mining?.pow_mining_enabled ?? this.powMiningEnabled; 852 this.powMiningWorkers = status.mining?.pow_mining_workers ?? this.powMiningWorkers; 853 this.maxPowMiningWorkers = 854 status.mining?.max_pow_mining_workers ?? this.maxPowMiningWorkers; 855 if (!this.burnAmountDirty) { 856 this.burnAmountDraft = this.amountLabel(this.burnAmount); 857 this.burnFeeDraft = this.amountLabel(this.burnFee); 858 } 859 this.lastUpdated = new Date(); 860 this.syncMiningEvents({ status, blocks }); 861 this.scheduleFeeEstimates(); 862 await Promise.all( 863 pagedDatasets.map((kind) => 864 this.refreshPagedDataset(kind, { silent: options.silent === true }) 865 ) 866 ); 867 } catch (error) { 868 if (String(error.message || "").includes("401")) { 869 this.stopPolling(); 870 await this.refreshAuth(); 871 return; 872 } 873 this.showFlash(error.message, "error"); 874 } finally { 875 if (shouldLoadBlocks) this.loadingInitialBlocks = false; 876 } 877 }, 878 879 async refreshShellState(options = {}) { 880 if (!this.canUseProtectedApi()) return; 881 if (this.shellRefreshPromise) return this.shellRefreshPromise; 882 const addressBookVersion = options.addressBookVersion ?? this.addressBookVersion; 883 this.shellRefreshPromise = Promise.all([ 884 this.fetchJson("/api/config"), 885 this.fetchJson("/api/status"), 886 this.refreshNetworkHealth({ silent: true }), 887 ]) 888 .then(async ([config, status, networkHealth]) => { 889 const previousChainHeight = this.status.chain?.height; 890 this.status = status; 891 if (networkHealth) { 892 this.networkHealth = networkHealth; 893 this.networkHealthLoaded = true; 894 } 895 this.config = config; 896 this.syncConfigState({ addressBookVersion }); 897 if (!this.allowedTabs().includes(this.tab)) { 898 this.setTab("dashboard"); 899 } 900 if (!this.config.setup_complete) { 901 await this.refreshWalletSetup(); 902 } 903 this.syncMempoolBlockMarker(previousChainHeight, status.chain?.height); 904 this.burnAmount = status.mining?.burn_per_block ?? this.burnAmount; 905 this.burnFee = status.mining?.automatic_burn_fee ?? this.burnFee; 906 this.miningEnabled = status.mining?.automatic ?? this.miningEnabled; 907 this.powMiningEnabled = status.mining?.pow_mining_enabled ?? this.powMiningEnabled; 908 this.powMiningWorkers = status.mining?.pow_mining_workers ?? this.powMiningWorkers; 909 this.maxPowMiningWorkers = 910 status.mining?.max_pow_mining_workers ?? this.maxPowMiningWorkers; 911 if (!this.burnAmountDirty) { 912 this.burnAmountDraft = this.amountLabel(this.burnAmount); 913 this.burnFeeDraft = this.amountLabel(this.burnFee); 914 } 915 this.lastUpdated = new Date(); 916 this.syncMiningEvents({ status, blocks: null }); 917 this.scheduleFeeEstimates(); 918 }) 919 .catch((error) => { 920 if (options.silent !== true) this.showFlash(error.message, "error"); 921 }) 922 .finally(() => { 923 this.shellRefreshPromise = null; 924 }); 925 return this.shellRefreshPromise; 926 }, 927 928 async refreshNetworkHealth(options = {}) { 929 if (!this.canUseProtectedApi()) return null; 930 if (this.networkHealthPromise) return this.networkHealthPromise; 931 this.networkHealthPromise = this.fetchJson("/api/network/health") 932 .then((networkHealth) => networkHealth) 933 .catch((error) => { 934 if (options.silent !== true) this.showFlash(error.message, "error"); 935 return null; 936 }) 937 .finally(() => { 938 this.networkHealthPromise = null; 939 }); 940 return this.networkHealthPromise; 941 }, 942 943 async fetchJson(path) { 944 const response = await this.fetchWithTimeout(path, { 945 headers: { Accept: "application/json" }, 946 cache: "no-store", 947 }); 948 if (!response.ok) { 949 throw new Error(`${path} returned ${response.status}`); 950 } 951 return response.json(); 952 }, 953 954 async fetchWithTimeout(path, options = {}) { 955 const controller = new AbortController(); 956 const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs); 957 try { 958 return await fetch(path, { ...options, signal: controller.signal }); 959 } catch (error) { 960 if (error?.name === "AbortError") { 961 throw new Error(`${path} timed out`); 962 } 963 throw error; 964 } finally { 965 clearTimeout(timeout); 966 } 967 }, 968 969 datasetConfig(kind) { 970 return { 971 walletTx: { 972 items: "walletTxs", 973 page: "walletTxPage", 974 path: () => this.walletTransactionsPath(), 975 key: (tx) => `${tx.status || ""}:${tx.signature || ""}`, 976 }, 977 walletUtxo: { 978 items: "walletUtxos", 979 page: "walletUtxoPage", 980 path: () => "/api/wallet/utxos", 981 key: (utxo) => this.utxoOutpoint(utxo), 982 }, 983 mempool: { 984 items: "mempool", 985 page: "mempoolPage", 986 path: () => "/api/mempool", 987 key: (tx) => tx.signature || "", 988 }, 989 peer: { 990 items: "peers", 991 page: "peerPage", 992 path: () => "/api/peers", 993 key: (peer) => peer.address || "", 994 }, 995 }[kind]; 996 }, 997 998 async resetPagedDataset(kind) { 999 const config = this.datasetConfig(kind); 1000 if (!config) return; 1001 this[config.items] = []; 1002 this.resetPageState(kind); 1003 await this.refreshPagedDataset(kind); 1004 }, 1005 1006 resetPageState(kind) { 1007 const config = this.datasetConfig(kind); 1008 if (!config) return; 1009 Object.assign(this[config.page], { 1010 offset: 0, 1011 total: 0, 1012 hasMore: true, 1013 loading: false, 1014 backgroundLoading: false, 1015 }); 1016 }, 1017 1018 async refreshPagedDataset(kind, options = {}) { 1019 if (!this.canUseProtectedApi()) return; 1020 const config = this.datasetConfig(kind); 1021 if (!config) return; 1022 const page = this[config.page]; 1023 if (page.loading || page.backgroundLoading) return; 1024 const currentLength = this[config.items].length; 1025 const limit = Math.max(this.datasetPageSize, currentLength || 0); 1026 await this.loadPagedDataset(kind, { 1027 offset: 0, 1028 limit, 1029 replace: true, 1030 silent: options.silent === true, 1031 }); 1032 }, 1033 1034 async loadNextPage(kind) { 1035 if (!this.canUseProtectedApi()) return; 1036 const config = this.datasetConfig(kind); 1037 if (!config) return; 1038 const page = this[config.page]; 1039 if (page.loading || page.backgroundLoading || !page.hasMore) return; 1040 await this.loadPagedDataset(kind, { 1041 offset: page.offset ?? this[config.items].length, 1042 limit: this.datasetPageSize, 1043 replace: false, 1044 }); 1045 }, 1046 1047 async loadPagedDataset(kind, options) { 1048 const config = this.datasetConfig(kind); 1049 const page = this[config.page]; 1050 const loadingKey = options.silent === true ? "backgroundLoading" : "loading"; 1051 page[loadingKey] = true; 1052 try { 1053 const payload = await this.fetchJson( 1054 this.paginatedPath(config.path(), options.offset, options.limit) 1055 ); 1056 const normalized = this.normalizedPage(payload, options.offset, options.limit); 1057 this[config.items] = options.replace 1058 ? normalized.items 1059 : this.mergeDatasetItems(this[config.items], normalized.items, config.key); 1060 if (kind === "mempool") { 1061 this.trackMempoolFirstSeenHeights({ append: options.replace !== true }); 1062 this.sortMempoolNewestFirst(); 1063 } 1064 page.offset = normalized.nextOffset ?? this[config.items].length; 1065 page.total = normalized.total; 1066 page.hasMore = normalized.hasMore; 1067 if (kind === "walletUtxo") { 1068 this.rememberUtxoAmounts(this.walletUtxos); 1069 this.pruneSelectedTransferUtxos(); 1070 } 1071 } catch (error) { 1072 this.showFlash(error.message, "error"); 1073 } finally { 1074 page[loadingKey] = false; 1075 } 1076 }, 1077 1078 paginatedPath(path, offset, limit) { 1079 const url = new URL(path, window.location.origin); 1080 url.searchParams.set("offset", String(offset)); 1081 url.searchParams.set("limit", String(limit)); 1082 return `${url.pathname}?${url.searchParams.toString()}`; 1083 }, 1084 1085 normalizedPage(payload, offset, limit) { 1086 if (Array.isArray(payload)) { 1087 const nextOffset = offset + payload.length; 1088 return { 1089 items: payload, 1090 total: nextOffset, 1091 hasMore: payload.length >= limit, 1092 nextOffset, 1093 }; 1094 } 1095 const items = Array.isArray(payload?.items) ? payload.items : []; 1096 return { 1097 items, 1098 total: Number(payload?.total ?? offset + items.length), 1099 hasMore: payload?.hasMore === true, 1100 nextOffset: payload?.nextOffset ?? offset + items.length, 1101 }; 1102 }, 1103 1104 mergeDatasetItems(existing, incoming, keyFn) { 1105 const rows = []; 1106 const seen = new Set(); 1107 for (const item of [...existing, ...incoming]) { 1108 const key = keyFn(item); 1109 if (!key || seen.has(key)) continue; 1110 seen.add(key); 1111 rows.push(item); 1112 } 1113 return rows; 1114 }, 1115 1116 syncMempoolBlockMarker(previousHeight, currentHeight) { 1117 const normalizedCurrent = Number(currentHeight); 1118 if (!Number.isFinite(normalizedCurrent)) return; 1119 const normalizedPrevious = Number(previousHeight); 1120 if (this.lastBlockMempoolHeight === null) { 1121 this.lastBlockMempoolHeight = normalizedCurrent; 1122 return; 1123 } 1124 if (!Number.isFinite(normalizedPrevious) || normalizedCurrent > normalizedPrevious) { 1125 this.lastBlockMempoolHeight = normalizedCurrent; 1126 } 1127 }, 1128 1129 trackMempoolFirstSeenHeights(options = {}) { 1130 const height = Number(this.status.chain?.height); 1131 if (!Number.isFinite(height)) return; 1132 const active = new Set(); 1133 const firstBatch = !this.mempoolSeenInitialized; 1134 const seenHeight = firstBatch ? height - 1 : height; 1135 const knownSeenTimes = Object.values(this.mempoolFirstSeenAt) 1136 .map((value) => Number(value)) 1137 .filter((value) => Number.isFinite(value)); 1138 const oldestSeenAt = knownSeenTimes.length ? Math.min(...knownSeenTimes) : Date.now(); 1139 const baseSeenAt = options.append && this.mempoolSeenInitialized 1140 ? oldestSeenAt - 1 1141 : Date.now(); 1142 let newIndex = 0; 1143 for (const tx of this.mempool) { 1144 const key = this.mempoolKey(tx); 1145 if (!key) continue; 1146 active.add(key); 1147 if (this.mempoolFirstSeenHeights[key] === undefined) { 1148 this.mempoolFirstSeenHeights[key] = seenHeight; 1149 this.mempoolFirstSeenAt[key] = baseSeenAt - newIndex; 1150 newIndex += 1; 1151 } 1152 } 1153 this.mempoolSeenInitialized = true; 1154 for (const key of Object.keys(this.mempoolFirstSeenHeights)) { 1155 if (!active.has(key)) { 1156 delete this.mempoolFirstSeenHeights[key]; 1157 delete this.mempoolFirstSeenAt[key]; 1158 } 1159 } 1160 }, 1161 1162 mempoolKey(tx) { 1163 return tx?.signature || ""; 1164 }, 1165 1166 mempoolItemClass(tx) { 1167 const key = this.mempoolKey(tx); 1168 const firstSeenHeight = Number(this.mempoolFirstSeenHeights[key]); 1169 const markerHeight = Number(this.status.chain?.height ?? this.lastBlockMempoolHeight); 1170 const classes = []; 1171 if (key && Number.isFinite(firstSeenHeight) && Number.isFinite(markerHeight)) { 1172 classes.push(firstSeenHeight >= markerHeight ? "new-since-block" : "before-last-block"); 1173 } 1174 return classes.join(" "); 1175 }, 1176 1177 mempoolSeenTimeLabel(tx) { 1178 const seenAt = Number(this.mempoolFirstSeenAt[this.mempoolKey(tx)]); 1179 if (!Number.isFinite(seenAt)) return ""; 1180 return `Seen ${new Date(seenAt).toLocaleTimeString()}`; 1181 }, 1182 1183 sortMempoolNewestFirst() { 1184 this.mempool = [...this.mempool].sort((left, right) => { 1185 const leftSeenAt = Number(this.mempoolFirstSeenAt[this.mempoolKey(left)]); 1186 const rightSeenAt = Number(this.mempoolFirstSeenAt[this.mempoolKey(right)]); 1187 if (Number.isFinite(leftSeenAt) && Number.isFinite(rightSeenAt) && leftSeenAt !== rightSeenAt) { 1188 return rightSeenAt - leftSeenAt; 1189 } 1190 const leftSeen = Number(this.mempoolFirstSeenHeights[this.mempoolKey(left)]); 1191 const rightSeen = Number(this.mempoolFirstSeenHeights[this.mempoolKey(right)]); 1192 if (Number.isFinite(leftSeen) && Number.isFinite(rightSeen) && leftSeen !== rightSeen) { 1193 return rightSeen - leftSeen; 1194 } 1195 return this.mempoolKey(right).localeCompare(this.mempoolKey(left)); 1196 }); 1197 }, 1198 1199 observePageSentinel(kind, element) { 1200 if (!element || element.__iunaPageObserver) return; 1201 const observer = new IntersectionObserver((entries) => { 1202 if (this.canUseProtectedApi() && entries.some((entry) => entry.isIntersecting)) { 1203 this.loadNextPage(kind); 1204 } 1205 }, { root: null, rootMargin: "180px 0px" }); 1206 observer.observe(element); 1207 element.__iunaPageObserver = observer; 1208 }, 1209 1210 observeBlockSentinel(element) { 1211 if (!element || element.__iunaBlockObserver) return; 1212 const observer = new IntersectionObserver((entries) => { 1213 if (this.canUseProtectedApi() && entries.some((entry) => entry.isIntersecting)) { 1214 this.loadOlderBlocks(); 1215 } 1216 }, { root: null, rootMargin: "180px 0px" }); 1217 observer.observe(element); 1218 element.__iunaBlockObserver = observer; 1219 }, 1220 1221 walletTransactionsPath() { 1222 const params = new URLSearchParams({ 1223 tx: String(this.walletTxFilters.transfer), 1224 mine: String(this.walletTxFilters.mine), 1225 burn: String(this.walletTxFilters.burn), 1226 reward: String(this.walletTxFilters.reward), 1227 }); 1228 return `/api/wallet/transactions?${params.toString()}`; 1229 }, 1230 1231 async refreshWalletTransactions() { 1232 await this.resetPagedDataset("walletTx"); 1233 }, 1234 1235 async checkLatestRelease() { 1236 if (this.releaseCheckState === "checking") return; 1237 this.releaseCheckState = "checking"; 1238 this.releaseCheckError = null; 1239 try { 1240 const response = await fetch(IUNA_RELEASE_METADATA_URL, { 1241 cache: "no-store", 1242 headers: { Accept: "application/json" }, 1243 }); 1244 if (!response.ok) { 1245 throw new Error(`Release check failed (${response.status})`); 1246 } 1247 const release = await response.json(); 1248 const version = this.normalizeVersion(release.tag || release.version); 1249 if (!version) { 1250 throw new Error("Release metadata is missing a version"); 1251 } 1252 let desktopVersion = null; 1253 const invoke = window.__TAURI__?.core?.invoke; 1254 if (typeof invoke === "function") { 1255 try { 1256 desktopVersion = this.normalizeVersion(await invoke("check_desktop_update")); 1257 } catch {} 1258 } 1259 this.latestRelease = { 1260 tag: `v${version}`, 1261 url: release.url || IUNA_DOWNLOADS_URL, 1262 desktopReady: desktopVersion === version, 1263 }; 1264 this.releaseCheckState = "done"; 1265 } catch (error) { 1266 this.releaseCheckError = error.message || "Release check failed"; 1267 this.releaseCheckState = "failed"; 1268 } 1269 }, 1270 1271 mergeFreshBlocks(freshBlocks, options = {}) { 1272 const hadBlocks = this.blocks.length > 0; 1273 const previousHeights = new Set(this.blocks.map((block) => block.height)); 1274 const previousHead = this.blocks[0]?.height; 1275 const previousHeadHash = this.blocks[0]?.hash; 1276 const wasFollowingHead = 1277 !this.selectedBlock || (previousHeadHash && this.selectedBlock.hash === previousHeadHash); 1278 const rail = this.$refs.blockRail; 1279 const previousScrollWidth = hadBlocks ? rail?.scrollWidth ?? 0 : 0; 1280 const known = new Map(this.blocks.map((block) => [block.hash, block])); 1281 for (const block of freshBlocks) { 1282 known.set(block.hash, block); 1283 } 1284 this.blocks = Array.from(known.values()).sort((left, right) => right.height - left.height); 1285 const currentHead = this.blocks[0] || null; 1286 if (wasFollowingHead) { 1287 this.selectedBlock = currentHead; 1288 } else if (!this.selectedBlock || !known.has(this.selectedBlock.hash)) { 1289 this.selectedBlock = this.blocks[0] || null; 1290 } else { 1291 this.selectedBlock = known.get(this.selectedBlock.hash); 1292 } 1293 this.hasMoreBlocks = 1294 this.blocks.some((block) => block.height > 0) && 1295 !this.blocks.some((block) => block.height === 0); 1296 1297 const newHeadBlocks = options.animateHead 1298 && hadBlocks 1299 ? this.blocks.filter( 1300 (block) => 1301 !previousHeights.has(block.height) && 1302 (typeof previousHead !== "number" || block.height > previousHead) 1303 ) 1304 : []; 1305 if (newHeadBlocks.length > 0) { 1306 this.markNewBlocks(newHeadBlocks.map((block) => block.hash)); 1307 this.$nextTick(() => 1308 this.slideNewHeadBlocks(previousScrollWidth, { force: wasFollowingHead }) 1309 ); 1310 } else if (!hadBlocks) { 1311 this.$nextTick(() => this.resetBlockRailPosition()); 1312 } 1313 this.$nextTick(() => this.maybeLoadOlderBlocksFromRail()); 1314 }, 1315 1316 markNewBlocks(hashes) { 1317 this.newBlockHashes = new Set(hashes); 1318 if (this.newBlockTimer) { 1319 clearTimeout(this.newBlockTimer); 1320 } 1321 this.newBlockTimer = setTimeout(() => { 1322 this.newBlockHashes = new Set(); 1323 this.newBlockTimer = null; 1324 }, 650); 1325 }, 1326 1327 slideNewHeadBlocks(previousScrollWidth, options = {}) { 1328 const rail = this.$refs.blockRail; 1329 if (!rail || previousScrollWidth === 0 || (!options.force && rail.scrollLeft > 4)) return; 1330 const addedWidth = rail.scrollWidth - previousScrollWidth; 1331 if (addedWidth <= 0) return; 1332 rail.scrollLeft = addedWidth; 1333 rail.scrollTo({ left: 0, behavior: "smooth" }); 1334 }, 1335 1336 resetBlockRailPosition() { 1337 const rail = this.$refs.blockRail; 1338 if (!rail) return; 1339 rail.scrollLeft = 0; 1340 }, 1341 1342 selectBlock(block) { 1343 this.selectedBlock = block; 1344 }, 1345 1346 openBurnLeaderRanksModal(block) { 1347 this.selectedBurnLeaderBlock = block; 1348 }, 1349 1350 closeBurnLeaderRanksModal() { 1351 this.selectedBurnLeaderBlock = null; 1352 }, 1353 1354 openBlockBytesModal(block) { 1355 this.selectedByteBlock = block; 1356 }, 1357 1358 closeBlockBytesModal() { 1359 this.selectedByteBlock = null; 1360 }, 1361 1362 openBurnBundleModal(block) { 1363 this.selectedBurnBundleBlock = block; 1364 }, 1365 1366 closeBurnBundleModal() { 1367 this.selectedBurnBundleBlock = null; 1368 }, 1369 1370 openTransactionModal(tx, context = {}) { 1371 this.selectedTransaction = { tx, context }; 1372 }, 1373 1374 openBlockRewardModal(block) { 1375 this.openTransactionModal(this.blockRewardTransaction(block), { 1376 source: "Block reward", 1377 blockHeight: block.height, 1378 blockFinalizer: block.miner, 1379 reward: true, 1380 }); 1381 }, 1382 1383 closeTransactionModal() { 1384 this.selectedTransaction = null; 1385 }, 1386 1387 openWalletUtxosModal() { 1388 this.showWalletUtxos = true; 1389 }, 1390 1391 closeWalletUtxosModal() { 1392 this.showWalletUtxos = false; 1393 }, 1394 1395 openWalletAddressesModal() { 1396 this.showWalletAddresses = true; 1397 }, 1398 1399 closeWalletAddressesModal() { 1400 this.showWalletAddresses = false; 1401 }, 1402 1403 openPowDifficultyInfo() { 1404 this.showPowDifficultyInfo = true; 1405 }, 1406 1407 closePowDifficultyInfo() { 1408 this.showPowDifficultyInfo = false; 1409 }, 1410 1411 openChainResetModal() { 1412 this.chainResetConfirm = ""; 1413 this.chainResetModalOpen = true; 1414 }, 1415 1416 closeChainResetModal() { 1417 if (this.chainResetBusy) return; 1418 this.chainResetModalOpen = false; 1419 this.chainResetConfirm = ""; 1420 }, 1421 1422 async resetLocalChain() { 1423 if (this.chainResetConfirm.trim() !== "RESET") { 1424 this.showFlash("Type RESET to confirm deleting the local chain", "error"); 1425 return; 1426 } 1427 this.chainResetBusy = true; 1428 try { 1429 await this.submitForm("/api/settings/chain-reset", { 1430 confirm: this.chainResetConfirm, 1431 }); 1432 this.blocks = []; 1433 this.selectedBlock = null; 1434 this.selectedByteBlock = null; 1435 this.selectedBurnLeaderBlock = null; 1436 this.selectedTransaction = null; 1437 this.mempool = []; 1438 this.walletTxs = []; 1439 this.walletUtxos = []; 1440 this.mempoolFirstSeenHeights = {}; 1441 this.mempoolFirstSeenAt = {}; 1442 this.mempoolSeenInitialized = false; 1443 this.lastBlockMempoolHeight = null; 1444 this.resetPageState("walletTx"); 1445 this.resetPageState("walletUtxo"); 1446 this.resetPageState("mempool"); 1447 this.chainResetModalOpen = false; 1448 this.chainResetConfirm = ""; 1449 await this.refresh({ force: true }); 1450 this.showFlash("Local chain deleted. Sync requested from peers.", "success"); 1451 } catch (error) { 1452 this.showFlash(error.message, "error"); 1453 } finally { 1454 this.chainResetBusy = false; 1455 } 1456 }, 1457 1458 closeModals() { 1459 this.closeDesktopUpdateModal(); 1460 this.closeOptimizeWallet(); 1461 this.closeSendConfirmModal(); 1462 this.closeTransactionModal(); 1463 this.closeWalletUtxosModal(); 1464 this.closeWalletAddressesModal(); 1465 this.closePowDifficultyInfo(); 1466 this.closeBurnLeaderRanksModal(); 1467 this.closeBurnBundleModal(); 1468 this.closeChainResetModal(); 1469 this.closePeerModal(); 1470 }, 1471 1472 async loadOlderBlocks() { 1473 if (!this.canUseProtectedApi()) return; 1474 if (this.loadingOlder || !this.hasMoreBlocks || this.blocks.length === 0) return; 1475 const oldest = Math.min(...this.blocks.map((block) => block.height)); 1476 if (oldest <= 0) { 1477 this.hasMoreBlocks = false; 1478 return; 1479 } 1480 this.loadingOlder = true; 1481 try { 1482 const older = await this.fetchJson( 1483 `/api/blocks?before_height=${oldest}&limit=${this.blockPageSize}` 1484 ); 1485 if ( 1486 older.length === 0 || 1487 older.length < this.blockPageSize || 1488 older.some((block) => block.height === 0) 1489 ) { 1490 this.hasMoreBlocks = false; 1491 } 1492 this.mergeFreshBlocks(older); 1493 } catch (error) { 1494 this.showFlash(error.message, "error"); 1495 } finally { 1496 this.loadingOlder = false; 1497 } 1498 }, 1499 1500 maybeLoadOlderBlocks(event) { 1501 this.maybeLoadOlderBlocksFromRail(event.currentTarget); 1502 }, 1503 1504 maybeLoadOlderBlocksFromRail(rail = this.$refs.blockRail) { 1505 if (this.tab !== "chain" || !rail || this.loadingOlder || !this.hasMoreBlocks) return; 1506 const remaining = rail.scrollWidth - rail.scrollLeft - rail.clientWidth; 1507 if (remaining <= 180) { 1508 this.loadOlderBlocks(); 1509 } 1510 }, 1511 1512 async postForm(path, fields, successMessage, method = "POST") { 1513 await this.submitForm(path, fields, method); 1514 await this.refresh({ force: true }); 1515 this.showFlash(successMessage, "success"); 1516 }, 1517 1518 async submitForm(path, fields, method = "POST") { 1519 const body = new URLSearchParams(); 1520 for (const [key, value] of Object.entries(fields)) { 1521 if (Array.isArray(value)) { 1522 for (const item of value) body.append(key, item); 1523 } else { 1524 body.set(key, value); 1525 } 1526 } 1527 const response = await this.fetchWithTimeout(path, { 1528 method, 1529 headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, 1530 body, 1531 }); 1532 const text = await response.text(); 1533 let payload = { ok: response.ok, error: null }; 1534 if (text) { 1535 try { 1536 payload = JSON.parse(text); 1537 } catch { 1538 payload = { ok: false, error: text }; 1539 } 1540 } 1541 if (!response.ok || !payload.ok) { 1542 throw new Error(payload?.error || `${path} returned ${response.status}`); 1543 } 1544 return payload; 1545 }, 1546 1547 scheduleFeeEstimates() { 1548 if (this.feeEstimateTimer) clearTimeout(this.feeEstimateTimer); 1549 this.feeEstimateTimer = setTimeout(() => this.refreshFeeEstimates(), 220); 1550 }, 1551 1552 async refreshFeeEstimates() { 1553 if (this.showingAuth()) return; 1554 if (this.tab === "wallet") { 1555 await this.refreshTransferFeeEstimate(); 1556 return; 1557 } 1558 if (this.tab === "mining") { 1559 await Promise.all([ 1560 this.refreshBurnFeeEstimate(), 1561 this.refreshMineFeeEstimate(), 1562 ]); 1563 } 1564 }, 1565 1566 async refreshBurnFeeEstimate() { 1567 const amount = this.parseiunaAmount(this.burnAmountDraft); 1568 const feePerByte = this.parseiunaAmount(this.burnFeeDraft); 1569 if (amount <= 0) { 1570 this.feeEstimates.burn = null; 1571 return; 1572 } 1573 this.feeEstimates.burn = await this.fetchFeeEstimate("/api/fee-estimate/burn", { 1574 amount, 1575 fee_per_byte: feePerByte, 1576 }); 1577 }, 1578 1579 async refreshMineFeeEstimate() { 1580 this.feeEstimates.mine = await this.fetchFeeEstimate("/api/fee-estimate/mine", {}); 1581 }, 1582 1583 async refreshTransferFeeEstimate() { 1584 const amount = this.parseiunaAmount(this.transferAmount); 1585 const feePerByte = this.parseiunaAmount(this.transferFee); 1586 if (!this.transferTo.trim() || amount <= 0) { 1587 this.feeEstimates.transfer = null; 1588 return; 1589 } 1590 this.feeEstimates.transfer = await this.fetchFeeEstimate("/api/fee-estimate/transfer", { 1591 to: this.transferTo, 1592 amount, 1593 fee_per_byte: feePerByte, 1594 utxos: this.selectedTransferUtxos.join("\n"), 1595 }); 1596 }, 1597 1598 async fetchFeeEstimate(path, fields) { 1599 try { 1600 const body = new URLSearchParams(); 1601 for (const [key, value] of Object.entries(fields)) body.set(key, value); 1602 const response = await this.fetchWithTimeout(path, { 1603 method: "POST", 1604 headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, 1605 body, 1606 }); 1607 const payload = await response.json(); 1608 if (!response.ok || !payload.ok) { 1609 return { error: payload.error || `${path} returned ${response.status}` }; 1610 } 1611 return payload; 1612 } catch (error) { 1613 return { error: error.message }; 1614 } 1615 }, 1616 1617 feeEstimateLabel(kind) { 1618 const estimate = this.feeEstimates[kind]; 1619 if (!estimate) return "Enter details to estimate fee"; 1620 if (estimate.error) return estimate.error; 1621 return `${estimate.bytes} bytes -> IUNA ${this.amountLabel(estimate.fee)}`; 1622 }, 1623 1624 feeEstimateError(kind) { 1625 return Boolean(this.feeEstimates[kind]?.error); 1626 }, 1627 1628 feeExceedsAmount(kind) { 1629 const estimate = this.feeEstimates[kind]; 1630 if (!estimate || estimate.error) return false; 1631 const amount = kind === "burn" 1632 ? this.parseiunaAmount(this.burnAmountDraft) 1633 : this.parseiunaAmount(this.transferAmount); 1634 const fee = Number(estimate.fee); 1635 return amount > 0 && Number.isFinite(fee) && fee > amount; 1636 }, 1637 1638 feeExceedsAmountLabel(kind) { 1639 if (!this.feeExceedsAmount(kind)) return ""; 1640 const label = kind === "burn" ? "burn" : "transfer"; 1641 const amount = kind === "burn" 1642 ? this.parseiunaAmount(this.burnAmountDraft) 1643 : this.parseiunaAmount(this.transferAmount); 1644 const fee = Number(this.feeEstimates[kind].fee); 1645 return `Warning: estimated fee IUNA ${this.amountLabel(fee)} exceeds the ${label} amount IUNA ${this.amountLabel(amount)}.`; 1646 }, 1647 1648 async saveBurn() { 1649 try { 1650 const amount = this.parseiunaAmount(this.burnAmountDraft); 1651 const fee = this.parseiunaAmountRequired(this.burnFeeDraft, "Burn fee per byte is required"); 1652 if (amount === 0) { 1653 throw new Error("IUNA per block must be greater than zero"); 1654 } 1655 this.burnAmountDraft = this.amountLabel(amount); 1656 this.burnFeeDraft = this.amountLabel(fee); 1657 await this.postForm( 1658 "/api/settings/burn-per-block", 1659 { enabled: this.miningEnabled, amount, fee_per_byte: fee }, 1660 this.miningEnabled 1661 ? `Finalization burns on: ${this.amountLabel(amount)} IUNA per block with ${this.amountLabel(fee)} per byte` 1662 : `Burn settings saved while off` 1663 ); 1664 this.appendMiningEvent("Burn settings saved", `Configured ${this.amountLabel(amount)} IUNA per block with ${this.amountLabel(fee)} IUNA fee/byte.`, "info"); 1665 this.burnAmountDirty = false; 1666 this.burnAmount = amount; 1667 this.burnFee = fee; 1668 } catch (error) { 1669 this.showFlash(error.message, "error"); 1670 } 1671 }, 1672 1673 async setMiningEnabled(enabled) { 1674 const previous = this.miningEnabled; 1675 try { 1676 const amount = this.parseiunaAmount(this.burnAmountDraft); 1677 const fee = this.parseiunaAmountRequired(this.burnFeeDraft, "Burn fee per byte is required"); 1678 if (enabled && amount === 0) { 1679 this.miningEnabled = false; 1680 throw new Error("Set IUNA per block before turning finalization burns on"); 1681 } 1682 this.miningEnabled = enabled; 1683 await this.postForm( 1684 "/api/settings/burn-per-block", 1685 { enabled, amount, fee_per_byte: fee }, 1686 enabled ? "Finalization burns turned on" : "Finalization burns turned off" 1687 ); 1688 this.appendMiningEvent( 1689 enabled ? "Finalization burns turned on" : "Finalization burns turned off", 1690 enabled 1691 ? `Burning ${this.amountLabel(amount)} IUNA per block with ${this.amountLabel(fee)} IUNA fee/byte.` 1692 : "Automatic burn preparation paused.", 1693 enabled ? "active" : "warning" 1694 ); 1695 this.miningEventState.pob = enabled ? "on" : "off"; 1696 this.burnAmountDirty = false; 1697 this.burnAmount = amount; 1698 this.burnFee = fee; 1699 } catch (error) { 1700 this.miningEnabled = previous; 1701 this.showFlash(error.message, "error"); 1702 } 1703 }, 1704 1705 async setPowMiningEnabled(enabled) { 1706 const previous = this.powMiningEnabled; 1707 try { 1708 this.powMiningEnabled = enabled; 1709 await this.postForm( 1710 "/api/settings/pow-mining", 1711 { enabled, workers: this.powMiningWorkers }, 1712 enabled ? "PoW mining turned on" : "PoW mining turned off" 1713 ); 1714 this.appendMiningEvent( 1715 enabled ? "PoW mining turned on" : "PoW mining turned off", 1716 enabled 1717 ? `Resource budget: ${this.powMiningWorkers} worker${this.powMiningWorkers === 1 ? "" : "s"}.` 1718 : "PoW worker search paused.", 1719 enabled ? "active" : "warning" 1720 ); 1721 this.miningEventState["pow-workers"] = String(this.powMiningWorkers); 1722 } catch (error) { 1723 this.powMiningEnabled = previous; 1724 this.showFlash(error.message, "error"); 1725 } 1726 }, 1727 1728 async setPowMiningWorkers(workers) { 1729 const previous = this.powMiningWorkers; 1730 const parsed = Number.parseInt(workers, 10); 1731 const clamped = Math.min( 1732 this.maxPowMiningWorkers, 1733 Math.max(1, Number.isFinite(parsed) ? parsed : 1) 1734 ); 1735 try { 1736 this.powMiningWorkers = clamped; 1737 await this.postForm( 1738 "/api/settings/pow-mining", 1739 { enabled: this.powMiningEnabled, workers: clamped }, 1740 `PoW workers set to ${clamped}` 1741 ); 1742 this.appendMiningEvent( 1743 "PoW worker budget changed", 1744 `Resource budget: ${clamped} worker${clamped === 1 ? "" : "s"}.`, 1745 "info" 1746 ); 1747 this.miningEventState["pow-workers"] = String(clamped); 1748 } catch (error) { 1749 this.powMiningWorkers = previous; 1750 this.showFlash(error.message, "error"); 1751 } 1752 }, 1753 1754 async setKeepTrackOfMetrics(enabled) { 1755 const previous = this.keepTrackOfMetrics; 1756 try { 1757 this.keepTrackOfMetrics = enabled; 1758 await this.postForm( 1759 "/api/settings/metrics", 1760 { enabled }, 1761 enabled ? "Development mode is preparing" : "Development mode turned off" 1762 ); 1763 await this.refreshConfig(); 1764 if (!enabled && (this.tab === "metrics" || this.tab === "leaderboards")) { 1765 this.setTab("settings"); 1766 } 1767 } catch (error) { 1768 this.keepTrackOfMetrics = previous; 1769 this.showFlash(error.message, "error"); 1770 } 1771 }, 1772 1773 async setRecoveryVdfTopRankPercent(percent) { 1774 const previous = this.recoveryVdfTopRankPercent; 1775 const normalized = Math.max(0, Math.min(100, Math.round(Number(percent) || 0))); 1776 try { 1777 this.recoveryVdfTopRankPercent = normalized; 1778 await this.postForm( 1779 "/api/settings/recovery-vdf", 1780 { top_rank_percent: String(normalized) }, 1781 `Fallback VDF threshold set to top ${normalized}%` 1782 ); 1783 await this.refreshConfig(); 1784 } catch (error) { 1785 this.recoveryVdfTopRankPercent = previous; 1786 this.showFlash(error.message, "error"); 1787 } 1788 }, 1789 1790 async setP2pAcceptInbound(enabled) { 1791 const previous = this.p2pAcceptInbound; 1792 const walletWasEnabled = this.walletEndpointEnabled; 1793 try { 1794 this.p2pAcceptInbound = enabled; 1795 await this.postForm( 1796 "/api/settings/p2p-inbound", 1797 { enabled, bind_port: this.p2pBindPortValue() }, 1798 enabled ? "Public node setting saved" : "Switched to outbound-only P2P" 1799 ); 1800 this.p2pBindPortDirty = false; 1801 await this.refreshConfig(); 1802 if (!enabled && walletWasEnabled && this.walletEndpointRestartRequired()) { 1803 this.showFlash(this.walletEndpointRestartMessage(), "success"); 1804 } 1805 } catch (error) { 1806 this.p2pAcceptInbound = previous; 1807 this.showFlash(error.message, "error"); 1808 } 1809 }, 1810 1811 p2pBindPortValue() { 1812 const port = Number(this.p2pBindPort); 1813 if (!Number.isInteger(port) || port < 1 || port > 65535) { 1814 throw new Error("P2P bind port must be between 1 and 65535"); 1815 } 1816 return port; 1817 }, 1818 1819 p2pConfiguredBindAddr() { 1820 const port = Number(this.config.p2p_bind_port || 9444); 1821 if (!Number.isInteger(port) || port < 1 || port > 65535) return null; 1822 return `0.0.0.0:${port}`; 1823 }, 1824 1825 p2pRestartRequired() { 1826 const runtimeActive = this.config.p2p_inbound_runtime_active === true; 1827 if (this.p2pAcceptInbound !== runtimeActive) return true; 1828 if (!this.p2pAcceptInbound) return false; 1829 const configured = this.p2pConfiguredBindAddr(); 1830 return configured ? this.config.p2p_runtime_bind_addr !== configured : false; 1831 }, 1832 1833 p2pRestartMessage() { 1834 if (!this.p2pRestartRequired()) return ""; 1835 if (!this.p2pAcceptInbound && this.config.p2p_inbound_runtime_active === true) { 1836 return "Restart iuna to close the public P2P listener."; 1837 } 1838 const configured = this.p2pConfiguredBindAddr(); 1839 return `Restart iuna to open public P2P on ${configured || "the configured bind port"}.`; 1840 }, 1841 1842 async setStratumEnabled(enabled) { 1843 const previous = this.stratumEnabled; 1844 try { 1845 this.stratumEnabled = enabled; 1846 await this.postForm( 1847 "/api/settings/stratum", 1848 { enabled, bind_port: this.stratumBindPortValue() }, 1849 enabled ? "Stratum endpoint setting saved" : "Stratum endpoint disabled" 1850 ); 1851 this.stratumBindPortDirty = false; 1852 await this.refreshConfig(); 1853 const restartMessage = this.stratumRestartMessage(); 1854 if (restartMessage) this.showFlash(restartMessage, "success"); 1855 } catch (error) { 1856 this.stratumEnabled = previous; 1857 this.showFlash(error.message, "error"); 1858 } 1859 }, 1860 1861 stratumBindPortValue() { 1862 const port = Number(this.stratumBindPort); 1863 if (!Number.isInteger(port) || port < 1 || port > 65535) { 1864 throw new Error("Stratum bind port must be between 1 and 65535"); 1865 } 1866 return port; 1867 }, 1868 1869 stratumConfiguredBindAddr() { 1870 const port = Number(this.config.stratum_bind_port || 3333); 1871 if (!Number.isInteger(port) || port < 1 || port > 65535) return null; 1872 return `0.0.0.0:${port}`; 1873 }, 1874 1875 stratumRestartRequired() { 1876 const runtimeActive = this.config.stratum_runtime_enabled === true; 1877 if (this.stratumEnabled !== runtimeActive) return true; 1878 if (!this.stratumEnabled) return false; 1879 const configured = this.stratumConfiguredBindAddr(); 1880 return configured ? this.config.stratum_runtime_listen_addr !== configured : false; 1881 }, 1882 1883 stratumRestartMessage() { 1884 if (!this.stratumRestartRequired()) return ""; 1885 if (!this.stratumEnabled && this.config.stratum_runtime_enabled === true) { 1886 return "Restart iuna to close the Stratum listener."; 1887 } 1888 const configured = this.stratumConfiguredBindAddr(); 1889 return `Restart iuna to open Stratum on ${configured || "the configured bind port"}.`; 1890 }, 1891 1892 async saveStratumSettings() { 1893 try { 1894 await this.postForm( 1895 "/api/settings/stratum", 1896 { enabled: this.stratumEnabled, bind_port: this.stratumBindPortValue() }, 1897 "Stratum endpoint setting saved" 1898 ); 1899 this.stratumBindPortDirty = false; 1900 await this.refreshConfig(); 1901 const restartMessage = this.stratumRestartMessage(); 1902 if (restartMessage) this.showFlash(restartMessage, "success"); 1903 } catch (error) { 1904 this.showFlash(error.message, "error"); 1905 } 1906 }, 1907 1908 async setWalletEndpointEnabled(enabled) { 1909 if (enabled && !this.p2pAcceptInbound) { 1910 this.showFlash("Enable Public node before enabling the wallet endpoint", "error"); 1911 return; 1912 } 1913 const previous = this.walletEndpointEnabled; 1914 try { 1915 this.walletEndpointEnabled = enabled; 1916 await this.postForm( 1917 "/api/settings/wallet-endpoint", 1918 { enabled, bind_port: this.walletEndpointBindPortValue() }, 1919 enabled ? "Wallet endpoint setting saved" : "Wallet endpoint disabled" 1920 ); 1921 this.walletEndpointBindPortDirty = false; 1922 await this.refreshConfig(); 1923 const restartMessage = this.walletEndpointRestartMessage(); 1924 if (restartMessage) this.showFlash(restartMessage, "success"); 1925 } catch (error) { 1926 this.walletEndpointEnabled = previous; 1927 this.showFlash(error.message, "error"); 1928 } 1929 }, 1930 1931 walletEndpointBindPortValue() { 1932 const port = Number(this.walletEndpointBindPort); 1933 if (!Number.isInteger(port) || port < 1 || port > 65535) { 1934 throw new Error("Wallet endpoint bind port must be between 1 and 65535"); 1935 } 1936 return port; 1937 }, 1938 1939 walletEndpointConfiguredBindAddr() { 1940 const port = Number(this.config.wallet_endpoint_bind_port || 18662); 1941 if (!Number.isInteger(port) || port < 1 || port > 65535) return null; 1942 return `0.0.0.0:${port}`; 1943 }, 1944 1945 walletEndpointRestartRequired() { 1946 const runtimeActive = this.config.wallet_endpoint_runtime_enabled === true; 1947 if (this.walletEndpointEnabled !== runtimeActive) return true; 1948 if (!this.walletEndpointEnabled) return false; 1949 const configured = this.walletEndpointConfiguredBindAddr(); 1950 return configured 1951 ? this.config.wallet_endpoint_runtime_listen_addr !== configured 1952 : false; 1953 }, 1954 1955 walletEndpointRestartMessage() { 1956 if (!this.walletEndpointRestartRequired()) return ""; 1957 if (!this.walletEndpointEnabled && this.config.wallet_endpoint_runtime_enabled === true) { 1958 return "Restart iuna to close the public wallet listener."; 1959 } 1960 const configured = this.walletEndpointConfiguredBindAddr(); 1961 return `Restart iuna to open the wallet API on ${configured || "the configured bind port"}.`; 1962 }, 1963 1964 async saveWalletEndpointSettings() { 1965 if (!this.p2pAcceptInbound) { 1966 this.showFlash("Enable Public node before configuring the wallet endpoint", "error"); 1967 return; 1968 } 1969 try { 1970 await this.postForm( 1971 "/api/settings/wallet-endpoint", 1972 { enabled: this.walletEndpointEnabled, bind_port: this.walletEndpointBindPortValue() }, 1973 "Wallet endpoint setting saved" 1974 ); 1975 this.walletEndpointBindPortDirty = false; 1976 await this.refreshConfig(); 1977 const restartMessage = this.walletEndpointRestartMessage(); 1978 if (restartMessage) this.showFlash(restartMessage, "success"); 1979 } catch (error) { 1980 this.showFlash(error.message, "error"); 1981 } 1982 }, 1983 1984 async saveP2pAnnounce() { 1985 if (!this.p2pAcceptInbound) { 1986 this.showFlash("Enable public node before setting a public P2P address", "error"); 1987 return; 1988 } 1989 const addr = this.p2pAnnounceAddr.trim(); 1990 try { 1991 if (this.p2pBindPortDirty) { 1992 await this.submitForm("/api/settings/p2p-inbound", { 1993 enabled: true, 1994 bind_port: this.p2pBindPortValue(), 1995 }); 1996 this.p2pBindPortDirty = false; 1997 } 1998 await this.postForm( 1999 "/api/settings/p2p-announce", 2000 { addr }, 2001 addr ? "P2P announce address saved" : "P2P announce address cleared" 2002 ); 2003 this.p2pAnnounceAddr = addr; 2004 this.p2pAnnounceDirty = false; 2005 await this.refreshConfig(); 2006 } catch (error) { 2007 this.showFlash(error.message, "error"); 2008 } 2009 }, 2010 2011 automaticBurnFeeDraft() { 2012 return this.parseiunaAmount(this.burnFeeDraft); 2013 }, 2014 2015 powMineReward() { 2016 return Math.max(0, Math.trunc(Number(this.status.chain?.mine_reward ?? 1000000))); 2017 }, 2018 2019 pobStatusLabel() { 2020 const mining = this.status.mining; 2021 if (!mining) return "-"; 2022 if (this.status.wallet_locked) return "Wallet locked"; 2023 if (!mining.automatic) return "Off"; 2024 if ((mining.burn_per_block ?? 0) <= 0) return "Anchor only"; 2025 if (mining.wallet_is_current_leader) return "Selected"; 2026 if (mining.current_leader) return "Waiting"; 2027 return "Recovery standby"; 2028 }, 2029 2030 powStatusShortLabel() { 2031 if (this.status.wallet_locked) return "Wallet locked"; 2032 if (!this.powMiningEnabled) return "Off"; 2033 const status = this.status.mining?.last_auto_pow_mine_status || ""; 2034 if (status.includes("queued")) return "Queued"; 2035 if (status.includes("searched")) return "Searching"; 2036 if (status.includes("waiting")) return "Waiting"; 2037 if (status.includes("failed")) return "Error"; 2038 return `${this.powMiningWorkers} worker${this.powMiningWorkers === 1 ? "" : "s"}`; 2039 }, 2040 2041 pobDetailLabel() { 2042 return this.status.mining?.last_auto_finalization_status || "Waiting for next automatic finalization tick"; 2043 }, 2044 2045 autoPowStatusLabel() { 2046 if (!this.powMiningEnabled) return "PoW mining is off"; 2047 const status = 2048 this.status.mining?.last_auto_pow_mine_status || "Waiting for next automatic PoW mining tick"; 2049 return `${status} (${this.powMiningWorkers} worker${this.powMiningWorkers === 1 ? "" : "s"})`; 2050 }, 2051 2052 currentFinalizerLabel() { 2053 const leader = this.currentFinalizerAddress(); 2054 if (!leader) return "-"; 2055 if (leader === this.status.wallet_address) return "you"; 2056 return this.shortAddressLabel(leader); 2057 }, 2058 2059 currentFinalizerAddress() { 2060 return this.status.mining?.current_leader ?? this.status.chain?.next_leader ?? null; 2061 }, 2062 2063 localMiningMempoolLabel() { 2064 const pending = this.status.chain?.pending_transactions; 2065 if (typeof pending !== "number") return "-"; 2066 const visibleMines = this.localMineActionCount(); 2067 return `${pending} pending / ${visibleMines} visible mines`; 2068 }, 2069 2070 powDifficultyLabel() { 2071 return this.status.chain?.current_mine_difficulty_bits ?? this.status.launch_profile?.mine_difficulty_bits ?? "-"; 2072 }, 2073 2074 localMineActionCount() { 2075 return this.mempool.filter((tx) => tx?.kind === "mine").length; 2076 }, 2077 2078 appendMiningEvent(title, detail, kind = "info", timestamp = new Date()) { 2079 const last = this.miningEvents[0]; 2080 if (last?.title === title && last?.detail === detail && last?.kind === kind) return; 2081 this.miningEventCounter += 1; 2082 const entry = { 2083 key: `${timestamp.getTime()}-${this.miningEventCounter}`, 2084 timestamp, 2085 time: timestamp.toLocaleTimeString(), 2086 kind, 2087 title, 2088 detail, 2089 }; 2090 this.miningEvents = [entry, ...this.miningEvents].slice(0, this.miningEventLimit); 2091 }, 2092 2093 isPowMineSuccessStatus(status) { 2094 return /queued mine action/i.test(status || ""); 2095 }, 2096 2097 syncMiningEvents({ status, blocks }) { 2098 const mining = status?.mining || {}; 2099 const chain = status?.chain || {}; 2100 if (!this.miningEventState.started) { 2101 this.appendMiningEvent( 2102 "Mining log started", 2103 `Height ${chain.height ?? "-"}, PoB ${mining.automatic ? "on" : "off"}, PoW ${mining.pow_mining_enabled ? "on" : "off"}.`, 2104 "info" 2105 ); 2106 this.miningEventState.started = true; 2107 } 2108 2109 this.noteMiningStateChange( 2110 "pob", 2111 mining.automatic ? "on" : "off", 2112 mining.automatic ? "Finalization burns active" : "Finalization burns inactive", 2113 mining.automatic 2114 ? `Burning ${this.amountLabel(mining.burn_per_block || 0)} IUNA per block with ${this.amountLabel(mining.automatic_burn_fee || 0)} IUNA fee/byte.` 2115 : "Automatic burn preparation is off.", 2116 mining.automatic ? "active" : "warning" 2117 ); 2118 const finalizationStatus = mining.last_auto_finalization_status || ""; 2119 this.noteMiningStateChange( 2120 "pob-status", 2121 finalizationStatus, 2122 "PoB status", 2123 finalizationStatus || "Waiting for next automatic finalization tick.", 2124 mining.automatic ? "active" : "info" 2125 ); 2126 this.noteMiningStateChange( 2127 "pow-workers", 2128 String(mining.pow_mining_workers ?? this.powMiningWorkers), 2129 "PoW worker budget", 2130 `Resource budget: ${mining.pow_mining_workers ?? this.powMiningWorkers} worker${(mining.pow_mining_workers ?? this.powMiningWorkers) === 1 ? "" : "s"}.`, 2131 "info" 2132 ); 2133 const powMineStatus = mining.last_auto_pow_mine_status || ""; 2134 if (this.isPowMineSuccessStatus(powMineStatus)) { 2135 this.noteMiningStateChange( 2136 "pow-mine-success", 2137 powMineStatus, 2138 "You mined a PoW action", 2139 `${powMineStatus}. Waiting for a finalizer to include it in a block.`, 2140 "active" 2141 ); 2142 } else { 2143 this.noteMiningStateChange( 2144 "pow-status", 2145 powMineStatus, 2146 "PoW status", 2147 powMineStatus || "Waiting for next automatic PoW mining tick.", 2148 mining.pow_mining_enabled ? "active" : "info" 2149 ); 2150 } 2151 this.noteMiningStateChange( 2152 "leader", 2153 mining.current_leader || "", 2154 mining.wallet_is_current_leader ? "This wallet is selected" : "Selected finalizer changed", 2155 mining.current_leader 2156 ? `Current finalizer: ${this.currentFinalizerLabel()} at height ${chain.height ?? "-"}.` 2157 : `No current finalizer reported at height ${chain.height ?? "-"}.`, 2158 mining.wallet_is_current_leader ? "active" : "info" 2159 ); 2160 if (typeof mining.last_auto_burn_height === "number") { 2161 this.noteMiningStateChange( 2162 "last-burn-height", 2163 String(mining.last_auto_burn_height), 2164 `Automatic burn prepared at height ${mining.last_auto_burn_height}`, 2165 "Eligible for the next block opportunity.", 2166 "active" 2167 ); 2168 } 2169 2170 const latestBlock = Array.isArray(blocks) 2171 ? blocks.find((block) => Number(block?.height) > 0) 2172 : this.blocks.find((block) => Number(block?.height) > 0); 2173 if (latestBlock) { 2174 const finalizer = this.addressLabel(latestBlock.miner); 2175 const locallyFinalized = latestBlock.miner === status.wallet_address; 2176 if (locallyFinalized) { 2177 this.noteMiningStateChange( 2178 "latest-local-block", 2179 latestBlock.hash || String(latestBlock.height), 2180 `You finalized block ${latestBlock.height}`, 2181 `Success. ${this.burnCountLabel(latestBlock)} burned, fees IUNA ${this.amountLabel(latestBlock.total_fees ?? latestBlock.totalFees ?? 0)}.`, 2182 "active", 2183 new Date(Number(latestBlock.timestamp_ms ?? latestBlock.timestampMs) || Date.now()) 2184 ); 2185 } 2186 if (!locallyFinalized) { 2187 this.noteMiningStateChange( 2188 "latest-block", 2189 latestBlock.hash || String(latestBlock.height), 2190 `Observed block ${latestBlock.height}`, 2191 `Finalized by ${finalizer}. ${this.burnCountLabel(latestBlock)} burned, fees IUNA ${this.amountLabel(latestBlock.total_fees ?? latestBlock.totalFees ?? 0)}.`, 2192 "active", 2193 new Date(Number(latestBlock.timestamp_ms ?? latestBlock.timestampMs) || Date.now()) 2194 ); 2195 } 2196 } 2197 }, 2198 2199 noteMiningStateChange(key, value, title, detail, kind = "info", timestamp = new Date()) { 2200 if (this.miningEventState[key] === value) return; 2201 this.miningEventState[key] = value; 2202 if (value === "" && key !== "pow-status" && key !== "leader") return; 2203 this.appendMiningEvent(title, detail, kind, timestamp); 2204 }, 2205 2206 miningEventLog() { 2207 return this.miningEvents; 2208 }, 2209 2210 metricsCharts() { 2211 return Array.isArray(this.blockchainMetrics?.charts) ? this.blockchainMetrics.charts : []; 2212 }, 2213 2214 metricsPreparing() { 2215 return this.blockchainMetrics?.preparing === true; 2216 }, 2217 2218 metricsLatest() { 2219 return this.blockchainMetrics?.latest || {}; 2220 }, 2221 2222 metricsLeaderboards() { 2223 return this.blockchainMetrics?.leaderboards || {}; 2224 }, 2225 2226 topMineProofRows() { 2227 const rows = this.blockchainMetrics?.topMineProofs; 2228 return Array.isArray(rows) ? rows : []; 2229 }, 2230 2231 leaderboardRows(kind) { 2232 const rows = this.metricsLeaderboards()?.[kind]; 2233 return Array.isArray(rows) ? rows : []; 2234 }, 2235 2236 leaderboardPlaceholderRanks(rowCount) { 2237 const count = Math.min(10, Math.max(0, Number(rowCount) || 0)); 2238 return Array.from({ length: 10 - count }, (_, index) => count + index); 2239 }, 2240 2241 leaderboardTitle(kind) { 2242 return { 2243 balances: "Top 10 Balance", 2244 miners: "Top 10 Miners", 2245 burners: "Top 10 Burners", 2246 }[kind] || "Leaderboard"; 2247 }, 2248 2249 leaderboardRankLabel(index) { 2250 return ["Gold", "Silver", "Bronze"][index] || `#${index + 1}`; 2251 }, 2252 2253 leaderboardRankClass(index) { 2254 return index < 3 ? `medal-${index + 1}` : ""; 2255 }, 2256 2257 leaderboardAmountLabel(row) { 2258 return `IUNA ${this.amountLabel(row?.amount || 0)}`; 2259 }, 2260 2261 leaderboardCountLabel(kind, row) { 2262 const count = Number(row?.count || 0); 2263 if (kind === "miners") return `${count} mine${count === 1 ? "" : "s"}`; 2264 if (kind === "burners") return `${count} burn${count === 1 ? "" : "s"}`; 2265 return `${count} UTXO${count === 1 ? "" : "s"}`; 2266 }, 2267 2268 metricsPath(range = this.metricsRange) { 2269 return range === "all" ? "/api/metrics" : `/api/metrics?limit=${range}`; 2270 }, 2271 2272 setMetricsRange(range) { 2273 this.metricsRange = range === 1000 || range === "all" ? range : 100; 2274 this.metricHover = null; 2275 try { 2276 localStorage.setItem("iunaMetricsRange", String(this.metricsRange)); 2277 } catch { 2278 // Non-persistent filtering is fine when storage is unavailable. 2279 } 2280 if (this.tab === "metrics") { 2281 this.refreshMetrics(); 2282 } 2283 }, 2284 2285 async fetchMetricsResponse(range = this.metricsRange) { 2286 return this.prepareMetricsResponse(await this.fetchJson(this.metricsPath(range))); 2287 }, 2288 2289 async refreshMetrics(options = {}) { 2290 if (!this.canUseProtectedApi()) return this.blockchainMetrics; 2291 const requestId = ++this.metricsRequestSeq; 2292 const range = this.metricsRange; 2293 if (this.metricsCharts().length === 0 && options.silent !== true) { 2294 this.loadingMetrics = true; 2295 } 2296 try { 2297 const metrics = await this.fetchMetricsResponse(range); 2298 if (requestId === this.metricsRequestSeq && this.metricsRange === range) { 2299 this.blockchainMetrics = metrics; 2300 } 2301 return metrics; 2302 } catch (error) { 2303 if (options.silent !== true) this.showFlash(error.message, "error"); 2304 return this.blockchainMetrics; 2305 } finally { 2306 if (requestId === this.metricsRequestSeq) { 2307 this.loadingMetrics = false; 2308 } 2309 } 2310 }, 2311 2312 prepareMetricsResponse(metrics) { 2313 const charts = Array.isArray(metrics?.charts) 2314 ? metrics.charts.map((chart) => this.prepareMetricChart(chart)) 2315 : []; 2316 return { ...(metrics || {}), charts }; 2317 }, 2318 2319 prepareMetricChart(chart) { 2320 const points = this.metricValidPoints(chart); 2321 const bounds = this.metricChartBoundsForPoints(points); 2322 const yTicks = this.metricYAxisTicksForPoints(points); 2323 const xTicks = this.metricXAxisTicksForPoints(points); 2324 const linePoints = points 2325 .map((point) => { 2326 const x = this.metricXAxisPositionFromBounds(bounds, Number(point.height)); 2327 const y = this.metricYAxisPositionFromBounds(bounds, Number(point.value)); 2328 return `${x.toFixed(1)},${y.toFixed(1)}`; 2329 }) 2330 .join(" "); 2331 const markers = points.map((point) => { 2332 const height = Number(point.height); 2333 const value = Number(point.value); 2334 return { 2335 height, 2336 value, 2337 x: this.metricXAxisPositionFromBounds(bounds, height), 2338 y: this.metricYAxisPositionFromBounds(bounds, value), 2339 }; 2340 }); 2341 const gridPath = [ 2342 ...yTicks.map((tick) => { 2343 const y = this.metricYAxisPositionFromBounds(bounds, Number(tick)).toFixed(1); 2344 return `M4 ${y} H296`; 2345 }), 2346 ...xTicks.map((tick) => { 2347 const x = this.metricXAxisPositionFromBounds(bounds, Number(tick)).toFixed(1); 2348 return `M${x} 8 V132`; 2349 }), 2350 ].join(" "); 2351 return { 2352 ...chart, 2353 _visiblePoints: points, 2354 _bounds: bounds, 2355 _yTicks: yTicks, 2356 _xTicks: xTicks, 2357 _linePoints: linePoints, 2358 _markers: markers, 2359 _gridPath: gridPath, 2360 }; 2361 }, 2362 2363 metricChartPoints(chart) { 2364 return chart?._linePoints || ""; 2365 }, 2366 2367 metricChartPointMarkers(chart) { 2368 return chart?._markers || []; 2369 }, 2370 2371 metricGridPath(chart) { 2372 return chart?._gridPath || ""; 2373 }, 2374 2375 metricValidPoints(chart) { 2376 const points = Array.isArray(chart?.points) ? chart.points : []; 2377 return points.filter((point) => Number.isFinite(Number(point.value))); 2378 }, 2379 2380 metricVisiblePoints(chart) { 2381 return chart?._visiblePoints || this.metricValidPoints(chart); 2382 }, 2383 2384 metricLatestValueLabel(chart) { 2385 const points = this.metricVisiblePoints(chart); 2386 if (points.length === 0) return "-"; 2387 return this.metricValueLabel(chart, points[points.length - 1].value); 2388 }, 2389 2390 metricChartBounds(chart) { 2391 return chart?._bounds || this.metricChartBoundsForPoints(this.metricVisiblePoints(chart)); 2392 }, 2393 2394 metricChartBoundsForPoints(points) { 2395 if (points.length === 0) { 2396 return { minHeight: 0, maxHeight: 1, minValue: 0, maxValue: 1 }; 2397 } 2398 const heights = points.map((point) => Number(point.height)); 2399 const values = points.map((point) => Number(point.value)); 2400 const valueTicks = this.niceTicks(Math.min(...values), Math.max(...values), 5); 2401 return { 2402 minHeight: Math.min(...heights), 2403 maxHeight: Math.max(...heights), 2404 minValue: Math.min(...valueTicks), 2405 maxValue: Math.max(...valueTicks), 2406 }; 2407 }, 2408 2409 metricYAxisTicks(chart) { 2410 return chart?._yTicks || this.metricYAxisTicksForPoints(this.metricVisiblePoints(chart)); 2411 }, 2412 2413 metricYAxisTicksForPoints(points) { 2414 if (points.length === 0) return []; 2415 const values = points.map((point) => Number(point.value)); 2416 return this.niceTicks(Math.min(...values), Math.max(...values), 5).reverse(); 2417 }, 2418 2419 metricXAxisTicks(chart) { 2420 return chart?._xTicks || this.metricXAxisTicksForPoints(this.metricVisiblePoints(chart)); 2421 }, 2422 2423 metricXAxisTicksForPoints(points) { 2424 if (points.length === 0) return []; 2425 const heights = points.map((point) => Number(point.height)); 2426 const minHeight = Math.min(...heights); 2427 const maxHeight = Math.max(...heights); 2428 if (minHeight === maxHeight) return [minHeight]; 2429 return this.niceTicks(minHeight, maxHeight, 5) 2430 .map((tick) => Math.round(tick)) 2431 .filter((tick) => tick >= minHeight && tick <= maxHeight) 2432 .filter((tick, index, ticks) => ticks.indexOf(tick) === index); 2433 }, 2434 2435 niceTicks(minValue, maxValue, maxTicks = 5) { 2436 const min = Number(minValue); 2437 const max = Number(maxValue); 2438 if (!Number.isFinite(min) || !Number.isFinite(max)) return []; 2439 if (min === max) { 2440 if (min === 0) return [0]; 2441 const step = this.niceTickStep(Math.abs(min) / Math.max(1, maxTicks - 1)); 2442 const tickMin = Math.floor(Math.min(0, min) / step) * step; 2443 const tickMax = Math.ceil(max / step) * step; 2444 return this.tickRange(tickMin, tickMax, step); 2445 } 2446 const range = this.niceTickStep((max - min) / Math.max(1, maxTicks - 1)); 2447 const tickMin = Math.floor(min / range) * range; 2448 const tickMax = Math.ceil(max / range) * range; 2449 return this.tickRange(tickMin, tickMax, range); 2450 }, 2451 2452 niceTickStep(value) { 2453 if (!Number.isFinite(value) || value <= 0) return 1; 2454 const exponent = Math.floor(Math.log10(value)); 2455 const fraction = value / Math.pow(10, exponent); 2456 const niceFraction = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10; 2457 return niceFraction * Math.pow(10, exponent); 2458 }, 2459 2460 tickRange(min, max, step) { 2461 if (!Number.isFinite(step) || step <= 0) return []; 2462 const precision = Math.max(0, Math.ceil(-Math.log10(step)) + 2); 2463 const ticks = []; 2464 for (let tick = min; tick <= max + step / 2; tick += step) { 2465 ticks.push(Number(tick.toFixed(precision))); 2466 if (ticks.length > 8) break; 2467 } 2468 return ticks; 2469 }, 2470 2471 metricYAxisPositionFromBounds(bounds, value) { 2472 const valueRange = Math.max(1, bounds.maxValue - bounds.minValue); 2473 return 132 - ((value - bounds.minValue) / valueRange) * 124; 2474 }, 2475 2476 metricXAxisPositionFromBounds(bounds, height) { 2477 const heightRange = Math.max(1, bounds.maxHeight - bounds.minHeight); 2478 return 4 + ((height - bounds.minHeight) / heightRange) * 292; 2479 }, 2480 2481 metricYAxisLabelStyle(chart, value) { 2482 const y = this.metricYAxisPositionFromBounds(this.metricChartBounds(chart), Number(value)); 2483 return `top: ${(y / 148) * 100}%`; 2484 }, 2485 2486 metricXAxisLabelStyle(chart, height) { 2487 const x = this.metricXAxisPositionFromBounds(this.metricChartBounds(chart), Number(height)); 2488 return `left: ${(x / 300) * 100}%`; 2489 }, 2490 2491 metricHoverPointStyle(chart) { 2492 const hover = this.metricHover; 2493 if (!hover || hover.chartId !== chart.id) return ""; 2494 return `left: ${(hover.x / 300) * 100}%; top: ${(hover.y / 148) * 100}%;`; 2495 }, 2496 2497 setMetricHover(chart, marker) { 2498 this.metricHover = { 2499 chartId: chart.id, 2500 height: marker.height, 2501 value: marker.value, 2502 x: marker.x, 2503 y: marker.y, 2504 label: this.metricPointLabel(chart, marker), 2505 }; 2506 }, 2507 2508 setMetricHoverFromPlot(chart, event) { 2509 const markers = this.metricChartPointMarkers(chart); 2510 if (markers.length === 0) { 2511 this.clearMetricHover(chart); 2512 return; 2513 } 2514 const rect = event.currentTarget.getBoundingClientRect(); 2515 const relativeX = Math.min(Math.max(event.clientX - rect.left, 0), rect.width); 2516 const chartX = (relativeX / Math.max(1, rect.width)) * 300; 2517 const nearest = markers.reduce((best, marker) => { 2518 const distance = Math.abs(marker.x - chartX); 2519 return !best || distance < best.distance ? { marker, distance } : best; 2520 }, null)?.marker; 2521 if (nearest) { 2522 this.setMetricHover(chart, nearest); 2523 } 2524 }, 2525 2526 clearMetricHover(chart) { 2527 if (this.metricHover?.chartId === chart.id) { 2528 this.metricHover = null; 2529 } 2530 }, 2531 2532 metricTooltipLabel(chart) { 2533 return this.metricHover?.chartId === chart.id ? this.metricHover.label : ""; 2534 }, 2535 2536 metricTooltipStyle(chart) { 2537 const hover = this.metricHover; 2538 if (!hover || hover.chartId !== chart.id) return ""; 2539 const left = (hover.x / 300) * 100; 2540 const top = (hover.y / 148) * 100; 2541 const xShift = hover.x > 238 ? "-100%" : hover.x < 62 ? "0" : "-50%"; 2542 const yShift = hover.y < 34 ? "12px" : "-115%"; 2543 return `left: ${left}%; top: ${top}%; transform: translate(${xShift}, ${yShift});`; 2544 }, 2545 2546 metricPointLabel(chart, point) { 2547 return `#${point.height}: ${this.metricValueLabel(chart, point.value)}`; 2548 }, 2549 2550 metricAxisValueLabel(chart, value) { 2551 const number = Number(value); 2552 if (!Number.isFinite(number)) return "-"; 2553 if (chart?.valueKind === "seconds") return `${this.compactNumber(number)}s`; 2554 if (chart?.valueKind === "bytes") return `${this.compactNumber(number)} B`; 2555 return this.compactNumber(number); 2556 }, 2557 2558 metricValueLabel(chart, value) { 2559 const number = Number(value); 2560 if (!Number.isFinite(number)) return "-"; 2561 if (chart?.valueKind === "iuna") return `IUNA ${this.compactNumber(number)}`; 2562 if (chart?.valueKind === "seconds") return `${this.compactNumber(number)} s`; 2563 if (chart?.valueKind === "bytes") return `${this.compactNumber(number)} bytes`; 2564 return `${this.compactNumber(number)}${chart?.unit ? ` ${chart.unit}` : ""}`; 2565 }, 2566 2567 compactNumber(value) { 2568 const number = Number(value); 2569 if (!Number.isFinite(number)) return "-"; 2570 if (Math.abs(number) >= 1000) { 2571 return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(number); 2572 } 2573 if (Number.isInteger(number)) return String(number); 2574 return number.toFixed(6).replace(/0+$/, "").replace(/\.$/, ""); 2575 }, 2576 2577 amountLabel(value) { 2578 const microiuna = this.microiunaAmount(value); 2579 const whole = Math.floor(microiuna / 1000000); 2580 const fractional = String(microiuna % 1000000).padStart(6, "0").replace(/0+$/, ""); 2581 return fractional ? `${whole}.${fractional}` : `${whole}`; 2582 }, 2583 2584 microiunaAmount(value) { 2585 return Math.max(0, Math.round(Number(value) || 0)); 2586 }, 2587 2588 metricAmountLabel(value) { 2589 return value === null || value === undefined ? "-" : `IUNA ${this.amountLabel(value)}`; 2590 }, 2591 2592 amountNumber(value) { 2593 return Number(this.amountLabel(value)); 2594 }, 2595 2596 parseiunaAmount(value) { 2597 const text = String(value ?? "").trim().replace(",", "."); 2598 if (!text) return 0; 2599 const match = text.match(/^(\d+)(?:\.(\d{0,6})\d*)?$/); 2600 if (!match) return 0; 2601 const whole = Number(match[1] || 0); 2602 const fractional = Number((match[2] || "").padEnd(6, "0")); 2603 return Math.max(0, Math.trunc(whole * 1000000 + fractional)); 2604 }, 2605 2606 parseiunaAmountRequired(value, message) { 2607 const text = String(value ?? "").trim().replace(",", "."); 2608 if (!text) throw new Error(message); 2609 const parsed = this.parseiunaAmount(text); 2610 if (parsed === 0 && !/^0(?:\.0*)?$/.test(text)) throw new Error(message); 2611 return parsed; 2612 }, 2613 2614 showOptimizeSuggestion() { 2615 if (this.optimizeDismissed || this.walletUtxoPage.total < 500) return false; 2616 try { 2617 return Date.now() > Number(localStorage.getItem(`iunaOptimizeLater:${this.status.wallet_address}`) || 0); 2618 } catch { return true; } 2619 }, 2620 2621 dismissOptimizeSuggestion() { 2622 this.optimizeDismissed = true; 2623 try { localStorage.setItem(`iunaOptimizeLater:${this.status.wallet_address}`, String(Date.now() + 7 * 86400000)); } catch {} 2624 }, 2625 2626 openOptimizeWallet() { 2627 this.showWalletUtxos = false; 2628 this.optimizeOpen = true; 2629 }, 2630 2631 closeOptimizeWallet() { 2632 if (this.optimizeRunning) return; 2633 this.optimizeOpen = false; 2634 }, 2635 2636 async previewOptimization() { 2637 if (this.optimizeBusy || this.optimizeRunning) return; 2638 this.optimizeBusy = true; 2639 this.optimizePlan = null; 2640 this.optimizeError = ""; 2641 this.optimizeMessage = ""; 2642 try { 2643 const rate = this.parseiunaAmountRequired(this.optimizeFee, "Enter a fee per byte"); 2644 if (!Number.isSafeInteger(rate) || rate < 1) throw new Error("Fee per byte must be at least 0.000001 IUNA"); 2645 const result = await this.submitForm("/api/wallet/optimize/preview", { 2646 fee_per_byte: rate, merge_roots: this.optimizeMergeRoots, 2647 }); 2648 const plan = result.plan; 2649 if (![plan.fee, ...plan.batches.flatMap(batch => [batch.fee, batch.amount])].every(Number.isSafeInteger)) { 2650 throw new Error("Amounts exceed the safe range for this interface"); 2651 } 2652 this.optimizePlan = { ...plan, rate, mergeRoots: this.optimizeMergeRoots }; 2653 } catch (error) { this.optimizeError = error.message; } 2654 finally { this.optimizeBusy = false; } 2655 }, 2656 2657 async previewQuantumMigration() { 2658 if (this.quantumMigrationBusy) return; 2659 this.quantumMigrationBusy = true; 2660 this.quantumMigrationPreview = null; 2661 this.quantumMigrationError = ""; 2662 try { 2663 const rate = this.parseiunaAmountRequired( 2664 this.quantumMigrationFee, 2665 "Enter a migration fee per byte" 2666 ); 2667 if (!Number.isSafeInteger(rate) || rate < 1) { 2668 throw new Error("Fee per byte must be at least 0.000001 IUNA"); 2669 } 2670 const result = await this.submitForm("/api/wallet/quantum-migration/preview", { 2671 fee_per_byte: rate, 2672 }); 2673 const preview = result.preview; 2674 if (![preview.fee, preview.amount, preview.bytes, preview.input_count, preview.remaining_legacy_utxos].every(Number.isSafeInteger)) { 2675 throw new Error("Migration values exceed the safe range for this interface"); 2676 } 2677 this.quantumMigrationPreview = { ...preview, rate }; 2678 } catch (error) { 2679 this.quantumMigrationError = error.message; 2680 } finally { 2681 this.quantumMigrationBusy = false; 2682 } 2683 }, 2684 2685 async submitQuantumMigration() { 2686 const preview = this.quantumMigrationPreview; 2687 if (!preview || this.quantumMigrationSubmitting || this.quantumMigrationBusy) return; 2688 this.quantumMigrationSubmitting = true; 2689 this.quantumMigrationError = ""; 2690 try { 2691 let result; 2692 try { 2693 result = await this.submitForm("/api/wallet/quantum-migration/submit", { 2694 fee_per_byte: preview.rate, 2695 max_fee: preview.fee, 2696 transaction_id: preview.transaction_id, 2697 }); 2698 } catch (error) { 2699 this.quantumMigrationPreview = null; 2700 throw new Error(`${error.message}. Check wallet activity before requesting a new preview.`); 2701 } 2702 this.quantumMigrationPreview = null; 2703 if (result.persistence_error) { 2704 throw new Error("Migration queued locally, but durable recovery failed. Keep this node running and check its storage before continuing."); 2705 } 2706 if (result.broadcast_error) { 2707 throw new Error("Migration saved locally, but broadcasting failed. It will be rebroadcast automatically; check connectivity before continuing."); 2708 } 2709 const remainder = Number(result.remaining_legacy_utxos || 0); 2710 this.showFlash( 2711 remainder > 0 2712 ? `Migration batch queued. ${remainder} legacy UTXOs remain after confirmation.` 2713 : "Wallet migration queued.", 2714 "success" 2715 ); 2716 await this.refresh({ force: true }); 2717 } catch (error) { 2718 this.quantumMigrationError = error.message; 2719 } finally { 2720 this.quantumMigrationSubmitting = false; 2721 } 2722 }, 2723 2724 async runOptimization() { 2725 const plan = this.optimizePlan; 2726 if (!plan || this.optimizeRunning || this.optimizeBusy) return; 2727 this.optimizeRunning = true; 2728 this.optimizeError = ""; 2729 try { 2730 for (let batchIndex = 0; batchIndex < plan.batches.length; batchIndex += 1) { 2731 if (this.status.wallet_locked || this.status.wallet_address !== plan.address) { 2732 throw new Error("Wallet locked or changed. Unlock the original wallet and request a new preview."); 2733 } 2734 const batch = plan.batches[batchIndex]; 2735 this.optimizeMessage = `Submitting batch ${batchIndex + 1} of ${plan.batches.length}…`; 2736 let result; 2737 try { 2738 result = await this.submitForm("/api/wallet/optimize/submit", { 2739 address: plan.address, fee_per_byte: plan.rate, max_fee: batch.fee, 2740 merge_roots: plan.mergeRoots, utxos: JSON.stringify(batch.utxos), 2741 }); 2742 if (!result.signature) throw new Error("No transaction confirmation received from the node"); 2743 } catch (error) { 2744 // A timeout may mean the transaction was accepted. Never retry it automatically. 2745 this.optimizePlan = null; 2746 throw new Error(`${error.message}. Check wallet activity before requesting a new preview.`); 2747 } 2748 if (result.broadcast_error) { 2749 this.optimizePlan = null; 2750 throw new Error("Batch queued locally, but broadcasting failed. Check connectivity before requesting a new preview."); 2751 } 2752 } 2753 this.optimizeMessage = `Optimization submitted. Expected after confirmation: ${plan.before - plan.after} fewer balance parts; network fees ${this.amountLabel(plan.fee)} IUNA.`; 2754 this.optimizePlan = null; 2755 this.optimizeOpen = false; 2756 this.showFlash(this.optimizeMessage, "success"); 2757 await this.refresh({ force: true }); 2758 } catch (error) { 2759 this.optimizePlan = null; 2760 this.optimizeError = error.message; 2761 } 2762 finally { this.optimizeRunning = false; } 2763 }, 2764 2765 async sendTransfer() { 2766 if (this.sendPreparing || this.sendConfirmBusy) return; 2767 this.sendPreparing = true; 2768 try { 2769 const amount = this.parseiunaAmountRequired(this.transferAmount, "Transfer amount is required"); 2770 if (amount <= 0) throw new Error("Transfer amount must be greater than zero"); 2771 const fee = this.parseiunaAmountRequired(this.transferFee, "Transfer fee per byte is required"); 2772 const fullRecipient = this.transferTo.trim(); 2773 if (!fullRecipient) throw new Error("Recipient is required"); 2774 const estimate = await this.transferFeeEstimateForAmount(amount); 2775 this.feeEstimates.transfer = estimate; 2776 this.pendingTransfer = { 2777 recipient: fullRecipient, 2778 amount, 2779 feePerByte: fee, 2780 bytes: Number(estimate.bytes), 2781 fee: this.microiunaAmount(estimate.fee), 2782 utxos: this.hybridTransferRecipient() ? "" : this.selectedTransferUtxos.join("\n"), 2783 }; 2784 this.sendConfirmModalOpen = true; 2785 } catch (error) { 2786 this.showFlash(error.message, "error"); 2787 } finally { 2788 this.sendPreparing = false; 2789 } 2790 }, 2791 2792 closeSendConfirmModal() { 2793 if (this.sendConfirmBusy) return; 2794 this.sendConfirmModalOpen = false; 2795 this.pendingTransfer = null; 2796 }, 2797 2798 async confirmTransfer() { 2799 const transfer = this.pendingTransfer; 2800 if (!transfer || this.sendConfirmBusy) return; 2801 this.sendConfirmBusy = true; 2802 try { 2803 const recipient = this.short(transfer.recipient); 2804 await this.postForm( 2805 "/api/transfer", 2806 { 2807 to: transfer.recipient, 2808 amount: transfer.amount, 2809 fee_per_byte: transfer.feePerByte, 2810 utxos: transfer.utxos, 2811 }, 2812 `Queued transfer of ${this.amountLabel(transfer.amount)} IUNA to ${recipient}` 2813 ); 2814 this.sendConfirmModalOpen = false; 2815 this.pendingTransfer = null; 2816 this.transferTo = ""; 2817 this.transferAmount = null; 2818 this.selectedTransferUtxos = []; 2819 this.selectedTransferUtxoAmounts = {}; 2820 this.showSendAdvanced = false; 2821 this.feeEstimates.transfer = null; 2822 } catch (error) { 2823 this.showFlash(error.message, "error"); 2824 } finally { 2825 this.sendConfirmBusy = false; 2826 } 2827 }, 2828 2829 transferMaxDisabled() { 2830 if (this.hybridTransferRecipient()) { 2831 return Number(this.status.quantum_migration?.hybrid_balance || 0) <= 0; 2832 } 2833 return this.selectedTransferUtxoTotal() <= 0 && Number(this.status.wallet_balance || 0) <= 0; 2834 }, 2835 2836 hybridTransferRecipient() { 2837 return /^(?:iuna|tiuna)1p/i.test(this.transferTo.trim()); 2838 }, 2839 2840 transferRecipientChanged() { 2841 if (this.hybridTransferRecipient()) { 2842 this.showSendAdvanced = false; 2843 this.selectedTransferUtxos = []; 2844 this.selectedTransferUtxoAmounts = {}; 2845 } 2846 this.scheduleFeeEstimates(); 2847 }, 2848 2849 async setMaxTransferAmount() { 2850 try { 2851 let selectedTotal = this.hybridTransferRecipient() 2852 ? Number(this.status.quantum_migration?.hybrid_balance || 0) 2853 : this.selectedTransferUtxoTotal(); 2854 if (!this.hybridTransferRecipient() && this.selectedTransferUtxos.length === 0) { 2855 const utxos = await this.fetchJson("/api/wallet/utxos/selectable"); 2856 this.rememberUtxoAmounts(utxos); 2857 this.selectedTransferUtxos = utxos.map((utxo) => this.utxoOutpoint(utxo)); 2858 this.lastSelectedTransferUtxo = 2859 this.selectedTransferUtxos[this.selectedTransferUtxos.length - 1] || null; 2860 selectedTotal = utxos.reduce((sum, utxo) => sum + Number(utxo.amount || 0), 0); 2861 } 2862 2863 if (selectedTotal <= 0) { 2864 this.showFlash("No spendable UTXOs", "error"); 2865 return; 2866 } 2867 2868 const amount = await this.maxTransferAmountForSelectedUtxos(selectedTotal); 2869 this.transferAmount = this.amountLabel(amount); 2870 this.scheduleFeeEstimates(); 2871 } catch (error) { 2872 this.showFlash(error.message, "error"); 2873 } 2874 }, 2875 2876 async maxTransferAmountForSelectedUtxos(selectedTotal) { 2877 const total = this.microiunaAmount(selectedTotal); 2878 let low = 1; 2879 let high = total; 2880 let bestAmount = 0; 2881 let bestEstimate = null; 2882 2883 while (low <= high) { 2884 const amount = Math.floor((low + high) / 2); 2885 try { 2886 const estimate = await this.transferFeeEstimateForAmount(amount); 2887 const required = amount + this.microiunaAmount(estimate.fee); 2888 if (required <= total) { 2889 bestAmount = amount; 2890 bestEstimate = estimate; 2891 low = amount + 1; 2892 } else { 2893 high = amount - 1; 2894 } 2895 } catch { 2896 high = amount - 1; 2897 } 2898 } 2899 2900 if (bestAmount <= 0 || !bestEstimate) { 2901 throw new Error("Selected UTXOs do not cover amount plus fee"); 2902 } 2903 this.feeEstimates.transfer = bestEstimate; 2904 return bestAmount; 2905 }, 2906 2907 async transferFeeEstimateForAmount(amount) { 2908 const recipient = this.transferTo.trim() || this.status.wallet_address; 2909 if (!recipient) { 2910 throw new Error("Recipient is required before max can estimate fees"); 2911 } 2912 const estimate = await this.fetchFeeEstimate("/api/fee-estimate/transfer", { 2913 to: recipient, 2914 amount, 2915 fee_per_byte: this.parseiunaAmount(this.transferFee), 2916 utxos: this.hybridTransferRecipient() ? "" : this.selectedTransferUtxos.join("\n"), 2917 }); 2918 if ( 2919 estimate?.error 2920 || !Number.isFinite(Number(estimate?.fee)) 2921 || !Number.isInteger(Number(estimate?.bytes)) 2922 || Number(estimate.bytes) <= 0 2923 ) { 2924 throw new Error(estimate?.error || "Could not estimate transfer fee"); 2925 } 2926 return estimate; 2927 }, 2928 2929 toggleSendAdvanced() { 2930 this.showSendAdvanced = !this.showSendAdvanced; 2931 if (!this.showSendAdvanced) { 2932 this.selectedTransferUtxos = []; 2933 } 2934 }, 2935 2936 async addPeer() { 2937 try { 2938 const peer = this.peerAddress.trim(); 2939 await this.postForm("/api/peers", { peer }, `Added peer ${peer}`); 2940 this.peerAddress = ""; 2941 } catch (error) { 2942 this.showFlash(error.message, "error"); 2943 } 2944 }, 2945 2946 async removePeer(peer) { 2947 try { 2948 await this.postForm("/api/peers", { peer: peer.address }, `Removed peer ${peer.address}`, "DELETE"); 2949 if (this.selectedPeerAddress === peer.address) this.closePeerModal(); 2950 } catch (error) { 2951 this.showFlash(error.message, "error"); 2952 } 2953 }, 2954 2955 openPeerModal(peer) { 2956 this.selectedPeerAddress = peer?.address || null; 2957 }, 2958 2959 closePeerModal() { 2960 this.selectedPeerAddress = null; 2961 }, 2962 2963 peerDetail() { 2964 return this.peers.find((peer) => peer.address === this.selectedPeerAddress) || null; 2965 }, 2966 2967 peerCapabilities(peer) { 2968 return Array.isArray(peer?.last_hello?.capabilities) ? peer.last_hello.capabilities : []; 2969 }, 2970 2971 peerTimestampLabel(timestampMs) { 2972 if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs) || timestampMs <= 0) return "-"; 2973 return `${new Date(timestampMs).toLocaleString()} (${this.relativeTimeLabel(timestampMs)})`; 2974 }, 2975 2976 addressBookEntries() { 2977 return Object.entries(this.addressBook || {}) 2978 .map(([address, name]) => ({ address, name })) 2979 .sort((left, right) => left.name.localeCompare(right.name) || left.address.localeCompare(right.address)); 2980 }, 2981 2982 validAddressBookAddress(address) { 2983 const text = String(address ?? "").trim(); 2984 const hasLower = /[a-z]/.test(text); 2985 const hasUpper = /[A-Z]/.test(text); 2986 if (hasLower && hasUpper) return false; 2987 const normalized = text.toLowerCase(); 2988 const prefix = this.setupAddress().startsWith("tiuna1") ? "tiuna1" : "iuna1"; 2989 return normalized.startsWith(prefix) && /^[02-9ac-hj-np-z]{59}$/.test(normalized.slice(prefix.length)); 2990 }, 2991 2992 selectTransferContact(address) { 2993 if (!address) return; 2994 this.transferTo = address; 2995 this.scheduleFeeEstimates(); 2996 this.closeAddressBookPicker(); 2997 }, 2998 2999 openAddressBookModal(entry = null, standalone = true) { 3000 this.addressBookEditingAddress = entry?.address || null; 3001 this.addressBookDraftAddress = entry?.address || ""; 3002 this.addressBookDraftName = entry?.name || ""; 3003 this.addressBookStandalone = standalone; 3004 this.addressBookPickerOpen = true; 3005 this.addressBookModalOpen = true; 3006 }, 3007 3008 openAddressContact(address) { 3009 const value = String(address ?? "").trim(); 3010 const publicAddress = this.contactAddress(value); 3011 if (!publicAddress) return; 3012 const canonical = this.canonicalAddressKey(value); 3013 const entry = this.addressBookEntries().find( 3014 (candidate) => this.canonicalAddressKey(candidate.address) === canonical, 3015 ); 3016 this.addressBookEditingAddress = entry?.address || null; 3017 this.addressBookDraftAddress = entry && this.validAddressBookAddress(entry.address) 3018 ? entry.address 3019 : publicAddress; 3020 this.addressBookDraftName = entry?.name || ""; 3021 this.addressBookStandalone = true; 3022 this.addressBookPickerOpen = true; 3023 this.addressBookModalOpen = true; 3024 }, 3025 3026 contactAddress(address) { 3027 const normalized = String(address ?? "").trim().toLowerCase(); 3028 if (this.validAddressBookAddress(normalized)) return normalized; 3029 if (!/^[0-9a-f]{64}$/.test(normalized)) return null; 3030 3031 const hrp = this.setupAddress().startsWith("tiuna1") ? "tiuna" : "iuna"; 3032 const charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; 3033 const bytes = normalized.match(/../g).map((pair) => Number.parseInt(pair, 16)); 3034 const data = [0]; 3035 let accumulator = 0; 3036 let bits = 0; 3037 for (const byte of bytes) { 3038 accumulator = ((accumulator << 8) | byte) & 0xfff; 3039 bits += 8; 3040 while (bits >= 5) { 3041 bits -= 5; 3042 data.push((accumulator >> bits) & 31); 3043 } 3044 } 3045 if (bits > 0) data.push((accumulator << (5 - bits)) & 31); 3046 3047 const values = [ 3048 ...[...hrp].map((character) => character.charCodeAt(0) >> 5), 3049 0, 3050 ...[...hrp].map((character) => character.charCodeAt(0) & 31), 3051 ...data, 3052 0, 0, 0, 0, 0, 0, 3053 ]; 3054 const generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; 3055 let polymod = 1; 3056 for (const value of values) { 3057 const top = polymod >>> 25; 3058 polymod = (((polymod & 0x1ffffff) << 5) ^ value) >>> 0; 3059 for (let index = 0; index < generators.length; index += 1) { 3060 if ((top >>> index) & 1) polymod = (polymod ^ generators[index]) >>> 0; 3061 } 3062 } 3063 polymod = (polymod ^ 0x2bc830a3) >>> 0; 3064 const checksum = Array.from({ length: 6 }, (_, index) => (polymod >>> (5 * (5 - index))) & 31); 3065 return `${hrp}1${[...data, ...checksum].map((value) => charset[value]).join("")}`; 3066 }, 3067 3068 hasWalletAddress(address) { 3069 return this.contactAddress(address) !== null; 3070 }, 3071 3072 closeAddressBookModal() { 3073 const closePicker = this.addressBookStandalone; 3074 this.addressBookModalOpen = false; 3075 this.addressBookStandalone = false; 3076 this.addressBookEditingAddress = null; 3077 this.addressBookDraftAddress = ""; 3078 this.addressBookDraftName = ""; 3079 if (closePicker) this.addressBookPickerOpen = false; 3080 }, 3081 3082 openAddressBookPicker() { 3083 this.addressBookStandalone = false; 3084 this.addressBookPickerOpen = true; 3085 }, 3086 3087 closeAddressBookPicker() { 3088 this.addressBookPickerOpen = false; 3089 this.closeAddressBookModal(); 3090 }, 3091 3092 async saveAddressBookEntry() { 3093 const address = this.addressBookDraftAddress.trim(); 3094 const name = this.addressBookDraftName.trim(); 3095 if (!address || !name) { 3096 this.showFlash("Address and name are required", "error"); 3097 return; 3098 } 3099 if (!this.validAddressBookAddress(address)) { 3100 this.showFlash("Address must be a Bech32m address for this network", "error"); 3101 return; 3102 } 3103 const canonicalAddress = address.toLowerCase(); 3104 const oldAddress = this.addressBookEditingAddress; 3105 if (this.addressBook?.[canonicalAddress] && canonicalAddress !== oldAddress) { 3106 this.showFlash("Address is already saved", "error"); 3107 return; 3108 } 3109 try { 3110 const fields = oldAddress ? { address, name, old_address: oldAddress } : { address, name }; 3111 await this.submitForm("/api/address-book", fields); 3112 this.addressBookVersion += 1; 3113 const nextBook = { ...(this.addressBook || {}) }; 3114 if (oldAddress && oldAddress !== canonicalAddress) delete nextBook[oldAddress]; 3115 nextBook[canonicalAddress] = name; 3116 this.addressBook = nextBook; 3117 this.config = { ...this.config, address_book: this.addressBook }; 3118 this.closeAddressBookModal(); 3119 this.showFlash(`Saved ${name}`, "success"); 3120 } catch (error) { 3121 this.showFlash(error.message, "error"); 3122 } 3123 }, 3124 3125 editAddressBookEntry(entry) { 3126 this.openAddressBookModal(entry); 3127 }, 3128 3129 async removeAddressBookEntry(entry) { 3130 try { 3131 await this.submitForm("/api/address-book", { address: entry.address }, "DELETE"); 3132 this.addressBookVersion += 1; 3133 const nextBook = { ...(this.addressBook || {}) }; 3134 delete nextBook[entry.address]; 3135 this.addressBook = nextBook; 3136 this.config = { ...this.config, address_book: nextBook }; 3137 if (this.addressBookEditingAddress === entry.address) this.closeAddressBookModal(); 3138 this.showFlash(`Removed ${entry.name}`, "success"); 3139 } catch (error) { 3140 this.showFlash(error.message, "error"); 3141 } 3142 }, 3143 3144 async copyAddress() { 3145 try { 3146 await navigator.clipboard.writeText(this.setupAddress()); 3147 this.showFlash("Address copied", "success"); 3148 } catch (error) { 3149 this.showFlash("Could not copy address", "error"); 3150 } 3151 }, 3152 3153 async copyReceiveAddress() { 3154 try { 3155 await navigator.clipboard.writeText(this.receiveAddress()); 3156 this.showFlash("Address copied", "success"); 3157 } catch (error) { 3158 this.showFlash("Could not copy address", "error"); 3159 } 3160 }, 3161 3162 showFlash(message, kind) { 3163 this.flash = { message, kind }; 3164 if (this.flashTimer) { 3165 clearTimeout(this.flashTimer); 3166 } 3167 this.flashTimer = setTimeout(() => { 3168 this.flash = null; 3169 this.flashTimer = null; 3170 }, kind === "error" ? 7000 : 3500); 3171 }, 3172 3173 showSetupFeedback(message, kind) { 3174 this.setupFeedback = { message, kind }; 3175 }, 3176 3177 showAuthFeedback(message, kind) { 3178 this.authFeedback = { message, kind }; 3179 }, 3180 3181 showSettingsFeedback(message, kind) { 3182 this.settingsFeedback = { message, kind }; 3183 }, 3184 3185 short(value) { 3186 if (!value) return "-"; 3187 if (value.length <= 16) return value; 3188 return `${value.slice(0, 8)}...${value.slice(-8)}`; 3189 }, 3190 3191 canonicalAddressKey(address) { 3192 const normalized = String(address ?? "").trim().toLowerCase(); 3193 if (/^[0-9a-f]{64}$/.test(normalized)) return normalized; 3194 3195 const separator = normalized.lastIndexOf("1"); 3196 const hrp = normalized.slice(0, separator); 3197 if (separator <= 0 || (hrp !== "iuna" && hrp !== "tiuna")) return normalized; 3198 3199 const charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; 3200 const encoded = normalized.slice(separator + 1); 3201 if (encoded.length < 7) return normalized; 3202 const payload = [...encoded.slice(0, -6)].map((character) => charset.indexOf(character)); 3203 if (payload.length === 0 || payload[0] !== 0 || payload.some((value) => value < 0)) return normalized; 3204 3205 let accumulator = 0; 3206 let bits = 0; 3207 const bytes = []; 3208 for (const value of payload.slice(1)) { 3209 accumulator = (accumulator << 5) | value; 3210 bits += 5; 3211 while (bits >= 8) { 3212 bits -= 8; 3213 bytes.push((accumulator >> bits) & 0xff); 3214 accumulator &= (1 << bits) - 1; 3215 } 3216 } 3217 if (bits >= 5 || accumulator !== 0 || bytes.length !== 32) return normalized; 3218 return bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(""); 3219 }, 3220 3221 addressName(address) { 3222 if (!address) return null; 3223 const normalized = String(address).trim().toLowerCase(); 3224 const directName = this.addressBook?.[normalized]; 3225 if (directName) return directName; 3226 3227 const canonical = this.canonicalAddressKey(normalized); 3228 for (const [savedAddress, name] of Object.entries(this.addressBook || {})) { 3229 if (this.canonicalAddressKey(savedAddress) === canonical) return name; 3230 } 3231 return null; 3232 }, 3233 3234 addressLabel(address) { 3235 return this.addressName(address) || address || "-"; 3236 }, 3237 3238 shortAddressLabel(address) { 3239 return this.addressName(address) || this.short(address); 3240 }, 3241 3242 txFrom(tx) { 3243 return tx.from ?? tx.inputs?.[0]?.owner ?? ""; 3244 }, 3245 3246 txTo(tx) { 3247 return tx.to ?? tx.outputs?.[0]?.address ?? null; 3248 }, 3249 3250 txAmount(tx) { 3251 if (tx?.kind === "reward" && tx.rewardTotal !== null && tx.rewardTotal !== undefined) { 3252 return tx.rewardTotal; 3253 } 3254 return tx.amount ?? tx.outputs?.[0]?.amount ?? 0; 3255 }, 3256 3257 isMineTx(tx) { 3258 return tx?.kind === "mine"; 3259 }, 3260 3261 isRewardTx(tx) { 3262 return tx?.kind === "reward"; 3263 }, 3264 3265 txFeeLabel(tx) { 3266 return `IUNA ${this.amountLabel(tx?.fee ?? 0)}`; 3267 }, 3268 3269 txPillLabel(tx) { 3270 return tx?.kind || "-"; 3271 }, 3272 3273 txPillClass(tx) { 3274 return tx?.kind || ""; 3275 }, 3276 3277 txDifficultyBits(tx) { 3278 return tx?.difficulty_bits ?? tx?.difficultyBits ?? null; 3279 }, 3280 3281 txProofBits(tx) { 3282 return tx?.proof_bits ?? tx?.proofBits ?? null; 3283 }, 3284 3285 txProofHash(tx) { 3286 return tx?.proof_hash ?? tx?.proofHash ?? tx?.signature ?? null; 3287 }, 3288 3289 txInputs(tx) { 3290 if (tx?.kind === "reward" && Array.isArray(tx.rewardFeeInputs)) { 3291 return tx.rewardFeeInputs.map((input) => ({ 3292 ...input, 3293 rewardFee: true, 3294 outpoint: { txid: input.signature, index: "fee" }, 3295 })); 3296 } 3297 return Array.isArray(tx.inputs) ? tx.inputs : []; 3298 }, 3299 3300 txVisualOutputs(tx) { 3301 if (tx?.kind === "reward" && Array.isArray(tx.rewardOutputs)) { 3302 return tx.rewardOutputs.map((output) => ({ ...output, kind: "reward" })); 3303 } 3304 const rows = []; 3305 if (tx.kind === "burn" && Number(tx.amount || 0) > 0) { 3306 rows.push({ 3307 kind: "burned", 3308 label: "Burn", 3309 amount: tx.amount, 3310 address: null, 3311 }); 3312 } 3313 if (Number(tx.fee || 0) > 0) { 3314 rows.push({ 3315 kind: "fee", 3316 label: "Fee", 3317 amount: tx.fee, 3318 address: null, 3319 detailLabel: "To", 3320 detail: this.txFeeRecipient(tx), 3321 }); 3322 } 3323 const directOutputs = Array.isArray(tx.outputs) ? tx.outputs : []; 3324 for (const [index, output] of directOutputs.entries()) { 3325 rows.push({ 3326 kind: output.kind || "output", 3327 label: output.label || `Output ${index + 1}`, 3328 amount: output.amount, 3329 address: output.address, 3330 }); 3331 } 3332 const changeOutputs = Array.isArray(tx.change) ? tx.change : []; 3333 for (const [index, output] of changeOutputs.entries()) { 3334 rows.push({ 3335 kind: "change", 3336 label: `Change ${index + 1}`, 3337 amount: output.amount, 3338 address: output.address, 3339 }); 3340 } 3341 return rows; 3342 }, 3343 3344 txInputKey(input, index) { 3345 return `${input.outpoint?.txid || "input"}:${input.outpoint?.index ?? index}`; 3346 }, 3347 3348 txOutputKey(output, index) { 3349 return `${output.kind}:${output.address || output.kind}:${output.amount}:${index}`; 3350 }, 3351 3352 txInputOutpoint(input) { 3353 const txid = input.outpoint?.txid || "-"; 3354 const index = input.outpoint?.index ?? "-"; 3355 return `${txid}:${index}`; 3356 }, 3357 3358 utxoOutpoint(utxo) { 3359 return this.txInputOutpoint({ outpoint: utxo.outpoint }); 3360 }, 3361 3362 spendableWalletUtxos() { 3363 return this.walletUtxos.filter((utxo) => utxo.spendable !== false); 3364 }, 3365 3366 rememberUtxoAmounts(utxos) { 3367 for (const utxo of utxos || []) { 3368 this.selectedTransferUtxoAmounts[this.utxoOutpoint(utxo)] = Number(utxo.amount || 0); 3369 } 3370 }, 3371 3372 pruneSelectedTransferUtxos() { 3373 const visible = new Map(this.walletUtxos.map((utxo) => [this.utxoOutpoint(utxo), utxo])); 3374 this.selectedTransferUtxos = this.selectedTransferUtxos.filter((outpoint) => { 3375 const utxo = visible.get(outpoint); 3376 return !utxo || utxo.spendable !== false; 3377 }); 3378 if (this.lastSelectedTransferUtxo && !this.selectedTransferUtxos.includes(this.lastSelectedTransferUtxo)) { 3379 this.lastSelectedTransferUtxo = null; 3380 } 3381 }, 3382 3383 toggleTransferUtxoSelection(event, utxo) { 3384 const outpoint = this.utxoOutpoint(utxo); 3385 if (!utxo || utxo.spendable === false || !outpoint) { 3386 this.scheduleFeeEstimates(); 3387 return; 3388 } 3389 3390 this.rememberUtxoAmounts([utxo]); 3391 const spendable = this.spendableWalletUtxos(); 3392 const outpoints = spendable.map((item) => this.utxoOutpoint(item)); 3393 const currentIndex = outpoints.indexOf(outpoint); 3394 const anchorIndex = this.lastSelectedTransferUtxo 3395 ? outpoints.indexOf(this.lastSelectedTransferUtxo) 3396 : -1; 3397 3398 const selected = new Set(this.selectedTransferUtxos); 3399 const checked = !selected.has(outpoint); 3400 if (event?.shiftKey && anchorIndex >= 0 && currentIndex >= 0) { 3401 const [from, to] = [anchorIndex, currentIndex].sort((left, right) => left - right); 3402 const range = spendable.slice(from, to + 1); 3403 this.rememberUtxoAmounts(range); 3404 for (const item of range) { 3405 const itemOutpoint = this.utxoOutpoint(item); 3406 if (checked) { 3407 selected.add(itemOutpoint); 3408 } else { 3409 selected.delete(itemOutpoint); 3410 } 3411 } 3412 } else if (checked) { 3413 selected.add(outpoint); 3414 } else { 3415 selected.delete(outpoint); 3416 } 3417 this.selectedTransferUtxos = Array.from(selected); 3418 3419 this.lastSelectedTransferUtxo = outpoint; 3420 this.scheduleFeeEstimates(); 3421 }, 3422 3423 async selectAllTransferUtxos() { 3424 try { 3425 const utxos = await this.fetchJson("/api/wallet/utxos/selectable"); 3426 this.rememberUtxoAmounts(utxos); 3427 this.selectedTransferUtxos = utxos.map((utxo) => this.utxoOutpoint(utxo)); 3428 this.lastSelectedTransferUtxo = 3429 this.selectedTransferUtxos[this.selectedTransferUtxos.length - 1] || null; 3430 this.scheduleFeeEstimates(); 3431 if (this.selectedTransferUtxos.length === 0) { 3432 this.showFlash("No spendable UTXOs", "error"); 3433 } 3434 } catch (error) { 3435 this.showFlash(error.message, "error"); 3436 } 3437 }, 3438 3439 clearTransferUtxos() { 3440 this.selectedTransferUtxos = []; 3441 this.selectedTransferUtxoAmounts = {}; 3442 this.lastSelectedTransferUtxo = null; 3443 this.scheduleFeeEstimates(); 3444 }, 3445 3446 selectedTransferUtxoTotal() { 3447 return this.selectedTransferUtxos.reduce((sum, outpoint) => { 3448 return sum + this.microiunaAmount(this.selectedTransferUtxoAmounts[outpoint]); 3449 }, 0); 3450 }, 3451 3452 transferRequiredTotal() { 3453 return this.parseiunaAmount(this.transferAmount) + this.microiunaAmount(this.feeEstimates.transfer?.fee); 3454 }, 3455 3456 selectedTransferUtxosCoverTransfer() { 3457 return this.selectedTransferUtxoShortfall() === 0; 3458 }, 3459 3460 selectedTransferUtxoShortfall() { 3461 if (this.selectedTransferUtxos.length === 0) return 0; 3462 return Math.max(0, this.microiunaAmount(this.transferRequiredTotal()) - this.microiunaAmount(this.selectedTransferUtxoTotal())); 3463 }, 3464 3465 txInputAmountLabel(input) { 3466 return input.amount === null || input.amount === undefined ? "-" : `IUNA ${this.amountLabel(input.amount)}`; 3467 }, 3468 3469 txFeeRecipient(tx) { 3470 const context = this.selectedTransaction?.context || {}; 3471 const address = tx.blockFinalizer ?? tx.blockMiner ?? context.blockFinalizer ?? context.blockMiner; 3472 return address ? this.addressLabel(address) : "future block finalizer"; 3473 }, 3474 3475 selectedTransactionLabel() { 3476 if (!this.selectedTransaction) return "-"; 3477 const { tx, context } = this.selectedTransaction; 3478 if (context.reward && context.blockHeight !== undefined) return `Block ${context.blockHeight} reward`; 3479 if (context.blockHeight !== undefined) return `Block ${context.blockHeight}`; 3480 if (tx?.status === "pending") return "Wallet pending"; 3481 if (tx?.blockHeight !== null && tx?.blockHeight !== undefined) { 3482 return `Wallet block ${tx.blockHeight}`; 3483 } 3484 return context.source || "-"; 3485 }, 3486 3487 blockLostIuna(block) { 3488 const explicitTotal = block?.lostIuna ?? block?.lost_iuna; 3489 if (explicitTotal !== null && explicitTotal !== undefined) return Number(explicitTotal) || 0; 3490 return this.blockTransactions(block) 3491 .filter((tx) => tx.kind === "burn") 3492 .reduce((sum, tx) => sum + this.txAmount(tx), 0); 3493 }, 3494 3495 blockTotalFees(block) { 3496 const explicitTotal = block?.totalFees ?? block?.total_fees ?? block?.reward; 3497 if (explicitTotal !== null && explicitTotal !== undefined) return Number(explicitTotal) || 0; 3498 return this.blockTransactions(block).reduce((sum, tx) => sum + Number(tx.fee || 0), 0); 3499 }, 3500 3501 blockRewardTransaction(block) { 3502 const inputs = this.blockTransactions(block) 3503 .filter((tx) => Number(tx.fee || 0) > 0) 3504 .map((tx) => ({ 3505 rewardFee: true, 3506 transactionKind: tx.kind || "transaction", 3507 amount: tx.fee, 3508 owner: this.txFrom(tx) || this.txTo(tx) || null, 3509 signature: tx.signature, 3510 outpoint: { txid: tx.signature, index: "fee" }, 3511 })); 3512 return { 3513 kind: "reward", 3514 signature: block.hash, 3515 amount: block.reward, 3516 fee: 0, 3517 inputs, 3518 outputs: this.blockRewardOutputs(block), 3519 }; 3520 }, 3521 3522 blockRewardOutputs(block) { 3523 const reward = Math.max(0, Math.trunc(Number(block?.reward || 0))); 3524 if (reward === 0) return []; 3525 3526 const bundles = Array.isArray(block?.burn_bundles) 3527 ? block.burn_bundles 3528 : (Array.isArray(block?.burnBundles) ? block.burnBundles : []); 3529 const committee = block?.finalizer_mode === "ticket" && Number(block?.finalizer_rank || 0) <= 1 3530 ? [...bundles] 3531 .sort((left, right) => Number(left.slot || 0) - Number(right.slot || 0)) 3532 .filter((bundle) => bundle.member && bundle.member !== block.miner) 3533 : []; 3534 const committeePool = committee.length > 0 ? Math.floor(reward / 2) : 0; 3535 const outputs = [{ 3536 kind: "reward", 3537 label: "Finalizer reward", 3538 amount: reward - committeePool, 3539 address: block.miner, 3540 }]; 3541 3542 let remaining = committeePool; 3543 committee.forEach((bundle, index) => { 3544 const membersLeft = committee.length - index; 3545 const amount = membersLeft === 1 ? remaining : Math.floor(remaining / membersLeft); 3546 remaining -= amount; 3547 if (amount > 0) { 3548 outputs.push({ 3549 kind: "reward", 3550 label: `Committee reward (slot ${bundle.slot})`, 3551 amount, 3552 address: bundle.member, 3553 }); 3554 } 3555 }); 3556 return outputs; 3557 }, 3558 3559 blockTimestampLabel(block) { 3560 const timestamp = Number(block?.timestamp_ms ?? block?.timestampMs); 3561 if (Number(block?.height) === 0) return "Genesis"; 3562 if (!Number.isFinite(timestamp) || timestamp <= 0) return "-"; 3563 return new Date(timestamp).toLocaleString(); 3564 }, 3565 3566 blockTotalBytes(block) { 3567 return Number(block?.totalBytes ?? block?.total_bytes ?? 0); 3568 }, 3569 3570 blockBurnBundleQuorum(block) { 3571 return block?.burnBundleQuorum ?? block?.burn_bundle_quorum ?? {}; 3572 }, 3573 3574 blockBurnBundleRatio(block) { 3575 const quorum = this.blockBurnBundleQuorum(block); 3576 const included = Number(quorum.burnBundlesIncluded ?? quorum.burn_bundles_included ?? 0); 3577 const committeeSize = Number(quorum.committeeSize ?? quorum.committee_size ?? 0); 3578 return `${included}/${committeeSize}`; 3579 }, 3580 3581 blockBurnBundleSlots(block) { 3582 if (!block || block.finalizer_mode === "recovery") return []; 3583 const bundles = block.burn_bundles || block.burnBundles || []; 3584 return [ 3585 { 3586 slot: 0, 3587 member: block.miner, 3588 role: "Finalizer", 3589 implicit: true, 3590 burns: this.blockTransactions(block).filter((tx) => tx.kind === "burn"), 3591 byteSize: 0, 3592 hash: "", 3593 }, 3594 ...bundles.map((bundle) => ({ 3595 slot: Number(bundle.slot), 3596 member: bundle.member, 3597 role: "Committee member", 3598 implicit: false, 3599 burns: bundle.burns || [], 3600 byteSize: Number(bundle.byte_size ?? bundle.byteSize ?? 0), 3601 hash: bundle.hash || "", 3602 })), 3603 ].sort((left, right) => left.slot - right.slot); 3604 }, 3605 3606 burnBundleSlotDetail(slot) { 3607 if (slot?.implicit) return "Implicit block attestation"; 3608 const count = slot?.burns?.length || 0; 3609 return `${count} burn${count === 1 ? "" : "s"} · ${slot?.byteSize || 0}B gossip JSON`; 3610 }, 3611 3612 blockByteBreakdown(block) { 3613 const transactionRows = this.blockTransactionByteBreakdown(block); 3614 return [ 3615 ["Header and proof", Number(block?.headerAndProofBytes ?? block?.header_and_proof_bytes ?? 0), ""], 3616 ...(transactionRows.length 3617 ? transactionRows 3618 : [["Transactions", Number(block?.transactionBytes ?? block?.transaction_bytes ?? 0), ""]]), 3619 ["Burn bundles", Number(block?.burnBundleBytes ?? block?.burn_bundle_bytes ?? 0), "burn"], 3620 ]; 3621 }, 3622 3623 blockTransactionByteBreakdown(block) { 3624 const rows = block?.transactionByteBreakdown ?? block?.transaction_byte_breakdown; 3625 if (!Array.isArray(rows)) return []; 3626 return rows 3627 .map((row) => { 3628 const label = row.label || row.kind || "transaction"; 3629 return [label, Number(row.bytes ?? 0), label]; 3630 }) 3631 .filter((row) => row[1] > 0); 3632 }, 3633 3634 recentBlockFeeAverage(count) { 3635 const sample = this.blocks.filter((block) => block.height > 0).slice(0, count); 3636 if (sample.length === 0) return 0; 3637 return Math.round(sample.reduce((sum, block) => sum + this.blockTotalFees(block), 0) / sample.length); 3638 }, 3639 3640 blockBurnCount(block) { 3641 return this.blockTransactions(block).filter((tx) => tx.kind === "burn").length; 3642 }, 3643 3644 blockTransferCount(block) { 3645 return this.blockTransactions(block).filter((tx) => tx.kind === "transfer").length; 3646 }, 3647 3648 blockMineCount(block) { 3649 return this.blockTransactions(block).filter((tx) => tx.kind === "mine").length; 3650 }, 3651 3652 blockTransactions(block) { 3653 return block?.transactions || []; 3654 }, 3655 3656 burnCountLabel(block) { 3657 const count = this.blockBurnCount(block); 3658 return `${count} burn${count === 1 ? "" : "s"}`; 3659 }, 3660 3661 transferCountLabel(block) { 3662 const count = this.blockTransferCount(block); 3663 return `${count} transfer${count === 1 ? "" : "s"}`; 3664 }, 3665 3666 mineCountLabel(block) { 3667 const count = this.blockMineCount(block); 3668 return `${count} mine${count === 1 ? "" : "s"}`; 3669 }, 3670 3671 blockFinalizerLabel(block) { 3672 const finalizer = this.shortAddressLabel(block.miner); 3673 const owner = block.miner === this.status.wallet_address ? `${finalizer} (me)` : finalizer; 3674 return block.finalizer_mode === "recovery" ? `${owner} · Recovery` : owner; 3675 }, 3676 3677 burnLeaderRanks(block) { 3678 if (Array.isArray(block?.burn_leader_ranks)) return block.burn_leader_ranks; 3679 return Array.isArray(block?.burnLeaderRanks) ? block.burnLeaderRanks : []; 3680 }, 3681 3682 burnLeaderRanksTitle(block) { 3683 if (!block) return "Burn Leader Ranks"; 3684 return `Block ${block.height} Burn Leader Ranks`; 3685 }, 3686 3687 burnLeaderRankLabel(rank) { 3688 const value = Number(rank?.rank ?? 0); 3689 return `#${value + 1}`; 3690 }, 3691 3692 burnLeaderEligibilityLabel(rank) { 3693 const from = rank?.eligible_from_height ?? rank?.eligibleFromHeight ?? "-"; 3694 const until = rank?.eligible_until_height ?? rank?.eligibleUntilHeight ?? "-"; 3695 return `${from}-${until}`; 3696 }, 3697 3698 walletTransactions() { 3699 return this.walletTxs; 3700 }, 3701 3702 txTitle(tx) { 3703 if (tx.status === "pending") return "Pending"; 3704 return tx.blockHeight === null ? "Confirmed" : `Block ${tx.blockHeight}`; 3705 }, 3706 3707 walletTxTimeLabel(tx) { 3708 const timestamp = Number(tx?.timestampMs ?? tx?.timestamp_ms); 3709 if (!Number.isFinite(timestamp) || timestamp <= 0) { 3710 return tx?.status === "pending" ? "Pending" : "-"; 3711 } 3712 return new Date(timestamp).toLocaleString(); 3713 }, 3714 3715 isLeaderLabel() { 3716 if (!this.status.mining) return "-"; 3717 return this.status.mining.wallet_is_current_leader ? "yes" : "no"; 3718 }, 3719 3720 sharedHeightLabel() { 3721 const local = this.status.chain?.height; 3722 if (typeof local !== "number") return "-"; 3723 const peerHeights = this.peers 3724 .filter((peer) => !peer.last_error) 3725 .map((peer) => peer.last_known_height) 3726 .filter((height) => typeof height === "number"); 3727 if (peerHeights.length === 0) return local; 3728 return Math.min(local, ...peerHeights); 3729 }, 3730 3731 networkHealthClass() { 3732 if (this.networkHealth.ok) return "healthy"; 3733 if (this.networkHealth.state === "syncing") return "syncing"; 3734 if (this.networkHealth.state === "isolated") return "isolated"; 3735 if (this.networkHealth.state === "stale") return "stale"; 3736 if (this.networkHealth.state === "banned") return "banned"; 3737 return "error"; 3738 }, 3739 3740 syncingNode() { 3741 return ( 3742 this.canUseProtectedApi() && 3743 !this.showingNetworkMigration() && 3744 !this.chainResetModalOpen && 3745 this.config.setup_complete === true && 3746 this.networkHealthLoaded && 3747 this.networkHealth.state === "syncing" 3748 ); 3749 }, 3750 3751 syncCurrentHeight() { 3752 const height = Number( 3753 this.networkHealth.sync_validated_height ?? 3754 this.networkHealth.local_height ?? 3755 this.status.chain?.height ?? 3756 0 3757 ); 3758 return Number.isFinite(height) && height >= 0 ? Math.floor(height) : 0; 3759 }, 3760 3761 syncTargetHeight() { 3762 const batchTarget = Number(this.networkHealth.sync_target_height ?? 0); 3763 const knownTarget = Number(this.networkHealth.best_known_height ?? 0); 3764 const target = Math.max( 3765 Number.isFinite(batchTarget) ? batchTarget : 0, 3766 Number.isFinite(knownTarget) ? knownTarget : 0, 3767 this.syncCurrentHeight() 3768 ); 3769 return Number.isFinite(target) && target >= 0 3770 ? Math.max(this.syncCurrentHeight(), Math.floor(target)) 3771 : this.syncCurrentHeight(); 3772 }, 3773 3774 syncProgressPercent() { 3775 const current = this.syncCurrentHeight(); 3776 const target = this.syncTargetHeight(); 3777 if (target <= 0) return 0; 3778 return Math.max(0, Math.min(100, (current / target) * 100)); 3779 }, 3780 3781 syncProgressLabel() { 3782 return `Syncing ${this.syncCurrentHeight().toLocaleString()} of ${this.syncTargetHeight().toLocaleString()}`; 3783 }, 3784 3785 networkLagLabel() { 3786 const lag = this.networkHealth.lag_blocks; 3787 if (typeof lag !== "number") return "-"; 3788 if (lag === 0) return "even"; 3789 return `${lag} behind`; 3790 }, 3791 3792 networkTipLabel() { 3793 return this.short(this.networkHealth.local_tip_hash); 3794 }, 3795 3796 networkLastBlockAgeLabel() { 3797 const ageMs = this.networkHealth.last_block_age_ms; 3798 if (typeof ageMs !== "number" || !Number.isFinite(ageMs)) return "-"; 3799 return this.durationLabel(ageMs); 3800 }, 3801 3802 dashboardMiningState() { 3803 return this.powMiningEnabled ? "good" : "neutral"; 3804 }, 3805 3806 dashboardMiningLabel() { 3807 if (!this.powMiningEnabled) return "Off"; 3808 return `${this.powMiningWorkers} worker${this.powMiningWorkers === 1 ? "" : "s"}`; 3809 }, 3810 3811 dashboardBurnState() { 3812 return this.miningEnabled ? "good" : "neutral"; 3813 }, 3814 3815 dashboardBurnLabel() { 3816 if (!this.miningEnabled) return "Off"; 3817 return `IUNA ${this.amountLabel(this.burnAmount)} / blk`; 3818 }, 3819 3820 dashboardBlockState() { 3821 const ageMs = this.networkHealth.last_block_age_ms; 3822 if (typeof ageMs !== "number" || !Number.isFinite(ageMs)) return "neutral"; 3823 if (ageMs < 15 * 60 * 1000) return "good"; 3824 if (ageMs < 20 * 60 * 1000) return "warning"; 3825 return "bad"; 3826 }, 3827 3828 dashboardLastBlockLabel() { 3829 const age = this.networkLastBlockAgeLabel(); 3830 return age === "-" ? "Unavailable" : `${age} ago`; 3831 }, 3832 3833 dashboardNetworkState() { 3834 const state = this.networkHealth.state; 3835 if (!state) return "neutral"; 3836 if (state === "healthy" || state === "ahead of peers") return "good"; 3837 if ( 3838 state === "syncing" || 3839 state === "mempool syncing" || 3840 state === "stale" || 3841 state === "peer errors" || 3842 state === "banned" 3843 ) { 3844 return "warning"; 3845 } 3846 return "bad"; 3847 }, 3848 3849 networkFinalizerLabel() { 3850 return this.shortAddressLabel(this.networkHealth.current_leader); 3851 }, 3852 3853 networkFinalizerModeLabel() { 3854 const mode = this.networkHealth.last_finalizer_mode; 3855 if (!mode) return "-"; 3856 const rank = this.networkHealth.last_finalizer_rank; 3857 const label = mode.charAt(0).toUpperCase() + mode.slice(1); 3858 return typeof rank === "number" ? `${label} #${rank}` : label; 3859 }, 3860 3861 networkVdfLabel() { 3862 const rounds = this.networkHealth.vdf_rounds; 3863 const targetMs = this.networkHealth.vdf_target_block_ms; 3864 if (typeof rounds !== "number") return "-"; 3865 const target = typeof targetMs === "number" ? ` / ${this.durationLabel(targetMs)}` : ""; 3866 return `${rounds.toLocaleString()}${target}`; 3867 }, 3868 3869 basicNetworkStatusLabel() { 3870 const state = this.networkHealth.state; 3871 if (!state) return "Network starting"; 3872 if (state === "healthy" || state === "ahead of peers") return "Connected"; 3873 if (state === "syncing" || state === "mempool syncing") return "Syncing"; 3874 if (state === "isolated") return "Offline"; 3875 return state.charAt(0).toUpperCase() + state.slice(1); 3876 }, 3877 3878 basicNetworkNeedsAttention() { 3879 if (!this.networkHealth.state) return false; 3880 return !this.networkHealth.ok && this.networkHealth.state !== "syncing"; 3881 }, 3882 3883 networkTimeOffsetLabel() { 3884 return this.clockOffsetLabel(this.networkHealth.network_time_offset_ms, true); 3885 }, 3886 3887 outboundPeers() { 3888 return this.peers.filter((peer) => peer.direction !== "inbound"); 3889 }, 3890 3891 inboundPeers() { 3892 return this.peers.filter((peer) => peer.direction === "inbound"); 3893 }, 3894 3895 healthyPeers() { 3896 return this.peers.filter((peer) => !peer.last_error && typeof peer.last_known_height === "number"); 3897 }, 3898 3899 failedPeers() { 3900 return this.peers.filter((peer) => peer.last_error); 3901 }, 3902 3903 stalePeer(peer) { 3904 const lastSuccess = peer.last_success_ms; 3905 if (typeof lastSuccess !== "number") return false; 3906 return Date.now() - lastSuccess > 20 * 60 * 1000; 3907 }, 3908 3909 bannedPeer(peer) { 3910 const bannedUntil = peer.banned_until_ms; 3911 return typeof bannedUntil === "number" && bannedUntil > Date.now(); 3912 }, 3913 3914 peerStatus(peer) { 3915 if (this.bannedPeer(peer)) return "banned"; 3916 if (peer.last_error) return "error"; 3917 if (this.stalePeer(peer)) return "stale"; 3918 if (typeof peer.last_known_height === "number") return "synced"; 3919 if ((peer.messages_sent ?? 0) > 0 || (peer.messages_received ?? 0) > 0) return "active"; 3920 return "pending"; 3921 }, 3922 3923 peerStatusLabel(peer) { 3924 return { 3925 error: "Error", 3926 banned: "Banned", 3927 stale: "Stale", 3928 synced: "Synced", 3929 active: "Active", 3930 pending: "Pending", 3931 }[this.peerStatus(peer)]; 3932 }, 3933 3934 relativeTimeLabel(timestampMs) { 3935 if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "-"; 3936 const ageSeconds = Math.max(0, Math.round((Date.now() - timestampMs) / 1000)); 3937 if (ageSeconds < 5) return "now"; 3938 if (ageSeconds < 60) return `${ageSeconds}s ago`; 3939 const ageMinutes = Math.round(ageSeconds / 60); 3940 if (ageMinutes < 60) return `${ageMinutes}m ago`; 3941 const ageHours = Math.round(ageMinutes / 60); 3942 if (ageHours < 48) return `${ageHours}h ago`; 3943 return `${Math.round(ageHours / 24)}d ago`; 3944 }, 3945 3946 durationLabel(durationMs) { 3947 if (typeof durationMs !== "number" || !Number.isFinite(durationMs)) return "-"; 3948 const seconds = Math.max(0, Math.round(durationMs / 1000)); 3949 if (seconds < 60) return `${seconds}s`; 3950 const minutes = Math.round(seconds / 60); 3951 if (minutes < 60) return `${minutes}m`; 3952 const hours = Math.round(minutes / 60); 3953 if (hours < 48) return `${hours}h`; 3954 return `${Math.round(hours / 24)}d`; 3955 }, 3956 3957 peerLastContactLabel(peer) { 3958 return this.relativeTimeLabel(peer.last_contact_ms); 3959 }, 3960 3961 peerClockLabel(peer) { 3962 const label = this.clockOffsetLabel(peer.last_clock_offset_ms, false); 3963 if (label === "-") return "-"; 3964 return peer.last_clock_offset_accepted === false ? `${label} ignored` : label; 3965 }, 3966 3967 clockOffsetLabel(offsetMs, zeroAsSynced) { 3968 if (typeof offsetMs !== "number") return "-"; 3969 const sign = offsetMs > 0 ? "+" : offsetMs < 0 ? "-" : ""; 3970 const absoluteSeconds = Math.round(Math.abs(offsetMs) / 1000); 3971 if (absoluteSeconds === 0) return zeroAsSynced ? "even" : "0s"; 3972 if (absoluteSeconds < 60) return `${sign}${absoluteSeconds}s`; 3973 const minutes = Math.round(absoluteSeconds / 60); 3974 if (minutes < 60) return `${sign}${minutes}m`; 3975 return `${sign}${Math.round(minutes / 60)}h`; 3976 }, 3977 3978 peerBanLabel(peer) { 3979 if (!this.bannedPeer(peer)) return "-"; 3980 const remainingSeconds = Math.max(0, Math.round((peer.banned_until_ms - Date.now()) / 1000)); 3981 if (remainingSeconds < 60) return `${remainingSeconds}s`; 3982 const remainingMinutes = Math.round(remainingSeconds / 60); 3983 if (remainingMinutes < 60) return `${remainingMinutes}m`; 3984 return `${Math.round(remainingMinutes / 60)}h`; 3985 }, 3986 3987 normalizeVersion(version) { 3988 return String(version || "").trim().replace(/^v/i, ""); 3989 }, 3990 3991 versionParts(version) { 3992 const [core] = this.normalizeVersion(version).split("-"); 3993 return core.split(".").map((part) => Number.parseInt(part, 10) || 0); 3994 }, 3995 3996 compareVersions(left, right) { 3997 const leftParts = this.versionParts(left); 3998 const rightParts = this.versionParts(right); 3999 const length = Math.max(leftParts.length, rightParts.length, 3); 4000 for (let index = 0; index < length; index += 1) { 4001 const diff = (leftParts[index] || 0) - (rightParts[index] || 0); 4002 if (diff !== 0) return diff; 4003 } 4004 return 0; 4005 }, 4006 4007 peerHeightDelta(peer) { 4008 const local = this.status.chain?.height; 4009 const remote = peer.last_known_height; 4010 if (typeof local !== "number" || typeof remote !== "number") return "-"; 4011 if (remote === local) return "even"; 4012 if (remote > local) return `+${remote - local}`; 4013 return `-${local - remote}`; 4014 }, 4015 4016 canRemovePeer(peer) { 4017 return peer.direction !== "inbound"; 4018 }, 4019 4020 targetSecondsLabel() { 4021 const ms = this.status.mining?.vdf_target_block_ms; 4022 if (!ms) return "-"; 4023 const seconds = Math.round(ms / 1000); 4024 if (seconds % 60 === 0) return `${seconds / 60}m`; 4025 return `${seconds}s`; 4026 }, 4027 4028 stratumListenAddr() { 4029 return this.status.stratum?.listen_addr || "-"; 4030 }, 4031 4032 stratumRuntimeEnabled() { 4033 return this.status.stratum?.enabled === true; 4034 }, 4035 4036 stratumPoolUrl() { 4037 const listen = this.status.stratum?.listen_addr; 4038 if (!this.status.stratum?.enabled || !listen) return "-"; 4039 const lastColon = listen.lastIndexOf(":"); 4040 if (lastColon < 0) return `stratum+tcp://${listen}`; 4041 let host = listen.slice(0, lastColon); 4042 const port = listen.slice(lastColon + 1); 4043 if (host === "0.0.0.0" || host === "::" || host === "[::]") { 4044 host = window.location.hostname || "127.0.0.1"; 4045 } 4046 return `stratum+tcp://${host}:${port}`; 4047 }, 4048 4049 lastUpdatedLabel() { 4050 return this.lastUpdated ? `Updated ${this.lastUpdated.toLocaleTimeString()}` : "Loading"; 4051 }, 4052 }; 4053 };