theseus/bundled-addons/bchwallet/index.js
Local Dev c9106d4ede fix(theseus/light): darker acid (#3a5c00) + add missing overrides in addon panels
Previous #4d7300 (0.3.10) was still too light against actual white
backgrounds — several tint fills (rgba(214,255,61,X)) and unpatched
addon panels were making the effective color feel bright green. Two
fixes bundled:

1) Bump --acid in every top-level page's light-media block from
   #4d7300 to #3a5c00 — same hue, ~7:1 contrast on #ffffff (was ~5.5:1).
2) Add the missing light-media --acid override to the addon panels
   that were still resolving to #d6ff3d: bchwallet/panel.html,
   siawallet/panel.html, and screenshot/editor.css (was #b4e024, now
   #3a5c00 to match).

Dark mode unchanged. Tint fills (rgba backgrounds at low alpha) still
stay as-is — at 8–15% opacity the specific hue barely matters and the
darker foreground now dominates.
2026-09-07 00:56:33 +02:00

791 lines
34 KiB
JavaScript

// Aegis — multi-chain wallet bundled with Theseus. activate() runs in the
// main process; every wallet's key material lives here, in memory, and is
// re-derived from the password vault on every launch. Nothing secret is ever
// written to disk or logged.
//
// A single addon can hold many wallets — one per {chain, network}, or several
// sub-accounts of the same chain — and each wallet is backed by its own
// vault-derived 32-byte HKDF root. Chains today: BCH, Tron mainnet, Tron
// Nile testnet. Adding a fourth chain is a new adapter file under lib/ and
// an entry in the CHAIN_REGISTRY below.
//
// Back-compat: the addon id stays "bchwallet" (the manifest label became
// Aegis, but the id gates the vault-derive namespace and older vaults have
// funds against it). The legacy BCH default wallet uses purpose
// "bchwallet/mainnet/0" — byte-identical to the pre-multi-wallet build —
// so on-disk funds are untouched. See memory bchwallet-vault-root-derivation.
const path = require("node:path");
const fs = require("node:fs");
const LEGACY_BCH_PURPOSE = "bchwallet/mainnet/0";
const LEGACY_BCH_WALLET_ID = "bch-default";
let ctx = null;
// ---- deps -------------------------------------------------------------------
async function loadDeps(api) {
const { secp256k1 } = await api.import("@noble/curves/secp256k1.js");
const { sha256 } = await api.import("@noble/hashes/sha2.js");
const { ripemd160 } = await api.import("@noble/hashes/legacy.js");
const { keccak_256 } = await api.import("@noble/hashes/sha3.js");
const { HDKey } = await api.import("@scure/bip32");
const WebSocket = api.require("ws");
const cashaddr = require("./lib/cashaddr.js");
const keysLib = require("./lib/keys.js")({ HDKey, secp256k1, sha256, ripemd160, cashaddr });
const tx = require("./lib/tx.js")({ sha256 });
const electrum = require("./lib/electrum.js")({ WebSocket, log: (...a) => api.log("electrum", ...a) });
const base58check = require("./lib/base58check.js")({ sha256 });
const bchAdapter = require("./lib/chain-bch.js")({
HDKey, secp256k1, sha256, ripemd160, cashaddr, keysLib, tx, electrum, WebSocket,
});
const tronAdapter = require("./lib/chain-tron.js")({
HDKey, secp256k1, sha256, keccak_256, base58check,
});
return { HDKey, secp256k1, sha256, ripemd160, keccak_256, cashaddr, keysLib, tx, electrum, base58check, bchAdapter, tronAdapter };
}
// ---- servers ---------------------------------------------------------------
function bchDefaultServers(api) {
try { return JSON.parse(fs.readFileSync(path.join(api.folder, "electrum-servers.json"), "utf8")); }
catch { return []; }
}
function bchServerList(api) {
const custom = api.storage.get("servers", null);
return Array.isArray(custom) && custom.length ? custom : bchDefaultServers(api);
}
// ---- chain registry --------------------------------------------------------
// Chain-side facts that don't depend on state. Adding a chain = extending this
// map + writing an adapter that constructs a wallet from a 32-byte root.
const CHAIN_REGISTRY = {
"bch:mainnet": {
chain: "bch", network: "mainnet",
label: "Bitcoin Cash", short: "BCH", ticker: "BCH", decimals: 8,
badge: "🟨", color: "#0ac18e",
purposePrefix: "bchwallet/bch/", // legacy wallet uses the flat "bchwallet/mainnet/0" instead
startIndex: 1, // 0 is reserved for the legacy wallet
supportsMessageSign: true,
supportsPageInject: true, // window.bitcoincash on *.x pages
},
"trx:mainnet": {
chain: "trx", network: "mainnet",
label: "Tron", short: "TRX", ticker: "TRX", decimals: 6,
badge: "🔴", color: "#ff060a",
purposePrefix: "bchwallet/trx/mainnet/",
startIndex: 0,
supportsMessageSign: true,
supportsPageInject: true, // window.tronWeb everywhere
},
"trx:nile": {
chain: "trx", network: "nile",
label: "Tron Nile testnet", short: "TRX (Nile)", ticker: "TRX", decimals: 6,
badge: "🔵", color: "#4d9dff",
purposePrefix: "bchwallet/trx/nile/",
startIndex: 0,
supportsMessageSign: true,
supportsPageInject: true,
},
};
function chainKey(chain, network) { return `${chain}:${network}`; }
function chainMeta(chain, network) { return CHAIN_REGISTRY[chainKey(chain, network)] || null; }
// ---- wallet list ------------------------------------------------------------
function readWallets(api) {
const raw = api.storage.get("wallets", null);
return Array.isArray(raw) ? raw : null;
}
function writeWallets(api, list) { api.storage.set("wallets", list); }
// Bring pre-multi-wallet storage forward: create the legacy BCH default entry
// and rehome its receiveCursor / txCache under the new per-wallet subkey.
function migrateLegacyStorage(api) {
if (readWallets(api)) return; // already multi-wallet
const legacyAccountPath = String(api.storage.get("accountPath", "") || "").trim() || "m/44'/145'/0'";
const wallets = [{
id: LEGACY_BCH_WALLET_ID,
label: "BCH — main",
chain: "bch",
network: "mainnet",
purpose: LEGACY_BCH_PURPOSE,
accountPath: legacyAccountPath,
isDefault: true,
isLegacy: true,
createdAt: 0,
}];
writeWallets(api, wallets);
api.storage.set("selectedWalletId", LEGACY_BCH_WALLET_ID);
// Move per-wallet state under the scoped prefix used by chain-bch.js.
const prefix = `wallets/${LEGACY_BCH_WALLET_ID}/`;
for (const legacyKey of ["receiveCursor", "txCache"]) {
const v = api.storage.get(legacyKey, null);
if (v !== null && api.storage.get(prefix + legacyKey, null) === null) {
api.storage.set(prefix + legacyKey, v);
}
}
api.log("migrated legacy BCH wallet into multi-wallet layout");
}
function nextIndex(wallets, meta) {
let max = meta.startIndex - 1;
for (const w of wallets) {
if (chainKey(w.chain, w.network) !== chainKey(meta.chain, meta.network)) continue;
if (w.isLegacy) continue;
const m = /\/(\d+)$/.exec(w.purpose || "");
const n = m ? Number(m[1]) : NaN;
if (Number.isFinite(n) && n > max) max = n;
}
return max + 1;
}
function makeWalletId(meta, index) {
const n = String(meta.network).replace(/[^a-z0-9]/gi, "");
return `${meta.chain}-${n}-${index}`;
}
function autoLabel(meta, wallets) {
const same = wallets.filter((w) => chainKey(w.chain, w.network) === chainKey(meta.chain, meta.network));
if (!same.length) return meta.short;
return `${meta.short} #${same.length + 1}`;
}
// ---- runtime wallet map -----------------------------------------------------
// A "runtime" is a mounted wallet: its adapter instance plus phase + error.
// activate() derives all of them in parallel once the vault unlocks.
async function mountAllWallets() {
const c = ctx;
const walletList = readWallets(c.api) || [];
for (const w of walletList) {
if (!c.runtimes.has(w.id)) c.runtimes.set(w.id, { entry: w, phase: "locked", error: null, adapter: null });
}
emitState();
await Promise.all(walletList.map((w) => mountWallet(w)));
}
async function mountWallet(entry) {
const c = ctx;
const rt = c.runtimes.get(entry.id) || { entry, phase: "locked", error: null, adapter: null };
rt.entry = entry;
rt.phase = "locked"; rt.error = null;
c.runtimes.set(entry.id, rt);
emitState();
let root;
try {
root = await c.api.vault.derive(entry.purpose);
} catch (e) {
const msg = e?.message || String(e);
rt.phase = /not set up/i.test(msg) ? "nosetup" : "error";
rt.error = msg;
emitState();
return;
}
if (ctx !== c) return;
try {
let adapter;
if (entry.chain === "bch") {
adapter = new c.d.bchAdapter.BchWallet(root, {
walletId: entry.id,
storage: c.api.storage,
log: (...a) => c.api.log(`[${entry.id}]`, ...a),
onChange: () => emitStateForWallet(entry.id),
servers: bchServerList(c.api),
accountPath: entry.accountPath,
});
} else if (entry.chain === "trx") {
adapter = new c.d.tronAdapter.TronWallet(root, entry.network, {
storage: c.api.storage,
log: (...a) => c.api.log(`[${entry.id}]`, ...a),
onChange: () => emitStateForWallet(entry.id),
});
adapter.schedulePoll(20_000);
} else {
throw new Error(`unknown chain ${entry.chain}`);
}
rt.adapter = adapter;
rt.phase = "ready";
// Kick a first fetch. Errors here don't fail the mount — the panel shows
// them per-wallet via snapshot.error.
adapter.refresh(true).catch((e) => c.api.log(`[${entry.id}] initial refresh:`, e?.message || e));
emitStateForWallet(entry.id);
} catch (e) {
rt.phase = "error";
rt.error = e?.message || String(e);
emitState();
} finally {
// Wipe the root buffer — the adapter has already turned it into keys.
if (root) try { root.fill(0); } catch {}
}
}
function unmountWallet(walletId) {
const rt = ctx.runtimes.get(walletId);
if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} }
ctx.runtimes.delete(walletId);
}
// ---- state / snapshot -------------------------------------------------------
function selectedWalletId() {
const list = readWallets(ctx.api) || [];
if (!list.length) return null;
const saved = String(ctx.api.storage.get("selectedWalletId", "") || "");
if (saved && list.some((w) => w.id === saved)) return saved;
const dflt = list.find((w) => w.isDefault) || list[0];
return dflt.id;
}
function walletEntries() { return readWallets(ctx.api) || []; }
function overallPhase() {
// If ANY wallet is nosetup, treat the whole addon as nosetup — the user
// hasn't unlocked / set up the vault, so no wallet can work.
const runtimes = [...ctx.runtimes.values()];
if (!runtimes.length) return "locked";
if (runtimes.some((r) => r.phase === "nosetup")) return "nosetup";
if (runtimes.some((r) => r.phase === "locked")) return "locked";
return "ready";
}
function walletSummary(w) {
const meta = chainMeta(w.chain, w.network);
const rt = ctx.runtimes.get(w.id);
const snap = rt && rt.adapter ? rt.adapter.snapshot() : null;
return {
id: w.id, label: w.label, chain: w.chain, network: w.network, isDefault: !!w.isDefault, isLegacy: !!w.isLegacy,
badge: meta?.badge || "🧩", color: meta?.color || "#888", ticker: meta?.ticker || "?",
short: meta?.short || w.chain, decimals: meta?.decimals || 8,
address: snap?.address || null,
balance: snap?.balance || { confirmed: 0, unconfirmed: 0 },
phase: rt?.phase || "locked",
error: rt?.error || null,
};
}
function snapshotForSelected() {
const id = selectedWalletId();
if (!id) return { phase: "empty" };
const rt = ctx.runtimes.get(id);
const entry = walletEntries().find((w) => w.id === id);
const meta = entry ? chainMeta(entry.chain, entry.network) : null;
const base = {
walletId: id,
label: entry?.label,
chain: entry?.chain,
network: entry?.network,
isLegacy: !!entry?.isLegacy,
meta: meta ? { badge: meta.badge, color: meta.color, short: meta.short, ticker: meta.ticker, decimals: meta.decimals } : null,
supportsMessageSign: !!meta?.supportsMessageSign,
phase: rt?.phase || "locked",
error: rt?.error || null,
};
if (rt && rt.adapter) Object.assign(base, rt.adapter.snapshot());
return base;
}
function fullState() {
return {
overallPhase: overallPhase(),
selectedWalletId: selectedWalletId(),
wallets: walletEntries().map(walletSummary),
selected: snapshotForSelected(),
bchServers: {
list: bchServerList(ctx.api),
custom: Array.isArray(ctx.api.storage.get("servers", null)),
},
chains: Object.entries(CHAIN_REGISTRY).map(([k, m]) => ({
key: k, chain: m.chain, network: m.network, label: m.label, short: m.short, ticker: m.ticker, badge: m.badge, decimals: m.decimals,
})),
};
}
function emitState() { try { ctx.api.emit("state", fullState()); } catch {} }
function emitStateForWallet(id) {
// Any wallet change fans out to the panel with the full state so the
// wallet list balances update in the header too.
if (!ctx || !ctx.runtimes.has(id)) return;
emitState();
}
// ---- panel messages ---------------------------------------------------------
function requireWallet(id) {
const rt = ctx.runtimes.get(id);
if (!rt || rt.phase !== "ready" || !rt.adapter) throw new Error("wallet is not ready (vault locked?)");
return rt;
}
function requireSelected() { return requireWallet(selectedWalletId()); }
function fromPanel(m) { if (!m || m.from !== "panel") throw new Error("panel-only message"); }
function fromPage(m) {
if (!m || m.from !== "page" || !m.origin) throw new Error("page-only message");
return m.origin;
}
const fmtBch = (sats) => (Number(sats) / 1e8).toFixed(8).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1");
const fmtTrx = (sun) => (Number(sun) / 1e6).toFixed(6).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1");
function fmtValue(units, decimals) {
const n = Number(units) / Math.pow(10, decimals);
return n.toFixed(decimals).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1");
}
function registerPanelMessages(api) {
api.onMessage("state", (_p, m) => { fromPanel(m); return fullState(); });
api.onMessage("selectWallet", (p, m) => {
fromPanel(m);
const id = String(p && p.id || "");
if (!walletEntries().some((w) => w.id === id)) throw new Error("unknown wallet");
api.storage.set("selectedWalletId", id);
emitState();
return fullState();
});
api.onMessage("addWallet", async (p, m) => {
fromPanel(m);
const chain = String(p && p.chain || "");
const network = String(p && p.network || "");
const meta = chainMeta(chain, network);
if (!meta) throw new Error("unknown chain/network");
const list = walletEntries().slice();
const index = nextIndex(list, meta);
const purpose = meta.purposePrefix + index;
const id = makeWalletId(meta, index);
if (list.some((w) => w.id === id || w.purpose === purpose)) throw new Error("duplicate wallet");
const label = String(p && p.label || "").trim() || autoLabel(meta, list);
const entry = { id, label, chain, network, purpose, createdAt: Date.now() };
list.push(entry);
writeWallets(api, list);
api.storage.set("selectedWalletId", id);
ctx.runtimes.set(id, { entry, phase: "locked", error: null, adapter: null });
emitState();
await mountWallet(entry);
return fullState();
});
api.onMessage("removeWallet", (p, m) => {
fromPanel(m);
const id = String(p && p.id || "");
const list = walletEntries();
const entry = list.find((w) => w.id === id);
if (!entry) throw new Error("unknown wallet");
if (entry.isDefault) throw new Error("the default wallet cannot be removed");
const next = list.filter((w) => w.id !== id);
writeWallets(api, next);
if (selectedWalletId() === id) api.storage.set("selectedWalletId", next[0]?.id || "");
unmountWallet(id);
// Drop per-wallet storage subtree.
const all = api.storage.all ? api.storage.all() : {};
const prefix = `wallets/${id}/`;
for (const k of Object.keys(all)) if (k.startsWith(prefix)) api.storage.set(k, null);
emitState();
return fullState();
});
api.onMessage("renameWallet", (p, m) => {
fromPanel(m);
const id = String(p && p.id || "");
const label = String(p && p.label || "").trim().slice(0, 60);
if (!label) throw new Error("label required");
const list = walletEntries();
const entry = list.find((w) => w.id === id);
if (!entry) throw new Error("unknown wallet");
entry.label = label;
writeWallets(api, list);
emitState();
return fullState();
});
api.onMessage("refresh", async (_p, m) => { fromPanel(m); const rt = requireSelected(); await rt.adapter.refresh(true); return snapshotForSelected(); });
api.onMessage("nextAddress", (_p, m) => {
fromPanel(m);
const rt = requireSelected();
if (rt.entry.chain !== "bch") throw new Error("only BCH wallets have multiple receive addresses");
rt.adapter.nextAddress();
return snapshotForSelected();
});
api.onMessage("openUrl", (p, m) => { fromPanel(m); api.openTab(String(p && p.url || "")); return true; });
api.onMessage("setBchServers", (p, m) => {
fromPanel(m);
const patch = p || {};
if ("servers" in patch) {
const list = Array.isArray(patch.servers) ? patch.servers.map((s) => String(s).trim()).filter(Boolean) : [];
for (const s of list) if (!/^wss?:\/\/[^/\s]+$/i.test(s)) throw new Error(`server must be ws(s)://host:port — got ${s}`);
api.storage.set("servers", list.length ? list : null);
// Push the new server list into every mounted BCH wallet.
for (const rt of ctx.runtimes.values()) {
if (rt.entry.chain === "bch" && rt.adapter) rt.adapter.setServers(bchServerList(api));
}
}
return fullState();
});
api.onMessage("setAccountPath", (p, m) => {
fromPanel(m);
const patch = p || {};
const id = String(patch.id || selectedWalletId());
const entry = walletEntries().find((w) => w.id === id);
if (!entry) throw new Error("unknown wallet");
if (entry.chain !== "bch") throw new Error("account path is a BCH-only setting");
const v = String(patch.accountPath || "").trim();
if (v && !/^m(\/\d+'?)+$/.test(v)) throw new Error("derivation path must look like m/44'/145'/0'");
const list = walletEntries();
const idx = list.findIndex((w) => w.id === id);
list[idx] = { ...list[idx], accountPath: v || "m/44'/145'/0'" };
writeWallets(api, list);
// Rebuild the wallet's adapter with the new account path.
const rt = ctx.runtimes.get(id);
if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} rt.adapter = null; rt.phase = "locked"; }
mountWallet(list[idx]);
return fullState();
});
// Live plan preview for the selected wallet.
api.onMessage("planSend", async (p, m) => {
fromPanel(m);
const rt = requireSelected();
const plan = await Promise.resolve(rt.adapter.plan(p || {}));
return describePlan(plan, rt.entry.chain, rt.entry.network);
});
// Execute a send with approval overlay.
api.onMessage("send", async (p, m) => {
fromPanel(m);
const rt = requireSelected();
const plan = await Promise.resolve(rt.adapter.plan(p || {}));
const d = describePlan(plan, rt.entry.chain, rt.entry.network);
const meta = chainMeta(rt.entry.chain, rt.entry.network);
const rows = [
{ label: "To", value: d.recipients[0].to, mono: true },
{ label: "Amount", value: `${fmtValue(d.recipients[0].value, meta.decimals)} ${meta.ticker}`, strong: true },
{ label: "Fee", value: rt.entry.chain === "bch" ? `${plan.fee} sat (${plan.feeRate} sat/B)` : `${fmtValue(plan.fee, meta.decimals)} ${meta.ticker}` },
{ label: "Total", value: `${fmtValue(d.total, meta.decimals)} ${meta.ticker}` },
{ label: "Wallet", value: `${meta.badge} ${rt.entry.label}` },
];
const pick = await api.approvalModal({
title: `Send ${meta.ticker}?`,
origin: "Aegis wallet panel",
rows,
actions: [{ id: "send", label: "Send", primary: true }],
});
if (pick !== "send") throw new Error("cancelled");
return rt.adapter.signAndBroadcast(plan);
});
api.onMessage("recovery", async (p, m) => {
fromPanel(m);
const id = String(p && p.id || selectedWalletId());
const rt = requireWallet(id);
if (rt.entry.chain !== "bch") throw new Error("recovery details are only exposed for BCH wallets in this build");
const r = rt.adapter.recovery();
const out = { accountPath: r.accountPath, xpub: r.xpub, purpose: rt.entry.purpose };
if (p && p.reveal) {
const pick = await api.approvalModal({
title: "Reveal the account private key?",
origin: "Aegis wallet panel",
body: "Anyone holding this key 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.xprv = r.xprv;
}
return out;
});
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;
});
}
// One "describePlan" is enough for both chains because plan() returns a
// common shape: {recipients:[{to,value}], fee, feeRate, total, inputs:[]…}.
function describePlan(plan) {
const sent = plan.recipients.reduce((a, r) => a + r.value, 0);
return {
recipients: plan.recipients, fee: plan.fee, feeRate: plan.feeRate,
inputs: (plan.inputs || []).length, change: plan.change || null,
total: plan.total != null ? plan.total : (sent + plan.fee),
};
}
// ---- dapp bridges (page → activate()) --------------------------------------
// Permission model stays the same as the single-wallet build for BCH:
// { [origin]: { readAddress:true, sendTx:{capSats,usedSats,grantedAt},
// trx: { readAddress:true, network } } }
// The BCH bridge always talks to the LEGACY default BCH wallet (the .x pages
// pre-date multi-wallet and cannot pick between them). The Tron bridge talks
// to the currently-SELECTED Tron wallet; if none is selected, requests fail.
const BCH_ALLOWANCES = [100000, 1000000, 10000000]; // 0.001, 0.01, 0.1 BCH
const pendingByOrigin = new Set();
function permissions(api) { const p = api.storage.get("permissions", {}); return p && typeof p === "object" ? p : {}; }
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); }
}
// Only .x sites (BCNR-native TLD) get the BCH bridge, matching the pre-
// multi-wallet gate. Widening the manifest to https://*/* makes the Tron
// bridge available everywhere; the BCH side enforces its narrower rule
// inside the handlers.
function isBchOrigin(origin) {
try { const h = new URL(origin).hostname; return /\.x$/.test(h); }
catch { return false; }
}
function legacyBchRuntime() {
const rt = ctx.runtimes.get(LEGACY_BCH_WALLET_ID);
if (!rt || rt.phase !== "ready" || !rt.adapter) throw new Error("wallet is not ready (vault locked?)");
return rt;
}
// The Tron bridge routes to the currently-selected wallet if it is Tron;
// otherwise it looks for the first ready Tron wallet on the selected network
// hint; else rejects with "no tron wallet".
function activeTronRuntime() {
const selId = selectedWalletId();
const selRt = selId && ctx.runtimes.get(selId);
if (selRt && selRt.entry.chain === "trx" && selRt.phase === "ready") return selRt;
for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "trx" && rt.phase === "ready") return rt;
throw new Error("no Tron wallet available — add one in the Aegis sidebar");
}
function registerPageMessages(api) {
// ---- BCH bridge (unchanged behavior; wallet source is legacy default) ----
api.onMessage("getAddress", async (_p, m) => {
const origin = fromPage(m);
if (!isBchOrigin(origin)) throw new Error("this site is not on a Bitcoin Cash origin");
const rt = legacyBchRuntime();
const perms = permissions(api);
if (perms[origin] && perms[origin].readAddress) return rt.adapter.current().address;
return withOriginLock(origin, async () => {
const pick = await api.approvalModal({
title: "Share your Bitcoin Cash 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: rt.adapter.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 rt.adapter.current().address;
});
});
api.onMessage("signAndSend", async (p, m) => {
const origin = fromPage(m);
if (!isBchOrigin(origin)) throw new Error("this site is not on a Bitcoin Cash origin");
const rt = legacyBchRuntime();
return withOriginLock(origin, async () => {
let plan;
try { plan = rt.adapter.plan(p || {}); }
catch (e) { throw new Error(/insufficient funds|too small/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 ? Math.max(0, (budget.capSats | 0) - (budget.usedSats | 0)) : 0;
if (budget && d.total <= remaining) {
const r = await rt.adapter.signAndBroadcast(plan);
budget.usedSats = (budget.usedSats | 0) + d.total;
api.storage.set("permissions", perms);
emitState();
api.log(`silent send ${d.total} sat for ${origin}, ${remaining - d.total} sat 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: fmtBch(d.recipients.reduce((a, r) => a + r.value, 0)) + " BCH", strong: true });
rows.push({ label: "Fee", value: `${plan.fee} sat (${plan.feeRate} sat/B)` });
rows.push({ label: "Total", value: fmtBch(d.total) + " BCH" });
const pick = await api.approvalModal({
title: "Send Bitcoin Cash?",
origin,
body: budget
? `This payment is over what is left of the site's allowance (${fmtBch(remaining)} BCH). 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" }, ...BCH_ALLOWANCES.map((s) => ({ value: String(s), label: `allow up to ${fmtBch(s)} BCH 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 capSats = cap ? Number(cap.slice(4)) : 0;
if (BCH_ALLOWANCES.includes(capSats)) {
perms[origin] = { ...(perms[origin] || {}), sendTx: { capSats, usedSats: 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 rt.adapter.signAndBroadcast(plan);
return { txid: r.txid };
});
});
api.onMessage("signMessage", async (p, m) => {
const origin = fromPage(m);
if (!isBchOrigin(origin)) throw new Error("this site is not on a Bitcoin Cash origin");
const rt = legacyBchRuntime();
const message = String(p && p.message != null ? p.message : "");
if (message.length > 4096) throw new Error("message too long");
return withOriginLock(origin, async () => {
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: rt.adapter.current().address, mono: true },
],
actions: [{ id: "sign", label: "Sign", primary: true }],
});
if (pick !== "sign") throw new Error("user rejected");
return rt.adapter.signMessage(message);
});
});
// ---- Tron bridge (tronWeb / tronLink) -----------------------------------
api.onMessage("trx.requestAccounts", async (_p, m) => {
const origin = fromPage(m);
const rt = activeTronRuntime();
const perms = permissions(api);
const alreadyOK = perms[origin] && perms[origin].trx && perms[origin].trx.readAddress;
const snap = rt.adapter.snapshot();
if (alreadyOK) return { code: 200, address: snap.address, network: snap.network };
return withOriginLock(origin, async () => {
const pick = await api.approvalModal({
title: "Connect this site to your Tron wallet?",
origin,
body: "The site will see this address and can build transactions for you to sign.",
rows: [
{ label: "Address", value: snap.address, mono: true },
{ label: "Network", value: snap.network === "nile" ? "Nile testnet" : "Tron mainnet" },
{ label: "Wallet", value: `🔴 ${rt.entry.label}` },
],
actions: [{ id: "allow", label: "Connect", primary: true }],
checkbox: { id: "always", label: "Always allow this site to see this address" },
});
if (!pick.startsWith("allow")) throw new Error("user rejected");
if (pick === "allow+always") {
perms[origin] = { ...(perms[origin] || {}), trx: { readAddress: true, network: snap.network } };
api.storage.set("permissions", perms);
emitState();
}
return { code: 200, address: snap.address, network: snap.network };
});
});
api.onMessage("trx.getAccount", (_p, m) => {
const origin = fromPage(m);
const perms = permissions(api);
if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first");
const rt = activeTronRuntime();
const snap = rt.adapter.snapshot();
return { address: snap.address, network: snap.network };
});
// Sign an arbitrary raw_data_hex the dapp built (with its own tronWeb).
// The wallet never guesses the intent — the approval overlay shows the
// decoded contract type and destination when it can, and always the txID.
api.onMessage("trx.signTransaction", async (p, m) => {
const origin = fromPage(m);
const rt = activeTronRuntime();
const perms = permissions(api);
if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first");
const tx = p && p.transaction;
if (!tx || typeof tx !== "object" || !tx.raw_data_hex || !tx.raw_data) throw new Error("bad transaction");
return withOriginLock(origin, async () => {
const contract = (tx.raw_data.contract || [])[0];
const type = contract?.type || "Contract";
const rows = [{ label: "Type", value: type }, { label: "Tx ID", value: tx.txID || "(unset)", mono: true }];
if (type === "TransferContract") {
const v = contract.parameter?.value || {};
try {
const to = v.to_address ? ctx.d.tronAdapter.hexToAddress(v.to_address) : (v.to_address || "");
const amount = Number(v.amount || 0);
rows.splice(1, 0, { label: "To", value: to, mono: true }, { label: "Amount", value: `${fmtTrx(amount)} TRX`, strong: true });
} catch {}
}
rows.push({ label: "Wallet", value: `🔴 ${rt.entry.label} (${rt.adapter.snapshot().network})` });
const pick = await api.approvalModal({
title: "Sign a Tron transaction?",
origin,
body: "The site built this transaction. Check the type, amount, and destination before signing.",
rows,
actions: [{ id: "sign", label: "Sign", primary: true }],
});
if (pick !== "sign") throw new Error("user rejected");
const sig = rt.adapter.signRawData(tx.raw_data_hex);
const signed = { ...tx, signature: [sig] };
return signed;
});
});
api.onMessage("trx.sendRawTransaction", async (p, m) => {
const origin = fromPage(m);
const rt = activeTronRuntime();
const perms = permissions(api);
if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first");
const signedTx = p && p.transaction;
if (!signedTx || !signedTx.raw_data_hex || !Array.isArray(signedTx.signature)) throw new Error("bad signed tx");
// No approval here — broadcasting a *signed* tx does not add any risk
// the sign step didn't already carry. Sites that don't want an extra
// network round-trip pass {broadcast:true} to sign; we support both.
return rt.adapter.broadcastSignedTx(signedTx);
});
api.onMessage("trx.signMessageV2", async (p, m) => {
const origin = fromPage(m);
const rt = activeTronRuntime();
const perms = permissions(api);
if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first");
const message = String(p && p.message != null ? p.message : "");
if (message.length > 4096) throw new Error("message too long");
return withOriginLock(origin, async () => {
const snap = rt.adapter.snapshot();
const pick = await api.approvalModal({
title: "Sign a Tron message?",
origin,
body: "Signing proves you control this address. It moves no coins.",
rows: [
{ label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true },
{ label: "Address", value: snap.address, mono: true },
],
actions: [{ id: "sign", label: "Sign", primary: true }],
});
if (pick !== "sign") throw new Error("user rejected");
return rt.adapter.signMessageV2(message);
});
});
}
// ---- activate ---------------------------------------------------------------
module.exports = {
activate(api) {
api.registerSidebarPanel({ id: "main", title: "Wallet", icon: "🛡", page: "panel.html" });
const c = ctx = {
api,
d: null,
runtimes: new Map(), // walletId -> { entry, phase, error, adapter }
};
migrateLegacyStorage(api);
registerPanelMessages(api);
registerPageMessages(api);
loadDeps(api).then((d) => {
if (ctx !== c) return;
c.d = d;
return mountAllWallets();
}).catch((e) => {
if (ctx !== c) return;
api.log("startup failed:", e?.message);
emitState();
});
},
deactivate() {
const c = ctx; ctx = null;
if (!c) return;
for (const rt of c.runtimes.values()) {
try { rt.adapter && rt.adapter.dispose(); } catch {}
}
c.runtimes.clear();
},
};