iuna

iuna

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

app.js (45222B)


      1 import {
      2   API_BASE, LEGACY_STORAGE_KEY, STORAGE_KEY, api, buildSignedTransfer, decodeVersionedAddress,
      3   decryptWallet, encodeAddress, encryptWallet, formatIuna, hexToBytes, normalizeWalletStore,
      4   parseFeeRate, parseIuna, removeWallet, upsertWallet, walletFromSeed, walletId,
      5 } from "./wallet-core.js";
      6 import { generateMnemonic, validateMnemonic } from "./mnemonic.js";
      7 import initQuantumCrypto, {
      8   build_migration as buildQuantumMigration,
      9   build_transfer as buildQuantumTransfer,
     10   derive_external_addresses as deriveExternalAddresses,
     11   derive_reward_addresses as deriveRewardAddresses,
     12 } from "./crypto/iuna_wallet_crypto.js";
     13 
     14 const app = document.querySelector("#app");
     15 const toastElement = document.querySelector("#toast");
     16 const TRANSACTION_PAGE_SIZE = 25;
     17 const ALL_TRANSACTION_FILTERS = { transfer: true, mine: true, burn: true, reward: true };
     18 const state = {
     19   store: null, wallet: null, walletMeta: null, status: null, address: "", balance: null,
     20   addresses: [], addressIndex: 0, legacyAddress: "", legacyUtxos: [], hybridUtxos: [],
     21   derivedAddresses: [], derivedRewardAddresses: [], derivedWalletId: null,
     22   hybridSpendable: 0, utxos: [], transactions: [], recentTransactions: [], view: "home", timer: null,
     23   transactionFilters: { ...ALL_TRANSACTION_FILTERS },
     24   transactionPage: { offset: 0, total: 0, hasMore: true, loading: false, error: "" },
     25   transactionRequest: 0,
     26   activityObserver: null,
     27   selectedTransaction: null,
     28   transactionReturnView: "home",
     29 };
     30 let quantumCryptoPromise;
     31 
     32 async function ensureQuantumCrypto() {
     33   quantumCryptoPromise ||= initQuantumCrypto();
     34   await quantumCryptoPromise;
     35 }
     36 const icon = (name) => {
     37   const paths = {
     38     send: '<path d="M6 18 18 6M6 6h12v12"/>',
     39     receive: '<path d="M18 6 6 18M6 6v12h12"/>',
     40     home: '<rect x="3" y="5" width="18" height="15" rx="3"/><path d="M3 9h18M16 14h2"/>',
     41     activity: '<path d="M4 6h16M4 12h16M4 18h10"/>',
     42     settings: '<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="3"/><circle cx="15" cy="17" r="3"/>',
     43     lock: '<rect x="5" y="10" width="14" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v3"/>',
     44     copy: '<rect x="8" y="8" width="12" height="13" rx="2"/><path d="M15 8V3H3v13h5"/>',
     45     refresh: '<path d="M20 7v5h-5M4 17v-5h5M6 6a8 8 0 0 1 14 6M18 18a8 8 0 0 1-14-6"/>',
     46   };
     47   return '<svg class="ui-icon" viewBox="0 0 24 24" aria-hidden="true">' + (paths[name] || paths.activity) + '</svg>';
     48 };
     49 
     50 function escapeHtml(value) {
     51   return String(value ?? "").replace(/[&<>'"]/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" })[character]);
     52 }
     53 
     54 function toast(message, error = false) {
     55   toastElement.textContent = message;
     56   toastElement.className = `toast show${error ? " error" : ""}`;
     57   window.clearTimeout(toastElement.timeout);
     58   toastElement.timeout = window.setTimeout(() => { toastElement.className = "toast"; }, error ? 7000 : 3000);
     59 }
     60 
     61 function readJson(key) {
     62   try { return JSON.parse(localStorage.getItem(key)); } catch { return null; }
     63 }
     64 
     65 function loadStore() {
     66   const store = normalizeWalletStore(readJson(STORAGE_KEY), readJson(LEGACY_STORAGE_KEY));
     67   localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
     68   if (localStorage.getItem(LEGACY_STORAGE_KEY)) localStorage.removeItem(LEGACY_STORAGE_KEY);
     69   return store;
     70 }
     71 
     72 function saveStore(store) {
     73   state.store = store;
     74   localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
     75 }
     76 
     77 function activeWallet() {
     78   return state.store?.wallets.find((wallet) => wallet.id === state.store.activeId) || null;
     79 }
     80 
     81 function defaultWalletName(type) {
     82   const count = state.store.wallets.filter((wallet) => wallet.type === type).length + 1;
     83   return type === "readonly" ? `Watch-only ${count}` : `Wallet ${count}`;
     84 }
     85 
     86 function logo() {
     87   return '<div class="brand"><div class="brand-mark" aria-hidden="true"><svg viewBox="0 0 32 32" focusable="false"><circle class="mark-dot" cx="9.4" cy="7.6" r="2.8"></circle><path class="mark-loop" d="M9.4 13v7.1c0 3.7 2.9 6.4 6.6 6.4s6.6-2.7 6.6-6.4V13"></path></svg></div><span>iuna wallet</span></div>';
     88 }
     89 
     90 function renderWelcome() {
     91   app.innerHTML = `<section class="onboarding"><div class="hero">${logo()}<h1>Your iuna.<br><em>In your hands.</em></h1><p>A lightweight wallet that connects directly to the iuna network. Manage multiple signing and watch-only wallets on this device.</p></div><div class="onboarding-actions"><button class="button" data-action="create">Create a new wallet</button><button class="button secondary" data-action="import">Use an existing seed</button><button class="button ghost" data-action="watch">Add a watch-only wallet</button><div class="security-note"><span>◇</span><span>Self-custody means only you can recover your seed. iuna cannot retrieve it for you.</span></div></div></section>`;
     92 }
     93 
     94 function renderLock() {
     95   const wallet = activeWallet();
     96   if (!wallet) { renderWelcome(); return; }
     97   if (wallet.type === "readonly") { openStoredWallet(wallet); return; }
     98   app.innerHTML = `<section class="onboarding"><div class="lock-card">${logo()}<p class="eyebrow">${escapeHtml(wallet.name)}</p><h1>Welcome back.</h1><p class="view-copy">Unlock this wallet on this device.</p><form id="unlock-form"><div class="field"><label for="password">Password</label><input id="password" type="password" autocomplete="current-password" autofocus required></div><button class="button" type="submit">Unlock</button></form><button class="button ghost" data-action="wallets" style="width:100%;margin-top:10px">Choose another wallet</button><p class="security-note">Wallet ${escapeHtml(wallet.publicKeyHex?.slice(0, 8))}… is encrypted locally.</p></div></section>`;
     99 }
    100 
    101 function renderWalletPicker() {
    102   const items = state.store.wallets.map((wallet) => `<button class="wallet-row ${wallet.id === state.store.activeId ? "active" : ""}" data-wallet-id="${escapeHtml(wallet.id)}"><span><strong>${escapeHtml(wallet.name)}</strong><small>${wallet.type === "readonly" ? "Watch-only" : "Signing wallet"} · ${escapeHtml(wallet.publicKeyHex.slice(0, 10))}…</small></span><span>›</span></button>`).join("");
    103   app.innerHTML = `<section class="onboarding"><div>${logo()}<p class="eyebrow picker-title">Wallets</p><h1 class="view-title">Choose a wallet</h1><div class="wallet-list">${items}</div></div><div class="onboarding-actions"><button class="button" data-action="create">Create a new wallet</button><button class="button secondary" data-action="import">Import a seed</button><button class="button ghost" data-action="watch">Add watch-only</button></div></section>`;
    104 }
    105 
    106 function renderWatchOnly() {
    107   app.innerHTML = `<section class="onboarding"><div><button class="back" data-action="back-to-wallets">← Back</button><p class="eyebrow">Watch-only</p><h1 class="view-title">Follow an address.</h1><p class="view-copy">View its balance and activity without storing a seed or private key. This wallet can never sign transactions.</p><form id="watch-form"><div class="field"><label for="wallet-name">Wallet name</label><input id="wallet-name" maxlength="40" placeholder="Savings" required></div><div class="field"><label for="watch-address">Mainnet iuna address</label><input id="watch-address" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="iuna1q…" required></div><button class="button" type="submit" style="width:100%">Add watch-only wallet</button></form></div><div class="security-note" style="margin-top:auto"><span>◇</span><span>Only the public address is saved on this device.</span></div></section>`;
    108   document.querySelector("#wallet-name").value = defaultWalletName("readonly");
    109 }
    110 
    111 function renderImport() {
    112   app.innerHTML = `<section class="onboarding"><div><button class="back" data-action="back-to-wallets">← Back</button><p class="eyebrow">Recover wallet</p><h1 class="view-title">Existing seed</h1><p class="view-copy">Enter the 24 words in the same order. They are processed locally only.</p><form id="import-form"><div class="field"><label for="import-name">Wallet name</label><input id="import-name" maxlength="40" required></div><div class="field"><label for="seed">Seed phrase</label><textarea id="seed" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="word 1  word 2  word 3 …" required></textarea></div><div class="field"><label for="new-password">New wallet password</label><input id="new-password" type="password" minlength="10" autocomplete="new-password" placeholder="At least 10 characters" required></div><button class="button" type="submit" style="width:100%">Recover wallet</button></form></div><div class="security-note" style="margin-top:auto"><span>◇</span><span>Your seed never leaves this browser.</span></div></section>`;
    113   document.querySelector("#import-name").value = defaultWalletName("signing");
    114 }
    115 
    116 async function renderNewSeed() {
    117   app.innerHTML = `<section class="center-card"><div class="spinner"></div><p>Creating a secure seed…</p></section>`;
    118   const seed = await generateMnemonic();
    119   const words = seed.split(" ").map((word) => `<div class="seed-word">${word}</div>`).join("");
    120   app.innerHTML = `<section class="onboarding"><div><button class="back" data-action="back-to-wallets">← Back</button><p class="eyebrow">Step 1 of 2</p><h1 class="view-title">Save your seed.</h1><p class="view-copy">Write these 24 words on paper in this exact order.</p><div class="seed-grid">${words}</div><div class="warning">Do not take a screenshot. Anyone with these words can access your wallet.</div><button class="button secondary" data-action="copy-seed" style="width:100%">Copy seed</button><button class="button" data-action="seed-saved" style="width:100%;margin-top:10px">I have saved the words</button></div></section>`;
    121   app.dataset.pendingSeed = seed;
    122 }
    123 
    124 function renderPasswordSetup() {
    125   const seed = app.dataset.pendingSeed;
    126   app.innerHTML = `<section class="onboarding" data-seed="${escapeHtml(seed)}"><div><button class="back" data-action="create">← Back</button><p class="eyebrow">Step 2 of 2</p><h1 class="view-title">Secure this device.</h1><p class="view-copy">This password encrypts your seed before it is stored in the browser.</p><form id="create-form"><div class="field"><label for="create-name">Wallet name</label><input id="create-name" maxlength="40" required></div><div class="field"><label for="create-password">Password</label><input id="create-password" type="password" minlength="10" autocomplete="new-password" required></div><div class="field"><label for="confirm-password">Repeat password</label><input id="confirm-password" type="password" minlength="10" autocomplete="new-password" required></div><button class="button" type="submit" style="width:100%">Open wallet</button></form></div></section>`;
    127   document.querySelector("#create-name").value = defaultWalletName("signing");
    128   app.dataset.pendingSeed = seed;
    129 }
    130 
    131 async function saveAndOpen(seed, password, name) {
    132   const walletName = name.trim();
    133   if (!walletName) throw new Error("Enter a wallet name");
    134   const wallet = await walletFromSeed(seed);
    135   const record = await encryptWallet(seed, password, wallet.publicKeyHex);
    136   const meta = { id: walletId(wallet.publicKeyHex), name: walletName, type: "signing", publicKeyHex: wallet.publicKeyHex, record };
    137   saveStore(upsertWallet(state.store, meta));
    138   state.wallet = wallet;
    139   state.walletMeta = meta;
    140   await openWallet();
    141 }
    142 
    143 async function openStoredWallet(meta) {
    144   state.walletMeta = meta;
    145   state.wallet = meta.type === "readonly"
    146     ? { type: "readonly", publicKeyHex: meta.publicKeyHex, address: meta.address || "", publicKey: Uint8Array.from(meta.publicKeyHex.match(/.{2}/g), (pair) => Number.parseInt(pair, 16)) }
    147     : state.wallet;
    148   await openWallet();
    149 }
    150 
    151 async function fetchWalletData() {
    152   const previousAddress = state.address;
    153   state.status = await api("/status");
    154   state.legacyAddress = state.walletMeta?.type === "readonly" && state.wallet.address
    155     ? state.wallet.address
    156     : encodeAddress(state.wallet.publicKey, state.status.chain_id);
    157   let snapshot;
    158   if (state.walletMeta?.type === "readonly") {
    159     const address = state.wallet.address || state.legacyAddress;
    160     snapshot = await fetchSnapshot([address]);
    161     state.addresses = [address];
    162     state.addressIndex = 0;
    163     state.address = address;
    164   } else if (state.status.transaction_v2_active) {
    165     if (Number(state.status.api_version || 0) < 2) throw new Error("The public iuna endpoint must be upgraded for rotating quantum-resistant wallets");
    166     await ensureQuantumCrypto();
    167     const gapLimit = Math.max(1, Number(state.status.hybrid_address_gap_limit || 20));
    168     let externalCount = gapLimit + 1;
    169     let rewardCount = gapLimit;
    170     let derived;
    171     let rewardDerived;
    172     const derivationScope = `${state.walletMeta.id}:${state.status.chain_id}`;
    173     if (state.derivedWalletId !== derivationScope) {
    174       state.derivedAddresses = [];
    175       state.derivedRewardAddresses = [];
    176       state.derivedWalletId = derivationScope;
    177     }
    178     for (;;) {
    179       if (state.derivedAddresses.length < externalCount) {
    180         state.derivedAddresses = JSON.parse(deriveExternalAddresses(state.wallet.seedPhrase, externalCount, state.status.chain_id));
    181       }
    182       if (state.derivedRewardAddresses.length < rewardCount) {
    183         state.derivedRewardAddresses = JSON.parse(deriveRewardAddresses(state.wallet.seedPhrase, rewardCount, state.status.chain_id));
    184       }
    185       derived = state.derivedAddresses.slice(0, externalCount);
    186       rewardDerived = state.derivedRewardAddresses.slice(0, rewardCount);
    187       snapshot = await fetchSnapshot([
    188         state.legacyAddress,
    189         ...derived.map((item) => item.address),
    190         ...rewardDerived.map((item) => item.address),
    191       ]);
    192       const used = new Set(snapshot.addresses.filter((item) => item.used).map((item) => item.address));
    193       const highestExternal = derived.reduce((highest, item) => used.has(item.address) ? Math.max(highest, item.index) : highest, -1);
    194       const highestReward = rewardDerived.reduce((highest, item) => used.has(item.address) ? Math.max(highest, item.index) : highest, -1);
    195       const nextExternalCount = highestExternal < externalCount - gapLimit || externalCount >= 10_000
    196         ? externalCount
    197         : Math.min(10_000, highestExternal + gapLimit + 1);
    198       const nextRewardCount = highestReward < rewardCount - gapLimit || rewardCount >= 10_000
    199         ? rewardCount
    200         : Math.min(10_000, highestReward + gapLimit + 1);
    201       if (nextExternalCount === externalCount && nextRewardCount === rewardCount) break;
    202       externalCount = nextExternalCount;
    203       rewardCount = nextRewardCount;
    204     }
    205     const used = new Set(snapshot.addresses.filter((item) => item.used).map((item) => item.address));
    206     const highestUsed = derived.reduce((highest, item) => used.has(item.address) ? Math.max(highest, item.index) : highest, -1);
    207     state.addressIndex = highestUsed + 1;
    208     const current = derived.find((item) => item.index === state.addressIndex);
    209     if (!current) throw new Error("Wallet address discovery exceeded its recovery limit");
    210     state.addresses = [
    211       state.legacyAddress,
    212       ...derived.map((item) => item.address),
    213       ...rewardDerived.map((item) => item.address),
    214     ];
    215     state.address = current.address;
    216     const descriptorByAddress = new Map([
    217       ...derived.map((item) => [item.address, { addressIndex: item.index, addressBranch: "external" }]),
    218       ...rewardDerived.map((item) => [item.address, { addressIndex: item.index, addressBranch: "reward" }]),
    219     ]);
    220     state.legacyUtxos = (snapshot.utxos || []).filter((utxo) => utxo.address === state.legacyAddress);
    221     state.hybridUtxos = (snapshot.utxos || [])
    222       .filter((utxo) => descriptorByAddress.has(utxo.address))
    223       .map((utxo) => ({ ...utxo, ...descriptorByAddress.get(utxo.address) }));
    224     state.hybridSpendable = snapshot.addresses.filter((item) => item.version === 1).reduce((sum, item) => sum + Number(item.spendable || 0), 0);
    225   } else {
    226     snapshot = await fetchSnapshot([state.legacyAddress]);
    227     state.addresses = [state.legacyAddress];
    228     state.addressIndex = 0;
    229     state.address = state.legacyAddress;
    230     state.legacyUtxos = snapshot.utxos || [];
    231     state.hybridUtxos = [];
    232     state.hybridSpendable = 0;
    233   }
    234   const address = state.address;
    235   if (previousAddress && previousAddress !== address) {
    236     state.transactions = [];
    237     state.recentTransactions = [];
    238     Object.assign(state.transactionPage, { offset: 0, total: 0, hasMore: true, loading: false, error: "" });
    239   }
    240   state.address = address;
    241   const previousTransactionCount = state.transactions.length;
    242   const transactionLimit = Math.min(100, Math.max(TRANSACTION_PAGE_SIZE, previousTransactionCount));
    243   const refreshTransactions = !state.transactionPage.loading;
    244   const transactionRequest = refreshTransactions ? ++state.transactionRequest : null;
    245   const [transactions, recentTransactions] = await Promise.all([
    246     refreshTransactions ? fetchTransactions(0, transactionLimit) : Promise.resolve(null),
    247     fetchTransactions(0, 5, ALL_TRANSACTION_FILTERS),
    248   ]);
    249   state.balance = snapshot;
    250   state.utxos = snapshot.utxos || [];
    251   state.recentTransactions = normalizeTransactionItems(recentTransactions?.items);
    252   if (transactions && transactionRequest === state.transactionRequest) {
    253     transactions.items = normalizeTransactionItems(transactions.items);
    254     applyTransactionPage(transactions, true, previousTransactionCount);
    255   }
    256 }
    257 
    258 function fetchSnapshot(addresses) {
    259   return api("/wallets/snapshot", { method: "POST", body: JSON.stringify({ addresses }) });
    260 }
    261 
    262 function fetchTransactions(offset, limit, filters = state.transactionFilters) {
    263   return api("/wallets/transactions", {
    264     method: "POST",
    265     body: JSON.stringify({ addresses: state.addresses, ...filters, offset, limit }),
    266   });
    267 }
    268 
    269 function normalizeTransactionItems(items) {
    270   return Array.isArray(items) ? items.map((row) => ({
    271     kind: row.kind === "migration" ? "transfer" : row.kind,
    272     status: row.status,
    273     block_height: row.blockHeight,
    274     timestamp_ms: row.timestampMs,
    275     direction: row.direction,
    276     transaction: {
    277       kind: row.kind,
    278       inputs: row.inputs || [],
    279       outputs: row.outputs || [],
    280       change: row.change || [],
    281       amount: row.amount || 0,
    282       fee: row.fee || 0,
    283       signature: row.signature,
    284     },
    285   })) : [];
    286 }
    287 
    288 function transactionKey(item) {
    289   return `${item.kind || ""}:${item.transaction?.signature || ""}`;
    290 }
    291 
    292 function networkAddress(publicKey) {
    293   if (!publicKey) return "Unknown";
    294   if (/^(t?iuna)1/i.test(publicKey)) return publicKey;
    295   try {
    296     return encodeAddress(hexToBytes(publicKey), state.status?.chain_id);
    297   } catch {
    298     return String(publicKey);
    299   }
    300 }
    301 
    302 function applyTransactionPage(payload, replace, preserveCount = 0) {
    303   const items = Array.isArray(payload?.items) ? payload.items : [];
    304   state.transactionPage.error = "";
    305   if (replace) {
    306     if (preserveCount > items.length && payload?.has_more === true) {
    307       const fresh = new Set(items.map(transactionKey));
    308       const retained = state.transactions.filter((item) => !fresh.has(transactionKey(item)));
    309       state.transactions = items.concat(retained).slice(0, preserveCount);
    310     } else {
    311       state.transactions = items;
    312     }
    313   } else {
    314     const known = new Set(state.transactions.map(transactionKey));
    315     state.transactions = state.transactions.concat(items.filter((item) => {
    316       const key = transactionKey(item);
    317       if (known.has(key)) return false;
    318       known.add(key);
    319       return true;
    320     }));
    321   }
    322   state.transactionPage.offset = preserveCount > items.length
    323     ? state.transactions.length
    324     : Number(payload?.next_offset ?? (replace ? items.length : state.transactionPage.offset + items.length));
    325   state.transactionPage.total = Number(payload?.total ?? state.transactions.length);
    326   state.transactionPage.hasMore = state.transactions.length < state.transactionPage.total;
    327 }
    328 
    329 async function loadTransactions({ replace = false } = {}) {
    330   if (!state.wallet || (state.transactionPage.loading && !replace)) return;
    331   const request = ++state.transactionRequest;
    332   if (replace) {
    333     state.transactions = [];
    334     Object.assign(state.transactionPage, { offset: 0, total: 0, hasMore: true });
    335   } else if (!state.transactionPage.hasMore) {
    336     return;
    337   }
    338   state.transactionPage.loading = true;
    339   state.transactionPage.error = "";
    340   if (state.view === "activity") renderApp();
    341   try {
    342     const offset = replace ? 0 : state.transactionPage.offset;
    343     const payload = await fetchTransactions(offset, TRANSACTION_PAGE_SIZE);
    344     if (request !== state.transactionRequest) return;
    345     payload.items = normalizeTransactionItems(payload.items);
    346     applyTransactionPage(payload, replace);
    347   } catch (error) {
    348     if (request === state.transactionRequest) {
    349       state.transactionPage.error = error.message;
    350       toast(error.message, true);
    351     }
    352   } finally {
    353     if (request === state.transactionRequest) {
    354       state.transactionPage.loading = false;
    355       if (state.view === "activity") renderApp();
    356     }
    357   }
    358 }
    359 
    360 async function openWallet() {
    361   state.transactionRequest += 1;
    362   state.transactionPage.loading = false;
    363   state.activityObserver?.disconnect();
    364   state.activityObserver = null;
    365   app.innerHTML = `<section class="center-card"><div class="spinner"></div><p>Connecting to iuna…</p></section>`;
    366   try {
    367     await fetchWalletData();
    368     renderApp();
    369     window.clearInterval(state.timer);
    370     state.timer = window.setInterval(refreshSilently, 20_000);
    371   } catch (error) {
    372     renderOffline(error.message);
    373   }
    374 }
    375 
    376 function renderOffline(message) {
    377   app.innerHTML = `<section class="onboarding"><div class="lock-card">${logo()}<p class="eyebrow">Connection failed</p><h1 class="view-title">Endpoint unavailable.</h1><p class="view-copy">${escapeHtml(message)}</p><button class="button" data-action="retry" style="width:100%">Try again</button><button class="button ghost" data-action="lock" style="width:100%;margin-top:10px">Lock wallet</button><p class="security-note">Endpoint: ${escapeHtml(API_BASE)}</p></div></section>`;
    378 }
    379 
    380 async function refreshSilently() {
    381   if (!state.wallet) return;
    382   try { await fetchWalletData(); if (["home", "activity"].includes(state.view)) renderApp(); } catch { /* keep the last known state */ }
    383 }
    384 
    385 function topbar() {
    386   const online = state.status?.ready;
    387   return `<header class="topbar"><button class="wallet-button" data-action="wallets" aria-label="Switch wallet">${logo()}<span><strong>${escapeHtml(state.walletMeta?.name)}</strong><small>${state.walletMeta?.type === "readonly" ? "Watch-only" : "Signing"}</small></span></button><div style="display:flex;align-items:center;gap:10px"><div class="network"><span class="dot ${online ? "live" : ""}"></span>${online ? "Mainnet" : "Syncing"}</div><button class="icon-button" data-action="lock" aria-label="Lock">${icon("lock")}</button></div></header>`;
    388 }
    389 
    390 function nav() {
    391   return `<nav class="bottom-nav">${[["home","Overview"],["send","Send"],["receive","Receive"],["settings","Settings"]].map(([view,label]) => `<button class="nav-item ${state.view === view ? "active" : ""}" data-view="${view}"><span>${icon(view)}</span>${label}</button>`).join("")}</nav>`;
    392 }
    393 
    394 function transactionInfo(item) {
    395   const tx = item.transaction || {};
    396   const owns = (address) => state.addresses.includes(address) || address === state.wallet.publicKeyHex;
    397   if (tx.kind === "migration") return { title: "Migrated", incoming: true, amount: tx.amount || 0, symbol: "◇" };
    398   if (item.kind === "reward") {
    399     const amount = tx.outputs?.filter((output) => owns(output.address)).reduce((sum, output) => sum + Number(output.amount || 0), 0) || 0;
    400     return { title: "Block reward", incoming: true, amount, symbol: "★" };
    401   }
    402   if (tx.kind === "mine") return { title: "Mining reward", incoming: true, amount: 1_000_000, symbol: "✦" };
    403   if (tx.kind === "burn") return { title: "Burn", incoming: false, amount: tx.amount || 0, symbol: "×" };
    404   const sent = tx.inputs?.some((input) => owns(input.owner));
    405   const relevant = tx.outputs?.filter((output) => sent ? !owns(output.address) : owns(output.address)) || [];
    406   return { title: sent ? "Sent" : "Received", incoming: !sent, amount: relevant.reduce((sum, output) => sum + Number(output.amount || 0), 0), symbol: sent ? "↗" : "↙" };
    407 }
    408 
    409 function formatTransactionDate(item) {
    410   if (!item.timestamp_ms) return "Pending";
    411   const timestamp = new Date(Number(item.timestamp_ms));
    412   if (Number.isNaN(timestamp.getTime())) return "Unknown time";
    413   return timestamp.toLocaleString("en-GB", {
    414     day: "numeric",
    415     month: "short",
    416     hour: "2-digit",
    417     minute: "2-digit",
    418   }).replace(",", "");
    419 }
    420 
    421 function activityList(limit, filtered = false, source = state.transactions) {
    422   const items = typeof limit === "number" ? source.slice(0, limit) : source;
    423   if (!items.length && filtered && state.transactionPage.loading) return '';
    424   if (!items.length) return filtered
    425     ? '<div class="empty">No transactions match these filters.</div>'
    426     : '<div class="empty">No transactions yet.<br>Your new wallet is ready to use.</div>';
    427   return `<div class="activity-list">${items.map((item) => {
    428     const info = transactionInfo(item);
    429     const date = formatTransactionDate(item);
    430     return `<button class="activity" data-transaction-key="${escapeHtml(transactionKey(item))}" type="button" aria-label="View ${escapeHtml(info.title.toLowerCase())} transaction details"><span class="activity-icon">${info.symbol}</span><span><span class="activity-title">${info.title}</span><span class="activity-meta">${escapeHtml(date)} · ${escapeHtml(item.status)}</span></span><span class="activity-amount ${info.incoming ? "in" : ""}">${info.incoming ? "+" : "−"}${formatIuna(info.amount, 4)} IUNA</span><span class="activity-chevron" aria-hidden="true">›</span></button>`;
    431   }).join("")}</div>`;
    432 }
    433 
    434 function addressDetail(label, address, amount = null) {
    435   const formattedAmount = amount === null ? "" : `<small>${formatIuna(amount, 6)} IUNA</small>`;
    436   return `<div class="address-detail"><span>${escapeHtml(label)}</span><div><code>${escapeHtml(address)}</code>${formattedAmount}</div></div>`;
    437 }
    438 
    439 function transactionFlow(item) {
    440   const tx = item.transaction || {};
    441   if (item.kind === "reward") {
    442     return {
    443       from: [{ label: "From", address: "Network reward", amount: null }],
    444       to: (tx.outputs || []).map((output) => ({ label: "To", address: networkAddress(output.address), amount: output.amount })),
    445     };
    446   }
    447   if (tx.kind === "mine") {
    448     return {
    449       from: [{ label: "From", address: "Mining protocol", amount: null }],
    450       to: [{ label: "To", address: networkAddress(tx.recipient), amount: 1_000_000 }],
    451     };
    452   }
    453   const owners = [...new Set((tx.inputs || []).map((input) => input.owner).filter(Boolean))];
    454   const outputs = tx.kind === "burn" ? (tx.change || []) : (tx.outputs || []);
    455   const from = owners.map((owner) => ({ label: "From", address: networkAddress(owner), amount: null }));
    456   const to = outputs.map((output) => ({
    457     label: owners.includes(output.address) ? "Change to" : "To",
    458     address: networkAddress(output.address),
    459     amount: output.amount,
    460   }));
    461   if (tx.kind === "burn") to.unshift({ label: "To", address: "Burned permanently", amount: tx.amount || 0 });
    462   return {
    463     from: from.length ? from : [{ label: "From", address: "Unknown", amount: null }],
    464     to: to.length ? to : [{ label: "To", address: "Unknown", amount: null }],
    465   };
    466 }
    467 
    468 function renderTransaction() {
    469   const item = state.selectedTransaction;
    470   if (!item) {
    471     state.view = state.transactionReturnView;
    472     return state.view === "activity" ? renderActivity() : renderHome();
    473   }
    474   const tx = item.transaction || {};
    475   const info = transactionInfo(item);
    476   const flow = transactionFlow(item);
    477   const transactionId = tx.signature || "Unknown";
    478   const fee = Number(tx.fee || 0);
    479   const addresses = [...flow.from, ...flow.to].map((entry) => addressDetail(entry.label, entry.address, entry.amount)).join("");
    480   return `${topbar()}<button class="back" data-action="close-transaction">← Back</button><p class="eyebrow">Transaction details</p><h1 class="view-title">${escapeHtml(info.title)}</h1><p class="transaction-total ${info.incoming ? "in" : ""}">${info.incoming ? "+" : "−"}${formatIuna(info.amount, 6)} IUNA</p><div class="panel transaction-detail">${addresses}<div class="detail-line"><span>Status</span><strong class="status-value">${escapeHtml(item.status || "Unknown")}</strong></div><div class="detail-line"><span>Date</span><strong>${escapeHtml(formatTransactionDate(item))}</strong></div>${item.block_height === null || item.block_height === undefined ? "" : `<div class="detail-line"><span>Block</span><strong>${escapeHtml(item.block_height)}</strong></div>`}${fee ? `<div class="detail-line"><span>Network fee</span><strong>${formatIuna(fee, 6)} IUNA</strong></div>` : ""}<div class="transaction-id"><span>Transaction ID</span><code>${escapeHtml(transactionId)}</code><button class="icon-button" data-action="copy-transaction-id" aria-label="Copy transaction ID">${icon("copy")}</button></div></div>`;
    481 }
    482 
    483 function renderHome() {
    484   const readonly = state.walletMeta?.type === "readonly";
    485   const legacySpendable = state.legacyUtxos.reduce((sum, utxo) => sum + Number(utxo.output.amount || 0), 0);
    486   const migration = !readonly && state.status?.transaction_v2_active && legacySpendable > 0
    487     ? `<div class="panel migration-card"><p class="eyebrow">Quantum-resistant wallet</p><h2>Migrate ${formatIuna(legacySpendable, 6)} IUNA</h2><p>Move the remaining legacy outputs into your rotating hybrid wallet.</p><button class="button" data-action="migrate">Migrate now</button></div>`
    488     : "";
    489   return `${topbar()}<section>${readonly ? '<div class="mode-badge">Watch-only · signing disabled</div>' : ""}<p class="eyebrow">Available balance</p><h1 class="balance">${formatIuna(state.balance?.spendable, 6)} <span>IUNA</span></h1><p class="subbalance">${formatIuna(state.balance?.confirmed, 6)} confirmed · block ${escapeHtml(state.balance?.height)}</p><div class="actions"><button class="button" data-view="send" ${readonly ? "disabled" : ""}>${icon("send")} Send</button><button class="button secondary" data-view="receive">${icon("receive")} Receive</button></div>${migration}<div class="section-head"><h2>Recent activity</h2><button data-view="activity">View all</button></div><div class="panel">${activityList(5, false, state.recentTransactions)}</div></section>`;
    490 }
    491 
    492 function renderSend() {
    493   if (state.walletMeta?.type === "readonly") return `${topbar()}<p class="eyebrow">Watch-only</p><h1 class="view-title">Sending is disabled.</h1><p class="view-copy">This wallet contains no seed or private key, so it cannot sign transactions.</p><button class="button secondary" data-view="home" style="width:100%">Back to overview</button>`;
    494   const defaultFeeRate = state.status.default_fee_per_byte ?? 1;
    495   const available = state.status?.transaction_v2_active ? state.hybridSpendable : state.balance?.spendable;
    496   return `${topbar()}<p class="eyebrow">Transaction</p><h1 class="view-title">Send IUNA</h1><p class="view-copy">The transaction is signed on this device with your hybrid quantum-resistant key.</p><form id="send-form" class="panel send-card"><div class="field"><label for="recipient">Recipient</label><input id="recipient" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="iuna1p…" required></div><div class="field"><label for="amount">Amount</label><div class="amount-wrap"><input id="amount" inputmode="decimal" placeholder="0.00" required><span>IUNA</span></div></div><div class="field"><label for="fee-rate">Fee rate (µIUNA per byte)</label><input id="fee-rate" name="fee-rate" type="number" inputmode="numeric" min="1" step="1" value="${escapeHtml(defaultFeeRate)}" required></div><div class="fee-line"><span>Available</span><strong>${formatIuna(available)} IUNA</strong></div><button class="button" type="submit">Review transaction</button></form>`;
    497 }
    498 
    499 function renderReceive() {
    500   const readonly = state.walletMeta?.type === "readonly";
    501   const explanation = readonly
    502     ? "This watch-only entry follows this address only; it cannot derive future rotating addresses."
    503     : "This address rotates automatically after funds arrive. Previous addresses remain part of your wallet.";
    504   return `${topbar()}<p class="eyebrow">Your address</p><h1 class="view-title">Receive IUNA</h1><p class="view-copy">Share this current mainnet address with the sender.</p><div class="panel"><div class="receive-emblem">${icon("receive")}</div><p class="eyebrow">${readonly ? "Watched address" : "Current receiving address"}</p><div class="address-box"><code>${escapeHtml(state.address)}</code><button class="icon-button" data-action="copy-address" aria-label="Copy address">${icon("copy")}</button></div><p class="security-note">${explanation}</p></div>`;
    505 }
    506 
    507 function renderActivity() {
    508   const filters = [["transfer", "Tx"], ["mine", "Mine"], ["burn", "Burn"], ["reward", "Reward"]]
    509     .map(([kind, label]) => `<button class="transaction-filter ${state.transactionFilters[kind] ? "active" : ""}" data-transaction-filter="${kind}" aria-pressed="${state.transactionFilters[kind]}">${label}</button>`)
    510     .join("");
    511   const loader = state.transactionPage.loading ? '<div class="activity-loader"><span class="spinner"></span><span>Loading transactions…</span></div>' : '';
    512   const retry = state.transactionPage.error ? '<button class="activity-retry" data-action="retry-transactions">Loading failed · try again</button>' : '';
    513   const sentinel = state.transactionPage.hasMore && !state.transactionPage.error ? '<div id="activity-sentinel" class="activity-sentinel" aria-hidden="true"></div>' : '';
    514   return `${topbar()}<p class="eyebrow">Wallet</p><h1 class="view-title">Activity</h1><p class="view-copy">Confirmed and pending transactions.</p><div class="transaction-filters" aria-label="Transaction filters">${filters}</div><div class="panel">${activityList(undefined, true)}${loader}${retry}${sentinel}</div>`;
    515 }
    516 
    517 function renderSettings() {
    518   const security = state.walletMeta?.type === "readonly" ? "Watch-only · no private key stored" : "AES-256-GCM · PBKDF2-SHA256 · 310,000 iterations";
    519   return `${topbar()}<p class="eyebrow">Wallet</p><h1 class="view-title">Settings</h1><div class="panel"><div class="setting"><h3>Name</h3><p>${escapeHtml(state.walletMeta?.name)}</p></div><div class="setting"><h3>Network</h3><p>${escapeHtml(state.status.network_id)} · block ${escapeHtml(state.status.height)}</p></div><div class="setting"><h3>Public endpoint</h3><p>${escapeHtml(API_BASE)}</p></div><div class="setting"><h3>Local security</h3><p>${security}</p></div><div class="setting"><h3>Wallet address</h3><p style="word-break:break-all">${escapeHtml(state.address)}</p></div></div><button class="button secondary" data-action="wallets" style="width:100%;margin-top:12px">Switch or add wallet</button><button class="button ghost" data-action="lock" style="width:100%;margin-top:10px">Lock wallet</button><button class="button danger" data-action="forget" style="width:100%;margin-top:10px">Remove this wallet</button><p class="security-note">iuna is experimental software. Only use funds you can afford to lose.</p>`;
    520 }
    521 
    522 function renderApp() {
    523   const renderers = { home: renderHome, send: renderSend, receive: renderReceive, activity: renderActivity, transaction: renderTransaction, settings: renderSettings };
    524   state.activityObserver?.disconnect();
    525   state.activityObserver = null;
    526   app.innerHTML = `${(renderers[state.view] || renderHome)()}${nav()}`;
    527   if (state.view === "activity") {
    528     const sentinel = document.querySelector("#activity-sentinel");
    529     if (sentinel) {
    530       state.activityObserver = new IntersectionObserver((entries) => {
    531         if (entries.some((entry) => entry.isIntersecting)) loadTransactions();
    532       }, { rootMargin: "240px 0px" });
    533       state.activityObserver.observe(sentinel);
    534     }
    535   }
    536 }
    537 
    538 function renderConfirmation(transaction, fee, recipientAddress, amount) {
    539   app.innerHTML = `${topbar()}<button class="back" data-view="send">← Edit</button><p class="eyebrow">Review</p><h1 class="view-title">Does everything look right?</h1><div class="panel"><div class="detail-line"><span>You send</span><strong>${formatIuna(amount)} IUNA</strong></div><div class="detail-line"><span>To</span><strong>${escapeHtml(recipientAddress.slice(0, 12))}…${escapeHtml(recipientAddress.slice(-8))}</strong></div><div class="detail-line"><span>Network fee</span><strong>${formatIuna(fee)} IUNA</strong></div><div class="detail-line"><span>Total</span><strong>${formatIuna(amount + fee)} IUNA</strong></div></div><button class="button" id="confirm-send" style="width:100%;margin-top:14px">Sign & send</button><p class="security-note">This action cannot be reversed after submission.</p>${nav()}`;
    540   document.querySelector("#confirm-send").addEventListener("click", async (event) => {
    541     const button = event.currentTarget;
    542     button.disabled = true; button.innerHTML = '<span class="spinner"></span> Sending…';
    543     try {
    544       const isV2 = typeof transaction.envelope === "string";
    545       const result = await api(isV2 ? "/transactions-v2" : "/transactions", {
    546         method: "POST",
    547         body: JSON.stringify(isV2 ? { envelope: transaction.envelope } : transaction),
    548         timeoutMs: 20_000,
    549       });
    550       toast(result.status === "accepted" ? "Transaction sent" : "Transaction was already known");
    551       state.view = "home";
    552       await fetchWalletData();
    553       renderApp();
    554     } catch (error) { toast(error.message, true); button.disabled = false; button.textContent = "Try again"; }
    555   });
    556 }
    557 
    558 app.addEventListener("click", async (event) => {
    559   const button = event.target.closest("button");
    560   if (!button) return;
    561   const action = button.dataset.action;
    562   if (button.dataset.transactionKey) {
    563     const key = button.dataset.transactionKey;
    564     const item = [...state.recentTransactions, ...state.transactions].find((transaction) => transactionKey(transaction) === key);
    565     if (item) {
    566       state.transactionReturnView = state.view === "activity" ? "activity" : "home";
    567       state.selectedTransaction = item;
    568       state.view = "transaction";
    569       renderApp();
    570     }
    571     return;
    572   }
    573   if (action === "close-transaction") {
    574     state.selectedTransaction = null;
    575     state.view = state.transactionReturnView;
    576     renderApp();
    577     return;
    578   }
    579   if (action === "copy-transaction-id") {
    580     await navigator.clipboard.writeText(state.selectedTransaction?.transaction?.signature || "");
    581     toast("Transaction ID copied");
    582     return;
    583   }
    584   if (button.dataset.transactionFilter) {
    585     const filter = button.dataset.transactionFilter;
    586     state.transactionFilters[filter] = !state.transactionFilters[filter];
    587     await loadTransactions({ replace: true });
    588     return;
    589   }
    590   if (action === "retry-transactions") {
    591     await loadTransactions({ replace: state.transactions.length === 0 });
    592     return;
    593   }
    594   if (button.dataset.view) {
    595     if (button.dataset.view === "send" && state.walletMeta?.type === "readonly") { toast("Watch-only wallets cannot send", true); return; }
    596     state.view = button.dataset.view; renderApp(); return;
    597   }
    598   if (button.dataset.walletId) {
    599     saveStore({ ...state.store, activeId: button.dataset.walletId });
    600     state.wallet = null; state.walletMeta = null; state.view = "home";
    601     renderLock();
    602     return;
    603   }
    604   if (action === "welcome") renderWelcome();
    605   if (action === "back-to-wallets") state.store.wallets.length ? renderWalletPicker() : renderWelcome();
    606   if (action === "create") await renderNewSeed();
    607   if (action === "import") renderImport();
    608   if (action === "watch") renderWatchOnly();
    609   if (action === "seed-saved") renderPasswordSetup();
    610   if (action === "copy-seed") { await navigator.clipboard.writeText(app.dataset.pendingSeed); toast("Seed copied — clear your clipboard after use"); }
    611   if (action === "copy-address") { await navigator.clipboard.writeText(state.address); toast("Address copied"); }
    612   if (action === "migrate") {
    613     button.disabled = true;
    614     button.innerHTML = '<span class="spinner"></span> Preparing…';
    615     try {
    616       await ensureQuantumCrypto();
    617       const built = JSON.parse(buildQuantumMigration(JSON.stringify({
    618         seed: state.wallet.seedPhrase,
    619         chainId: state.status.chain_id,
    620         genesisHash: state.status.genesis_hash,
    621         destinationIndex: state.addressIndex,
    622         feeRate: String(state.status.default_fee_per_byte || 1),
    623         utxos: state.legacyUtxos,
    624       })));
    625       const amount = state.legacyUtxos.reduce((sum, utxo) => sum + BigInt(utxo.output.amount), 0n) - BigInt(built.fee);
    626       renderConfirmation(built, BigInt(built.fee), state.address, amount);
    627     } catch (error) {
    628       toast(error.message || String(error), true);
    629       button.disabled = false;
    630       button.textContent = "Migrate now";
    631     }
    632     return;
    633   }
    634   if (action === "retry") await openWallet();
    635   if (action === "wallets") { window.clearInterval(state.timer); state.wallet = null; state.walletMeta = null; state.view = "home"; renderWalletPicker(); }
    636   if (action === "lock") {
    637     window.clearInterval(state.timer); state.wallet = null; state.walletMeta = null; state.view = "home";
    638     if (activeWallet()?.type === "readonly") renderWalletPicker(); else renderLock();
    639   }
    640   if (action === "forget") {
    641     const warning = state.walletMeta?.type === "readonly"
    642       ? `Remove “${state.walletMeta.name}” from this device?`
    643       : `Remove “${state.walletMeta?.name}” from this device? Make sure you have saved the seed.`;
    644     if (window.confirm(warning)) {
    645       saveStore(removeWallet(state.store, state.walletMeta.id));
    646       window.clearInterval(state.timer); state.wallet = null; state.walletMeta = null; state.view = "home";
    647       state.store.wallets.length ? renderWalletPicker() : renderWelcome();
    648     }
    649   }
    650 });
    651 
    652 app.addEventListener("submit", async (event) => {
    653   event.preventDefault();
    654   const form = event.target;
    655   const button = form.querySelector('[type="submit"]');
    656   button.disabled = true;
    657   const original = button.textContent;
    658   button.innerHTML = '<span class="spinner"></span> Please wait…';
    659   try {
    660     if (form.id === "unlock-form") {
    661       const meta = activeWallet();
    662       state.wallet = await decryptWallet(meta.record, form.password.value);
    663       state.walletMeta = meta;
    664       await openWallet();
    665     } else if (form.id === "import-form") {
    666       const seed = await validateMnemonic(form.seed.value);
    667       await saveAndOpen(seed, form["new-password"].value, form["import-name"].value);
    668     } else if (form.id === "create-form") {
    669       if (form["create-password"].value !== form["confirm-password"].value) throw new Error("The passwords do not match");
    670       await saveAndOpen(app.dataset.pendingSeed, form["create-password"].value, form["create-name"].value);
    671     } else if (form.id === "watch-form") {
    672       const address = form["watch-address"].value.trim().toLowerCase();
    673       const walletName = form["wallet-name"].value.trim();
    674       if (!walletName) throw new Error("Enter a wallet name");
    675       const decoded = decodeVersionedAddress(address, "iuna");
    676       const publicKeyHex = decoded.payloadHex;
    677       if (state.store.wallets.some((wallet) => (wallet.address || "").toLowerCase() === address || (!wallet.address && wallet.publicKeyHex === publicKeyHex))) throw new Error("This wallet is already on this device");
    678       const meta = { id: `watch-${decoded.version}-${publicKeyHex}`, name: walletName, type: "readonly", publicKeyHex, address };
    679       saveStore(upsertWallet(state.store, meta));
    680       await openStoredWallet(meta);
    681     } else if (form.id === "send-form") {
    682       if (state.walletMeta?.type === "readonly") throw new Error("Watch-only wallets cannot sign transactions");
    683       const amount = parseIuna(form.amount.value);
    684       const feeRate = parseFeeRate(form["fee-rate"].value);
    685       if (state.status.transaction_v2_active) {
    686         await ensureQuantumCrypto();
    687         const built = JSON.parse(buildQuantumTransfer(JSON.stringify({
    688           seed: state.wallet.seedPhrase,
    689           chainId: state.status.chain_id,
    690           genesisHash: state.status.genesis_hash,
    691           recipientAddress: form.recipient.value.trim(),
    692           amount: amount.toString(),
    693           feeRate: feeRate.toString(),
    694           changeIndex: state.addressIndex,
    695           utxos: state.hybridUtxos,
    696         })));
    697         renderConfirmation(built, BigInt(built.fee), form.recipient.value.trim(), amount);
    698       } else {
    699         const built = await buildSignedTransfer({ wallet: state.wallet, status: state.status, utxos: state.utxos, recipientAddress: form.recipient.value, amount, feeRate });
    700         renderConfirmation(built.transaction, built.fee, form.recipient.value.trim(), amount);
    701       }
    702     }
    703   } catch (error) {
    704     toast(error?.message || String(error), true); button.disabled = false; button.textContent = original;
    705   }
    706 });
    707 
    708 state.store = loadStore();
    709 
    710 if (!window.isSecureContext || !crypto?.subtle) {
    711   app.innerHTML = '<section class="center-card"><div><h1>Secure connection required</h1><p>Open this wallet over HTTPS or localhost.</p></div></section>';
    712 } else if (state.store.wallets.length) renderLock(); else renderWelcome();