iuna

iuna

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

iuna-ui.js (102147B)


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