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.
284 lines
13 KiB
JavaScript
284 lines
13 KiB
JavaScript
// 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);
|
|
},
|
|
};
|