// 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 { ed25519 } = await api.import("@noble/curves/ed25519.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 { blake2b } = await api.import("@noble/hashes/blake2.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, }); const siaAdapter = require("./lib/chain-sia.js")({ ed25519, blake2b }); const ethAdapter = require("./lib/chain-eth.js")({ HDKey, secp256k1, keccak_256 }); const eip712 = require("./lib/eip712.js")({ keccak_256 }); // Solana uses the raw base58 alphabet (no checksum), which lives inside // base58check as encodeBase58 / decodeBase58 — expose them under a // `{encode, decode}` shape the SOL adapter reads from. const solBase58 = { encode: base58check.encodeBase58, decode: base58check.decodeBase58 }; const solAdapter = require("./lib/chain-sol.js")({ ed25519, base58: solBase58, sha256 }); // DGB delegates address derivation + PSBT to the vendored @dgb-wallet/* // packages under lib/dgb/. Those are ESM; the peer deps (bitcoinjs-lib, // bip32, ecpair, @bitcoinerlab/secp256k1) are CommonJS and reachable via // api.require from the Theseus dependency tree. const { pathToFileURL } = require("node:url"); const dgbCore = await import(pathToFileURL(path.join(api.folder, "lib/dgb/core/index.js")).href); const dgbPsbt = await import(pathToFileURL(path.join(api.folder, "lib/dgb/psbt/index.js")).href); const bitcoinjs = api.require("bitcoinjs-lib"); const { BIP32Factory } = api.require("bip32"); const { ECPairFactory } = api.require("ecpair"); const ecc = api.require("@bitcoinerlab/secp256k1"); const dgbAdapter = require("./lib/chain-dgb.js")({ dgbCore, dgbPsbt, bitcoinjs, bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, ecc, sha256, electrum, }); const btcAdapter = require("./lib/chain-btc.js")({ bitcoinjs, bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, ecc, sha256, electrum, }); return { HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256, blake2b, cashaddr, keysLib, tx, electrum, base58check, bchAdapter, tronAdapter, siaAdapter, dgbAdapter, ethAdapter, solAdapter, btcAdapter, dgbCore, dgbPsbt, bitcoinjs, ecc, eip712 }; } // ---- 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 -------------------------------------------------------- // Two-level structure so the panel can present coin-then-network as separate // picks. `coin` fields are chain-wide; `networks[]` fields override or // add to them per-network. `logo` is the SVG key panel.js draws from. const COINS = { bch: { chain: "bch", label: "Bitcoin Cash", short: "BCH", ticker: "BCH", decimals: 8, color: "#0ac18e", logo: "bch", supportsMessageSign: true, supportsPageInject: true, // window.bitcoincash on *.x pages networks: { mainnet: { id: "mainnet", label: "Mainnet", testnet: false, // Legacy default wallet uses the flat "bchwallet/mainnet/0" purpose; // new BCH mainnet sub-accounts start at index 1 under the /bch/ prefix. purposePrefix: "bchwallet/bch/", startIndex: 1, }, chipnet: { id: "chipnet", label: "Chipnet testnet", testnet: true, purposePrefix: "bchwallet/bch/chipnet/", startIndex: 0, }, }, }, trx: { chain: "trx", label: "Tron", short: "TRX", ticker: "TRX", decimals: 6, color: "#ff060a", logo: "trx", supportsMessageSign: true, supportsPageInject: true, // window.tronWeb everywhere networks: { mainnet: { id: "mainnet", label: "Mainnet", testnet: false, purposePrefix: "bchwallet/trx/mainnet/", startIndex: 0, }, nile: { id: "nile", label: "Nile testnet", testnet: true, purposePrefix: "bchwallet/trx/nile/", startIndex: 0, }, }, }, sc: { chain: "sc", label: "Siacoin", short: "SC", ticker: "SC", decimals: 24, color: "#20be82", logo: "sc", supportsMessageSign: true, supportsPageInject: false, // First SC wallet in Aegis reuses the legacy standalone-siawallet purpose // so pre-Aegis funds carry over automatically (addon.json declares // `absorbs: ["siawallet"]` to allow the derivation). Second+ use the new // Aegis-namespaced prefix. networks: { mainnet: { id: "mainnet", label: "Mainnet", testnet: false, purposePrefix: "bchwallet/sc/mainnet/", startIndex: 1, legacyFirstPurpose: "siawallet/mainnet/0", }, }, }, dgb: { chain: "dgb", label: "DigiByte", short: "DGB", ticker: "DGB", decimals: 8, color: "#0066cc", logo: "dgb", supportsMessageSign: true, supportsPageInject: false, // BIP44/49/84/86 address families — the picker lives in the DGB // settings block. Default is BIP84 (dgb1q…), which matches modern // DGB Core, DigiByte-Go, and the SilentCode web-wallet. `coinType` // per chain feeds the path builder below. addressFamilies: [ { id: "bip84", purpose: 84, label: "Native SegWit (dgb1q…)" }, { id: "bip86", purpose: 86, label: "Taproot (dgb1p…)" }, { id: "bip49", purpose: 49, label: "Wrapped SegWit (S…)" }, { id: "bip44", purpose: 44, label: "Legacy P2PKH (D…)" }, ], coinType: 20, defaultPurpose: 84, networks: { mainnet: { id: "mainnet", label: "Mainnet", testnet: false, purposePrefix: "bchwallet/dgb/mainnet/", startIndex: 0, }, }, }, eth: { chain: "eth", label: "Ethereum", short: "ETH", ticker: "ETH", decimals: 18, color: "#627eea", logo: "eth", supportsMessageSign: true, supportsPageInject: false, // EIP-1193 provider is a follow-up networks: { mainnet: { id: "mainnet", label: "Mainnet", testnet: false, purposePrefix: "bchwallet/eth/mainnet/", startIndex: 0, }, sepolia: { id: "sepolia", label: "Sepolia testnet", testnet: true, purposePrefix: "bchwallet/eth/sepolia/", startIndex: 0, }, }, }, sol: { chain: "sol", label: "Solana", short: "SOL", ticker: "SOL", decimals: 9, color: "#9945ff", logo: "sol", supportsMessageSign: true, supportsPageInject: false, networks: { mainnet: { id: "mainnet", label: "Mainnet-beta", testnet: false, purposePrefix: "bchwallet/sol/mainnet/", startIndex: 0, }, devnet: { id: "devnet", label: "Devnet", testnet: true, purposePrefix: "bchwallet/sol/devnet/", startIndex: 0, }, }, }, btc: { chain: "btc", label: "Bitcoin", short: "BTC", ticker: "BTC", decimals: 8, color: "#f7931a", logo: "btc", supportsMessageSign: true, supportsPageInject: false, // BIP44/49/84/86 across bc1q… / bc1p… / 3… / 1… on mainnet and // tb1q… / tb1p… / 2… / m/n… on testnet3 + signet. Coin type shifts // per network (0 for mainnet, 1 for both testnet3 and signet — SLIP-44 // treats every Bitcoin testnet as coin type 1). addressFamilies: [ { id: "bip84", purpose: 84, label: "Native SegWit (bc1q… / tb1q…)" }, { id: "bip86", purpose: 86, label: "Taproot (bc1p… / tb1p…)" }, { id: "bip49", purpose: 49, label: "Wrapped SegWit (3… / 2…)" }, { id: "bip44", purpose: 44, label: "Legacy P2PKH (1… / m…, n…)" }, ], coinType: { mainnet: 0, testnet: 1, signet: 1 }, defaultPurpose: 84, networks: { mainnet: { id: "mainnet", label: "Mainnet", testnet: false, purposePrefix: "bchwallet/btc/mainnet/", startIndex: 0, }, testnet: { id: "testnet", label: "Testnet3", testnet: true, purposePrefix: "bchwallet/btc/testnet/", startIndex: 0, }, signet: { id: "signet", label: "Signet", testnet: true, purposePrefix: "bchwallet/btc/signet/", startIndex: 0, }, }, }, }; function chainKey(chain, network) { return `${chain}:${network}`; } // Coin type per (chain, network). A number literal on the COINS entry // (DGB uses a single 20) or a per-network object ({mainnet: 0, testnet: 1} // for BTC). Returns null when the chain doesn't declare a family picker. function coinTypeFor(c, network) { if (!c || c.coinType == null) return null; return typeof c.coinType === "number" ? c.coinType : (c.coinType[network] ?? null); } // Full derivation paths per family for a given (chain, network) — expands // the family list on the fly so each picker knows exactly which path a // pick would produce. function addressFamiliesFor(c, network) { if (!c || !c.addressFamilies) return null; const ct = coinTypeFor(c, network); if (ct == null) return c.addressFamilies; return c.addressFamilies.map((f) => ({ ...f, defaultAccountPath: `m/${f.purpose}'/${ct}'/0'`, })); } function defaultAccountPathFor(c, network) { const ct = coinTypeFor(c, network); if (ct == null || c.defaultPurpose == null) return null; return `m/${c.defaultPurpose}'/${ct}'/0'`; } // Custom EVM chains (EIP-3085) live in api.storage under `customEthChains`. // Structured as { [chainId]: {chainId, chainName, rpcUrl, explorerTx, // explorerAddr, ticker, addedAt, addedByOrigin} }. They're not in COINS at // module-load time — we synthesize a chainMeta / mount entry from storage // so wallet_addEthereumChain can register new networks at runtime without // a Theseus restart. const CUSTOM_ETH_PREFIX = "custom-"; function customEthChains(api) { const raw = api.storage.get("customEthChains", {}); return raw && typeof raw === "object" ? raw : {}; } function customEthNetworkEntry(api, network) { if (!network || !network.startsWith(CUSTOM_ETH_PREFIX)) return null; const chainId = Number(network.slice(CUSTOM_ETH_PREFIX.length)); if (!Number.isFinite(chainId)) return null; const all = customEthChains(api); const cfg = all[String(chainId)]; if (!cfg) return null; return { id: network, label: cfg.chainName || `EVM #${chainId}`, chainId, defaultRpc: cfg.rpcUrl, explorerTx: cfg.explorerTx, explorerAddr: cfg.explorerAddr, ticker: cfg.ticker || "ETH", faucet: null, }; } function chainMeta(chain, network) { // ETH custom-network fallback for EIP-3085 chains. if (chain === "eth" && String(network || "").startsWith(CUSTOM_ETH_PREFIX)) { const cfg = customEthNetworkEntry(ctx?.api, network); if (!cfg) return null; return { chain: "eth", network: cfg.id, label: "Ethereum · " + cfg.label, short: cfg.ticker, ticker: cfg.ticker, decimals: 18, color: COINS.eth.color, logo: COINS.eth.logo, coinLabel: cfg.label, networkLabel: `chainId ${cfg.chainId}`, testnet: false, purposePrefix: `bchwallet/eth/${cfg.id}/`, startIndex: 0, supportsMessageSign: true, supportsPageInject: false, addressFamilies: null, defaultAccountPath: null, isCustom: true, chainId: cfg.chainId, }; } const c = COINS[chain]; const n = c && c.networks[network]; if (!c || !n) return null; return { chain: c.chain, network: n.id, label: c.label + " · " + n.label, short: c.short, ticker: c.ticker, decimals: c.decimals, color: c.color, logo: c.logo, coinLabel: c.label, networkLabel: n.label, testnet: !!n.testnet, purposePrefix: n.purposePrefix, startIndex: n.startIndex, supportsMessageSign: !!c.supportsMessageSign, supportsPageInject: !!c.supportsPageInject, addressFamilies: addressFamiliesFor(c, network), defaultAccountPath: defaultAccountPathFor(c, network), }; } function coinsForPanel() { return Object.values(COINS).map((c) => ({ chain: c.chain, label: c.label, short: c.short, ticker: c.ticker, color: c.color, logo: c.logo, decimals: c.decimals, networks: Object.values(c.networks).map((n) => ({ id: n.id, label: n.label, testnet: !!n.testnet })), })); } // ---- 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") { // Mainnet still honors the user-set custom electrum list; chipnet uses // adapter-embedded defaults (no per-network custom list in this rev). const servers = entry.network === "mainnet" ? bchServerList(c.api) : undefined; 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), network: entry.network, servers, 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 if (entry.chain === "sc") { // walletdUrl is per-Sia-wallet (each sub-account may point at a // different node) and stored under the scoped wallets//walletdUrl // key. Empty = the panel shows a "point me at walletd" gate. const walletdUrl = String(c.api.storage.get(`wallets/${entry.id}/walletdUrl`, "") || ""); adapter = new c.d.siaAdapter.SiaWallet(root, { walletId: entry.id, storage: c.api.storage, log: (...a) => c.api.log(`[${entry.id}]`, ...a), onChange: () => emitStateForWallet(entry.id), walletdUrl, }); if (walletdUrl) adapter.startPolling(); } else if (entry.chain === "dgb") { adapter = new c.d.dgbAdapter.DgbWallet(root, { walletId: entry.id, storage: c.api.storage, log: (...a) => c.api.log(`[${entry.id}]`, ...a), onChange: () => emitStateForWallet(entry.id), accountPath: entry.accountPath, }); } else if (entry.chain === "eth") { const rpcUrl = String(c.api.storage.get(`wallets/${entry.id}/rpcUrl`, "") || ""); // Custom EIP-3085 chains resolve their config from storage rather than // the built-in NETWORKS map. const customNetwork = customEthNetworkEntry(c.api, entry.network); adapter = new c.d.ethAdapter.EthWallet(root, entry.network, { walletId: entry.id, storage: c.api.storage, log: (...a) => c.api.log(`[${entry.id}]`, ...a), onChange: () => emitStateForWallet(entry.id), rpcUrl, customNetwork, }); adapter.schedulePoll(20_000); } else if (entry.chain === "sol") { const rpcUrl = String(c.api.storage.get(`wallets/${entry.id}/rpcUrl`, "") || ""); adapter = new c.d.solAdapter.SolWallet(root, entry.network, { walletId: entry.id, storage: c.api.storage, log: (...a) => c.api.log(`[${entry.id}]`, ...a), onChange: () => emitStateForWallet(entry.id), rpcUrl, }); adapter.schedulePoll(20_000); } else if (entry.chain === "btc") { adapter = new c.d.btcAdapter.BtcWallet(root, entry.network, { walletId: entry.id, storage: c.api.storage, log: (...a) => c.api.log(`[${entry.id}]`, ...a), onChange: () => emitStateForWallet(entry.id), accountPath: entry.accountPath, }); } 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, logo: meta?.logo || null, color: meta?.color || "#888", coinLabel: meta?.coinLabel || w.chain, networkLabel: meta?.networkLabel || w.network, testnet: !!meta?.testnet, 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 ? { logo: meta.logo, color: meta.color, short: meta.short, ticker: meta.ticker, decimals: meta.decimals, coinLabel: meta.coinLabel, networkLabel: meta.networkLabel, testnet: meta.testnet, addressFamilies: meta.addressFamilies, defaultAccountPath: meta.defaultAccountPath, } : 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)), }, coins: coinsForPanel(), }; } 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"); } // BigInt-safe display for SPL token amounts (raw units in u64 strings). function fmtTokenAmount(rawStr, decimals) { const s = String(rawStr || "0"); const neg = s.startsWith("-"); const abs = neg ? s.slice(1) : s; const d = Number(decimals) || 0; if (d === 0) return (neg ? "-" : "") + abs; const pad = abs.padStart(d + 1, "0"); const whole = pad.slice(0, pad.length - d); const frac = pad.slice(pad.length - d).replace(/0+$/, ""); return (neg ? "-" : "") + whole + (frac ? "." + frac : ""); } 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(); // If this coin+network declares a `legacyFirstPurpose` (SC does, to // recover funds from the standalone siawallet addon) and no wallet of // this coin+network exists yet, use that purpose verbatim. The bump // to the /aegis-namespaced/ prefix only starts on the second sub-account. const netMeta = COINS[chain]?.networks?.[network] || {}; const existingForCoin = list.filter((w) => w.chain === chain && w.network === network && !w.isLegacy); let purpose, isLegacy = false; if (netMeta.legacyFirstPurpose && existingForCoin.length === 0 && !list.some((w) => w.purpose === netMeta.legacyFirstPurpose)) { purpose = netMeta.legacyFirstPurpose; isLegacy = true; } else { const index = nextIndex(list, meta); purpose = meta.purposePrefix + index; } const idIndex = nextIndex(list, meta); const id = makeWalletId(meta, idIndex); 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, isLegacy, 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 (!["bch", "dgb", "btc"].includes(entry.chain)) throw new Error("account path is a BCH/BTC/DGB-only setting"); const v = String(patch.accountPath || "").trim(); if (v && !/^m(\/\d+'?)+$/.test(v)) throw new Error("derivation path must look like m/84'/0'/0'"); const list = walletEntries(); const idx = list.findIndex((w) => w.id === id); // Per-network default: BTC/DGB come from the registry helper, BCH keeps // its historical m/44'/145'/0'. const dflt = entry.chain === "bch" ? "m/44'/145'/0'" : (defaultAccountPathFor(COINS[entry.chain], entry.network) || "m/84'/0'/0'"); list[idx] = { ...list[idx], accountPath: v || dflt }; writeWallets(api, list); 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(); }); // ETH/SOL: per-wallet RPC URL, live-swap without key rebuild. api.onMessage("setRpcUrl", (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 !== "eth" && entry.chain !== "sol") throw new Error("RPC URL is an ETH/SOL setting"); const v = String(patch.rpcUrl || "").trim(); if (v && !/^https?:\/\/[^\s]+$/i.test(v)) throw new Error("RPC URL must start with http:// or https://"); api.storage.set(`wallets/${id}/rpcUrl`, v); const rt = ctx.runtimes.get(id); if (rt && rt.adapter && typeof rt.adapter.setRpcUrl === "function") { rt.adapter.setRpcUrl(v); rt.adapter.refresh().catch((e) => api.log(`[${id}] refresh:`, e?.message || e)); } emitState(); return fullState(); }); // Sia-only: per-wallet walletd URL. Live: pushes the URL into the adapter // without rebuilding the keys (the seed stays derived from the same // vault path — only the node the wallet talks to changes). api.onMessage("setWalletdUrl", (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 !== "sc") throw new Error("walletd URL is a Sia-only setting"); 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(`wallets/${id}/walletdUrl`, v); const rt = ctx.runtimes.get(id); if (rt && rt.adapter) { rt.adapter.setWalletdUrl(v); if (v) rt.adapter.startPolling(); rt.adapter.refresh(true).catch((e) => api.log(`[${id}] refresh:`, e?.message || e)); } emitState(); 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); }); // SPL token plan/send — only meaningful when the selected wallet is SOL. api.onMessage("planTokenSend", async (p, m) => { fromPanel(m); const rt = requireSelected(); if (rt.entry.chain !== "sol" || typeof rt.adapter.planTokenTransfer !== "function") { throw new Error("token send is a Solana-only flow"); } const plan = await rt.adapter.planTokenTransfer(p || {}); return { recipients: plan.recipients, fee: String(plan.fee), feeRate: String(plan.feeRate), inputs: 0, change: "0", total: plan.total, mint: plan.mint, decimals: plan.decimals, }; }); api.onMessage("sendToken", async (p, m) => { fromPanel(m); const rt = requireSelected(); if (rt.entry.chain !== "sol") throw new Error("token send is Solana-only"); const plan = await rt.adapter.planTokenTransfer(p || {}); const meta = chainMeta("sol", rt.entry.network); const tokenInfo = (rt.adapter.snapshot().tokens || []).find((t) => t.mint === plan.mint) || {}; const rows = [ { label: "To", value: plan.recipients[0].to, mono: true }, { label: "Amount", value: `${fmtTokenAmount(plan.recipients[0].value, plan.decimals)} ${tokenInfo.symbol || "token"}`, strong: true }, { label: "Mint", value: plan.mint, mono: true }, { label: "Network fee", value: `~${fmtValue(Number(plan.fee), meta.decimals)} SOL${(plan._spl && plan._spl.destExists === false) ? " (includes new token account rent)" : ""}` }, { label: "Wallet", value: `${rt.entry.label} — Solana · ${rt.entry.network}` }, ]; const pick = await api.approvalModal({ title: `Send ${tokenInfo.symbol || "SPL token"}?`, origin: "Aegis wallet panel", rows, actions: [{ id: "send", label: "Send", primary: true }], }); if (pick !== "send") throw new Error("cancelled"); return rt.adapter.signAndBroadcastToken(plan); }); // 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: `${rt.entry.label} — ${meta.coinLabel} · ${meta.networkLabel}` }, ]; 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 (typeof rt.adapter.recovery !== "function") throw new Error("this chain does not expose recovery details"); const r = rt.adapter.recovery(); const out = { accountPath: r.accountPath, xpub: r.xpub, purpose: rt.entry.purpose }; if (p && p.reveal) { // Sia's "xprv" is really the 32-byte wallet seed (walletd KeyFromSeed); // BCH/DGB's is the account xprv. Warning copy fits both. const pick = await api.approvalModal({ title: rt.entry.chain === "sc" ? "Reveal the wallet seed?" : "Reveal the account private key?", origin: "Aegis wallet panel", body: "Anyone holding this 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} — Tron · ${snap.network === "nile" ? "Nile testnet" : "Mainnet"}` }, ], 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} — Tron · ${rt.adapter.snapshot().network === "nile" ? "Nile testnet" : "Mainnet"}` }); 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); }); }); // ---- Ethereum (EIP-1193) --------------------------------------------- // Routes the same way as Tron: pick the currently-selected ETH wallet if // any; else the first ready ETH wallet. Chain switches happen at the // wallet-picker level, not here — dapps that call wallet_switchEthereumChain // get a friendly "switch wallet in the Aegis sidebar" error. function activeEthRuntime() { const selId = selectedWalletId(); const selRt = selId && ctx.runtimes.get(selId); if (selRt && selRt.entry.chain === "eth" && selRt.phase === "ready") return selRt; for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "eth" && rt.phase === "ready") return rt; throw new Error("no Ethereum wallet available — add one in the Aegis sidebar"); } function ethConnectedFor(origin) { const p = permissions(api)[origin]; return !!(p && p.eth && p.eth.readAddress); } api.onMessage("eth.requestAccounts", async (_p, m) => { const origin = fromPage(m); const rt = activeEthRuntime(); const snap = rt.adapter.snapshot(); const chainIdHex = "0x" + Number(snap.chainId).toString(16); const networkVersion = String(snap.chainId); const perms = permissions(api); if (perms[origin] && perms[origin].eth && perms[origin].eth.readAddress) { return { address: snap.address, chainIdHex, networkVersion }; } return withOriginLock(origin, async () => { const pick = await api.approvalModal({ title: "Connect this site to your Ethereum 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 === "mainnet" ? "Ethereum mainnet" : "Sepolia testnet" }, { label: "Wallet", value: `${rt.entry.label} — Ethereum · ${snap.network}` }, ], 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] || {}), eth: { readAddress: true, chainId: snap.chainId } }; api.storage.set("permissions", perms); emitState(); } return { address: snap.address, chainIdHex, networkVersion }; }); }); api.onMessage("eth.personalSign", async (p, m) => { const origin = fromPage(m); if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first"); const rt = activeEthRuntime(); 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 an Ethereum message?", origin, body: "Signing proves you control this address. It moves no ETH.", rows: [ { label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true }, { label: "Address", value: rt.adapter.snapshot().address, mono: true }, ], actions: [{ id: "sign", label: "Sign", primary: true }], }); if (pick !== "sign") throw new Error("user rejected"); return rt.adapter.signMessage(message); }); }); api.onMessage("eth.sendTransaction", async (p, m) => { const origin = fromPage(m); if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first"); const rt = activeEthRuntime(); const tx = (p && p.tx) || {}; if (!tx.to) throw new Error("tx.to required"); // MetaMask semantics: `value` and `gas`/`gasLimit` are hex-encoded wei; // convert to numbers/bigints Aegis's own plan() understands. const valueWei = tx.value ? BigInt(tx.value).toString() : "0"; return withOriginLock(origin, async () => { const snap = rt.adapter.snapshot(); const plan = await rt.adapter.plan({ to: tx.to, amount: valueWei, sendMax: false }); const meta = chainMeta("eth", rt.entry.network); const pick = await api.approvalModal({ title: "Send Ethereum transaction?", origin, body: tx.data && tx.data !== "0x" ? "This transaction carries call data (a contract call). Check the destination + value carefully." : "This site is asking your wallet to send ETH.", rows: [ { label: "To", value: plan.recipients[0].to, mono: true }, { label: "Amount", value: `${fmtValue(plan.recipients[0].value, meta.decimals)} ETH`, strong: true }, { label: "Fee (est.)", value: `${fmtValue(plan.fee, meta.decimals)} ETH` }, { label: "Wallet", value: `${rt.entry.label} — Ethereum · ${snap.network}` }, ], actions: [{ id: "send", label: "Send", primary: true }], }); if (pick !== "send") throw new Error("user rejected"); const r = await rt.adapter.signAndBroadcast(plan); return { txid: r.txid }; }); }); api.onMessage("eth.signTypedData", async (p, m) => { const origin = fromPage(m); if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first"); const rt = activeEthRuntime(); // Dapps send typedData as either a JSON string (older MetaMask spec) or // an object (v4). Accept both; the encoder wants an object. let td = p && p.typedData; if (typeof td === "string") { try { td = JSON.parse(td); } catch (e) { throw new Error("typedData: JSON parse failed: " + e.message); } } if (!td || typeof td !== "object") throw new Error("typedData required"); // Compute the digest first — if the encoder rejects the input the user // never sees an approval overlay for a broken payload. let digest; try { digest = ctx.d.eip712.digest(td); } catch (e) { throw new Error("EIP-712 encode failed: " + e.message); } // Approval overlay: show domain (name + chain), primary type, and a // truncated JSON preview of the message so the user has a fighting // chance to spot phishing. const dom = td.domain || {}; const domainSummary = [dom.name, dom.version && `v${dom.version}`, dom.chainId && `chain ${dom.chainId}`].filter(Boolean).join(" · ") || "(no domain)"; const messagePreview = JSON.stringify(td.message, null, 2); const preview = messagePreview.length > 600 ? messagePreview.slice(0, 600) + "…" : messagePreview; return withOriginLock(origin, async () => { const pick = await api.approvalModal({ title: "Sign typed data (EIP-712)?", origin, body: "The site is asking you to sign a structured message. Verify the domain matches the site you're on — a mismatched domain is the classic phishing tell.", rows: [ { label: "Domain", value: domainSummary }, { label: "Primary type", value: String(td.primaryType || "") }, { label: "Message", value: preview, mono: true }, { label: "Address", value: rt.adapter.snapshot().address, mono: true }, ], actions: [{ id: "sign", label: "Sign", primary: true }], }); if (pick !== "sign") throw new Error("user rejected"); return rt.adapter.signTypedDataDigest(digest); }); }); api.onMessage("eth.switchChain", async (p, m) => { fromPage(m); const wantHex = String(p && p.chainId || "").toLowerCase(); const wantId = Number(wantHex); if (!Number.isFinite(wantId) || wantId <= 0) throw new Error("bad chainId"); // Find any wallet already on that chain and select it. for (const rt of ctx.runtimes.values()) { if (rt.entry.chain !== "eth" || rt.phase !== "ready") continue; const snap = rt.adapter.snapshot(); if (Number(snap.chainId) === wantId) { api.storage.set("selectedWalletId", rt.entry.id); emitState(); return null; } } // EIP-3326: throw the well-known "chain not added" code so dapps fall // back to wallet_addEthereumChain. const err = new Error(`Aegis: chainId ${wantHex} is not added. Ask via wallet_addEthereumChain.`); err.code = 4902; throw err; }); // EIP-3085: dapp asks Aegis to add a new EVM chain. On approval, we // persist the chain config and create a wallet on it under the same // vault-derived key. Existing addresses on that chain remain visible on // whatever wallet they were funded on — a chain add doesn't move any // key material, just registers the network. api.onMessage("eth.addChain", async (p, m) => { const origin = fromPage(m); const spec = (p && p.params) || {}; const chainIdHex = String(spec.chainId || "").toLowerCase(); const chainId = Number(chainIdHex); if (!chainIdHex.startsWith("0x") || !Number.isFinite(chainId) || chainId <= 0) { throw new Error("wallet_addEthereumChain: chainId must be a positive hex integer (e.g. '0x89')"); } const chainName = String(spec.chainName || "").trim() || `EVM #${chainId}`; const rpcUrls = Array.isArray(spec.rpcUrls) ? spec.rpcUrls.filter((u) => /^https?:\/\//i.test(u)) : []; const rpcUrl = rpcUrls[0]; if (!rpcUrl) throw new Error("wallet_addEthereumChain: at least one https rpcUrls entry is required"); const explorerBase = Array.isArray(spec.blockExplorerUrls) && spec.blockExplorerUrls[0] ? String(spec.blockExplorerUrls[0]).replace(/\/+$/, "") : null; const nc = spec.nativeCurrency || {}; const ticker = String(nc.symbol || "ETH").slice(0, 6).toUpperCase(); // Reject if a wallet on this chain already exists — no-op success per // EIP-3085 conventions. const existing = walletEntries().find((w) => w.chain === "eth" && (w.network === CUSTOM_ETH_PREFIX + chainId || (chainMeta("eth", w.network)?.chainId === chainId))); if (existing) { // Auto-connect the origin to this wallet — dapps expect the returned // provider to be pointed at the added chain immediately. const perms = permissions(api); perms[origin] = { ...(perms[origin] || {}), eth: { readAddress: true, chainId } }; api.storage.set("permissions", perms); api.storage.set("selectedWalletId", existing.id); emitState(); return null; } return withOriginLock(origin, async () => { const pick = await api.approvalModal({ title: "Add an Ethereum chain?", origin, body: "The site is asking to add a new EVM network to Aegis. Verify the RPC and chain ID — a malicious 'chain add' can point you at a fraudulent RPC that intercepts your reads or signs.", rows: [ { label: "Chain name", value: chainName }, { label: "Chain ID", value: `${chainId} (${chainIdHex})` }, { label: "Native ticker", value: ticker }, { label: "RPC", value: rpcUrl, mono: true }, { label: "Explorer", value: explorerBase || "(none)", mono: true }, ], actions: [{ id: "add", label: "Add chain", primary: true }], }); if (pick !== "add") throw new Error("user rejected"); // Persist chain config + create a wallet on it. const chains = customEthChains(api); chains[String(chainId)] = { chainId, chainName, rpcUrl, explorerTx: explorerBase ? explorerBase + "/tx/" : "", explorerAddr: explorerBase ? explorerBase + "/address/" : "", ticker, addedAt: Date.now(), addedByOrigin: origin, }; api.storage.set("customEthChains", chains); const network = CUSTOM_ETH_PREFIX + chainId; const meta = chainMeta("eth", network); const list = walletEntries().slice(); const index = nextIndex(list, meta); const purpose = meta.purposePrefix + index; const id = makeWalletId(meta, index); const label = `${chainName} — ${meta.short}`; const entry = { id, label, chain: "eth", network, purpose, createdAt: Date.now() }; list.push(entry); writeWallets(api, list); api.storage.set("selectedWalletId", id); // Grant the origin read access on this chain by default (they just // approved adding it — implicit consent to also see the address). const perms = permissions(api); perms[origin] = { ...(perms[origin] || {}), eth: { readAddress: true, chainId } }; api.storage.set("permissions", perms); ctx.runtimes.set(id, { entry, phase: "locked", error: null, adapter: null }); emitState(); await mountWallet(entry); return null; }); }); // Cheap state peek — used by the main-world bridge right after a switch // or add to emit accountsChanged / chainChanged without needing another // approval overlay. Only returns the wallet the origin already sees. api.onMessage("eth.state", (_p, m) => { const origin = fromPage(m); if (!ethConnectedFor(origin)) return { address: null, chainIdHex: "0x0", networkVersion: "0" }; const rt = activeEthRuntime(); const snap = rt.adapter.snapshot(); return { address: snap.address, chainIdHex: "0x" + Number(snap.chainId).toString(16), networkVersion: String(snap.chainId), }; }); // Read passthrough: forward eth_getBalance / eth_call / etc. to the // wallet's own configured RPC. Nothing here reveals the private key. api.onMessage("eth.rpc", async (p, m) => { fromPage(m); const rt = activeEthRuntime(); const method = String(p && p.method || ""); const params = (p && p.params) || []; if (!/^eth_|^net_|^web3_/.test(method)) throw new Error("Aegis: only eth_/net_/web3_ read methods are passed through"); return rt.adapter._client.call(method, params); }); // ---- Solana (wallet-adapter) ------------------------------------------ function activeSolRuntime() { const selId = selectedWalletId(); const selRt = selId && ctx.runtimes.get(selId); if (selRt && selRt.entry.chain === "sol" && selRt.phase === "ready") return selRt; for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "sol" && rt.phase === "ready") return rt; throw new Error("no Solana wallet available — add one in the Aegis sidebar"); } function solConnectedFor(origin) { const p = permissions(api)[origin]; return !!(p && p.sol && p.sol.readAddress); } api.onMessage("sol.connect", async (_p, m) => { const origin = fromPage(m); const rt = activeSolRuntime(); const snap = rt.adapter.snapshot(); if (solConnectedFor(origin)) return { address: snap.address, network: snap.network }; return withOriginLock(origin, async () => { const pick = await api.approvalModal({ title: "Connect this site to your Solana 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 === "mainnet" ? "Mainnet-beta" : "Devnet" }, { label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` }, ], 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") { const perms = permissions(api); perms[origin] = { ...(perms[origin] || {}), sol: { readAddress: true, network: snap.network } }; api.storage.set("permissions", perms); emitState(); } return { address: snap.address, network: snap.network }; }); }); api.onMessage("sol.signMessage", async (p, m) => { const origin = fromPage(m); if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first"); const rt = activeSolRuntime(); const b64 = String(p && p.messageB64 || ""); const bytes = Buffer.from(b64, "base64"); if (bytes.length > 4096) throw new Error("message too long"); return withOriginLock(origin, async () => { const preview = bytes.every((c) => c >= 0x20 && c < 0x7f) ? bytes.toString("utf8") : `<${bytes.length} bytes: 0x${bytes.toString("hex").slice(0, 60)}…>`; const pick = await api.approvalModal({ title: "Sign a Solana message?", origin, body: "Signing proves you control this address. It moves no SOL.", rows: [ { label: "Message", value: preview.length > 400 ? preview.slice(0, 400) + "…" : preview, mono: true }, { label: "Address", value: rt.adapter.snapshot().address, mono: true }, ], actions: [{ id: "sign", label: "Sign", primary: true }], }); if (pick !== "sign") throw new Error("user rejected"); return rt.adapter.signMessage(bytes); }); }); // Dapp-built transaction. The main-world bridge passes the FULL wire // (tx.serialize({requireAllSignatures:false, verifySignatures:false})) // — signature slots the dapp already filled with partialSign() are // preserved; our wallet only overwrites its own slot. That's the only // way to sign multi-signer transactions the dapp has partially // co-signed (co-signer sigs, ephemeral session keys, etc.). api.onMessage("sol.signAndSend", async (p, m) => { const origin = fromPage(m); if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first"); const rt = activeSolRuntime(); const wireB64 = String(p && p.wireB64 || ""); if (!wireB64) throw new Error("wireB64 required (full serialized transaction)"); const wire = new Uint8Array(Buffer.from(wireB64, "base64")); // Parse: compact-u16(sigCount) || sig[0..64]*sigCount || message let off = 0; const readCompactU16 = () => { let n = 0, shift = 0; while (true) { const b = wire[off++]; n |= (b & 0x7f) << shift; if ((b & 0x80) === 0) break; shift += 7; if (shift > 21) throw new Error("compact-u16 too long"); } return n; }; const sigCount = readCompactU16(); if (sigCount < 1 || sigCount > 32) throw new Error("bad signature count " + sigCount); const sigsStart = off; const messageStart = sigsStart + sigCount * 64; if (wire.length < messageStart) throw new Error("truncated tx wire"); const messageBytes = wire.slice(messageStart); // Parse the message enough to find our pubkey's index in the account // list. Layout: header(3) || compactU16(keyCount) || key[32]*keyCount || … if (messageBytes.length < 3 + 1 + 32) throw new Error("message too short"); const numRequiredSigs = messageBytes[0]; let moff = 3; const readKeyCount = () => { let n = 0, shift = 0; while (true) { const b = messageBytes[moff++]; n |= (b & 0x7f) << shift; if ((b & 0x80) === 0) break; shift += 7; } return n; }; const keyCount = readKeyCount(); if (keyCount < 1 || keyCount > 64) throw new Error("bad account key count"); // Find our public key among the key list. const ourPub = rt.adapter._pub; let ourIndex = -1; for (let i = 0; i < keyCount; i++) { const key = messageBytes.subarray(moff + i * 32, moff + (i + 1) * 32); let eq = true; for (let j = 0; j < 32; j++) if (key[j] !== ourPub[j]) { eq = false; break; } if (eq) { ourIndex = i; break; } } if (ourIndex < 0) throw new Error("this wallet's key is not among the transaction's account keys"); if (ourIndex >= numRequiredSigs) throw new Error(`this wallet's key is not a required signer (index ${ourIndex}, requiredSigs ${numRequiredSigs})`); return withOriginLock(origin, async () => { const snap = rt.adapter.snapshot(); const otherSigners = numRequiredSigs > 1 ? numRequiredSigs - 1 : 0; const pick = await api.approvalModal({ title: "Sign + send a Solana transaction?", origin, body: "The site built this transaction. Aegis can't decode arbitrary Solana instructions in this rev — verify the site before signing.", rows: [ { label: "Message size", value: `${messageBytes.length} bytes` }, { label: "Required signers", value: otherSigners ? `${numRequiredSigs} — you (slot #${ourIndex}) + ${otherSigners} other${otherSigners === 1 ? "" : "s"}` : "1 — you" }, { label: "Address", value: snap.address, mono: true }, { label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` }, ], actions: [{ id: "send", label: "Sign & send", primary: true }], }); if (pick !== "send") throw new Error("user rejected"); // Sign the message and patch our slot. Any partial signatures already // in the wire (from tx.partialSign()) at other slots are preserved. const sigInfo = rt.adapter.signMessage(messageBytes); const sigBytes = ctx.d.base58check.decodeBase58(sigInfo.signature); if (sigBytes.length !== 64) throw new Error("bad ed25519 signature length"); const wireOut = new Uint8Array(wire); // copy so we don't mutate caller wireOut.set(sigBytes, sigsStart + ourIndex * 64); const wireB58 = ctx.d.base58check.encodeBase58(wireOut); const txid = await rt.adapter._client.call("sendTransaction", [wireB58]); if (typeof txid !== "string" || !txid.length) throw new Error("bad txid from RPC: " + JSON.stringify(txid)); setTimeout(() => rt.adapter.refresh().catch(() => {}), 4000); return { txid }; }); }); } // ---- 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(); }, };