// Siacoin Wallet — bundled Theseus add-on. activate() runs in the main // process; keys are re-derived from the password vault on every launch and // live only in memory. Chain access goes through a walletd node the user // configures (Settings); no default endpoint ships with the add-on. const NETWORK = "mainnet"; const PURPOSE = "siawallet/mainnet/0"; const EXPLORER_TX = "https://siascan.com/tx/"; const EXPLORER_ADDR = "https://siascan.com/address/"; const SC = 10n ** 24n; const ALLOWANCES = [100n * SC, 1000n * SC, 10000n * SC]; let ctx = null; // { api, d, keys, wallet, client, root, phase, error } async function deps(api) { const { ed25519 } = await api.import("@noble/curves/ed25519.js"); const { blake2b } = await api.import("@noble/hashes/blake2.js"); const sia = require("./lib/sia.js")({ ed25519, blake2b }); const keysLib = require("./lib/keys.js")({ sia }); const walletd = require("./lib/walletd.js")({ log: (...a) => api.log("walletd", ...a) }); return { sia, keysLib, walletd }; } const walletdUrl = (api) => String(api.storage.get("walletdUrl", "") || "").trim(); const fmt = (h) => ctx.d.sia.formatSC(h); function snapshot() { const c = ctx; const base = { network: NETWORK, phase: c.phase, error: c.error, server: c.client ? c.client.displayUrl : null, hasUrl: !!walletdUrl(c.api), explorerTx: EXPLORER_TX, explorerAddr: EXPLORER_ADDR, }; if (c.wallet) Object.assign(base, c.wallet.snapshot()); return base; } function emitState() { try { ctx.api.emit("state", snapshot()); } catch {} } function setPhase(phase, error = null) { ctx.phase = phase; ctx.error = error; emitState(); } function buildWallet() { const c = ctx; if (c.wallet) { c.wallet.dispose(); c.wallet = null; } if (!c.keys) c.keys = new c.d.keysLib.WalletKeys(c.root); const url = walletdUrl(c.api); if (!url) { setPhase("nourl"); return; } if (!c.client) c.client = new c.d.walletd.Client(url); else c.client.setBase(url); c.wallet = require("./lib/wallet.js")({ client: c.client, keys: c.keys, sia: c.d.sia, storage: c.api.storage, log: (...a) => c.api.log("wallet", ...a), onChange: emitState, }); c.api.log("wallet ready, receive address", c.keys.entry(0).address, "via", c.client.displayUrl); setPhase("ready"); c.wallet.refresh(true).then(() => c.wallet && c.wallet.startPolling()); } async function deriveAndStart() { const c = ctx; setPhase("locked"); try { c.root = await c.api.vault.derive(PURPOSE); } catch (e) { const msg = e?.message || String(e); setPhase(/not set up/i.test(msg) ? "nosetup" : "error", msg); return; } if (ctx !== c) return; try { buildWallet(); } catch (e) { setPhase("error", e?.message || String(e)); c.api.log("wallet build failed:", e?.message); } } function requireReady() { if (!ctx || ctx.phase !== "ready" || !ctx.wallet) throw new Error(ctx && ctx.phase === "nourl" ? "no walletd URL configured" : "wallet is not ready (vault locked?)"); return ctx; } const fromPanel = (m) => { if (!m || m.from !== "panel") throw new Error("panel-only message"); }; function planFrom(p) { const c = requireReady(); const spec = p || {}; const targets = Array.isArray(spec.outputs) && spec.outputs.length ? spec.outputs.map((o) => ({ to: o.to, value: toHastings(o.amount ?? o.value) })) : [{ to: spec.to, value: toHastings(spec.amount ?? spec.value) }]; return c.wallet.plan({ targets, feeMultiplier: spec.feeMultiplier, sendMax: !!spec.sendMax }); } // Amounts arrive as decimal hastings strings (or numbers for small values). function toHastings(v) { if (typeof v === "bigint") return v; const s = String(v ?? "0").trim(); if (!/^\d+$/.test(s)) throw new Error("amount must be an integer number of hastings"); return BigInt(s); } function describePlan(plan) { const sent = plan.recipients.reduce((a, r) => a + BigInt(r.value), 0n); return { recipients: plan.recipients, fee: plan.fee.toString(), feePerByte: plan.feePerByte.toString(), inputs: plan.tx.inputs.length, change: plan.change.toString(), total: (sent + plan.fee).toString() }; } function registerPanelMessages(api) { api.onMessage("state", (_p, m) => { fromPanel(m); return snapshot(); }); api.onMessage("refresh", async (_p, m) => { fromPanel(m); const c = requireReady(); await c.wallet.refresh(true); return snapshot(); }); api.onMessage("nextAddress", (_p, m) => { fromPanel(m); const c = requireReady(); c.wallet.nextUnusedAddress(); return snapshot(); }); api.onMessage("openUrl", (p, m) => { fromPanel(m); api.openTab(String(p && p.url || "")); return true; }); api.onMessage("settings", (_p, m) => { fromPanel(m); return { walletdUrl: walletdUrl(api) }; }); api.onMessage("setSettings", (p, m) => { fromPanel(m); const patch = p || {}; if ("walletdUrl" in patch) { const v = String(patch.walletdUrl || "").trim(); if (v && !/^https?:\/\/[^\s]+$/i.test(v)) throw new Error("walletd URL must start with http:// or https://"); api.storage.set("walletdUrl", v); } if (ctx && ctx.root) buildWallet(); return snapshot(); }); api.onMessage("planSend", (p, m) => { fromPanel(m); return describePlan(planFrom(p)); }); api.onMessage("send", async (p, m) => { fromPanel(m); const c = requireReady(); const plan = planFrom(p); const d = describePlan(plan); const pick = await api.approvalModal({ title: "Send Siacoin?", origin: "Theseus wallet panel", rows: [ { label: "To", value: d.recipients[0].to, mono: true }, { label: "Amount", value: fmt(d.recipients[0].value) + " SC", strong: true }, { label: "Fee", value: fmt(d.fee) + " SC" }, { label: "Total", value: fmt(d.total) + " SC" }, ], actions: [{ id: "send", label: "Send", primary: true }], }); if (pick !== "send") throw new Error("cancelled"); return c.wallet.signAndBroadcast(plan); }); // Recovery: the 32-byte wallet seed (walletd KeyFromSeed scheme), only // after an explicit confirmation in the approval overlay. api.onMessage("recovery", async (p, m) => { fromPanel(m); const c = requireReady(); const out = { purpose: PURPOSE, scheme: "walletd KeyFromSeed(seed, index); address = standard unlock hash", firstAddress: c.keys.entry(0).address }; if (p && p.reveal) { const pick = await api.approvalModal({ title: "Reveal the wallet seed?", origin: "Theseus wallet panel", body: "Anyone holding this seed can spend every coin in this wallet. It stays on screen until you close the Settings tab.", actions: [{ id: "reveal", label: "Reveal", danger: true }], }); if (pick === "reveal") out.seedHex = c.keys.seedHex; } return out; }); } // ---- dapp bridge (window.siacoin) ------------------------------------------ // permissions: { [origin]: { readAddress: true, sendTx: { cap, used, grantedAt } } } // cap/used are hastings as decimal strings. No unlimited option; signing // always asks. const pendingByOrigin = new Set(); function permissions(api) { const p = api.storage.get("permissions", {}); return p && typeof p === "object" ? p : {}; } const fromPage = (m) => { if (!m || m.from !== "page" || !m.origin) throw new Error("page-only message"); return m.origin; }; async function withOriginLock(origin, fn) { if (pendingByOrigin.has(origin)) throw new Error("a wallet request from this site is already waiting for approval"); pendingByOrigin.add(origin); try { return await fn(); } finally { pendingByOrigin.delete(origin); } } function registerPageMessages(api) { api.onMessage("getAddress", async (_p, m) => { const origin = fromPage(m); const c = requireReady(); const perms = permissions(api); if (perms[origin] && perms[origin].readAddress) return c.wallet.current().address; return withOriginLock(origin, async () => { const pick = await api.approvalModal({ title: "Share your Siacoin address?", origin, body: "The site will see your current receiving address and can look up its balance and history on the public chain.", rows: [{ label: "Address", value: c.wallet.current().address, mono: true }], actions: [{ id: "allow", label: "Share", primary: true }], checkbox: { id: "always", label: "Always allow this site to see my address" }, }); if (!pick.startsWith("allow")) throw new Error("user rejected"); if (pick === "allow+always") { perms[origin] = { ...(perms[origin] || {}), readAddress: true }; api.storage.set("permissions", perms); emitState(); } return c.wallet.current().address; }); }); api.onMessage("signAndSend", async (p, m) => { const origin = fromPage(m); const c = requireReady(); return withOriginLock(origin, async () => { let plan; try { plan = planFrom(p); } catch (e) { throw new Error(/insufficient funds|balance/i.test(e?.message) ? "insufficient funds" : e?.message || String(e)); } const d = describePlan(plan); if (d.recipients.length > 8) throw new Error("too many outputs"); const perms = permissions(api); const budget = perms[origin] && perms[origin].sendTx; const remaining = budget ? BigInt(budget.cap) - BigInt(budget.used || "0") : 0n; const total = BigInt(d.total); if (budget && total <= remaining) { const r = await c.wallet.signAndBroadcast(plan); budget.used = (BigInt(budget.used || "0") + total).toString(); api.storage.set("permissions", perms); emitState(); api.log(`silent send ${fmt(total)} SC for ${origin}, ${fmt(remaining - total)} SC of allowance left`); return { txid: r.txid }; } const rows = d.recipients.map((r, i) => ({ label: d.recipients.length > 1 ? `To #${i + 1}` : "To", value: r.to, mono: true })); rows.push({ label: "Amount", value: fmt(d.recipients.reduce((a, r) => a + BigInt(r.value), 0n)) + " SC", strong: true }); rows.push({ label: "Fee", value: fmt(d.fee) + " SC" }); rows.push({ label: "Total", value: fmt(d.total) + " SC" }); const pick = await api.approvalModal({ title: "Send Siacoin?", origin, body: budget ? `This payment is over what is left of the site's allowance (${fmt(remaining)} SC). Check the address and amount.` : "This site is asking your wallet to pay. Check the address and amount.", rows, actions: [{ id: "send", label: "Send", primary: true }], select: { id: "cap", label: "Afterwards", options: [{ value: "", label: "ask every time" }, ...ALLOWANCES.map((s) => ({ value: s.toString(), label: `allow up to ${fmt(s)} SC more without asking` }))], }, }); const [action, ...flags] = pick.split("+"); if (action !== "send") throw new Error("user rejected"); const cap = flags.find((f) => f.startsWith("cap=")); const capValue = cap ? cap.slice(4) : ""; if (ALLOWANCES.some((a) => a.toString() === capValue)) { perms[origin] = { ...(perms[origin] || {}), sendTx: { cap: capValue, used: "0", grantedAt: Date.now() } }; api.storage.set("permissions", perms); } else if (budget) { delete perms[origin].sendTx; api.storage.set("permissions", perms); } emitState(); const r = await c.wallet.signAndBroadcast(plan); return { txid: r.txid }; }); }); api.onMessage("signMessage", async (p, m) => { const origin = fromPage(m); const c = requireReady(); const message = String(p && p.message != null ? p.message : ""); if (message.length > 4096) throw new Error("message too long"); return withOriginLock(origin, async () => { const entry = c.wallet.current(); const pick = await api.approvalModal({ title: "Sign a message?", origin, body: "Signing proves you control the address below. It moves no coins.", rows: [{ label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true }, { label: "Address", value: entry.address, mono: true }], actions: [{ id: "sign", label: "Sign", primary: true }], }); if (pick !== "sign") throw new Error("user rejected"); const digest = c.d.sia.b256(new TextEncoder().encode(message)); const sig = c.keys.sign(entry, digest); return { address: entry.address, publicKey: "ed25519:" + c.d.sia.toHex(entry.pub), signature: c.d.sia.toHex(sig) }; }); }); api.onMessage("permissions", (_p, m) => { fromPanel(m); return permissions(api); }); api.onMessage("revoke", (p, m) => { fromPanel(m); const perms = permissions(api); delete perms[String(p && p.origin || "")]; api.storage.set("permissions", perms); return perms; }); } module.exports = { activate(api) { api.registerSidebarPanel({ id: "main", title: "Sia", icon: "Ⓢ", page: "panel.html" }); const c = ctx = { api, d: null, keys: null, wallet: null, client: null, root: null, phase: "locked", error: null }; registerPanelMessages(api); registerPageMessages(api); deps(api).then((d) => { if (ctx !== c) return; c.d = d; return deriveAndStart(); }) .catch((e) => { if (ctx !== c) return; setPhase("error", e?.message || String(e)); api.log("startup failed:", e?.message); }); }, deactivate() { const c = ctx; ctx = null; if (!c) return; try { c.wallet && c.wallet.dispose(); } catch {} try { c.keys && c.keys.wipe(); } catch {} if (c.root) c.root.fill(0); }, };