iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

iuna-ui.js (144211B)


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