commit 79541ed5e00f8036857d0f92907abccf4feb988b
parent ceef8976f09f19f7b3ba18e4d81ff94f8150cb0f
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Tue, 8 Sep 2026 00:41:37 +0200
feat: add lightweight browser wallet
Diffstat:
12 files changed, 802 insertions(+), 0 deletions(-)
diff --git a/.dockerignore b/.dockerignore
@@ -17,6 +17,7 @@
!.git/**
!www/**
!downloads/**
+!wallet/**
!scripts/inject_cloudflare_analytics.py
!scripts/postprocess_stagit_site.py
!src-tauri/icons/32x32.png
diff --git a/Dockerfile b/Dockerfile
@@ -60,6 +60,7 @@ RUN set -eux; \
python3 /src/iuna-work/scripts/postprocess_stagit_site.py /site/git
COPY www /site
+COPY wallet /site/wallet
COPY downloads /site/downloads
RUN set -eux; \
cp /src/iuna-work/src-tauri/icons/icon.svg /site/favicon.svg; \
diff --git a/README.md b/README.md
@@ -275,6 +275,12 @@ configuration, or authentication routes. JSON request bodies are capped at 64
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 MVP lives in `wallet/` and is copied to
+`/wallet/` in the public site image. It keeps the seed encrypted in browser
+`localStorage`, signs transfers locally, and talks only to the public v1 wallet
+endpoint. A separate host such as `wallet.getiuna.org` can point its document
+root at that same `/wallet/` directory.
+
## Optional: Stratum Mining
iuna can expose a Stratum V1 endpoint for SHA-256 ASIC miners such as a Bitaxe:
diff --git a/wallet/app.js b/wallet/app.js
@@ -0,0 +1,242 @@
+import {
+ API_BASE, STORAGE_KEY, api, buildSignedTransfer, decryptWallet, encodeAddress,
+ encryptWallet, formatIuna, parseIuna, walletFromSeed,
+} 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 icon = (name) => {
+ const paths = {
+ send: '<path d="M6 18 18 6M6 6h12v12"/>',
+ receive: '<path d="M18 6 6 18M6 6v12h12"/>',
+ home: '<rect x="3" y="5" width="18" height="15" rx="3"/><path d="M3 9h18M16 14h2"/>',
+ activity: '<path d="M4 6h16M4 12h16M4 18h10"/>',
+ settings: '<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="3"/><circle cx="15" cy="17" r="3"/>',
+ lock: '<rect x="5" y="10" width="14" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v3"/>',
+ copy: '<rect x="8" y="8" width="12" height="13" rx="2"/><path d="M15 8V3H3v13h5"/>',
+ refresh: '<path d="M20 7v5h-5M4 17v-5h5M6 6a8 8 0 0 1 14 6M18 18a8 8 0 0 1-14-6"/>',
+ };
+ return '<svg class="ui-icon" viewBox="0 0 24 24" aria-hidden="true">' + (paths[name] || paths.activity) + '</svg>';
+};
+
+function escapeHtml(value) {
+ return String(value ?? "").replace(/[&<>'"]/g, (character) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[character]);
+}
+
+function toast(message, error = false) {
+ toastElement.textContent = message;
+ toastElement.className = `toast show${error ? " error" : ""}`;
+ window.clearTimeout(toastElement.timeout);
+ toastElement.timeout = window.setTimeout(() => { toastElement.className = "toast"; }, 3000);
+}
+
+function storedWallet() {
+ try { return JSON.parse(localStorage.getItem(STORAGE_KEY)); } catch { return null; }
+}
+
+function logo() {
+ 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>';
+}
+
+function renderWelcome() {
+ app.innerHTML = `<section class="onboarding"><div class="hero">${logo()}<h1>Jouw iuna.<br><em>In jouw handen.</em></h1><p>Een lichte wallet die rechtstreeks met het iuna-netwerk praat. Je seed blijft versleuteld op dit apparaat.</p></div><div class="onboarding-actions"><button class="button" data-action="create">Nieuwe wallet maken</button><button class="button secondary" data-action="import">Bestaande seed gebruiken</button><div class="security-note"><span>◇</span><span>Self-custody betekent dat alleen jij je seed kunt herstellen. iuna kan hem niet terughalen.</span></div></div></section>`;
+}
+
+function renderLock() {
+ const record = storedWallet();
+ app.innerHTML = `<section class="onboarding"><div class="lock-card">${logo()}<h1>Welkom terug.</h1><p class="view-copy">Ontgrendel je wallet op dit apparaat.</p><form id="unlock-form"><div class="field"><label for="password">Wachtwoord</label><input id="password" type="password" autocomplete="current-password" autofocus required></div><button class="button" type="submit">Ontgrendelen</button></form><button class="button ghost" data-action="forget" style="width:100%;margin-top:10px">Andere wallet gebruiken</button><p class="security-note">Wallet ${escapeHtml(record?.address?.slice(0, 8))}… is lokaal versleuteld.</p></div></section>`;
+}
+
+function renderImport() {
+ app.innerHTML = `<section class="onboarding"><div><button class="back" data-action="welcome">← Terug</button><p class="eyebrow">Wallet herstellen</p><h1 class="view-title">Bestaande seed</h1><p class="view-copy">Vul de 24 woorden in dezelfde volgorde in. Ze worden alleen lokaal verwerkt.</p><form id="import-form"><div class="field"><label for="seed">Seed phrase</label><textarea id="seed" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="woord 1 woord 2 woord 3 …" required></textarea></div><div class="field"><label for="new-password">Nieuw wallet-wachtwoord</label><input id="new-password" type="password" minlength="10" autocomplete="new-password" placeholder="Minimaal 10 tekens" required></div><button class="button" type="submit" style="width:100%">Wallet herstellen</button></form></div><div class="security-note" style="margin-top:auto"><span>◇</span><span>Je seed verlaat deze browser niet.</span></div></section>`;
+}
+
+async function renderNewSeed() {
+ app.innerHTML = `<section class="center-card"><div class="spinner"></div><p>Veilige seed maken…</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">← Terug</button><p class="eyebrow">Stap 1 van 2</p><h1 class="view-title">Bewaar je seed.</h1><p class="view-copy">Schrijf deze 24 woorden op papier, in exact deze volgorde.</p><div class="seed-grid">${words}</div><div class="warning">Maak geen screenshot. Wie deze woorden heeft, heeft toegang tot je wallet.</div><button class="button secondary" data-action="copy-seed" style="width:100%">Seed kopiëren</button><button class="button" data-action="seed-saved" style="width:100%;margin-top:10px">Ik heb de woorden bewaard</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">← Terug</button><p class="eyebrow">Stap 2 van 2</p><h1 class="view-title">Beveilig dit apparaat.</h1><p class="view-copy">Dit wachtwoord versleutelt je seed voordat hij in de browser wordt opgeslagen.</p><form id="create-form"><div class="field"><label for="create-password">Wachtwoord</label><input id="create-password" type="password" minlength="10" autocomplete="new-password" required></div><div class="field"><label for="confirm-password">Herhaal wachtwoord</label><input id="confirm-password" type="password" minlength="10" autocomplete="new-password" required></div><button class="button" type="submit" style="width:100%">Wallet openen</button></form></div></section>`;
+ app.dataset.pendingSeed = seed;
+}
+
+async function saveAndOpen(seed, password) {
+ const wallet = await walletFromSeed(seed);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(await encryptWallet(seed, password, wallet.publicKeyHex)));
+ state.wallet = wallet;
+ await openWallet();
+}
+
+async function fetchWalletData() {
+ state.status = await api("/status");
+ state.address = encodeAddress(state.wallet.publicKey, state.status.chain_id);
+ const encoded = encodeURIComponent(state.address);
+ const [balance, utxos, transactions] = await Promise.all([
+ api(`/addresses/${encoded}/balance`),
+ api(`/addresses/${encoded}/utxos`),
+ api(`/addresses/${encoded}/transactions?limit=50`),
+ ]);
+ state.balance = balance;
+ state.utxos = utxos.utxos || [];
+ state.transactions = transactions.items || [];
+}
+
+async function openWallet() {
+ app.innerHTML = `<section class="center-card"><div class="spinner"></div><p>Verbinden met iuna…</p></section>`;
+ try {
+ await fetchWalletData();
+ renderApp();
+ window.clearInterval(state.timer);
+ state.timer = window.setInterval(refreshSilently, 20_000);
+ } catch (error) {
+ renderOffline(error.message);
+ }
+}
+
+function renderOffline(message) {
+ app.innerHTML = `<section class="onboarding"><div class="lock-card">${logo()}<p class="eyebrow">Verbinding mislukt</p><h1 class="view-title">Endpoint niet bereikbaar.</h1><p class="view-copy">${escapeHtml(message)}</p><button class="button" data-action="retry" style="width:100%">Opnieuw proberen</button><button class="button ghost" data-action="lock" style="width:100%;margin-top:10px">Wallet vergrendelen</button><p class="security-note">Endpoint: ${escapeHtml(API_BASE)}</p></div></section>`;
+}
+
+async function refreshSilently() {
+ if (!state.wallet) return;
+ try { await fetchWalletData(); if (["home", "activity"].includes(state.view)) renderApp(); } catch { /* keep the last known state */ }
+}
+
+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" : "Synchroniseren"}</div><button class="icon-button" data-action="lock" aria-label="Vergrendel">${icon("lock")}</button></div></header>`;
+}
+
+function nav() {
+ return `<nav class="bottom-nav">${[["home","Overzicht"],["send","Verstuur"],["receive","Ontvang"],["settings","Instellingen"]].map(([view,label]) => `<button class="nav-item ${state.view === view ? "active" : ""}" data-view="${view}"><span>${icon(view)}</span>${label}</button>`).join("")}</nav>`;
+}
+
+function transactionInfo(item) {
+ const tx = item.transaction || {};
+ const owner = state.wallet.publicKeyHex;
+ if (tx.kind === "mine") return { title: "Mining reward", incoming: true, amount: 1_000_000, symbol: "✦" };
+ if (tx.kind === "burn") return { title: "Burn", incoming: false, amount: tx.amount || 0, symbol: "×" };
+ const sent = tx.inputs?.some((input) => input.owner === owner);
+ const relevant = tx.outputs?.filter((output) => sent ? output.address !== owner : output.address === owner) || [];
+ return { title: sent ? "Verstuurd" : "Ontvangen", incoming: !sent, amount: relevant.reduce((sum, output) => sum + Number(output.amount || 0), 0), symbol: sent ? "↗" : "↙" };
+}
+
+function formatTransactionDate(item) {
+ if (!item.timestamp_ms) return "In behandeling";
+ const timestamp = new Date(Number(item.timestamp_ms));
+ if (Number.isNaN(timestamp.getTime())) return "Onbekende tijd";
+ return timestamp.toLocaleString("nl-NL", {
+ day: "numeric",
+ month: "short",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).replace(",", "");
+}
+
+function activityList(limit) {
+ const items = state.transactions.slice(0, limit);
+ if (!items.length) return '<div class="empty">Nog geen transacties.<br>Je nieuwe wallet is klaar voor gebruik.</div>';
+ return `<div class="activity-list">${items.map((item) => {
+ const info = transactionInfo(item);
+ const date = formatTransactionDate(item);
+ return `<div class="activity"><div class="activity-icon">${info.symbol}</div><div><div class="activity-title">${info.title}</div><div class="activity-meta">${escapeHtml(date)} · ${escapeHtml(item.status)}</div></div><div class="activity-amount ${info.incoming ? "in" : ""}">${info.incoming ? "+" : "−"}${formatIuna(info.amount, 4)} IUNA</div></div>`;
+ }).join("")}</div>`;
+}
+
+function renderHome() {
+ return `${topbar()}<section><p class="eyebrow">Beschikbaar saldo</p><h1 class="balance">${formatIuna(state.balance?.spendable, 6)} <span>IUNA</span></h1><p class="subbalance">${formatIuna(state.balance?.confirmed, 6)} bevestigd · blok ${escapeHtml(state.balance?.height)}</p><div class="actions"><button class="button" data-view="send">${icon("send")} Verstuur</button><button class="button secondary" data-view="receive">${icon("receive")} Ontvang</button></div><div class="section-head"><h2>Recente activiteit</h2><button data-view="activity">Bekijk alles</button></div><div class="panel">${activityList(5)}</div></section>`;
+}
+
+function renderSend() {
+ return `${topbar()}<p class="eyebrow">Transactie</p><h1 class="view-title">Verstuur IUNA</h1><p class="view-copy">De transactie wordt op dit apparaat ondertekend.</p><form id="send-form" class="panel send-card"><div class="field"><label for="recipient">Ontvanger</label><input id="recipient" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="iuna1q…" required></div><div class="field"><label for="amount">Bedrag</label><div class="amount-wrap"><input id="amount" inputmode="decimal" placeholder="0,00" required><span>IUNA</span></div></div><div class="fee-line"><span>Beschikbaar</span><strong>${formatIuna(state.balance?.spendable)} IUNA</strong></div><div class="fee-line"><span>Tarief</span><strong>${escapeHtml(state.status.default_fee_per_byte)} µIUNA / byte</strong></div><button class="button" type="submit">Controleer transactie</button></form>`;
+}
+
+function renderReceive() {
+ return `${topbar()}<p class="eyebrow">Jouw adres</p><h1 class="view-title">Ontvang IUNA</h1><p class="view-copy">Deel dit mainnet-adres met de afzender.</p><div class="panel"><div class="receive-emblem">${icon("receive")}</div><p class="eyebrow">Ontvangstadres</p><div class="address-box"><code>${escapeHtml(state.address)}</code><button class="icon-button" data-action="copy-address" aria-label="Kopieer adres">${icon("copy")}</button></div><p class="security-note">Controleer altijd de eerste en laatste tekens wanneer je een adres deelt.</p></div>`;
+}
+
+function renderActivity() {
+ return `${topbar()}<p class="eyebrow">Wallet</p><h1 class="view-title">Activiteit</h1><p class="view-copy">Bevestigde en nog lopende transacties.</p><div class="panel">${activityList(50)}</div>`;
+}
+
+function renderSettings() {
+ return `${topbar()}<p class="eyebrow">Wallet</p><h1 class="view-title">Instellingen</h1><div class="panel"><div class="setting"><h3>Netwerk</h3><p>${escapeHtml(state.status.network_id)} · blok ${escapeHtml(state.status.height)}</p></div><div class="setting"><h3>Public endpoint</h3><p>${escapeHtml(API_BASE)}</p></div><div class="setting"><h3>Lokale beveiliging</h3><p>AES-256-GCM · PBKDF2-SHA256 · 310.000 iteraties</p></div><div class="setting"><h3>Wallet-adres</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">Wallet vergrendelen</button><button class="button danger" data-action="forget" style="width:100%;margin-top:10px">Wallet van dit apparaat verwijderen</button><p class="security-note">iuna is experimentele software. Gebruik alleen bedragen die je kunt missen.</p>`;
+}
+
+function renderApp() {
+ const renderers = { home: renderHome, send: renderSend, receive: renderReceive, activity: renderActivity, settings: renderSettings };
+ app.innerHTML = `${(renderers[state.view] || renderHome)()}${nav()}`;
+}
+
+function renderConfirmation(transaction, fee, recipientAddress, amount) {
+ app.innerHTML = `${topbar()}<button class="back" data-view="send">← Wijzigen</button><p class="eyebrow">Controle</p><h1 class="view-title">Klopt alles?</h1><div class="panel"><div class="detail-line"><span>Je verstuurt</span><strong>${formatIuna(amount)} IUNA</strong></div><div class="detail-line"><span>Naar</span><strong>${escapeHtml(recipientAddress.slice(0, 12))}…${escapeHtml(recipientAddress.slice(-8))}</strong></div><div class="detail-line"><span>Netwerkkosten</span><strong>${formatIuna(fee)} IUNA</strong></div><div class="detail-line"><span>Totaal</span><strong>${formatIuna(amount + fee)} IUNA</strong></div></div><button class="button" id="confirm-send" style="width:100%;margin-top:14px">Onderteken & verstuur</button><p class="security-note">Deze actie kan na verzending niet worden teruggedraaid.</p>${nav()}`;
+ document.querySelector("#confirm-send").addEventListener("click", async (event) => {
+ const button = event.currentTarget;
+ button.disabled = true; button.innerHTML = '<span class="spinner"></span> Verzenden…';
+ try {
+ const result = await api("/transactions", { method: "POST", body: JSON.stringify(transaction) });
+ toast(result.status === "accepted" ? "Transactie verstuurd" : "Transactie was al bekend");
+ state.view = "home";
+ await fetchWalletData();
+ renderApp();
+ } catch (error) { toast(error.message, true); button.disabled = false; button.textContent = "Opnieuw proberen"; }
+ });
+}
+
+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 (action === "welcome") renderWelcome();
+ if (action === "create") await renderNewSeed();
+ if (action === "import") renderImport();
+ if (action === "seed-saved") renderPasswordSetup();
+ if (action === "copy-seed") { await navigator.clipboard.writeText(app.dataset.pendingSeed); toast("Seed gekopieerd — wis je klembord na gebruik"); }
+ if (action === "copy-address") { await navigator.clipboard.writeText(state.address); toast("Adres gekopieerd"); }
+ if (action === "retry") await openWallet();
+ if (action === "lock") { window.clearInterval(state.timer); state.wallet = null; state.view = "home"; renderLock(); }
+ if (action === "forget") {
+ if (window.confirm("Weet je zeker dat je de lokale wallet wilt verwijderen? Zorg dat je de seed hebt bewaard.")) {
+ localStorage.removeItem(STORAGE_KEY); window.clearInterval(state.timer); state.wallet = null; renderWelcome();
+ }
+ }
+});
+
+app.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ const form = event.target;
+ const button = form.querySelector('[type="submit"]');
+ button.disabled = true;
+ const original = button.textContent;
+ button.innerHTML = '<span class="spinner"></span> Even geduld…';
+ try {
+ if (form.id === "unlock-form") {
+ state.wallet = await decryptWallet(storedWallet(), form.password.value);
+ await openWallet();
+ } else if (form.id === "import-form") {
+ const seed = await validateMnemonic(form.seed.value);
+ await saveAndOpen(seed, form["new-password"].value);
+ } else if (form.id === "create-form") {
+ if (form["create-password"].value !== form["confirm-password"].value) throw new Error("De wachtwoorden komen niet overeen");
+ await saveAndOpen(app.dataset.pendingSeed, form["create-password"].value);
+ } else if (form.id === "send-form") {
+ 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);
+ }
+ } catch (error) {
+ toast(error.message, true); button.disabled = false; button.textContent = original;
+ }
+});
+
+if (!window.isSecureContext || !crypto?.subtle) {
+ app.innerHTML = '<section class="center-card"><div><h1>Veilige verbinding vereist</h1><p>Open deze wallet via HTTPS of localhost.</p></div></section>';
+} else if (storedWallet()) renderLock(); else renderWelcome();
diff --git a/wallet/icon.svg b/wallet/icon.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
+ <rect x="64" y="64" width="896" height="896" rx="224" fill="#101315"/>
+ <path fill="#d5f55f" d="M301 349c-37 0-67 30-67 67v227c0 156 122 272 278 272s278-116 278-272V416c0-37-30-67-67-67s-67 30-67 67v227c0 81-63 138-144 138s-144-57-144-138V416c0-37-30-67-67-67Z"/>
+ <circle cx="301" cy="243" r="90" fill="#d5f55f"/>
+</svg>
diff --git a/wallet/index.html b/wallet/index.html
@@ -0,0 +1,22 @@
+<!doctype html>
+<html lang="nl">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+ <meta name="theme-color" content="#0f1012">
+ <meta name="description" content="Een lichte, self-custody wallet voor iuna.">
+ <title>iuna wallet</title>
+ <link rel="manifest" href="./manifest.webmanifest">
+ <link rel="icon" href="./icon.svg" type="image/svg+xml">
+ <link rel="stylesheet" href="./styles.css">
+ <link rel="stylesheet" href="./node-theme.css">
+</head>
+<body>
+ <div class="ambient ambient-one"></div><div class="ambient ambient-two"></div>
+ <main id="app" class="shell" aria-live="polite">
+ <section id="loading" class="center-card"><img src="./icon.svg" width="48" height="48" alt="iuna"><p>Wallet laden…</p></section>
+ </main>
+ <div id="toast" class="toast" role="status"></div>
+ <script type="module" src="./app.js"></script>
+</body>
+</html>
diff --git a/wallet/manifest.webmanifest b/wallet/manifest.webmanifest
@@ -0,0 +1,10 @@
+{
+ "name": "iuna wallet",
+ "short_name": "iuna",
+ "start_url": "./",
+ "display": "standalone",
+ "background_color": "#0d1512",
+ "theme_color": "#0d1512",
+ "description": "Een lichte, self-custody wallet voor iuna.",
+ "icons": [{ "src": "./icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }]
+}
diff --git a/wallet/mnemonic.js b/wallet/mnemonic.js
@@ -0,0 +1,24 @@
+import { WORDS } from "./words.js";
+import { normalizeSeed } from "./wallet-core.js";
+
+export async function generateMnemonic() {
+ const entropy = crypto.getRandomValues(new Uint8Array(32));
+ const checksum = new Uint8Array(await crypto.subtle.digest("SHA-256", entropy));
+ const bits = [...entropy].map((byte) => byte.toString(2).padStart(8, "0")).join("")
+ + checksum[0].toString(2).padStart(8, "0");
+ return Array.from({ length: 24 }, (_, index) => WORDS[Number.parseInt(bits.slice(index * 11, index * 11 + 11), 2)]).join(" ");
+}
+
+export async function validateMnemonic(value) {
+ const words = normalizeSeed(value).split(" ");
+ if (words.length !== 24) throw new Error("Een seed phrase moet precies 24 woorden bevatten");
+ const indexes = words.map((word) => WORDS.indexOf(word));
+ if (indexes.some((index) => index < 0)) throw new Error("De seed phrase bevat een onbekend BIP-39 woord");
+ const bits = indexes.map((index) => index.toString(2).padStart(11, "0")).join("");
+ const entropyBits = bits.slice(0, 256);
+ const checksumBits = bits.slice(256);
+ const entropy = Uint8Array.from(entropyBits.match(/.{8}/g), (byte) => Number.parseInt(byte, 2));
+ const checksum = new Uint8Array(await crypto.subtle.digest("SHA-256", entropy));
+ if (checksum[0].toString(2).padStart(8, "0") !== checksumBits) throw new Error("De checksum van de seed phrase klopt niet");
+ return normalizeSeed(value);
+}
diff --git a/wallet/node-theme.css b/wallet/node-theme.css
@@ -0,0 +1,47 @@
+/* Node palette; responsive wallet layout. */
+:root { --bg:#0f1012; --panel:#181b1f; --line:#2a3035; --ink:#e8edf0; --muted:#99a3aa; --lime:#d5f55f; }
+body { background:var(--bg); }.ambient { display:none; }
+.shell { padding:24px 22px 116px; }
+.brand { font:800 18px/1.2 system-ui,sans-serif; letter-spacing:-.03em; gap:9px; white-space:nowrap; }
+.brand-mark { flex:none; width:36px; height:36px; margin:0; border-radius:9px; background:#22291a; color:var(--lime); }
+.brand-mark svg { width:28px; height:28px; display:block; }
+.mark-loop { fill:none; stroke:currentColor; stroke-width:4.2; stroke-linecap:round; stroke-linejoin:round; }.mark-dot { fill:currentColor; }
+.topbar { gap:10px; padding-bottom:22px; margin-bottom:30px; border-bottom:1px solid var(--line); }
+.network { font-size:11px; font-weight:650; }.dot.live { background:var(--lime); box-shadow:0 0 0 3px #d5f55f12; }
+.icon-button { flex:none; border-radius:8px; width:40px; height:40px; background:transparent; border-color:var(--line); color:var(--muted); }
+.ui-icon { width:20px; height:20px; display:block; flex:none; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linecap:round; stroke-linejoin:round; }
+.balance,.hero h1,.lock-card h1,.view-title,.section-head h2 { font-family:inherit; }
+.balance { font-size:clamp(30px,8.5vw,44px); font-weight:750; letter-spacing:-.05em; overflow-wrap:anywhere; font-variant-numeric:tabular-nums; }.balance span { font-size:15px; font-weight:650; letter-spacing:0; }
+.eyebrow { font-size:10px; font-weight:750; letter-spacing:.13em; }.subbalance { font-size:12px; line-height:1.7; }
+.view-title { font-size:28px; line-height:1.2; font-weight:750; letter-spacing:-.035em; margin:10px 0; }.view-copy { font-size:13px; line-height:1.7; margin-bottom:24px; }
+.button { min-height:48px; border:1px solid var(--lime); border-radius:9px; font-family:inherit; font-size:13px; font-weight:750; }
+.button:hover { background:#e4ff83; }.button.secondary { background:#202328; border-color:#3a4248; }.button.ghost { background:transparent; border-color:var(--line); }
+.button.secondary:hover,.button.ghost:hover,.icon-button:hover { color:var(--lime); border-color:var(--lime); background:#202328; }
+.button.danger { background:#231718; border-color:#62312d; color:#ffb1a8; }
+button:focus-visible { outline:2px solid var(--lime); outline-offset:3px; }
+.panel { padding:18px; border-radius:12px; background:var(--panel); box-shadow:none; backdrop-filter:none; }
+.section-head h2 { font-size:15px; font-weight:700; }.section-head button { font-size:12px; padding:8px 0 8px 12px; }
+.activity { grid-template-columns:36px minmax(0,1fr) auto; gap:10px; padding:15px 0; border-color:var(--line); }
+.activity-icon { width:34px; height:34px; border-radius:9px; background:#22291a; color:var(--lime); }
+.activity-title { font-size:12px; }.activity-meta { font-size:10px; line-height:1.6; }.activity-amount { font-size:12px; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; max-width:135px; }.activity-amount.in { color:var(--lime); }
+.empty { padding:30px 8px; font-size:13px; line-height:1.9; }
+.bottom-nav { border-radius:13px; padding:6px; background:#15171af5; border-color:#30363c; box-shadow:0 8px 30px #0005; }
+.nav-item { min-height:52px; border-radius:8px; font-size:10px; font-weight:650; gap:6px; }.nav-item.active { background:#252b20; color:var(--lime); }
+.field { margin-bottom:20px; gap:9px; }.field label { color:#b4bec4; font-size:12px; font-weight:600; }
+input,textarea { font-family:inherit; font-size:16px; border-radius:8px; padding:14px; background:#101215; border-color:#3a4248; color:var(--ink); }
+input::placeholder,textarea::placeholder { color:#68737c; }input:focus,textarea:focus { border-color:var(--lime); box-shadow:0 0 0 3px #d5f55f12; }
+textarea { line-height:1.8; }.amount-wrap input { font-family:inherit; font-size:28px; font-weight:650; }.amount-wrap span { font-size:12px; top:22px; }
+.fee-line,.detail-line { font-size:12px; line-height:1.6; padding:12px 0; }.detail-line + .detail-line { border-top:1px solid var(--line); }.detail-line strong { overflow-wrap:anywhere; min-width:0; }
+.address-box { align-items:flex-start; border-radius:9px; padding:16px; background:#101215; border-color:var(--line); }.address-box code { white-space:normal; overflow:visible; overflow-wrap:anywhere; line-height:1.9; color:var(--ink); }
+.receive-emblem { display:grid; place-items:center; margin:12px auto 28px; width:88px; height:88px; border:1px solid #d5f55f30; border-radius:24px; background:#22291a; color:var(--lime); }.receive-emblem .ui-icon { width:36px; height:36px; }
+.setting { padding:18px 0; }.setting:first-child { padding-top:0; }.setting:last-child { padding-bottom:0; }.setting h3 { font-size:12px; }.setting p { font-size:12px; line-height:1.8; overflow-wrap:anywhere; }
+.onboarding { min-height:calc(100dvh - 160px); }.onboarding .hero { padding:7vh 0 40px; }.hero h1 { font-size:clamp(36px,10vw,48px); line-height:1.12; font-weight:750; letter-spacing:-.055em; margin:38px 0 20px; }.hero p { font-size:14px; line-height:1.8; }.onboarding-actions { padding-top:28px; }
+.lock-card .brand { margin-bottom:40px; }.lock-card .brand-mark { margin:0; }.lock-card h1 { font-size:30px; line-height:1.25; letter-spacing:-.04em; }
+.security-note { padding:0; margin:20px 0 0; font-size:11px; line-height:1.8; overflow-wrap:anywhere; }.back { display:inline-flex; align-items:center; min-height:44px; font-size:12px; margin:0 0 18px; }
+.seed-grid { grid-template-columns:repeat(3,minmax(0,1fr)); gap:7px; }.seed-word { background:#101215; border-color:var(--line); padding:11px 6px; font-size:11px; border-radius:7px; }.seed-word::before { width:18px; font-size:9px; }
+.warning { border-radius:9px; background:#292315; border-color:#594926; color:#e6cb8e; line-height:1.8; }
+.toast { border-radius:9px; background:var(--ink); color:var(--bg); line-height:1.6; }.toast.error { background:#ffb1a8; }
+.spinner { border-color:#d5f55f30; border-top-color:var(--lime); margin:auto; }.button .spinner { border-color:#15171a30; border-top-color:#15171a; margin:0; }
+@media(min-width:700px) { body { padding:32px; background:#111316; }.shell { min-height:calc(100dvh - 64px); border-radius:18px; border-color:var(--line); background:var(--bg); padding:28px 26px 116px; }.onboarding { min-height:calc(100dvh - 208px); } }
+@media(max-width:360px) { .shell { padding-inline:14px; }.brand { font-size:16px; }.network { font-size:10px; }.activity { grid-template-columns:28px minmax(0,1fr) auto; gap:7px; }.activity-amount { font-size:11px; max-width:100px; }.activity-icon { width:28px; height:28px; }.panel { padding:13px; } }
+@media(prefers-reduced-motion:reduce) { *,*::before,*::after { animation:none!important; transition:none!important; } }
diff --git a/wallet/styles.css b/wallet/styles.css
@@ -0,0 +1,2 @@
+:root{color-scheme:dark;--bg:#0f1012;--panel:#181b1f;--panel-2:#202328;--line:#2a3035;--ink:#e8edf0;--muted:#8d989f;--lime:#d5f55f;--lime-ink:#15171a;--danger:#ff8f82;--shadow:0 18px 42px rgba(0,0,0,.22);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
+*{box-sizing:border-box}body{margin:0;min-height:100vh;background:var(--bg);color:var(--ink);overflow-x:hidden}.ambient{position:fixed;border-radius:999px;filter:blur(110px);opacity:.11;pointer-events:none}.ambient-one{width:420px;height:420px;background:var(--lime);top:-190px;right:-170px}.ambient-two{width:300px;height:300px;background:#55e2ac;bottom:-170px;left:-160px}.shell{width:min(100%,480px);min-height:100vh;margin:0 auto;padding:24px 18px 112px;position:relative}.center-card{min-height:calc(100vh - 48px);display:grid;place-content:center;text-align:center;color:var(--muted)}.brand-mark{display:grid;place-items:center;width:42px;height:42px;margin:auto;border-radius:14px;background:var(--lime);color:var(--lime-ink);font:800 27px/1 Georgia,serif}.topbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:30px}.brand{display:flex;align-items:center;gap:11px;font:700 20px/1 Georgia,serif;letter-spacing:-.02em}.network{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.dot{width:7px;height:7px;border-radius:50%;background:#ffb45e;box-shadow:0 0 0 4px rgba(255,180,94,.1)}.dot.live{background:#77e8a3}.icon-button{appearance:none;border:1px solid var(--line);background:var(--panel);color:var(--ink);width:42px;height:42px;border-radius:14px;display:grid;place-items:center;cursor:pointer}.eyebrow{margin:0 0 10px;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.14em}.balance{font:600 clamp(40px,12vw,58px)/1 Georgia,serif;letter-spacing:-.055em;margin:0}.balance span{color:var(--lime);font-size:.38em;letter-spacing:.02em;margin-left:4px}.subbalance{font-size:13px;color:var(--muted);margin-top:10px}.actions{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:28px 0}.button{appearance:none;border:0;border-radius:16px;min-height:52px;padding:0 18px;font:700 14px/1 inherit;cursor:pointer;background:var(--lime);color:var(--lime-ink);display:flex;align-items:center;justify-content:center;gap:9px}.button.secondary{background:var(--panel-2);color:var(--ink);border:1px solid var(--line)}.button.ghost{background:transparent;color:var(--muted);border:1px solid var(--line)}.button.danger{background:rgba(255,142,128,.12);color:var(--danger);border:1px solid rgba(255,142,128,.28)}.button:disabled{opacity:.5;cursor:not-allowed}.section-head{display:flex;justify-content:space-between;align-items:center;margin:30px 0 12px}.section-head h2{font:600 19px/1.2 Georgia,serif;margin:0}.section-head button{border:0;background:transparent;color:var(--lime);cursor:pointer}.panel{border:1px solid var(--line);background:rgba(20,31,26,.82);border-radius:21px;padding:16px;box-shadow:var(--shadow);backdrop-filter:blur(18px)}.activity-list{display:grid;gap:2px}.activity{display:grid;grid-template-columns:42px 1fr auto;align-items:center;gap:11px;padding:12px 4px;border-bottom:1px solid rgba(42,58,49,.7)}.activity:last-child{border-bottom:0}.activity-icon{display:grid;place-items:center;width:39px;height:39px;border-radius:13px;background:var(--panel-2);color:var(--lime)}.activity-title{font-weight:650;font-size:14px}.activity-meta{font-size:11px;color:var(--muted);margin-top:4px}.activity-amount{font-weight:700;font-size:14px;text-align:right}.activity-amount.in{color:#78e5a2}.empty{text-align:center;padding:35px 16px;color:var(--muted);font-size:14px}.bottom-nav{position:fixed;z-index:5;bottom:max(14px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);width:min(calc(100% - 28px),452px);display:grid;grid-template-columns:repeat(4,1fr);padding:7px;background:rgba(20,31,26,.94);border:1px solid var(--line);border-radius:21px;box-shadow:var(--shadow);backdrop-filter:blur(20px)}.nav-item{border:0;background:transparent;color:var(--muted);padding:8px 4px;border-radius:14px;font-size:10px;display:grid;gap:4px;justify-items:center;cursor:pointer}.nav-item.active{color:var(--lime);background:rgba(216,255,98,.07)}.view-title{font:600 32px/1 Georgia,serif;letter-spacing:-.03em;margin:20px 0 8px}.view-copy{color:var(--muted);line-height:1.55;font-size:14px;margin:0 0 26px}.field{display:grid;gap:8px;margin-bottom:17px}.field label{font-size:12px;color:var(--muted)}input,textarea{width:100%;border:1px solid var(--line);background:#101a16;color:var(--ink);border-radius:15px;padding:15px;font:500 16px/1.35 inherit;outline:0}input:focus,textarea:focus{border-color:rgba(216,255,98,.65);box-shadow:0 0 0 3px rgba(216,255,98,.08)}textarea{min-height:130px;resize:vertical}.amount-wrap{position:relative}.amount-wrap input{font:600 30px/1 Georgia,serif;padding-right:70px}.amount-wrap span{position:absolute;right:15px;top:18px;color:var(--lime);font-weight:700}.fee-line,.detail-line{display:flex;justify-content:space-between;gap:16px;padding:10px 0;color:var(--muted);font-size:13px}.fee-line strong,.detail-line strong{color:var(--ink);font-weight:600;text-align:right}.send-card .button{width:100%;margin-top:8px}.receive-code{background:#f2f4ec;border-radius:20px;width:min(250px,100%);aspect-ratio:1;margin:8px auto 24px;padding:16px;display:grid;place-items:center;color:#132019}.qr-placeholder{width:100%;height:100%;border:12px solid #132019;border-radius:5px;display:grid;place-content:center;text-align:center;font-weight:900;word-break:break-all;padding:12px;font-size:9px}.address-box{display:flex;gap:8px;align-items:center;background:#101a16;border:1px solid var(--line);border-radius:15px;padding:13px}.address-box code{min-width:0;overflow:hidden;text-overflow:ellipsis;font-size:12px;color:var(--muted)}.address-box button{flex:none}.setting{padding:15px 0;border-bottom:1px solid var(--line)}.setting:last-child{border:0}.setting h3{font-size:14px;margin:0 0 5px}.setting p{font-size:12px;color:var(--muted);line-height:1.5;margin:0}.onboarding{min-height:calc(100vh - 48px);display:flex;flex-direction:column}.onboarding .hero{padding:15vh 0 36px}.hero h1{font:600 clamp(47px,15vw,68px)/.94 Georgia,serif;letter-spacing:-.065em;margin:20px 0}.hero h1 em{color:var(--lime);font-style:normal}.hero p{color:var(--muted);font-size:15px;line-height:1.6;max-width:360px}.onboarding-actions{margin-top:auto;display:grid;gap:10px}.security-note{display:flex;gap:10px;color:var(--muted);font-size:11px;line-height:1.5;margin-top:18px;padding:0 5px}.back{border:0;background:transparent;color:var(--muted);padding:0;margin:4px 0 22px;cursor:pointer}.seed-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;counter-reset:seed;margin:18px 0}.seed-word{counter-increment:seed;background:#101a16;border:1px solid var(--line);border-radius:10px;padding:10px;font:600 12px/1 monospace}.seed-word::before{content:counter(seed);color:var(--muted);display:inline-block;width:20px;font-weight:400}.warning{border:1px solid rgba(255,180,94,.25);background:rgba(255,180,94,.07);color:#e8c99e;border-radius:14px;padding:13px;font-size:12px;line-height:1.5;margin:14px 0}.lock-card{margin:auto 0}.lock-card .brand-mark{margin:0 0 30px}.lock-card h1{font:600 38px/1 Georgia,serif;margin:0 0 10px}.lock-card .button{width:100%}.toast{position:fixed;z-index:20;left:50%;bottom:104px;transform:translate(-50%,20px);max-width:calc(100% - 36px);background:#f2f4ec;color:#132019;border-radius:12px;padding:11px 15px;font-size:13px;font-weight:650;opacity:0;pointer-events:none;transition:.2s}.toast.show{opacity:1;transform:translate(-50%,0)}.toast.error{background:var(--danger)}.spinner{width:18px;height:18px;border:2px solid rgba(24,32,10,.28);border-top-color:var(--lime-ink);border-radius:50%;animation:spin .7s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}@media(min-width:700px){body{padding:18px}.shell{min-height:calc(100vh - 36px);border:1px solid rgba(42,58,49,.65);border-radius:30px;background:rgba(13,21,18,.65)}.center-card,.onboarding{min-height:calc(100vh - 84px)}}
diff --git a/wallet/wallet-core.js b/wallet/wallet-core.js
@@ -0,0 +1,311 @@
+const encoder = new TextEncoder();
+
+export const API_BASE = "https://iuna.jhx.app/v1";
+export const STORAGE_KEY = "iuna.wallet.v1";
+export const MICRO_IUNA = 1_000_000n;
+
+export function bytesToHex(bytes) {
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
+}
+
+export function hexToBytes(hex) {
+ if (!/^[0-9a-f]*$/.test(hex) || hex.length % 2) throw new Error("Ongeldige hexwaarde");
+ return Uint8Array.from(hex.match(/.{2}/g) || [], (pair) => Number.parseInt(pair, 16));
+}
+
+function base64url(bytes) {
+ let binary = "";
+ bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
+}
+
+function fromBase64url(value) {
+ const normalized = value.replaceAll("-", "+").replaceAll("_", "/");
+ const binary = atob(normalized + "=".repeat((4 - normalized.length % 4) % 4));
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
+}
+
+export async function walletFromSeed(seedPhrase) {
+ const normalized = normalizeSeed(seedPhrase);
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(`iuna-wallet-seed:${normalized}`)));
+ const pkcs8Prefix = hexToBytes("302e020100300506032b657004220420");
+ const pkcs8 = new Uint8Array(pkcs8Prefix.length + digest.length);
+ pkcs8.set(pkcs8Prefix);
+ pkcs8.set(digest, pkcs8Prefix.length);
+ let privateKey;
+ try {
+ privateKey = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
+ } catch {
+ throw new Error("Deze browser ondersteunt geen veilige Ed25519-sleutels. Gebruik een recente Safari, Chrome, Edge of Firefox.");
+ }
+ const jwk = await crypto.subtle.exportKey("jwk", privateKey);
+ const publicKey = fromBase64url(jwk.x);
+ return { seedPhrase: normalized, privateKey, publicKey, publicKeyHex: bytesToHex(publicKey) };
+}
+
+export function normalizeSeed(seed) {
+ return seed.trim().toLowerCase().split(/\s+/).join(" ");
+}
+
+export async function encryptWallet(seedPhrase, password, publicKeyHex) {
+ if (password.length < 10) throw new Error("Kies een wachtwoord van minimaal 10 tekens");
+ const salt = crypto.getRandomValues(new Uint8Array(16));
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const material = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveKey"]);
+ const key = await crypto.subtle.deriveKey(
+ { name: "PBKDF2", hash: "SHA-256", salt, iterations: 310_000 },
+ material,
+ { name: "AES-GCM", length: 256 },
+ false,
+ ["encrypt"],
+ );
+ const plaintext = encoder.encode(JSON.stringify({ seed: normalizeSeed(seedPhrase) }));
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt(
+ { name: "AES-GCM", iv, additionalData: encoder.encode(publicKeyHex) },
+ key,
+ plaintext,
+ ));
+ return {
+ version: 1,
+ address: publicKeyHex,
+ kdf: "PBKDF2-SHA256",
+ iterations: 310_000,
+ cipher: "AES-256-GCM",
+ salt: base64url(salt),
+ iv: base64url(iv),
+ ciphertext: base64url(ciphertext),
+ };
+}
+
+export async function decryptWallet(record, password) {
+ try {
+ const material = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveKey"]);
+ const key = await crypto.subtle.deriveKey(
+ { name: "PBKDF2", hash: "SHA-256", salt: fromBase64url(record.salt), iterations: record.iterations },
+ material,
+ { name: "AES-GCM", length: 256 },
+ false,
+ ["decrypt"],
+ );
+ const plaintext = await crypto.subtle.decrypt(
+ { name: "AES-GCM", iv: fromBase64url(record.iv), additionalData: encoder.encode(record.address) },
+ key,
+ fromBase64url(record.ciphertext),
+ );
+ const wallet = await walletFromSeed(JSON.parse(new TextDecoder().decode(plaintext)).seed);
+ if (wallet.publicKeyHex !== record.address) throw new Error("Adrescontrole mislukt");
+ return wallet;
+ } catch {
+ throw new Error("Onjuist wachtwoord of beschadigde wallet");
+ }
+}
+
+const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
+const BECH32M = 0x2bc830a3;
+
+function polymod(values) {
+ const generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
+ let checksum = 1;
+ for (const value of values) {
+ const top = checksum >>> 25;
+ checksum = ((checksum & 0x1ffffff) << 5) ^ value;
+ for (let index = 0; index < 5; index += 1) if ((top >>> index) & 1) checksum ^= generators[index];
+ checksum >>>= 0;
+ }
+ return checksum >>> 0;
+}
+
+function hrpExpand(hrp) {
+ return [...hrp].map((c) => c.charCodeAt(0) >>> 5).concat(0, [...hrp].map((c) => c.charCodeAt(0) & 31));
+}
+
+function convertBits(data, from, to, pad) {
+ let acc = 0;
+ let bits = 0;
+ const result = [];
+ const max = (1 << to) - 1;
+ for (const value of data) {
+ if (value >>> from) throw new Error("Ongeldige adresdata");
+ acc = ((acc << from) | value) & ((1 << (from + to - 1)) - 1);
+ bits += from;
+ while (bits >= to) { bits -= to; result.push((acc >>> bits) & max); }
+ }
+ if (pad && bits) result.push((acc << (to - bits)) & max);
+ else if (!pad && (bits >= from || ((acc << (to - bits)) & max))) throw new Error("Ongeldige adrespadding");
+ return result;
+}
+
+export function encodeAddress(publicKey, networkId = "iuna-mainnet-v1") {
+ const hrp = networkId.includes("testnet") || networkId.includes("e2e") ? "tiuna" : "iuna";
+ const data = [0, ...convertBits(publicKey, 8, 5, true)];
+ const values = [...hrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0];
+ const mod = (polymod(values) ^ BECH32M) >>> 0;
+ const checksum = Array.from({ length: 6 }, (_, index) => (mod >>> (5 * (5 - index))) & 31);
+ return `${hrp}1${[...data, ...checksum].map((value) => CHARSET[value]).join("")}`;
+}
+
+export function decodeAddress(address, expectedHrp = "iuna") {
+ const normalized = address.trim().toLowerCase();
+ if (address !== address.toLowerCase() && address !== address.toUpperCase()) throw new Error("Adres gebruikt hoofdletters en kleine letters door elkaar");
+ const separator = normalized.lastIndexOf("1");
+ if (separator < 1 || separator + 7 > normalized.length || normalized.slice(0, separator) !== expectedHrp) throw new Error(`Dit is geen geldig ${expectedHrp}-adres`);
+ const data = [...normalized.slice(separator + 1)].map((char) => {
+ const value = CHARSET.indexOf(char);
+ if (value < 0) throw new Error("Adres bevat een ongeldig teken");
+ return value;
+ });
+ if (polymod([...hrpExpand(expectedHrp), ...data]) !== BECH32M) throw new Error("De adres-checksum klopt niet");
+ const payload = data.slice(0, -6);
+ if (payload.shift() !== 0) throw new Error("Niet-ondersteunde adresversie");
+ const key = Uint8Array.from(convertBits(payload, 5, 8, false));
+ if (key.length !== 32) throw new Error("Ongeldige adressleutel");
+ return bytesToHex(key);
+}
+
+function pushU32(target, value) {
+ const view = new DataView(new ArrayBuffer(4));
+ view.setUint32(0, value, false);
+ target.push(...new Uint8Array(view.buffer));
+}
+
+function pushU64(target, value) {
+ const view = new DataView(new ArrayBuffer(8));
+ view.setBigUint64(0, BigInt(value), false);
+ target.push(...new Uint8Array(view.buffer));
+}
+
+function pushBytes(target, value) {
+ pushU32(target, value.length);
+ target.push(...value);
+}
+
+export function transferSigningBytes(status, inputs, outputs, fee) {
+ const bytes = [...encoder.encode("IUNA-TX"), 0, Number(status.transaction_signing_format_version || 1)];
+ pushBytes(bytes, encoder.encode(status.chain_id));
+ pushBytes(bytes, hexToBytes(status.genesis_hash));
+ bytes.push(1);
+ pushU32(bytes, inputs.length);
+ inputs.forEach((input) => {
+ pushBytes(bytes, hexToBytes(input.outpoint.txid));
+ pushU32(bytes, input.outpoint.index);
+ pushBytes(bytes, hexToBytes(input.owner));
+ });
+ pushU32(bytes, outputs.length);
+ outputs.forEach((output) => {
+ pushBytes(bytes, hexToBytes(output.address));
+ pushU64(bytes, output.amount);
+ });
+ pushU64(bytes, fee);
+ return Uint8Array.from(bytes);
+}
+
+function legacyTransferPayload(inputs, outputs, fee) {
+ const canonicalInputs = inputs.map((input) => `${input.outpoint.txid}:${input.outpoint.index}:${input.owner}`).join("|");
+ const canonicalOutputs = outputs.map((output) => `${output.address}:${output.amount}`).join("|");
+ return `utxo-transfer:${canonicalInputs}:${canonicalOutputs}:${fee}`;
+}
+
+function compactLength(value) {
+ let n = BigInt(value);
+ let length = 1;
+ while (n >= 128n) { n >>= 7n; length += 1; }
+ return length;
+}
+
+function protocolIdSize(hex) { return 1 + hex.length / 2; }
+
+export function estimateTransferSize(inputs, outputs, fee) {
+ return 1 + compactLength(inputs.length)
+ + inputs.reduce((sum, input) => sum + protocolIdSize(input.outpoint.txid) + compactLength(input.outpoint.index) + 33, 0)
+ + compactLength(outputs.length)
+ + outputs.reduce((sum, output) => sum + 33 + compactLength(output.amount), 0)
+ + compactLength(fee) + 65;
+}
+
+export function selectInputs(utxos, amount, feeRate, owner, recipient) {
+ const sorted = [...utxos].sort((a, b) => Number(BigInt(b.output.amount) - BigInt(a.output.amount)));
+ const selected = [];
+ let total = 0n;
+ let fee = 0n;
+ for (const utxo of sorted) {
+ selected.push({ outpoint: utxo.outpoint, owner });
+ total += BigInt(utxo.output.amount);
+ for (let pass = 0; pass < 8; pass += 1) {
+ const provisionalChange = total - amount - fee;
+ const outputs = [{ address: recipient, amount }];
+ if (provisionalChange > 0n) outputs.push({ address: owner, amount: provisionalChange });
+ const nextFee = BigInt(estimateTransferSize(selected, outputs, fee)) * BigInt(feeRate);
+ if (nextFee === fee) break;
+ fee = nextFee;
+ }
+ if (total >= amount + fee) return { inputs: selected, total, fee };
+ }
+ throw new Error("Onvoldoende besteedbaar saldo voor bedrag en netwerkkosten");
+}
+
+export async function buildSignedTransfer({ wallet, status, utxos, recipientAddress, amount }) {
+ 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);
+ const { inputs, total, fee } = selectInputs(utxos, amount, feeRate, wallet.publicKeyHex, recipient);
+ const outputs = [{ address: recipient, amount }];
+ const change = total - amount - fee;
+ if (change > 0n) outputs.push({ address: wallet.publicKeyHex, amount: change });
+ const activation = BigInt(status.transaction_signing_v1_activation_height || 1000);
+ const signingHeight = BigInt(status.height || 0) + 1n;
+ const signingPayload = signingHeight >= activation
+ ? transferSigningBytes(status, inputs, outputs, fee)
+ : encoder.encode(legacyTransferPayload(inputs, outputs, fee));
+ const signature = bytesToHex(new Uint8Array(await crypto.subtle.sign("Ed25519", wallet.privateKey, signingPayload)));
+ return {
+ transaction: {
+ kind: "transfer",
+ inputs: inputs.map((input) => ({ ...input, signature })),
+ outputs: outputs.map((output) => ({ ...output, amount: Number(output.amount) })),
+ fee: Number(fee),
+ signature,
+ },
+ fee,
+ };
+}
+
+export function parseIuna(value) {
+ const normalized = value.trim().replace(",", ".");
+ if (!/^\d+(\.\d{0,6})?$/.test(normalized)) throw new Error("Vul een geldig bedrag in (maximaal 6 decimalen)");
+ const [whole, fraction = ""] = normalized.split(".");
+ return BigInt(whole) * MICRO_IUNA + BigInt(fraction.padEnd(6, "0"));
+}
+
+export function formatIuna(value, maximumFractionDigits = 6) {
+ const amount = BigInt(value || 0);
+ const whole = amount / MICRO_IUNA;
+ const fraction = (amount % MICRO_IUNA).toString().padStart(6, "0").slice(0, maximumFractionDigits).replace(/0+$/, "");
+ return `${whole.toLocaleString("nl-NL")}${fraction ? `,${fraction}` : ""}`;
+}
+
+export async function api(path, options = {}) {
+ const controller = new AbortController();
+ const timeout = window.setTimeout(() => controller.abort(), options.timeoutMs || 8_000);
+ const { timeoutMs: _timeoutMs, signal: callerSignal, ...requestOptions } = options;
+ if (callerSignal) callerSignal.addEventListener("abort", () => controller.abort(), { once: true });
+ let response;
+ try {
+ response = await fetch(`${API_BASE}${path}`, {
+ ...requestOptions,
+ signal: controller.signal,
+ headers: {
+ ...(requestOptions.method && requestOptions.method !== "GET" ? { "content-type": "application/json" } : {}),
+ ...(requestOptions.headers || {}),
+ },
+ });
+ } catch (error) {
+ if (error.name === "AbortError") throw new Error(`Geen antwoord van iuna na ${(_timeoutMs || 8_000) / 1000} seconden`);
+ throw new Error("Kan geen verbinding maken met het iuna-endpoint");
+ } finally {
+ window.clearTimeout(timeout);
+ }
+ let body;
+ try { body = await response.json(); } catch { body = {}; }
+ if (!response.ok) throw new Error(body.error || `Endpoint gaf status ${response.status}`);
+ return body;
+}
diff --git a/wallet/words.js b/wallet/words.js
@@ -0,0 +1,131 @@
+// BIP-39 English word list (bitcoin/bips, MIT).
+export const WORDS = Object.freeze([
+ "abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract", "absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid",
+ "acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual", "adapt", "add", "addict", "address", "adjust", "admit", "adult", "advance",
+ "advice", "aerobic", "affair", "afford", "afraid", "again", "age", "agent", "agree", "ahead", "aim", "air", "airport", "aisle", "alarm", "album",
+ "alcohol", "alert", "alien", "all", "alley", "allow", "almost", "alone", "alpha", "already", "also", "alter", "always", "amateur", "amazing", "among",
+ "amount", "amused", "analyst", "anchor", "ancient", "anger", "angle", "angry", "animal", "ankle", "announce", "annual", "another", "answer", "antenna", "antique",
+ "anxiety", "any", "apart", "apology", "appear", "apple", "approve", "april", "arch", "arctic", "area", "arena", "argue", "arm", "armed", "armor",
+ "army", "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact", "artist", "artwork", "ask", "aspect", "assault", "asset", "assist", "assume",
+ "asthma", "athlete", "atom", "attack", "attend", "attitude", "attract", "auction", "audit", "august", "aunt", "author", "auto", "autumn", "average", "avocado",
+ "avoid", "awake", "aware", "away", "awesome", "awful", "awkward", "axis", "baby", "bachelor", "bacon", "badge", "bag", "balance", "balcony", "ball",
+ "bamboo", "banana", "banner", "bar", "barely", "bargain", "barrel", "base", "basic", "basket", "battle", "beach", "bean", "beauty", "because", "become",
+ "beef", "before", "begin", "behave", "behind", "believe", "below", "belt", "bench", "benefit", "best", "betray", "better", "between", "beyond", "bicycle",
+ "bid", "bike", "bind", "biology", "bird", "birth", "bitter", "black", "blade", "blame", "blanket", "blast", "bleak", "bless", "blind", "blood",
+ "blossom", "blouse", "blue", "blur", "blush", "board", "boat", "body", "boil", "bomb", "bone", "bonus", "book", "boost", "border", "boring",
+ "borrow", "boss", "bottom", "bounce", "box", "boy", "bracket", "brain", "brand", "brass", "brave", "bread", "breeze", "brick", "bridge", "brief",
+ "bright", "bring", "brisk", "broccoli", "broken", "bronze", "broom", "brother", "brown", "brush", "bubble", "buddy", "budget", "buffalo", "build", "bulb",
+ "bulk", "bullet", "bundle", "bunker", "burden", "burger", "burst", "bus", "business", "busy", "butter", "buyer", "buzz", "cabbage", "cabin", "cable",
+ "cactus", "cage", "cake", "call", "calm", "camera", "camp", "can", "canal", "cancel", "candy", "cannon", "canoe", "canvas", "canyon", "capable",
+ "capital", "captain", "car", "carbon", "card", "cargo", "carpet", "carry", "cart", "case", "cash", "casino", "castle", "casual", "cat", "catalog",
+ "catch", "category", "cattle", "caught", "cause", "caution", "cave", "ceiling", "celery", "cement", "census", "century", "cereal", "certain", "chair", "chalk",
+ "champion", "change", "chaos", "chapter", "charge", "chase", "chat", "cheap", "check", "cheese", "chef", "cherry", "chest", "chicken", "chief", "child",
+ "chimney", "choice", "choose", "chronic", "chuckle", "chunk", "churn", "cigar", "cinnamon", "circle", "citizen", "city", "civil", "claim", "clap", "clarify",
+ "claw", "clay", "clean", "clerk", "clever", "click", "client", "cliff", "climb", "clinic", "clip", "clock", "clog", "close", "cloth", "cloud",
+ "clown", "club", "clump", "cluster", "clutch", "coach", "coast", "coconut", "code", "coffee", "coil", "coin", "collect", "color", "column", "combine",
+ "come", "comfort", "comic", "common", "company", "concert", "conduct", "confirm", "congress", "connect", "consider", "control", "convince", "cook", "cool", "copper",
+ "copy", "coral", "core", "corn", "correct", "cost", "cotton", "couch", "country", "couple", "course", "cousin", "cover", "coyote", "crack", "cradle",
+ "craft", "cram", "crane", "crash", "crater", "crawl", "crazy", "cream", "credit", "creek", "crew", "cricket", "crime", "crisp", "critic", "crop",
+ "cross", "crouch", "crowd", "crucial", "cruel", "cruise", "crumble", "crunch", "crush", "cry", "crystal", "cube", "culture", "cup", "cupboard", "curious",
+ "current", "curtain", "curve", "cushion", "custom", "cute", "cycle", "dad", "damage", "damp", "dance", "danger", "daring", "dash", "daughter", "dawn",
+ "day", "deal", "debate", "debris", "decade", "december", "decide", "decline", "decorate", "decrease", "deer", "defense", "define", "defy", "degree", "delay",
+ "deliver", "demand", "demise", "denial", "dentist", "deny", "depart", "depend", "deposit", "depth", "deputy", "derive", "describe", "desert", "design", "desk",
+ "despair", "destroy", "detail", "detect", "develop", "device", "devote", "diagram", "dial", "diamond", "diary", "dice", "diesel", "diet", "differ", "digital",
+ "dignity", "dilemma", "dinner", "dinosaur", "direct", "dirt", "disagree", "discover", "disease", "dish", "dismiss", "disorder", "display", "distance", "divert", "divide",
+ "divorce", "dizzy", "doctor", "document", "dog", "doll", "dolphin", "domain", "donate", "donkey", "donor", "door", "dose", "double", "dove", "draft",
+ "dragon", "drama", "drastic", "draw", "dream", "dress", "drift", "drill", "drink", "drip", "drive", "drop", "drum", "dry", "duck", "dumb",
+ "dune", "during", "dust", "dutch", "duty", "dwarf", "dynamic", "eager", "eagle", "early", "earn", "earth", "easily", "east", "easy", "echo",
+ "ecology", "economy", "edge", "edit", "educate", "effort", "egg", "eight", "either", "elbow", "elder", "electric", "elegant", "element", "elephant", "elevator",
+ "elite", "else", "embark", "embody", "embrace", "emerge", "emotion", "employ", "empower", "empty", "enable", "enact", "end", "endless", "endorse", "enemy",
+ "energy", "enforce", "engage", "engine", "enhance", "enjoy", "enlist", "enough", "enrich", "enroll", "ensure", "enter", "entire", "entry", "envelope", "episode",
+ "equal", "equip", "era", "erase", "erode", "erosion", "error", "erupt", "escape", "essay", "essence", "estate", "eternal", "ethics", "evidence", "evil",
+ "evoke", "evolve", "exact", "example", "excess", "exchange", "excite", "exclude", "excuse", "execute", "exercise", "exhaust", "exhibit", "exile", "exist", "exit",
+ "exotic", "expand", "expect", "expire", "explain", "expose", "express", "extend", "extra", "eye", "eyebrow", "fabric", "face", "faculty", "fade", "faint",
+ "faith", "fall", "false", "fame", "family", "famous", "fan", "fancy", "fantasy", "farm", "fashion", "fat", "fatal", "father", "fatigue", "fault",
+ "favorite", "feature", "february", "federal", "fee", "feed", "feel", "female", "fence", "festival", "fetch", "fever", "few", "fiber", "fiction", "field",
+ "figure", "file", "film", "filter", "final", "find", "fine", "finger", "finish", "fire", "firm", "first", "fiscal", "fish", "fit", "fitness",
+ "fix", "flag", "flame", "flash", "flat", "flavor", "flee", "flight", "flip", "float", "flock", "floor", "flower", "fluid", "flush", "fly",
+ "foam", "focus", "fog", "foil", "fold", "follow", "food", "foot", "force", "forest", "forget", "fork", "fortune", "forum", "forward", "fossil",
+ "foster", "found", "fox", "fragile", "frame", "frequent", "fresh", "friend", "fringe", "frog", "front", "frost", "frown", "frozen", "fruit", "fuel",
+ "fun", "funny", "furnace", "fury", "future", "gadget", "gain", "galaxy", "gallery", "game", "gap", "garage", "garbage", "garden", "garlic", "garment",
+ "gas", "gasp", "gate", "gather", "gauge", "gaze", "general", "genius", "genre", "gentle", "genuine", "gesture", "ghost", "giant", "gift", "giggle",
+ "ginger", "giraffe", "girl", "give", "glad", "glance", "glare", "glass", "glide", "glimpse", "globe", "gloom", "glory", "glove", "glow", "glue",
+ "goat", "goddess", "gold", "good", "goose", "gorilla", "gospel", "gossip", "govern", "gown", "grab", "grace", "grain", "grant", "grape", "grass",
+ "gravity", "great", "green", "grid", "grief", "grit", "grocery", "group", "grow", "grunt", "guard", "guess", "guide", "guilt", "guitar", "gun",
+ "gym", "habit", "hair", "half", "hammer", "hamster", "hand", "happy", "harbor", "hard", "harsh", "harvest", "hat", "have", "hawk", "hazard",
+ "head", "health", "heart", "heavy", "hedgehog", "height", "hello", "helmet", "help", "hen", "hero", "hidden", "high", "hill", "hint", "hip",
+ "hire", "history", "hobby", "hockey", "hold", "hole", "holiday", "hollow", "home", "honey", "hood", "hope", "horn", "horror", "horse", "hospital",
+ "host", "hotel", "hour", "hover", "hub", "huge", "human", "humble", "humor", "hundred", "hungry", "hunt", "hurdle", "hurry", "hurt", "husband",
+ "hybrid", "ice", "icon", "idea", "identify", "idle", "ignore", "ill", "illegal", "illness", "image", "imitate", "immense", "immune", "impact", "impose",
+ "improve", "impulse", "inch", "include", "income", "increase", "index", "indicate", "indoor", "industry", "infant", "inflict", "inform", "inhale", "inherit", "initial",
+ "inject", "injury", "inmate", "inner", "innocent", "input", "inquiry", "insane", "insect", "inside", "inspire", "install", "intact", "interest", "into", "invest",
+ "invite", "involve", "iron", "island", "isolate", "issue", "item", "ivory", "jacket", "jaguar", "jar", "jazz", "jealous", "jeans", "jelly", "jewel",
+ "job", "join", "joke", "journey", "joy", "judge", "juice", "jump", "jungle", "junior", "junk", "just", "kangaroo", "keen", "keep", "ketchup",
+ "key", "kick", "kid", "kidney", "kind", "kingdom", "kiss", "kit", "kitchen", "kite", "kitten", "kiwi", "knee", "knife", "knock", "know",
+ "lab", "label", "labor", "ladder", "lady", "lake", "lamp", "language", "laptop", "large", "later", "latin", "laugh", "laundry", "lava", "law",
+ "lawn", "lawsuit", "layer", "lazy", "leader", "leaf", "learn", "leave", "lecture", "left", "leg", "legal", "legend", "leisure", "lemon", "lend",
+ "length", "lens", "leopard", "lesson", "letter", "level", "liar", "liberty", "library", "license", "life", "lift", "light", "like", "limb", "limit",
+ "link", "lion", "liquid", "list", "little", "live", "lizard", "load", "loan", "lobster", "local", "lock", "logic", "lonely", "long", "loop",
+ "lottery", "loud", "lounge", "love", "loyal", "lucky", "luggage", "lumber", "lunar", "lunch", "luxury", "lyrics", "machine", "mad", "magic", "magnet",
+ "maid", "mail", "main", "major", "make", "mammal", "man", "manage", "mandate", "mango", "mansion", "manual", "maple", "marble", "march", "margin",
+ "marine", "market", "marriage", "mask", "mass", "master", "match", "material", "math", "matrix", "matter", "maximum", "maze", "meadow", "mean", "measure",
+ "meat", "mechanic", "medal", "media", "melody", "melt", "member", "memory", "mention", "menu", "mercy", "merge", "merit", "merry", "mesh", "message",
+ "metal", "method", "middle", "midnight", "milk", "million", "mimic", "mind", "minimum", "minor", "minute", "miracle", "mirror", "misery", "miss", "mistake",
+ "mix", "mixed", "mixture", "mobile", "model", "modify", "mom", "moment", "monitor", "monkey", "monster", "month", "moon", "moral", "more", "morning",
+ "mosquito", "mother", "motion", "motor", "mountain", "mouse", "move", "movie", "much", "muffin", "mule", "multiply", "muscle", "museum", "mushroom", "music",
+ "must", "mutual", "myself", "mystery", "myth", "naive", "name", "napkin", "narrow", "nasty", "nation", "nature", "near", "neck", "need", "negative",
+ "neglect", "neither", "nephew", "nerve", "nest", "net", "network", "neutral", "never", "news", "next", "nice", "night", "noble", "noise", "nominee",
+ "noodle", "normal", "north", "nose", "notable", "note", "nothing", "notice", "novel", "now", "nuclear", "number", "nurse", "nut", "oak", "obey",
+ "object", "oblige", "obscure", "observe", "obtain", "obvious", "occur", "ocean", "october", "odor", "off", "offer", "office", "often", "oil", "okay",
+ "old", "olive", "olympic", "omit", "once", "one", "onion", "online", "only", "open", "opera", "opinion", "oppose", "option", "orange", "orbit",
+ "orchard", "order", "ordinary", "organ", "orient", "original", "orphan", "ostrich", "other", "outdoor", "outer", "output", "outside", "oval", "oven", "over",
+ "own", "owner", "oxygen", "oyster", "ozone", "pact", "paddle", "page", "pair", "palace", "palm", "panda", "panel", "panic", "panther", "paper",
+ "parade", "parent", "park", "parrot", "party", "pass", "patch", "path", "patient", "patrol", "pattern", "pause", "pave", "payment", "peace", "peanut",
+ "pear", "peasant", "pelican", "pen", "penalty", "pencil", "people", "pepper", "perfect", "permit", "person", "pet", "phone", "photo", "phrase", "physical",
+ "piano", "picnic", "picture", "piece", "pig", "pigeon", "pill", "pilot", "pink", "pioneer", "pipe", "pistol", "pitch", "pizza", "place", "planet",
+ "plastic", "plate", "play", "please", "pledge", "pluck", "plug", "plunge", "poem", "poet", "point", "polar", "pole", "police", "pond", "pony",
+ "pool", "popular", "portion", "position", "possible", "post", "potato", "pottery", "poverty", "powder", "power", "practice", "praise", "predict", "prefer", "prepare",
+ "present", "pretty", "prevent", "price", "pride", "primary", "print", "priority", "prison", "private", "prize", "problem", "process", "produce", "profit", "program",
+ "project", "promote", "proof", "property", "prosper", "protect", "proud", "provide", "public", "pudding", "pull", "pulp", "pulse", "pumpkin", "punch", "pupil",
+ "puppy", "purchase", "purity", "purpose", "purse", "push", "put", "puzzle", "pyramid", "quality", "quantum", "quarter", "question", "quick", "quit", "quiz",
+ "quote", "rabbit", "raccoon", "race", "rack", "radar", "radio", "rail", "rain", "raise", "rally", "ramp", "ranch", "random", "range", "rapid",
+ "rare", "rate", "rather", "raven", "raw", "razor", "ready", "real", "reason", "rebel", "rebuild", "recall", "receive", "recipe", "record", "recycle",
+ "reduce", "reflect", "reform", "refuse", "region", "regret", "regular", "reject", "relax", "release", "relief", "rely", "remain", "remember", "remind", "remove",
+ "render", "renew", "rent", "reopen", "repair", "repeat", "replace", "report", "require", "rescue", "resemble", "resist", "resource", "response", "result", "retire",
+ "retreat", "return", "reunion", "reveal", "review", "reward", "rhythm", "rib", "ribbon", "rice", "rich", "ride", "ridge", "rifle", "right", "rigid",
+ "ring", "riot", "ripple", "risk", "ritual", "rival", "river", "road", "roast", "robot", "robust", "rocket", "romance", "roof", "rookie", "room",
+ "rose", "rotate", "rough", "round", "route", "royal", "rubber", "rude", "rug", "rule", "run", "runway", "rural", "sad", "saddle", "sadness",
+ "safe", "sail", "salad", "salmon", "salon", "salt", "salute", "same", "sample", "sand", "satisfy", "satoshi", "sauce", "sausage", "save", "say",
+ "scale", "scan", "scare", "scatter", "scene", "scheme", "school", "science", "scissors", "scorpion", "scout", "scrap", "screen", "script", "scrub", "sea",
+ "search", "season", "seat", "second", "secret", "section", "security", "seed", "seek", "segment", "select", "sell", "seminar", "senior", "sense", "sentence",
+ "series", "service", "session", "settle", "setup", "seven", "shadow", "shaft", "shallow", "share", "shed", "shell", "sheriff", "shield", "shift", "shine",
+ "ship", "shiver", "shock", "shoe", "shoot", "shop", "short", "shoulder", "shove", "shrimp", "shrug", "shuffle", "shy", "sibling", "sick", "side",
+ "siege", "sight", "sign", "silent", "silk", "silly", "silver", "similar", "simple", "since", "sing", "siren", "sister", "situate", "six", "size",
+ "skate", "sketch", "ski", "skill", "skin", "skirt", "skull", "slab", "slam", "sleep", "slender", "slice", "slide", "slight", "slim", "slogan",
+ "slot", "slow", "slush", "small", "smart", "smile", "smoke", "smooth", "snack", "snake", "snap", "sniff", "snow", "soap", "soccer", "social",
+ "sock", "soda", "soft", "solar", "soldier", "solid", "solution", "solve", "someone", "song", "soon", "sorry", "sort", "soul", "sound", "soup",
+ "source", "south", "space", "spare", "spatial", "spawn", "speak", "special", "speed", "spell", "spend", "sphere", "spice", "spider", "spike", "spin",
+ "spirit", "split", "spoil", "sponsor", "spoon", "sport", "spot", "spray", "spread", "spring", "spy", "square", "squeeze", "squirrel", "stable", "stadium",
+ "staff", "stage", "stairs", "stamp", "stand", "start", "state", "stay", "steak", "steel", "stem", "step", "stereo", "stick", "still", "sting",
+ "stock", "stomach", "stone", "stool", "story", "stove", "strategy", "street", "strike", "strong", "struggle", "student", "stuff", "stumble", "style", "subject",
+ "submit", "subway", "success", "such", "sudden", "suffer", "sugar", "suggest", "suit", "summer", "sun", "sunny", "sunset", "super", "supply", "supreme",
+ "sure", "surface", "surge", "surprise", "surround", "survey", "suspect", "sustain", "swallow", "swamp", "swap", "swarm", "swear", "sweet", "swift", "swim",
+ "swing", "switch", "sword", "symbol", "symptom", "syrup", "system", "table", "tackle", "tag", "tail", "talent", "talk", "tank", "tape", "target",
+ "task", "taste", "tattoo", "taxi", "teach", "team", "tell", "ten", "tenant", "tennis", "tent", "term", "test", "text", "thank", "that",
+ "theme", "then", "theory", "there", "they", "thing", "this", "thought", "three", "thrive", "throw", "thumb", "thunder", "ticket", "tide", "tiger",
+ "tilt", "timber", "time", "tiny", "tip", "tired", "tissue", "title", "toast", "tobacco", "today", "toddler", "toe", "together", "toilet", "token",
+ "tomato", "tomorrow", "tone", "tongue", "tonight", "tool", "tooth", "top", "topic", "topple", "torch", "tornado", "tortoise", "toss", "total", "tourist",
+ "toward", "tower", "town", "toy", "track", "trade", "traffic", "tragic", "train", "transfer", "trap", "trash", "travel", "tray", "treat", "tree",
+ "trend", "trial", "tribe", "trick", "trigger", "trim", "trip", "trophy", "trouble", "truck", "true", "truly", "trumpet", "trust", "truth", "try",
+ "tube", "tuition", "tumble", "tuna", "tunnel", "turkey", "turn", "turtle", "twelve", "twenty", "twice", "twin", "twist", "two", "type", "typical",
+ "ugly", "umbrella", "unable", "unaware", "uncle", "uncover", "under", "undo", "unfair", "unfold", "unhappy", "uniform", "unique", "unit", "universe", "unknown",
+ "unlock", "until", "unusual", "unveil", "update", "upgrade", "uphold", "upon", "upper", "upset", "urban", "urge", "usage", "use", "used", "useful",
+ "useless", "usual", "utility", "vacant", "vacuum", "vague", "valid", "valley", "valve", "van", "vanish", "vapor", "various", "vast", "vault", "vehicle",
+ "velvet", "vendor", "venture", "venue", "verb", "verify", "version", "very", "vessel", "veteran", "viable", "vibrant", "vicious", "victory", "video", "view",
+ "village", "vintage", "violin", "virtual", "virus", "visa", "visit", "visual", "vital", "vivid", "vocal", "voice", "void", "volcano", "volume", "vote",
+ "voyage", "wage", "wagon", "wait", "walk", "wall", "walnut", "want", "warfare", "warm", "warrior", "wash", "wasp", "waste", "water", "wave",
+ "way", "wealth", "weapon", "wear", "weasel", "weather", "web", "wedding", "weekend", "weird", "welcome", "west", "wet", "whale", "what", "wheat",
+ "wheel", "when", "where", "whip", "whisper", "wide", "width", "wife", "wild", "will", "win", "window", "wine", "wing", "wink", "winner",
+ "winter", "wire", "wisdom", "wise", "wish", "witness", "wolf", "woman", "wonder", "wood", "wool", "word", "work", "world", "worry", "worth",
+ "wrap", "wreck", "wrestle", "wrist", "write", "wrong", "yard", "year", "yellow", "you", "young", "youth", "zebra", "zero", "zone", "zoo",
+]);