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) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[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();