204 lines
13 KiB
JavaScript
204 lines
13 KiB
JavaScript
|
|
// 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 = `<div class="big">${copy[0]}</div><div><b>${esc(copy[1])}</b></div><div class="hint" style="margin-top:8px">${esc(copy[2])}</div>`
|
|||
|
|
+ (state.phase === "nourl" ? `<input type="text" id="gateUrl" placeholder="https://host[:port]" spellcheck="false"><div class="actions" style="justify-content:center"><button class="btn primary" id="gateApply">Connect</button></div><div class="msg err" id="gateMsg" hidden></div>` : "");
|
|||
|
|
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 = `<div class="empty">${state.scanning ? "Syncing…" : "No transactions yet."}</div>`; 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 `<div class="tx" data-id="${esc(t.id)}" title="${esc(t.id)}">
|
|||
|
|
<div class="ic ${inc ? "in" : "out"}">${inc ? "↓" : "↑"}</div>
|
|||
|
|
<div class="what">${what}</div>
|
|||
|
|
<div class="amt2 ${inc ? "in" : ""}">${inc ? "+" : "−"}${fmtSC(inc ? delta : -delta)}</div>
|
|||
|
|
<div class="when">${esc(when)}</div>
|
|||
|
|
<div class="conf ${t.confirmations > 0 && !immature ? "" : "pending"}">${esc(conf)}</div>
|
|||
|
|
</div>`;
|
|||
|
|
}).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. <a class="link">${esc(r.txid.slice(0, 16))}…</a>` : "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 = `<div class="lbl">Scheme</div><div class="mono">${esc(r.scheme)}</div><div class="lbl">First address</div><div class="mono">${esc(r.firstAddress)}</div>`;
|
|||
|
|
if (r.seedHex) h += `<div class="lbl">Wallet seed (32 bytes, hex)</div><div class="mono" style="color:var(--danger)">${esc(r.seedHex)}</div>`;
|
|||
|
|
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 = `<div class="hint">None yet.</div>`; 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 `<div class="tx" style="grid-template-columns:1fr auto;cursor:default"><div><div class="mono">${esc(o)}</div><div class="hint">${esc(what.join(" · "))}</div></div><button class="btn sm" data-origin="${esc(o)}">Revoke</button></div>`;
|
|||
|
|
}).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 = `<div class="big">⚠</div><div>${esc(cleanErr(e))}</div>`; }
|
|||
|
|
})();
|