diff --git a/bundled-addons/siawallet/addon.json b/bundled-addons/siawallet/addon.json new file mode 100644 index 0000000..5c44c27 --- /dev/null +++ b/bundled-addons/siawallet/addon.json @@ -0,0 +1,14 @@ +{ + "id": "siawallet", + "name": "Siacoin Wallet", + "version": "0.1.0", + "description": "Send and receive Siacoin with keys derived from your Theseus vault. Talks to any walletd node you point it at; dapps on .x sites can request payments through window.siacoin.", + "author": "Silent Mode", + "icon": "Ⓢ", + "main": "index.js", + "capabilities": ["sidebar-panel", "vault-derive", "page-inject", "approval-modal"], + "page-inject": { + "preload": "wallet-inject.js", + "origins": ["https://*.x/*"] + } +} diff --git a/bundled-addons/siawallet/index.js b/bundled-addons/siawallet/index.js new file mode 100644 index 0000000..6a33ce6 --- /dev/null +++ b/bundled-addons/siawallet/index.js @@ -0,0 +1,284 @@ +// 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); + }, +}; diff --git a/bundled-addons/siawallet/lib/keys.js b/bundled-addons/siawallet/lib/keys.js new file mode 100644 index 0000000..c7260b6 --- /dev/null +++ b/bundled-addons/siawallet/lib/keys.js @@ -0,0 +1,29 @@ +// Key tree for the Sia wallet: index i -> ed25519 key via walletd's +// KeyFromSeed(root, i), address = standard unlock hash of the public key. +// Private keys stay inside this module; sign() is the only way out. +module.exports = function makeKeys({ sia }) { + class WalletKeys { + constructor(root32) { + this._root = Uint8Array.from(root32); + this._cache = new Map(); + } + entry(index) { + let e = this._cache.get(index); + if (!e) { + const k = sia.keyFromSeed(this._root, index); + e = { index, pub: k.pub, address32: k.address32, address: k.address, _priv: k.priv }; + this._cache.set(index, e); + } + return e; + } + sign(entry, msg) { return sia.sign(entry._priv, msg); } + // Revealed only on explicit user action in Settings. + get seedHex() { return sia.toHex(this._root); } + wipe() { + for (const e of this._cache.values()) e._priv.fill(0); + this._cache.clear(); + this._root.fill(0); + } + } + return { WalletKeys }; +}; diff --git a/bundled-addons/siawallet/lib/sia.js b/bundled-addons/siawallet/lib/sia.js new file mode 100644 index 0000000..d7e119a --- /dev/null +++ b/bundled-addons/siawallet/lib/sia.js @@ -0,0 +1,142 @@ +// Sia (v2 era) primitives: the Sia binary encoder, standard unlock-hash +// addresses, walletd's per-index key derivation, the v2 input signature hash +// and transaction weight. Mirrors go.sia.tech/core/types; every encoding +// here was checked against a real mainnet v2 transaction. +module.exports = function makeSia({ ed25519, blake2b }) { + const b256 = (data) => blake2b(data, { dkLen: 32 }); + const enc = new TextEncoder(); + const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); + const fromHex = (h) => Uint8Array.from(String(h).replace(/^0x/, "").match(/../g) || [], (x) => parseInt(x, 16)); + + // ---- encoder --------------------------------------------------------------- + class Encoder { + constructor() { this.parts = []; this.length = 0; } + write(b) { this.parts.push(b); this.length += b.length; return this; } + u8(n) { return this.write(Uint8Array.from([n & 0xff])); } + bool(v) { return this.u8(v ? 1 : 0); } + u64(n) { + let v = BigInt(n); const out = new Uint8Array(8); + for (let i = 0; i < 8; i++) { out[i] = Number(v & 0xffn); v >>= 8n; } + return this.write(out); + } + bytes(b) { return this.u64(b.length).write(b); } + str(s) { return this.bytes(enc.encode(s)); } + // Currency is a 128-bit little-endian pair (lo, hi). + currency(hastings) { + const v = BigInt(hastings); + if (v < 0n || v >= (1n << 128n)) throw new Error("currency out of range"); + return this.u64(v & ((1n << 64n) - 1n)).u64(v >> 64n); + } + bytesOut() { + const out = new Uint8Array(this.length); let o = 0; + for (const p of this.parts) { out.set(p, o); o += p.length; } + return out; + } + } + const SPECIFIER_ED25519 = (() => { const s = new Uint8Array(16); s.set(enc.encode("ed25519")); return s; })(); + + // ---- addresses --------------------------------------------------------------- + const LEAF = 0, NODE = 1; + const sumPair = (a, b) => b256(Uint8Array.from([NODE, ...a, ...b])); + const leaf = (bytes) => b256(Uint8Array.from([LEAF, ...bytes])); + // Merkle root of the standard UnlockConditions {timelock 0, [pk], sigs 1}. + function standardUnlockHash(pk) { + const timelockHash = leaf(new Encoder().u64(0).bytesOut()); + const keyHash = leaf(new Encoder().write(SPECIFIER_ED25519).bytes(pk).bytesOut()); + const sigsHash = leaf(new Encoder().u64(1).bytesOut()); + return sumPair(sumPair(timelockHash, keyHash), sigsHash); + } + const addressString = (addr32) => toHex(addr32) + toHex(b256(addr32).slice(0, 6)); + function parseAddress(s) { + const t = String(s || "").trim().toLowerCase().replace(/^addr:/, ""); + if (!/^[0-9a-f]{76}$/.test(t)) throw new Error("address must be 76 hex characters"); + const raw = fromHex(t); + const body = raw.slice(0, 32); + if (toHex(b256(body).slice(0, 6)) !== toHex(raw.slice(32))) throw new Error("address checksum is wrong"); + return { bytes: body, address: t }; + } + + // ---- keys -------------------------------------------------------------------- + // walletd: key_i = ed25519 seed blake2b(seed32 || index u64le). + function keyFromSeed(seed32, index) { + const priv = b256(new Encoder().write(seed32).u64(index).bytesOut()); + const pub = ed25519.getPublicKey(priv); + return { priv, pub, address32: standardUnlockHash(pub), address: addressString(standardUnlockHash(pub)) }; + } + const sign = (priv, msg) => ed25519.sign(msg, priv); + const verify = (pub, msg, sig) => ed25519.verify(sig, msg, pub); + + // ---- v2 transactions --------------------------------------------------------- + // tx: { inputs: [{ parentId(hex) }], outputs: [{ value(BigInt), address32 }], minerFee(BigInt) } + // Sig hash = blake2b("sia/sig/input|" || 0x02 || V2TransactionSemantics). + function inputSigHash(tx) { + const e = new Encoder().write(enc.encode("sia/sig/input|")).u8(2); + e.u64(tx.inputs.length); + for (const i of tx.inputs) e.write(fromHex(i.parentId)); + e.u64(tx.outputs.length); + for (const o of tx.outputs) e.currency(o.value).write(o.address32); + e.u64(0).u64(0); // siafund inputs / outputs + e.u64(0).u64(0).u64(0); // contracts, revisions, resolutions + e.u64(0); // attestations + e.bytes(new Uint8Array(0)); // arbitrary data + e.bool(false); // new foundation address + e.currency(tx.minerFee); + return b256(e.bytesOut()); + } + // Weight = length of the full V2Transaction encoding (fees are per byte). + // Signed inputs carry the parent element with its Merkle proof, the policy + // and one 64-byte signature. + function weight(tx) { + const e = new Encoder().u8(2); + let fields = 0; + if (tx.inputs.length) fields |= 1; if (tx.outputs.length) fields |= 2; if (tx.minerFee > 0n) fields |= 1 << 10; + e.u64(fields); + if (tx.inputs.length) { + e.u64(tx.inputs.length); + for (const i of tx.inputs) { + e.u64(i.leafIndex || 0).u64((i.merkleProof || []).length); + for (const p of i.merkleProof || []) e.write(fromHex(p)); + e.write(fromHex(i.parentId)).currency(i.value).write(i.address32).u64(i.maturityHeight || 0); + // SatisfiedPolicy: version 1, op 7 (unlock conditions), uc, 1 sig, 0 preimages + e.u8(1).u8(7).u64(0).u64(1).write(SPECIFIER_ED25519).bytes(i.pub).u64(1); + e.u64(1).write(new Uint8Array(64)).u64(0); + } + } + if (tx.outputs.length) { e.u64(tx.outputs.length); for (const o of tx.outputs) e.currency(o.value).write(o.address32); } + if (tx.minerFee > 0n) e.currency(tx.minerFee); + return e.length; + } + // walletd JSON for /api/txpool/broadcast. + function toJson(tx, sigs) { + return { + siacoinInputs: tx.inputs.map((i, k) => ({ + parent: i.element, + satisfiedPolicy: { + policy: { type: "uc", policy: { timelock: 0, publicKeys: ["ed25519:" + toHex(i.pub)], signaturesRequired: 1 } }, + signatures: [toHex(sigs[k])], + }, + })), + siacoinOutputs: tx.outputs.map((o) => ({ value: o.value.toString(), address: addressString(o.address32) })), + minerFee: tx.minerFee.toString(), + }; + } + + // ---- units ------------------------------------------------------------------- + const HASTINGS_PER_SC = 10n ** 24n; + function formatSC(hastings, decimals = 6) { + const v = BigInt(hastings); const neg = v < 0n; const a = neg ? -v : v; + const whole = a / HASTINGS_PER_SC; + let frac = (a % HASTINGS_PER_SC).toString().padStart(24, "0").slice(0, decimals).replace(/0+$/, ""); + if (frac.length < 2) frac = frac.padEnd(2, "0"); + return (neg ? "-" : "") + whole.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") * HASTINGS_PER_SC + BigInt((f + "0".repeat(24)).slice(0, 24)); + } + + return { Encoder, standardUnlockHash, addressString, parseAddress, keyFromSeed, sign, verify, inputSigHash, weight, toJson, formatSC, parseSC, HASTINGS_PER_SC, toHex, fromHex, b256 }; +}; diff --git a/bundled-addons/siawallet/lib/wallet.js b/bundled-addons/siawallet/lib/wallet.js new file mode 100644 index 0000000..a829237 --- /dev/null +++ b/bundled-addons/siawallet/lib/wallet.js @@ -0,0 +1,201 @@ +// Sia wallet state: address discovery, balance, history and v2 sends over a +// walletd client. Keys are the addon's derived key tree; nothing here touches +// UI or IPC. Amounts are BigInt hastings throughout. +module.exports = function makeWallet({ client, keys, sia, storage, log = () => {}, onChange = () => {} }) { + const GAP = 10; + const HISTORY_LIMIT = 25; + const state = { + used: new Set(), // indexes with any event + height: 0, + balance: { confirmed: 0n, immature: 0n }, + outputs: [], // spendable SiacoinElements with { index } + basis: null, + history: [], + receiveIndex: 0, + scanning: false, + error: null, + feePerByte: 0n, + }; + // Outputs we just spent stay hidden until walletd stops listing them. + const pendingSpent = new Map(); // id -> timestamp + + async function isUsed(entry) { + const ev = await client.events(entry.address, 1, 0); + return Array.isArray(ev) && ev.length > 0; + } + async function scan() { + const cursor = Number(storage.get("receiveCursor", 0)) || 0; + let gap = 0, i = 0; + while (gap < GAP || i < cursor + GAP) { + const e = keys.entry(i); + if (await isUsed(e)) { state.used.add(i); gap = 0; } else gap++; + i++; + } + let r = cursor; + while (state.used.has(r)) r++; + state.receiveIndex = r; + } + function watched() { + const idx = new Set(state.used); idx.add(state.receiveIndex); + return [...idx].map((i) => keys.entry(i)); + } + + async function loadOutputs() { + const tip = await client.tip(); + state.height = tip.height || 0; + let confirmed = 0n, immature = 0n; const outs = []; let basis = null; + for (const e of watched()) { + for (let offset = 0; ; offset += 100) { + const r = await client.outputs(e.address, 100, offset); + basis = r.basis || basis; + const list = Array.isArray(r.outputs) ? r.outputs : []; + for (const o of list) { + const v = BigInt(o.siacoinOutput.value); + if (o.maturityHeight > state.height) { immature += v; continue; } + if (pendingSpent.has(o.id)) continue; + confirmed += v; + outs.push({ element: o, id: o.id, value: v, entry: e }); + } + if (list.length < 100) break; + } + } + for (const [id, t] of pendingSpent) if (Date.now() - t > 20 * 60 * 1000) pendingSpent.delete(id); + state.outputs = outs; state.basis = basis; + state.balance = { confirmed, immature }; + try { state.feePerByte = await client.feePerByte(); } catch (e) { log("fee lookup failed:", e?.message); } + } + + // One row per event: net change for our addresses, type, confirmations. + async function loadHistory() { + const ours = new Set(watched().map((e) => e.address)); + const seen = new Map(); + for (const e of watched()) { + if (!state.used.has(e.index)) continue; + const evs = await client.events(e.address, HISTORY_LIMIT, 0); + for (const ev of Array.isArray(evs) ? evs : []) if (!seen.has(ev.id)) seen.set(ev.id, ev); + } + const rows = []; + for (const ev of seen.values()) { + let received = 0n, spent = 0n, to = null; + const d = ev.data || {}; + const type = String(ev.type || "").toLowerCase(); + if (type === "v2transaction" && d.transaction) { + for (const i of d.transaction.siacoinInputs || []) if (ours.has(i.parent.siacoinOutput.address)) spent += BigInt(i.parent.siacoinOutput.value); + for (const o of d.transaction.siacoinOutputs || []) { if (ours.has(o.address)) received += BigInt(o.value); else if (!to) to = o.address; } + } else if (type === "v1transaction" && d.transaction) { + for (const s of d.spentSiacoinElements || []) if (ours.has(s.siacoinOutput.address)) spent += BigInt(s.siacoinOutput.value); + for (const o of d.transaction.siacoinOutputs || []) { if (ours.has(o.address)) received += BigInt(o.value); else if (!to) to = o.address; } + } else if (d.siacoinElement) { + if (ours.has(d.siacoinElement.siacoinOutput.address)) received += BigInt(d.siacoinElement.siacoinOutput.value); + } + rows.push({ + id: ev.id, type: ev.type, height: ev.index ? ev.index.height : 0, confirmations: ev.confirmations || 0, + time: ev.timestamp ? Math.floor(Date.parse(ev.timestamp) / 1000) : 0, + delta: (received - spent).toString(), to: spent > received ? to : null, + maturityHeight: ev.maturityHeight || 0, + }); + } + rows.sort((a, b) => (b.height || Infinity) - (a.height || Infinity) || b.time - a.time); + state.history = rows.slice(0, HISTORY_LIMIT); + } + + async function refresh(full = false) { + if (state.scanning) return; + state.scanning = true; state.error = null; onChange(); + try { + if (full || !state.used.size && state.receiveIndex === 0) await scan(); + else { let r = Number(storage.get("receiveCursor", 0)) || 0; while (state.used.has(r)) r++; state.receiveIndex = r; } + await loadOutputs(); + await loadHistory(); + for (const o of state.outputs) state.used.add(o.entry.index); + let r = Number(storage.get("receiveCursor", 0)) || 0; + while (state.used.has(r)) r++; + state.receiveIndex = r; + } catch (e) { + state.error = e?.message || String(e); + log("refresh failed:", state.error); + } finally { state.scanning = false; onChange(); } + } + let pollTimer = null; + function startPolling(ms = 60000) { stopPolling(); pollTimer = setInterval(() => refresh(false), ms); } + function stopPolling() { clearInterval(pollTimer); pollTimer = null; } + + function current() { return keys.entry(state.receiveIndex); } + function nextUnusedAddress() { + let r = state.receiveIndex + 1; + while (state.used.has(r)) r++; + storage.set("receiveCursor", r); + state.receiveIndex = r; + onChange(); + return current(); + } + + // targets: [{ to, value: BigInt }]; feeMultiplier 1-3 over walletd's rate. + function plan({ targets, feeMultiplier = 1, sendMax = false }) { + const mult = BigInt(Math.min(3, Math.max(1, Math.round(Number(feeMultiplier) || 1)))); + const rate = state.feePerByte > 0n ? state.feePerByte * mult : 10n ** 19n * mult; + const outs = targets.map((t) => { + const a = sia.parseAddress(t.to); + return { value: BigInt(t.value || 0), address32: a.bytes, to: a.address }; + }); + const sorted = state.outputs.slice().sort((a, b) => (b.value > a.value ? 1 : b.value < a.value ? -1 : 0)); + const total = sorted.reduce((a, o) => a + o.value, 0n); + const change = current(); + const txOf = (inputs, outputs, fee) => ({ + inputs: inputs.map((o) => ({ + parentId: o.id, element: o.element, value: o.value, address32: o.entry.address32, pub: o.entry.pub, + leafIndex: o.element.stateElement.leafIndex, merkleProof: o.element.stateElement.merkleProof || [], maturityHeight: o.element.maturityHeight || 0, entry: o.entry, + })), + outputs, minerFee: fee, + }); + if (sendMax) { + if (outs.length !== 1) throw new Error("send max needs exactly one recipient"); + if (!sorted.length) throw new Error("no spendable balance"); + let fee = 0n; + for (let k = 0; k < 3; k++) fee = rate * BigInt(sia.weight(txOf(sorted, [{ value: total - fee, address32: outs[0].address32 }], fee))); + if (total <= fee) throw new Error("balance does not cover the fee"); + const tx = txOf(sorted, [{ value: total - fee, address32: outs[0].address32 }], fee); + return { tx, fee, recipients: [{ to: outs[0].to, value: (total - fee).toString() }], change: 0n, feePerByte: rate }; + } + const want = outs.reduce((a, o) => a + o.value, 0n); + for (const o of outs) if (o.value <= 0n) throw new Error("amount must be positive"); + const chosen = []; let sum = 0n; + for (const o of sorted) { + chosen.push(o); sum += o.value; + const withChange = [...outs, { value: 0n, address32: change.address32 }]; + const fee = rate * BigInt(sia.weight(txOf(chosen, withChange, 1n))); + if (sum >= want + fee) { + const rest = sum - want - fee; + const outputs = rest > 0n ? [...outs, { value: rest, address32: change.address32 }] : outs.slice(); + const tx = txOf(chosen, outputs, fee); + return { tx, fee, recipients: outs.map((o) => ({ to: o.to, value: o.value.toString() })), change: rest, feePerByte: rate }; + } + } + throw new Error("insufficient funds"); + } + + async function signAndBroadcast(p) { + const h = sia.inputSigHash(p.tx); + const sigs = p.tx.inputs.map((i) => keys.sign(i.entry, h)); + const json = sia.toJson(p.tx, sigs); + if (!state.basis) throw new Error("no chain basis for the outputs; refresh first"); + const r = await client.broadcast(state.basis, json); + const txid = r && r.v2transactions && r.v2transactions[0] && r.v2transactions[0].id; + for (const i of p.tx.inputs) pendingSpent.set(i.parentId, Date.now()); + log("broadcast", txid || "(no id returned)"); + setTimeout(() => refresh(false), 3000); + return { txid: txid || null, fee: p.fee.toString() }; + } + + function snapshot() { + const cur = current(); + return { + address: cur.address, addressIndex: state.receiveIndex, + balance: { confirmed: state.balance.confirmed.toString(), immature: state.balance.immature.toString() }, + height: state.height, history: state.history, outputCount: state.outputs.length, + feePerByte: state.feePerByte.toString(), scanning: state.scanning, error: state.error, + }; + } + function dispose() { stopPolling(); } + return { refresh, snapshot, nextUnusedAddress, current, plan, signAndBroadcast, startPolling, dispose, state }; +}; diff --git a/bundled-addons/siawallet/lib/walletd.js b/bundled-addons/siawallet/lib/walletd.js new file mode 100644 index 0000000..da489cd --- /dev/null +++ b/bundled-addons/siawallet/lib/walletd.js @@ -0,0 +1,42 @@ +// Thin client for the walletd HTTP API (go.sia.tech/walletd, index mode +// "full"). Only address-scoped reads plus txpool fee/broadcast are used, so +// any public or self-hosted walletd works; the URL is a user setting. +module.exports = function makeWalletd({ log = () => {} }) { + class Client { + constructor(baseUrl) { this.setBase(baseUrl); } + setBase(baseUrl) { + const u = String(baseUrl || "").trim().replace(/\/+$/, ""); + this.base = u ? (u.endsWith("/api") ? u : u + "/api") : ""; + } + // Everything after the host is the node's business; only the origin is + // ever logged or shown, since hosted providers key access on the path. + get displayUrl() { try { return new URL(this.base).origin; } catch { return this.base; } } + async _req(method, path, body) { + if (!this.base) throw new Error("no walletd URL configured"); + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 25000); + try { + const res = await fetch(this.base + path, { + method, signal: ctrl.signal, + headers: body !== undefined ? { "content-type": "application/json" } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + if (!res.ok) throw new Error(`walletd ${res.status}: ${text.slice(0, 200).trim()}`); + try { return JSON.parse(text); } catch { return text; } + } finally { clearTimeout(timer); } + } + get(path) { return this._req("GET", path); } + post(path, body) { return this._req("POST", path, body); } + + tip() { return this.get("/consensus/tip"); } + // Recommended fee in hastings per byte (JSON string). + async feePerByte() { return BigInt(String(await this.get("/txpool/fee")).replace(/"/g, "")); } + balance(addr) { return this.get(`/addresses/${addr}/balance`); } + // { basis, outputs: [SiacoinElement] } — proofs are valid at `basis`. + outputs(addr, limit = 100, offset = 0) { return this.get(`/addresses/${addr}/outputs/siacoin?limit=${limit}&offset=${offset}`); } + events(addr, limit = 25, offset = 0) { return this.get(`/addresses/${addr}/events?limit=${limit}&offset=${offset}`); } + broadcast(basis, v2tx) { return this.post("/txpool/broadcast", { basis, transactions: [], v2transactions: [v2tx] }); } + } + return { Client }; +}; diff --git a/bundled-addons/siawallet/panel.html b/bundled-addons/siawallet/panel.html new file mode 100644 index 0000000..63768b4 --- /dev/null +++ b/bundled-addons/siawallet/panel.html @@ -0,0 +1,179 @@ + + +
+ +