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