iuna

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

iuna-ui.js (102199B)


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