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