theseus/bundled-addons/siawallet/panel.js

204 lines
13 KiB
JavaScript
Raw Normal View History

feat(theseus/siawallet): bundled Siacoin wallet add-on (walletd-backed, v2) Second bundled wallet, same shape as bchwallet: - keys: api.vault.derive("siawallet/mainnet/0") as the seed for walletd's KeyFromSeed(seed, index) (blake2b(seed||index) -> ed25519); addresses are standard unlock hashes, so a future walletd seed import yields the same addresses. Seed and keys live in memory only. - lib/sia.js: Sia binary encoder, StandardUnlockHash, address checksum, v2 InputSigHash ("sia/sig/input|" + replay byte 2 + transaction semantics), transaction weight, walletd JSON. Address hashing and the sighash were verified against real mainnet v2 transactions (signatures from block 591853 verify under this implementation). - lib/walletd.js: address-scoped walletd HTTP client (tip, fee, balance, outputs with proofs, events, broadcast). The node URL is a user setting with no default; hosted providers embed the access key in the path, so only the origin is ever displayed or logged. - lib/wallet.js: gap-limit discovery via events, mature/immature balance, history deltas from v1/v2/foundation/miner events, largest-first selection with change to the current address, fee = walletd rate x weight x 1-3 multiplier, broadcast with the outputs' basis. A signed tx built here was accepted structurally by a live walletd (rejected only for the stub key not owning the parent). - panel: Receive (QR), Send, History, Settings (node URL, derivation info, seed reveal behind approval, connected sites); gates for locked vault, no vault, no node URL. - window.siacoin dapp bridge: getAddress (rememberable), signAndSend with 100/1,000/10,000 SC allowances, signMessage (ed25519 over blake2b-256 of the message) — same approval and permission rules as the BCH wallet.
2026-09-06 18:49:13 +02:00
// 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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>`; }
})();