theseus/bundled-addons/siawallet/index.js

285 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
// 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);
},
};