theseus/bundled-addons/siawallet/panel.js
Local Dev 7931d981aa 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

203 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>`; }
})();