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.
This commit is contained in:
parent
e3a44e42e3
commit
7931d981aa
10 changed files with 1339 additions and 0 deletions
14
bundled-addons/siawallet/addon.json
Normal file
14
bundled-addons/siawallet/addon.json
Normal file
|
|
@ -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/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
284
bundled-addons/siawallet/index.js
Normal file
284
bundled-addons/siawallet/index.js
Normal file
|
|
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
29
bundled-addons/siawallet/lib/keys.js
Normal file
29
bundled-addons/siawallet/lib/keys.js
Normal file
|
|
@ -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 };
|
||||||
|
};
|
||||||
142
bundled-addons/siawallet/lib/sia.js
Normal file
142
bundled-addons/siawallet/lib/sia.js
Normal file
|
|
@ -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 };
|
||||||
|
};
|
||||||
201
bundled-addons/siawallet/lib/wallet.js
Normal file
201
bundled-addons/siawallet/lib/wallet.js
Normal file
|
|
@ -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 };
|
||||||
|
};
|
||||||
42
bundled-addons/siawallet/lib/walletd.js
Normal file
42
bundled-addons/siawallet/lib/walletd.js
Normal file
|
|
@ -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 };
|
||||||
|
};
|
||||||
179
bundled-addons/siawallet/panel.html
Normal file
179
bundled-addons/siawallet/panel.html
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Siacoin Wallet</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark;
|
||||||
|
--bg:#0e131c; --panel:#141a24; --card:#0f1621; --line:rgba(255,255,255,.09);
|
||||||
|
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --danger:#f6768a; --ok:#5ad38a;
|
||||||
|
--sia:#20ee82; }
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root { --bg:#f8faff; --panel:#ffffff; --card:#f1f4fa; --line:rgba(0,0,0,.10);
|
||||||
|
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --sia:#1abc6a; }
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; height: 100%; }
|
||||||
|
body { background: var(--bg); color: var(--ink);
|
||||||
|
font: 13.5px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
display: flex; flex-direction: column; }
|
||||||
|
header { padding: 12px 14px 10px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
||||||
|
header .row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
header .t { font-weight: 600; display: flex; gap: 8px; align-items: center; }
|
||||||
|
header .t .coin { color: var(--sia); font-size: 16px; }
|
||||||
|
.net { display: flex; align-items: center; gap: 6px; color: var(--dim); font-size: 11.5px; white-space: nowrap; }
|
||||||
|
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--dim); }
|
||||||
|
.dot.on { background: var(--ok); } .dot.busy { background: #e0b341; }
|
||||||
|
.bal { margin-top: 8px; font-variant-numeric: tabular-nums; }
|
||||||
|
.bal .big { font-size: 22px; font-weight: 650; letter-spacing: .2px; }
|
||||||
|
.bal .big small { font-size: 13px; color: var(--mut); font-weight: 500; margin-left: 4px; }
|
||||||
|
.bal .sub { color: var(--dim); font-size: 11.5px; }
|
||||||
|
nav { display: flex; border-bottom: 1px solid var(--line); background: var(--panel); }
|
||||||
|
nav button { flex: 1; padding: 9px 0 8px; border: 0; background: transparent; color: var(--mut); cursor: pointer;
|
||||||
|
font: inherit; font-size: 12.5px; border-bottom: 2px solid transparent; }
|
||||||
|
nav button.on { color: var(--acid); border-bottom-color: var(--acid); }
|
||||||
|
nav button:hover { color: var(--ink); }
|
||||||
|
main { flex: 1; overflow: auto; padding: 14px; }
|
||||||
|
section[hidden] { display: none; }
|
||||||
|
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px; }
|
||||||
|
.mono { font: 12.5px/1.45 ui-monospace, "Cascadia Code", Consolas, monospace; word-break: break-all; }
|
||||||
|
.lbl { color: var(--dim); font-size: 11.5px; margin-bottom: 4px; }
|
||||||
|
.btn { padding: 7px 12px; border-radius: 7px; border: 1px solid var(--line); background: var(--panel); color: var(--ink);
|
||||||
|
cursor: pointer; font: inherit; font-size: 12.5px; }
|
||||||
|
.btn:hover { border-color: rgba(214,255,61,.4); }
|
||||||
|
.btn.primary { background: var(--acid); color: #0b0e14; border-color: transparent; font-weight: 650; }
|
||||||
|
.btn.primary:disabled { opacity: .45; cursor: default; }
|
||||||
|
.btn.danger { color: var(--danger); }
|
||||||
|
.btn.sm { padding: 4px 9px; font-size: 12px; }
|
||||||
|
.actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
||||||
|
.qrwrap { display: grid; place-items: center; padding: 12px; background: #fff; border-radius: 10px; margin-bottom: 12px; }
|
||||||
|
canvas { image-rendering: pixelated; }
|
||||||
|
input[type=text], textarea { width: 100%; padding: 7px 9px; border-radius: 7px; background: var(--panel);
|
||||||
|
border: 1px solid var(--line); color: var(--ink); font: inherit; font-size: 13px; outline: none; }
|
||||||
|
input:focus, textarea:focus { border-color: rgba(214,255,61,.5); }
|
||||||
|
.field { margin-bottom: 12px; }
|
||||||
|
.hint { color: var(--dim); font-size: 11.5px; margin-top: 4px; }
|
||||||
|
.amt { display: flex; gap: 6px; }
|
||||||
|
.amt input { flex: 1; }
|
||||||
|
.fee { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.fee input[type=range] { flex: 1; accent-color: var(--acid); }
|
||||||
|
.fee .v { color: var(--mut); font-size: 12px; min-width: 70px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.summary { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; font-size: 12.5px; margin-top: 10px; }
|
||||||
|
.summary .k { color: var(--dim); } .summary .v { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.msg { margin-top: 10px; font-size: 12.5px; padding: 8px 10px; border-radius: 7px; }
|
||||||
|
.msg.err { background: rgba(246,118,138,.12); color: var(--danger); }
|
||||||
|
.msg.ok { background: rgba(90,211,138,.12); color: var(--ok); }
|
||||||
|
.tx { display: grid; grid-template-columns: 22px 1fr auto; gap: 2px 10px; padding: 9px 4px; border-bottom: 1px solid var(--line); cursor: pointer; }
|
||||||
|
.tx:hover { background: rgba(255,255,255,.03); }
|
||||||
|
.tx .ic { font-size: 15px; line-height: 1.3; }
|
||||||
|
.tx .ic.in { color: var(--ok); } .tx .ic.out { color: var(--danger); }
|
||||||
|
.tx .what { font-size: 12.5px; }
|
||||||
|
.tx .when { color: var(--dim); font-size: 11.5px; grid-column: 2; }
|
||||||
|
.tx .amt2 { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; font-size: 12.5px; }
|
||||||
|
.tx .amt2.in { color: var(--ok); }
|
||||||
|
.tx .conf { grid-column: 3; text-align: right; color: var(--dim); font-size: 11px; }
|
||||||
|
.tx .conf.pending { color: #e0b341; }
|
||||||
|
.empty { color: var(--dim); text-align: center; padding: 30px 10px; font-size: 12.5px; }
|
||||||
|
.gate { padding: 40px 18px; text-align: center; color: var(--mut); }
|
||||||
|
.gate .big { font-size: 28px; margin-bottom: 8px; }
|
||||||
|
.gate b { color: var(--ink); }
|
||||||
|
.gate input { margin-top: 14px; text-align: left; }
|
||||||
|
.kv { margin-top: 10px; }
|
||||||
|
.kv .lbl { margin-top: 8px; }
|
||||||
|
a.link { color: var(--acid); text-decoration: none; cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="row">
|
||||||
|
<div class="t"><span class="coin">Ⓢ</span><span>Siacoin</span></div>
|
||||||
|
<div class="net"><span class="dot" id="dot"></span><span id="netlbl">connecting…</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="bal">
|
||||||
|
<div class="big"><span id="balSc">—</span><small>SC</small></div>
|
||||||
|
<div class="sub" id="balSub">mainnet</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<nav>
|
||||||
|
<button data-tab="receive" class="on">Receive</button>
|
||||||
|
<button data-tab="send">Send</button>
|
||||||
|
<button data-tab="history">History</button>
|
||||||
|
<button data-tab="settings">Settings</button>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<div id="gate" class="gate" hidden></div>
|
||||||
|
<div id="tabs">
|
||||||
|
<section id="tab-receive">
|
||||||
|
<div class="qrwrap"><canvas id="qr" width="200" height="200"></canvas></div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="lbl">Receiving address <span id="addrMeta"></span></div>
|
||||||
|
<div class="mono" id="addr">—</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn sm" id="copyAddr">Copy</button>
|
||||||
|
<button class="btn sm" id="nextAddr">Next unused address</button>
|
||||||
|
<button class="btn sm" id="viewAddr">Explorer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="tab-send" hidden>
|
||||||
|
<div class="field">
|
||||||
|
<div class="lbl">Recipient</div>
|
||||||
|
<input type="text" id="sendTo" spellcheck="false" autocomplete="off" placeholder="76-character Sia address">
|
||||||
|
<div class="hint" id="sendToHint"></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="lbl">Amount (SC)</div>
|
||||||
|
<div class="amt">
|
||||||
|
<input type="text" id="sendAmt" inputmode="decimal" autocomplete="off" placeholder="0.00">
|
||||||
|
<button class="btn sm" id="sendMax" type="button">Max</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="lbl">Fee</div>
|
||||||
|
<div class="fee"><input type="range" id="feeMult" min="1" max="3" step="1" value="1"><span class="v" id="feeLbl">normal</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="summary">
|
||||||
|
<div class="k">Amount</div><div class="v" id="sumAmt">—</div>
|
||||||
|
<div class="k">Network fee</div><div class="v" id="sumFee">—</div>
|
||||||
|
<div class="k">Total</div><div class="v" id="sumTotal">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions"><button class="btn primary" id="sendBtn" disabled>Send</button></div>
|
||||||
|
<div class="msg" id="sendMsg" hidden></div>
|
||||||
|
</section>
|
||||||
|
<section id="tab-history" hidden>
|
||||||
|
<div id="txlist"></div>
|
||||||
|
</section>
|
||||||
|
<section id="tab-settings" hidden>
|
||||||
|
<div class="field">
|
||||||
|
<div class="lbl">walletd node URL</div>
|
||||||
|
<input type="text" id="setUrl" spellcheck="false" placeholder="https://host[:port]">
|
||||||
|
<div class="hint" id="urlHint">Any walletd (v2) with index mode "full". Hosted providers put your access key in the path; it is stored locally only.</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn primary" id="applySettings">Apply</button>
|
||||||
|
<button class="btn" id="refreshBtn">Rescan</button>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-top:16px">
|
||||||
|
<div class="lbl">Recovery info</div>
|
||||||
|
<div class="hint">Keys come from your Theseus password vault under <span class="mono">silentmode/addons/siawallet/mainnet/0</span>, using walletd's per-index key scheme. The same vault seed on another machine recreates this wallet.</div>
|
||||||
|
<div class="kv" id="recovery"></div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn" id="showInfo">Show derivation</button>
|
||||||
|
<button class="btn danger" id="showSeed">Show wallet seed</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-top:16px">
|
||||||
|
<div class="lbl">Connected sites</div>
|
||||||
|
<div class="hint">Sites allowed to read your address, and sites with a payment allowance they can spend without asking. Message signing always asks.</div>
|
||||||
|
<div id="sites" class="kv"></div>
|
||||||
|
</div>
|
||||||
|
<div class="msg err" id="settingsMsg" hidden></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="qr.js"></script>
|
||||||
|
<script src="panel.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
203
bundled-addons/siawallet/panel.js
Normal file
203
bundled-addons/siawallet/panel.js
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
// 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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>`; }
|
||||||
|
})();
|
||||||
218
bundled-addons/siawallet/qr.js
Normal file
218
bundled-addons/siawallet/qr.js
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
// Minimal QR encoder for the receive tab: byte mode, versions 1-10, error
|
||||||
|
// correction M (falls back to L when M won't fit). Returns a boolean matrix.
|
||||||
|
// Loaded as a plain script in panel.html and as a CommonJS module in tests.
|
||||||
|
(function (root) {
|
||||||
|
const EC_LEVELS = { L: 1, M: 0 };
|
||||||
|
// Per version: [totalCodewords, {L:[ecPerBlock, [[blocks, dataCw], ...]], M:[...]}]
|
||||||
|
const TABLE = {
|
||||||
|
1: [26, { L: [7, [[1, 19]]], M: [10, [[1, 16]]] }],
|
||||||
|
2: [44, { L: [10, [[1, 34]]], M: [16, [[1, 28]]] }],
|
||||||
|
3: [70, { L: [15, [[1, 55]]], M: [26, [[1, 44]]] }],
|
||||||
|
4: [100, { L: [20, [[1, 80]]], M: [18, [[2, 32]]] }],
|
||||||
|
5: [134, { L: [26, [[1, 108]]], M: [24, [[2, 43]]] }],
|
||||||
|
6: [172, { L: [18, [[2, 68]]], M: [16, [[4, 27]]] }],
|
||||||
|
7: [196, { L: [20, [[2, 78]]], M: [18, [[4, 31]]] }],
|
||||||
|
8: [242, { L: [24, [[2, 97]]], M: [22, [[2, 38], [2, 39]]] }],
|
||||||
|
9: [292, { L: [30, [[2, 116]]], M: [22, [[3, 36], [2, 37]]] }],
|
||||||
|
10: [346, { L: [18, [[2, 68], [2, 69]]], M: [26, [[4, 43], [1, 44]]] }],
|
||||||
|
};
|
||||||
|
const ALIGN = { 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50] };
|
||||||
|
const REMAINDER = { 1: 0, 2: 7, 3: 7, 4: 7, 5: 7, 6: 7, 7: 0, 8: 0, 9: 0, 10: 0 };
|
||||||
|
|
||||||
|
// GF(256) with the QR polynomial 0x11d.
|
||||||
|
const EXP = new Uint8Array(512), LOG = new Uint8Array(256);
|
||||||
|
(function () {
|
||||||
|
let x = 1;
|
||||||
|
for (let i = 0; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; }
|
||||||
|
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
||||||
|
})();
|
||||||
|
const gmul = (a, b) => (a && b) ? EXP[LOG[a] + LOG[b]] : 0;
|
||||||
|
function generator(n) {
|
||||||
|
let g = [1];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const next = new Array(g.length + 1).fill(0);
|
||||||
|
for (let j = 0; j < g.length; j++) { next[j] ^= g[j]; next[j + 1] ^= gmul(g[j], EXP[i]); }
|
||||||
|
g = next;
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
function rsEncode(data, nEc) {
|
||||||
|
const g = generator(nEc);
|
||||||
|
const out = new Uint8Array(data.length + nEc);
|
||||||
|
out.set(data);
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const c = out[i];
|
||||||
|
if (c) for (let j = 1; j < g.length; j++) out[i + j] ^= gmul(g[j], c);
|
||||||
|
}
|
||||||
|
return out.slice(data.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataCapacity(v, ec) { return TABLE[v][1][ec][1].reduce((a, [b, d]) => a + b * d, 0); }
|
||||||
|
function pickVersion(len) {
|
||||||
|
for (const ec of ["M", "L"]) {
|
||||||
|
for (let v = 1; v <= 10; v++) {
|
||||||
|
const bitsNeeded = 4 + (v <= 9 ? 8 : 16) + len * 8;
|
||||||
|
if (bitsNeeded <= dataCapacity(v, ec) * 8) return { v, ec };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("qr: text too long");
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeData(bytes, v, ec) {
|
||||||
|
const cap = dataCapacity(v, ec);
|
||||||
|
const bits = [];
|
||||||
|
const push = (val, n) => { for (let i = n - 1; i >= 0; i--) bits.push((val >> i) & 1); };
|
||||||
|
push(0b0100, 4);
|
||||||
|
push(bytes.length, v <= 9 ? 8 : 16);
|
||||||
|
for (const b of bytes) push(b, 8);
|
||||||
|
for (let i = 0; i < 4 && bits.length < cap * 8; i++) bits.push(0);
|
||||||
|
while (bits.length % 8) bits.push(0);
|
||||||
|
const data = new Uint8Array(cap);
|
||||||
|
for (let i = 0; i < bits.length / 8; i++) data[i] = parseInt(bits.slice(i * 8, i * 8 + 8).join(""), 2);
|
||||||
|
for (let i = bits.length / 8, k = 0; i < cap; i++, k++) data[i] = k % 2 ? 0x11 : 0xec;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interleave(data, v, ec) {
|
||||||
|
const [nEc, groups] = TABLE[v][1][ec];
|
||||||
|
const blocks = []; let o = 0;
|
||||||
|
for (const [count, size] of groups) for (let i = 0; i < count; i++) { blocks.push(data.slice(o, o + size)); o += size; }
|
||||||
|
const ecBlocks = blocks.map((b) => rsEncode(b, nEc));
|
||||||
|
const out = [];
|
||||||
|
const maxLen = Math.max(...blocks.map((b) => b.length));
|
||||||
|
for (let i = 0; i < maxLen; i++) for (const b of blocks) if (i < b.length) out.push(b[i]);
|
||||||
|
for (let i = 0; i < nEc; i++) for (const b of ecBlocks) out.push(b[i]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remainder of value·x^degree modulo the generator poly (BCH error correction
|
||||||
|
// for the format / version fields).
|
||||||
|
function bch(value, poly, degree) {
|
||||||
|
let v = value << degree;
|
||||||
|
for (let i = 31 - Math.clz32(v); i >= degree; i--) if ((v >> i) & 1) v ^= poly << (i - degree);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
const formatBits = (ec, mask) => { const d = (EC_LEVELS[ec] << 3) | mask; return ((d << 10) | bch(d, 0x537, 10)) ^ 0x5412; };
|
||||||
|
const versionBits = (v) => (v << 12) | bch(v, 0x1f25, 12);
|
||||||
|
|
||||||
|
function build(text) {
|
||||||
|
const bytes = typeof text === "string" ? new TextEncoder().encode(text) : Uint8Array.from(text);
|
||||||
|
const { v, ec } = pickVersion(bytes.length);
|
||||||
|
const size = v * 4 + 17;
|
||||||
|
const codewords = interleave(encodeData(bytes, v, ec), v, ec);
|
||||||
|
const grid = Array.from({ length: size }, () => new Uint8Array(size)); // 1 dark, 0 light
|
||||||
|
const fixed = Array.from({ length: size }, () => new Uint8Array(size)); // function pattern / reserved
|
||||||
|
const set = (r, c, val) => { grid[r][c] = val ? 1 : 0; fixed[r][c] = 1; };
|
||||||
|
const finder = (r0, c0) => {
|
||||||
|
for (let r = -1; r <= 7; r++) for (let c = -1; c <= 7; c++) {
|
||||||
|
const rr = r0 + r, cc = c0 + c;
|
||||||
|
if (rr < 0 || cc < 0 || rr >= size || cc >= size) continue;
|
||||||
|
const inner = r >= 0 && r <= 6 && c >= 0 && c <= 6;
|
||||||
|
const dark = inner && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4));
|
||||||
|
set(rr, cc, dark);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
finder(0, 0); finder(0, size - 7); finder(size - 7, 0);
|
||||||
|
for (let i = 8; i < size - 8; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
|
||||||
|
const al = ALIGN[v], last = al.length - 1;
|
||||||
|
al.forEach((r, ri) => al.forEach((c, ci) => {
|
||||||
|
// The three corners that would sit on a finder pattern are omitted.
|
||||||
|
if ((ri === 0 && ci === 0) || (ri === 0 && ci === last) || (ri === last && ci === 0)) return;
|
||||||
|
for (let dr = -2; dr <= 2; dr++) for (let dc = -2; dc <= 2; dc++) {
|
||||||
|
set(r + dr, c + dc, Math.max(Math.abs(dr), Math.abs(dc)) !== 1);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
set(size - 8, 8, 1); // dark module
|
||||||
|
// Reserve format areas (filled per mask below) and version areas.
|
||||||
|
for (let i = 0; i < 9; i++) { fixed[8][i] = 1; fixed[i][8] = 1; }
|
||||||
|
for (let i = 0; i < 8; i++) { fixed[8][size - 1 - i] = 1; fixed[size - 1 - i][8] = 1; }
|
||||||
|
if (v >= 7) {
|
||||||
|
const vb = versionBits(v);
|
||||||
|
for (let i = 0; i < 18; i++) {
|
||||||
|
const bit = (vb >> i) & 1;
|
||||||
|
set(Math.floor(i / 3), size - 11 + (i % 3), bit);
|
||||||
|
set(size - 11 + (i % 3), Math.floor(i / 3), bit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Zigzag data placement.
|
||||||
|
const bits = [];
|
||||||
|
for (const cw of codewords) for (let i = 7; i >= 0; i--) bits.push((cw >> i) & 1);
|
||||||
|
for (let i = 0; i < REMAINDER[v]; i++) bits.push(0);
|
||||||
|
let bi = 0, up = true;
|
||||||
|
for (let col = size - 1; col > 0; col -= 2) {
|
||||||
|
if (col === 6) col--;
|
||||||
|
for (let k = 0; k < size; k++) {
|
||||||
|
const r = up ? size - 1 - k : k;
|
||||||
|
for (const c of [col, col - 1]) {
|
||||||
|
if (fixed[r][c]) continue;
|
||||||
|
grid[r][c] = bi < bits.length ? bits[bi] : 0;
|
||||||
|
bi++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
up = !up;
|
||||||
|
}
|
||||||
|
// Try every mask, keep the lowest penalty.
|
||||||
|
const MASKS = [
|
||||||
|
(i, j) => (i + j) % 2 === 0, (i) => i % 2 === 0, (_i, j) => j % 3 === 0, (i, j) => (i + j) % 3 === 0,
|
||||||
|
(i, j) => (Math.floor(i / 2) + Math.floor(j / 3)) % 2 === 0, (i, j) => ((i * j) % 2) + ((i * j) % 3) === 0,
|
||||||
|
(i, j) => (((i * j) % 2) + ((i * j) % 3)) % 2 === 0, (i, j) => (((i + j) % 2) + ((i * j) % 3)) % 2 === 0,
|
||||||
|
];
|
||||||
|
let best = null;
|
||||||
|
for (let m = 0; m < 8; m++) {
|
||||||
|
const g = grid.map((row) => Uint8Array.from(row));
|
||||||
|
for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) if (!fixed[r][c] && MASKS[m](r, c)) g[r][c] ^= 1;
|
||||||
|
const fb = formatBits(ec, m);
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
const bit = (fb >> i) & 1;
|
||||||
|
// Copy 1: down column 8 (bits 0-7, skipping the timing row), then
|
||||||
|
// left along row 8 (bits 8-14). Copy 2: right end of row 8, bottom
|
||||||
|
// of column 8.
|
||||||
|
if (i < 6) g[i][8] = bit; else if (i === 6) g[7][8] = bit; else if (i === 7) g[8][8] = bit;
|
||||||
|
else if (i === 8) g[8][7] = bit; else g[8][14 - i] = bit;
|
||||||
|
if (i < 8) g[8][size - 1 - i] = bit; else g[size - 15 + i][8] = bit;
|
||||||
|
}
|
||||||
|
const p = penalty(g, size);
|
||||||
|
if (!best || p < best.p) best = { g, p, m };
|
||||||
|
}
|
||||||
|
return { size, version: v, ec, mask: best.m, modules: best.g.map((row) => Array.from(row, (x) => !!x)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function penalty(g, n) {
|
||||||
|
let p = 0;
|
||||||
|
const runs = (get) => {
|
||||||
|
for (let a = 0; a < n; a++) {
|
||||||
|
let run = 1;
|
||||||
|
for (let b = 1; b <= n; b++) {
|
||||||
|
if (b < n && get(a, b) === get(a, b - 1)) run++;
|
||||||
|
else { if (run >= 5) p += 3 + run - 5; run = 1; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
runs((a, b) => g[a][b]); runs((a, b) => g[b][a]);
|
||||||
|
for (let r = 0; r < n - 1; r++) for (let c = 0; c < n - 1; c++) {
|
||||||
|
const s = g[r][c] + g[r][c + 1] + g[r + 1][c] + g[r + 1][c + 1];
|
||||||
|
if (s === 0 || s === 4) p += 3;
|
||||||
|
}
|
||||||
|
const pat = [1, 0, 1, 1, 1, 0, 1];
|
||||||
|
const finderLike = (get, a) => {
|
||||||
|
for (let b = 0; b <= n - 7; b++) {
|
||||||
|
let ok = true;
|
||||||
|
for (let k = 0; k < 7; k++) if (get(a, b + k) !== pat[k]) { ok = false; break; }
|
||||||
|
if (!ok) continue;
|
||||||
|
const before = b >= 4 && [0, 1, 2, 3].every((k) => get(a, b - 1 - k) === 0);
|
||||||
|
const after = b + 10 < n && [0, 1, 2, 3].every((k) => get(a, b + 7 + k) === 0);
|
||||||
|
if (before || after) p += 40;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (let a = 0; a < n; a++) { finderLike((x, y) => g[x][y], a); finderLike((x, y) => g[y][x], a); }
|
||||||
|
let dark = 0;
|
||||||
|
for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) dark += g[r][c];
|
||||||
|
const pct = (dark * 100) / (n * n);
|
||||||
|
p += Math.floor(Math.abs(pct - 50) / 5) * 10;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = { build };
|
||||||
|
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||||
|
else root.QR = api;
|
||||||
|
})(typeof globalThis !== "undefined" ? globalThis : this);
|
||||||
27
bundled-addons/siawallet/wallet-inject.js
Normal file
27
bundled-addons/siawallet/wallet-inject.js
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
// Dapp bridge for Sia, run in the isolated world of pages matching
|
||||||
|
// addon.json "page-inject".origins. Exposes window.siacoin; every call goes
|
||||||
|
// through the wallet's activate() context in main, which shows the approval
|
||||||
|
// overlay and enforces per-origin permissions.
|
||||||
|
//
|
||||||
|
// `theseus` is provided by the host: { id, origin, contextBridge, invoke }.
|
||||||
|
const call = (msg, payload) =>
|
||||||
|
theseus.invoke(msg, payload).catch((e) => {
|
||||||
|
const text = String(e && e.message || e).replace(/^Error invoking remote method '[^']+': (Error: )?/, "");
|
||||||
|
throw new Error(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
theseus.contextBridge.exposeInMainWorld("siacoin", {
|
||||||
|
isTheseus: true,
|
||||||
|
version: "0.1.0",
|
||||||
|
network: "mainnet",
|
||||||
|
// Current receiving address (76-hex). First call per origin asks the user;
|
||||||
|
// "always allow" makes later calls silent.
|
||||||
|
getAddress: () => call("getAddress"),
|
||||||
|
// txSpec: { to, amount } or { outputs: [{ to, amount }] } — amounts are
|
||||||
|
// hastings as decimal strings (1 SC = 1e24). Always approval-gated;
|
||||||
|
// resolves { txid }.
|
||||||
|
signAndSend: (txSpec) => call("signAndSend", txSpec && typeof txSpec === "object" ? txSpec : {}),
|
||||||
|
// ed25519 signature over blake2b-256(message) with the current address's
|
||||||
|
// key. Always approval-gated; resolves { address, publicKey, signature }.
|
||||||
|
signMessage: (message) => call("signMessage", { message: String(message ?? "") }),
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue