iuna-ui.js (104218B)
1 const IUNA_DOWNLOADS_URL = "https://getiuna.org/downloads/"; 2 const IUNA_RELEASE_METADATA_URL = "https://getiuna.org/downloads/latest.json"; 3 4 window.iunaApp = function iunaApp() { 5 return { 6 tab: "wallet", 7 status: {}, 8 blocks: [], 9 selectedBlock: null, 10 selectedByteBlock: null, 11 selectedTransaction: null, 12 selectedBurnLeaderBlock: null, 13 loadingInitialBlocks: false, 14 loadingOlder: false, 15 hasMoreBlocks: true, 16 walletTxs: [], 17 walletUtxos: [], 18 mempool: [], 19 peers: [], 20 p2pMetrics: {}, 21 blockchainMetrics: { enabled: false, latest: null, charts: [] }, 22 loadingMetrics: false, 23 metricsRequestSeq: 0, 24 metricHover: null, 25 metricsRange: (() => { 26 try { 27 const stored = localStorage.getItem("iunaMetricsRange"); 28 if (stored === "1000") return 1000; 29 if (stored === "all") return "all"; 30 } catch { 31 // Ignore storage failures; the in-memory default is enough. 32 } 33 return 100; 34 })(), 35 networkHealth: {}, 36 uiMode: (() => { 37 try { 38 return localStorage.getItem("iunaUiMode") === "advanced" ? "advanced" : "basic"; 39 } catch { 40 return "basic"; 41 } 42 })(), 43 latestRelease: null, 44 releaseCheckState: "idle", 45 releaseCheckError: null, 46 config: { setup_complete: false }, 47 auth: { configured: false, authenticated: false }, 48 authLoaded: false, 49 authPassword: "", 50 authPasswordConfirm: "", 51 loginPassword: "", 52 authFeedback: null, 53 settingsOldPassword: "", 54 settingsNewPassword: "", 55 settingsPasswordConfirm: "", 56 settingsFeedback: null, 57 keepTrackOfMetrics: false, 58 addressBook: {}, 59 addressBookVersion: 0, 60 addressBookModalOpen: false, 61 addressBookPickerOpen: false, 62 addressBookEditingAddress: null, 63 addressBookDraftAddress: "", 64 addressBookDraftName: "", 65 p2pAcceptInbound: false, 66 p2pBindPort: 9444, 67 p2pBindPortDirty: false, 68 p2pAnnounceAddr: "", 69 p2pAnnounceDirty: false, 70 setupWallet: { address: null, seed_phrase: null, dev_verify_bypass: false, requires_peer: false }, 71 setupNodeMode: "wallet", 72 setupWalletMode: "create", 73 setupSeedStep: "write", 74 generatedSeedPhrase: "", 75 verifyChallenges: [], 76 verifyAnswers: {}, 77 importSeedPhrase: "", 78 walletVerified: false, 79 setupFeedback: null, 80 burnAmount: 100, 81 burnAmountDraft: "0.0001", 82 burnFee: 100, 83 burnFeeDraft: "0.0001", 84 miningEnabled: false, 85 powMiningEnabled: false, 86 powMiningWorkers: 1, 87 maxPowMiningWorkers: 32, 88 recoveryVdfTopRankPercent: 50, 89 burnAmountDirty: false, 90 miningEvents: [], 91 miningEventLimit: 1000, 92 miningEventState: {}, 93 miningEventCounter: 0, 94 transferTo: "", 95 transferAmount: null, 96 transferFee: "0.000001", 97 feeEstimates: { transfer: null, burn: null, mine: null }, 98 feeEstimateTimer: null, 99 showSendAdvanced: false, 100 selectedTransferUtxos: [], 101 selectedTransferUtxoAmounts: {}, 102 lastSelectedTransferUtxo: null, 103 walletTxFilters: { transfer: true, mine: false, burn: false }, 104 setupPeerAddress: "iuna.jhx.app:9444", 105 peerAddress: "", 106 flash: null, 107 flashTimer: null, 108 chainResetModalOpen: false, 109 chainResetConfirm: "", 110 chainResetBusy: false, 111 showWalletUtxos: false, 112 showPowDifficultyInfo: false, 113 lastUpdated: null, 114 pollHandle: null, 115 refreshPromise: null, 116 shellRefreshPromise: null, 117 networkHealthPromise: null, 118 requestTimeoutMs: 12000, 119 hashListenerInstalled: false, 120 newBlockHashes: new Set(), 121 newBlockTimer: null, 122 lastBlockMempoolHeight: null, 123 mempoolFirstSeenHeights: {}, 124 mempoolFirstSeenAt: {}, 125 mempoolSeenInitialized: false, 126 blockPageSize: 20, 127 datasetPageSize: 25, 128 walletTxPage: { offset: 0, total: 0, hasMore: true, loading: false, backgroundLoading: false }, 129 walletUtxoPage: { offset: 0, total: 0, hasMore: true, loading: false, backgroundLoading: false }, 130 mempoolPage: { offset: 0, total: 0, hasMore: true, loading: false, backgroundLoading: false }, 131 peerPage: { offset: 0, total: 0, hasMore: true, loading: false, backgroundLoading: false }, 132 133 init() { 134 this.bootstrap(); 135 }, 136 137 async bootstrap() { 138 await this.refreshAuth(); 139 if (this.showingAuth()) return; 140 await this.bootstrapAuthenticated(); 141 }, 142 143 async bootstrapAuthenticated() { 144 await this.refreshConfig(); 145 if (!this.config.setup_complete) { 146 await this.refreshWalletSetup(); 147 } 148 this.tab = this.tabFromHash(); 149 if (!this.hashListenerInstalled) { 150 window.addEventListener("hashchange", () => { 151 this.setTab(this.tabFromHash()); 152 }); 153 this.hashListenerInstalled = true; 154 } 155 await this.refresh(); 156 this.checkLatestRelease(); 157 if (!this.pollHandle) { 158 this.pollHandle = setInterval(() => this.refresh({ silent: true }), 5000); 159 } 160 }, 161 162 canUseProtectedApi() { 163 return this.authLoaded && this.auth.configured === true && this.auth.authenticated === true; 164 }, 165 166 stopPolling() { 167 if (!this.pollHandle) return; 168 clearInterval(this.pollHandle); 169 this.pollHandle = null; 170 }, 171 172 tabFromHash() { 173 const hash = window.location.hash.replace(/^#\/?/, ""); 174 return this.allowedTabs().includes(hash) ? hash : "wallet"; 175 }, 176 177 setTab(tab) { 178 if (!this.allowedTabs().includes(tab)) return; 179 const alreadyActive = this.tab === tab; 180 this.tab = tab; 181 if (window.location.hash !== `#${tab}`) { 182 window.location.hash = tab; 183 } 184 if (alreadyActive) return; 185 this.refresh({ silent: true }); 186 }, 187 188 allowedTabs() { 189 const tabs = this.advancedMode() 190 ? ["wallet", "mining", "p2p", "chain", "settings"] 191 : ["wallet", "p2p", "chain", "settings"]; 192 if (this.developmentMode()) { 193 tabs.splice(tabs.indexOf("chain") + 1, 0, "metrics"); 194 } 195 return tabs; 196 }, 197 198 developmentMode() { 199 return this.keepTrackOfMetrics === true || this.config.keep_track_of_metrics === true; 200 }, 201 202 basicMode() { 203 return this.uiMode !== "advanced"; 204 }, 205 206 advancedMode() { 207 return this.uiMode === "advanced"; 208 }, 209 210 setUiMode(mode) { 211 this.uiMode = mode === "advanced" ? "advanced" : "basic"; 212 try { 213 localStorage.setItem("iunaUiMode", this.uiMode); 214 } catch {} 215 if (!this.allowedTabs().includes(this.tab)) { 216 this.setTab("wallet"); 217 } 218 }, 219 220 toggleUiMode() { 221 this.setUiMode(this.advancedMode() ? "basic" : "advanced"); 222 }, 223 224 pageTitle() { 225 return { 226 wallet: "iuna", 227 mining: "Mining", 228 p2p: "P2P", 229 chain: "Chain", 230 metrics: "Metrics", 231 settings: "Settings", 232 }[this.tab] || "iuna"; 233 }, 234 235 appVersionLabel() { 236 return `v${this.normalizeVersion(this.status.app_version || "0.0.0")}`; 237 }, 238 239 latestReleaseLabel() { 240 return this.latestRelease?.tag || ""; 241 }, 242 243 updateAvailable() { 244 const current = this.normalizeVersion(this.status.app_version); 245 const latest = this.normalizeVersion(this.latestRelease?.tag); 246 if (!current || !latest) return false; 247 if (current === latest) return false; 248 return this.compareVersions(latest, current) > 0; 249 }, 250 251 versionPanelTitle() { 252 if (this.updateAvailable()) return `Update available: ${this.latestReleaseLabel()}`; 253 if (this.releaseCheckState === "failed") return this.releaseCheckError || "Could not check latest release"; 254 if (this.releaseCheckState === "checking") return "Checking latest release"; 255 return "iuna is up to date"; 256 }, 257 258 async openLatestRelease() { 259 const url = this.latestRelease?.url || IUNA_DOWNLOADS_URL; 260 try { 261 const tauriOpen = window.__TAURI__?.shell?.open; 262 if (typeof tauriOpen === "function") { 263 await tauriOpen(url); 264 return; 265 } 266 } catch {} 267 window.open(url, "_blank", "noopener,noreferrer"); 268 }, 269 270 showingSetup() { 271 return this.authLoaded && !this.showingAuth() && !this.config.setup_complete; 272 }, 273 274 showingAuth() { 275 return this.authLoaded && (!this.auth.configured || !this.auth.authenticated); 276 }, 277 278 setupRequiresPeer() { 279 return this.setupWallet.requires_peer === true; 280 }, 281 282 setupHasPeer() { 283 return this.setupPeerAddress.trim().length > 0 || this.outboundPeers().length > 0; 284 }, 285 286 setupCanContinue() { 287 return this.walletVerified && (!this.setupRequiresPeer() || this.setupHasPeer()); 288 }, 289 290 selectSetupNodeMode(mode) { 291 this.setupNodeMode = ["wallet", "non-listening", "listening"].includes(mode) 292 ? mode 293 : "wallet"; 294 this.setupFeedback = null; 295 }, 296 297 setupNodeModeCopy() { 298 if (this.setupNodeMode === "listening") { 299 return "Listening node shows mining and P2P controls and accepts inbound P2P connections when TCP port 9444 is reachable."; 300 } 301 if (this.setupNodeMode === "non-listening") { 302 return "Non-listening node shows mining and P2P controls, connects out to peers, and keeps inbound P2P closed."; 303 } 304 return "Wallet mode keeps the interface focused on your wallet and chain, while this node only connects out to peers."; 305 }, 306 307 async refreshAuth() { 308 this.auth = await this.fetchJson("/api/auth/status"); 309 this.authLoaded = true; 310 }, 311 312 async setupPassword() { 313 try { 314 this.authFeedback = null; 315 if (this.authPassword !== this.authPasswordConfirm) { 316 throw new Error("Passwords do not match"); 317 } 318 await this.postAuth("/api/auth/setup", this.authPassword); 319 this.authPassword = ""; 320 this.authPasswordConfirm = ""; 321 await this.refreshAuth(); 322 await this.bootstrapAuthenticated(); 323 this.showFlash("Password set", "success"); 324 } catch (error) { 325 this.showAuthFeedback(error.message, "error"); 326 } 327 }, 328 329 async login() { 330 try { 331 this.authFeedback = null; 332 await this.postAuth("/api/auth/login", this.loginPassword); 333 this.loginPassword = ""; 334 await this.refreshAuth(); 335 await this.bootstrapAuthenticated(); 336 this.showFlash("Logged in", "success"); 337 } catch (error) { 338 this.showAuthFeedback(error.message, "error"); 339 } 340 }, 341 342 async postAuth(path, password) { 343 const response = await this.fetchWithTimeout(path, { 344 method: "POST", 345 headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, 346 body: new URLSearchParams({ password }), 347 }); 348 const text = await response.text(); 349 let payload = { ok: response.ok, error: null }; 350 if (text) { 351 try { 352 payload = JSON.parse(text); 353 } catch { 354 payload = { ok: false, error: text }; 355 } 356 } 357 if (!response.ok || !payload.ok) { 358 throw new Error(payload.error || `${path} returned ${response.status}`); 359 } 360 return payload; 361 }, 362 363 async logout() { 364 try { 365 await this.postAuth("/api/auth/logout", ""); 366 this.stopPolling(); 367 await this.refreshAuth(); 368 this.showFlash("Locked", "success"); 369 } catch (error) { 370 this.showFlash(error.message, "error"); 371 } 372 }, 373 374 async changePassword() { 375 try { 376 this.settingsFeedback = null; 377 if (this.settingsNewPassword !== this.settingsPasswordConfirm) { 378 throw new Error("New passwords do not match"); 379 } 380 const body = new URLSearchParams({ 381 old_password: this.settingsOldPassword, 382 new_password: this.settingsNewPassword, 383 }); 384 const response = await this.fetchWithTimeout("/api/auth/change-password", { 385 method: "POST", 386 headers: { 387 Accept: "application/json", 388 "Content-Type": "application/x-www-form-urlencoded", 389 }, 390 body, 391 }); 392 const payload = await response.json(); 393 if (!response.ok || !payload.ok) { 394 throw new Error(payload.error || `/api/auth/change-password returned ${response.status}`); 395 } 396 this.settingsOldPassword = ""; 397 this.settingsNewPassword = ""; 398 this.settingsPasswordConfirm = ""; 399 await this.refreshAuth(); 400 this.showSettingsFeedback("Password changed", "success"); 401 this.showFlash("Password changed", "success"); 402 } catch (error) { 403 this.showSettingsFeedback(error.message, "error"); 404 } 405 }, 406 407 async refreshConfig() { 408 this.config = await this.fetchJson("/api/config"); 409 this.syncConfigState({ addressBookVersion: this.addressBookVersion }); 410 }, 411 412 syncConfigState(options = {}) { 413 this.keepTrackOfMetrics = this.config.keep_track_of_metrics === true; 414 this.recoveryVdfTopRankPercent = Number( 415 this.config.recovery_vdf_top_rank_percent ?? 416 this.config.recoveryVdfTopRankPercent ?? 417 this.recoveryVdfTopRankPercent 418 ); 419 this.p2pAcceptInbound = this.config.p2p_accept_inbound === true; 420 if (!this.p2pBindPortDirty) { 421 this.p2pBindPort = Number(this.config.p2p_bind_port || 9444); 422 } 423 if ( 424 options.addressBookVersion === undefined || 425 options.addressBookVersion >= this.addressBookVersion 426 ) { 427 this.addressBook = this.config.address_book || this.config.addressBook || {}; 428 } 429 if (!this.p2pAnnounceDirty) { 430 this.p2pAnnounceAddr = this.config.p2p_announce_addr || ""; 431 } 432 }, 433 434 async refreshWalletSetup() { 435 const payload = await this.fetchJson("/api/wallet/setup"); 436 if (!payload.ok) { 437 throw new Error(payload.error || "Could not load wallet setup"); 438 } 439 this.setupWallet = payload; 440 if ( 441 payload.seed_phrase && 442 payload.seed_phrase !== this.generatedSeedPhrase && 443 this.setupWalletMode === "create" && 444 !this.walletVerified 445 ) { 446 this.generatedSeedPhrase = payload.seed_phrase; 447 this.walletVerified = false; 448 this.setupSeedStep = "write"; 449 this.verifyChallenges = []; 450 this.verifyAnswers = {}; 451 } 452 }, 453 454 setupSeedWords() { 455 return this.generatedSeedPhrase ? this.generatedSeedPhrase.split(/\s+/) : []; 456 }, 457 458 setupAddress() { 459 return this.setupWallet.address || this.status.wallet_address || "-"; 460 }, 461 462 selectSetupWalletMode(mode) { 463 this.setupWalletMode = mode; 464 this.walletVerified = mode === "import" ? this.walletVerified && !this.generatedSeedPhrase : false; 465 this.setupFeedback = null; 466 }, 467 468 async generateSetupSeed() { 469 try { 470 this.setupFeedback = null; 471 const payload = await this.postWalletSetup("/api/wallet/generate", {}); 472 this.setupWallet = payload; 473 this.generatedSeedPhrase = payload.seed_phrase || ""; 474 this.setupWalletMode = "create"; 475 this.setupSeedStep = "write"; 476 this.walletVerified = false; 477 this.verifyChallenges = []; 478 this.verifyAnswers = {}; 479 await this.refresh({ force: true }); 480 } catch (error) { 481 this.showSetupFeedback(error.message, "error"); 482 } 483 }, 484 485 beginSeedVerification() { 486 this.setupFeedback = null; 487 const words = this.setupSeedWords(); 488 if (words.length < 4) { 489 this.showSetupFeedback("Generate a recovery phrase first", "error"); 490 return; 491 } 492 const positions = words.map((_, index) => index); 493 for (let index = positions.length - 1; index > 0; index -= 1) { 494 const swapIndex = Math.floor(Math.random() * (index + 1)); 495 [positions[index], positions[swapIndex]] = [positions[swapIndex], positions[index]]; 496 } 497 this.verifyChallenges = positions 498 .slice(0, 4) 499 .sort((left, right) => left - right) 500 .map((index) => ({ index, position: index + 1 })); 501 this.verifyAnswers = {}; 502 for (const challenge of this.verifyChallenges) { 503 this.verifyAnswers[challenge.index] = ""; 504 } 505 this.setupSeedStep = "verify"; 506 }, 507 508 verifyGeneratedSeed() { 509 const words = this.setupSeedWords(); 510 const ok = this.verifyChallenges.every((challenge) => { 511 const expected = words[challenge.index] || ""; 512 const actual = (this.verifyAnswers[challenge.index] || "").trim().toLowerCase(); 513 return actual === expected; 514 }); 515 if (!ok) { 516 this.showSetupFeedback("Seed word check failed", "error"); 517 return; 518 } 519 this.walletVerified = true; 520 this.setupSeedStep = "verified"; 521 this.showSetupFeedback("Recovery phrase verified", "success"); 522 }, 523 524 skipSeedVerificationForDev() { 525 if (!this.setupWallet.dev_verify_bypass) return; 526 this.walletVerified = true; 527 this.setupSeedStep = "verified"; 528 this.showSetupFeedback("Recovery phrase verification skipped", "success"); 529 }, 530 531 async importSetupSeed() { 532 try { 533 this.setupFeedback = null; 534 const payload = await this.postWalletSetup("/api/wallet/import", { 535 seed_phrase: this.importSeedPhrase, 536 }); 537 this.setupWallet = payload; 538 this.generatedSeedPhrase = ""; 539 this.verifyChallenges = []; 540 this.verifyAnswers = {}; 541 this.walletVerified = true; 542 this.setupSeedStep = "verified"; 543 await this.refresh({ force: true }); 544 this.showSetupFeedback("Recovery phrase imported", "success"); 545 } catch (error) { 546 this.showSetupFeedback(error.message, "error"); 547 } 548 }, 549 550 async postWalletSetup(path, fields) { 551 const body = new URLSearchParams(); 552 for (const [key, value] of Object.entries(fields)) { 553 body.set(key, value); 554 } 555 const response = await this.fetchWithTimeout(path, { 556 method: "POST", 557 headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, 558 body, 559 }); 560 const payload = await response.json(); 561 if (!response.ok || !payload.ok) { 562 throw new Error(payload.error || `${path} returned ${response.status}`); 563 } 564 return payload; 565 }, 566 567 async completeSetup() { 568 try { 569 if (!this.walletVerified) { 570 throw new Error("Verify or import a recovery phrase first"); 571 } 572 if (this.setupRequiresPeer() && !this.setupHasPeer()) { 573 throw new Error("Add a bootstrap peer before continuing"); 574 } 575 await this.applySetupNodeMode(); 576 const response = await this.fetchWithTimeout("/api/config", { 577 method: "POST", 578 headers: { 579 Accept: "application/json", 580 "Content-Type": "application/x-www-form-urlencoded", 581 }, 582 body: new URLSearchParams({ 583 setup_complete: "true", 584 peer: this.setupPeerAddress.trim(), 585 }), 586 }); 587 const payload = await response.json(); 588 if (!response.ok || !payload.ok) { 589 throw new Error(payload.error || `/api/config returned ${response.status}`); 590 } 591 await this.refresh({ force: true }); 592 this.setupFeedback = null; 593 this.generatedSeedPhrase = ""; 594 this.importSeedPhrase = ""; 595 this.setupPeerAddress = ""; 596 this.verifyChallenges = []; 597 this.verifyAnswers = {}; 598 this.showFlash("Setup complete", "success"); 599 this.setTab("wallet"); 600 } catch (error) { 601 this.showSetupFeedback(error.message, "error"); 602 } 603 }, 604 605 async applySetupNodeMode() { 606 const mode = ["wallet", "non-listening", "listening"].includes(this.setupNodeMode) 607 ? this.setupNodeMode 608 : "wallet"; 609 const acceptInbound = mode === "listening"; 610 if (this.p2pAcceptInbound !== acceptInbound) { 611 await this.submitForm("/api/settings/p2p-inbound", { 612 enabled: acceptInbound, 613 bind_port: this.p2pBindPortValue(), 614 }); 615 this.p2pAcceptInbound = acceptInbound; 616 } 617 this.setUiMode(mode === "wallet" ? "basic" : "advanced"); 618 }, 619 620 async refresh(options = {}) { 621 if (this.refreshPromise) { 622 if (options.force === true) { 623 try { 624 await this.refreshPromise; 625 } catch { 626 // The forced refresh below should report the current state. 627 } 628 } else { 629 return this.refreshPromise; 630 } 631 } 632 this.refreshPromise = this.refreshNow(options).finally(() => { 633 this.refreshPromise = null; 634 }); 635 return this.refreshPromise; 636 }, 637 638 async refreshNow(options = {}) { 639 if (!this.canUseProtectedApi()) return; 640 const addressBookVersion = this.addressBookVersion; 641 const tab = this.tab; 642 const shouldLoadBlocks = tab === "chain" || tab === "mining"; 643 const shouldLoadP2pMetrics = tab === "p2p" && this.developmentMode(); 644 const shouldLoadMetrics = tab === "metrics"; 645 if (shouldLoadMetrics) { 646 await this.refreshMetrics(options); 647 this.refreshShellState({ addressBookVersion, silent: true }); 648 return; 649 } 650 if (shouldLoadBlocks && this.blocks.length === 0) this.loadingInitialBlocks = true; 651 const pagedDatasets = []; 652 if (tab === "wallet") pagedDatasets.push("walletTx", "walletUtxo"); 653 if (tab === "chain") pagedDatasets.push("mempool"); 654 if (tab === "p2p") pagedDatasets.push("peer"); 655 try { 656 const [config, status, blocks, p2pMetrics, blockchainMetrics] = await Promise.all([ 657 this.fetchJson("/api/config"), 658 this.fetchJson("/api/status"), 659 shouldLoadBlocks ? this.fetchJson("/api/blocks?limit=30") : Promise.resolve(null), 660 shouldLoadP2pMetrics ? this.fetchJson("/api/p2p/metrics") : Promise.resolve(this.p2pMetrics), 661 Promise.resolve(this.blockchainMetrics), 662 ]); 663 const previousChainHeight = this.status.chain?.height; 664 this.status = status; 665 this.config = config; 666 this.syncConfigState({ addressBookVersion }); 667 if (!this.allowedTabs().includes(this.tab)) { 668 this.setTab("wallet"); 669 } 670 if (!this.config.setup_complete) { 671 await this.refreshWalletSetup(); 672 } 673 this.syncMempoolBlockMarker(previousChainHeight, status.chain?.height); 674 if (blocks) this.mergeFreshBlocks(blocks, { animateHead: true }); 675 this.pruneSelectedTransferUtxos(); 676 this.p2pMetrics = p2pMetrics; 677 this.blockchainMetrics = blockchainMetrics; 678 this.burnAmount = status.mining?.burn_per_block ?? this.burnAmount; 679 this.burnFee = status.mining?.automatic_burn_fee ?? this.burnFee; 680 this.miningEnabled = status.mining?.automatic ?? this.miningEnabled; 681 this.powMiningEnabled = status.mining?.pow_mining_enabled ?? this.powMiningEnabled; 682 this.powMiningWorkers = status.mining?.pow_mining_workers ?? this.powMiningWorkers; 683 this.maxPowMiningWorkers = 684 status.mining?.max_pow_mining_workers ?? this.maxPowMiningWorkers; 685 if (!this.burnAmountDirty) { 686 this.burnAmountDraft = this.amountLabel(this.burnAmount); 687 this.burnFeeDraft = this.amountLabel(this.burnFee); 688 } 689 this.lastUpdated = new Date(); 690 this.syncMiningEvents({ status, blocks }); 691 this.scheduleFeeEstimates(); 692 this.refreshNetworkHealth({ silent: options.silent === true }); 693 await Promise.all( 694 pagedDatasets.map((kind) => 695 this.refreshPagedDataset(kind, { silent: options.silent === true }) 696 ) 697 ); 698 } catch (error) { 699 if (String(error.message || "").includes("401")) { 700 this.stopPolling(); 701 await this.refreshAuth(); 702 return; 703 } 704 this.showFlash(error.message, "error"); 705 } finally { 706 if (shouldLoadBlocks) this.loadingInitialBlocks = false; 707 } 708 }, 709 710 async refreshShellState(options = {}) { 711 if (!this.canUseProtectedApi()) return; 712 if (this.shellRefreshPromise) return this.shellRefreshPromise; 713 const addressBookVersion = options.addressBookVersion ?? this.addressBookVersion; 714 this.shellRefreshPromise = Promise.all([ 715 this.fetchJson("/api/config"), 716 this.fetchJson("/api/status"), 717 ]) 718 .then(async ([config, status]) => { 719 const previousChainHeight = this.status.chain?.height; 720 this.status = status; 721 this.config = config; 722 this.syncConfigState({ addressBookVersion }); 723 if (!this.allowedTabs().includes(this.tab)) { 724 this.setTab("wallet"); 725 } 726 if (!this.config.setup_complete) { 727 await this.refreshWalletSetup(); 728 } 729 this.syncMempoolBlockMarker(previousChainHeight, status.chain?.height); 730 this.burnAmount = status.mining?.burn_per_block ?? this.burnAmount; 731 this.burnFee = status.mining?.automatic_burn_fee ?? this.burnFee; 732 this.miningEnabled = status.mining?.automatic ?? this.miningEnabled; 733 this.powMiningEnabled = status.mining?.pow_mining_enabled ?? this.powMiningEnabled; 734 this.powMiningWorkers = status.mining?.pow_mining_workers ?? this.powMiningWorkers; 735 this.maxPowMiningWorkers = 736 status.mining?.max_pow_mining_workers ?? this.maxPowMiningWorkers; 737 if (!this.burnAmountDirty) { 738 this.burnAmountDraft = this.amountLabel(this.burnAmount); 739 this.burnFeeDraft = this.amountLabel(this.burnFee); 740 } 741 this.lastUpdated = new Date(); 742 this.syncMiningEvents({ status, blocks: null }); 743 this.scheduleFeeEstimates(); 744 this.refreshNetworkHealth({ silent: true }); 745 }) 746 .catch((error) => { 747 if (options.silent !== true) this.showFlash(error.message, "error"); 748 }) 749 .finally(() => { 750 this.shellRefreshPromise = null; 751 }); 752 return this.shellRefreshPromise; 753 }, 754 755 async refreshNetworkHealth(options = {}) { 756 if (!this.canUseProtectedApi()) return; 757 if (this.networkHealthPromise) return this.networkHealthPromise; 758 this.networkHealthPromise = this.fetchJson("/api/network/health") 759 .then((networkHealth) => { 760 this.networkHealth = networkHealth; 761 return networkHealth; 762 }) 763 .catch((error) => { 764 if (options.silent !== true) this.showFlash(error.message, "error"); 765 return null; 766 }) 767 .finally(() => { 768 this.networkHealthPromise = null; 769 }); 770 return this.networkHealthPromise; 771 }, 772 773 async fetchJson(path) { 774 const response = await this.fetchWithTimeout(path, { 775 headers: { Accept: "application/json" }, 776 cache: "no-store", 777 }); 778 if (!response.ok) { 779 throw new Error(`${path} returned ${response.status}`); 780 } 781 return response.json(); 782 }, 783 784 async fetchWithTimeout(path, options = {}) { 785 const controller = new AbortController(); 786 const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs); 787 try { 788 return await fetch(path, { ...options, signal: controller.signal }); 789 } catch (error) { 790 if (error?.name === "AbortError") { 791 throw new Error(`${path} timed out`); 792 } 793 throw error; 794 } finally { 795 clearTimeout(timeout); 796 } 797 }, 798 799 datasetConfig(kind) { 800 return { 801 walletTx: { 802 items: "walletTxs", 803 page: "walletTxPage", 804 path: () => this.walletTransactionsPath(), 805 key: (tx) => `${tx.status || ""}:${tx.signature || ""}`, 806 }, 807 walletUtxo: { 808 items: "walletUtxos", 809 page: "walletUtxoPage", 810 path: () => "/api/wallet/utxos", 811 key: (utxo) => this.utxoOutpoint(utxo), 812 }, 813 mempool: { 814 items: "mempool", 815 page: "mempoolPage", 816 path: () => "/api/mempool", 817 key: (tx) => tx.signature || "", 818 }, 819 peer: { 820 items: "peers", 821 page: "peerPage", 822 path: () => "/api/peers", 823 key: (peer) => peer.address || "", 824 }, 825 }[kind]; 826 }, 827 828 async resetPagedDataset(kind) { 829 const config = this.datasetConfig(kind); 830 if (!config) return; 831 this[config.items] = []; 832 this.resetPageState(kind); 833 await this.refreshPagedDataset(kind); 834 }, 835 836 resetPageState(kind) { 837 const config = this.datasetConfig(kind); 838 if (!config) return; 839 Object.assign(this[config.page], { 840 offset: 0, 841 total: 0, 842 hasMore: true, 843 loading: false, 844 backgroundLoading: false, 845 }); 846 }, 847 848 async refreshPagedDataset(kind, options = {}) { 849 if (!this.canUseProtectedApi()) return; 850 const config = this.datasetConfig(kind); 851 if (!config) return; 852 const page = this[config.page]; 853 if (page.loading || page.backgroundLoading) return; 854 const currentLength = this[config.items].length; 855 const limit = Math.max(this.datasetPageSize, currentLength || 0); 856 await this.loadPagedDataset(kind, { 857 offset: 0, 858 limit, 859 replace: true, 860 silent: options.silent === true, 861 }); 862 }, 863 864 async loadNextPage(kind) { 865 if (!this.canUseProtectedApi()) return; 866 const config = this.datasetConfig(kind); 867 if (!config) return; 868 const page = this[config.page]; 869 if (page.loading || page.backgroundLoading || !page.hasMore) return; 870 await this.loadPagedDataset(kind, { 871 offset: page.offset ?? this[config.items].length, 872 limit: this.datasetPageSize, 873 replace: false, 874 }); 875 }, 876 877 async loadPagedDataset(kind, options) { 878 const config = this.datasetConfig(kind); 879 const page = this[config.page]; 880 const loadingKey = options.silent === true ? "backgroundLoading" : "loading"; 881 page[loadingKey] = true; 882 try { 883 const payload = await this.fetchJson( 884 this.paginatedPath(config.path(), options.offset, options.limit) 885 ); 886 const normalized = this.normalizedPage(payload, options.offset, options.limit); 887 this[config.items] = options.replace 888 ? normalized.items 889 : this.mergeDatasetItems(this[config.items], normalized.items, config.key); 890 if (kind === "mempool") { 891 this.trackMempoolFirstSeenHeights({ append: options.replace !== true }); 892 this.sortMempoolNewestFirst(); 893 } 894 page.offset = normalized.nextOffset ?? this[config.items].length; 895 page.total = normalized.total; 896 page.hasMore = normalized.hasMore; 897 if (kind === "walletUtxo") { 898 this.rememberUtxoAmounts(this.walletUtxos); 899 this.pruneSelectedTransferUtxos(); 900 } 901 } catch (error) { 902 this.showFlash(error.message, "error"); 903 } finally { 904 page[loadingKey] = false; 905 } 906 }, 907 908 paginatedPath(path, offset, limit) { 909 const url = new URL(path, window.location.origin); 910 url.searchParams.set("offset", String(offset)); 911 url.searchParams.set("limit", String(limit)); 912 return `${url.pathname}?${url.searchParams.toString()}`; 913 }, 914 915 normalizedPage(payload, offset, limit) { 916 if (Array.isArray(payload)) { 917 const nextOffset = offset + payload.length; 918 return { 919 items: payload, 920 total: nextOffset, 921 hasMore: payload.length >= limit, 922 nextOffset, 923 }; 924 } 925 const items = Array.isArray(payload?.items) ? payload.items : []; 926 return { 927 items, 928 total: Number(payload?.total ?? offset + items.length), 929 hasMore: payload?.hasMore === true, 930 nextOffset: payload?.nextOffset ?? offset + items.length, 931 }; 932 }, 933 934 mergeDatasetItems(existing, incoming, keyFn) { 935 const rows = []; 936 const seen = new Set(); 937 for (const item of [...existing, ...incoming]) { 938 const key = keyFn(item); 939 if (!key || seen.has(key)) continue; 940 seen.add(key); 941 rows.push(item); 942 } 943 return rows; 944 }, 945 946 syncMempoolBlockMarker(previousHeight, currentHeight) { 947 const normalizedCurrent = Number(currentHeight); 948 if (!Number.isFinite(normalizedCurrent)) return; 949 const normalizedPrevious = Number(previousHeight); 950 if (this.lastBlockMempoolHeight === null) { 951 this.lastBlockMempoolHeight = normalizedCurrent; 952 return; 953 } 954 if (!Number.isFinite(normalizedPrevious) || normalizedCurrent > normalizedPrevious) { 955 this.lastBlockMempoolHeight = normalizedCurrent; 956 } 957 }, 958 959 trackMempoolFirstSeenHeights(options = {}) { 960 const height = Number(this.status.chain?.height); 961 if (!Number.isFinite(height)) return; 962 const active = new Set(); 963 const firstBatch = !this.mempoolSeenInitialized; 964 const seenHeight = firstBatch ? height - 1 : height; 965 const knownSeenTimes = Object.values(this.mempoolFirstSeenAt) 966 .map((value) => Number(value)) 967 .filter((value) => Number.isFinite(value)); 968 const oldestSeenAt = knownSeenTimes.length ? Math.min(...knownSeenTimes) : Date.now(); 969 const baseSeenAt = options.append && this.mempoolSeenInitialized 970 ? oldestSeenAt - 1 971 : Date.now(); 972 let newIndex = 0; 973 for (const tx of this.mempool) { 974 const key = this.mempoolKey(tx); 975 if (!key) continue; 976 active.add(key); 977 if (this.mempoolFirstSeenHeights[key] === undefined) { 978 this.mempoolFirstSeenHeights[key] = seenHeight; 979 this.mempoolFirstSeenAt[key] = baseSeenAt - newIndex; 980 newIndex += 1; 981 } 982 } 983 this.mempoolSeenInitialized = true; 984 for (const key of Object.keys(this.mempoolFirstSeenHeights)) { 985 if (!active.has(key)) { 986 delete this.mempoolFirstSeenHeights[key]; 987 delete this.mempoolFirstSeenAt[key]; 988 } 989 } 990 }, 991 992 mempoolKey(tx) { 993 return tx?.signature || tx?.commitment || ""; 994 }, 995 996 mempoolItemClass(tx) { 997 const key = this.mempoolKey(tx); 998 const firstSeenHeight = Number(this.mempoolFirstSeenHeights[key]); 999 const markerHeight = Number(this.status.chain?.height ?? this.lastBlockMempoolHeight); 1000 const classes = []; 1001 if (isBlindedMempoolItem(tx)) classes.push("blinded-hidden"); 1002 if (key && Number.isFinite(firstSeenHeight) && Number.isFinite(markerHeight)) { 1003 classes.push(firstSeenHeight >= markerHeight ? "new-since-block" : "before-last-block"); 1004 } 1005 return classes.join(" "); 1006 }, 1007 1008 mempoolSeenTimeLabel(tx) { 1009 const seenAt = Number(this.mempoolFirstSeenAt[this.mempoolKey(tx)]); 1010 if (!Number.isFinite(seenAt)) return ""; 1011 return `Seen ${new Date(seenAt).toLocaleTimeString()}`; 1012 }, 1013 1014 sortMempoolNewestFirst() { 1015 this.mempool = [...this.mempool].sort((left, right) => { 1016 const leftSeenAt = Number(this.mempoolFirstSeenAt[this.mempoolKey(left)]); 1017 const rightSeenAt = Number(this.mempoolFirstSeenAt[this.mempoolKey(right)]); 1018 if (Number.isFinite(leftSeenAt) && Number.isFinite(rightSeenAt) && leftSeenAt !== rightSeenAt) { 1019 return rightSeenAt - leftSeenAt; 1020 } 1021 const leftSeen = Number(this.mempoolFirstSeenHeights[this.mempoolKey(left)]); 1022 const rightSeen = Number(this.mempoolFirstSeenHeights[this.mempoolKey(right)]); 1023 if (Number.isFinite(leftSeen) && Number.isFinite(rightSeen) && leftSeen !== rightSeen) { 1024 return rightSeen - leftSeen; 1025 } 1026 return this.mempoolKey(right).localeCompare(this.mempoolKey(left)); 1027 }); 1028 }, 1029 1030 observePageSentinel(kind, element) { 1031 if (!element || element.__iunaPageObserver) return; 1032 const observer = new IntersectionObserver((entries) => { 1033 if (this.canUseProtectedApi() && entries.some((entry) => entry.isIntersecting)) { 1034 this.loadNextPage(kind); 1035 } 1036 }, { root: null, rootMargin: "180px 0px" }); 1037 observer.observe(element); 1038 element.__iunaPageObserver = observer; 1039 }, 1040 1041 observeBlockSentinel(element) { 1042 if (!element || element.__iunaBlockObserver) return; 1043 const observer = new IntersectionObserver((entries) => { 1044 if (this.canUseProtectedApi() && entries.some((entry) => entry.isIntersecting)) { 1045 this.loadOlderBlocks(); 1046 } 1047 }, { root: null, rootMargin: "180px 0px" }); 1048 observer.observe(element); 1049 element.__iunaBlockObserver = observer; 1050 }, 1051 1052 walletTransactionsPath() { 1053 const params = new URLSearchParams({ 1054 tx: String(this.walletTxFilters.transfer), 1055 mine: String(this.walletTxFilters.mine), 1056 burn: String(this.walletTxFilters.burn), 1057 }); 1058 return `/api/wallet/transactions?${params.toString()}`; 1059 }, 1060 1061 async refreshWalletTransactions() { 1062 await this.resetPagedDataset("walletTx"); 1063 }, 1064 1065 async checkLatestRelease() { 1066 if (this.releaseCheckState === "checking") return; 1067 this.releaseCheckState = "checking"; 1068 this.releaseCheckError = null; 1069 try { 1070 const response = await fetch(IUNA_RELEASE_METADATA_URL, { 1071 cache: "no-store", 1072 headers: { Accept: "application/json" }, 1073 }); 1074 if (!response.ok) { 1075 throw new Error(`Release check failed (${response.status})`); 1076 } 1077 const release = await response.json(); 1078 const version = this.normalizeVersion(release.tag || release.version); 1079 if (!version) { 1080 throw new Error("Release metadata is missing a version"); 1081 } 1082 this.latestRelease = { 1083 tag: `v${version}`, 1084 url: release.url || IUNA_DOWNLOADS_URL, 1085 }; 1086 this.releaseCheckState = "done"; 1087 } catch (error) { 1088 this.releaseCheckError = error.message || "Release check failed"; 1089 this.releaseCheckState = "failed"; 1090 } 1091 }, 1092 1093 mergeFreshBlocks(freshBlocks, options = {}) { 1094 const hadBlocks = this.blocks.length > 0; 1095 const previousHeights = new Set(this.blocks.map((block) => block.height)); 1096 const previousHead = this.blocks[0]?.height; 1097 const previousHeadHash = this.blocks[0]?.hash; 1098 const wasFollowingHead = 1099 !this.selectedBlock || (previousHeadHash && this.selectedBlock.hash === previousHeadHash); 1100 const rail = this.$refs.blockRail; 1101 const previousScrollWidth = hadBlocks ? rail?.scrollWidth ?? 0 : 0; 1102 const known = new Map(this.blocks.map((block) => [block.hash, block])); 1103 for (const block of freshBlocks) { 1104 known.set(block.hash, block); 1105 } 1106 this.blocks = Array.from(known.values()).sort((left, right) => right.height - left.height); 1107 const currentHead = this.blocks[0] || null; 1108 if (wasFollowingHead) { 1109 this.selectedBlock = currentHead; 1110 } else if (!this.selectedBlock || !known.has(this.selectedBlock.hash)) { 1111 this.selectedBlock = this.blocks[0] || null; 1112 } else { 1113 this.selectedBlock = known.get(this.selectedBlock.hash); 1114 } 1115 this.hasMoreBlocks = 1116 this.blocks.some((block) => block.height > 0) && 1117 !this.blocks.some((block) => block.height === 0); 1118 1119 const newHeadBlocks = options.animateHead 1120 && hadBlocks 1121 ? this.blocks.filter( 1122 (block) => 1123 !previousHeights.has(block.height) && 1124 (typeof previousHead !== "number" || block.height > previousHead) 1125 ) 1126 : []; 1127 if (newHeadBlocks.length > 0) { 1128 this.markNewBlocks(newHeadBlocks.map((block) => block.hash)); 1129 this.$nextTick(() => 1130 this.slideNewHeadBlocks(previousScrollWidth, { force: wasFollowingHead }) 1131 ); 1132 } else if (!hadBlocks) { 1133 this.$nextTick(() => this.resetBlockRailPosition()); 1134 } 1135 this.$nextTick(() => this.maybeLoadOlderBlocksFromRail()); 1136 }, 1137 1138 markNewBlocks(hashes) { 1139 this.newBlockHashes = new Set(hashes); 1140 if (this.newBlockTimer) { 1141 clearTimeout(this.newBlockTimer); 1142 } 1143 this.newBlockTimer = setTimeout(() => { 1144 this.newBlockHashes = new Set(); 1145 this.newBlockTimer = null; 1146 }, 650); 1147 }, 1148 1149 slideNewHeadBlocks(previousScrollWidth, options = {}) { 1150 const rail = this.$refs.blockRail; 1151 if (!rail || previousScrollWidth === 0 || (!options.force && rail.scrollLeft > 4)) return; 1152 const addedWidth = rail.scrollWidth - previousScrollWidth; 1153 if (addedWidth <= 0) return; 1154 rail.scrollLeft = addedWidth; 1155 rail.scrollTo({ left: 0, behavior: "smooth" }); 1156 }, 1157 1158 resetBlockRailPosition() { 1159 const rail = this.$refs.blockRail; 1160 if (!rail) return; 1161 rail.scrollLeft = 0; 1162 }, 1163 1164 selectBlock(block) { 1165 this.selectedBlock = block; 1166 }, 1167 1168 openBurnLeaderRanksModal(block) { 1169 this.selectedBurnLeaderBlock = block; 1170 }, 1171 1172 closeBurnLeaderRanksModal() { 1173 this.selectedBurnLeaderBlock = null; 1174 }, 1175 1176 openBlockBytesModal(block) { 1177 this.selectedByteBlock = block; 1178 }, 1179 1180 closeBlockBytesModal() { 1181 this.selectedByteBlock = null; 1182 }, 1183 1184 openTransactionModal(tx, context = {}) { 1185 this.selectedTransaction = { tx, context }; 1186 }, 1187 1188 closeTransactionModal() { 1189 this.selectedTransaction = null; 1190 }, 1191 1192 openWalletUtxosModal() { 1193 this.showWalletUtxos = true; 1194 }, 1195 1196 closeWalletUtxosModal() { 1197 this.showWalletUtxos = false; 1198 }, 1199 1200 openPowDifficultyInfo() { 1201 this.showPowDifficultyInfo = true; 1202 }, 1203 1204 closePowDifficultyInfo() { 1205 this.showPowDifficultyInfo = false; 1206 }, 1207 1208 openChainResetModal() { 1209 this.chainResetConfirm = ""; 1210 this.chainResetModalOpen = true; 1211 }, 1212 1213 closeChainResetModal() { 1214 if (this.chainResetBusy) return; 1215 this.chainResetModalOpen = false; 1216 this.chainResetConfirm = ""; 1217 }, 1218 1219 async resetLocalChain() { 1220 if (this.chainResetConfirm.trim() !== "RESET") { 1221 this.showFlash("Type RESET to confirm deleting the local chain", "error"); 1222 return; 1223 } 1224 this.chainResetBusy = true; 1225 try { 1226 await this.submitForm("/api/settings/chain-reset", { 1227 confirm: this.chainResetConfirm, 1228 }); 1229 this.blocks = []; 1230 this.selectedBlock = null; 1231 this.selectedByteBlock = null; 1232 this.selectedBurnLeaderBlock = null; 1233 this.selectedTransaction = null; 1234 this.mempool = []; 1235 this.walletTxs = []; 1236 this.walletUtxos = []; 1237 this.mempoolFirstSeenHeights = {}; 1238 this.mempoolFirstSeenAt = {}; 1239 this.mempoolSeenInitialized = false; 1240 this.lastBlockMempoolHeight = null; 1241 this.resetPageState("walletTx"); 1242 this.resetPageState("walletUtxo"); 1243 this.resetPageState("mempool"); 1244 this.chainResetModalOpen = false; 1245 this.chainResetConfirm = ""; 1246 await this.refresh({ force: true }); 1247 this.showFlash("Local chain deleted. Sync requested from peers.", "success"); 1248 } catch (error) { 1249 this.showFlash(error.message, "error"); 1250 } finally { 1251 this.chainResetBusy = false; 1252 } 1253 }, 1254 1255 closeModals() { 1256 this.closeTransactionModal(); 1257 this.closeWalletUtxosModal(); 1258 this.closePowDifficultyInfo(); 1259 this.closeBurnLeaderRanksModal(); 1260 this.closeChainResetModal(); 1261 }, 1262 1263 async loadOlderBlocks() { 1264 if (!this.canUseProtectedApi()) return; 1265 if (this.loadingOlder || !this.hasMoreBlocks || this.blocks.length === 0) return; 1266 const oldest = Math.min(...this.blocks.map((block) => block.height)); 1267 if (oldest <= 0) { 1268 this.hasMoreBlocks = false; 1269 return; 1270 } 1271 this.loadingOlder = true; 1272 try { 1273 const older = await this.fetchJson( 1274 `/api/blocks?before_height=${oldest}&limit=${this.blockPageSize}` 1275 ); 1276 if ( 1277 older.length === 0 || 1278 older.length < this.blockPageSize || 1279 older.some((block) => block.height === 0) 1280 ) { 1281 this.hasMoreBlocks = false; 1282 } 1283 this.mergeFreshBlocks(older); 1284 } catch (error) { 1285 this.showFlash(error.message, "error"); 1286 } finally { 1287 this.loadingOlder = false; 1288 } 1289 }, 1290 1291 maybeLoadOlderBlocks(event) { 1292 this.maybeLoadOlderBlocksFromRail(event.currentTarget); 1293 }, 1294 1295 maybeLoadOlderBlocksFromRail(rail = this.$refs.blockRail) { 1296 if (this.tab !== "chain" || !rail || this.loadingOlder || !this.hasMoreBlocks) return; 1297 const remaining = rail.scrollWidth - rail.scrollLeft - rail.clientWidth; 1298 if (remaining <= 180) { 1299 this.loadOlderBlocks(); 1300 } 1301 }, 1302 1303 async postForm(path, fields, successMessage, method = "POST") { 1304 await this.submitForm(path, fields, method); 1305 await this.refresh({ force: true }); 1306 this.showFlash(successMessage, "success"); 1307 }, 1308 1309 async submitForm(path, fields, method = "POST") { 1310 const body = new URLSearchParams(); 1311 for (const [key, value] of Object.entries(fields)) { 1312 if (Array.isArray(value)) { 1313 for (const item of value) body.append(key, item); 1314 } else { 1315 body.set(key, value); 1316 } 1317 } 1318 const response = await this.fetchWithTimeout(path, { 1319 method, 1320 headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, 1321 body, 1322 }); 1323 const text = await response.text(); 1324 let payload = { ok: response.ok, error: null }; 1325 if (text) { 1326 try { 1327 payload = JSON.parse(text); 1328 } catch { 1329 payload = { ok: false, error: text }; 1330 } 1331 } 1332 if (!response.ok || !payload.ok) { 1333 throw new Error(payload?.error || `${path} returned ${response.status}`); 1334 } 1335 return payload; 1336 }, 1337 1338 scheduleFeeEstimates() { 1339 if (this.feeEstimateTimer) clearTimeout(this.feeEstimateTimer); 1340 this.feeEstimateTimer = setTimeout(() => this.refreshFeeEstimates(), 220); 1341 }, 1342 1343 async refreshFeeEstimates() { 1344 if (this.showingAuth()) return; 1345 if (this.tab === "wallet") { 1346 await this.refreshTransferFeeEstimate(); 1347 return; 1348 } 1349 if (this.tab === "mining") { 1350 await Promise.all([ 1351 this.refreshBurnFeeEstimate(), 1352 this.refreshMineFeeEstimate(), 1353 ]); 1354 } 1355 }, 1356 1357 async refreshBurnFeeEstimate() { 1358 const amount = this.parseiunaAmount(this.burnAmountDraft); 1359 const feePerByte = this.parseiunaAmount(this.burnFeeDraft); 1360 if (amount <= 0) { 1361 this.feeEstimates.burn = null; 1362 return; 1363 } 1364 this.feeEstimates.burn = await this.fetchFeeEstimate("/api/fee-estimate/burn", { 1365 amount, 1366 fee_per_byte: feePerByte, 1367 }); 1368 }, 1369 1370 async refreshMineFeeEstimate() { 1371 this.feeEstimates.mine = await this.fetchFeeEstimate("/api/fee-estimate/mine", {}); 1372 }, 1373 1374 async refreshTransferFeeEstimate() { 1375 const amount = this.parseiunaAmount(this.transferAmount); 1376 const feePerByte = this.parseiunaAmount(this.transferFee); 1377 if (!this.transferTo.trim() || amount <= 0) { 1378 this.feeEstimates.transfer = null; 1379 return; 1380 } 1381 this.feeEstimates.transfer = await this.fetchFeeEstimate("/api/fee-estimate/transfer", { 1382 to: this.transferTo, 1383 amount, 1384 fee_per_byte: feePerByte, 1385 utxos: this.selectedTransferUtxos.join("\n"), 1386 }); 1387 }, 1388 1389 async fetchFeeEstimate(path, fields) { 1390 try { 1391 const body = new URLSearchParams(); 1392 for (const [key, value] of Object.entries(fields)) body.set(key, value); 1393 const response = await this.fetchWithTimeout(path, { 1394 method: "POST", 1395 headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, 1396 body, 1397 }); 1398 const payload = await response.json(); 1399 if (!response.ok || !payload.ok) { 1400 return { error: payload.error || `${path} returned ${response.status}` }; 1401 } 1402 return payload; 1403 } catch (error) { 1404 return { error: error.message }; 1405 } 1406 }, 1407 1408 feeEstimateLabel(kind) { 1409 const estimate = this.feeEstimates[kind]; 1410 if (!estimate) return "Enter details to estimate fee"; 1411 if (estimate.error) return estimate.error; 1412 return `${estimate.bytes} bytes -> IUNA ${this.amountLabel(estimate.fee)}`; 1413 }, 1414 1415 async saveBurn() { 1416 try { 1417 const amount = this.parseiunaAmount(this.burnAmountDraft); 1418 const fee = this.parseiunaAmountRequired(this.burnFeeDraft, "Burn fee per byte is required"); 1419 if (amount === 0) { 1420 throw new Error("IUNA per block must be greater than zero"); 1421 } 1422 this.burnAmountDraft = this.amountLabel(amount); 1423 this.burnFeeDraft = this.amountLabel(fee); 1424 await this.postForm( 1425 "/api/settings/burn-per-block", 1426 { enabled: this.miningEnabled, amount, fee_per_byte: fee }, 1427 this.miningEnabled 1428 ? `Finalization burns on: ${this.amountLabel(amount)} IUNA per block with ${this.amountLabel(fee)} per byte` 1429 : `Burn settings saved while off` 1430 ); 1431 this.appendMiningEvent("Burn settings saved", `Configured ${this.amountLabel(amount)} IUNA per block with ${this.amountLabel(fee)} IUNA fee/byte.`, "info"); 1432 this.burnAmountDirty = false; 1433 this.burnAmount = amount; 1434 this.burnFee = fee; 1435 } catch (error) { 1436 this.showFlash(error.message, "error"); 1437 } 1438 }, 1439 1440 async setMiningEnabled(enabled) { 1441 const previous = this.miningEnabled; 1442 try { 1443 const amount = this.parseiunaAmount(this.burnAmountDraft); 1444 const fee = this.parseiunaAmountRequired(this.burnFeeDraft, "Burn fee per byte is required"); 1445 if (enabled && amount === 0) { 1446 this.miningEnabled = false; 1447 throw new Error("Set IUNA per block before turning finalization burns on"); 1448 } 1449 this.miningEnabled = enabled; 1450 await this.postForm( 1451 "/api/settings/burn-per-block", 1452 { enabled, amount, fee_per_byte: fee }, 1453 enabled ? "Finalization burns turned on" : "Finalization burns turned off" 1454 ); 1455 this.appendMiningEvent( 1456 enabled ? "Finalization burns turned on" : "Finalization burns turned off", 1457 enabled 1458 ? `Burning ${this.amountLabel(amount)} IUNA per block with ${this.amountLabel(fee)} IUNA fee/byte.` 1459 : "Automatic burn preparation paused.", 1460 enabled ? "active" : "warning" 1461 ); 1462 this.miningEventState.pob = enabled ? "on" : "off"; 1463 this.burnAmountDirty = false; 1464 this.burnAmount = amount; 1465 this.burnFee = fee; 1466 } catch (error) { 1467 this.miningEnabled = previous; 1468 this.showFlash(error.message, "error"); 1469 } 1470 }, 1471 1472 async setPowMiningEnabled(enabled) { 1473 const previous = this.powMiningEnabled; 1474 try { 1475 this.powMiningEnabled = enabled; 1476 await this.postForm( 1477 "/api/settings/pow-mining", 1478 { enabled, workers: this.powMiningWorkers }, 1479 enabled ? "PoW mining turned on" : "PoW mining turned off" 1480 ); 1481 this.appendMiningEvent( 1482 enabled ? "PoW mining turned on" : "PoW mining turned off", 1483 enabled 1484 ? `Resource budget: ${this.powMiningWorkers} worker${this.powMiningWorkers === 1 ? "" : "s"}.` 1485 : "PoW worker search paused.", 1486 enabled ? "active" : "warning" 1487 ); 1488 this.miningEventState["pow-workers"] = String(this.powMiningWorkers); 1489 } catch (error) { 1490 this.powMiningEnabled = previous; 1491 this.showFlash(error.message, "error"); 1492 } 1493 }, 1494 1495 async setPowMiningWorkers(workers) { 1496 const previous = this.powMiningWorkers; 1497 const parsed = Number.parseInt(workers, 10); 1498 const clamped = Math.min( 1499 this.maxPowMiningWorkers, 1500 Math.max(1, Number.isFinite(parsed) ? parsed : 1) 1501 ); 1502 try { 1503 this.powMiningWorkers = clamped; 1504 await this.postForm( 1505 "/api/settings/pow-mining", 1506 { enabled: this.powMiningEnabled, workers: clamped }, 1507 `PoW workers set to ${clamped}` 1508 ); 1509 this.appendMiningEvent( 1510 "PoW worker budget changed", 1511 `Resource budget: ${clamped} worker${clamped === 1 ? "" : "s"}.`, 1512 "info" 1513 ); 1514 this.miningEventState["pow-workers"] = String(clamped); 1515 } catch (error) { 1516 this.powMiningWorkers = previous; 1517 this.showFlash(error.message, "error"); 1518 } 1519 }, 1520 1521 async setKeepTrackOfMetrics(enabled) { 1522 const previous = this.keepTrackOfMetrics; 1523 try { 1524 this.keepTrackOfMetrics = enabled; 1525 await this.postForm( 1526 "/api/settings/metrics", 1527 { enabled }, 1528 enabled ? "Development mode turned on" : "Development mode turned off" 1529 ); 1530 await this.refreshConfig(); 1531 if (!enabled && this.tab === "metrics") { 1532 this.setTab("settings"); 1533 } 1534 } catch (error) { 1535 this.keepTrackOfMetrics = previous; 1536 this.showFlash(error.message, "error"); 1537 } 1538 }, 1539 1540 async setRecoveryVdfTopRankPercent(percent) { 1541 const previous = this.recoveryVdfTopRankPercent; 1542 const normalized = Math.max(0, Math.min(100, Math.round(Number(percent) || 0))); 1543 try { 1544 this.recoveryVdfTopRankPercent = normalized; 1545 await this.postForm( 1546 "/api/settings/recovery-vdf", 1547 { top_rank_percent: String(normalized) }, 1548 `Recovery VDF threshold set to top ${normalized}%` 1549 ); 1550 await this.refreshConfig(); 1551 } catch (error) { 1552 this.recoveryVdfTopRankPercent = previous; 1553 this.showFlash(error.message, "error"); 1554 } 1555 }, 1556 1557 async setP2pAcceptInbound(enabled) { 1558 const previous = this.p2pAcceptInbound; 1559 try { 1560 this.p2pAcceptInbound = enabled; 1561 await this.postForm( 1562 "/api/settings/p2p-inbound", 1563 { enabled, bind_port: this.p2pBindPortValue() }, 1564 enabled ? "Public node setting saved" : "Switched to outbound-only P2P" 1565 ); 1566 this.p2pBindPortDirty = false; 1567 await this.refreshConfig(); 1568 } catch (error) { 1569 this.p2pAcceptInbound = previous; 1570 this.showFlash(error.message, "error"); 1571 } 1572 }, 1573 1574 p2pBindPortValue() { 1575 const port = Number(this.p2pBindPort); 1576 if (!Number.isInteger(port) || port < 1 || port > 65535) { 1577 throw new Error("P2P bind port must be between 1 and 65535"); 1578 } 1579 return port; 1580 }, 1581 1582 p2pConfiguredBindAddr() { 1583 const port = Number(this.config.p2p_bind_port || 9444); 1584 if (!Number.isInteger(port) || port < 1 || port > 65535) return null; 1585 return `0.0.0.0:${port}`; 1586 }, 1587 1588 p2pRestartRequired() { 1589 const runtimeActive = this.config.p2p_inbound_runtime_active === true; 1590 if (this.p2pAcceptInbound !== runtimeActive) return true; 1591 if (!this.p2pAcceptInbound) return false; 1592 const configured = this.p2pConfiguredBindAddr(); 1593 return configured ? this.config.p2p_runtime_bind_addr !== configured : false; 1594 }, 1595 1596 p2pRestartMessage() { 1597 if (!this.p2pRestartRequired()) return ""; 1598 if (!this.p2pAcceptInbound && this.config.p2p_inbound_runtime_active === true) { 1599 return "Restart iuna to close the public P2P listener."; 1600 } 1601 const configured = this.p2pConfiguredBindAddr(); 1602 return `Restart iuna to open public P2P on ${configured || "the configured bind port"}.`; 1603 }, 1604 1605 async saveP2pAnnounce() { 1606 if (!this.p2pAcceptInbound) { 1607 this.showFlash("Enable public node before setting a public P2P address", "error"); 1608 return; 1609 } 1610 const addr = this.p2pAnnounceAddr.trim(); 1611 try { 1612 if (this.p2pBindPortDirty) { 1613 await this.submitForm("/api/settings/p2p-inbound", { 1614 enabled: true, 1615 bind_port: this.p2pBindPortValue(), 1616 }); 1617 this.p2pBindPortDirty = false; 1618 } 1619 await this.postForm( 1620 "/api/settings/p2p-announce", 1621 { addr }, 1622 addr ? "P2P announce address saved" : "P2P announce address cleared" 1623 ); 1624 this.p2pAnnounceAddr = addr; 1625 this.p2pAnnounceDirty = false; 1626 await this.refreshConfig(); 1627 } catch (error) { 1628 this.showFlash(error.message, "error"); 1629 } 1630 }, 1631 1632 automaticBurnFeeDraft() { 1633 return this.parseiunaAmount(this.burnFeeDraft); 1634 }, 1635 1636 powMineReward() { 1637 return Math.max(0, Math.trunc(Number(this.status.chain?.mine_reward ?? 1000000))); 1638 }, 1639 1640 pobStatusLabel() { 1641 const mining = this.status.mining; 1642 if (!mining) return "-"; 1643 if (this.status.wallet_locked) return "Wallet locked"; 1644 if (!mining.automatic) return "Off"; 1645 if ((mining.burn_per_block ?? 0) <= 0) return "Anchor only"; 1646 if (mining.wallet_is_current_leader) return "Selected"; 1647 if (mining.current_leader) return "Waiting"; 1648 return "Recovery standby"; 1649 }, 1650 1651 powStatusShortLabel() { 1652 if (this.status.wallet_locked) return "Wallet locked"; 1653 if (!this.powMiningEnabled) return "Off"; 1654 const status = this.status.mining?.last_auto_pow_mine_status || ""; 1655 if (status.includes("queued")) return "Queued"; 1656 if (status.includes("searched")) return "Searching"; 1657 if (status.includes("waiting")) return "Waiting"; 1658 if (status.includes("failed")) return "Error"; 1659 return `${this.powMiningWorkers} worker${this.powMiningWorkers === 1 ? "" : "s"}`; 1660 }, 1661 1662 pobDetailLabel() { 1663 return this.status.mining?.last_auto_finalization_status || "Waiting for next automatic finalization tick"; 1664 }, 1665 1666 autoPowStatusLabel() { 1667 if (!this.powMiningEnabled) return "PoW mining is off"; 1668 const status = 1669 this.status.mining?.last_auto_pow_mine_status || "Waiting for next automatic PoW mining tick"; 1670 return `${status} (${this.powMiningWorkers} worker${this.powMiningWorkers === 1 ? "" : "s"})`; 1671 }, 1672 1673 currentFinalizerLabel() { 1674 const leader = this.status.mining?.current_leader ?? this.status.chain?.next_leader; 1675 if (!leader) return "-"; 1676 if (leader === this.status.wallet_address) return "you"; 1677 return this.shortAddressLabel(leader); 1678 }, 1679 1680 localMiningMempoolLabel() { 1681 const pending = this.status.chain?.pending_transactions; 1682 if (typeof pending !== "number") return "-"; 1683 const visibleMines = this.localMineActionCount(); 1684 return `${pending} pending / ${visibleMines} visible mines`; 1685 }, 1686 1687 powDifficultyLabel() { 1688 return this.status.chain?.current_mine_difficulty_bits ?? this.status.launch_profile?.mine_difficulty_bits ?? "-"; 1689 }, 1690 1691 localMineActionCount() { 1692 return this.mempool.filter((tx) => tx?.kind === "mine").length; 1693 }, 1694 1695 appendMiningEvent(title, detail, kind = "info", timestamp = new Date()) { 1696 const last = this.miningEvents[0]; 1697 if (last?.title === title && last?.detail === detail && last?.kind === kind) return; 1698 this.miningEventCounter += 1; 1699 const entry = { 1700 key: `${timestamp.getTime()}-${this.miningEventCounter}`, 1701 timestamp, 1702 time: timestamp.toLocaleTimeString(), 1703 kind, 1704 title, 1705 detail, 1706 }; 1707 this.miningEvents = [entry, ...this.miningEvents].slice(0, this.miningEventLimit); 1708 }, 1709 1710 isPowMineSuccessStatus(status) { 1711 return /queued mine action/i.test(status || ""); 1712 }, 1713 1714 syncMiningEvents({ status, blocks }) { 1715 const mining = status?.mining || {}; 1716 const chain = status?.chain || {}; 1717 if (!this.miningEventState.started) { 1718 this.appendMiningEvent( 1719 "Mining log started", 1720 `Height ${chain.height ?? "-"}, PoB ${mining.automatic ? "on" : "off"}, PoW ${mining.pow_mining_enabled ? "on" : "off"}.`, 1721 "info" 1722 ); 1723 this.miningEventState.started = true; 1724 } 1725 1726 this.noteMiningStateChange( 1727 "pob", 1728 mining.automatic ? "on" : "off", 1729 mining.automatic ? "Finalization burns active" : "Finalization burns inactive", 1730 mining.automatic 1731 ? `Burning ${this.amountLabel(mining.burn_per_block || 0)} IUNA per block with ${this.amountLabel(mining.automatic_burn_fee || 0)} IUNA fee/byte.` 1732 : "Automatic burn preparation is off.", 1733 mining.automatic ? "active" : "warning" 1734 ); 1735 const finalizationStatus = mining.last_auto_finalization_status || ""; 1736 this.noteMiningStateChange( 1737 "pob-status", 1738 finalizationStatus, 1739 "PoB status", 1740 finalizationStatus || "Waiting for next automatic finalization tick.", 1741 mining.automatic ? "active" : "info" 1742 ); 1743 this.noteMiningStateChange( 1744 "pow-workers", 1745 String(mining.pow_mining_workers ?? this.powMiningWorkers), 1746 "PoW worker budget", 1747 `Resource budget: ${mining.pow_mining_workers ?? this.powMiningWorkers} worker${(mining.pow_mining_workers ?? this.powMiningWorkers) === 1 ? "" : "s"}.`, 1748 "info" 1749 ); 1750 const powMineStatus = mining.last_auto_pow_mine_status || ""; 1751 if (this.isPowMineSuccessStatus(powMineStatus)) { 1752 this.noteMiningStateChange( 1753 "pow-mine-success", 1754 powMineStatus, 1755 "You mined a PoW action", 1756 `${powMineStatus}. Waiting for a finalizer to include it in a block.`, 1757 "active" 1758 ); 1759 } else { 1760 this.noteMiningStateChange( 1761 "pow-status", 1762 powMineStatus, 1763 "PoW status", 1764 powMineStatus || "Waiting for next automatic PoW mining tick.", 1765 mining.pow_mining_enabled ? "active" : "info" 1766 ); 1767 } 1768 this.noteMiningStateChange( 1769 "leader", 1770 mining.current_leader || "", 1771 mining.wallet_is_current_leader ? "This wallet is selected" : "Selected finalizer changed", 1772 mining.current_leader 1773 ? `Current finalizer: ${this.currentFinalizerLabel()} at height ${chain.height ?? "-"}.` 1774 : `No current finalizer reported at height ${chain.height ?? "-"}.`, 1775 mining.wallet_is_current_leader ? "active" : "info" 1776 ); 1777 if (typeof mining.last_auto_burn_height === "number") { 1778 this.noteMiningStateChange( 1779 "last-burn-height", 1780 String(mining.last_auto_burn_height), 1781 `Automatic burn prepared at height ${mining.last_auto_burn_height}`, 1782 "Eligible for the next block opportunity.", 1783 "active" 1784 ); 1785 } 1786 1787 const latestBlock = Array.isArray(blocks) 1788 ? blocks.find((block) => Number(block?.height) > 0) 1789 : this.blocks.find((block) => Number(block?.height) > 0); 1790 if (latestBlock) { 1791 const finalizer = this.addressLabel(latestBlock.miner); 1792 const locallyFinalized = latestBlock.miner === status.wallet_address; 1793 if (locallyFinalized) { 1794 this.noteMiningStateChange( 1795 "latest-local-block", 1796 latestBlock.hash || String(latestBlock.height), 1797 `You finalized block ${latestBlock.height}`, 1798 `Success. ${this.burnCountLabel(latestBlock)} burned, fees IUNA ${this.amountLabel(latestBlock.total_fees ?? latestBlock.totalFees ?? 0)}.`, 1799 "active", 1800 new Date(Number(latestBlock.timestamp_ms ?? latestBlock.timestampMs) || Date.now()) 1801 ); 1802 } 1803 if (!locallyFinalized) { 1804 this.noteMiningStateChange( 1805 "latest-block", 1806 latestBlock.hash || String(latestBlock.height), 1807 `Observed block ${latestBlock.height}`, 1808 `Finalized by ${finalizer}. ${this.burnCountLabel(latestBlock)} burned, fees IUNA ${this.amountLabel(latestBlock.total_fees ?? latestBlock.totalFees ?? 0)}.`, 1809 "active", 1810 new Date(Number(latestBlock.timestamp_ms ?? latestBlock.timestampMs) || Date.now()) 1811 ); 1812 } 1813 } 1814 }, 1815 1816 noteMiningStateChange(key, value, title, detail, kind = "info", timestamp = new Date()) { 1817 if (this.miningEventState[key] === value) return; 1818 this.miningEventState[key] = value; 1819 if (value === "" && key !== "pow-status" && key !== "leader") return; 1820 this.appendMiningEvent(title, detail, kind, timestamp); 1821 }, 1822 1823 miningEventLog() { 1824 return this.miningEvents; 1825 }, 1826 1827 metricsCharts() { 1828 return Array.isArray(this.blockchainMetrics?.charts) ? this.blockchainMetrics.charts : []; 1829 }, 1830 1831 metricsLatest() { 1832 return this.blockchainMetrics?.latest || {}; 1833 }, 1834 1835 metricsLeaderboards() { 1836 return this.blockchainMetrics?.leaderboards || {}; 1837 }, 1838 1839 leaderboardRows(kind) { 1840 const rows = this.metricsLeaderboards()?.[kind]; 1841 return Array.isArray(rows) ? rows : []; 1842 }, 1843 1844 leaderboardRankLabel(index) { 1845 return ["Gold", "Silver", "Bronze"][index] || `#${index + 1}`; 1846 }, 1847 1848 leaderboardRankClass(index) { 1849 return index < 3 ? `medal-${index + 1}` : ""; 1850 }, 1851 1852 leaderboardAmountLabel(row) { 1853 return `IUNA ${this.amountLabel(row?.amount || 0)}`; 1854 }, 1855 1856 leaderboardCountLabel(kind, row) { 1857 const count = Number(row?.count || 0); 1858 if (kind === "miners") return `${count} mine${count === 1 ? "" : "s"}`; 1859 if (kind === "burners") return `${count} burn${count === 1 ? "" : "s"}`; 1860 return `${count} UTXO${count === 1 ? "" : "s"}`; 1861 }, 1862 1863 metricsPath(range = this.metricsRange) { 1864 return range === "all" ? "/api/metrics" : `/api/metrics?limit=${range}`; 1865 }, 1866 1867 setMetricsRange(range) { 1868 this.metricsRange = range === 1000 || range === "all" ? range : 100; 1869 this.metricHover = null; 1870 try { 1871 localStorage.setItem("iunaMetricsRange", String(this.metricsRange)); 1872 } catch { 1873 // Non-persistent filtering is fine when storage is unavailable. 1874 } 1875 if (this.tab === "metrics") { 1876 this.refreshMetrics(); 1877 } 1878 }, 1879 1880 async fetchMetricsResponse(range = this.metricsRange) { 1881 return this.prepareMetricsResponse(await this.fetchJson(this.metricsPath(range))); 1882 }, 1883 1884 async refreshMetrics(options = {}) { 1885 if (!this.canUseProtectedApi()) return this.blockchainMetrics; 1886 const requestId = ++this.metricsRequestSeq; 1887 const range = this.metricsRange; 1888 if (this.metricsCharts().length === 0 && options.silent !== true) { 1889 this.loadingMetrics = true; 1890 } 1891 try { 1892 const metrics = await this.fetchMetricsResponse(range); 1893 if (requestId === this.metricsRequestSeq && this.metricsRange === range) { 1894 this.blockchainMetrics = metrics; 1895 } 1896 return metrics; 1897 } catch (error) { 1898 if (options.silent !== true) this.showFlash(error.message, "error"); 1899 return this.blockchainMetrics; 1900 } finally { 1901 if (requestId === this.metricsRequestSeq) { 1902 this.loadingMetrics = false; 1903 } 1904 } 1905 }, 1906 1907 prepareMetricsResponse(metrics) { 1908 const charts = Array.isArray(metrics?.charts) 1909 ? metrics.charts.map((chart) => this.prepareMetricChart(chart)) 1910 : []; 1911 return { ...(metrics || {}), charts }; 1912 }, 1913 1914 prepareMetricChart(chart) { 1915 const points = this.metricValidPoints(chart); 1916 const bounds = this.metricChartBoundsForPoints(points); 1917 const yTicks = this.metricYAxisTicksForPoints(points); 1918 const xTicks = this.metricXAxisTicksForPoints(points); 1919 const linePoints = points 1920 .map((point) => { 1921 const x = this.metricXAxisPositionFromBounds(bounds, Number(point.height)); 1922 const y = this.metricYAxisPositionFromBounds(bounds, Number(point.value)); 1923 return `${x.toFixed(1)},${y.toFixed(1)}`; 1924 }) 1925 .join(" "); 1926 const markers = points.map((point) => { 1927 const height = Number(point.height); 1928 const value = Number(point.value); 1929 return { 1930 height, 1931 value, 1932 x: this.metricXAxisPositionFromBounds(bounds, height), 1933 y: this.metricYAxisPositionFromBounds(bounds, value), 1934 }; 1935 }); 1936 const gridPath = [ 1937 ...yTicks.map((tick) => { 1938 const y = this.metricYAxisPositionFromBounds(bounds, Number(tick)).toFixed(1); 1939 return `M4 ${y} H296`; 1940 }), 1941 ...xTicks.map((tick) => { 1942 const x = this.metricXAxisPositionFromBounds(bounds, Number(tick)).toFixed(1); 1943 return `M${x} 8 V132`; 1944 }), 1945 ].join(" "); 1946 return { 1947 ...chart, 1948 _visiblePoints: points, 1949 _bounds: bounds, 1950 _yTicks: yTicks, 1951 _xTicks: xTicks, 1952 _linePoints: linePoints, 1953 _markers: markers, 1954 _gridPath: gridPath, 1955 }; 1956 }, 1957 1958 metricChartPoints(chart) { 1959 return chart?._linePoints || ""; 1960 }, 1961 1962 metricChartPointMarkers(chart) { 1963 return chart?._markers || []; 1964 }, 1965 1966 metricGridPath(chart) { 1967 return chart?._gridPath || ""; 1968 }, 1969 1970 metricValidPoints(chart) { 1971 const points = Array.isArray(chart?.points) ? chart.points : []; 1972 return points.filter((point) => Number.isFinite(Number(point.value))); 1973 }, 1974 1975 metricVisiblePoints(chart) { 1976 return chart?._visiblePoints || this.metricValidPoints(chart); 1977 }, 1978 1979 metricLatestValueLabel(chart) { 1980 const points = this.metricVisiblePoints(chart); 1981 if (points.length === 0) return "-"; 1982 return this.metricValueLabel(chart, points[points.length - 1].value); 1983 }, 1984 1985 metricChartBounds(chart) { 1986 return chart?._bounds || this.metricChartBoundsForPoints(this.metricVisiblePoints(chart)); 1987 }, 1988 1989 metricChartBoundsForPoints(points) { 1990 if (points.length === 0) { 1991 return { minHeight: 0, maxHeight: 1, minValue: 0, maxValue: 1 }; 1992 } 1993 const heights = points.map((point) => Number(point.height)); 1994 const values = points.map((point) => Number(point.value)); 1995 const valueTicks = this.niceTicks(Math.min(...values), Math.max(...values), 5); 1996 return { 1997 minHeight: Math.min(...heights), 1998 maxHeight: Math.max(...heights), 1999 minValue: Math.min(...valueTicks), 2000 maxValue: Math.max(...valueTicks), 2001 }; 2002 }, 2003 2004 metricYAxisTicks(chart) { 2005 return chart?._yTicks || this.metricYAxisTicksForPoints(this.metricVisiblePoints(chart)); 2006 }, 2007 2008 metricYAxisTicksForPoints(points) { 2009 if (points.length === 0) return []; 2010 const values = points.map((point) => Number(point.value)); 2011 return this.niceTicks(Math.min(...values), Math.max(...values), 5).reverse(); 2012 }, 2013 2014 metricXAxisTicks(chart) { 2015 return chart?._xTicks || this.metricXAxisTicksForPoints(this.metricVisiblePoints(chart)); 2016 }, 2017 2018 metricXAxisTicksForPoints(points) { 2019 if (points.length === 0) return []; 2020 const heights = points.map((point) => Number(point.height)); 2021 const minHeight = Math.min(...heights); 2022 const maxHeight = Math.max(...heights); 2023 if (minHeight === maxHeight) return [minHeight]; 2024 return this.niceTicks(minHeight, maxHeight, 5) 2025 .map((tick) => Math.round(tick)) 2026 .filter((tick) => tick >= minHeight && tick <= maxHeight) 2027 .filter((tick, index, ticks) => ticks.indexOf(tick) === index); 2028 }, 2029 2030 niceTicks(minValue, maxValue, maxTicks = 5) { 2031 const min = Number(minValue); 2032 const max = Number(maxValue); 2033 if (!Number.isFinite(min) || !Number.isFinite(max)) return []; 2034 if (min === max) { 2035 if (min === 0) return [0]; 2036 const step = this.niceTickStep(Math.abs(min) / Math.max(1, maxTicks - 1)); 2037 const tickMin = Math.floor(Math.min(0, min) / step) * step; 2038 const tickMax = Math.ceil(max / step) * step; 2039 return this.tickRange(tickMin, tickMax, step); 2040 } 2041 const range = this.niceTickStep((max - min) / Math.max(1, maxTicks - 1)); 2042 const tickMin = Math.floor(min / range) * range; 2043 const tickMax = Math.ceil(max / range) * range; 2044 return this.tickRange(tickMin, tickMax, range); 2045 }, 2046 2047 niceTickStep(value) { 2048 if (!Number.isFinite(value) || value <= 0) return 1; 2049 const exponent = Math.floor(Math.log10(value)); 2050 const fraction = value / Math.pow(10, exponent); 2051 const niceFraction = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10; 2052 return niceFraction * Math.pow(10, exponent); 2053 }, 2054 2055 tickRange(min, max, step) { 2056 if (!Number.isFinite(step) || step <= 0) return []; 2057 const precision = Math.max(0, Math.ceil(-Math.log10(step)) + 2); 2058 const ticks = []; 2059 for (let tick = min; tick <= max + step / 2; tick += step) { 2060 ticks.push(Number(tick.toFixed(precision))); 2061 if (ticks.length > 8) break; 2062 } 2063 return ticks; 2064 }, 2065 2066 metricYAxisPositionFromBounds(bounds, value) { 2067 const valueRange = Math.max(1, bounds.maxValue - bounds.minValue); 2068 return 132 - ((value - bounds.minValue) / valueRange) * 124; 2069 }, 2070 2071 metricXAxisPositionFromBounds(bounds, height) { 2072 const heightRange = Math.max(1, bounds.maxHeight - bounds.minHeight); 2073 return 4 + ((height - bounds.minHeight) / heightRange) * 292; 2074 }, 2075 2076 metricYAxisLabelStyle(chart, value) { 2077 const y = this.metricYAxisPositionFromBounds(this.metricChartBounds(chart), Number(value)); 2078 return `top: ${(y / 148) * 100}%`; 2079 }, 2080 2081 metricXAxisLabelStyle(chart, height) { 2082 const x = this.metricXAxisPositionFromBounds(this.metricChartBounds(chart), Number(height)); 2083 return `left: ${(x / 300) * 100}%`; 2084 }, 2085 2086 metricHoverPointStyle(chart) { 2087 const hover = this.metricHover; 2088 if (!hover || hover.chartId !== chart.id) return ""; 2089 return `left: ${(hover.x / 300) * 100}%; top: ${(hover.y / 148) * 100}%;`; 2090 }, 2091 2092 setMetricHover(chart, marker) { 2093 this.metricHover = { 2094 chartId: chart.id, 2095 height: marker.height, 2096 value: marker.value, 2097 x: marker.x, 2098 y: marker.y, 2099 label: this.metricPointLabel(chart, marker), 2100 }; 2101 }, 2102 2103 setMetricHoverFromPlot(chart, event) { 2104 const markers = this.metricChartPointMarkers(chart); 2105 if (markers.length === 0) { 2106 this.clearMetricHover(chart); 2107 return; 2108 } 2109 const rect = event.currentTarget.getBoundingClientRect(); 2110 const relativeX = Math.min(Math.max(event.clientX - rect.left, 0), rect.width); 2111 const chartX = (relativeX / Math.max(1, rect.width)) * 300; 2112 const nearest = markers.reduce((best, marker) => { 2113 const distance = Math.abs(marker.x - chartX); 2114 return !best || distance < best.distance ? { marker, distance } : best; 2115 }, null)?.marker; 2116 if (nearest) { 2117 this.setMetricHover(chart, nearest); 2118 } 2119 }, 2120 2121 clearMetricHover(chart) { 2122 if (this.metricHover?.chartId === chart.id) { 2123 this.metricHover = null; 2124 } 2125 }, 2126 2127 metricTooltipLabel(chart) { 2128 return this.metricHover?.chartId === chart.id ? this.metricHover.label : ""; 2129 }, 2130 2131 metricTooltipStyle(chart) { 2132 const hover = this.metricHover; 2133 if (!hover || hover.chartId !== chart.id) return ""; 2134 const left = (hover.x / 300) * 100; 2135 const top = (hover.y / 148) * 100; 2136 const xShift = hover.x > 238 ? "-100%" : hover.x < 62 ? "0" : "-50%"; 2137 const yShift = hover.y < 34 ? "12px" : "-115%"; 2138 return `left: ${left}%; top: ${top}%; transform: translate(${xShift}, ${yShift});`; 2139 }, 2140 2141 metricPointLabel(chart, point) { 2142 return `#${point.height}: ${this.metricValueLabel(chart, point.value)}`; 2143 }, 2144 2145 metricAxisValueLabel(chart, value) { 2146 const number = Number(value); 2147 if (!Number.isFinite(number)) return "-"; 2148 if (chart?.valueKind === "seconds") return `${this.compactNumber(number)}s`; 2149 return this.compactNumber(number); 2150 }, 2151 2152 metricValueLabel(chart, value) { 2153 const number = Number(value); 2154 if (!Number.isFinite(number)) return "-"; 2155 if (chart?.valueKind === "iuna") return `IUNA ${this.compactNumber(number)}`; 2156 if (chart?.valueKind === "seconds") return `${this.compactNumber(number)} s`; 2157 return `${this.compactNumber(number)}${chart?.unit ? ` ${chart.unit}` : ""}`; 2158 }, 2159 2160 compactNumber(value) { 2161 const number = Number(value); 2162 if (!Number.isFinite(number)) return "-"; 2163 if (Math.abs(number) >= 1000) { 2164 return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(number); 2165 } 2166 if (Number.isInteger(number)) return String(number); 2167 return number.toFixed(6).replace(/0+$/, "").replace(/\.$/, ""); 2168 }, 2169 2170 amountLabel(value) { 2171 const microiuna = Math.max(0, Math.trunc(Number(value) || 0)); 2172 const whole = Math.floor(microiuna / 1000000); 2173 const fractional = String(microiuna % 1000000).padStart(6, "0").replace(/0+$/, ""); 2174 return fractional ? `${whole}.${fractional}` : `${whole}`; 2175 }, 2176 2177 metricAmountLabel(value) { 2178 return value === null || value === undefined ? "-" : `IUNA ${this.amountLabel(value)}`; 2179 }, 2180 2181 amountNumber(value) { 2182 return Number(this.amountLabel(value)); 2183 }, 2184 2185 parseiunaAmount(value) { 2186 const text = String(value ?? "").trim(); 2187 if (!text) return 0; 2188 const match = text.match(/^(\d+)(?:\.(\d{0,6})\d*)?$/); 2189 if (!match) return 0; 2190 const whole = Number(match[1] || 0); 2191 const fractional = Number((match[2] || "").padEnd(6, "0")); 2192 return Math.max(0, Math.trunc(whole * 1000000 + fractional)); 2193 }, 2194 2195 parseiunaAmountRequired(value, message) { 2196 const text = String(value ?? "").trim(); 2197 if (!text) throw new Error(message); 2198 const parsed = this.parseiunaAmount(text); 2199 if (parsed === 0 && !/^0(?:\.0*)?$/.test(text)) throw new Error(message); 2200 return parsed; 2201 }, 2202 2203 async sendTransfer() { 2204 try { 2205 const amount = this.parseiunaAmount(this.transferAmount); 2206 const fee = this.parseiunaAmountRequired(this.transferFee, "Transfer fee per byte is required"); 2207 const recipient = this.short(this.transferTo); 2208 await this.postForm( 2209 "/api/transfer", 2210 { to: this.transferTo, amount, fee_per_byte: fee, utxos: this.selectedTransferUtxos.join("\n") }, 2211 `Queued transfer of ${this.amountLabel(amount)} IUNA to ${recipient}` 2212 ); 2213 this.transferTo = ""; 2214 this.transferAmount = null; 2215 this.selectedTransferUtxos = []; 2216 this.selectedTransferUtxoAmounts = {}; 2217 this.showSendAdvanced = false; 2218 this.feeEstimates.transfer = null; 2219 } catch (error) { 2220 this.showFlash(error.message, "error"); 2221 } 2222 }, 2223 2224 toggleSendAdvanced() { 2225 this.showSendAdvanced = !this.showSendAdvanced; 2226 if (!this.showSendAdvanced) { 2227 this.selectedTransferUtxos = []; 2228 } 2229 }, 2230 2231 async addPeer() { 2232 try { 2233 const peer = this.peerAddress.trim(); 2234 await this.postForm("/api/peers", { peer }, `Added peer ${peer}`); 2235 this.peerAddress = ""; 2236 } catch (error) { 2237 this.showFlash(error.message, "error"); 2238 } 2239 }, 2240 2241 async removePeer(peer) { 2242 try { 2243 await this.postForm("/api/peers", { peer: peer.address }, `Removed peer ${peer.address}`, "DELETE"); 2244 } catch (error) { 2245 this.showFlash(error.message, "error"); 2246 } 2247 }, 2248 2249 addressBookEntries() { 2250 return Object.entries(this.addressBook || {}) 2251 .map(([address, name]) => ({ address, name })) 2252 .sort((left, right) => left.name.localeCompare(right.name) || left.address.localeCompare(right.address)); 2253 }, 2254 2255 validAddressBookAddress(address) { 2256 return /^[0-9a-fA-F]{64}$/.test(String(address ?? "").trim()); 2257 }, 2258 2259 selectTransferContact(address) { 2260 if (!address) return; 2261 this.transferTo = address; 2262 this.scheduleFeeEstimates(); 2263 this.closeAddressBookPicker(); 2264 }, 2265 2266 openAddressBookModal(entry = null) { 2267 this.addressBookEditingAddress = entry?.address || null; 2268 this.addressBookDraftAddress = entry?.address || ""; 2269 this.addressBookDraftName = entry?.name || ""; 2270 this.addressBookPickerOpen = true; 2271 this.addressBookModalOpen = true; 2272 }, 2273 2274 closeAddressBookModal() { 2275 this.addressBookModalOpen = false; 2276 this.addressBookEditingAddress = null; 2277 this.addressBookDraftAddress = ""; 2278 this.addressBookDraftName = ""; 2279 }, 2280 2281 openAddressBookPicker() { 2282 this.addressBookPickerOpen = true; 2283 }, 2284 2285 closeAddressBookPicker() { 2286 this.addressBookPickerOpen = false; 2287 this.closeAddressBookModal(); 2288 }, 2289 2290 async saveAddressBookEntry() { 2291 const address = this.addressBookDraftAddress.trim().toLowerCase(); 2292 const name = this.addressBookDraftName.trim(); 2293 if (!address || !name) { 2294 this.showFlash("Address and name are required", "error"); 2295 return; 2296 } 2297 if (!this.validAddressBookAddress(address)) { 2298 this.showFlash("Address must be a 64 character hex public key", "error"); 2299 return; 2300 } 2301 const oldAddress = this.addressBookEditingAddress; 2302 if (this.addressBook?.[address] && address !== oldAddress) { 2303 this.showFlash("Address is already saved", "error"); 2304 return; 2305 } 2306 try { 2307 const fields = oldAddress ? { address, name, old_address: oldAddress } : { address, name }; 2308 await this.submitForm("/api/address-book", fields); 2309 this.addressBookVersion += 1; 2310 const nextBook = { ...(this.addressBook || {}) }; 2311 if (oldAddress && oldAddress !== address) delete nextBook[oldAddress]; 2312 nextBook[address] = name; 2313 this.addressBook = nextBook; 2314 this.config = { ...this.config, address_book: this.addressBook }; 2315 this.closeAddressBookModal(); 2316 this.showFlash(`Saved ${name}`, "success"); 2317 } catch (error) { 2318 this.showFlash(error.message, "error"); 2319 } 2320 }, 2321 2322 editAddressBookEntry(entry) { 2323 this.openAddressBookModal(entry); 2324 }, 2325 2326 async removeAddressBookEntry(entry) { 2327 try { 2328 await this.submitForm("/api/address-book", { address: entry.address }, "DELETE"); 2329 this.addressBookVersion += 1; 2330 const nextBook = { ...(this.addressBook || {}) }; 2331 delete nextBook[entry.address]; 2332 this.addressBook = nextBook; 2333 this.config = { ...this.config, address_book: nextBook }; 2334 if (this.addressBookEditingAddress === entry.address) this.closeAddressBookModal(); 2335 this.showFlash(`Removed ${entry.name}`, "success"); 2336 } catch (error) { 2337 this.showFlash(error.message, "error"); 2338 } 2339 }, 2340 2341 async copyAddress() { 2342 try { 2343 await navigator.clipboard.writeText(this.setupAddress()); 2344 this.showFlash("Address copied", "success"); 2345 } catch (error) { 2346 this.showFlash("Could not copy address", "error"); 2347 } 2348 }, 2349 2350 showFlash(message, kind) { 2351 this.flash = { message, kind }; 2352 if (this.flashTimer) { 2353 clearTimeout(this.flashTimer); 2354 } 2355 this.flashTimer = setTimeout(() => { 2356 this.flash = null; 2357 this.flashTimer = null; 2358 }, kind === "error" ? 7000 : 3500); 2359 }, 2360 2361 showSetupFeedback(message, kind) { 2362 this.setupFeedback = { message, kind }; 2363 }, 2364 2365 showAuthFeedback(message, kind) { 2366 this.authFeedback = { message, kind }; 2367 }, 2368 2369 showSettingsFeedback(message, kind) { 2370 this.settingsFeedback = { message, kind }; 2371 }, 2372 2373 short(value) { 2374 if (!value) return "-"; 2375 if (value.length <= 16) return value; 2376 return `${value.slice(0, 8)}...${value.slice(-8)}`; 2377 }, 2378 2379 addressName(address) { 2380 if (!address) return null; 2381 return this.addressBook?.[address] || null; 2382 }, 2383 2384 addressLabel(address) { 2385 return this.addressName(address) || address || "-"; 2386 }, 2387 2388 shortAddressLabel(address) { 2389 return this.addressName(address) || this.short(address); 2390 }, 2391 2392 txFrom(tx) { 2393 return tx.from ?? tx.inputs?.[0]?.owner ?? ""; 2394 }, 2395 2396 txTo(tx) { 2397 return tx.to ?? tx.outputs?.[0]?.address ?? null; 2398 }, 2399 2400 txAmount(tx) { 2401 return tx.amount ?? tx.outputs?.[0]?.amount ?? 0; 2402 }, 2403 2404 isMineTx(tx) { 2405 return tx?.kind === "mine"; 2406 }, 2407 2408 isBlindedMempoolItem(tx) { 2409 return !tx?.revealed && (tx?.kind === "blinded" || tx?.kind === "reveal"); 2410 }, 2411 2412 txFeeLabel(tx) { 2413 if (!tx?.revealed && tx?.kind === "reveal") return "unknown until reveal"; 2414 return `IUNA ${this.amountLabel(tx?.fee ?? 0)}`; 2415 }, 2416 2417 txPillLabel(tx) { 2418 return tx?.revealed ? "revealed" : (tx?.kind || "-"); 2419 }, 2420 2421 txPillClass(tx) { 2422 return tx?.revealed ? "revealed" : (tx?.kind || ""); 2423 }, 2424 2425 txDifficultyBits(tx) { 2426 return tx?.difficulty_bits ?? tx?.difficultyBits ?? null; 2427 }, 2428 2429 txProofBits(tx) { 2430 return tx?.proof_bits ?? tx?.proofBits ?? null; 2431 }, 2432 2433 txProofHash(tx) { 2434 return tx?.proof_hash ?? tx?.proofHash ?? tx?.signature ?? null; 2435 }, 2436 2437 txInputs(tx) { 2438 return Array.isArray(tx.inputs) ? tx.inputs : []; 2439 }, 2440 2441 txVisualOutputs(tx) { 2442 const rows = []; 2443 if (tx.kind === "burn" && Number(tx.amount || 0) > 0) { 2444 rows.push({ 2445 kind: "burned", 2446 label: "Burn", 2447 amount: tx.amount, 2448 address: null, 2449 }); 2450 } 2451 if (Number(tx.fee || 0) > 0) { 2452 rows.push({ 2453 kind: "fee", 2454 label: "Fee", 2455 amount: tx.fee, 2456 address: null, 2457 detailLabel: "To", 2458 detail: this.txFeeRecipient(tx), 2459 }); 2460 } 2461 const directOutputs = Array.isArray(tx.outputs) ? tx.outputs : []; 2462 for (const [index, output] of directOutputs.entries()) { 2463 rows.push({ 2464 kind: "output", 2465 label: `Output ${index + 1}`, 2466 amount: output.amount, 2467 address: output.address, 2468 }); 2469 } 2470 const changeOutputs = Array.isArray(tx.change) ? tx.change : []; 2471 for (const [index, output] of changeOutputs.entries()) { 2472 rows.push({ 2473 kind: "change", 2474 label: `Change ${index + 1}`, 2475 amount: output.amount, 2476 address: output.address, 2477 }); 2478 } 2479 return rows; 2480 }, 2481 2482 txInputKey(input, index) { 2483 return `${input.outpoint?.txid || "input"}:${input.outpoint?.index ?? index}`; 2484 }, 2485 2486 txOutputKey(output, index) { 2487 return `${output.kind}:${output.address || output.kind}:${output.amount}:${index}`; 2488 }, 2489 2490 txInputOutpoint(input) { 2491 const txid = input.outpoint?.txid || "-"; 2492 const index = input.outpoint?.index ?? "-"; 2493 return `${txid}:${index}`; 2494 }, 2495 2496 utxoOutpoint(utxo) { 2497 return this.txInputOutpoint({ outpoint: utxo.outpoint }); 2498 }, 2499 2500 spendableWalletUtxos() { 2501 return this.walletUtxos.filter((utxo) => utxo.spendable !== false); 2502 }, 2503 2504 rememberUtxoAmounts(utxos) { 2505 for (const utxo of utxos || []) { 2506 this.selectedTransferUtxoAmounts[this.utxoOutpoint(utxo)] = Number(utxo.amount || 0); 2507 } 2508 }, 2509 2510 pruneSelectedTransferUtxos() { 2511 const visible = new Map(this.walletUtxos.map((utxo) => [this.utxoOutpoint(utxo), utxo])); 2512 this.selectedTransferUtxos = this.selectedTransferUtxos.filter((outpoint) => { 2513 const utxo = visible.get(outpoint); 2514 return !utxo || utxo.spendable !== false; 2515 }); 2516 if (this.lastSelectedTransferUtxo && !this.selectedTransferUtxos.includes(this.lastSelectedTransferUtxo)) { 2517 this.lastSelectedTransferUtxo = null; 2518 } 2519 }, 2520 2521 toggleTransferUtxoSelection(event, utxo) { 2522 const outpoint = this.utxoOutpoint(utxo); 2523 if (!utxo || utxo.spendable === false || !outpoint) { 2524 this.scheduleFeeEstimates(); 2525 return; 2526 } 2527 2528 this.rememberUtxoAmounts([utxo]); 2529 const spendable = this.spendableWalletUtxos(); 2530 const outpoints = spendable.map((item) => this.utxoOutpoint(item)); 2531 const currentIndex = outpoints.indexOf(outpoint); 2532 const anchorIndex = this.lastSelectedTransferUtxo 2533 ? outpoints.indexOf(this.lastSelectedTransferUtxo) 2534 : -1; 2535 2536 const selected = new Set(this.selectedTransferUtxos); 2537 const checked = !selected.has(outpoint); 2538 if (event?.shiftKey && anchorIndex >= 0 && currentIndex >= 0) { 2539 const [from, to] = [anchorIndex, currentIndex].sort((left, right) => left - right); 2540 const range = spendable.slice(from, to + 1); 2541 this.rememberUtxoAmounts(range); 2542 for (const item of range) { 2543 const itemOutpoint = this.utxoOutpoint(item); 2544 if (checked) { 2545 selected.add(itemOutpoint); 2546 } else { 2547 selected.delete(itemOutpoint); 2548 } 2549 } 2550 } else if (checked) { 2551 selected.add(outpoint); 2552 } else { 2553 selected.delete(outpoint); 2554 } 2555 this.selectedTransferUtxos = Array.from(selected); 2556 2557 this.lastSelectedTransferUtxo = outpoint; 2558 this.scheduleFeeEstimates(); 2559 }, 2560 2561 async selectAllTransferUtxos() { 2562 try { 2563 const utxos = await this.fetchJson("/api/wallet/utxos/selectable"); 2564 this.rememberUtxoAmounts(utxos); 2565 this.selectedTransferUtxos = utxos.map((utxo) => this.utxoOutpoint(utxo)); 2566 this.lastSelectedTransferUtxo = 2567 this.selectedTransferUtxos[this.selectedTransferUtxos.length - 1] || null; 2568 this.scheduleFeeEstimates(); 2569 if (this.selectedTransferUtxos.length === 0) { 2570 this.showFlash("No spendable UTXOs", "error"); 2571 } 2572 } catch (error) { 2573 this.showFlash(error.message, "error"); 2574 } 2575 }, 2576 2577 clearTransferUtxos() { 2578 this.selectedTransferUtxos = []; 2579 this.selectedTransferUtxoAmounts = {}; 2580 this.lastSelectedTransferUtxo = null; 2581 this.scheduleFeeEstimates(); 2582 }, 2583 2584 selectedTransferUtxoTotal() { 2585 return this.selectedTransferUtxos.reduce((sum, outpoint) => { 2586 return sum + Number(this.selectedTransferUtxoAmounts[outpoint] || 0); 2587 }, 0); 2588 }, 2589 2590 transferRequiredTotal() { 2591 return this.parseiunaAmount(this.transferAmount) + Number(this.feeEstimates.transfer?.fee || 0); 2592 }, 2593 2594 selectedTransferUtxosCoverTransfer() { 2595 return this.selectedTransferUtxos.length === 0 || this.selectedTransferUtxoTotal() >= this.transferRequiredTotal(); 2596 }, 2597 2598 txInputAmountLabel(input) { 2599 return input.amount === null || input.amount === undefined ? "-" : `IUNA ${this.amountLabel(input.amount)}`; 2600 }, 2601 2602 txFeeRecipient(tx) { 2603 const context = this.selectedTransaction?.context || {}; 2604 const address = tx.blockFinalizer ?? tx.blockMiner ?? context.blockFinalizer ?? context.blockMiner; 2605 return address ? this.addressLabel(address) : "future block finalizer"; 2606 }, 2607 2608 selectedTransactionLabel() { 2609 if (!this.selectedTransaction) return "-"; 2610 const { tx, context } = this.selectedTransaction; 2611 if (context.blockHeight !== undefined) return `Block ${context.blockHeight}`; 2612 if (tx?.status === "pending") return "Wallet pending"; 2613 if (tx?.blockHeight !== null && tx?.blockHeight !== undefined) { 2614 return `Wallet block ${tx.blockHeight}`; 2615 } 2616 return context.source || "-"; 2617 }, 2618 2619 blockBurned(block) { 2620 return this.blockTransactions(block) 2621 .filter((tx) => tx.kind === "burn") 2622 .reduce((sum, tx) => sum + this.txAmount(tx), 0); 2623 }, 2624 2625 blockTotalFees(block) { 2626 const explicitTotal = block?.totalFees ?? block?.total_fees ?? block?.reward; 2627 if (explicitTotal !== null && explicitTotal !== undefined) return Number(explicitTotal) || 0; 2628 return this.blockTransactions(block).reduce((sum, tx) => sum + Number(tx.fee || 0), 0); 2629 }, 2630 2631 blockTimestampLabel(block) { 2632 const timestamp = Number(block?.timestamp_ms ?? block?.timestampMs); 2633 if (!Number.isFinite(timestamp)) return "-"; 2634 return new Date(timestamp).toLocaleString(); 2635 }, 2636 2637 blockTotalBytes(block) { 2638 return Number(block?.totalBytes ?? block?.total_bytes ?? 0); 2639 }, 2640 2641 blockPayloadBytes(block) { 2642 return ( 2643 Number(block?.transactionBytes ?? block?.transaction_bytes ?? 0) + 2644 Number(block?.blindedTransactionBytes ?? block?.blinded_transaction_bytes ?? 0) + 2645 Number(block?.revealBundleBytes ?? block?.reveal_bundle_bytes ?? 0) 2646 ); 2647 }, 2648 2649 blockRevealFeePenalty(block) { 2650 return block?.revealFeePenalty ?? block?.reveal_fee_penalty ?? {}; 2651 }, 2652 2653 blockRevealListRatio(block) { 2654 const penalty = this.blockRevealFeePenalty(block); 2655 const included = Number(penalty.revealListsIncluded ?? penalty.reveal_lists_included ?? 0); 2656 const committeeSize = Number(penalty.committeeSize ?? penalty.committee_size ?? 0); 2657 return `${included}/${committeeSize}`; 2658 }, 2659 2660 blockRevealFeePenaltyAmount(block) { 2661 const penalty = this.blockRevealFeePenalty(block); 2662 return Number(penalty.feePenalty ?? penalty.fee_penalty ?? 0); 2663 }, 2664 2665 blockByteBreakdown(block) { 2666 const transactionRows = this.blockTransactionByteBreakdown(block); 2667 return [ 2668 ["Header and proof", Math.max(0, this.blockTotalBytes(block) - this.blockPayloadBytes(block)), ""], 2669 ...(transactionRows.length 2670 ? transactionRows 2671 : [["Transactions", Number(block?.transactionBytes ?? block?.transaction_bytes ?? 0), ""]]), 2672 ["Blinded commits", Number(block?.blindedTransactionBytes ?? block?.blinded_transaction_bytes ?? 0), "blinded"], 2673 ["Reveal bundles", Number(block?.revealBundleBytes ?? block?.reveal_bundle_bytes ?? 0), "reveal"], 2674 ]; 2675 }, 2676 2677 blockTransactionByteBreakdown(block) { 2678 const rows = block?.transactionByteBreakdown ?? block?.transaction_byte_breakdown; 2679 if (!Array.isArray(rows)) return []; 2680 return rows 2681 .map((row) => { 2682 const label = row.label || row.kind || "transaction"; 2683 return [label, Number(row.bytes ?? 0), label]; 2684 }) 2685 .filter((row) => row[1] > 0); 2686 }, 2687 2688 recentBlockFeeAverage(count) { 2689 const sample = this.blocks.filter((block) => block.height > 0).slice(0, count); 2690 if (sample.length === 0) return 0; 2691 return Math.round(sample.reduce((sum, block) => sum + this.blockTotalFees(block), 0) / sample.length); 2692 }, 2693 2694 blockBurnCount(block) { 2695 return this.blockTransactions(block).filter((tx) => tx.kind === "burn").length; 2696 }, 2697 2698 blockTransferCount(block) { 2699 return this.blockTransactions(block).filter((tx) => tx.kind === "transfer").length; 2700 }, 2701 2702 blockCommitCount(block) { 2703 return this.blockTransactions(block).filter((tx) => tx.kind === "blinded").length; 2704 }, 2705 2706 blockMineCount(block) { 2707 return this.blockTransactions(block).filter((tx) => tx.kind === "mine").length; 2708 }, 2709 2710 blockTransactions(block) { 2711 const transactions = block?.transactions || []; 2712 if (transactions.some((tx) => tx?.revealed)) return transactions; 2713 return [ 2714 ...transactions, 2715 ...(block?.revealedTransactions || block?.revealed_transactions || []), 2716 ]; 2717 }, 2718 2719 burnCountLabel(block) { 2720 const count = this.blockBurnCount(block); 2721 return `${count} burn${count === 1 ? "" : "s"}`; 2722 }, 2723 2724 transferCountLabel(block) { 2725 const count = this.blockTransferCount(block); 2726 return `${count} transfer${count === 1 ? "" : "s"}`; 2727 }, 2728 2729 commitCountLabel(block) { 2730 const count = this.blockCommitCount(block); 2731 return `${count} commit${count === 1 ? "" : "s"}`; 2732 }, 2733 2734 mineCountLabel(block) { 2735 const count = this.blockMineCount(block); 2736 return `${count} mine${count === 1 ? "" : "s"}`; 2737 }, 2738 2739 blockFinalizerLabel(block) { 2740 const finalizer = this.shortAddressLabel(block.miner); 2741 const owner = block.miner === this.status.wallet_address ? `${finalizer} (me)` : finalizer; 2742 return block.finalizer_mode === "recovery" ? `${owner} ยท Recovery` : owner; 2743 }, 2744 2745 burnLeaderRanks(block) { 2746 if (Array.isArray(block?.burn_leader_ranks)) return block.burn_leader_ranks; 2747 return Array.isArray(block?.burnLeaderRanks) ? block.burnLeaderRanks : []; 2748 }, 2749 2750 burnLeaderRanksTitle(block) { 2751 if (!block) return "Burn Leader Ranks"; 2752 return `Block ${block.height} Burn Leader Ranks`; 2753 }, 2754 2755 burnLeaderRankLabel(rank) { 2756 const value = Number(rank?.rank ?? 0); 2757 return `#${value + 1}`; 2758 }, 2759 2760 burnLeaderEligibilityLabel(rank) { 2761 const from = rank?.eligible_from_height ?? rank?.eligibleFromHeight ?? "-"; 2762 const until = rank?.eligible_until_height ?? rank?.eligibleUntilHeight ?? "-"; 2763 return `${from}-${until}`; 2764 }, 2765 2766 walletTransactions() { 2767 return this.walletTxs; 2768 }, 2769 2770 txTitle(tx) { 2771 if (tx.status === "pending") return tx.blinded ? "Pending blind" : "Pending"; 2772 return tx.blockHeight === null ? "Confirmed" : `Block ${tx.blockHeight}`; 2773 }, 2774 2775 walletTxTimeLabel(tx) { 2776 const timestamp = Number(tx?.timestampMs ?? tx?.timestamp_ms); 2777 if (!Number.isFinite(timestamp) || timestamp <= 0) { 2778 return tx?.status === "pending" ? "Pending" : "-"; 2779 } 2780 return new Date(timestamp).toLocaleString(); 2781 }, 2782 2783 isLeaderLabel() { 2784 if (!this.status.mining) return "-"; 2785 return this.status.mining.wallet_is_current_leader ? "yes" : "no"; 2786 }, 2787 2788 sharedHeightLabel() { 2789 const local = this.status.chain?.height; 2790 if (typeof local !== "number") return "-"; 2791 const peerHeights = this.peers 2792 .filter((peer) => !peer.last_error) 2793 .map((peer) => peer.last_known_height) 2794 .filter((height) => typeof height === "number"); 2795 if (peerHeights.length === 0) return local; 2796 return Math.min(local, ...peerHeights); 2797 }, 2798 2799 networkHealthClass() { 2800 if (this.networkHealth.ok) return "healthy"; 2801 if (this.networkHealth.state === "syncing") return "syncing"; 2802 if (this.networkHealth.state === "isolated") return "isolated"; 2803 if (this.networkHealth.state === "stale") return "stale"; 2804 if (this.networkHealth.state === "banned") return "banned"; 2805 return "error"; 2806 }, 2807 2808 networkLagLabel() { 2809 const lag = this.networkHealth.lag_blocks; 2810 if (typeof lag !== "number") return "-"; 2811 if (lag === 0) return "even"; 2812 return `${lag} behind`; 2813 }, 2814 2815 basicNetworkStatusLabel() { 2816 const state = this.networkHealth.state; 2817 if (!state) return "Network starting"; 2818 if (state === "healthy" || state === "ahead of peers") return "Connected"; 2819 if (state === "syncing" || state === "mempool syncing") return "Syncing"; 2820 if (state === "isolated") return "Offline"; 2821 return state.charAt(0).toUpperCase() + state.slice(1); 2822 }, 2823 2824 basicNetworkNeedsAttention() { 2825 if (!this.networkHealth.state) return false; 2826 return !this.networkHealth.ok && this.networkHealth.state !== "syncing"; 2827 }, 2828 2829 networkTimeOffsetLabel() { 2830 return this.clockOffsetLabel(this.networkHealth.network_time_offset_ms, true); 2831 }, 2832 2833 outboundPeers() { 2834 return this.peers.filter((peer) => peer.direction !== "inbound"); 2835 }, 2836 2837 inboundPeers() { 2838 return this.peers.filter((peer) => peer.direction === "inbound"); 2839 }, 2840 2841 healthyPeers() { 2842 return this.peers.filter((peer) => !peer.last_error && typeof peer.last_known_height === "number"); 2843 }, 2844 2845 failedPeers() { 2846 return this.peers.filter((peer) => peer.last_error); 2847 }, 2848 2849 stalePeer(peer) { 2850 const lastSuccess = peer.last_success_ms; 2851 if (typeof lastSuccess !== "number") return false; 2852 return Date.now() - lastSuccess > 20 * 60 * 1000; 2853 }, 2854 2855 bannedPeer(peer) { 2856 const bannedUntil = peer.banned_until_ms; 2857 return typeof bannedUntil === "number" && bannedUntil > Date.now(); 2858 }, 2859 2860 peerStatus(peer) { 2861 if (this.bannedPeer(peer)) return "banned"; 2862 if (peer.last_error) return "error"; 2863 if (this.stalePeer(peer)) return "stale"; 2864 if (typeof peer.last_known_height === "number") return "synced"; 2865 if ((peer.messages_sent ?? 0) > 0 || (peer.messages_received ?? 0) > 0) return "active"; 2866 return "pending"; 2867 }, 2868 2869 peerStatusLabel(peer) { 2870 return { 2871 error: "Error", 2872 banned: "Banned", 2873 stale: "Stale", 2874 synced: "Synced", 2875 active: "Active", 2876 pending: "Pending", 2877 }[this.peerStatus(peer)]; 2878 }, 2879 2880 relativeTimeLabel(timestampMs) { 2881 if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "-"; 2882 const ageSeconds = Math.max(0, Math.round((Date.now() - timestampMs) / 1000)); 2883 if (ageSeconds < 5) return "now"; 2884 if (ageSeconds < 60) return `${ageSeconds}s ago`; 2885 const ageMinutes = Math.round(ageSeconds / 60); 2886 if (ageMinutes < 60) return `${ageMinutes}m ago`; 2887 const ageHours = Math.round(ageMinutes / 60); 2888 if (ageHours < 48) return `${ageHours}h ago`; 2889 return `${Math.round(ageHours / 24)}d ago`; 2890 }, 2891 2892 peerLastContactLabel(peer) { 2893 return this.relativeTimeLabel(peer.last_contact_ms); 2894 }, 2895 2896 peerClockLabel(peer) { 2897 const label = this.clockOffsetLabel(peer.last_clock_offset_ms, false); 2898 if (label === "-") return "-"; 2899 return peer.last_clock_offset_accepted === false ? `${label} ignored` : label; 2900 }, 2901 2902 clockOffsetLabel(offsetMs, zeroAsSynced) { 2903 if (typeof offsetMs !== "number") return "-"; 2904 const sign = offsetMs > 0 ? "+" : offsetMs < 0 ? "-" : ""; 2905 const absoluteSeconds = Math.round(Math.abs(offsetMs) / 1000); 2906 if (absoluteSeconds === 0) return zeroAsSynced ? "even" : "0s"; 2907 if (absoluteSeconds < 60) return `${sign}${absoluteSeconds}s`; 2908 const minutes = Math.round(absoluteSeconds / 60); 2909 if (minutes < 60) return `${sign}${minutes}m`; 2910 return `${sign}${Math.round(minutes / 60)}h`; 2911 }, 2912 2913 peerBanLabel(peer) { 2914 if (!this.bannedPeer(peer)) return "-"; 2915 const remainingSeconds = Math.max(0, Math.round((peer.banned_until_ms - Date.now()) / 1000)); 2916 if (remainingSeconds < 60) return `${remainingSeconds}s`; 2917 const remainingMinutes = Math.round(remainingSeconds / 60); 2918 if (remainingMinutes < 60) return `${remainingMinutes}m`; 2919 return `${Math.round(remainingMinutes / 60)}h`; 2920 }, 2921 2922 normalizeVersion(version) { 2923 return String(version || "").trim().replace(/^v/i, ""); 2924 }, 2925 2926 versionParts(version) { 2927 const [core] = this.normalizeVersion(version).split("-"); 2928 return core.split(".").map((part) => Number.parseInt(part, 10) || 0); 2929 }, 2930 2931 compareVersions(left, right) { 2932 const leftParts = this.versionParts(left); 2933 const rightParts = this.versionParts(right); 2934 const length = Math.max(leftParts.length, rightParts.length, 3); 2935 for (let index = 0; index < length; index += 1) { 2936 const diff = (leftParts[index] || 0) - (rightParts[index] || 0); 2937 if (diff !== 0) return diff; 2938 } 2939 return 0; 2940 }, 2941 2942 peerHeightDelta(peer) { 2943 const local = this.status.chain?.height; 2944 const remote = peer.last_known_height; 2945 if (typeof local !== "number" || typeof remote !== "number") return "-"; 2946 if (remote === local) return "even"; 2947 if (remote > local) return `+${remote - local}`; 2948 return `-${local - remote}`; 2949 }, 2950 2951 canRemovePeer(peer) { 2952 return peer.direction !== "inbound"; 2953 }, 2954 2955 targetSecondsLabel() { 2956 const ms = this.status.mining?.vdf_target_block_ms; 2957 if (!ms) return "-"; 2958 const seconds = Math.round(ms / 1000); 2959 if (seconds % 60 === 0) return `${seconds / 60}m`; 2960 return `${seconds}s`; 2961 }, 2962 2963 stratumListenAddr() { 2964 return this.status.stratum?.listen_addr || "-"; 2965 }, 2966 2967 stratumPoolUrl() { 2968 const listen = this.status.stratum?.listen_addr; 2969 if (!this.status.stratum?.enabled || !listen) return "-"; 2970 const lastColon = listen.lastIndexOf(":"); 2971 if (lastColon < 0) return `stratum+tcp://${listen}`; 2972 let host = listen.slice(0, lastColon); 2973 const port = listen.slice(lastColon + 1); 2974 if (host === "0.0.0.0" || host === "::" || host === "[::]") { 2975 host = window.location.hostname || "127.0.0.1"; 2976 } 2977 return `stratum+tcp://${host}:${port}`; 2978 }, 2979 2980 lastUpdatedLabel() { 2981 return this.lastUpdated ? `Updated ${this.lastUpdated.toLocaleTimeString()}` : "Loading"; 2982 }, 2983 }; 2984 };