// Sia wallet panel. State comes from activate() via window.silentmode; this // file renders and collects input. Amounts cross the bridge as hastings // strings and are formatted here with BigInt. const $ = (id) => document.getElementById(id); const S = window.silentmode; let state = null; const H = 10n ** 24n; function fmtSC(h, decimals = 6) { const v = BigInt(h || 0); const neg = v < 0n; const a = neg ? -v : v; let frac = (a % H).toString().padStart(24, "0").slice(0, decimals).replace(/0+$/, ""); if (frac.length < 2) frac = frac.padEnd(2, "0"); return (neg ? "-" : "") + (a / H).toString() + "." + frac; } function parseSC(text) { const s = String(text || "").trim().replace(/,/g, ""); if (!/^\d*(\.\d*)?$/.test(s) || s === "" || s === ".") throw new Error("amount must be a number"); const [w = "0", f = ""] = s.split("."); if (f.length > 24) throw new Error("too many decimals"); return BigInt(w || "0") * H + BigInt((f + "0".repeat(24)).slice(0, 24)); } const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); const openUrl = (url) => S.invoke("openUrl", { url }).catch(() => {}); const cleanErr = (e) => String(e?.message || e).replace(/^Error invoking remote method '[^']+': Error: /, ""); // ---- tabs ------------------------------------------------------------------ document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab))); function showTab(name) { document.querySelectorAll("nav button").forEach((b) => b.classList.toggle("on", b.dataset.tab === name)); document.querySelectorAll("main section").forEach((s) => { s.hidden = s.id !== "tab-" + name; }); if (name !== "settings") $("recovery").innerHTML = ""; if (name === "settings") fillSettings(); } // ---- render ---------------------------------------------------------------- function render() { if (!state) return; const ready = state.phase === "ready"; const gate = $("gate"); $("tabs").hidden = !ready; gate.hidden = ready; if (!ready) { const copy = { locked: ["🔒", "Unlock your password vault to open the wallet.", "Settings › Passwords. The wallet keys derive from the vault seed."], nosetup: ["🗝", "Set up a password vault to create your wallet.", "Settings › Passwords › Set up. With a recovery phrase this wallet can be recreated on any machine."], nourl: ["🌐", "Point the wallet at a walletd node.", "Paste the URL of a walletd (v2, full index) node. Hosted providers give you a URL with your key in it; it stays on this machine."], error: ["⚠", "The wallet could not start.", state.error || ""], }[state.phase] || ["…", "Starting…", ""]; gate.innerHTML = `
${copy[0]}
${esc(copy[1])}
${esc(copy[2])}
` + (state.phase === "nourl" ? `
` : ""); if (state.phase === "nourl") $("gateApply").addEventListener("click", () => applyUrl($("gateUrl").value, $("gateMsg"))); } const dot = $("dot"); dot.className = "dot " + (ready && state.server ? (state.scanning ? "busy" : "on") : ""); $("netlbl").textContent = ready && state.server ? state.server.replace(/^https?:\/\//, "") + (state.scanning ? " · syncing" : "") : "mainnet"; if (ready) { $("balSc").textContent = fmtSC(state.balance?.confirmed || 0); const parts = []; if (state.balance?.immature && state.balance.immature !== "0") parts.push(fmtSC(state.balance.immature) + " SC maturing"); if (state.height) parts.push("block " + Number(state.height).toLocaleString("en-US")); if (state.feePerByte && state.feePerByte !== "0") parts.push("fee " + fmtSC(BigInt(state.feePerByte) * 1000n, 4) + " SC/kB"); $("balSub").textContent = state.error || parts.join(" · ") || "mainnet"; } else { $("balSc").textContent = "—"; $("balSub").textContent = "mainnet"; } if (!ready) return; if (state.address && $("addr").textContent !== state.address) { $("addr").textContent = state.address; drawQr(state.address); } $("addrMeta").textContent = `· #${state.addressIndex}`; renderHistory(); } function drawQr(text) { const cv = $("qr"); const g = cv.getContext("2d"); let q; try { q = window.QR.build(text); } catch { g.clearRect(0, 0, cv.width, cv.height); return; } const scale = Math.max(2, Math.floor(200 / (q.size + 2))); const px = (q.size + 2) * scale; cv.width = cv.height = px; cv.style.width = cv.style.height = px + "px"; g.fillStyle = "#fff"; g.fillRect(0, 0, px, px); g.fillStyle = "#000"; for (let r = 0; r < q.size; r++) for (let c = 0; c < q.size; c++) if (q.modules[r][c]) g.fillRect((c + 1) * scale, (r + 1) * scale, scale, scale); } function renderHistory() { const list = state.history || []; const el = $("txlist"); if (!list.length) { el.innerHTML = `
${state.scanning ? "Syncing…" : "No transactions yet."}
`; return; } el.innerHTML = list.map((t) => { const delta = BigInt(t.delta || 0); const inc = delta >= 0n; const when = t.time ? new Date(t.time * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "pending"; const kind = { miner: "Mining reward", foundation: "Foundation subsidy", siafundclaim: "Siafund claim" }[String(t.type).toLowerCase()]; const what = kind || (inc ? "Received" : "Sent" + (t.to ? " to " + esc(t.to.slice(0, 10)) + "…" : "")); const immature = t.maturityHeight && state.height && t.maturityHeight > state.height; const conf = immature ? "matures at " + t.maturityHeight : t.confirmations > 0 ? (t.confirmations >= 6 ? "confirmed" : t.confirmations + " conf") : "unconfirmed"; return `
${inc ? "↓" : "↑"}
${what}
${inc ? "+" : "−"}${fmtSC(inc ? delta : -delta)}
${esc(when)}
${esc(conf)}
`; }).join(""); el.querySelectorAll(".tx").forEach((row) => row.addEventListener("click", () => openUrl(state.explorerTx + row.dataset.id))); } // ---- receive --------------------------------------------------------------- $("copyAddr").addEventListener("click", async () => { try { await navigator.clipboard.writeText(state.address); flash($("copyAddr"), "Copied"); } catch {} }); $("nextAddr").addEventListener("click", async () => { try { state = await S.invoke("nextAddress"); render(); } catch { flash($("nextAddr"), "Failed"); } }); $("viewAddr").addEventListener("click", () => openUrl(state.explorerAddr + state.address)); function flash(btn, text) { const old = btn.textContent; btn.textContent = text; setTimeout(() => { btn.textContent = old; }, 1200); } // ---- send ------------------------------------------------------------------ let sendMax = false, planTimer = null, lastPlan = null; const FEE_LABELS = { 1: "normal", 2: "2× fee", 3: "3× fee" }; $("sendMax").addEventListener("click", () => { sendMax = !sendMax; $("sendMax").classList.toggle("primary", sendMax); $("sendAmt").disabled = sendMax; if (!sendMax) $("sendAmt").value = ""; schedulePlan(); }); $("feeMult").addEventListener("input", () => { $("feeLbl").textContent = FEE_LABELS[$("feeMult").value]; schedulePlan(); }); ["sendTo", "sendAmt"].forEach((id) => $(id).addEventListener("input", () => { if (id === "sendAmt" && sendMax) return; schedulePlan(); })); function schedulePlan() { clearTimeout(planTimer); planTimer = setTimeout(updatePlan, 250); } function sendSpec() { const amount = sendMax ? "0" : parseSC($("sendAmt").value).toString(); return { to: $("sendTo").value.trim(), amount, feeMultiplier: Number($("feeMult").value), sendMax }; } async function updatePlan() { const msg = $("sendMsg"); msg.hidden = true; lastPlan = null; $("sendBtn").disabled = true; $("sumAmt").textContent = $("sumFee").textContent = $("sumTotal").textContent = "—"; if (!$("sendTo").value.trim() || (!sendMax && !$("sendAmt").value.trim())) return; try { const p = await S.invoke("planSend", sendSpec()); lastPlan = p; $("sumAmt").textContent = fmtSC(p.recipients[0].value) + " SC"; $("sumFee").textContent = fmtSC(p.fee) + " SC"; $("sumTotal").textContent = fmtSC(p.total) + " SC"; if (sendMax) $("sendAmt").value = fmtSC(p.recipients[0].value, 24).replace(/0+$/, "").replace(/\.$/, ".0"); $("sendBtn").disabled = false; } catch (e) { msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false; } } $("sendBtn").addEventListener("click", async () => { if (!lastPlan) return; const msg = $("sendMsg"); msg.hidden = true; $("sendBtn").disabled = true; $("sendBtn").textContent = "Waiting for approval…"; try { const r = await S.invoke("send", sendSpec()); msg.className = "msg ok"; msg.innerHTML = r.txid ? `Sent. ${esc(r.txid.slice(0, 16))}…` : "Sent."; if (r.txid) msg.querySelector("a").addEventListener("click", () => openUrl(state.explorerTx + r.txid)); msg.hidden = false; $("sendTo").value = ""; $("sendAmt").value = ""; sendMax = false; $("sendMax").classList.remove("primary"); $("sendAmt").disabled = false; lastPlan = null; } catch (e) { const t = cleanErr(e); if (t !== "cancelled") { msg.className = "msg err"; msg.textContent = t; msg.hidden = false; } $("sendBtn").disabled = !lastPlan; } finally { $("sendBtn").textContent = "Send"; } }); // ---- settings -------------------------------------------------------------- async function fillSettings() { try { const s = await S.invoke("settings"); if (document.activeElement !== $("setUrl")) $("setUrl").value = s.walletdUrl || ""; } catch {} renderSites(); } async function applyUrl(url, msgEl) { msgEl.hidden = true; try { state = await S.invoke("setSettings", { walletdUrl: url }); render(); } catch (e) { msgEl.textContent = cleanErr(e); msgEl.hidden = false; } } $("applySettings").addEventListener("click", () => applyUrl($("setUrl").value, $("settingsMsg"))); $("refreshBtn").addEventListener("click", async () => { try { state = await S.invoke("refresh"); render(); } catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; } }); $("showInfo").addEventListener("click", async () => { try { $("recovery").innerHTML = recoveryHtml(await S.invoke("recovery", {})); } catch (e) { $("recovery").textContent = cleanErr(e); } }); $("showSeed").addEventListener("click", async () => { try { $("recovery").innerHTML = recoveryHtml(await S.invoke("recovery", { reveal: true })); } catch (e) { $("recovery").textContent = cleanErr(e); } }); function recoveryHtml(r) { let h = `
Scheme
${esc(r.scheme)}
First address
${esc(r.firstAddress)}
`; if (r.seedHex) h += `
Wallet seed (32 bytes, hex)
${esc(r.seedHex)}
`; return h; } async function renderSites() { let perms = {}; try { perms = await S.invoke("permissions"); } catch {} const origins = Object.keys(perms).filter((o) => perms[o] && (perms[o].readAddress || perms[o].sendTx)); const el = $("sites"); if (!origins.length) { el.innerHTML = `
None yet.
`; return; } el.innerHTML = origins.map((o) => { const p = perms[o]; const what = []; if (p.readAddress) what.push("address"); if (p.sendTx) { const left = BigInt(p.sendTx.cap) - BigInt(p.sendTx.used || 0); what.push(`payments: ${fmtSC(left < 0n ? 0n : left)} of ${fmtSC(p.sendTx.cap)} SC left`); } return `
${esc(o)}
${esc(what.join(" · "))}
`; }).join(""); el.querySelectorAll("button[data-origin]").forEach((b) => b.addEventListener("click", async () => { try { await S.invoke("revoke", { origin: b.dataset.origin }); renderSites(); } catch {} })); } // ---- boot ------------------------------------------------------------------ S.on("state", (s) => { state = s; render(); }); (async () => { try { state = await S.invoke("state"); render(); } catch (e) { $("gate").hidden = false; $("gate").innerHTML = `
${esc(cleanErr(e))}
`; } })();