// 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"; // New each time the add-on's main process starts, i.e. once per Theseus // launch. Comparing it against the id stored the last time a PIN was // accepted is how "ask again after a browser restart" is detected — a // timestamp cannot tell a restart from a long idle, and the panel cannot be // trusted to report its own restarts. const BOOT_ID = require("node:crypto").randomBytes(8).toString("hex"); const PIN_INTERVAL_MS = 6 * 60 * 60 * 1000; 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, sha512 } = await api.import("@noble/hashes/sha2.js"); const { hkdf } = await api.import("@noble/hashes/hkdf.js"); // For DigiByte's legacy master-key derivation — see HMAC_DIGIBYTE_SEED in // lib/dgb/core/hd.js. const { hmac } = await api.import("@noble/hashes/hmac.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 }); // Tron raw_data_hex decoder — the approval overlay for dapp-built Tron // transactions is built from these bytes, never from the dapp's JSON. const tronDecode = require("./lib/tron-decode.js")({ sha256, base58check }); // 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"); // DGB's bundled ESM packages import their peer deps by bare specifier // ("bip32", "bitcoinjs-lib", …). When aegis is loaded from a copy in // userData/addons/, Node's ESM resolver can't reach Theseus's node_modules // from that path — so the import throws. Wrap it: DGB just becomes // unavailable, the rest of Aegis keeps working. const bitcoinjs = api.require("bitcoinjs-lib"); const { BIP32Factory } = api.require("bip32"); const { ECPairFactory } = api.require("ecpair"); const ecc = api.require("@bitcoinerlab/secp256k1"); let dgbCore = null, dgbPsbt = null, dgbAdapter = null; try { // Inject before importing anything under lib/dgb/. Those modules used to // import "bitcoinjs-lib" and friends as bare specifiers, which only // resolves when the add-on sits inside the app tree — so DigiByte worked // on a fresh install and vanished after the first OTA update, where Aegis // runs from /extensions/aegis/. See lib/dgb/deps.js. const dgbDeps = await import(pathToFileURL(path.join(api.folder, "lib/dgb/deps.js")).href); dgbDeps.setDgbDeps({ bitcoinjs, ecc, bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, bip39: api.require("bip39"), hmac, sha512, }); dgbCore = await import(pathToFileURL(path.join(api.folder, "lib/dgb/core/index.js")).href); dgbPsbt = await import(pathToFileURL(path.join(api.folder, "lib/dgb/psbt/index.js")).href); } catch (e) { api.log("dgb unavailable:", e?.message || e); } if (dgbCore && dgbPsbt) { 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, }); const bip39 = api.require("bip39"); // Imported BCH — a lean read-only adapter for wallets whose key material // lives in Theseus's wallet-imports.enc. Same electrum/cashaddr surface as // the primary BCH adapter but a single fixed address per wallet. const importedBchAdapter = require("./lib/chain-bch-imported.js")({ sha256, ripemd160, cashaddr, electrum, WebSocket, tx, HDKey, secp256k1, base58check, vaultImports: api.vault && api.vault.imports, }); // Multi-chain imported adapters. UTXO chains (BTC, DGB) share an electrum- // based reader; account-model chains (ETH, TRX, SOL) share a JSON-RPC // reader. Every runtime is read-only in M.1b, matching chain-bch-imported. const utxoImportedAdapter = require("./lib/chain-utxo-imported.js")({ sha256, bitcoinjs, dgbCore, electrum, WebSocket, }); const genericImportedAdapter = require("./lib/chain-generic-imported.js")(); // Per-chain address derivation from raw material (mnemonic + path or // chain-native private key). Used by the importWallet handler to compute // the address client-side before wallet-imports.enc stores the material. const derive = require("./lib/import-derive.js")({ HDKey, secp256k1, ed25519, sha256, sha512, hmac, ripemd160, keccak_256, blake2b, cashaddr, base58check, bitcoinjs, bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, ecc, bip39, dgbCore, }); // WizardConnect: LGPL-3.0-or-later. Dynamic-linked via api.import so the // §4d combined-work requirement (dynamic linkage + license notice + source // availability) is met — package sources ship with npm. const wcCore = await api.import("@wizardconnect/core"); const wcWallet = await api.import("@wizardconnect/wallet"); const libauth = await api.import("@bitauth/libauth"); return { HDKey, secp256k1, ed25519, sha256, hkdf, ripemd160, keccak_256, blake2b, cashaddr, keysLib, tx, electrum, base58check, bchAdapter, tronAdapter, siaAdapter, dgbAdapter, ethAdapter, solAdapter, btcAdapter, importedBchAdapter, utxoImportedAdapter, genericImportedAdapter, derive, bip39, dgbCore, dgbPsbt, bitcoinjs, ecc, eip712, tronDecode, wcCore, wcWallet, libauth }; } // ---- 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 ------------------------------------------------------------ // Replace api.storage with a write-through read cache. Writes still go to the // host so nothing changes about durability or who owns the file; only the // repeated parsing of it disappears. Falls back to the host's storage // untouched if the api object will not accept the substitution. function installStorageCache(api, onSet) { const raw = api.storage; if (!raw || typeof raw.all !== "function") return false; let mem = null; const load = () => { if (!mem) { try { mem = raw.all() || {}; } catch { mem = {}; } } return mem; }; const cached = { get: (key, fallback = null) => { const s = load(); return Object.prototype.hasOwnProperty.call(s, key) ? s[key] : fallback; }, set: (key, value) => { load()[key] = value; raw.set(key, value); if (onSet) try { onSet(key); } catch {} }, all: () => ({ ...load() }), }; try { api.storage = cached; return api.storage === cached; } catch { return false; } } // Aegis starts on first use (addon.json "activation": "on-demand"). Two // things only work while it runs, so while either is on it asks the host to // start it at launch: a live WizardConnect pairing (nobody else listens on // the relays for the dapp's sign requests) and "stay unlocked" (that // auto-unlock also opens Settings › Passwords). Pairings of a removed wallet // stay in storage, so only wallets still in the list count. function needsLaunchStart(api) { const ids = new Set((readWallets(api) || []).map((w) => w.id)); const all = api.storage.all ? api.storage.all() : {}; for (const [k, v] of Object.entries(all)) { const m = /^wc\/(.+)\/uris$/.exec(k); if (m && ids.has(m[1]) && Array.isArray(v) && v.length) return true; } const cfg = api.storage.get("aegis/session/cfg", null); const blob = api.storage.get("aegis/session/enc", null); return !!(cfg && cfg.lockOnClose === false && blob && blob.encPwB64); } let launchStartAsked = null; function syncLaunchStart(api) { if (typeof api.startAtLaunch !== "function") return; // host predates on-demand const on = needsLaunchStart(api); if (on === launchStartAsked) return; launchStartAsked = on; try { api.startAtLaunch(on); } catch (e) { api.log("startAtLaunch:", e?.message || e); } } const LAUNCH_START_KEYS = /^(wallets$|wc\/|aegis\/session\/)/; 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"); } // The lowest index not in use. It used to be highest + 1, so removing a // wallet retired its index for good: the keys were still derivable from the // vault, but nothing in the UI could ever produce that wallet again, and // whatever was on it was out of reach. Now "Add wallet" after a removal // brings the same wallet (same keys, same address) back. function nextIndex(wallets, meta) { const used = new Set(); 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)) used.add(n); } let i = meta.startIndex; while (used.has(i)) i++; return i; } 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))); } // An import stores the FULL leaf path the user typed (m/44'/1'/0'/0/0) in // entry.accountPath, because that is what derived the single address the // strip shows. WizardConnect wants the BIP44 *account* node instead — it // appends the branch (receive/change/defi) and the address index itself. // Handing it the leaf made it derive m/44'/1'/0'/0/0//: a tree // the user holds no keys in, so pairing looked healthy and then every sign // request failed with "no path for input". The account level is the last // hardened element of the path. function wcAccountPath(path) { const p = String(path || "").trim(); if (!/^m(\/\d+'?)+$/.test(p)) return null; const parts = p.split("/"); let last = -1; for (let i = 1; i < parts.length; i++) if (parts[i].endsWith("'")) last = i; if (last < 1) return null; return parts.slice(0, last + 1).join("/"); } 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(); // Imported wallets skip the vault-derive/HKDF path entirely: their signer // material is in wallet-imports.enc, and the runtime here is read-only so // it doesn't need the material until spend support ships (M.1b). if (entry.kind === "imported") { try { let adapter; const commonOpts = { walletId: entry.id, storage: c.api.storage, log: (...a) => c.api.log(`[${entry.id}]`, ...a), onChange: () => emitStateForWallet(entry.id), network: entry.network, }; if (entry.chain === "bch") { // A seed import IS an HD wallet. Bitcoin.com, Electron Cash and the // rest spread funds across a whole BIP44 account, so watching the one // address a leaf path happens to derive reports 0 for a wallet that // plainly has money in it. Mount the seed on the same adapter the // vault-derived wallets use — WalletKeys walks receive AND change to // a gap limit of 20, which is what actually finds the balance, and it // brings real spend support with it. // // WIF imports stay on the single-address adapter: one key is one // address, and there is nothing to scan. // // Reading the signer needs the vault unlocked. When it is locked we // fall back to the watch-only adapter so balances still render from // the stored address instead of the wallet failing to mount at all. let seedRoot = null; try { const blob = await c.api.vault.imports.signer(entry.importId); if (ctx !== c) return; if (blob && blob.kind === "seed" && blob.seed) { const seedHex = String(blob.seed).trim(); if (/^[0-9a-f]+$/i.test(seedHex) && seedHex.length >= 32) { seedRoot = new Uint8Array(seedHex.match(/../g).map((x) => parseInt(x, 16))); } else { wcIneligible.set(entry.id, { short: "unsupported key", detail: "Imported seed material is not in a form WizardConnect can derive from.", }); } } else { wcIneligible.set(entry.id, { short: "WIF import", detail: "Wallets imported from a single private key (WIF) can't pair. WizardConnect hands the dapp an xpub so it can derive addresses on its own, and a lone private key carries no chain code to build one from. Open this wallet's ⋯ menu and choose \"Promote to HD wallet\" — Aegis derives a proper wallet from your vault and sweeps this key into it.", }); } } catch (e) { c.api.log(`[${entry.id}] signer unavailable:`, e?.message || e); wcIneligible.set(entry.id, wcErrReason(e)); } const bchServers = entry.network === "mainnet" ? bchServerList(c.api) : undefined; if (seedRoot) { // entry.accountPath arrives in either shape: imports used to store // the full leaf the displayed address came from // (m/44'/145'/0'/0/0), while a Copay / Bitcoin.com backup QR // carries the ACCOUNT path. Trim both to the account — the last // hardened element — so WalletKeys derives the branches the wallet // actually used rather than a tree hanging off a leaf. adapter = new c.d.bchAdapter.BchWallet(seedRoot, { ...commonOpts, servers: bchServers, accountPath: wcAccountPath(entry.accountPath) || undefined, }); } else { adapter = new c.d.importedBchAdapter.ImportedBchWallet({ ...commonOpts, cashaddr: entry.importedCashaddr || entry.importedAddress, servers: bchServers, importId: entry.importId, }); adapter.schedulePoll(20_000); } // Imported BCH wallets were never registered with WizardConnect — // startForWallet only ran in the vault-derived branch, so they showed // up in the "Sign with" picker and then failed on pair with "no // manager". Register the seed-backed ones here too. if (c.wc && seedRoot) { c.wc.startForWallet({ walletId: entry.id, label: entry.label, // Its own copy: the WC adapter keeps a live reference for the // per-URI relay-identity HKDF, so it must not share the buffer // we are about to wipe. root32: new Uint8Array(seedRoot), accountPath: wcAccountPath(entry.accountPath) || adapter.snapshot?.()?.accountPath || "m/44'/145'/0'", }).catch((e) => { c.api.log(`[${entry.id}] wc start (imported):`, e?.message || e); wcIneligible.set(entry.id, wcErrReason(e)); emitStateForWallet(entry.id); }); } // BchWallet copied the root and WalletKeys already derived from it, // so the local buffer has no further use. if (seedRoot) { try { seedRoot.fill(0); } catch {} } } else if (entry.chain === "btc" || entry.chain === "dgb") { adapter = new c.d.utxoImportedAdapter.UtxoImportedWallet({ ...commonOpts, chain: entry.chain, address: entry.importedAddress, }); adapter.schedulePoll(20_000); } else if (entry.chain === "eth" || entry.chain === "trx" || entry.chain === "sol") { adapter = new c.d.genericImportedAdapter.GenericImportedWallet({ ...commonOpts, chain: entry.chain, address: entry.importedAddress, // Only a real custom URL overrides the network default. The old // String(x || undefined) turned an unset override into the text // "undefined", which is truthy — so every imported TRX/ETH/SOL wallet // without a custom RPC fetched "undefined/v1/accounts/…" and showed // "undefined" as its server. rpcUrl: String(c.api.storage.get(`wallets/${entry.id}/rpcUrl`, "") || "").trim() || undefined, }); adapter.schedulePoll(20_000); } else if (entry.chain === "sc") { // Sia's SiaWallet needs the 32-byte root at mount time — its key // tree derives eagerly. Fetch the signer material from the vault // (this branch runs only while the vault is unlocked; a locked // vault would surface at import-time and gate the flow there). // Falls back to a read-only stub if the fetch fails so a stray // locked mount doesn't break panel rendering. if (!c.api.vault?.imports || typeof c.api.vault.imports.signer !== "function") { throw new Error("vault.imports.signer unavailable — cannot mount Sia import"); } const signerBlob = await c.api.vault.imports.signer(entry.importId); if (!signerBlob || signerBlob.kind !== "seed" || !signerBlob.seed) { throw new Error("Sia signer material missing or malformed"); } const seedHex = String(signerBlob.seed).trim(); if (!/^[0-9a-f]{64}$/i.test(seedHex)) throw new Error("Sia seed must be 32 bytes"); const rootBytes = new Uint8Array(seedHex.match(/../g).map((x) => parseInt(x, 16))); const walletdUrl = String(c.api.storage.get(`wallets/${entry.id}/walletdUrl`, "") || ""); adapter = new c.d.siaAdapter.SiaWallet(rootBytes, { walletId: entry.id, storage: c.api.storage, log: (...a) => c.api.log(`[${entry.id}]`, ...a), onChange: () => emitStateForWallet(entry.id), walletdUrl, }); if (walletdUrl && typeof adapter.startPolling === "function") adapter.startPolling(); } else { throw new Error(`no imported adapter for chain "${entry.chain}"`); } rt.adapter = adapter; rt.phase = "ready"; 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(); } return; } 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") { if (!c.d.dgbAdapter) throw new Error("DGB unavailable (bundled ESM couldn't resolve peer deps)"); 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)); if (entry.chain === "bch" && c.wc) { c.wc.startForWallet({ walletId: entry.id, label: entry.label, // Resolve the default from the wallet's OWN network. This used to // fall back to a hardcoded m/44'/145'/0' (mainnet), so a chipnet // wallet — which derives from m/44'/1'/0' — advertised xpubs for a // completely different key tree. Pairing succeeded and then the // dapp saw an unrelated, empty wallet; anything it built spent // from addresses this wallet does not own, so signing failed with // "no path for input". Silent, and only visible on testnet. root32: root, accountPath: entry.accountPath || adapter.snapshot?.()?.accountPath || "m/44'/145'/0'", }).catch((e) => c.api.log(`[${entry.id}] wc start:`, 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 {} } } // Create a vault-derived wallet for a coin+network and mount it. Lifted out // of the addWallet handler so "promote to HD" can build its destination // through exactly the same path the Add flow uses — purpose allocation, the // legacy-purpose carry-over and id numbering all stay in one place rather // than being reimplemented slightly differently next to a money transfer. async function createVaultWallet({ chain, network, label }) { const meta = chainMeta(chain, network); if (!meta) throw new Error("unknown chain/network"); const list = walletEntries().slice(); 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 { purpose = meta.purposePrefix + nextIndex(list, meta); } const id = makeWalletId(meta, nextIndex(list, meta)); if (list.some((w) => w.id === id || w.purpose === purpose)) throw new Error("duplicate wallet"); const entry = { id, chain, network, purpose, isLegacy, label: String(label || "").trim() || autoLabel(meta, list), createdAt: Date.now(), }; list.push(entry); writeWallets(ctx.api, list); ctx.api.storage.set("selectedWalletId", id); ctx.runtimes.set(id, { entry, phase: "locked", error: null, adapter: null }); emitState(); await mountWallet(entry); return entry; } function unmountWallet(walletId) { const rt = ctx.runtimes.get(walletId); if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} } if (ctx.wc) { try { ctx.wc.stopForWallet(walletId); } catch {} } wcIneligible.delete(walletId); ctx.runtimes.delete(walletId); } // ---- import derive helpers (client-side, cashaddr only) -------------------- // The pasted material never leaves this process — main-side stores it once // via api.vault.imports.add. These helpers only turn (seed+path) or WIF into // a P2PKH cashaddr, which is safe to send back to the panel. // The import form's path box can hold either shape: a full leaf // (m/44'/145'/0'/0/0) or a BIP44 ACCOUNT path, which is what a Copay / // Bitcoin.com backup QR carries. The address we display should be the // wallet's first receive address either way, so an account path gets // extended rather than derived as if it were a leaf — deriving the account // node itself yields an address the wallet has never used, which is exactly // how an imported wallet ends up showing a balance of zero. function firstReceivePath(path) { const p = String(path || "").trim(); return wcAccountPath(p) === p ? p + "/0/0" : p; } function deriveCashaddrFromSeed(seedHex, path, prefix) { const d = ctx.d; const seed = new Uint8Array(seedHex.length / 2); for (let i = 0; i < seed.length; i++) seed[i] = parseInt(seedHex.substr(i * 2, 2), 16); const root = d.HDKey.fromMasterSeed(seed); const node = root.derive(path); const h160 = d.ripemd160(d.sha256(node.publicKey)); return d.cashaddr.encode(prefix, 0, h160); } function deriveCashaddrFromWif(wif, prefix) { const d = ctx.d; // WIF layout: base58check(networkByte || privkey32 || [compressionByte 0x01]) // Wrap decodeCheck so a malformed WIF (bad chars, bad checksum, or an // internal library shape change) surfaces as a user-facing "invalid WIF" // instead of leaking "TypeError: base58check.decode is not a function". let raw; try { raw = d.base58check.decodeCheck(wif); } catch (e) { throw new Error("invalid WIF format (base58check decode failed)"); } if (raw.length !== 33 && raw.length !== 34) throw new Error(`bad WIF length ${raw.length}`); // First byte is version (network); we allow any — BCH mainnet uses 0x80, // testnet 0xEF. Both round-trip through the same address derivation below. const priv = raw.slice(1, 33); const compressed = raw.length === 34; // trailing 0x01 marker const pub = d.secp256k1.getPublicKey(priv, compressed); const h160 = d.ripemd160(d.sha256(pub)); return d.cashaddr.encode(prefix, 0, h160); } // Plain-text body + rows for the host overlay (it escapes everything, so // markup would show up literally). Outputs are decoded best-effort from the // libauth transaction the dapp sent: P2PKH / P2SH locking bytecode → cashaddr. // The WC message nests the WcSignTransactionRequest under .transaction, so // the libauth tx itself is request.transaction.transaction (see wc-sign.js). function buildWcApproval(payload) { const req = payload?.request || {}; const tx = req.transaction || {}; const dappName = String(tx.userPrompt || payload?.dappName || "unknown dapp").slice(0, 120); const walletName = payload?.label || payload?.walletId || ""; const network = ctx.runtimes.get(payload?.walletId)?.entry?.network; const prefix = network === "chipnet" ? "bchtest" : "bitcoincash"; let inner = tx.transaction; if (typeof inner === "string") { try { const lib = ctx.d.libauth; const dec = (lib.decodeTransactionCommon || lib.decodeTransaction)(ctx.d.tx.fromHex(inner)); inner = typeof dec === "string" ? null : dec; } catch { inner = null; } } const inputCount = Array.isArray(req.inputPaths) ? req.inputPaths.length : (Array.isArray(inner?.inputs) ? inner.inputs.length : "?"); const rows = [{ label: "Wallet", value: walletName }, { label: "Inputs", value: String(inputCount) }]; // What the wallet is putting IN. The outputs alone never showed that a // transaction spends the user's tokens — and with the token prefix now // signed correctly (wc-sign.js) such a transaction is valid, so the // tokens leaving must be on the overlay. const hexOf = (v) => (typeof v === "string" ? v.toLowerCase() : Buffer.from(v || []).toString("hex")); const tokenText = (t) => { if (!t) return ""; const cat = hexOf(t.category); let amt = "0"; try { amt = BigInt(t.amount ?? 0).toString(); } catch {} const nft = t.nft ? ` + NFT (${String(t.nft.capability || "none")})` : ""; return ` + token ${cat.slice(0, 8)}…${cat.slice(-4)}: ${amt} units${nft}`; }; try { const srcs = Array.isArray(tx.sourceOutputs) ? tx.sourceOutputs : []; if (srcs.length) { let inTotal = 0n; const tokenLines = []; srcs.forEach((o, i) => { try { inTotal += BigInt(o.valueSatoshis ?? 0); } catch {} if (o.token) tokenLines.push(`input #${i + 1}${tokenText(o.token)}`); }); rows.push({ label: "Spending", value: `${fmtBch(Number(inTotal))} BCH from ${srcs.length} input${srcs.length === 1 ? "" : "s"}` }); if (tokenLines.length) rows.push({ label: "Tokens spent", value: tokenLines.slice(0, 8).join("\n") + (tokenLines.length > 8 ? `\n… and ${tokenLines.length - 8} more` : ""), mono: true, strong: true }); } } catch {} try { const outs = inner?.outputs || []; let total = 0n; outs.slice(0, 8).forEach((o, i) => { const lb = o.lockingBytecode; const hexScript = typeof lb === "string" ? lb.toLowerCase() : Buffer.from(lb || []).toString("hex"); let addr = null; if (/^76a914[0-9a-f]{40}88ac$/.test(hexScript)) addr = ctx.d.cashaddr.encode(prefix, 0, ctx.d.tx.fromHex(hexScript.slice(6, 46))); else if (/^a914[0-9a-f]{40}87$/.test(hexScript)) addr = ctx.d.cashaddr.encode(prefix, 1, ctx.d.tx.fromHex(hexScript.slice(4, 44))); const v = BigInt(o.valueSatoshis ?? 0); total += v; const token = tokenText(o.token); rows.push({ label: `Output #${i + 1}`, value: `${fmtBch(Number(v))} BCH${token} → ${addr || (hexScript.startsWith("6a") ? "OP_RETURN data" : "script " + hexScript.slice(0, 24) + "…")}`, mono: true }); }); if (outs.length > 8) rows.push({ label: "Outputs", value: `… and ${outs.length - 8} more` }); if (outs.length) rows.push({ label: "Total out", value: `${fmtBch(Number(total))} BCH`, strong: true }); } catch {} rows.push({ label: "After signing", value: tx.broadcast ? "the dapp broadcasts it" : "signed hex is returned to the dapp" }); rows.push({ label: "Sighash", value: "ALL | FORKID | UTXOS" }); return { body: `${dappName} asks you to sign a Bitcoin Cash transaction.`, rows, dappName }; } // ---- per-purpose default wallets ------------------------------------------- // The wallet you pay from and the wallet you hand to a dapp are not // necessarily the same one, and until now both fell out of whatever happened // to be selected in the panel. That is why a WizardConnect pairing could land // on an address the user did not recognise: with the selected wallet // ineligible, pairing silently took the first one in list order. // // A role points at a wallet id. An id whose wallet has since been removed // reads back as null rather than being trusted, so a stale pointer can never // quietly redirect a payment. const WALLET_ROLES = ["payments", "wizardconnect"]; function walletRoles() { const raw = ctx.api.storage.get("aegis/roles/v1", null); const list = readWallets(ctx.api) || []; const out = {}; for (const role of WALLET_ROLES) { const id = raw && typeof raw === "object" ? String(raw[role] || "") : ""; out[role] = id && list.some((w) => w.id === id) ? id : null; } return out; } function setWalletRole(role, walletId) { if (!WALLET_ROLES.includes(role)) throw new Error("unknown role: " + role); const next = walletRoles(); if (walletId == null || walletId === "") next[role] = null; else { const list = readWallets(ctx.api) || []; if (!list.some((w) => w.id === walletId)) throw new Error("no such wallet"); next[role] = String(walletId); } ctx.api.storage.set("aegis/roles/v1", next); return next; } // ---- 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; // Nothing picked yet this session. The wallet the user nominated for // payments is a better opening guess than list order. const pay = walletRoles().payments; if (pay) return pay; 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"; } // Per-wallet reason a BCH wallet can't do WizardConnect even though it's // mounted and ready — today that's single-key (WIF) imports, which have no // seed to derive a per-dapp key tree from. Surfaced in walletSummary so the // picker can grey them out instead of offering a pairing that must fail. const wcIneligible = new Map(); function wcErrReason(e) { const m = e?.message || String(e); return /locked|vault/i.test(m) ? { short: "vault locked", detail: "Unlock the password vault to use WizardConnect with this wallet." } : { short: "unavailable", detail: m }; } 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, kind: w.kind || null, 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, // Prefer the wallet-registry's stored accountPath; fall back to whatever // the runtime derived (default when the user hasn't overridden). Nulls // stay null so the picker knows whether to render the mono path line. accountPath: w.accountPath || snap?.accountPath || null, balance: snap?.balance || { confirmed: 0, unconfirmed: 0 }, // Per-wallet assets, so the coin drilldown can show what each ADDRESS // holds instead of only the selected wallet's. `tokens` is the account- // model shape (SPL / TRC20); `tokenBalances` is BCH CashTokens, keyed // by category. Both stay null/empty for chains that have neither. tokens: Array.isArray(snap?.tokens) ? snap.tokens : [], tokenBalances: snap?.tokenBalances || null, phase: rt?.phase || "locked", error: rt?.error || null, wcBlocked: w.chain === "bch" ? (wcIneligible.get(w.id)?.detail || null) : null, wcBlockedShort: w.chain === "bch" ? (wcIneligible.get(w.id)?.short || null) : 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, kind: entry?.kind || null, 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(), roles: walletRoles(), wallets: walletEntries().map(walletSummary), selected: snapshotForSelected(), bchServers: { list: bchServerList(ctx.api), custom: Array.isArray(ctx.api.storage.get("servers", null)), }, coins: coinsForPanel(), prices: ctx.priceFeed ? ctx.priceFeed.snapshot() : { enabled: false, prices: {} }, wc: ctx.wc ? ctx.wc.snapshot() : {}, }; } 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()); } // ---- PIN: sealed blob + host-verified transaction clearance --------------- // The PIN blob is the vault master password wrapped under a 6-digit PIN. In // plain add-on storage that made the master password exactly as strong as a // million PBKDF2 guesses to anyone holding a copy of the profile (a backup, // another machine, a disk image) — the panel's lockout counter never sees an // offline guess. It is therefore sealed with the OS keystore (DPAPI / // Keychain / libsecret) before it touches disk, the same as Theseus's own // vault PIN: a copied file is useless without this OS account. A plain blob // from an older build is sealed the first time it is read. const PIN_BLOB_KEY = "aegis/pin/v1"; function sealPinBlob(api, blob) { const ss = safeStorageOr(api); try { if (ss && ss.isEncryptionAvailable()) { return { v: 2, sealed: ss.encryptString(JSON.stringify(blob)).toString("base64") }; } } catch { /* fall through: unsealed is still better than no PIN at all */ } return blob; } function openPinBlob(api) { const b = api.storage.get(PIN_BLOB_KEY, null); if (!b || typeof b !== "object") return null; if (b.sealed) { const ss = safeStorageOr(api); if (!ss) return null; try { const plain = JSON.parse(ss.decryptString(Buffer.from(String(b.sealed), "base64"))); return (plain && typeof plain === "object") ? plain : null; } catch { return null; } // sealed under another OS account: the PIN is simply gone } const plain = { salt: b.salt, iv: b.iv, ct: b.ct, iters: b.iters }; const sealed = sealPinBlob(api, plain); if (sealed.sealed) api.storage.set(PIN_BLOB_KEY, sealed); return plain; } // The PIN is checked here, never in the panel. The panel used to fetch the // blob and decrypt it itself, then report its own failures — so the lockout // counted only the guesses a well-behaved panel chose to report, and anything // that could run in the panel could take the blob and search all million // PINs offline. Now the blob stays in this process, every guess is counted // before it is tried, and PIN_MAX_FAILS wrong guesses switch the PIN off // until the master password is entered (no timer that hands out more tries). // The blob format is unchanged: PBKDF2-SHA256(pin) -> AES-256-GCM, hex, tag // appended to the ciphertext, as WebCrypto wrote it. const PIN_ITERS = 600_000; const PIN_MAX_FAILS = 5; const PIN_RE = /^\d{6}$/; const nodeCrypto = require("node:crypto"); const pinKey = (pin, saltHex, iters) => new Promise((resolve, reject) => nodeCrypto.pbkdf2(String(pin), Buffer.from(saltHex, "hex"), iters, 32, "sha256", (e, k) => (e ? reject(e) : resolve(k)))); async function pinWrap(pin, masterPassword) { const salt = nodeCrypto.randomBytes(16).toString("hex"); const iv = nodeCrypto.randomBytes(12); const c = nodeCrypto.createCipheriv("aes-256-gcm", await pinKey(pin, salt, PIN_ITERS), iv); const ct = Buffer.concat([c.update(String(masterPassword), "utf8"), c.final(), c.getAuthTag()]); return { salt, iv: iv.toString("hex"), ct: ct.toString("hex"), iters: PIN_ITERS }; } async function pinUnwrapBlob(pin, blob) { const ct = Buffer.from(String(blob.ct), "hex"); const d = nodeCrypto.createDecipheriv("aes-256-gcm", await pinKey(pin, blob.salt, Number(blob.iters) || PIN_ITERS), Buffer.from(String(blob.iv), "hex")); d.setAuthTag(ct.subarray(ct.length - 16)); return Buffer.concat([d.update(ct.subarray(0, ct.length - 16)), d.final()]).toString("utf8"); } function pinFails(api) { const n = Number(api.storage.get("aegis/pin/failCount", 0)) || 0; return { fails: n, requireMaster: !!api.storage.get("aegis/pin/requireMaster", false) || n >= PIN_MAX_FAILS }; } // A master password the vault accepted. Clears the PIN strikes — the only // thing that does, apart from a correct PIN while the PIN is still allowed. function noteMasterVerified(api) { api.storage.set("aegis/pin/failCount", 0); api.storage.set("aegis/pin/requireMaster", false); } // "Ask for PIN on every transaction" used to be decided by the host and // enforced by nobody: `send` never checked it, and a dapp-initiated // transaction had no PIN step at all. A clearance is now a short-lived, // single-use fact recorded only after the host itself has verified the // master password the PIN unwraps (pinGateSatisfied). Panel sends consume // one; dapp transactions ask the open panel for one and wait. const TX_CLEARANCE_MS = 90_000; const DAPP_PIN_WAIT_MS = 120_000; let txClearanceUntil = 0; let pendingPinRequest = null; // { origin, what, at } while a dapp tx waits for the PIN function txPinOwed() { try { return !!(ctx && ctx.pinNeeded && ctx.pinNeeded("transaction").needPin); } catch { return true; } // the safe direction for a lock is closed } function takeTxClearance() { if (Date.now() < txClearanceUntil) { txClearanceUntil = 0; return true; } return false; } function requirePanelTxPin() { if (txPinOwed() && !takeTxClearance()) throw new Error("PIN required — confirm your PIN to continue"); } async function requireDappTxPin(origin, what) { if (!txPinOwed() || takeTxClearance()) return; pendingPinRequest = { origin: String(origin || ""), what: String(what || "transaction"), at: Date.now() }; try { try { ctx.api.emit("pinRequest", pendingPinRequest); } catch {} const deadline = Date.now() + DAPP_PIN_WAIT_MS; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 250)); if (!ctx) break; if (takeTxClearance()) return; } } finally { pendingPinRequest = null; } throw new Error("Aegis asks for your PIN on every transaction. Open the Aegis panel, confirm your PIN there, then try again."); } // Signed text shown on an approval overlay. Long messages are cut for the // overlay's sake, but never silently: the cut says how much is missing, so a // benign-looking opening cannot hide what the rest commits the user to. function previewText(text, max = 1500) { const s = String(text); return s.length > max ? `${s.slice(0, max)}\n… [${s.length - max} more characters are NOT shown but will be signed]` : s; } 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; } // Head and tail of an address, with any "bitcoincash:"/"bchtest:" prefix // dropped — the prefix is the same on every row, so it costs width without // helping anyone tell two addresses apart. function shortAddr(addr) { const s = String(addr || ""); const body = s.includes(":") ? s.slice(s.indexOf(":") + 1) : s; return body.length <= 20 ? body : body.slice(0, 10) + "…" + body.slice(-6); } 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 || ""); // Purpose allocation and mounting live in createVaultWallet — see there // for why (legacyFirstPurpose carry-over, id numbering). await createVaultWallet({ chain, network, label: String(p && p.label || "") }); return fullState(); }); // Vault lifecycle from inside the wallet panel — no more redirecting the // user to Settings > Passwords. After a successful unlock/setup we remount // every wallet: the vault-derive route is now available. api.onMessage("vaultSetup", async (p, m) => { fromPanel(m); const pw = String(p && p.masterPassword || ""); const seedSource = p && p.seedSource; await api.vault.lifecycle.setup(pw, seedSource); await mountAllWallets(); return fullState(); }); api.onMessage("vaultUnlock", async (p, m) => { fromPanel(m); const pw = String(p && p.masterPassword || ""); await api.vault.lifecycle.unlock(pw); noteMasterVerified(api); await mountAllWallets(); return fullState(); }); api.onMessage("vaultStatus", async (_p, m) => { fromPanel(m); return api.vault.lifecycle.status(); }); // ---- wallet import (M.1 of DESIGN-wallet-multi-account-amendment.md) ---- // Accepts either a BIP39 mnemonic (12/24 words) + BIP44 path, OR a raw WIF. // Derives the P2PKH cashaddr client-side, stores the signer material via // api.vault.imports.add (main-process holds it), then adds a slim Aegis // wallet entry with kind=imported pointing at the returned importId. api.onMessage("importWallet", async (p, m) => { fromPanel(m); const chain = String(p && p.chain || "bch"); const network = String(p && p.network || "").trim(); const label = String(p && p.label || "").trim(); if (!label) throw new Error("label required"); const category = String(p && p.category || "operational").trim(); const source = String(p && p.source || "manual-paste"); // Chain-specific address derivation. Every branch has to produce an // `address` string + fill spec.{seed,path} or spec.wif/privkey. The // spec is what lands in wallet-imports.enc; the address gets stored on // the Aegis wallet entry so the picker/strip can show it without // touching the imports file. const spec = { kind: null, label, category, source }; let address = null; const der = ctx.d.derive; if (chain === "bch") { const net = network || "chipnet"; if (net !== "mainnet" && net !== "chipnet") throw new Error(`BCH network must be mainnet or chipnet (got ${net})`); const prefix = net === "mainnet" ? "bitcoincash" : "bchtest"; if (p && p.wif) { spec.kind = "wif"; spec.wif = String(p.wif).trim(); address = deriveCashaddrFromWif(spec.wif, prefix); } else if (p && p.mnemonic) { spec.kind = "seed"; spec.seed = der.mnemonicToSeedHex(String(p.mnemonic).trim()); spec.path = String(p.path || (net === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0")); address = deriveCashaddrFromSeed(spec.seed, firstReceivePath(spec.path), prefix); } else if (p && p.seedHex) { spec.kind = "seed"; spec.seed = String(p.seedHex).trim().toLowerCase().replace(/^0x/, ""); if (!/^[0-9a-f]{64,128}$/.test(spec.seed)) throw new Error("seedHex must be 32-64 bytes of hex"); spec.path = String(p.path || (net === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0")); address = deriveCashaddrFromSeed(spec.seed, firstReceivePath(spec.path), prefix); } else { throw new Error("supply mnemonic, seedHex, or wif"); } spec.cashaddr = address; } else if (chain === "btc" || chain === "dgb") { const defaults = { btc: { network: "mainnet", path: "m/84'/0'/0'/0/0" }, dgb: { network: "mainnet", path: "m/84'/20'/0'/0/0" } }; const net = network || defaults[chain].network; const purposeHint = Number(p && p.purpose || 84); if (p && p.wif) { spec.kind = "wif"; spec.wif = String(p.wif).trim(); address = chain === "btc" ? der.btc.fromWif(spec.wif, net, purposeHint) : der.dgb.fromWif(spec.wif, purposeHint); } else if (p && p.mnemonic) { spec.kind = "seed"; spec.seed = der.mnemonicToSeedHex(String(p.mnemonic).trim()); spec.path = String(p.path || defaults[chain].path); if (chain === "btc") address = der.btc.fromSeed(spec.seed, spec.path, net); else { // A DigiByte seed can belong to the 2018-19 official mobile // wallets, which built the master node with HMAC key // "DigiByte seed". Carry the choice onto the spec so the entry // records which tree the stored address came from. const hk = String((p && p.hmacKey) || der.dgb.HMAC_BITCOIN_SEED); if (hk !== der.dgb.HMAC_BITCOIN_SEED && hk !== der.dgb.HMAC_DIGIBYTE_SEED) { throw new Error("unknown DigiByte master-key variant"); } if (hk !== der.dgb.HMAC_BITCOIN_SEED) spec.hmacKey = hk; address = der.dgb.fromSeed(spec.seed, spec.path, hk); } } else { throw new Error("supply mnemonic or wif"); } // Theseus's api.vault.imports.add validates a `cashaddr` field (from // when only BCH imports existed). Reuse the same field name for // every chain — Aegis reads it back by importId and knows the shape // via entry.chain. Doesn't have to be a real cashaddr. spec.cashaddr = address; } else if (chain === "eth" || chain === "trx" || chain === "sol") { const defaults = { eth: { network: "mainnet", path: "m/44'/60'/0'/0/0" }, trx: { network: "mainnet", path: "m/44'/195'/0'/0/0" }, sol: { network: "mainnet", path: "m/44'/501'/0'/0'" }, }; const net = network || defaults[chain].network; if (p && p.mnemonic) { spec.kind = "seed"; spec.seed = der.mnemonicToSeedHex(String(p.mnemonic).trim()); spec.path = String(p.path || defaults[chain].path); if (chain === "eth") address = der.eth.fromSeed(spec.seed, spec.path); else if (chain === "trx") address = der.trx.fromSeed(spec.seed, spec.path); else address = der.sol.fromSeed(spec.seed, spec.path); } else if (p && p.privHex) { // Theseus's vault.imports.add only recognises kind "seed" (BIP39 // + path) and "wif" (a base58check Bitcoin key). Raw hex keys // for ETH/TRX/SOL don't fit either shape, so we pack them into // the wif slot with a scheme prefix (`aegis-privhex:`) — // the vault doesn't inspect the value, just stores it. Aegis // reads its own prefix back when spending ships. Panel state // + address are computed here, so read-only balance / receive // work today without touching the vault field. spec.kind = "wif"; // Normalise raw hex the user pasted. Tolerate every common mangle // path so the panel error surface is a clear "expected 32-byte // hex" instead of the raw noble/hashes error string: // - leading / trailing whitespace, mixed case // - "0x" or "0X" prefix // - internal whitespace, tabs, newlines, commas, colons, dashes // - accidental quotes wrapping the paste // - a preamble like "private key: " (e.g. from an AI-agent // transcript) — pick the longest hex-shaped substring. let raw = String(p.privHex).trim(); raw = raw.replace(/^['"`]+|['"`]+$/g, ""); // If the user pasted a multi-line block, extract the longest // run of hex characters and treat that as the key. const hexRuns = raw.match(/[0-9a-fA-F]{16,}/g); if (hexRuns && hexRuns.length) { hexRuns.sort((a, b) => b.length - a.length); raw = hexRuns[0]; } raw = raw.toLowerCase().replace(/^0x/, "").replace(/[\s,:_\-]/g, ""); if (!/^[0-9a-f]+$/.test(raw)) { // Give the user something concrete to act on. Tron-specific // hints: base58-shaped strings that start with T (34 chars) are // addresses, not private keys; whitespace-separated words look // like a mnemonic. const original = String(p.privHex).trim(); if (/^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(original)) { throw new Error("That looks like a Tron address (T…), not a private key. Paste the 64-hex-character private key instead."); } if (/^([a-z]+\s+){11,}[a-z]+$/i.test(original)) { throw new Error("That looks like a BIP39 mnemonic. Switch the import format to 'Mnemonic + path'."); } throw new Error("Private key must be hex (with or without 0x). Whitespace, dashes and colons are ignored, but non-hex characters aren't accepted."); } if (raw.length !== 64) { throw new Error(`Private key must be 32 bytes (64 hex characters). Got ${raw.length} hex character${raw.length === 1 ? "" : "s"} after normalising the paste.`); } // Derive first so any bad key surfaces before we write to disk. if (chain === "eth") address = der.eth.fromPrivHex(raw); else if (chain === "trx") address = der.trx.fromPrivHex(raw); else address = der.sol.fromPrivHex(raw); spec.wif = `aegis-privhex:${raw}`; } else if (p && p.privB58 && chain === "sol") { // Same repacking trick as privhex above — Solana's Phantom-style // base58 key gets packed into wif with an `aegis-privb58:` tag. const raw = String(p.privB58).trim(); address = der.sol.fromBase58(raw); spec.kind = "wif"; spec.wif = `aegis-privb58:${raw}`; } else { throw new Error("supply mnemonic, privHex" + (chain === "sol" ? ", or privB58" : "")); } spec.cashaddr = address; // storage-key reuse — see BTC/DGB comment above } else if (chain === "sc") { // Siacoin. Uses a 32-byte root seed + u64 index (KeyFromSeed layout); // no BIP44 path. Accepts a BIP39 12-word mnemonic (matches Sia // Central Lite / walletd, PBKDF2 → first 32 bytes) or raw 32-byte // seed hex. Address at index 0 is what we surface at import time; // the mounted SiaWallet lets users advance through additional // indices via the "Next unused address" affordance. const net = network || "mainnet"; if (net !== "mainnet") throw new Error(`SC only supports mainnet (got ${net})`); const index = Number(p && p.index != null ? p.index : 0); if (!Number.isInteger(index) || index < 0) throw new Error("SC index must be a non-negative integer"); let seedHex; if (p && p.mnemonic) { const r = der.sc.fromMnemonic(String(p.mnemonic).trim(), index); seedHex = r.seedHex; address = r.address; } else if (p && p.seedHex) { const r = der.sc.fromSeedHex(String(p.seedHex).trim(), index); seedHex = r.seedHex; address = r.address; } else { throw new Error("supply mnemonic or seedHex"); } spec.kind = "seed"; spec.seed = seedHex; // path field carries the Sia address index as an integer string, // opaque to the vault. Mount reads it back as Number(spec.path). spec.path = String(index); spec.cashaddr = address; } else { throw new Error(`import not supported for chain "${chain}"`); } const { id: importId } = await api.vault.imports.add(spec); const netForId = network || "mainnet"; const list = walletEntries().slice(); const walletId = `${chain}-imported-${importId}`; if (list.some((w) => w.id === walletId)) throw new Error("duplicate import id"); const entry = { id: walletId, label, chain, network: netForId, kind: "imported", importId, importedAddress: address, importedCategory: category, accountPath: spec.path || null, createdAt: Date.now(), }; list.push(entry); writeWallets(api, list); api.storage.set("selectedWalletId", walletId); ctx.runtimes.set(walletId, { 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(); }); // Panel drilldown → refresh every wallet under a chain (optionally scoped // to one subnetwork). Fires each adapter's refresh in parallel; individual // failures set the adapter's own error field (surfaced back to the panel // via emitStateForWallet) rather than aborting the batch. Returns the // list of {id, ok, error} so the panel can flash a summary. api.onMessage("refreshChain", async (p, m) => { fromPanel(m); const chain = String(p?.chain || ""); const network = p?.network ? String(p.network) : null; if (!chain) throw new Error("chain is required"); const targets = walletEntries().filter((w) => w.chain === chain && (!network || w.network === network)); const out = []; await Promise.all(targets.map(async (w) => { const rt = ctx.runtimes.get(w.id); if (!rt || !rt.adapter) { out.push({ id: w.id, ok: false, error: "adapter not mounted" }); return; } try { await rt.adapter.refresh(true); // The adapters catch their own fetch failures onto state.error rather // than rejecting, so awaiting refresh() proves nothing. Read the // snapshot back, or a dead server reports a successful refresh. let snapErr = null; try { snapErr = rt.adapter.snapshot()?.error || null; } catch { snapErr = null; } out.push(snapErr ? { id: w.id, ok: false, error: snapErr } : { id: w.id, ok: true }); } catch (e) { out.push({ id: w.id, ok: false, error: e?.message || String(e) }); } })); return { chain, network, results: out }; }); 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("aegisVersion", (_p, m) => { fromPanel(m); try { return require("./addon.json").version; } catch { return ""; } }); // Panel gate uses this to jump to Settings › Passwords when the vault is // locked / not yet created — one-click bridge to Theseus's built-in UI. api.onMessage("openSettings", (p, m) => { fromPanel(m); api.openSettings(String(p && p.section || "")); return true; }); // Panel-initiated update flow. Preferred path: Theseus exposes // checkAndStageSelfUpdate + restartApp (added 0.3.48). The panel calls // "requestUpdate" for the two-step chip flow: // step "stage" — verify + stage the newest signed build; the reply // carries { status, current, next } so the panel can // show "Update to vX.Y.Z ready — restart to apply". // step "apply" — cleanly relaunches Theseus, which runs // promoteStagedUpdates() before activating add-ons. // Falls back to opening Settings › Extensions when running under an // older Theseus that lacks either hook. api.onMessage("requestUpdate", async (p, m) => { fromPanel(m); const step = String(p?.step || "stage"); if (step === "apply") { if (typeof api.restartApp !== "function") return { restarted: false, fallback: "settings" }; try { api.restartApp(); return { restarted: true }; } catch (e) { return { restarted: false, err: e?.message || String(e) }; } } if (typeof api.checkAndStageSelfUpdate !== "function") return { staged: false, fallback: "settings" }; try { const r = await api.checkAndStageSelfUpdate(); // "staged" and "already-staged" both mean a newer signed build is // waiting for the next launch — surface it to the panel identically. const ok = r?.status === "staged" || r?.status === "already-staged"; return { staged: ok, status: r?.status || "unknown", detail: r?.detail || null, current: r?.current || null, next: r?.next || null }; } catch (e) { return { staged: false, err: e?.message || String(e) }; } }); 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); requirePanelTxPin(); 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); requirePanelTxPin(); // The panel names the wallet its form was built for. If the selection // moved since (another surface, a late state push), refuse rather than // send this amount from a different wallet. if (p && p.walletId && String(p.walletId) !== selectedWalletId()) { throw new Error("the selected wallet changed — review the send and try again"); } 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); }); // ---- Balance consolidation ------------------------------------------------ // Batch send-max from every same-chain/same-network wallet (or a subset the // user picked with checkboxes) into the currently-selected wallet. Runs in // two phases: // consolidatePreview — dry-run plan() per source; returns balance/fee/ // net/error so the panel renders a preview list // with checkboxes without asking the user to // approve anything yet. // consolidateIntoSelected — signs + broadcasts one send per chosen source. // The panel shows a single upfront confirmation // (with the total to move and total fees); Theseus's // per-tx approval overlay is skipped because the // batch itself is the user's explicit intent. api.onMessage("consolidatePreview", async (_p, m) => { fromPanel(m); const destId = selectedWalletId(); if (!destId) throw new Error("no wallet selected"); const destEntry = walletEntries().find((w) => w.id === destId); if (!destEntry) throw new Error("selected wallet not found"); const destRt = ctx.runtimes.get(destId); if (!destRt?.adapter) throw new Error("destination wallet not ready"); const destSnap = destRt.adapter.snapshot(); const destAddr = destSnap.address; if (!destAddr) throw new Error("destination wallet has no receive address"); const sources = walletEntries().filter((w) => w.chain === destEntry.chain && w.network === destEntry.network && w.id !== destId, ); const items = []; for (const src of sources) { const rt = ctx.runtimes.get(src.id); const snap = rt?.adapter?.snapshot?.() || {}; const bal = snap.balance || {}; const totalUnits = typeof bal.confirmed === "string" ? (BigInt(bal.confirmed || "0") + BigInt(bal.unconfirmed || "0")).toString() : String((bal.confirmed || 0) + (bal.unconfirmed || 0)); const base = { walletId: src.id, label: src.label, address: snap.address || null, balance: totalUnits, fee: null, net: null, error: null, eligible: false, }; if (!rt?.adapter) { items.push({ ...base, error: "adapter not mounted" }); continue; } if (typeof rt.adapter.plan !== "function") { items.push({ ...base, error: "adapter has no plan()" }); continue; } // Dry-run send-max to the destination. plan() throws on empty / // dust-only wallets — that's the "nothing to sweep" case and it // reads as an error string per source in the preview. try { const plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true })); const fee = String(plan.fee ?? 0); const net = String(plan.recipients?.[0]?.value ?? 0); items.push({ ...base, fee, net, eligible: true }); } catch (e) { items.push({ ...base, error: e?.message || String(e) }); } } const meta = chainMeta(destEntry.chain, destEntry.network) || null; return { destinationWalletId: destId, destinationLabel: destEntry.label, destinationAddress: destAddr, chain: destEntry.chain, network: destEntry.network, ticker: meta?.ticker || "", decimals: meta?.decimals || 8, sources: items, }; }); api.onMessage("consolidateIntoSelected", async (p, m) => { fromPanel(m); requirePanelTxPin(); const destId = selectedWalletId(); if (!destId) throw new Error("no wallet selected"); // The destination is the wallet the PREVIEW was drawn for, not whatever // is selected by the time the button is pressed: a preview painted for A // followed by a switch to B used to sweep everything into B. if (p && p.destinationWalletId && String(p.destinationWalletId) !== destId) { throw new Error("the selected wallet changed since this preview — reopen Consolidate"); } const destEntry = walletEntries().find((w) => w.id === destId); if (!destEntry) throw new Error("selected wallet not found"); const destRt = ctx.runtimes.get(destId); if (!destRt?.adapter) throw new Error("destination wallet not ready"); const destAddr = destRt.adapter.snapshot().address; if (!destAddr) throw new Error("destination wallet has no receive address"); // sourceIds are the wallets the user CHECKED. They are required: an // omitted list used to mean "every sibling wallet". if (!Array.isArray(p?.sourceIds) || !p.sourceIds.length) throw new Error("choose at least one wallet to consolidate"); const requested = new Set(p.sourceIds.map(String)); const sources = walletEntries().filter((w) => w.chain === destEntry.chain && w.network === destEntry.network && w.id !== destId && requested.has(w.id), ); if (!sources.length) throw new Error("none of the chosen wallets can be consolidated into this one"); // Plan every sweep first, then put the WHOLE batch on the host overlay. // This emptied wallets on the panel's say-so alone; every other spend // in Aegis is confirmed on a surface the panel cannot draw. const meta = chainMeta(destEntry.chain, destEntry.network); const dec = meta?.decimals ?? 8; const planned = []; const results = []; for (const src of sources) { const rt = ctx.runtimes.get(src.id); if (!rt?.adapter || typeof rt.adapter.plan !== "function") { results.push({ walletId: src.id, label: src.label, ok: false, error: "adapter not mounted" }); continue; } try { const plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true })); planned.push({ src, rt, plan }); } catch (e) { results.push({ walletId: src.id, label: src.label, ok: false, error: e?.message || String(e) }); } } if (planned.length) { let totalNet = 0n, totalFee = 0n; const rows = planned.slice(0, 12).map(({ src, plan }) => { const net = BigInt(String(plan.recipients?.[0]?.value ?? 0)); const fee = BigInt(String(plan.fee ?? 0)); totalNet += net; totalFee += fee; return { label: "Empty", value: `${src.label} — ${fmtValue(net.toString(), dec)} ${meta?.ticker || ""}`, mono: true }; }); for (const { plan } of planned.slice(12)) { totalNet += BigInt(String(plan.recipients?.[0]?.value ?? 0)); totalFee += BigInt(String(plan.fee ?? 0)); } if (planned.length > 12) rows.push({ label: "…", value: `and ${planned.length - 12} more wallets` }); rows.push({ label: "Into", value: `${destEntry.label} — ${destAddr}`, mono: true }); rows.push({ label: "Arrives", value: `${fmtValue(totalNet.toString(), dec)} ${meta?.ticker || ""}`, strong: true }); rows.push({ label: "Fees", value: `${fmtValue(totalFee.toString(), dec)} ${meta?.ticker || ""} across ${planned.length} transaction${planned.length === 1 ? "" : "s"}` }); const pick = await api.approvalModal({ title: `Consolidate ${planned.length} wallet${planned.length === 1 ? "" : "s"}?`, origin: "Aegis wallet panel", body: "Each wallet listed is emptied into the destination in its own transaction. This cannot be undone.", rows, actions: [{ id: "send", label: "Consolidate", primary: true }], }); if (pick !== "send") throw new Error("cancelled"); } for (const { src, rt, plan } of planned) { try { const r = await rt.adapter.signAndBroadcast(plan); results.push({ walletId: src.id, label: src.label, ok: true, txid: r?.txid || null, sent: String(plan.recipients?.[0]?.value ?? 0), fee: String(plan.fee ?? 0), }); } catch (e) { results.push({ walletId: src.id, label: src.label, ok: false, error: e?.message || String(e) }); } } // Force a refresh on the destination so its balance jumps once the txs // reach the network's mempool. Silent-fail — panel will pick up state // on the next state emit anyway. try { if (typeof destRt.adapter.refresh === "function") destRt.adapter.refresh(false); } catch {} return { destinationWalletId: destId, destinationAddress: destAddr, chain: destEntry.chain, network: destEntry.network, results, }; }); // ---- promote an import to an HD wallet ---------------------------------- // // A wallet imported from a single private key cannot do WizardConnect: the // handshake ships BIP32 xpubs so the dapp can derive addresses on its own, // and a lone key has no chain code to build one from. Nothing Aegis can do // locally fixes that — manufacturing a parent whose child equals a given // key means inverting HMAC-SHA512. The way out is to stop being a // single-key wallet: derive a proper HD wallet from the vault and sweep the // imported key into it. // // Only BCH imports can do this. The BTC/DGB/ETH/TRX/SOL imported adapters // still throw "read-only" from plan(), so there is no sweep to run. function promotableImport(walletId) { const entry = walletEntries().find((w) => w.id === String(walletId || "")); if (!entry) throw new Error("unknown wallet"); if (entry.kind !== "imported") throw new Error("this wallet is already derived from your vault"); if (entry.chain !== "bch") { throw new Error(`Imported ${String(entry.chain).toUpperCase()} wallets are still read-only, so there is nothing to sweep with yet. Only BCH imports can be promoted today.`); } const rt = ctx.runtimes.get(entry.id); if (!rt?.adapter || rt.phase !== "ready") throw new Error("wallet is still loading — try again in a moment"); return { entry, rt }; } // Open the wallet as a full Theseus tab. It loads panel.html — the very // same file the sidebar uses — with ?surface=web, because an add-on's own // tab is handed the same window.silentmode surface and main dispatches it // as from:"panel". So this is a re-layout, not a second wallet: one set of // handlers, one UI, no drift between the two. api.onMessage("openFullScreen", (_p, m) => { fromPanel(m); if (typeof api.openTab !== "function") { throw new Error("This Theseus build can't open add-on tabs — update Theseus."); } api.openTab("panel.html", { query: { surface: "web" } }); return true; }); api.onMessage("promotePreview", async (p, m) => { fromPanel(m); const { entry, rt } = promotableImport(p && p.walletId); const snap = rt.adapter.snapshot(); const meta = chainMeta(entry.chain, entry.network); // Cost the sweep WITHOUT creating the destination wallet first, so // cancelling the preview leaves nothing behind. A send-max to our own // address spends the same UTXOs into the same single P2PKH output, so // the fee is identical to the real sweep — only the output's 20-byte // hash differs, and that does not change the transaction's size. let fee = null, net = null, error = null; try { const plan = await Promise.resolve(rt.adapter.plan({ to: snap.address, sendMax: true })); fee = String(plan.fee ?? 0); net = String(plan.recipients?.[0]?.value ?? 0); } catch (e) { error = e?.message || String(e); } return { walletId: entry.id, label: entry.label, chain: entry.chain, network: entry.network, networkLabel: meta?.networkLabel || entry.network, ticker: meta?.ticker || "", decimals: meta?.decimals || 8, address: snap.address || null, balance: snap.balance || null, fee, net, error, suggestedLabel: `${entry.label} (HD)`, }; }); api.onMessage("promoteToHd", async (p, m) => { fromPanel(m); requirePanelTxPin(); const { entry, rt } = promotableImport(p && p.walletId); const dest = await createVaultWallet({ chain: entry.chain, network: entry.network, label: String(p && p.label || "") || `${entry.label} (HD)`, }); const destRt = ctx.runtimes.get(dest.id); const destAddr = destRt?.adapter?.snapshot?.()?.address; if (!destAddr) throw new Error("the new wallet came up without a receive address — nothing was moved"); let plan; try { plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true })); } catch (e) { // Nothing was broadcast, so the empty wallet we just made is pure // litter — take it back out. Past this point we keep it even on // failure, because a transaction may already be on the wire and its // destination must stay visible. try { unmountWallet(dest.id); writeWallets(ctx.api, walletEntries().filter((w) => w.id !== dest.id)); } catch {} ctx.api.storage.set("selectedWalletId", entry.id); emitState(); throw e; } const sent = String(plan.recipients?.[0]?.value ?? 0); const fee = String(plan.fee ?? 0); const r = await rt.adapter.signAndBroadcast(plan); // The import keeps its key on purpose. The sweep is unconfirmed for now, // and anyone who still has the old address can pay into it — removing // the key here would strand those coins. The panel offers removal as a // separate step once the balance has actually gone to zero. try { rt.adapter.refresh(false); } catch {} try { destRt.adapter.refresh(false); } catch {} emitState(); return { ok: true, txid: r?.txid || null, sent, fee, fromWalletId: entry.id, fromLabel: entry.label, newWalletId: dest.id, newWalletLabel: dest.label, newAddress: destAddr, ticker: chainMeta(entry.chain, entry.network)?.ticker || "", decimals: chainMeta(entry.chain, entry.network)?.decimals || 8, }; }); 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(); // Public material only. Revealing a secret goes through revealSecret, // which proves the master password first — this used to hand back the // xprv behind nothing but an approval click. return { accountPath: r.accountPath, xpub: r.xpub, purpose: rt.entry.purpose }; }); // ---- reveal a wallet's secret ------------------------------------------ // Authorisation is the master password, and it is verified HERE rather // than taken on trust from the panel: the PIN route never touches // vaultUnlock, so without this check the only thing between a secret and // the screen would be panel-side logic. The panel gets the password either // by decrypting the PIN blob (which wraps exactly this) or by asking. // // What comes back is NOT a recovery phrase, because no BIP39 mnemonic is // stored anywhere in Aegis or the Theseus vault. An import keeps // mnemonicToSeedHex(words) and discards the words (PBKDF2, one-way), and a // vault wallet is HKDF(vault root, purpose) and never had words of its // own — password-vault.js is explicit that the seed is never persisted. // Calling any of this a "recovery phrase" in the UI would be a lie that // costs someone their backup, so every form names itself and says where it // can actually be restored. api.onMessage("revealSecret", async (p, m) => { fromPanel(m); const pw = String((p && p.masterPassword) || ""); if (!pw) throw new Error("master password required"); try { await api.vault.lifecycle.unlock(pw); } catch (e) { // A missing or unset-up vault is a structural failure, not a bad // password — don't accuse the user of mistyping something that was // never going to work. const msg = e?.message || String(e); if (/no vault|not set up/i.test(msg)) throw new Error(msg); throw new Error("wrong master password"); } noteMasterVerified(api); const id = String((p && p.walletId) || selectedWalletId() || ""); const entry = walletEntries().find((w) => w.id === id); if (!entry) throw new Error("no such wallet"); const meta = chainMeta(entry.chain, entry.network); const base = { walletId: entry.id, label: entry.label, chain: entry.chain, coinLabel: meta?.coinLabel || entry.chain, networkLabel: meta?.networkLabel || entry.network, address: ctx.runtimes.get(entry.id)?.adapter?.snapshot()?.address || null, }; if (entry.kind === "imported") { if (!api.vault?.imports || typeof api.vault.imports.signer !== "function") { throw new Error("this build cannot read imported keys"); } const blob = await api.vault.imports.signer(entry.importId); if (blob.kind === "wif") { // ETH/TRX/SOL raw hex keys ride in the wif slot behind a scheme // prefix (see importWallet) — unwrap so the user sees the key. const w = String(blob.wif || ""); const PRIVHEX = "aegis-privhex:"; return w.startsWith(PRIVHEX) ? { ...base, form: "privhex", secret: w.slice(PRIVHEX.length) } : { ...base, form: "wif", secret: w }; } if (blob.kind === "seed") { return { ...base, form: "seed", secret: String(blob.seed || ""), path: blob.path || null }; } throw new Error("unknown import kind: " + blob.kind); } // Vault-derived. Two genuinely useful forms: the account xprv, which // other HD wallets accept, and the 32-byte purpose root Aegis derives // from. Sia's recovery() calls its seed "xprv" and it is the same bytes // as the root, so don't report it twice. let xprv = null, xpub = null, accountPath = null; const rt = ctx.runtimes.get(entry.id); if (rt && rt.adapter && typeof rt.adapter.recovery === "function") { try { const r = rt.adapter.recovery(); xpub = r.xpub || null; accountPath = r.accountPath || null; if (entry.chain !== "sc") xprv = r.xprv || null; } catch { /* public material is a bonus, not the point */ } } const root = await api.vault.derive(entry.purpose); let rootHex = ""; try { rootHex = Array.from(root, (b) => b.toString(16).padStart(2, "0")).join(""); } finally { try { root.fill(0); } catch {} } return { ...base, form: "vault", secret: rootHex, purpose: entry.purpose, xprv, xpub, accountPath }; }); // Opt-in USD prices. Persist the choice so restart doesn't silently // disable it, and kick a fetch immediately when switched on. api.onMessage("setPricesEnabled", async (p, m) => { fromPanel(m); const on = !!(p && p.enabled); api.storage.set("pricesEnabled", on); if (ctx.priceFeed) await ctx.priceFeed.setEnabled(on); return fullState(); }); api.onMessage("refreshPrices", async (_p, m) => { fromPanel(m); if (ctx.priceFeed) await ctx.priceFeed.refresh(); return fullState(); }); api.onMessage("setPricesSource", async (p, m) => { fromPanel(m); const id = String(p && p.source || "").trim(); if (!id) throw new Error("source required"); api.storage.set("pricesSource", id); if (ctx.priceFeed) await ctx.priceFeed.setSource(id); return fullState(); }); // WizardConnect: pair a wiz:// URI with a specific BCH wallet. api.onMessage("wcConnect", async (p, m) => { fromPanel(m); if (!ctx.wc) throw new Error("WizardConnect not ready"); const walletId = String(p && p.walletId || ""); const uri = String(p && p.uri || ""); await ctx.wc.connectUri(walletId, uri); return fullState(); }); api.onMessage("wcDisconnect", async (p, m) => { fromPanel(m); if (!ctx.wc) throw new Error("WizardConnect not ready"); await ctx.wc.disconnect(String(p && p.walletId || ""), String(p && p.connId || "")); return fullState(); }); // Scan the open dapp tab for a wiz:// pairing code, for dapps that render // one but haven't adopted window.wizardconnect. Strictly user-initiated — // it runs when someone presses "Scan page", never on a timer and never in // the background. The host does the matching and returns only the URIs, so // Aegis never receives page content. api.onMessage("wcScanPage", async (_p, m) => { fromPanel(m); if (typeof api.scanActiveTabForUris !== "function") { throw new Error("This Theseus build can't scan pages yet — update Theseus, or paste the wiz:// code manually."); } const { origin, uris } = await api.scanActiveTabForUris({ scheme: "wiz", limit: 10 }); return { origin: origin || null, uris: Array.isArray(uris) ? uris : [] }; }); // ---- WizardConnect from the page (0.8.8) -------------------------------- // // WC was built for cross-device pairing: the dapp renders a QR, a phone // scans it. Same-device that means copying a wiz:// string out of one // tab and into the wallet by hand. These two handlers back the // window.wizardconnect bridge so a dapp can hand Aegis the URI it has // already generated, and the user just approves. // Which BCH wallets can actually pair right now. Used by the page bridge // AND by isReady() so a dapp can decide between "hand it to Aegis" and // "render the QR" before it commits to either. function wcPairableWallets() { if (!ctx.wc) return []; return walletEntries() .filter((w) => w.chain === "bch") .map((w) => ({ entry: w, rt: ctx.runtimes.get(w.id) })) .filter(({ entry, rt }) => rt && rt.phase === "ready" && !wcIneligible.has(entry.id)) .map(({ entry }) => entry); } api.onMessage("wcPageReady", async (_p, m) => { fromPage(m); // Deliberately coarse: a page learns only whether pairing is possible, // never how many wallets exist or what they are. return { available: !!ctx.wc, pairable: wcPairableWallets().length > 0 }; }); api.onMessage("wcConnectFromPage", async (p, m) => { const origin = fromPage(m); if (!ctx.wc) throw new Error("WizardConnect is still starting up — try again in a moment"); const uri = String(p && p.uri || "").trim(); // Validate before showing any UI so a malformed or hostile value can't // put a confusing approval in front of the user. if (!/^wiz:\/\//i.test(uri)) throw new Error("not a WizardConnect URI"); if (uri.length > 4096) throw new Error("WizardConnect URI is implausibly long"); const candidates = wcPairableWallets(); if (!candidates.length) { throw new Error("No Bitcoin Cash wallet is ready to pair. Unlock the Aegis vault (or add a BCH wallet) and try again."); } // Which wallet to offer first: the one nominated for WizardConnect, else // the one the panel is showing, else list order. Whatever wins is only a // default — with more than one candidate the approval lets the user say. const wcRole = walletRoles().wizardconnect; const selId = selectedWalletId(); const preferred = candidates.find((w) => w.id === wcRole) || candidates.find((w) => w.id === selId) || candidates[0]; // Default first: approval.html renders options in order, so the browser // selects the head of the list. const ordered = [preferred, ...candidates.filter((w) => w.id !== preferred.id)]; const addrOf = (w) => { try { return ctx.runtimes.get(w.id)?.adapter?.snapshot()?.address || ""; } catch { return ""; } }; // The address, not just the label — "I don't recognise the connected // wallet" is the failure this dialog has to prevent, and a label the user // never chose ("BCH wallet 2") does not prevent it. const describe = (w) => { const a = addrOf(w); return a ? `${w.label} · ${shortAddr(a)}` : w.label; }; const choose = ordered.length > 1; // With a choice on offer the dropdown is the only honest statement of // which wallet pairs: the rows are static, so a "Wallet: X" line above a // select the user just changed to Y would contradict itself. Each option // therefore carries its own short address, and the full-address row shows // only when there is nothing to choose. const rows = choose ? [{ label: "Pairing code", value: uri.slice(0, 48) + (uri.length > 48 ? "…" : ""), mono: true }] : [ { label: "Wallet", value: preferred.label, strong: true }, { label: "Address", value: addrOf(preferred) || "—", mono: true }, { label: "Pairing code", value: uri.slice(0, 48) + (uri.length > 48 ? "…" : ""), mono: true }, ]; return withOriginLock(origin, async () => { const pick = await api.approvalModal({ title: "Pair this site with your wallet?", origin, body: "The site will be able to ask Aegis to sign Bitcoin Cash transactions over WizardConnect. Every signature still needs your approval — pairing on its own moves no funds.", rows, actions: [{ id: "allow", label: "Pair", primary: true }], select: choose ? { id: "wallet", label: "Pair with", options: ordered.map((w) => ({ value: w.id, label: describe(w) })), } : null, }); const [action, ...flags] = String(pick || "").split("+"); if (action !== "allow") throw new Error("pairing declined"); const picked = flags.find((f) => f.startsWith("wallet=")); const pickedId = picked ? picked.slice(7) : ""; // Re-check against the candidate list: main validates the value came // from the options we offered, but the wallet could have gone away // while the dialog was open. const chosen = candidates.find((w) => w.id === pickedId) || preferred; await ctx.wc.connectUri(chosen.id, uri); return { paired: true, wallet: chosen.label }; }); }); // Reorder wallets by an explicit ID list. Silently drops IDs that are // not in the current wallet set (removed since the panel last read); // appends any wallets missing from `order` to the end of the list so a // stale panel reorder cannot make a wallet vanish from the strip. api.onMessage("reorderWallets", (p, m) => { fromPanel(m); const order = Array.isArray(p && p.order) ? p.order.map(String) : []; const current = walletEntries(); const byId = new Map(current.map((w) => [w.id, w])); const next = []; const seen = new Set(); for (const id of order) { if (byId.has(id) && !seen.has(id)) { next.push(byId.get(id)); seen.add(id); } } for (const w of current) if (!seen.has(w.id)) next.push(w); writeWallets(api, next); emitState(); return fullState(); }); // Which wallet each purpose reaches for. Panel-only: a page must never be // able to read the whole wallet set, let alone re-point a role at one. api.onMessage("walletRoles", (_p, m) => { fromPanel(m); return walletRoles(); }); api.onMessage("setWalletRole", (p, m) => { fromPanel(m); const role = String(p && p.role || ""); const id = p && p.walletId ? String(p.walletId) : null; setWalletRole(role, id); emitState(); // Full state, not just the role map — the panel assigns this straight // onto `state`, and the badges it drives live on the wallet rows. return fullState(); }); // ---- seed import: which derivation path actually holds the funds? ------- // The import form prefilled exactly one path, so a seed from a wallet that // used a different one imported a valid, empty address and reported 0 with // no way to tell "wrong path" from "empty wallet". Both real cases hit it: // Bitcoin.com derives mainnet BCH from coin type 0' (its Copay lineage // predates the 145' split), and chipnet tooling generally uses 145' rather // than BIP44's testnet 1' — which is what Aegis defaulted chipnet to. // // So ask the chain instead of guessing. Each candidate is scanned for // history and balance, and the panel reports what it found. const SEED_PATH_CANDIDATES = { "bch/mainnet": [ { path: "m/44'/145'/0'/0/0", note: "BCH standard — Electron Cash, Zapit, newer Bitcoin.com" }, { path: "m/44'/0'/0'/0/0", note: "BTC coin type — Bitcoin.com, Copay, BitPay" }, { path: "m/44'/145'/0'", note: "account node — some QR exports" }, { path: "m/0'/0/0", note: "pre-BIP44" }, ], // DigiByte. Both master-key variants are covered, because the 2018-19 // official mobile wallets (BreadWallet forks) used HMAC "DigiByte seed" // instead of BIP32's "Bitcoin seed" — the same words then produce a // completely different key tree and a standard scan finds nothing. // Finding from Digibyte.X/dgb-wallet @ 989a2696. "dgb/mainnet": [ { path: "m/84'/20'/0'/0/0", note: "BIP84 native SegWit — dgb1q…, the modern default" }, { path: "m/44'/20'/0'/0/0", note: "BIP44 legacy — D…" }, { path: "m/49'/20'/0'/0/0", note: "BIP49 wrapped SegWit — S…" }, { path: "m/86'/20'/0'/0/0", note: "BIP86 Taproot — dgb1p…" }, { path: "m/0'/0/0", note: "official 2018-19 mobile wallet", hmacKey: "DigiByte seed" }, { path: "m/44'/20'/0'/0/0", note: "BIP44 with the 2018-19 mobile master key", hmacKey: "DigiByte seed" }, ], "bch/chipnet": [ { path: "m/44'/145'/0'/0/0", note: "BCH coin type on chipnet — most chipnet tooling" }, { path: "m/44'/1'/0'/0/0", note: "BIP44 testnet coin type" }, { path: "m/44'/145'/0'", note: "account node" }, ], }; api.onMessage("seedPathCandidates", (p, m) => { fromPanel(m); const key = `${String(p && p.chain || "")}/${String(p && p.network || "")}`; return { key, candidates: (SEED_PATH_CANDIDATES[key] || []).map((c) => ({ ...c })) }; }); api.onMessage("scanSeedPaths", async (p, m) => { fromPanel(m); const chain = String(p && p.chain || "bch"); const network = String(p && p.network || ""); const cands = SEED_PATH_CANDIDATES[`${chain}/${network}`]; if (!cands) throw new Error(`Path scanning is not available for ${chain} ${network} yet.`); const mnemonic = String(p && p.mnemonic || "").trim(); if (!mnemonic) throw new Error("seed phrase required"); // Same conversion importWallet does. Never stored here — this handler // derives, queries and returns counts, nothing else. let seedHex; try { seedHex = ctx.d.derive.mnemonicToSeedHex(mnemonic); } catch (e) { throw new Error("That does not look like a BIP39 seed phrase: " + (e?.message || e)); } // Per-chain: where to ask, how to turn a path into an address, and how to // key a script for Electrum. Everything below is chain-agnostic. let servers, addressAt, scripthashOf; if (chain === "bch") { const prefix = network === "mainnet" ? "bitcoincash" : "bchtest"; servers = network === "mainnet" ? bchServerList(api) : ["wss://chipnet.imaginary.cash:50004", "wss://chipnet.bch.ninja:50004"]; addressAt = (pth) => deriveCashaddrFromSeed(seedHex, pth, prefix); scripthashOf = (addr) => { const d = ctx.d.cashaddr.decode(addr); const h = Buffer.from(d.hash); const spk = d.type === 1 ? Buffer.concat([Buffer.from([0xa9, 0x14]), h, Buffer.from([0x87])]) : Buffer.concat([Buffer.from([0x76, 0xa9, 0x14]), h, Buffer.from([0x88, 0xac])]); return Buffer.from(ctx.d.sha256(new Uint8Array(spk))).reverse().toString("hex"); }; } else if (chain === "dgb") { if (!ctx.d.dgbCore) throw new Error("the DigiByte modules did not load, so paths cannot be scanned"); servers = ["wss://electrum1.cipig.net:20063", "wss://electrum2.cipig.net:20063"]; // hmacKey rides on the candidate, so both master-key variants are // scanned in the same pass. addressAt = (pth, hmacKey) => ctx.d.derive.dgb.fromSeed(seedHex, pth, hmacKey); scripthashOf = (addr) => { // bitcoinjs knows every DGB output type (D…, S…, dgb1q…, dgb1p…), // so let it build the scriptPubKey rather than hand-rolling four. const spk = ctx.d.bitcoinjs.address.toOutputScript(addr, ctx.d.dgbCore.digibyte); return Buffer.from(ctx.d.sha256(new Uint8Array(spk))).reverse().toString("hex"); }; } else { throw new Error(`Path scanning is not available for ${chain} yet.`); } const client = new ctx.d.electrum.Client(servers); // Look a few addresses deep per candidate: a wallet whose first receive // address is spent clean still has history, and funds often sit further // along the branch. const DEPTH = 5; const out = []; try { for (const c of cands) { const acct = wcAccountPath(c.path) || c.path; const probes = []; for (let i = 0; i < DEPTH; i++) probes.push(`${acct}/0/${i}`); // The exact path the user would be importing, in case it is a leaf // outside the account/0/i shape we probe. if (!probes.includes(c.path) && /\/\d+$/.test(c.path)) probes.unshift(c.path); let balance = 0, txCount = 0, firstAddress = null, fundedAddress = null; for (const [idx, pth] of probes.entries()) { let addr; try { addr = addressAt(pth, c.hmacKey); } catch { continue; } if (idx === 0 || firstAddress === null) firstAddress = firstAddress || addr; const sh = scripthashOf(addr); try { const bal = await client.call("blockchain.scripthash.get_balance", [sh]); const got = Number(bal?.confirmed || 0) + Number(bal?.unconfirmed || 0); if (got > 0 && !fundedAddress) fundedAddress = addr; balance += got; const hist = await client.call("blockchain.scripthash.get_history", [sh]); txCount += Array.isArray(hist) ? hist.length : 0; } catch (e) { api.log("scanSeedPaths:", pth, e?.message || e); } } out.push({ path: c.path, note: c.note, accountPath: acct, hmacKey: c.hmacKey || null, firstAddress, fundedAddress, balance, txCount, scanned: probes.length, }); } } finally { try { client.disconnect(); } catch {} } const meta = chainMeta(chain, network); return { chain, network, decimals: meta?.decimals || 8, ticker: meta?.ticker || "BCH", results: out, // Rank for the panel: funds first, then any history at all. best: out.slice().sort((a, b) => (b.balance - a.balance) || (b.txCount - a.txCount))[0]?.path || null, }; }); // ---- who can see the wallet -------------------------------------------- const injectState = () => ({ supported: !!(api.features && api.features.pageInjectPolicy), mode: injectMode(api), origins: injectAllowList(api), }); api.onMessage("injectPolicyGet", (_p, m) => { fromPanel(m); return injectState(); }); api.onMessage("injectPolicySet", (p, m) => { fromPanel(m); if (!(api.features && api.features.pageInjectPolicy)) throw new Error("this Theseus cannot limit which sites see the wallet — update Theseus"); api.storage.set(INJECT_MODE_KEY, p && p.mode === "allowed" ? "allowed" : "all"); syncInjectPolicy(api); return injectState(); }); // Enable one site. With no origin given it is the site in the active tab, // which the host reports — the panel never has to type or guess a URL. api.onMessage("injectAllowSite", async (p, m) => { fromPanel(m); let origin = normalizeOrigin(p && p.origin); if (!origin && typeof api.scanActiveTabForUris === "function") { try { origin = normalizeOrigin((await api.scanActiveTabForUris({ scheme: "wiz", limit: 1 })).origin); } catch {} } if (!origin) throw new Error("Open the site in a tab first — there is no web page in the active tab."); const list = injectAllowList(api); if (!list.includes(origin)) list.push(origin); api.storage.set(INJECT_ALLOW_KEY, list); syncInjectPolicy(api); return { origin, ...injectState() }; }); api.onMessage("injectRemoveSite", (p, m) => { fromPanel(m); const origin = String(p && p.origin || ""); api.storage.set(INJECT_ALLOW_KEY, injectAllowList(api).filter((o) => o !== origin)); syncInjectPolicy(api); return injectState(); }); try { syncInjectPolicy(api); } catch {} api.onMessage("permissions", (_p, m) => { fromPanel(m); return grantsForPanel(api); }); api.onMessage("revoke", (p, m) => { fromPanel(m); revokeOrigin(api, String(p && p.origin || "")); emitState(); return grantsForPanel(api); }); // ---- security: quick-access PIN + policy flags ------------------------ // The PIN blob is a WebCrypto AES-GCM ciphertext of the master password, // derived from PBKDF2(pin, salt). Panel handles the actual encryption / // decryption inside its iframe — the master password never crosses the // process boundary except via vaultUnlock. These handlers only shuttle // the opaque blob + a small policy object in and out of api.storage. // Set or replace the PIN. The master password is checked against the vault // first, and the blob is built here, so the panel never holds one. api.onMessage("pinSet", async (p, m) => { fromPanel(m); const pin = String((p && p.pin) || ""); const pw = String((p && p.masterPassword) || ""); if (!PIN_RE.test(pin)) throw new Error("the PIN must be 6 digits"); if (!pw) throw new Error("master password required"); try { await api.vault.lifecycle.unlock(pw); } catch { throw new Error("wrong master password"); } noteMasterVerified(api); api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, await pinWrap(pin, pw))); return true; }); // Try a PIN. Counts the guess before trying it, so a crash or a closed // panel mid-check still costs an attempt. Answers // { ok: true, masterPassword } | { ok: false, remaining, requireMaster } api.onMessage("pinUnwrap", async (p, m) => { fromPanel(m); const pin = String((p && p.pin) || ""); const blob = openPinBlob(api); if (!blob) throw new Error("no PIN is set"); if (pinFails(api).requireMaster) return { ok: false, remaining: 0, requireMaster: true }; const before = pinFails(api).fails; api.storage.set("aegis/pin/failCount", before + 1); let pw = null; if (PIN_RE.test(pin)) { try { pw = await pinUnwrapBlob(pin, blob); } catch { pw = null; } } if (pw == null) { const fails = before + 1; if (fails >= PIN_MAX_FAILS) api.storage.set("aegis/pin/requireMaster", true); return { ok: false, remaining: Math.max(0, PIN_MAX_FAILS - fails), requireMaster: fails >= PIN_MAX_FAILS }; } api.storage.set("aegis/pin/failCount", 0); // A blob from an older build (200k iterations, or from before sealing) // is re-made now, under a fresh salt, while the PIN is at hand. if ((Number(blob.iters) || 0) < PIN_ITERS) { try { api.storage.set(PIN_BLOB_KEY, sealPinBlob(api, await pinWrap(pin, pw))); } catch (e) { api.log("PIN re-wrap:", e?.message || e); } } return { ok: true, masterPassword: pw }; }); api.onMessage("pinStatus", (_p, m) => { fromPanel(m); const f = pinFails(api); return { hasPin: !!api.storage.get(PIN_BLOB_KEY, null), fails: f.fails, maxFails: PIN_MAX_FAILS, requireMaster: f.requireMaster }; }); api.onMessage("pinBlobClear", (_p, m) => { fromPanel(m); api.storage.set("aegis/pin/v1", null); api.storage.set("aegis/pin/failCount", 0); api.storage.set("aegis/pin/requireMaster", false); // Drop the gate record as well, so enrolling a new PIN later starts from // "not yet satisfied" rather than inheriting the old PIN's clearance. api.storage.set("aegis/pin/gate", null); return true; }); // When to ask for the PIN. These are independent triggers, not a single // mode: wanting one at startup and one per transaction is a normal // combination. Previously the only control was requirePinForSending, which // produced the behaviour the user reported — Aegis opens unlocked after a // restart (safeStorage remembered the password) and then demands a PIN the // moment you touch something. Asking at the door or not at all is // coherent; asking only once you are inside is not. // // `restart` defaults ON for a wallet that otherwise reopens fully unlocked. // `transaction` inherits the old requirePinForSending so nobody silently // loses a gate they had chosen. function pinPolicy() { const cfg = api.storage.get("aegis/security/v1", {}) || {}; const on = (cfg.pinOn && typeof cfg.pinOn === "object") ? cfg.pinOn : null; return { restart: on ? !!on.restart : true, launch: on ? !!on.launch : false, interval: on ? !!on.interval : false, transaction: on ? !!on.transaction : !!cfg.requirePinForSending, }; } const pinGate = () => { const g = api.storage.get("aegis/pin/gate", null); return (g && typeof g === "object") ? g : {}; }; // Does the user have to prove the PIN right now? Decided host-side: the // panel reloads freely and must not be the thing that remembers whether a // gate was already satisfied. function pinNeeded(event) { if (!api.storage.get("aegis/pin/v1", null)) return { needPin: false, reason: "no-pin" }; const on = pinPolicy(); const g = pinGate(); if (on.interval) { // Never satisfied, or satisfied too long ago. Checked for every event // so a six-hour expiry also lands on the next transaction. if (!g.lastOkAt || (Date.now() - Number(g.lastOkAt)) > PIN_INTERVAL_MS) { return { needPin: true, reason: "interval" }; } } if (event === "transaction" && on.transaction) return { needPin: true, reason: "transaction" }; if (event === "panel-load") { if (on.launch) return { needPin: true, reason: "launch" }; if (on.restart && g.bootId !== BOOT_ID) return { needPin: true, reason: "restart" }; } return { needPin: false, reason: "satisfied" }; } api.onMessage("pinGateStatus", (p, m) => { fromPanel(m); const event = String(p && p.event || "panel-load"); return { ...pinNeeded(event), event, policy: pinPolicy() }; }); // The panel decrypts the PIN blob and hands over what was inside it. That // is the vault master password, and the host checks it against the vault // itself — so a gate is cleared by proof the host verified, not by the // panel saying so. The same proof opens a single-use transaction // clearance (see requirePanelTxPin / requireDappTxPin). api.onMessage("pinGateSatisfied", async (p, m) => { fromPanel(m); const pw = String((p && p.masterPassword) || ""); if (!pw) throw new Error("PIN proof required"); if (!api.vault?.lifecycle || typeof api.vault.lifecycle.unlock !== "function") throw new Error("this build cannot verify a PIN"); try { await api.vault.lifecycle.unlock(pw); } catch { throw new Error("PIN proof rejected"); } noteMasterVerified(api); api.storage.set("aegis/pin/gate", { lastOkAt: Date.now(), bootId: BOOT_ID }); txClearanceUntil = Date.now() + TX_CLEARANCE_MS; return true; }); // A panel opened while a dapp transaction is waiting for the PIN picks the // request up here; one already open gets the "pinRequest" event instead. api.onMessage("pinRequestPending", (_p, m) => { fromPanel(m); return pendingPinRequest ? { ...pendingPinRequest } : null; }); ctx.pinNeeded = pinNeeded; // Seal a plain blob left by an older build now, not when the panel next // happens to open. try { openPinBlob(api); } catch {} api.onMessage("securityGet", (_p, m) => { fromPanel(m); const cfg = api.storage.get("aegis/security/v1", {}) || {}; return { hasPin: !!api.storage.get("aegis/pin/v1", null), pinRequireMaster: pinFails(api).requireMaster, pinOn: pinPolicy(), pinIntervalHours: PIN_INTERVAL_MS / 3600000, requirePinForSending: !!cfg.requirePinForSending, // Defaults ON (note the !== false), unlike the send flag: a send is // already fronted by an approval overlay, whereas revealing a key is // irreversible the moment it is on screen. Turning this off does not // make a secret free to read — it moves the prompt to the master // password, which is the stronger credential, not a weaker one. requirePinForReveal: cfg.requirePinForReveal !== false, }; }); api.onMessage("securitySet", (p, m) => { fromPanel(m); const cur = api.storage.get("aegis/security/v1", {}) || {}; const next = { ...cur }; if (p && typeof p.requirePinForSending === "boolean") next.requirePinForSending = p.requirePinForSending; if (p && typeof p.requirePinForReveal === "boolean") next.requirePinForReveal = p.requirePinForReveal; if (p && p.pinOn && typeof p.pinOn === "object") { // Write the whole set from the current policy plus the keys given, so // the first edit materialises the migrated defaults instead of leaving // three triggers undefined and one set. const cur = pinPolicy(); const merged = { ...cur }; for (const k of ["restart", "launch", "interval", "transaction"]) { if (typeof p.pinOn[k] === "boolean") merged[k] = p.pinOn[k]; } next.pinOn = merged; // The legacy flag now lives in pinOn.transaction; keep them in step so // an older build reading this store still gates sends the same way. next.requirePinForSending = merged.transaction; } api.storage.set("aegis/security/v1", next); return { hasPin: !!api.storage.get("aegis/pin/v1", null), pinRequireMaster: pinFails(api).requireMaster, pinOn: pinPolicy(), pinIntervalHours: PIN_INTERVAL_MS / 3600000, requirePinForSending: !!next.requirePinForSending, requirePinForReveal: next.requirePinForReveal !== false, }; }); // ---- session: stay-signed-in + idle-lock + manual sign out ------------ // "Stay signed in" persists the master password across Theseus restarts // using electron.safeStorage — an OS-level protected keystore (Windows // DPAPI, macOS Keychain, libsecret on Linux). The encrypted blob only // decrypts under the same OS user account, so filesystem-only access // (SSH from another user, a lost backup) cannot use it. // // Storage: // aegis/session/enc — { encPwB64, savedAt } — safeStorage blob // aegis/session/cfg — { lockOnClose: bool, idleMinutes: number } // // Defaults: lockOnClose=true, idleMinutes=15. The user opts in to // remember-me by turning "Lock on Navigator close" off in Settings. api.onMessage("sessionStatus", (_p, m) => { fromPanel(m); return sessionStatusFor(api); }); api.onMessage("sessionConfigSet", (p, m) => { fromPanel(m); const cur = api.storage.get("aegis/session/cfg", null) || { lockOnClose: true, idleMinutes: 15 }; const next = { ...cur }; if (p && typeof p.lockOnClose === "boolean") next.lockOnClose = p.lockOnClose; if (p && typeof p.idleMinutes === "number") { const im = Math.max(0, Math.min(180, Math.floor(p.idleMinutes))); next.idleMinutes = im; } api.storage.set("aegis/session/cfg", next); // Turning "Lock on close" on invalidates any stored remember-me blob. if (next.lockOnClose) api.storage.set("aegis/session/enc", null); return sessionStatusFor(api); }); api.onMessage("sessionEnable", (p, m) => { fromPanel(m); const pw = String(p && p.masterPassword || ""); if (!pw) throw new Error("master password required"); const ss = safeStorageOr(api); if (!ss || !ss.isEncryptionAvailable()) throw new Error("OS keystore unavailable — remember-me needs Windows DPAPI / macOS Keychain / libsecret"); const enc = ss.encryptString(pw).toString("base64"); api.storage.set("aegis/session/enc", { encPwB64: enc, savedAt: Date.now() }); // Force lockOnClose = false alongside — semantically they're the same // switch as far as the user's UI expects. const cur = api.storage.get("aegis/session/cfg", {}) || {}; api.storage.set("aegis/session/cfg", { ...cur, lockOnClose: false }); return sessionStatusFor(api); }); api.onMessage("sessionDisable", (_p, m) => { fromPanel(m); api.storage.set("aegis/session/enc", null); const cur = api.storage.get("aegis/session/cfg", {}) || {}; api.storage.set("aegis/session/cfg", { ...cur, lockOnClose: true }); return sessionStatusFor(api); }); // Manual sign-out: locks the vault (main-process re-locks it in memory) // and drops the runtime cache. Also wipes any remember-me blob so the // NEXT Theseus launch will require the master password again — the user // just said "sign me out", not "sign me out just for this restart". api.onMessage("vaultLock", async (_p, m) => { fromPanel(m); api.storage.set("aegis/session/enc", null); for (const walletId of Array.from(ctx.runtimes.keys())) unmountWallet(walletId); try { await api.vault.lifecycle.lock(); } catch (e) { api.log("vault lock:", e?.message || e); } emitState(); return fullState(); }); } // Read session status. Kept as a plain helper so both the message handler // and the startup auto-unlock path can call it without duplicating shape. function sessionStatusFor(api) { const cfg = api.storage.get("aegis/session/cfg", null) || { lockOnClose: true, idleMinutes: 15 }; const blob = api.storage.get("aegis/session/enc", null); const ss = safeStorageOr(api); return { lockOnClose: !!cfg.lockOnClose, idleMinutes: Number(cfg.idleMinutes) || 0, hasSession: !!(blob && blob.encPwB64), safeStorageAvailable: !!(ss && ss.isEncryptionAvailable && ss.isEncryptionAvailable()), }; } // Best-effort access to electron.safeStorage from inside the addon. The // addon runs in the main process, so require("electron") gives us the // full main-process API; on hosts that shadow this (tests, older builds) // we degrade to "unavailable" instead of throwing. function safeStorageOr(api) { try { const e = api.require ? api.require("electron") : require("electron"); return e && e.safeStorage ? e.safeStorage : null; } catch { return null; } } // Called from activate() after deps + WC init, BEFORE mountAllWallets. // If the user opted into stay-signed-in AND we have a stored blob AND // safeStorage can decrypt it under this OS user → auto-unlock the vault. // Any failure is silent (log-only) — mountAllWallets will fall back to // the panel's lock screen exactly as before. async function tryAutoUnlock(api) { try { const status = await api.vault.lifecycle.status(); if (status && status.unlocked) return; } catch {} const cfg = api.storage.get("aegis/session/cfg", null) || { lockOnClose: true, idleMinutes: 15 }; if (cfg.lockOnClose) return; const blob = api.storage.get("aegis/session/enc", null); if (!blob || !blob.encPwB64) return; const ss = safeStorageOr(api); if (!ss || !ss.isEncryptionAvailable()) return; try { const pw = ss.decryptString(Buffer.from(blob.encPwB64, "base64")); await api.vault.lifecycle.unlock(pw); api.log("auto-unlocked via safeStorage session"); } catch (e) { api.log("auto-unlock failed:", e?.message || e); // Drop the stale blob so we don't retry every launch. api.storage.set("aegis/session/enc", null); } } // 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 : {}; } // ---- grants: session + persisted ------------------------------------------ // A plain "Connect" grants the origin for this browser session (memory only); // ticking "Always allow" persists it under api.storage "permissions". Both // look identical to the handlers via grantFor(). Scopes: "bch" (the // window.bitcoincash bridge keeps its flat readAddress), "eth", "sol", "trx". // ETH grants also carry the origin's chainId — chain is per origin, never a // global the sidebar or another site can flip under a connected dapp. // Before this, a plain "Connect" stored nothing, so the very next call from // the site failed with "not connected". const sessionGrants = new Map(); // origin -> { readAddress?, eth?, sol?, trx? } function grantFor(api, origin, scope) { const persisted = permissions(api)[origin] || {}; const session = sessionGrants.get(origin) || {}; if (scope === "bch") { if (persisted.readAddress) return { readAddress: true, persisted: true }; if (session.readAddress) return { readAddress: true, persisted: false }; return null; } const p = persisted[scope], s = session[scope]; if (p && p.readAddress) return { ...p, persisted: true }; if (s && s.readAddress) return { ...s, persisted: false }; return null; } function setGrant(api, origin, scope, value, persist) { const wrap = scope === "bch" ? value : { [scope]: value }; if (persist) { const perms = permissions(api); perms[origin] = { ...(perms[origin] || {}), ...wrap }; api.storage.set("permissions", perms); syncInjectPolicy(api); } else { sessionGrants.set(origin, { ...(sessionGrants.get(origin) || {}), ...wrap }); } emitState(); } // Patch fields (e.g. chainId) on whichever grant the origin currently holds. function patchGrant(api, origin, scope, patch) { const g = grantFor(api, origin, scope); if (!g) return false; const { persisted, ...rest } = g; setGrant(api, origin, scope, { ...rest, ...patch }, persisted); return true; } // An unconnected site that asked for a chain (addChain / switch before // connect) gets it remembered for its eventual eth_requestAccounts — no // address is disclosed by this. function rememberPendingChain(origin, chainId) { const cur = sessionGrants.get(origin) || {}; sessionGrants.set(origin, { ...cur, eth: { ...(cur.eth || {}), chainId } }); } function revokeOrigin(api, origin) { const perms = permissions(api); delete perms[origin]; api.storage.set("permissions", perms); sessionGrants.delete(origin); syncInjectPolicy(api); } // ---- who can see the wallet ------------------------------------------------ // The bridges used to go into every https page, which tells every page that // this browser carries a wallet (and which one) before the user has done // anything. In "allowed" mode Theseus injects only into origins listed in // the reserved storage key "__pageInjectPolicy" — it reads that key itself, // at document start, even while Aegis has not been started yet. The list is // the sites the user enabled plus every site with a saved connection, so // turning the mode on never breaks a dapp already in use. const INJECT_MODE_KEY = "aegis/inject/mode"; const INJECT_ALLOW_KEY = "aegis/inject/allow"; function injectAllowList(api) { const v = api.storage.get(INJECT_ALLOW_KEY, []); return Array.isArray(v) ? v.filter((o) => typeof o === "string") : []; } function injectMode(api) { return api.storage.get(INJECT_MODE_KEY, "all") === "allowed" ? "allowed" : "all"; } function syncInjectPolicy(api) { const origins = Array.from(new Set([...injectAllowList(api), ...Object.keys(permissions(api))])); api.storage.set("__pageInjectPolicy", { mode: injectMode(api), origins }); } function normalizeOrigin(raw) { try { const u = new URL(String(raw || "").replace(/^bns:\/\//i, "https://")); if (u.protocol !== "https:" && u.protocol !== "http:") return null; return `${u.protocol}//${u.host}`; } catch { return null; } } // Panel view: persisted grants plus session-only ones. An origin with no // persisted entry is flagged `session`; one with both gets the session // scopes merged in under the persisted ones. function grantsForPanel(api) { const out = JSON.parse(JSON.stringify(permissions(api))); for (const [origin, g] of sessionGrants) { const hasGrant = g.readAddress || g.eth?.readAddress || g.sol?.readAddress || g.trx?.readAddress; if (!hasGrant) continue; out[origin] = out[origin] ? { ...g, ...out[origin] } : { ...g, session: true }; } return out; } // Tron: only transactions Aegis itself signed may be broadcast through the // bridge — a dapp can't use the wallet as a relay for foreign signatures. const signedTronTxids = new Set(); function rememberSignedTron(txid) { signedTronTxids.add(txid); if (signedTronTxids.size > 200) signedTronTxids.delete(signedTronTxids.values().next().value); } // Dapp message bytes: personal_sign carries hex-encoded bytes (ethers, viem, // wagmi); a plain string is UTF-8 (older dapps, our own panel). function bytesFromDappMessage(raw) { const s = raw == null ? "" : String(raw); if (/^0x([0-9a-f]{2})*$/i.test(s)) return new Uint8Array(Buffer.from(s.slice(2), "hex")); return new TextEncoder().encode(s); } // Overlay preview: the text if it is clean UTF-8, else a length + hex prefix. function previewBytes(bytes, max = 400) { let text = null; try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch {} if (text != null && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text)) { return previewText(text, Math.max(max, 1500)); } return `<${bytes.length} bytes: 0x${Buffer.from(bytes.subarray(0, 30)).toString("hex")}${bytes.length > 30 ? "…" : ""}>`; } // ---- Solana message parsing ------------------------------------------------ const SOL_TOKEN_PROGRAMS = new Set([ "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", // SPL Token "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", // Token-2022 ]); // Full wire: compact-u16(sigCount) || sig[64]*sigCount || message. function splitSolWire(wire) { let off = 0; const compact = () => { let n = 0, shift = 0; for (;;) { if (off >= wire.length) throw new Error("truncated tx wire"); const b = wire[off++]; n |= (b & 0x7f) << shift; if ((b & 0x80) === 0) break; shift += 7; if (shift > 14) throw new Error("compact-u16 too long"); } return n; }; const sigCount = compact(); 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"); return { sigCount, sigsStart, messageBytes: wire.slice(messageStart) }; } // Structural parse of a legacy or v0 message. `ok:false` means the bytes // cannot be a message — used both to sign transactions and to REFUSE // transaction-shaped payloads handed to signMessage. function parseSolMessage(msg, ourPub) { try { let off = 0, version = null; if (!(msg instanceof Uint8Array) || msg.length < 3 + 1 + 32 + 32) throw new Error("too short"); if (msg[0] & 0x80) { version = msg[0] & 0x7f; off = 1; if (version !== 0) throw new Error("unsupported message version " + version); } const numRequiredSigs = msg[off], numReadonlySigned = msg[off + 1], numReadonlyUnsigned = msg[off + 2]; off += 3; const compact = () => { let n = 0, shift = 0; for (;;) { if (off >= msg.length) throw new Error("truncated"); const b = msg[off++]; n |= (b & 0x7f) << shift; if ((b & 0x80) === 0) break; shift += 7; if (shift > 14) throw new Error("bad compact-u16"); } return n; }; const keyCount = compact(); if (keyCount < 1 || keyCount > 256) throw new Error("bad account key count"); if (numRequiredSigs < 1 || numRequiredSigs > keyCount) throw new Error("bad header"); if (numReadonlySigned > numRequiredSigs || numReadonlyUnsigned > keyCount - numRequiredSigs) throw new Error("bad header"); if (msg.length < off + keyCount * 32 + 32) throw new Error("truncated keys"); const keys = []; for (let i = 0; i < keyCount; i++) { keys.push(msg.subarray(off, off + 32)); off += 32; } const blockhash = msg.subarray(off, off + 32); off += 32; const ixCount = compact(); const instructions = []; for (let i = 0; i < ixCount; i++) { if (off >= msg.length) throw new Error("truncated instruction"); const programIdx = msg[off++]; const na = compact(); const accounts = []; for (let k = 0; k < na; k++) { if (off >= msg.length) throw new Error("truncated accounts"); accounts.push(msg[off++]); } const nd = compact(); if (off + nd > msg.length) throw new Error("truncated instruction data"); const data = msg.subarray(off, off + nd); off += nd; instructions.push({ programIdx, accounts, data }); } let lookupTables = 0; if (version === 0) lookupTables = compact(); // static keys suffice for the signer checks let ourIndex = -1; if (ourPub) { for (let i = 0; i < keyCount; i++) { let eq = true; for (let j = 0; j < 32; j++) if (keys[i][j] !== ourPub[j]) { eq = false; break; } if (eq) { ourIndex = i; break; } } } return { ok: true, version, numRequiredSigs, keys, blockhash, instructions, lookupTables, ourIndex, length: msg.length }; } catch (e) { return { ok: false, error: e?.message || String(e) }; } } // Overlay rows: System transfers and SPL transfer/approve/set-authority are // decoded; everything else is named by program so the user at least sees // how much of the transaction Aegis could not read. function solInstructionRows(parsed) { const b58 = (b) => ctx.d.base58check.encodeBase58(b); const u64le = (d, o) => { let v = 0n; for (let i = 7; i >= 0; i--) v = (v << 8n) | BigInt(d[o + i] || 0); return v; }; const rows = []; const lines = parsed.instructions.map((ix, i) => { if (ix.programIdx >= parsed.keys.length) return `${i + 1}. program from a lookup table (not decoded)`; const progB58 = b58(parsed.keys[ix.programIdx]); const acct = (n) => { const idx = ix.accounts[n]; return idx == null ? "?" : (idx < parsed.keys.length ? b58(parsed.keys[idx]) : `lookup-table account #${idx}`); }; const d = ix.data; if (progB58 === "11111111111111111111111111111111" && d.length >= 12 && d[0] === 2 && d[1] === 0 && d[2] === 0 && d[3] === 0) { return `${i + 1}. System transfer ${fmtValue(u64le(d, 4).toString(), 9)} SOL → ${acct(1)}`; } if (SOL_TOKEN_PROGRAMS.has(progB58) && d.length >= 9) { if (d[0] === 3) return `${i + 1}. Token transfer ${u64le(d, 1).toString()} units → ${acct(1)}`; if (d[0] === 12) return `${i + 1}. Token transfer ${u64le(d, 1).toString()} units → ${acct(2)}`; if (d[0] === 4) return `${i + 1}. Token APPROVE: delegate ${acct(1)} may spend ${u64le(d, 1).toString()} units`; } if (SOL_TOKEN_PROGRAMS.has(progB58) && d.length >= 1 && d[0] === 6) return `${i + 1}. Token SET AUTHORITY on ${acct(0)} — hands the account to someone else`; return `${i + 1}. program ${progB58.slice(0, 8)}…: ${d.length} bytes, ${ix.accounts.length} account${ix.accounts.length === 1 ? "" : "s"} (not decoded)`; }); rows.push({ label: parsed.instructions.length === 1 ? "Instruction" : "Instructions", value: lines.join("\n") || "(none)", mono: true }); if (parsed.version === 0) { rows.push({ label: "Format", value: `v0 · ${parsed.lookupTables} address-lookup table${parsed.lookupTables === 1 ? "" : "s"} (accounts inside them are not shown)` }); } const others = parsed.numRequiredSigs - 1; rows.push({ label: "Signers", value: others ? `${parsed.numRequiredSigs} — you (slot #${parsed.ourIndex}) + ${others} other${others === 1 ? "" : "s"}` : "1 — you" }); return rows; } const ETH_RPC_PRECONNECT = new Set(["eth_chainId", "net_version", "eth_blockNumber"]); const ETH_RPC_NEVER = /^(eth_sign|eth_signTransaction|eth_sendTransaction|eth_accounts|eth_requestAccounts|eth_coinbase|eth_signTypedData|eth_newFilter|eth_newBlockFilter|eth_newPendingTransactionFilter|eth_uninstallFilter|eth_getFilterChanges|eth_getFilterLogs|eth_subscribe|eth_unsubscribe|eth_mining|eth_submit|eth_getWork)/; // EIP-2612 Permit, DAI-style permit, and Uniswap Permit2 (PermitSingle / // PermitBatch / PermitTransferFrom / PermitBatchTransferFrom). Returns the // overlay rows that say who may spend what until when, or null when the // typed data is not a spending approval. function describePermit(td) { const primary = String(td?.primaryType || ""); if (!/^Permit/.test(primary)) return null; const msg = (td && typeof td.message === "object" && td.message) || {}; const UNLIMITED = 1n << 159n; // ≥ half of uint160 covers Permit2's max and every uint256 max const big = (v) => { try { return BigInt(v); } catch { return null; } }; const amountText = (v) => { const b = big(v); return b === null ? String(v) : (b >= UNLIMITED ? "UNLIMITED (∞)" : b.toString() + " units"); }; const when = (v) => { const b = big(v); if (b === null) return String(v); if (b >= 4102444800n) return "never expires"; // 2100-01-01 and beyond try { return new Date(Number(b) * 1000).toISOString().replace("T", " ").slice(0, 16) + " UTC"; } catch { return b.toString(); } }; const rows = []; let risky = false; const spender = msg.spender; rows.push({ label: "Spender", value: spender ? String(spender) : "(none named — anyone holding this signature)", mono: true, strong: true }); if (!spender) risky = true; const items = []; if (primary === "Permit") { // EIP-2612 has `value`; DAI has `allowed: true` (always unlimited). if ("allowed" in msg) items.push({ token: td.domain?.verifyingContract, amount: msg.allowed ? UNLIMITED : 0n }); else items.push({ token: td.domain?.verifyingContract, amount: msg.value }); if (msg.deadline != null || msg.expiry != null) rows.push({ label: "Valid until", value: when(msg.deadline ?? msg.expiry) }); } else { const list = Array.isArray(msg.details) ? msg.details : (msg.details ? [msg.details] : (Array.isArray(msg.permitted) ? msg.permitted : (msg.permitted ? [msg.permitted] : []))); for (const d of list) items.push({ token: d?.token, amount: d?.amount, expiration: d?.expiration }); if (msg.sigDeadline != null || msg.deadline != null) rows.push({ label: "Signature valid until", value: when(msg.sigDeadline ?? msg.deadline) }); } items.slice(0, 10).forEach((it, i) => { const b = big(it.amount); if (b !== null && b >= UNLIMITED) risky = true; rows.push({ label: items.length > 1 ? `Token #${i + 1}` : "Token", value: `${it.token || "?"} — ${amountText(it.amount)}${it.expiration != null ? ` · allowance ${when(it.expiration)}` : ""}`, mono: true, }); }); if (items.length > 10) { rows.push({ label: "Tokens", value: `… and ${items.length - 10} more` }); risky = true; } if (!items.length) { rows.push({ label: "Token", value: "(could not read the approval — treat as unlimited)" }); risky = true; } return { rows, risky }; } 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. // Any ordinary web origin may talk to the BCH bridge. The old test here // required a ".x" hostname, which locked out every third-party BCH dapp // (Cauldron, bch.guru) and our own dapps on other TLDs — while the TRX / ETH // / SOL bridges and WizardConnect accepted any origin all along. The real // protection is downstream and unchanged: an approval overlay on every // action plus per-origin permissions. This only keeps non-web schemes out. function isDappOrigin(origin) { try { const u = new URL(origin); return u.protocol === "https:" || u.protocol === "http:"; } 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 (!isDappOrigin(origin)) throw new Error("this page cannot use the wallet bridge"); const rt = legacyBchRuntime(); if (grantFor(api, origin, "bch")) 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"); setGrant(api, origin, "bch", { readAddress: true }, pick === "allow+always"); return rt.adapter.current().address; }); }); api.onMessage("signAndSend", async (p, m) => { const origin = fromPage(m); if (!isDappOrigin(origin)) throw new Error("this page cannot use the wallet bridge"); 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) { // An allowance skips the overlay, not the PIN the user asked for on // every transaction. await requireDappTxPin(origin, "Bitcoin Cash payment"); 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"); await requireDappTxPin(origin, "Bitcoin Cash payment"); 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 (!isDappOrigin(origin)) throw new Error("this page cannot use the wallet bridge"); 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: previewText(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); }); }); // BCH message verification (BIP-137). Panel-only path: given a message, // a base64 signature, and an address, return { valid, address, // recoveredHash }. No approval modal (nothing spendable happens), no // wallet lookup — pure crypto against the given address. // Panel → BCMR resolver. Batched: pass an array of category hex strings, // get back { : {name, symbol, iconUri, decimals, source} } // for every one that resolved. Missed categories map to null. This // triggers a background fetch for anything not in the disk cache, so // the second call for the same set returns instantly. api.onMessage("tokenMetadata", async (p, m) => { fromPanel(m); const cats = Array.isArray(p?.categories) ? p.categories.map(String).filter((c) => /^[0-9a-f]{64}$/i.test(c)) : []; if (!cats.length) return {}; const entries = await ctx.bcmr.lookupMany(cats); const out = {}; // Pass the category so a local name can win over the registry — and so // a category with ONLY a local name still comes back named instead of // null, which is the whole point for self-minted tokens. for (const cat of cats) out[cat] = ctx.bcmr.metadataOf(entries[cat], cat); // Tell the panel whether the registries themselves are reachable. A dead // registry used to be indistinguishable from an unregistered token, and // both defaults were 404 for who knows how long because of it. const reachable = Object.values(entries).some((e) => e && e.snapshot); return { meta: out, registriesAnswered: reachable, registries: ctx.bcmr.registryList().length }; }); // Name a token yourself. The only way to label a category that publishes // no metadata anywhere — no registry entry, and commonly not even an // OP_RETURN in its genesis transaction. api.onMessage("setTokenLabel", (p, m) => { fromPanel(m); const cat = String(p?.category || ""); if (!/^[0-9a-f]{64}$/i.test(cat)) throw new Error("not a token category"); const rec = ctx.bcmr.setLocalName(cat, { name: p?.name, symbol: p?.symbol, decimals: p?.decimals, }); return { category: cat, local: rec }; }); // Read the configured BCMR registry list (defaults + any user additions). api.onMessage("bcmrRegistries", (_p, m) => { fromPanel(m); return { registries: ctx.bcmr.registryList() }; }); // Overwrite the registry list. Empty array restores defaults on next read. api.onMessage("setBcmrRegistries", (p, m) => { fromPanel(m); ctx.bcmr.setRegistries(Array.isArray(p?.registries) ? p.registries : []); return { registries: ctx.bcmr.registryList() }; }); api.onMessage("verifyMessage", (p, m) => { fromPanel(m); const message = String(p?.message != null ? p.message : ""); const signature = String(p?.signature || ""); const address = String(p?.address || ""); if (!signature || !address) throw new Error("signature and address are required"); try { return ctx.d.keysLib.verifyMessage(message, signature, address, { cashaddr: ctx.d.cashaddr, secp256k1: ctx.d.secp256k1, }); } catch (e) { return { valid: false, error: e?.message || String(e) }; } }); // ---- Tron bridge (tronWeb / tronLink) ----------------------------------- api.onMessage("trx.requestAccounts", async (_p, m) => { const origin = fromPage(m); const rt = activeTronRuntime(); const snap = rt.adapter.snapshot(); if (grantFor(api, origin, "trx")) 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"); setGrant(api, origin, "trx", { readAddress: true, network: snap.network }, pick === "allow+always"); return { code: 200, address: snap.address, network: snap.network }; }); }); api.onMessage("trx.getAccount", (_p, m) => { const origin = fromPage(m); if (!grantFor(api, origin, "trx")) 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). // Everything the overlay shows is decoded from raw_data_hex — the bytes // that get signed. The overlay used to read the dapp's raw_data JSON, // which can say "1 TRX to X" over bytes that do something else entirely. api.onMessage("trx.signTransaction", async (p, m) => { const origin = fromPage(m); const rt = activeTronRuntime(); if (!grantFor(api, origin, "trx")) throw new Error("not connected — call tron_requestAccounts first"); const tx = p && p.transaction; if (!tx || typeof tx !== "object" || !tx.raw_data_hex) throw new Error("bad transaction"); // The dapp's raw_data JSON and txID are cross-checked, never trusted. let decoded; try { decoded = ctx.d.tronDecode.decodeRawData(tx.raw_data_hex); } catch (e) { throw new Error("cannot decode raw_data_hex: " + (e?.message || e)); } if (tx.txID && String(tx.txID).toLowerCase() !== decoded.txid) throw new Error("txID does not match raw_data_hex"); const me = rt.adapter.address; for (const c of decoded.contracts) { if (!c.owner) throw new Error(`cannot read the owner of ${c.typeName}; refusing to sign`); if (c.owner !== me) throw new Error(`transaction owner ${c.owner} is not this wallet`); } return withOriginLock(origin, async () => { const rows = []; decoded.contracts.forEach((c, i) => { const n = decoded.contracts.length > 1 ? ` #${i + 1}` : ""; rows.push({ label: "Type" + n, value: c.typeName + (c.dangerous ? " — grants control to another account" : "") }); if (c.type === 1) { rows.push({ label: "To", value: c.to || "(none)", mono: true }); rows.push({ label: "Amount", value: `${fmtTrx(Number(c.amount))} TRX`, strong: true }); } else if (c.type === 2) { rows.push({ label: "To", value: c.to || "(none)", mono: true }); rows.push({ label: "Amount", value: `${String(c.amount)} units of asset ${c.assetName || "?"}`, strong: true }); } else if (c.type === 31) { rows.push({ label: "Contract", value: c.contract || "(none)", mono: true }); const call = c.call; if (call && call.name === "transfer") rows.push({ label: "Call", value: `transfer ${call.unlimited ? "UNLIMITED" : String(call.amount)} token units to ${call.to}`, strong: true }); else if (call && (call.name === "approve" || call.name === "increaseAllowance")) rows.push({ label: "Call", value: `${call.name}: let ${call.spender} spend ${call.unlimited ? "UNLIMITED (∞)" : String(call.amount)} token units`, strong: true }); else if (call && call.name === "transferFrom") rows.push({ label: "Call", value: `transferFrom ${call.from} → ${call.to}: ${call.unlimited ? "UNLIMITED" : String(call.amount)} units`, strong: true }); else rows.push({ label: "Call", value: call && call.selector ? `unknown method 0x${call.selector} (${c.data.length / 2} bytes, not decoded)` : "(no call data)", mono: true }); if (c.callValue) rows.push({ label: "TRX attached", value: `${fmtTrx(Number(c.callValue))} TRX` }); } }); if (decoded.feeLimit != null) rows.push({ label: "Fee limit", value: `${fmtTrx(Number(decoded.feeLimit))} TRX` }); if (decoded.memo) rows.push({ label: "Memo", value: decoded.memo.slice(0, 200), mono: true }); rows.push({ label: "Tx ID", value: decoded.txid, mono: true }); rows.push({ label: "Wallet", value: `${rt.entry.label} — Tron · ${rt.adapter.snapshot().network === "nile" ? "Nile testnet" : "Mainnet"}` }); const risky = decoded.contracts.some((c) => c.dangerous || (c.call && c.call.unlimited)); const pick = await api.approvalModal({ title: "Sign a Tron transaction?", origin, body: risky ? "WARNING: this transaction hands another account unlimited or permanent control over funds. Only sign if you fully trust this site." : "Decoded from the bytes that will be signed. Check the type, amount and destination.", rows, actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }], }); if (pick !== "sign") throw new Error("user rejected"); await requireDappTxPin(origin, "Tron transaction"); const sig = rt.adapter.signRawData(tx.raw_data_hex); rememberSignedTron(decoded.txid); return { ...tx, txID: decoded.txid, signature: [sig] }; }); }); api.onMessage("trx.sendRawTransaction", async (p, m) => { const origin = fromPage(m); const rt = activeTronRuntime(); if (!grantFor(api, origin, "trx")) 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 — the sign step carried it. But only what Aegis // signed goes out: the bridge is not a relay for foreign signatures. let txid; try { txid = ctx.d.tronDecode.decodeRawData(signedTx.raw_data_hex).txid; } catch (e) { throw new Error("cannot decode raw_data_hex: " + (e?.message || e)); } if (!signedTronTxids.has(txid)) throw new Error("refusing to broadcast a transaction this wallet did not sign"); return rt.adapter.broadcastSignedTx({ ...signedTx, txID: txid }); }); api.onMessage("trx.signMessageV2", async (p, m) => { const origin = fromPage(m); const rt = activeTronRuntime(); if (!grantFor(api, origin, "trx")) 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: previewText(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) --------------------------------------------- function ethRuntimeForChain(chainId) { for (const rt of ctx.runtimes.values()) { if (rt.entry.chain !== "eth" || rt.phase !== "ready") continue; if (Number(rt.adapter.snapshot().chainId) === Number(chainId)) return rt; } return null; } // The wallet a given origin talks to. Chain is per origin — fixed at // connect, changed only by that origin's own wallet_switchEthereumChain / // wallet_addEthereumChain. The sidebar selection is a UI preference and // never redirects a connected dapp. Unconnected origins get the chain // they asked for before connecting, else the selected ETH wallet. function activeEthRuntime(origin) { const g = origin ? grantFor(api, origin, "eth") : null; const wanted = g?.chainId ?? (origin ? sessionGrants.get(origin)?.eth?.chainId : null); if (wanted != null) { const rt = ethRuntimeForChain(wanted); if (rt) return rt; } 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) { return !!grantFor(api, origin, "eth"); } function ethError(message, code) { const e = new Error(message); e.code = code; return e; } api.onMessage("eth.requestAccounts", async (_p, m) => { const origin = fromPage(m); const rt = activeEthRuntime(origin); const snap = rt.adapter.snapshot(); const chainIdHex = "0x" + Number(snap.chainId).toString(16); const networkVersion = String(snap.chainId); if (ethConnectedFor(origin)) 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: chainMeta("eth", rt.entry.network)?.label || snap.network }, { 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"); setGrant(api, origin, "eth", { readAddress: true, chainId: snap.chainId }, pick === "allow+always"); 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(origin); // ethers / viem / wagmi send hex-encoded bytes; signing that hex as // literal text produced signatures no dapp could verify. The overlay // shows the decoded text. const bytes = bytesFromDappMessage(p && p.message); if (bytes.length > 16384) 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: previewBytes(bytes), 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); }); }); 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(origin); const tx = (p && p.tx) || {}; if (!tx.to) throw new Error("tx.to required"); if (tx.from && String(tx.from).toLowerCase() !== rt.adapter.address.toLowerCase()) throw new Error("tx.from is not the connected account"); // MetaMask semantics: every field the dapp set is honoured verbatim — // value, data, gas, fee caps, nonce. plan() fills in what is missing. // The calldata used to be dropped, so a token transfer went out as a // plain 0-value send to the token contract. const ethLib = ctx.d.ethAdapter; const data = ethLib.normalizeData(tx.data ?? tx.input); const valueWei = tx.value ? ethLib.toBig(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, data, gasLimit: tx.gas ?? tx.gasLimit, maxFeePerGas: tx.maxFeePerGas, maxPriorityFeePerGas: tx.maxPriorityFeePerGas, gasPrice: tx.gasPrice, nonce: tx.nonce, }); const meta = chainMeta("eth", rt.entry.network); const ticker = snap.ticker || meta.ticker; const isCall = data !== "0x"; const rows = [{ label: "To", value: ethLib.eip55(plan.recipients[0].to), mono: true }]; let body = `This site is asking your wallet to send ${ticker}.`; let risky = false; if (isCall) { let code = "0x"; try { code = await rt.adapter._client.call("eth_getCode", [plan.recipients[0].to, "latest"]); } catch {} rows.push({ label: "Destination", value: code && code !== "0x" ? "smart contract" : "NOT a contract — calldata sent to a plain address does nothing" }); const call = ethLib.decodeCalldata(data); if (call && call.name === "transfer") { rows.push({ label: "Call", value: `transfer ${call.unlimited ? "UNLIMITED" : call.amount.toString()} token units to ${call.to}`, strong: true }); } else if (call && (call.name === "approve" || call.name === "increaseAllowance")) { risky = !!call.unlimited; rows.push({ label: "Call", value: `${call.name}: let ${call.spender} spend ${call.unlimited ? "UNLIMITED (∞)" : call.amount.toString()} token units`, strong: true }); } else if (call && call.name === "transferFrom") { rows.push({ label: "Call", value: `transferFrom ${call.from} → ${call.to}: ${call.amount.toString()} units`, strong: true }); } else if (call && call.name === "setApprovalForAll") { risky = !!call.approved; rows.push({ label: "Call", value: `setApprovalForAll: ${call.approved ? "GRANT" : "revoke"} ${call.operator} control over every NFT in this collection`, strong: true }); } else if (call && call.name === "permit") { risky = !!call.unlimited; rows.push({ label: "Call", value: `permit: ${call.spender} may spend ${call.unlimited ? "UNLIMITED (∞)" : call.value.toString()} units`, strong: true }); } else { rows.push({ label: "Call", value: call ? `unknown method 0x${call.selector} (${call.bytes} bytes, not decoded)` : `${(data.length - 2) / 2} bytes of calldata`, mono: true }); } body = risky ? "WARNING: this call grants another address open-ended control over your tokens. Only sign if you fully trust this site." : "This transaction calls a contract. Aegis decoded what it could — check the destination, the call and the value."; } rows.push({ label: "Amount", value: `${fmtValue(plan.recipients[0].value, meta.decimals)} ${ticker}`, strong: true }); rows.push({ label: "Fee (est.)", value: `${fmtValue(plan.feeEstimate, meta.decimals)} ${ticker} · max ${fmtValue(plan.fee, meta.decimals)} ${ticker}` }); rows.push({ label: "Gas", value: `${plan.gasLimit} · nonce ${plan._draft.nonce}` }); rows.push({ label: "Wallet", value: `${rt.entry.label} — ${meta.label}` }); const pick = await api.approvalModal({ title: isCall ? "Send Ethereum contract call?" : "Send Ethereum transaction?", origin, body, rows, actions: [{ id: "send", label: risky ? "Send anyway" : "Send", primary: !risky, danger: risky }], }); if (pick !== "send") throw new Error("user rejected"); await requireDappTxPin(origin, "Ethereum transaction"); 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(origin); // 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 snapTd = rt.adapter.snapshot(); // A signature for another chain is the standard way to get an approval // the user believes is for a testnet or a sidechain. MetaMask refuses a // domain whose chainId is not the active chain; so does Aegis. if (dom.chainId != null && dom.chainId !== "") { let domChain = null; try { domChain = BigInt(dom.chainId); } catch {} if (domChain === null || domChain !== BigInt(snapTd.chainId)) { throw ethError(`typed data is for chain ${dom.chainId} but this site is connected on chain ${snapTd.chainId}`, 4901); } } 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, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2) || ""; const rows = [{ label: "Domain", value: domainSummary }]; if (dom.verifyingContract) rows.push({ label: "Contract", value: String(dom.verifyingContract), mono: true }); rows.push({ label: "Primary type", value: String(td.primaryType || "") }); // Off-chain approvals. A Permit / Permit2 signature moves no gas and // shows no transaction, yet lets the spender take the tokens later — it // is how most wallet drains happen now. Pull the spender, the amounts // and the deadline out of the message and say what they mean. const permit = describePermit(td); if (permit) for (const r of permit.rows) rows.push(r); rows.push({ label: "Message", value: previewText(messagePreview, 3000), mono: true }); rows.push({ label: "Address", value: snapTd.address, mono: true }); const risky = !!(permit && permit.risky); return withOriginLock(origin, async () => { const pick = await api.approvalModal({ title: permit ? "Sign a token spending permit?" : "Sign typed data (EIP-712)?", origin, body: permit ? (risky ? "WARNING: signing this lets the spender below take these tokens at any time, without another prompt and without a transaction from you. Only sign if you fully trust this site." : "Signing this lets the spender below move the stated amount of your tokens without a transaction from you.") : "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, actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }], }); if (pick !== "sign") throw new Error("user rejected"); // A permit moves tokens as surely as a transaction does. if (permit) await requireDappTxPin(origin, "token spending permit"); return rt.adapter.signTypedDataDigest(digest); }); }); api.onMessage("eth.switchChain", async (p, m) => { const origin = fromPage(m); const wantHex = String(p && p.chainId || "").toLowerCase(); const wantId = Number(wantHex); if (!Number.isFinite(wantId) || wantId <= 0) throw new Error("bad chainId"); // EIP-3326: the well-known "chain not added" code makes dapps fall back // to wallet_addEthereumChain. if (!ethRuntimeForChain(wantId)) throw ethError(`Aegis: chainId ${wantHex} is not added. Ask via wallet_addEthereumChain.`, 4902); // Per origin only: THIS site moves to the wallet on that chain. It used // to flip the global selected wallet, so any site — connected or not — // could move every other connected dapp onto another chain. Unconnected // sites just have the preference remembered for their connect prompt. if (!patchGrant(api, origin, "eth", { chainId: wantId })) rememberPendingChain(origin, wantId); return null; }); // 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) { // Already known: behaves like a switch for THIS origin only. Never a // grant — this used to persist a connection without any prompt, so // any site could read the address by "adding" a chain Aegis already // had. An unconnected site gets the chain remembered for its eventual // eth_requestAccounts and learns nothing else. if (!patchGrant(api, origin, "eth", { chainId })) rememberPendingChain(origin, chainId); 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); ctx.runtimes.set(id, { entry, phase: "locked", error: null, adapter: null }); emitState(); await mountWallet(entry); // Adding a chain is not connecting. A connected site moves to the new // chain; an unconnected one has it remembered for its connect prompt. if (!patchGrant(api, origin, "eth", { chainId })) rememberPendingChain(origin, chainId); 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(origin); 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) => { const origin = fromPage(m); const rt = activeEthRuntime(origin); 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"); // The user's RPC endpoint is theirs (often a keyed, metered one). A site // that has not connected gets the three calls every dapp makes before // asking to connect and nothing else; signing, account and filter-state // methods are never relayed, and broadcasting needs a connection. const connected = ethConnectedFor(origin); if (!connected && !ETH_RPC_PRECONNECT.has(method)) throw ethError("not connected — call eth_requestAccounts first", 4100); if (ETH_RPC_NEVER.test(method)) throw ethError(`Aegis does not relay ${method}`, 4200); 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) { return !!grantFor(api, origin, "sol"); } // Is this site already connected? Lets a Wallet Standard "silent" connect // (what adapters do on page load to restore a session) succeed without a // prompt for a connected site and stay quiet for everyone else. api.onMessage("sol.state", (_p, m) => { const origin = fromPage(m); if (!solConnectedFor(origin)) return { address: null }; try { const snap = activeSolRuntime().adapter.snapshot(); return { address: snap.address, network: snap.network }; } catch { return { address: null }; } }); 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"); setGrant(api, origin, "sol", { readAddress: true, network: snap.network }, pick === "allow+always"); 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 = new Uint8Array(Buffer.from(b64, "base64")); if (bytes.length > 16384) throw new Error("message too long"); // A "message" that parses as a transaction message would, once signed, // be a valid transaction signature behind a "moves no SOL" overlay. // Phantom refuses these; so do we. if (parseSolMessage(bytes, rt.adapter._pub).ok) { throw new Error("refusing to sign: this message is a Solana transaction. Use signTransaction."); } return withOriginLock(origin, async () => { 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: previewBytes(bytes), 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.signBytes(bytes); }); }); // Dapp-built transaction the dapp will broadcast itself (window.solana // .signTransaction). It used to go through signMessage and its "moves no // SOL" overlay; it gets its own decoded overlay now: signing IS sending. api.onMessage("sol.signTransaction", async (p, m) => { const origin = fromPage(m); if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first"); const rt = activeSolRuntime(); const messageBytes = new Uint8Array(Buffer.from(String(p && p.messageB64 || ""), "base64")); const parsed = parseSolMessage(messageBytes, rt.adapter._pub); if (!parsed.ok) throw new Error("cannot parse transaction message: " + parsed.error); if (parsed.ourIndex < 0) throw new Error("this wallet's key is not among the transaction's account keys"); if (parsed.ourIndex >= parsed.numRequiredSigs) throw new Error(`this wallet's key is not a required signer (index ${parsed.ourIndex}, requiredSigs ${parsed.numRequiredSigs})`); return withOriginLock(origin, async () => { const snap = rt.adapter.snapshot(); const rows = solInstructionRows(parsed); rows.push({ label: "Address", value: snap.address, mono: true }); rows.push({ label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` }); const pick = await api.approvalModal({ title: "Sign a Solana transaction?", origin, body: "The site built this transaction and will broadcast it itself — signing it is the same as sending it.", rows, actions: [{ id: "sign", label: "Sign", primary: true }], }); if (pick !== "sign") throw new Error("user rejected"); await requireDappTxPin(origin, "Solana transaction"); return rt.adapter.signBytes(messageBytes); }); }); // 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")); const { sigsStart, messageBytes } = splitSolWire(wire); // Legacy and v0 messages both parse (v0 used to be read with its // version byte as the header, so the signer lookup was garbage); our // key must be a required signer. const parsed = parseSolMessage(messageBytes, rt.adapter._pub); if (!parsed.ok) throw new Error("cannot parse transaction message: " + parsed.error); const { numRequiredSigs, ourIndex } = parsed; 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 rows = solInstructionRows(parsed); rows.push({ label: "Address", value: snap.address, mono: true }); rows.push({ label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` }); const pick = await api.approvalModal({ title: "Sign + send a Solana transaction?", origin, body: "The site built this transaction. Check every instruction — anything marked 'not decoded' is a program Aegis cannot read.", rows, actions: [{ id: "send", label: "Sign & send", primary: true }], }); if (pick !== "send") throw new Error("user rejected"); await requireDappTxPin(origin, "Solana transaction"); // Sign the exact message bytes and patch our slot. signMessage() ran // the bytes through String(), so every signature was over "1,2,3,…" // and the network rejected it. Partial signatures already in the wire // (tx.partialSign()) at other slots are preserved. const sigInfo = rt.adapter.signBytes(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 = { // Returns a promise that settles once Aegis can serve calls. Aegis starts // on first use, so the call that woke it is the first thing it sees, and // the host holds that call (up to 5 s) until this promise settles. activate(api) { // No explicit icon — inherit manifest.icon (branded shield data URI) // so the toolbar dock button renders the aegis.x brand mark instead // of a fallback emoji. Same id as the panel addon.json declares, which // this replaces. api.registerSidebarPanel({ id: "main", title: "Wallet", page: "panel.html" }); // Serve reads from memory. The host's storage.get() does a readFileSync // plus a JSON.parse of the ENTIRE add-on store on every single call, on // the Electron main thread. fullState() alone reads it seven-odd times // (selectedWalletId, walletRoles, walletEntries, snapshotForSelected, // the server list...) and emitState() runs on every adapter change, so a // large store made opening Aegis hang the whole browser. // // Safe because this process is the only writer: Aegis's panel talks to // the host over addon messages and never touches addon storage directly. // If that ever changes, this cache has to go or be invalidated. installStorageCache(api, (key) => { if (LAUNCH_START_KEYS.test(key)) syncLaunchStart(api); }); const c = ctx = { api, d: null, runtimes: new Map(), // walletId -> { entry, phase, error, adapter } priceFeed: require("./lib/prices.js")({ log: (...a) => api.log("prices", ...a), onChange: () => emitState(), }), // BCMR (CashTokens metadata registry) — resolves category hex to // { name, symbol, iconUri, decimals }. Storage-scoped so per-user // caches don't stomp each other; disk-cached with 6h TTL. bcmr: require("./lib/bcmr.js")({ storage: api.storage, log: (...a) => api.log("bcmr", ...a), }), wc: null, // WizardConnect manager, initialised when deps load }; migrateLegacyStorage(api); registerPanelMessages(api); registerPageMessages(api); launchStartAsked = null; syncLaunchStart(api); // Restore the user's opt-in choice from storage. Off by default so a // fresh install never hits any oracle without asking. Source can also // be pre-restored so a user who picked Kraken stays on Kraken. const savedSource = String(api.storage.get("pricesSource", "") || "").trim(); if (savedSource) c.priceFeed.setSource(savedSource).catch(() => {}); if (api.storage.get("pricesEnabled", false)) c.priceFeed.setEnabled(true).catch(() => {}); // The deps are heavy to evaluate (noble curve precompute, bitcoinjs, // libauth, WizardConnect) and that all happens on the main thread. Wait // for the browser chrome to paint so the cost never delays Theseus's // first frame; older hosts without whenUiReady load immediately. const uiReady = typeof api.whenUiReady === "function" ? api.whenUiReady() : Promise.resolve(); const started = uiReady.then(() => loadDeps(api)).then(async (d) => { if (ctx !== c) return; c.d = d; // Start the WizardConnect manager once deps are ready. Per-wallet // adapters get spun up in mountWallet() as each BCH wallet becomes // available (only BCH today — hdwalletv1 is BCH-scoped). c.wc = require("./lib/wc.js")({ HDKey: d.HDKey, secp256k1: d.secp256k1, sha256: d.sha256, hkdf: d.hkdf, WalletConnectionManager: d.wcWallet.WalletConnectionManager, wcCore: d.wcCore, libauth: d.libauth, log: (...a) => api.log("wc", ...a), api, // Bridge sign approvals through the addon's approval-modal capability. approvalRequest: async (payload) => { // The host overlay only knows `actions`; the old `approve`/`reject` // keys fell back to a lone "OK" button whose id never matched, so // every WizardConnect signing request was refused, and the HTML // body was shown as literal markup. const { body, rows, dappName } = buildWcApproval(payload); const pick = await api.approvalModal({ title: "Sign a Bitcoin Cash transaction (WizardConnect)?", origin: dappName, body, rows, actions: [{ id: "approve", label: "Sign", primary: true }], }); if (pick !== "approve") return { approved: false }; try { await requireDappTxPin(dappName, "Bitcoin Cash transaction (WizardConnect)"); } catch (e) { api.log("wc sign:", e?.message || e); return { approved: false }; } return { approved: true }; }, }); c.wc.onStateChange(() => emitState()); await tryAutoUnlock(api); // vault.derive() polls until the user unlocks, so with a locked vault // mountAllWallets() never settles. Only wait for it when the vault is // open; otherwise the waking call gets the usual locked answer now // rather than after the host's 5 s timeout. const st = await api.vault.lifecycle.status().catch(() => null); const mounting = mountAllWallets(); if (st && st.unlocked) await mounting; else mounting.catch((e) => api.log("mount:", e?.message || e)); }); started.catch((e) => { if (ctx !== c) return; api.log("startup failed:", e?.message); emitState(); }); return started.catch(() => {}); }, deactivate() { const c = ctx; ctx = null; if (!c) return; try { c.priceFeed && c.priceFeed.dispose(); } catch {} for (const rt of c.runtimes.values()) { try { rt.adapter && rt.adapter.dispose(); } catch {} } c.runtimes.clear(); }, };