iuna

iuna

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

commit 3f88c935c9f2b47fe94591ca95f6452e7d70c631
parent 1ca17903325434cdd912070a409d6db3a7cbbbd9
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue,  8 Sep 2026 21:11:33 +0200

feat(wallet): support multiple and watch-only wallets

Diffstat:
MREADME.md | 8+++++---
Atests/lightweight-wallet.test.cjs | 43+++++++++++++++++++++++++++++++++++++++++++
Mwallet/app.js | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mwallet/index.html | 1+
Awallet/multi-wallet.css | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/wallet-core.js | 49++++++++++++++++++++++++++++++++++++++++++++++++-
6 files changed, 260 insertions(+), 28 deletions(-)

diff --git a/README.md b/README.md @@ -276,9 +276,11 @@ KiB. The production manifest exposes this API as `https://iuna.jhx.app/v1` while `https://admin.iuna.jhx.app/` remains the separately protected management UI. The lightweight browser wallet lives in `wallet/` and is served at -`https://wallet.getiuna.org/`. It keeps the seed encrypted in browser -`localStorage`, signs transfers locally, and talks only to the public v1 wallet -endpoint. Requests to the old `/wallet/` path redirect to the dedicated host. +`https://wallet.getiuna.org/`. It supports multiple named wallets. Signing +wallets keep each seed encrypted in browser `localStorage`; watch-only wallets +store only a public address and cannot sign or send. The app talks only to the +public v1 wallet endpoint. Requests to the old `/wallet/` path redirect to the +dedicated host. ## Optional: Stratum Mining diff --git a/tests/lightweight-wallet.test.cjs b/tests/lightweight-wallet.test.cjs @@ -0,0 +1,43 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); + +let core; + +test.before(async () => { + const source = readFileSync(require.resolve('../wallet/wallet-core.js'), 'utf8'); + core = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); +}); + +test('migrates the single-wallet record into a named wallet collection', () => { + const legacy = { address: 'ab'.repeat(32), ciphertext: 'encrypted' }; + const store = core.normalizeWalletStore(null, legacy); + + assert.equal(store.version, 2); + assert.equal(store.wallets.length, 1); + assert.equal(store.wallets[0].type, 'signing'); + assert.equal(store.wallets[0].record, legacy); + assert.equal(store.activeId, store.wallets[0].id); +}); + +test('adds, selects, and removes multiple wallet types', () => { + const signing = { id: 'signing', name: 'Daily', type: 'signing', publicKeyHex: '11'.repeat(32), record: { ciphertext: 'secret' } }; + const readonly = { id: 'readonly', name: 'Savings', type: 'readonly', publicKeyHex: '22'.repeat(32) }; + let store = core.upsertWallet(core.emptyWalletStore(), signing); + store = core.upsertWallet(store, readonly); + + assert.deepEqual(store.wallets.map((wallet) => wallet.name), ['Daily', 'Savings']); + assert.equal(store.activeId, 'readonly'); + assert.equal('record' in store.wallets[1], false); + + store = core.removeWallet(store, 'readonly'); + assert.equal(store.activeId, 'signing'); + assert.deepEqual(store.wallets.map((wallet) => wallet.id), ['signing']); +}); + +test('refuses to sign with a watch-only wallet', async () => { + await assert.rejects( + core.buildSignedTransfer({ wallet: { publicKeyHex: '22'.repeat(32) } }), + /watch-only wallet cannot sign/, + ); +}); diff --git a/wallet/app.js b/wallet/app.js @@ -1,12 +1,13 @@ import { - API_BASE, STORAGE_KEY, api, buildSignedTransfer, decryptWallet, encodeAddress, - encryptWallet, formatIuna, parseIuna, walletFromSeed, + API_BASE, LEGACY_STORAGE_KEY, STORAGE_KEY, api, buildSignedTransfer, decodeAddress, + decryptWallet, encodeAddress, encryptWallet, formatIuna, normalizeWalletStore, + parseIuna, removeWallet, upsertWallet, walletFromSeed, walletId, } from "./wallet-core.js"; import { generateMnemonic, validateMnemonic } from "./mnemonic.js"; const app = document.querySelector("#app"); const toastElement = document.querySelector("#toast"); -const state = { wallet: null, status: null, address: "", balance: null, utxos: [], transactions: [], view: "home", timer: null }; +const state = { store: null, wallet: null, walletMeta: null, status: null, address: "", balance: null, utxos: [], transactions: [], view: "home", timer: null }; const icon = (name) => { const paths = { send: '<path d="M6 18 18 6M6 6h12v12"/>', @@ -32,8 +33,29 @@ function toast(message, error = false) { toastElement.timeout = window.setTimeout(() => { toastElement.className = "toast"; }, 3000); } -function storedWallet() { - try { return JSON.parse(localStorage.getItem(STORAGE_KEY)); } catch { return null; } +function readJson(key) { + try { return JSON.parse(localStorage.getItem(key)); } catch { return null; } +} + +function loadStore() { + const store = normalizeWalletStore(readJson(STORAGE_KEY), readJson(LEGACY_STORAGE_KEY)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + if (localStorage.getItem(LEGACY_STORAGE_KEY)) localStorage.removeItem(LEGACY_STORAGE_KEY); + return store; +} + +function saveStore(store) { + state.store = store; + localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); +} + +function activeWallet() { + return state.store?.wallets.find((wallet) => wallet.id === state.store.activeId) || null; +} + +function defaultWalletName(type) { + const count = state.store.wallets.filter((wallet) => wallet.type === type).length + 1; + return type === "readonly" ? `Watch-only ${count}` : `Wallet ${count}`; } function logo() { @@ -41,36 +63,63 @@ function logo() { } function renderWelcome() { - 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. Your seed stays encrypted 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><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>`; + 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>`; } function renderLock() { - const record = storedWallet(); - app.innerHTML = `<section class="onboarding"><div class="lock-card">${logo()}<h1>Welcome back.</h1><p class="view-copy">Unlock your 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="forget" style="width:100%;margin-top:10px">Use a different wallet</button><p class="security-note">Wallet ${escapeHtml(record?.address?.slice(0, 8))}… is encrypted locally.</p></div></section>`; + const wallet = activeWallet(); + if (!wallet) { renderWelcome(); return; } + if (wallet.type === "readonly") { openStoredWallet(wallet); return; } + 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>`; +} + +function renderWalletPicker() { + 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(""); + 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>`; +} + +function renderWatchOnly() { + 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>`; + document.querySelector("#wallet-name").value = defaultWalletName("readonly"); } function renderImport() { - app.innerHTML = `<section class="onboarding"><div><button class="back" data-action="welcome">← 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="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>`; + 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>`; + document.querySelector("#import-name").value = defaultWalletName("signing"); } async function renderNewSeed() { app.innerHTML = `<section class="center-card"><div class="spinner"></div><p>Creating a secure seed…</p></section>`; const seed = await generateMnemonic(); const words = seed.split(" ").map((word) => `<div class="seed-word">${word}</div>`).join(""); - app.innerHTML = `<section class="onboarding"><div><button class="back" data-action="welcome">← 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>`; + 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>`; app.dataset.pendingSeed = seed; } function renderPasswordSetup() { const seed = app.dataset.pendingSeed; - 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-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>`; + 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>`; + document.querySelector("#create-name").value = defaultWalletName("signing"); app.dataset.pendingSeed = seed; } -async function saveAndOpen(seed, password) { +async function saveAndOpen(seed, password, name) { + const walletName = name.trim(); + if (!walletName) throw new Error("Enter a wallet name"); const wallet = await walletFromSeed(seed); - localStorage.setItem(STORAGE_KEY, JSON.stringify(await encryptWallet(seed, password, wallet.publicKeyHex))); + const record = await encryptWallet(seed, password, wallet.publicKeyHex); + const meta = { id: walletId(wallet.publicKeyHex), name: walletName, type: "signing", publicKeyHex: wallet.publicKeyHex, record }; + saveStore(upsertWallet(state.store, meta)); state.wallet = wallet; + state.walletMeta = meta; + await openWallet(); +} + +async function openStoredWallet(meta) { + state.walletMeta = meta; + state.wallet = meta.type === "readonly" + ? { type: "readonly", publicKeyHex: meta.publicKeyHex, publicKey: Uint8Array.from(meta.publicKeyHex.match(/.{2}/g), (pair) => Number.parseInt(pair, 16)) } + : state.wallet; await openWallet(); } @@ -111,7 +160,7 @@ async function refreshSilently() { function topbar() { const online = state.status?.ready; - return `<header class="topbar">${logo()}<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>`; + 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>`; } function nav() { @@ -155,10 +204,12 @@ function activityList(limit) { } function renderHome() { - return `${topbar()}<section><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">${icon("send")} Send</button><button class="button secondary" data-view="receive">${icon("receive")} Receive</button></div><div class="section-head"><h2>Recent activity</h2><button data-view="activity">View all</button></div><div class="panel">${activityList(5)}</div></section>`; + const readonly = state.walletMeta?.type === "readonly"; + 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><div class="section-head"><h2>Recent activity</h2><button data-view="activity">View all</button></div><div class="panel">${activityList(5)}</div></section>`; } function renderSend() { + 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>`; 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.</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="iuna1q…" 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="fee-line"><span>Available</span><strong>${formatIuna(state.balance?.spendable)} IUNA</strong></div><div class="fee-line"><span>Fee rate</span><strong>${escapeHtml(state.status.default_fee_per_byte)} µIUNA / byte</strong></div><button class="button" type="submit">Review transaction</button></form>`; } @@ -171,7 +222,8 @@ function renderActivity() { } function renderSettings() { - return `${topbar()}<p class="eyebrow">Wallet</p><h1 class="view-title">Settings</h1><div class="panel"><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>AES-256-GCM · PBKDF2-SHA256 · 310,000 iterations</p></div><div class="setting"><h3>Wallet address</h3><p style="word-break:break-all">${escapeHtml(state.address)}</p></div></div><button class="button ghost" data-action="lock" style="width:100%;margin-top:12px">Lock wallet</button><button class="button danger" data-action="forget" style="width:100%;margin-top:10px">Remove wallet from this device</button><p class="security-note">iuna is experimental software. Only use funds you can afford to lose.</p>`; + const security = state.walletMeta?.type === "readonly" ? "Watch-only · no private key stored" : "AES-256-GCM · PBKDF2-SHA256 · 310,000 iterations"; + 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>`; } function renderApp() { @@ -198,18 +250,38 @@ app.addEventListener("click", async (event) => { const button = event.target.closest("button"); if (!button) return; const action = button.dataset.action; - if (button.dataset.view) { state.view = button.dataset.view; renderApp(); return; } + if (button.dataset.view) { + if (button.dataset.view === "send" && state.walletMeta?.type === "readonly") { toast("Watch-only wallets cannot send", true); return; } + state.view = button.dataset.view; renderApp(); return; + } + if (button.dataset.walletId) { + saveStore({ ...state.store, activeId: button.dataset.walletId }); + state.wallet = null; state.walletMeta = null; state.view = "home"; + renderLock(); + return; + } if (action === "welcome") renderWelcome(); + if (action === "back-to-wallets") state.store.wallets.length ? renderWalletPicker() : renderWelcome(); if (action === "create") await renderNewSeed(); if (action === "import") renderImport(); + if (action === "watch") renderWatchOnly(); if (action === "seed-saved") renderPasswordSetup(); if (action === "copy-seed") { await navigator.clipboard.writeText(app.dataset.pendingSeed); toast("Seed copied — clear your clipboard after use"); } if (action === "copy-address") { await navigator.clipboard.writeText(state.address); toast("Address copied"); } if (action === "retry") await openWallet(); - if (action === "lock") { window.clearInterval(state.timer); state.wallet = null; state.view = "home"; renderLock(); } + if (action === "wallets") { window.clearInterval(state.timer); state.wallet = null; state.walletMeta = null; state.view = "home"; renderWalletPicker(); } + if (action === "lock") { + window.clearInterval(state.timer); state.wallet = null; state.walletMeta = null; state.view = "home"; + if (activeWallet()?.type === "readonly") renderWalletPicker(); else renderLock(); + } if (action === "forget") { - if (window.confirm("Are you sure you want to remove the local wallet? Make sure you have saved the seed.")) { - localStorage.removeItem(STORAGE_KEY); window.clearInterval(state.timer); state.wallet = null; renderWelcome(); + const warning = state.walletMeta?.type === "readonly" + ? `Remove “${state.walletMeta.name}” from this device?` + : `Remove “${state.walletMeta?.name}” from this device? Make sure you have saved the seed.`; + if (window.confirm(warning)) { + saveStore(removeWallet(state.store, state.walletMeta.id)); + window.clearInterval(state.timer); state.wallet = null; state.walletMeta = null; state.view = "home"; + state.store.wallets.length ? renderWalletPicker() : renderWelcome(); } } }); @@ -223,15 +295,27 @@ app.addEventListener("submit", async (event) => { button.innerHTML = '<span class="spinner"></span> Please wait…'; try { if (form.id === "unlock-form") { - state.wallet = await decryptWallet(storedWallet(), form.password.value); + const meta = activeWallet(); + state.wallet = await decryptWallet(meta.record, form.password.value); + state.walletMeta = meta; await openWallet(); } else if (form.id === "import-form") { const seed = await validateMnemonic(form.seed.value); - await saveAndOpen(seed, form["new-password"].value); + await saveAndOpen(seed, form["new-password"].value, form["import-name"].value); } else if (form.id === "create-form") { if (form["create-password"].value !== form["confirm-password"].value) throw new Error("The passwords do not match"); - await saveAndOpen(app.dataset.pendingSeed, form["create-password"].value); + await saveAndOpen(app.dataset.pendingSeed, form["create-password"].value, form["create-name"].value); + } else if (form.id === "watch-form") { + const address = form["watch-address"].value.trim().toLowerCase(); + const walletName = form["wallet-name"].value.trim(); + if (!walletName) throw new Error("Enter a wallet name"); + const publicKeyHex = decodeAddress(address, "iuna"); + if (state.store.wallets.some((wallet) => wallet.publicKeyHex === publicKeyHex)) throw new Error("This wallet is already on this device"); + const meta = { id: walletId(publicKeyHex), name: walletName, type: "readonly", publicKeyHex }; + saveStore(upsertWallet(state.store, meta)); + await openStoredWallet(meta); } else if (form.id === "send-form") { + if (state.walletMeta?.type === "readonly") throw new Error("Watch-only wallets cannot sign transactions"); const amount = parseIuna(form.amount.value); const built = await buildSignedTransfer({ wallet: state.wallet, status: state.status, utxos: state.utxos, recipientAddress: form.recipient.value, amount }); renderConfirmation(built.transaction, built.fee, form.recipient.value.trim(), amount); @@ -241,6 +325,8 @@ app.addEventListener("submit", async (event) => { } }); +state.store = loadStore(); + if (!window.isSecureContext || !crypto?.subtle) { app.innerHTML = '<section class="center-card"><div><h1>Secure connection required</h1><p>Open this wallet over HTTPS or localhost.</p></div></section>'; -} else if (storedWallet()) renderLock(); else renderWelcome(); +} else if (state.store.wallets.length) renderLock(); else renderWelcome(); diff --git a/wallet/index.html b/wallet/index.html @@ -10,6 +10,7 @@ <link rel="icon" href="./icon.svg" type="image/svg+xml"> <link rel="stylesheet" href="./styles.css"> <link rel="stylesheet" href="./node-theme.css"> + <link rel="stylesheet" href="./multi-wallet.css"> </head> <body> <div class="ambient ambient-one"></div><div class="ambient ambient-two"></div> diff --git a/wallet/multi-wallet.css b/wallet/multi-wallet.css @@ -0,0 +1,53 @@ +.wallet-button { + appearance: none; + border: 0; + background: transparent; + color: var(--ink); + padding: 0; + display: flex; + align-items: center; + gap: 10px; + text-align: left; + cursor: pointer; +} +.wallet-button .brand span { display: none; } +.wallet-button .brand-mark { width: 38px; height: 38px; border-radius: 12px; } +.wallet-button > span { display: grid; gap: 3px; } +.wallet-button strong { + font-size: 13px; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.wallet-button small, .wallet-row small { font-size: 10px; color: var(--muted); } +.picker-title { margin-top: 34px; } +.wallet-list { display: grid; gap: 9px; margin: 22px 0; } +.wallet-row { + appearance: none; + width: 100%; + border: 1px solid var(--line); + background: var(--panel); + color: var(--ink); + border-radius: 16px; + padding: 15px; + display: flex; + align-items: center; + justify-content: space-between; + text-align: left; + cursor: pointer; +} +.wallet-row.active { border-color: rgba(213, 245, 95, .5); } +.wallet-row span:first-child { display: grid; gap: 6px; min-width: 0; } +.wallet-row strong { overflow: hidden; text-overflow: ellipsis; } +.mode-badge { + display: inline-flex; + margin: 0 0 18px; + padding: 7px 10px; + border-radius: 999px; + background: rgba(213, 245, 95, .08); + border: 1px solid rgba(213, 245, 95, .24); + color: var(--lime); + font-size: 11px; + font-weight: 700; +} diff --git a/wallet/wallet-core.js b/wallet/wallet-core.js @@ -1,9 +1,55 @@ const encoder = new TextEncoder(); export const API_BASE = "https://iuna.jhx.app/v1"; -export const STORAGE_KEY = "iuna.wallet.v1"; +export const STORAGE_KEY = "iuna.wallets.v2"; +export const LEGACY_STORAGE_KEY = "iuna.wallet.v1"; export const MICRO_IUNA = 1_000_000n; +export function emptyWalletStore() { + return { version: 2, activeId: null, wallets: [] }; +} + +export function normalizeWalletStore(value, legacyWallet = null) { + if (value?.version === 2 && Array.isArray(value.wallets)) { + const wallets = value.wallets.filter((wallet) => wallet + && wallet.id + && /^[0-9a-f]{64}$/.test(wallet.publicKeyHex) + && ["signing", "readonly"].includes(wallet.type) + && (wallet.type === "readonly" || wallet.record)); + const activeId = wallets.some((wallet) => wallet.id === value.activeId) ? value.activeId : wallets[0]?.id || null; + return { version: 2, activeId, wallets }; + } + if (legacyWallet?.address) { + return { + version: 2, + activeId: `wallet-${legacyWallet.address}`, + wallets: [{ + id: `wallet-${legacyWallet.address}`, + name: "My wallet", + type: "signing", + record: legacyWallet, + publicKeyHex: legacyWallet.address, + }], + }; + } + return emptyWalletStore(); +} + +export function walletId(publicKeyHex) { + return `wallet-${publicKeyHex}`; +} + +export function upsertWallet(store, wallet) { + const wallets = store.wallets.filter((item) => item.id !== wallet.id); + wallets.push(wallet); + return { version: 2, activeId: wallet.id, wallets }; +} + +export function removeWallet(store, id) { + const wallets = store.wallets.filter((wallet) => wallet.id !== id); + return { version: 2, activeId: wallets[0]?.id || null, wallets }; +} + export function bytesToHex(bytes) { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } @@ -244,6 +290,7 @@ export function selectInputs(utxos, amount, feeRate, owner, recipient) { } export async function buildSignedTransfer({ wallet, status, utxos, recipientAddress, amount }) { + if (!wallet?.privateKey) throw new Error("This watch-only wallet cannot sign transactions"); const expectedHrp = status.chain_id.includes("testnet") || status.chain_id.includes("e2e") ? "tiuna" : "iuna"; const recipient = decodeAddress(recipientAddress, expectedHrp); const feeRate = BigInt(status.default_fee_per_byte || 1);