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:
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);