iuna

iuna

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

iuna-ui.js (104218B)


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