Users saw a blank window with a white strip across the top for seconds on launch. Root cause: every part of startup, including session restore, waited for chrome.html's did-finish-load. That event also waits for the page's subresources, and the bookmarks bar loads its favicons over bns:// — a BNS lookup plus a network fetch each — so a slow link held the whole boot. On top of that, seven hidden overlay renderers, every restored tab, the BNS index build and three network fetches all started in the same tick and stalled the main thread ~1 s while the toolbar tried to paint. - Continue boot at chrome.html's dom-ready (toolbar scripts have run, IPC listeners exist) instead of did-finish-load; 8 s fallback timer. - Window and chrome view get the toolbar's --bg for the active theme so the pre-paint frame is never white. - Overlay pages (site info, engine picker, downloads, suggestions, password fill, link status, approval) load 250 ms after the toolbar or on first use; the approval modal awaits its page so a dapp request can't hang. - Session restore is staggered: active tab first, then one background tab per 150 ms slotted into its saved strip position. Session file v2 records the active index; v1 arrays still load (active = last, as the old loop effectively did). - AddonHost gains api.whenUiReady(); Aegis 0.6.2 defers its heavy dependency loading (noble precompute, bitcoinjs, libauth, WizardConnect) behind it. - BNS snapshot warm-up still starts right after createWindow (bookmark favicons need it); Sia refresh, update check and home-card fetch move to the post-paint phase. Measured on a clone of the real profile with nine restored tabs: toolbar usable at ~0.7 s instead of ~1.5 s, main-thread stall during toolbar load down from ~1.1 s to ~0.2 s.
1922 lines
86 KiB
JavaScript
1922 lines
86 KiB
JavaScript
// Aegis — multi-chain wallet bundled with Theseus. activate() runs in the
|
||
// main process; every wallet's key material lives here, in memory, and is
|
||
// re-derived from the password vault on every launch. Nothing secret is ever
|
||
// written to disk or logged.
|
||
//
|
||
// A single addon can hold many wallets — one per {chain, network}, or several
|
||
// sub-accounts of the same chain — and each wallet is backed by its own
|
||
// vault-derived 32-byte HKDF root. Chains today: BCH, Tron mainnet, Tron
|
||
// Nile testnet. Adding a fourth chain is a new adapter file under lib/ and
|
||
// an entry in the CHAIN_REGISTRY below.
|
||
//
|
||
// Back-compat: the addon id stays "bchwallet" (the manifest label became
|
||
// Aegis, but the id gates the vault-derive namespace and older vaults have
|
||
// funds against it). The legacy BCH default wallet uses purpose
|
||
// "bchwallet/mainnet/0" — byte-identical to the pre-multi-wallet build —
|
||
// so on-disk funds are untouched. See memory bchwallet-vault-root-derivation.
|
||
const path = require("node:path");
|
||
const fs = require("node:fs");
|
||
|
||
const LEGACY_BCH_PURPOSE = "bchwallet/mainnet/0";
|
||
const LEGACY_BCH_WALLET_ID = "bch-default";
|
||
|
||
let ctx = null;
|
||
|
||
// ---- deps -------------------------------------------------------------------
|
||
|
||
async function loadDeps(api) {
|
||
const { secp256k1 } = await api.import("@noble/curves/secp256k1.js");
|
||
const { ed25519 } = await api.import("@noble/curves/ed25519.js");
|
||
const { sha256 } = await api.import("@noble/hashes/sha2.js");
|
||
const { hkdf } = await api.import("@noble/hashes/hkdf.js");
|
||
const { ripemd160 } = await api.import("@noble/hashes/legacy.js");
|
||
const { keccak_256 } = await api.import("@noble/hashes/sha3.js");
|
||
const { blake2b } = await api.import("@noble/hashes/blake2.js");
|
||
const { HDKey } = await api.import("@scure/bip32");
|
||
const WebSocket = api.require("ws");
|
||
const cashaddr = require("./lib/cashaddr.js");
|
||
const keysLib = require("./lib/keys.js")({ HDKey, secp256k1, sha256, ripemd160, cashaddr });
|
||
const tx = require("./lib/tx.js")({ sha256 });
|
||
const electrum = require("./lib/electrum.js")({ WebSocket, log: (...a) => api.log("electrum", ...a) });
|
||
const base58check = require("./lib/base58check.js")({ sha256 });
|
||
const bchAdapter = require("./lib/chain-bch.js")({
|
||
HDKey, secp256k1, sha256, ripemd160, cashaddr, keysLib, tx, electrum, WebSocket,
|
||
});
|
||
const tronAdapter = require("./lib/chain-tron.js")({
|
||
HDKey, secp256k1, sha256, keccak_256, base58check,
|
||
});
|
||
const siaAdapter = require("./lib/chain-sia.js")({ ed25519, blake2b });
|
||
const ethAdapter = require("./lib/chain-eth.js")({ HDKey, secp256k1, keccak_256 });
|
||
const eip712 = require("./lib/eip712.js")({ keccak_256 });
|
||
// Solana uses the raw base58 alphabet (no checksum), which lives inside
|
||
// base58check as encodeBase58 / decodeBase58 — expose them under a
|
||
// `{encode, decode}` shape the SOL adapter reads from.
|
||
const solBase58 = { encode: base58check.encodeBase58, decode: base58check.decodeBase58 };
|
||
const solAdapter = require("./lib/chain-sol.js")({ ed25519, base58: solBase58, sha256 });
|
||
// DGB delegates address derivation + PSBT to the vendored @dgb-wallet/*
|
||
// packages under lib/dgb/. Those are ESM; the peer deps (bitcoinjs-lib,
|
||
// bip32, ecpair, @bitcoinerlab/secp256k1) are CommonJS and reachable via
|
||
// api.require from the Theseus dependency tree.
|
||
const { pathToFileURL } = require("node:url");
|
||
// 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.
|
||
let dgbCore = null, dgbPsbt = null, dgbAdapter = null;
|
||
try {
|
||
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);
|
||
}
|
||
const bitcoinjs = api.require("bitcoinjs-lib");
|
||
const { BIP32Factory } = api.require("bip32");
|
||
const { ECPairFactory } = api.require("ecpair");
|
||
const ecc = api.require("@bitcoinerlab/secp256k1");
|
||
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,
|
||
});
|
||
// 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, bip39,
|
||
dgbCore, dgbPsbt, bitcoinjs, ecc, eip712,
|
||
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[<id>]` fields override or
|
||
// add to them per-network. `logo` is the SVG key panel.js draws from.
|
||
const COINS = {
|
||
bch: {
|
||
chain: "bch",
|
||
label: "Bitcoin Cash",
|
||
short: "BCH",
|
||
ticker: "BCH",
|
||
decimals: 8,
|
||
color: "#0ac18e",
|
||
logo: "bch",
|
||
supportsMessageSign: true,
|
||
supportsPageInject: true, // window.bitcoincash on *.x pages
|
||
networks: {
|
||
mainnet: {
|
||
id: "mainnet", label: "Mainnet", testnet: false,
|
||
// Legacy default wallet uses the flat "bchwallet/mainnet/0" purpose;
|
||
// new BCH mainnet sub-accounts start at index 1 under the /bch/ prefix.
|
||
purposePrefix: "bchwallet/bch/",
|
||
startIndex: 1,
|
||
},
|
||
chipnet: {
|
||
id: "chipnet", label: "Chipnet testnet", testnet: true,
|
||
purposePrefix: "bchwallet/bch/chipnet/",
|
||
startIndex: 0,
|
||
},
|
||
},
|
||
},
|
||
trx: {
|
||
chain: "trx",
|
||
label: "Tron",
|
||
short: "TRX",
|
||
ticker: "TRX",
|
||
decimals: 6,
|
||
color: "#ff060a",
|
||
logo: "trx",
|
||
supportsMessageSign: true,
|
||
supportsPageInject: true, // window.tronWeb everywhere
|
||
networks: {
|
||
mainnet: {
|
||
id: "mainnet", label: "Mainnet", testnet: false,
|
||
purposePrefix: "bchwallet/trx/mainnet/", startIndex: 0,
|
||
},
|
||
nile: {
|
||
id: "nile", label: "Nile testnet", testnet: true,
|
||
purposePrefix: "bchwallet/trx/nile/", startIndex: 0,
|
||
},
|
||
},
|
||
},
|
||
sc: {
|
||
chain: "sc",
|
||
label: "Siacoin",
|
||
short: "SC",
|
||
ticker: "SC",
|
||
decimals: 24,
|
||
color: "#20be82",
|
||
logo: "sc",
|
||
supportsMessageSign: true,
|
||
supportsPageInject: false,
|
||
// First SC wallet in Aegis reuses the legacy standalone-siawallet purpose
|
||
// so pre-Aegis funds carry over automatically (addon.json declares
|
||
// `absorbs: ["siawallet"]` to allow the derivation). Second+ use the new
|
||
// Aegis-namespaced prefix.
|
||
networks: {
|
||
mainnet: {
|
||
id: "mainnet", label: "Mainnet", testnet: false,
|
||
purposePrefix: "bchwallet/sc/mainnet/", startIndex: 1,
|
||
legacyFirstPurpose: "siawallet/mainnet/0",
|
||
},
|
||
},
|
||
},
|
||
dgb: {
|
||
chain: "dgb",
|
||
label: "DigiByte",
|
||
short: "DGB",
|
||
ticker: "DGB",
|
||
decimals: 8,
|
||
color: "#0066cc",
|
||
logo: "dgb",
|
||
supportsMessageSign: true,
|
||
supportsPageInject: false,
|
||
// BIP44/49/84/86 address families — the picker lives in the DGB
|
||
// settings block. Default is BIP84 (dgb1q…), which matches modern
|
||
// DGB Core, DigiByte-Go, and the SilentCode web-wallet. `coinType`
|
||
// per chain feeds the path builder below.
|
||
addressFamilies: [
|
||
{ id: "bip84", purpose: 84, label: "Native SegWit (dgb1q…)" },
|
||
{ id: "bip86", purpose: 86, label: "Taproot (dgb1p…)" },
|
||
{ id: "bip49", purpose: 49, label: "Wrapped SegWit (S…)" },
|
||
{ id: "bip44", purpose: 44, label: "Legacy P2PKH (D…)" },
|
||
],
|
||
coinType: 20,
|
||
defaultPurpose: 84,
|
||
networks: {
|
||
mainnet: {
|
||
id: "mainnet", label: "Mainnet", testnet: false,
|
||
purposePrefix: "bchwallet/dgb/mainnet/", startIndex: 0,
|
||
},
|
||
},
|
||
},
|
||
eth: {
|
||
chain: "eth",
|
||
label: "Ethereum",
|
||
short: "ETH",
|
||
ticker: "ETH",
|
||
decimals: 18,
|
||
color: "#627eea",
|
||
logo: "eth",
|
||
supportsMessageSign: true,
|
||
supportsPageInject: false, // EIP-1193 provider is a follow-up
|
||
networks: {
|
||
mainnet: {
|
||
id: "mainnet", label: "Mainnet", testnet: false,
|
||
purposePrefix: "bchwallet/eth/mainnet/", startIndex: 0,
|
||
},
|
||
sepolia: {
|
||
id: "sepolia", label: "Sepolia testnet", testnet: true,
|
||
purposePrefix: "bchwallet/eth/sepolia/", startIndex: 0,
|
||
},
|
||
},
|
||
},
|
||
sol: {
|
||
chain: "sol",
|
||
label: "Solana",
|
||
short: "SOL",
|
||
ticker: "SOL",
|
||
decimals: 9,
|
||
color: "#9945ff",
|
||
logo: "sol",
|
||
supportsMessageSign: true,
|
||
supportsPageInject: false,
|
||
networks: {
|
||
mainnet: {
|
||
id: "mainnet", label: "Mainnet-beta", testnet: false,
|
||
purposePrefix: "bchwallet/sol/mainnet/", startIndex: 0,
|
||
},
|
||
devnet: {
|
||
id: "devnet", label: "Devnet", testnet: true,
|
||
purposePrefix: "bchwallet/sol/devnet/", startIndex: 0,
|
||
},
|
||
},
|
||
},
|
||
btc: {
|
||
chain: "btc",
|
||
label: "Bitcoin",
|
||
short: "BTC",
|
||
ticker: "BTC",
|
||
decimals: 8,
|
||
color: "#f7931a",
|
||
logo: "btc",
|
||
supportsMessageSign: true,
|
||
supportsPageInject: false,
|
||
// BIP44/49/84/86 across bc1q… / bc1p… / 3… / 1… on mainnet and
|
||
// tb1q… / tb1p… / 2… / m/n… on testnet3 + signet. Coin type shifts
|
||
// per network (0 for mainnet, 1 for both testnet3 and signet — SLIP-44
|
||
// treats every Bitcoin testnet as coin type 1).
|
||
addressFamilies: [
|
||
{ id: "bip84", purpose: 84, label: "Native SegWit (bc1q… / tb1q…)" },
|
||
{ id: "bip86", purpose: 86, label: "Taproot (bc1p… / tb1p…)" },
|
||
{ id: "bip49", purpose: 49, label: "Wrapped SegWit (3… / 2…)" },
|
||
{ id: "bip44", purpose: 44, label: "Legacy P2PKH (1… / m…, n…)" },
|
||
],
|
||
coinType: { mainnet: 0, testnet: 1, signet: 1 },
|
||
defaultPurpose: 84,
|
||
networks: {
|
||
mainnet: {
|
||
id: "mainnet", label: "Mainnet", testnet: false,
|
||
purposePrefix: "bchwallet/btc/mainnet/", startIndex: 0,
|
||
},
|
||
testnet: {
|
||
id: "testnet", label: "Testnet3", testnet: true,
|
||
purposePrefix: "bchwallet/btc/testnet/", startIndex: 0,
|
||
},
|
||
signet: {
|
||
id: "signet", label: "Signet", testnet: true,
|
||
purposePrefix: "bchwallet/btc/signet/", startIndex: 0,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
function chainKey(chain, network) { return `${chain}:${network}`; }
|
||
// Coin type per (chain, network). A number literal on the COINS entry
|
||
// (DGB uses a single 20) or a per-network object ({mainnet: 0, testnet: 1}
|
||
// for BTC). Returns null when the chain doesn't declare a family picker.
|
||
function coinTypeFor(c, network) {
|
||
if (!c || c.coinType == null) return null;
|
||
return typeof c.coinType === "number" ? c.coinType : (c.coinType[network] ?? null);
|
||
}
|
||
// Full derivation paths per family for a given (chain, network) — expands
|
||
// the family list on the fly so each picker knows exactly which path a
|
||
// pick would produce.
|
||
function addressFamiliesFor(c, network) {
|
||
if (!c || !c.addressFamilies) return null;
|
||
const ct = coinTypeFor(c, network);
|
||
if (ct == null) return c.addressFamilies;
|
||
return c.addressFamilies.map((f) => ({
|
||
...f,
|
||
defaultAccountPath: `m/${f.purpose}'/${ct}'/0'`,
|
||
}));
|
||
}
|
||
function defaultAccountPathFor(c, network) {
|
||
const ct = coinTypeFor(c, network);
|
||
if (ct == null || c.defaultPurpose == null) return null;
|
||
return `m/${c.defaultPurpose}'/${ct}'/0'`;
|
||
}
|
||
// Custom EVM chains (EIP-3085) live in api.storage under `customEthChains`.
|
||
// Structured as { [chainId]: {chainId, chainName, rpcUrl, explorerTx,
|
||
// explorerAddr, ticker, addedAt, addedByOrigin} }. They're not in COINS at
|
||
// module-load time — we synthesize a chainMeta / mount entry from storage
|
||
// so wallet_addEthereumChain can register new networks at runtime without
|
||
// a Theseus restart.
|
||
const CUSTOM_ETH_PREFIX = "custom-";
|
||
function customEthChains(api) {
|
||
const raw = api.storage.get("customEthChains", {});
|
||
return raw && typeof raw === "object" ? raw : {};
|
||
}
|
||
function customEthNetworkEntry(api, network) {
|
||
if (!network || !network.startsWith(CUSTOM_ETH_PREFIX)) return null;
|
||
const chainId = Number(network.slice(CUSTOM_ETH_PREFIX.length));
|
||
if (!Number.isFinite(chainId)) return null;
|
||
const all = customEthChains(api);
|
||
const cfg = all[String(chainId)];
|
||
if (!cfg) return null;
|
||
return {
|
||
id: network,
|
||
label: cfg.chainName || `EVM #${chainId}`,
|
||
chainId,
|
||
defaultRpc: cfg.rpcUrl,
|
||
explorerTx: cfg.explorerTx,
|
||
explorerAddr: cfg.explorerAddr,
|
||
ticker: cfg.ticker || "ETH",
|
||
faucet: null,
|
||
};
|
||
}
|
||
function chainMeta(chain, network) {
|
||
// ETH custom-network fallback for EIP-3085 chains.
|
||
if (chain === "eth" && String(network || "").startsWith(CUSTOM_ETH_PREFIX)) {
|
||
const cfg = customEthNetworkEntry(ctx?.api, network);
|
||
if (!cfg) return null;
|
||
return {
|
||
chain: "eth", network: cfg.id,
|
||
label: "Ethereum · " + cfg.label,
|
||
short: cfg.ticker, ticker: cfg.ticker, decimals: 18,
|
||
color: COINS.eth.color, logo: COINS.eth.logo,
|
||
coinLabel: cfg.label, networkLabel: `chainId ${cfg.chainId}`,
|
||
testnet: false,
|
||
purposePrefix: `bchwallet/eth/${cfg.id}/`, startIndex: 0,
|
||
supportsMessageSign: true, supportsPageInject: false,
|
||
addressFamilies: null, defaultAccountPath: null,
|
||
isCustom: true, chainId: cfg.chainId,
|
||
};
|
||
}
|
||
const c = COINS[chain]; const n = c && c.networks[network];
|
||
if (!c || !n) return null;
|
||
return {
|
||
chain: c.chain, network: n.id,
|
||
label: c.label + " · " + n.label, short: c.short, ticker: c.ticker, decimals: c.decimals,
|
||
color: c.color, logo: c.logo, coinLabel: c.label, networkLabel: n.label, testnet: !!n.testnet,
|
||
purposePrefix: n.purposePrefix, startIndex: n.startIndex,
|
||
supportsMessageSign: !!c.supportsMessageSign, supportsPageInject: !!c.supportsPageInject,
|
||
addressFamilies: addressFamiliesFor(c, network),
|
||
defaultAccountPath: defaultAccountPathFor(c, network),
|
||
};
|
||
}
|
||
function coinsForPanel() {
|
||
return Object.values(COINS).map((c) => ({
|
||
chain: c.chain, label: c.label, short: c.short, ticker: c.ticker, color: c.color, logo: c.logo, decimals: c.decimals,
|
||
networks: Object.values(c.networks).map((n) => ({ id: n.id, label: n.label, testnet: !!n.testnet })),
|
||
}));
|
||
}
|
||
|
||
// ---- wallet list ------------------------------------------------------------
|
||
|
||
function readWallets(api) {
|
||
const raw = api.storage.get("wallets", null);
|
||
return Array.isArray(raw) ? raw : null;
|
||
}
|
||
function writeWallets(api, list) { api.storage.set("wallets", list); }
|
||
|
||
// Bring pre-multi-wallet storage forward: create the legacy BCH default entry
|
||
// and rehome its receiveCursor / txCache under the new per-wallet subkey.
|
||
function migrateLegacyStorage(api) {
|
||
if (readWallets(api)) return; // already multi-wallet
|
||
const legacyAccountPath = String(api.storage.get("accountPath", "") || "").trim() || "m/44'/145'/0'";
|
||
const wallets = [{
|
||
id: LEGACY_BCH_WALLET_ID,
|
||
label: "BCH — main",
|
||
chain: "bch",
|
||
network: "mainnet",
|
||
purpose: LEGACY_BCH_PURPOSE,
|
||
accountPath: legacyAccountPath,
|
||
isDefault: true,
|
||
isLegacy: true,
|
||
createdAt: 0,
|
||
}];
|
||
writeWallets(api, wallets);
|
||
api.storage.set("selectedWalletId", LEGACY_BCH_WALLET_ID);
|
||
// Move per-wallet state under the scoped prefix used by chain-bch.js.
|
||
const prefix = `wallets/${LEGACY_BCH_WALLET_ID}/`;
|
||
for (const legacyKey of ["receiveCursor", "txCache"]) {
|
||
const v = api.storage.get(legacyKey, null);
|
||
if (v !== null && api.storage.get(prefix + legacyKey, null) === null) {
|
||
api.storage.set(prefix + legacyKey, v);
|
||
}
|
||
}
|
||
api.log("migrated legacy BCH wallet into multi-wallet layout");
|
||
}
|
||
|
||
function nextIndex(wallets, meta) {
|
||
let max = meta.startIndex - 1;
|
||
for (const w of wallets) {
|
||
if (chainKey(w.chain, w.network) !== chainKey(meta.chain, meta.network)) continue;
|
||
if (w.isLegacy) continue;
|
||
const m = /\/(\d+)$/.exec(w.purpose || "");
|
||
const n = m ? Number(m[1]) : NaN;
|
||
if (Number.isFinite(n) && n > max) max = n;
|
||
}
|
||
return max + 1;
|
||
}
|
||
|
||
function makeWalletId(meta, index) {
|
||
const n = String(meta.network).replace(/[^a-z0-9]/gi, "");
|
||
return `${meta.chain}-${n}-${index}`;
|
||
}
|
||
|
||
function autoLabel(meta, wallets) {
|
||
const same = wallets.filter((w) => chainKey(w.chain, w.network) === chainKey(meta.chain, meta.network));
|
||
if (!same.length) return meta.short;
|
||
return `${meta.short} #${same.length + 1}`;
|
||
}
|
||
|
||
// ---- runtime wallet map -----------------------------------------------------
|
||
// A "runtime" is a mounted wallet: its adapter instance plus phase + error.
|
||
// activate() derives all of them in parallel once the vault unlocks.
|
||
|
||
async function mountAllWallets() {
|
||
const c = ctx;
|
||
const walletList = readWallets(c.api) || [];
|
||
for (const w of walletList) {
|
||
if (!c.runtimes.has(w.id)) c.runtimes.set(w.id, { entry: w, phase: "locked", error: null, adapter: null });
|
||
}
|
||
emitState();
|
||
await Promise.all(walletList.map((w) => mountWallet(w)));
|
||
}
|
||
|
||
async function mountWallet(entry) {
|
||
const c = ctx;
|
||
const rt = c.runtimes.get(entry.id) || { entry, phase: "locked", error: null, adapter: null };
|
||
rt.entry = entry;
|
||
rt.phase = "locked"; rt.error = null;
|
||
c.runtimes.set(entry.id, rt);
|
||
emitState();
|
||
|
||
// 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 {
|
||
const adapter = new c.d.importedBchAdapter.ImportedBchWallet({
|
||
walletId: entry.id, storage: c.api.storage,
|
||
log: (...a) => c.api.log(`[${entry.id}]`, ...a),
|
||
onChange: () => emitStateForWallet(entry.id),
|
||
network: entry.network,
|
||
cashaddr: entry.importedCashaddr,
|
||
servers: entry.network === "mainnet" ? bchServerList(c.api) : undefined,
|
||
});
|
||
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/<id>/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,
|
||
root32: root, accountPath: entry.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 {}
|
||
}
|
||
}
|
||
|
||
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 {} }
|
||
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.
|
||
|
||
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])
|
||
const raw = d.base58check.decode(wif);
|
||
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);
|
||
}
|
||
|
||
function buildWcApprovalBody(payload) {
|
||
const req = payload?.request || {};
|
||
const tx = req.transaction || {};
|
||
const dappName = tx.userPrompt || "unknown dapp";
|
||
const inputCount = Array.isArray(req.inputPaths) ? req.inputPaths.length : "?";
|
||
const bc = tx.broadcast ? "Dapp will broadcast after signing." : "Signed hex returned to dapp.";
|
||
const walletName = payload?.label || payload?.walletId || "";
|
||
return "<div><b>" + dappName + "</b> requests a BCH transaction signature.</div>"
|
||
+ "<div>Wallet: <b>" + walletName + "</b> · " + inputCount + " input(s).</div>"
|
||
+ "<div class=hint>" + bc + " Signs with SIGHASH_ALL|FORKID|UTXOS.</div>";
|
||
}
|
||
|
||
// ---- state / snapshot -------------------------------------------------------
|
||
|
||
function selectedWalletId() {
|
||
const list = readWallets(ctx.api) || [];
|
||
if (!list.length) return null;
|
||
const saved = String(ctx.api.storage.get("selectedWalletId", "") || "");
|
||
if (saved && list.some((w) => w.id === saved)) return saved;
|
||
const dflt = list.find((w) => w.isDefault) || list[0];
|
||
return dflt.id;
|
||
}
|
||
|
||
function walletEntries() { return readWallets(ctx.api) || []; }
|
||
|
||
function overallPhase() {
|
||
// If ANY wallet is nosetup, treat the whole addon as nosetup — the user
|
||
// hasn't unlocked / set up the vault, so no wallet can work.
|
||
const runtimes = [...ctx.runtimes.values()];
|
||
if (!runtimes.length) return "locked";
|
||
if (runtimes.some((r) => r.phase === "nosetup")) return "nosetup";
|
||
if (runtimes.some((r) => r.phase === "locked")) return "locked";
|
||
return "ready";
|
||
}
|
||
|
||
function walletSummary(w) {
|
||
const meta = chainMeta(w.chain, w.network);
|
||
const rt = ctx.runtimes.get(w.id);
|
||
const snap = rt && rt.adapter ? rt.adapter.snapshot() : null;
|
||
return {
|
||
id: w.id, label: w.label, chain: w.chain, network: w.network, isDefault: !!w.isDefault, isLegacy: !!w.isLegacy,
|
||
logo: meta?.logo || null, color: meta?.color || "#888",
|
||
coinLabel: meta?.coinLabel || w.chain, networkLabel: meta?.networkLabel || w.network, testnet: !!meta?.testnet,
|
||
ticker: meta?.ticker || "?", short: meta?.short || w.chain, decimals: meta?.decimals || 8,
|
||
address: snap?.address || null,
|
||
balance: snap?.balance || { confirmed: 0, unconfirmed: 0 },
|
||
phase: rt?.phase || "locked",
|
||
error: rt?.error || null,
|
||
};
|
||
}
|
||
|
||
function snapshotForSelected() {
|
||
const id = selectedWalletId();
|
||
if (!id) return { phase: "empty" };
|
||
const rt = ctx.runtimes.get(id);
|
||
const entry = walletEntries().find((w) => w.id === id);
|
||
const meta = entry ? chainMeta(entry.chain, entry.network) : null;
|
||
const base = {
|
||
walletId: id,
|
||
label: entry?.label,
|
||
chain: entry?.chain,
|
||
network: entry?.network,
|
||
isLegacy: !!entry?.isLegacy,
|
||
meta: meta ? {
|
||
logo: meta.logo, color: meta.color, short: meta.short, ticker: meta.ticker, decimals: meta.decimals,
|
||
coinLabel: meta.coinLabel, networkLabel: meta.networkLabel, testnet: meta.testnet,
|
||
addressFamilies: meta.addressFamilies, defaultAccountPath: meta.defaultAccountPath,
|
||
} : null,
|
||
supportsMessageSign: !!meta?.supportsMessageSign,
|
||
phase: rt?.phase || "locked",
|
||
error: rt?.error || null,
|
||
};
|
||
if (rt && rt.adapter) Object.assign(base, rt.adapter.snapshot());
|
||
return base;
|
||
}
|
||
|
||
function fullState() {
|
||
return {
|
||
overallPhase: overallPhase(),
|
||
selectedWalletId: selectedWalletId(),
|
||
wallets: walletEntries().map(walletSummary),
|
||
selected: snapshotForSelected(),
|
||
bchServers: {
|
||
list: bchServerList(ctx.api),
|
||
custom: Array.isArray(ctx.api.storage.get("servers", null)),
|
||
},
|
||
coins: coinsForPanel(),
|
||
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()); }
|
||
function fromPanel(m) { if (!m || m.from !== "panel") throw new Error("panel-only message"); }
|
||
function fromPage(m) {
|
||
if (!m || m.from !== "page" || !m.origin) throw new Error("page-only message");
|
||
return m.origin;
|
||
}
|
||
|
||
const fmtBch = (sats) => (Number(sats) / 1e8).toFixed(8).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1");
|
||
const fmtTrx = (sun) => (Number(sun) / 1e6).toFixed(6).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1");
|
||
function fmtValue(units, decimals) {
|
||
const n = Number(units) / Math.pow(10, decimals);
|
||
return n.toFixed(decimals).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1");
|
||
}
|
||
// BigInt-safe display for SPL token amounts (raw units in u64 strings).
|
||
function fmtTokenAmount(rawStr, decimals) {
|
||
const s = String(rawStr || "0");
|
||
const neg = s.startsWith("-");
|
||
const abs = neg ? s.slice(1) : s;
|
||
const d = Number(decimals) || 0;
|
||
if (d === 0) return (neg ? "-" : "") + abs;
|
||
const pad = abs.padStart(d + 1, "0");
|
||
const whole = pad.slice(0, pad.length - d);
|
||
const frac = pad.slice(pad.length - d).replace(/0+$/, "");
|
||
return (neg ? "-" : "") + whole + (frac ? "." + frac : "");
|
||
}
|
||
|
||
function registerPanelMessages(api) {
|
||
api.onMessage("state", (_p, m) => { fromPanel(m); return fullState(); });
|
||
api.onMessage("selectWallet", (p, m) => {
|
||
fromPanel(m);
|
||
const id = String(p && p.id || "");
|
||
if (!walletEntries().some((w) => w.id === id)) throw new Error("unknown wallet");
|
||
api.storage.set("selectedWalletId", id);
|
||
emitState();
|
||
return fullState();
|
||
});
|
||
api.onMessage("addWallet", async (p, m) => {
|
||
fromPanel(m);
|
||
const chain = String(p && p.chain || "");
|
||
const network = String(p && p.network || "");
|
||
const meta = chainMeta(chain, network);
|
||
if (!meta) throw new Error("unknown chain/network");
|
||
const list = walletEntries().slice();
|
||
// If this coin+network declares a `legacyFirstPurpose` (SC does, to
|
||
// recover funds from the standalone siawallet addon) and no wallet of
|
||
// this coin+network exists yet, use that purpose verbatim. The bump
|
||
// to the /aegis-namespaced/ prefix only starts on the second sub-account.
|
||
const netMeta = COINS[chain]?.networks?.[network] || {};
|
||
const existingForCoin = list.filter((w) => w.chain === chain && w.network === network && !w.isLegacy);
|
||
let purpose, isLegacy = false;
|
||
if (netMeta.legacyFirstPurpose && existingForCoin.length === 0
|
||
&& !list.some((w) => w.purpose === netMeta.legacyFirstPurpose)) {
|
||
purpose = netMeta.legacyFirstPurpose;
|
||
isLegacy = true;
|
||
} else {
|
||
const index = nextIndex(list, meta);
|
||
purpose = meta.purposePrefix + index;
|
||
}
|
||
const idIndex = nextIndex(list, meta);
|
||
const id = makeWalletId(meta, idIndex);
|
||
if (list.some((w) => w.id === id || w.purpose === purpose)) throw new Error("duplicate wallet");
|
||
const label = String(p && p.label || "").trim() || autoLabel(meta, list);
|
||
const entry = { id, label, chain, network, purpose, isLegacy, createdAt: Date.now() };
|
||
list.push(entry);
|
||
writeWallets(api, list);
|
||
api.storage.set("selectedWalletId", id);
|
||
ctx.runtimes.set(id, { entry, phase: "locked", error: null, adapter: null });
|
||
emitState();
|
||
await mountWallet(entry);
|
||
return fullState();
|
||
});
|
||
// 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);
|
||
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");
|
||
if (chain !== "bch") throw new Error("only BCH imports are supported in this build");
|
||
const network = String(p && p.network || "chipnet");
|
||
if (network !== "mainnet" && network !== "chipnet") throw new Error(`unsupported network ${network}`);
|
||
const label = String(p && p.label || "").trim();
|
||
if (!label) throw new Error("label required");
|
||
const category = String(p && p.category || "operational").trim();
|
||
const prefix = network === "mainnet" ? "bitcoincash" : "bchtest";
|
||
const source = String(p && p.source || "manual-paste");
|
||
|
||
let kind, seedHex, path, wif, cashaddrStr;
|
||
if (p && p.wif) {
|
||
kind = "wif";
|
||
wif = String(p.wif).trim();
|
||
cashaddrStr = deriveCashaddrFromWif(wif, prefix);
|
||
} else if (p && p.mnemonic) {
|
||
kind = "seed";
|
||
const words = String(p.mnemonic).trim().split(/\s+/).length;
|
||
if (words !== 12 && words !== 15 && words !== 18 && words !== 21 && words !== 24) {
|
||
throw new Error(`mnemonic must be 12/15/18/21/24 words (got ${words})`);
|
||
}
|
||
if (!ctx.d.bip39.validateMnemonic(String(p.mnemonic).trim())) {
|
||
throw new Error("invalid BIP39 mnemonic (unknown word or bad checksum)");
|
||
}
|
||
const seed = ctx.d.bip39.mnemonicToSeedSync(String(p.mnemonic).trim());
|
||
seedHex = Buffer.from(seed).toString("hex");
|
||
path = String(p.path || (network === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0"));
|
||
cashaddrStr = deriveCashaddrFromSeed(seedHex, path, prefix);
|
||
} else if (p && p.seedHex) {
|
||
kind = "seed";
|
||
seedHex = String(p.seedHex).trim().toLowerCase().replace(/^0x/, "");
|
||
if (!/^[0-9a-f]{64,128}$/.test(seedHex)) throw new Error("seedHex must be 32-64 bytes of hex");
|
||
path = String(p.path || (network === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0"));
|
||
cashaddrStr = deriveCashaddrFromSeed(seedHex, path, prefix);
|
||
} else {
|
||
throw new Error("supply mnemonic, seedHex, or wif");
|
||
}
|
||
|
||
const spec = { kind, cashaddr: cashaddrStr, label, category, source };
|
||
if (kind === "seed") { spec.seed = seedHex; spec.path = path; }
|
||
else { spec.wif = wif; }
|
||
const { id: importId } = await api.vault.imports.add(spec);
|
||
|
||
// Persist as an Aegis wallet entry with kind=imported. Uses a distinct
|
||
// id prefix so it's obvious in storage that this row references the
|
||
// imports file rather than a vault-derive purpose.
|
||
const list = walletEntries().slice();
|
||
const walletId = `bch-imported-${importId}`;
|
||
if (list.some((w) => w.id === walletId)) throw new Error("duplicate import id");
|
||
const entry = {
|
||
id: walletId, label, chain: "bch", network,
|
||
kind: "imported", importId, importedCashaddr: cashaddrStr, importedCategory: category,
|
||
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(); });
|
||
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; });
|
||
// 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; });
|
||
|
||
api.onMessage("setBchServers", (p, m) => {
|
||
fromPanel(m);
|
||
const patch = p || {};
|
||
if ("servers" in patch) {
|
||
const list = Array.isArray(patch.servers) ? patch.servers.map((s) => String(s).trim()).filter(Boolean) : [];
|
||
for (const s of list) if (!/^wss?:\/\/[^/\s]+$/i.test(s)) throw new Error(`server must be ws(s)://host:port — got ${s}`);
|
||
api.storage.set("servers", list.length ? list : null);
|
||
// Push the new server list into every mounted BCH wallet.
|
||
for (const rt of ctx.runtimes.values()) {
|
||
if (rt.entry.chain === "bch" && rt.adapter) rt.adapter.setServers(bchServerList(api));
|
||
}
|
||
}
|
||
return fullState();
|
||
});
|
||
api.onMessage("setAccountPath", (p, m) => {
|
||
fromPanel(m);
|
||
const patch = p || {};
|
||
const id = String(patch.id || selectedWalletId());
|
||
const entry = walletEntries().find((w) => w.id === id);
|
||
if (!entry) throw new Error("unknown wallet");
|
||
if (!["bch", "dgb", "btc"].includes(entry.chain)) throw new Error("account path is a BCH/BTC/DGB-only setting");
|
||
const v = String(patch.accountPath || "").trim();
|
||
if (v && !/^m(\/\d+'?)+$/.test(v)) throw new Error("derivation path must look like m/84'/0'/0'");
|
||
const list = walletEntries();
|
||
const idx = list.findIndex((w) => w.id === id);
|
||
// Per-network default: BTC/DGB come from the registry helper, BCH keeps
|
||
// its historical m/44'/145'/0'.
|
||
const dflt = entry.chain === "bch"
|
||
? "m/44'/145'/0'"
|
||
: (defaultAccountPathFor(COINS[entry.chain], entry.network) || "m/84'/0'/0'");
|
||
list[idx] = { ...list[idx], accountPath: v || dflt };
|
||
writeWallets(api, list);
|
||
const rt = ctx.runtimes.get(id);
|
||
if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} rt.adapter = null; rt.phase = "locked"; }
|
||
mountWallet(list[idx]);
|
||
return fullState();
|
||
});
|
||
// ETH/SOL: per-wallet RPC URL, live-swap without key rebuild.
|
||
api.onMessage("setRpcUrl", (p, m) => {
|
||
fromPanel(m);
|
||
const patch = p || {};
|
||
const id = String(patch.id || selectedWalletId());
|
||
const entry = walletEntries().find((w) => w.id === id);
|
||
if (!entry) throw new Error("unknown wallet");
|
||
if (entry.chain !== "eth" && entry.chain !== "sol") throw new Error("RPC URL is an ETH/SOL setting");
|
||
const v = String(patch.rpcUrl || "").trim();
|
||
if (v && !/^https?:\/\/[^\s]+$/i.test(v)) throw new Error("RPC URL must start with http:// or https://");
|
||
api.storage.set(`wallets/${id}/rpcUrl`, v);
|
||
const rt = ctx.runtimes.get(id);
|
||
if (rt && rt.adapter && typeof rt.adapter.setRpcUrl === "function") {
|
||
rt.adapter.setRpcUrl(v);
|
||
rt.adapter.refresh().catch((e) => api.log(`[${id}] refresh:`, e?.message || e));
|
||
}
|
||
emitState();
|
||
return fullState();
|
||
});
|
||
// Sia-only: per-wallet walletd URL. Live: pushes the URL into the adapter
|
||
// without rebuilding the keys (the seed stays derived from the same
|
||
// vault path — only the node the wallet talks to changes).
|
||
api.onMessage("setWalletdUrl", (p, m) => {
|
||
fromPanel(m);
|
||
const patch = p || {};
|
||
const id = String(patch.id || selectedWalletId());
|
||
const entry = walletEntries().find((w) => w.id === id);
|
||
if (!entry) throw new Error("unknown wallet");
|
||
if (entry.chain !== "sc") throw new Error("walletd URL is a Sia-only setting");
|
||
const v = String(patch.walletdUrl || "").trim();
|
||
if (v && !/^https?:\/\/[^\s]+$/i.test(v)) throw new Error("walletd URL must start with http:// or https://");
|
||
api.storage.set(`wallets/${id}/walletdUrl`, v);
|
||
const rt = ctx.runtimes.get(id);
|
||
if (rt && rt.adapter) {
|
||
rt.adapter.setWalletdUrl(v);
|
||
if (v) rt.adapter.startPolling();
|
||
rt.adapter.refresh(true).catch((e) => api.log(`[${id}] refresh:`, e?.message || e));
|
||
}
|
||
emitState();
|
||
return fullState();
|
||
});
|
||
|
||
// Live plan preview for the selected wallet.
|
||
api.onMessage("planSend", async (p, m) => {
|
||
fromPanel(m);
|
||
const rt = requireSelected();
|
||
const plan = await Promise.resolve(rt.adapter.plan(p || {}));
|
||
return describePlan(plan, rt.entry.chain, rt.entry.network);
|
||
});
|
||
// SPL token plan/send — only meaningful when the selected wallet is SOL.
|
||
api.onMessage("planTokenSend", async (p, m) => {
|
||
fromPanel(m);
|
||
const rt = requireSelected();
|
||
if (rt.entry.chain !== "sol" || typeof rt.adapter.planTokenTransfer !== "function") {
|
||
throw new Error("token send is a Solana-only flow");
|
||
}
|
||
const plan = await rt.adapter.planTokenTransfer(p || {});
|
||
return {
|
||
recipients: plan.recipients, fee: String(plan.fee), feeRate: String(plan.feeRate),
|
||
inputs: 0, change: "0", total: plan.total, mint: plan.mint, decimals: plan.decimals,
|
||
};
|
||
});
|
||
api.onMessage("sendToken", async (p, m) => {
|
||
fromPanel(m);
|
||
const rt = requireSelected();
|
||
if (rt.entry.chain !== "sol") throw new Error("token send is Solana-only");
|
||
const plan = await rt.adapter.planTokenTransfer(p || {});
|
||
const meta = chainMeta("sol", rt.entry.network);
|
||
const tokenInfo = (rt.adapter.snapshot().tokens || []).find((t) => t.mint === plan.mint) || {};
|
||
const rows = [
|
||
{ label: "To", value: plan.recipients[0].to, mono: true },
|
||
{ label: "Amount", value: `${fmtTokenAmount(plan.recipients[0].value, plan.decimals)} ${tokenInfo.symbol || "token"}`, strong: true },
|
||
{ label: "Mint", value: plan.mint, mono: true },
|
||
{ label: "Network fee", value: `~${fmtValue(Number(plan.fee), meta.decimals)} SOL${(plan._spl && plan._spl.destExists === false) ? " (includes new token account rent)" : ""}` },
|
||
{ label: "Wallet", value: `${rt.entry.label} — Solana · ${rt.entry.network}` },
|
||
];
|
||
const pick = await api.approvalModal({
|
||
title: `Send ${tokenInfo.symbol || "SPL token"}?`,
|
||
origin: "Aegis wallet panel",
|
||
rows,
|
||
actions: [{ id: "send", label: "Send", primary: true }],
|
||
});
|
||
if (pick !== "send") throw new Error("cancelled");
|
||
return rt.adapter.signAndBroadcastToken(plan);
|
||
});
|
||
// Execute a send with approval overlay.
|
||
api.onMessage("send", async (p, m) => {
|
||
fromPanel(m);
|
||
const rt = requireSelected();
|
||
const plan = await Promise.resolve(rt.adapter.plan(p || {}));
|
||
const d = describePlan(plan, rt.entry.chain, rt.entry.network);
|
||
const meta = chainMeta(rt.entry.chain, rt.entry.network);
|
||
const rows = [
|
||
{ label: "To", value: d.recipients[0].to, mono: true },
|
||
{ label: "Amount", value: `${fmtValue(d.recipients[0].value, meta.decimals)} ${meta.ticker}`, strong: true },
|
||
{ label: "Fee", value: rt.entry.chain === "bch" ? `${plan.fee} sat (${plan.feeRate} sat/B)` : `${fmtValue(plan.fee, meta.decimals)} ${meta.ticker}` },
|
||
{ label: "Total", value: `${fmtValue(d.total, meta.decimals)} ${meta.ticker}` },
|
||
{ label: "Wallet", value: `${rt.entry.label} — ${meta.coinLabel} · ${meta.networkLabel}` },
|
||
];
|
||
const pick = await api.approvalModal({
|
||
title: `Send ${meta.ticker}?`,
|
||
origin: "Aegis wallet panel",
|
||
rows,
|
||
actions: [{ id: "send", label: "Send", primary: true }],
|
||
});
|
||
if (pick !== "send") throw new Error("cancelled");
|
||
return rt.adapter.signAndBroadcast(plan);
|
||
});
|
||
|
||
api.onMessage("recovery", async (p, m) => {
|
||
fromPanel(m);
|
||
const id = String(p && p.id || selectedWalletId());
|
||
const rt = requireWallet(id);
|
||
if (typeof rt.adapter.recovery !== "function") throw new Error("this chain does not expose recovery details");
|
||
const r = rt.adapter.recovery();
|
||
const out = { accountPath: r.accountPath, xpub: r.xpub, purpose: rt.entry.purpose };
|
||
if (p && p.reveal) {
|
||
// Sia's "xprv" is really the 32-byte wallet seed (walletd KeyFromSeed);
|
||
// BCH/DGB's is the account xprv. Warning copy fits both.
|
||
const pick = await api.approvalModal({
|
||
title: rt.entry.chain === "sc" ? "Reveal the wallet seed?" : "Reveal the account private key?",
|
||
origin: "Aegis wallet panel",
|
||
body: "Anyone holding this can spend every coin in this wallet. It stays on screen until you close the Settings tab.",
|
||
actions: [{ id: "reveal", label: "Reveal", danger: true }],
|
||
});
|
||
if (pick === "reveal") out.xprv = r.xprv;
|
||
}
|
||
return out;
|
||
});
|
||
|
||
// 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();
|
||
});
|
||
|
||
// 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();
|
||
});
|
||
|
||
api.onMessage("permissions", (_p, m) => { fromPanel(m); return permissions(api); });
|
||
api.onMessage("revoke", (p, m) => {
|
||
fromPanel(m);
|
||
const perms = permissions(api);
|
||
delete perms[String(p && p.origin || "")];
|
||
api.storage.set("permissions", perms);
|
||
return perms;
|
||
});
|
||
}
|
||
|
||
// One "describePlan" is enough for both chains because plan() returns a
|
||
// common shape: {recipients:[{to,value}], fee, feeRate, total, inputs:[]…}.
|
||
function describePlan(plan) {
|
||
const sent = plan.recipients.reduce((a, r) => a + r.value, 0);
|
||
return {
|
||
recipients: plan.recipients, fee: plan.fee, feeRate: plan.feeRate,
|
||
inputs: (plan.inputs || []).length, change: plan.change || null,
|
||
total: plan.total != null ? plan.total : (sent + plan.fee),
|
||
};
|
||
}
|
||
|
||
// ---- dapp bridges (page → activate()) --------------------------------------
|
||
// Permission model stays the same as the single-wallet build for BCH:
|
||
// { [origin]: { readAddress:true, sendTx:{capSats,usedSats,grantedAt},
|
||
// trx: { readAddress:true, network } } }
|
||
// The BCH bridge always talks to the LEGACY default BCH wallet (the .x pages
|
||
// pre-date multi-wallet and cannot pick between them). The Tron bridge talks
|
||
// to the currently-SELECTED Tron wallet; if none is selected, requests fail.
|
||
const BCH_ALLOWANCES = [100000, 1000000, 10000000]; // 0.001, 0.01, 0.1 BCH
|
||
const pendingByOrigin = new Set();
|
||
function permissions(api) { const p = api.storage.get("permissions", {}); return p && typeof p === "object" ? p : {}; }
|
||
async function withOriginLock(origin, fn) {
|
||
if (pendingByOrigin.has(origin)) throw new Error("a wallet request from this site is already waiting for approval");
|
||
pendingByOrigin.add(origin);
|
||
try { return await fn(); } finally { pendingByOrigin.delete(origin); }
|
||
}
|
||
|
||
// Only .x sites (BCNR-native TLD) get the BCH bridge, matching the pre-
|
||
// multi-wallet gate. Widening the manifest to https://*/* makes the Tron
|
||
// bridge available everywhere; the BCH side enforces its narrower rule
|
||
// inside the handlers.
|
||
function isBchOrigin(origin) {
|
||
try { const h = new URL(origin).hostname; return /\.x$/.test(h); }
|
||
catch { return false; }
|
||
}
|
||
function legacyBchRuntime() {
|
||
const rt = ctx.runtimes.get(LEGACY_BCH_WALLET_ID);
|
||
if (!rt || rt.phase !== "ready" || !rt.adapter) throw new Error("wallet is not ready (vault locked?)");
|
||
return rt;
|
||
}
|
||
// The Tron bridge routes to the currently-selected wallet if it is Tron;
|
||
// otherwise it looks for the first ready Tron wallet on the selected network
|
||
// hint; else rejects with "no tron wallet".
|
||
function activeTronRuntime() {
|
||
const selId = selectedWalletId();
|
||
const selRt = selId && ctx.runtimes.get(selId);
|
||
if (selRt && selRt.entry.chain === "trx" && selRt.phase === "ready") return selRt;
|
||
for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "trx" && rt.phase === "ready") return rt;
|
||
throw new Error("no Tron wallet available — add one in the Aegis sidebar");
|
||
}
|
||
|
||
function registerPageMessages(api) {
|
||
// ---- BCH bridge (unchanged behavior; wallet source is legacy default) ----
|
||
api.onMessage("getAddress", async (_p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!isBchOrigin(origin)) throw new Error("this site is not on a Bitcoin Cash origin");
|
||
const rt = legacyBchRuntime();
|
||
const perms = permissions(api);
|
||
if (perms[origin] && perms[origin].readAddress) return rt.adapter.current().address;
|
||
return withOriginLock(origin, async () => {
|
||
const pick = await api.approvalModal({
|
||
title: "Share your Bitcoin Cash address?",
|
||
origin,
|
||
body: "The site will see your current receiving address and can look up its balance and history on the public chain.",
|
||
rows: [{ label: "Address", value: rt.adapter.current().address, mono: true }],
|
||
actions: [{ id: "allow", label: "Share", primary: true }],
|
||
checkbox: { id: "always", label: "Always allow this site to see my address" },
|
||
});
|
||
if (!pick.startsWith("allow")) throw new Error("user rejected");
|
||
if (pick === "allow+always") { perms[origin] = { ...(perms[origin] || {}), readAddress: true }; api.storage.set("permissions", perms); emitState(); }
|
||
return rt.adapter.current().address;
|
||
});
|
||
});
|
||
api.onMessage("signAndSend", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!isBchOrigin(origin)) throw new Error("this site is not on a Bitcoin Cash origin");
|
||
const rt = legacyBchRuntime();
|
||
return withOriginLock(origin, async () => {
|
||
let plan;
|
||
try { plan = rt.adapter.plan(p || {}); }
|
||
catch (e) { throw new Error(/insufficient funds|too small/i.test(e?.message) ? "insufficient funds" : e?.message || String(e)); }
|
||
const d = describePlan(plan);
|
||
if (d.recipients.length > 8) throw new Error("too many outputs");
|
||
const perms = permissions(api);
|
||
const budget = perms[origin] && perms[origin].sendTx;
|
||
const remaining = budget ? Math.max(0, (budget.capSats | 0) - (budget.usedSats | 0)) : 0;
|
||
if (budget && d.total <= remaining) {
|
||
const r = await rt.adapter.signAndBroadcast(plan);
|
||
budget.usedSats = (budget.usedSats | 0) + d.total;
|
||
api.storage.set("permissions", perms);
|
||
emitState();
|
||
api.log(`silent send ${d.total} sat for ${origin}, ${remaining - d.total} sat of allowance left`);
|
||
return { txid: r.txid };
|
||
}
|
||
const rows = d.recipients.map((r, i) => ({ label: d.recipients.length > 1 ? `To #${i + 1}` : "To", value: r.to, mono: true }));
|
||
rows.push({ label: "Amount", value: fmtBch(d.recipients.reduce((a, r) => a + r.value, 0)) + " BCH", strong: true });
|
||
rows.push({ label: "Fee", value: `${plan.fee} sat (${plan.feeRate} sat/B)` });
|
||
rows.push({ label: "Total", value: fmtBch(d.total) + " BCH" });
|
||
const pick = await api.approvalModal({
|
||
title: "Send Bitcoin Cash?",
|
||
origin,
|
||
body: budget
|
||
? `This payment is over what is left of the site's allowance (${fmtBch(remaining)} BCH). Check the address and amount.`
|
||
: "This site is asking your wallet to pay. Check the address and amount.",
|
||
rows,
|
||
actions: [{ id: "send", label: "Send", primary: true }],
|
||
select: {
|
||
id: "cap", label: "Afterwards",
|
||
options: [{ value: "", label: "ask every time" }, ...BCH_ALLOWANCES.map((s) => ({ value: String(s), label: `allow up to ${fmtBch(s)} BCH more without asking` }))],
|
||
},
|
||
});
|
||
const [action, ...flags] = pick.split("+");
|
||
if (action !== "send") throw new Error("user rejected");
|
||
const cap = flags.find((f) => f.startsWith("cap="));
|
||
const capSats = cap ? Number(cap.slice(4)) : 0;
|
||
if (BCH_ALLOWANCES.includes(capSats)) {
|
||
perms[origin] = { ...(perms[origin] || {}), sendTx: { capSats, usedSats: 0, grantedAt: Date.now() } };
|
||
api.storage.set("permissions", perms);
|
||
} else if (budget) {
|
||
delete perms[origin].sendTx;
|
||
api.storage.set("permissions", perms);
|
||
}
|
||
emitState();
|
||
const r = await rt.adapter.signAndBroadcast(plan);
|
||
return { txid: r.txid };
|
||
});
|
||
});
|
||
api.onMessage("signMessage", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!isBchOrigin(origin)) throw new Error("this site is not on a Bitcoin Cash origin");
|
||
const rt = legacyBchRuntime();
|
||
const message = String(p && p.message != null ? p.message : "");
|
||
if (message.length > 4096) throw new Error("message too long");
|
||
return withOriginLock(origin, async () => {
|
||
const pick = await api.approvalModal({
|
||
title: "Sign a message?",
|
||
origin,
|
||
body: "Signing proves you control the address below. It moves no coins.",
|
||
rows: [
|
||
{ label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true },
|
||
{ label: "Address", value: rt.adapter.current().address, mono: true },
|
||
],
|
||
actions: [{ id: "sign", label: "Sign", primary: true }],
|
||
});
|
||
if (pick !== "sign") throw new Error("user rejected");
|
||
return rt.adapter.signMessage(message);
|
||
});
|
||
});
|
||
|
||
// ---- Tron bridge (tronWeb / tronLink) -----------------------------------
|
||
api.onMessage("trx.requestAccounts", async (_p, m) => {
|
||
const origin = fromPage(m);
|
||
const rt = activeTronRuntime();
|
||
const perms = permissions(api);
|
||
const alreadyOK = perms[origin] && perms[origin].trx && perms[origin].trx.readAddress;
|
||
const snap = rt.adapter.snapshot();
|
||
if (alreadyOK) return { code: 200, address: snap.address, network: snap.network };
|
||
return withOriginLock(origin, async () => {
|
||
const pick = await api.approvalModal({
|
||
title: "Connect this site to your Tron wallet?",
|
||
origin,
|
||
body: "The site will see this address and can build transactions for you to sign.",
|
||
rows: [
|
||
{ label: "Address", value: snap.address, mono: true },
|
||
{ label: "Network", value: snap.network === "nile" ? "Nile testnet" : "Tron mainnet" },
|
||
{ label: "Wallet", value: `${rt.entry.label} — Tron · ${snap.network === "nile" ? "Nile testnet" : "Mainnet"}` },
|
||
],
|
||
actions: [{ id: "allow", label: "Connect", primary: true }],
|
||
checkbox: { id: "always", label: "Always allow this site to see this address" },
|
||
});
|
||
if (!pick.startsWith("allow")) throw new Error("user rejected");
|
||
if (pick === "allow+always") {
|
||
perms[origin] = { ...(perms[origin] || {}), trx: { readAddress: true, network: snap.network } };
|
||
api.storage.set("permissions", perms);
|
||
emitState();
|
||
}
|
||
return { code: 200, address: snap.address, network: snap.network };
|
||
});
|
||
});
|
||
api.onMessage("trx.getAccount", (_p, m) => {
|
||
const origin = fromPage(m);
|
||
const perms = permissions(api);
|
||
if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first");
|
||
const rt = activeTronRuntime();
|
||
const snap = rt.adapter.snapshot();
|
||
return { address: snap.address, network: snap.network };
|
||
});
|
||
// Sign an arbitrary raw_data_hex the dapp built (with its own tronWeb).
|
||
// The wallet never guesses the intent — the approval overlay shows the
|
||
// decoded contract type and destination when it can, and always the txID.
|
||
api.onMessage("trx.signTransaction", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
const rt = activeTronRuntime();
|
||
const perms = permissions(api);
|
||
if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first");
|
||
const tx = p && p.transaction;
|
||
if (!tx || typeof tx !== "object" || !tx.raw_data_hex || !tx.raw_data) throw new Error("bad transaction");
|
||
return withOriginLock(origin, async () => {
|
||
const contract = (tx.raw_data.contract || [])[0];
|
||
const type = contract?.type || "Contract";
|
||
const rows = [{ label: "Type", value: type }, { label: "Tx ID", value: tx.txID || "(unset)", mono: true }];
|
||
if (type === "TransferContract") {
|
||
const v = contract.parameter?.value || {};
|
||
try {
|
||
const to = v.to_address ? ctx.d.tronAdapter.hexToAddress(v.to_address) : (v.to_address || "");
|
||
const amount = Number(v.amount || 0);
|
||
rows.splice(1, 0, { label: "To", value: to, mono: true }, { label: "Amount", value: `${fmtTrx(amount)} TRX`, strong: true });
|
||
} catch {}
|
||
}
|
||
rows.push({ label: "Wallet", value: `${rt.entry.label} — Tron · ${rt.adapter.snapshot().network === "nile" ? "Nile testnet" : "Mainnet"}` });
|
||
const pick = await api.approvalModal({
|
||
title: "Sign a Tron transaction?",
|
||
origin,
|
||
body: "The site built this transaction. Check the type, amount, and destination before signing.",
|
||
rows,
|
||
actions: [{ id: "sign", label: "Sign", primary: true }],
|
||
});
|
||
if (pick !== "sign") throw new Error("user rejected");
|
||
const sig = rt.adapter.signRawData(tx.raw_data_hex);
|
||
const signed = { ...tx, signature: [sig] };
|
||
return signed;
|
||
});
|
||
});
|
||
api.onMessage("trx.sendRawTransaction", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
const rt = activeTronRuntime();
|
||
const perms = permissions(api);
|
||
if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first");
|
||
const signedTx = p && p.transaction;
|
||
if (!signedTx || !signedTx.raw_data_hex || !Array.isArray(signedTx.signature)) throw new Error("bad signed tx");
|
||
// No approval here — broadcasting a *signed* tx does not add any risk
|
||
// the sign step didn't already carry. Sites that don't want an extra
|
||
// network round-trip pass {broadcast:true} to sign; we support both.
|
||
return rt.adapter.broadcastSignedTx(signedTx);
|
||
});
|
||
api.onMessage("trx.signMessageV2", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
const rt = activeTronRuntime();
|
||
const perms = permissions(api);
|
||
if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first");
|
||
const message = String(p && p.message != null ? p.message : "");
|
||
if (message.length > 4096) throw new Error("message too long");
|
||
return withOriginLock(origin, async () => {
|
||
const snap = rt.adapter.snapshot();
|
||
const pick = await api.approvalModal({
|
||
title: "Sign a Tron message?",
|
||
origin,
|
||
body: "Signing proves you control this address. It moves no coins.",
|
||
rows: [
|
||
{ label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true },
|
||
{ label: "Address", value: snap.address, mono: true },
|
||
],
|
||
actions: [{ id: "sign", label: "Sign", primary: true }],
|
||
});
|
||
if (pick !== "sign") throw new Error("user rejected");
|
||
return rt.adapter.signMessageV2(message);
|
||
});
|
||
});
|
||
|
||
// ---- Ethereum (EIP-1193) ---------------------------------------------
|
||
// Routes the same way as Tron: pick the currently-selected ETH wallet if
|
||
// any; else the first ready ETH wallet. Chain switches happen at the
|
||
// wallet-picker level, not here — dapps that call wallet_switchEthereumChain
|
||
// get a friendly "switch wallet in the Aegis sidebar" error.
|
||
function activeEthRuntime() {
|
||
const selId = selectedWalletId();
|
||
const selRt = selId && ctx.runtimes.get(selId);
|
||
if (selRt && selRt.entry.chain === "eth" && selRt.phase === "ready") return selRt;
|
||
for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "eth" && rt.phase === "ready") return rt;
|
||
throw new Error("no Ethereum wallet available — add one in the Aegis sidebar");
|
||
}
|
||
function ethConnectedFor(origin) {
|
||
const p = permissions(api)[origin];
|
||
return !!(p && p.eth && p.eth.readAddress);
|
||
}
|
||
api.onMessage("eth.requestAccounts", async (_p, m) => {
|
||
const origin = fromPage(m);
|
||
const rt = activeEthRuntime();
|
||
const snap = rt.adapter.snapshot();
|
||
const chainIdHex = "0x" + Number(snap.chainId).toString(16);
|
||
const networkVersion = String(snap.chainId);
|
||
const perms = permissions(api);
|
||
if (perms[origin] && perms[origin].eth && perms[origin].eth.readAddress) {
|
||
return { address: snap.address, chainIdHex, networkVersion };
|
||
}
|
||
return withOriginLock(origin, async () => {
|
||
const pick = await api.approvalModal({
|
||
title: "Connect this site to your Ethereum wallet?",
|
||
origin,
|
||
body: "The site will see this address and can build transactions for you to sign.",
|
||
rows: [
|
||
{ label: "Address", value: snap.address, mono: true },
|
||
{ label: "Network", value: snap.network === "mainnet" ? "Ethereum mainnet" : "Sepolia testnet" },
|
||
{ label: "Wallet", value: `${rt.entry.label} — Ethereum · ${snap.network}` },
|
||
],
|
||
actions: [{ id: "allow", label: "Connect", primary: true }],
|
||
checkbox: { id: "always", label: "Always allow this site to see this address" },
|
||
});
|
||
if (!pick.startsWith("allow")) throw new Error("user rejected");
|
||
if (pick === "allow+always") {
|
||
perms[origin] = { ...(perms[origin] || {}), eth: { readAddress: true, chainId: snap.chainId } };
|
||
api.storage.set("permissions", perms);
|
||
emitState();
|
||
}
|
||
return { address: snap.address, chainIdHex, networkVersion };
|
||
});
|
||
});
|
||
api.onMessage("eth.personalSign", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first");
|
||
const rt = activeEthRuntime();
|
||
const message = String(p && p.message != null ? p.message : "");
|
||
if (message.length > 4096) throw new Error("message too long");
|
||
return withOriginLock(origin, async () => {
|
||
const pick = await api.approvalModal({
|
||
title: "Sign an Ethereum message?",
|
||
origin,
|
||
body: "Signing proves you control this address. It moves no ETH.",
|
||
rows: [
|
||
{ label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true },
|
||
{ label: "Address", value: rt.adapter.snapshot().address, mono: true },
|
||
],
|
||
actions: [{ id: "sign", label: "Sign", primary: true }],
|
||
});
|
||
if (pick !== "sign") throw new Error("user rejected");
|
||
return rt.adapter.signMessage(message);
|
||
});
|
||
});
|
||
api.onMessage("eth.sendTransaction", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first");
|
||
const rt = activeEthRuntime();
|
||
const tx = (p && p.tx) || {};
|
||
if (!tx.to) throw new Error("tx.to required");
|
||
// MetaMask semantics: `value` and `gas`/`gasLimit` are hex-encoded wei;
|
||
// convert to numbers/bigints Aegis's own plan() understands.
|
||
const valueWei = tx.value ? BigInt(tx.value).toString() : "0";
|
||
return withOriginLock(origin, async () => {
|
||
const snap = rt.adapter.snapshot();
|
||
const plan = await rt.adapter.plan({ to: tx.to, amount: valueWei, sendMax: false });
|
||
const meta = chainMeta("eth", rt.entry.network);
|
||
const pick = await api.approvalModal({
|
||
title: "Send Ethereum transaction?",
|
||
origin,
|
||
body: tx.data && tx.data !== "0x" ? "This transaction carries call data (a contract call). Check the destination + value carefully." : "This site is asking your wallet to send ETH.",
|
||
rows: [
|
||
{ label: "To", value: plan.recipients[0].to, mono: true },
|
||
{ label: "Amount", value: `${fmtValue(plan.recipients[0].value, meta.decimals)} ETH`, strong: true },
|
||
{ label: "Fee (est.)", value: `${fmtValue(plan.fee, meta.decimals)} ETH` },
|
||
{ label: "Wallet", value: `${rt.entry.label} — Ethereum · ${snap.network}` },
|
||
],
|
||
actions: [{ id: "send", label: "Send", primary: true }],
|
||
});
|
||
if (pick !== "send") throw new Error("user rejected");
|
||
const r = await rt.adapter.signAndBroadcast(plan);
|
||
return { txid: r.txid };
|
||
});
|
||
});
|
||
api.onMessage("eth.signTypedData", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first");
|
||
const rt = activeEthRuntime();
|
||
// Dapps send typedData as either a JSON string (older MetaMask spec) or
|
||
// an object (v4). Accept both; the encoder wants an object.
|
||
let td = p && p.typedData;
|
||
if (typeof td === "string") { try { td = JSON.parse(td); } catch (e) { throw new Error("typedData: JSON parse failed: " + e.message); } }
|
||
if (!td || typeof td !== "object") throw new Error("typedData required");
|
||
// Compute the digest first — if the encoder rejects the input the user
|
||
// never sees an approval overlay for a broken payload.
|
||
let digest;
|
||
try { digest = ctx.d.eip712.digest(td); }
|
||
catch (e) { throw new Error("EIP-712 encode failed: " + e.message); }
|
||
// Approval overlay: show domain (name + chain), primary type, and a
|
||
// truncated JSON preview of the message so the user has a fighting
|
||
// chance to spot phishing.
|
||
const dom = td.domain || {};
|
||
const domainSummary = [dom.name, dom.version && `v${dom.version}`, dom.chainId && `chain ${dom.chainId}`].filter(Boolean).join(" · ") || "(no domain)";
|
||
const messagePreview = JSON.stringify(td.message, null, 2);
|
||
const preview = messagePreview.length > 600 ? messagePreview.slice(0, 600) + "…" : messagePreview;
|
||
return withOriginLock(origin, async () => {
|
||
const pick = await api.approvalModal({
|
||
title: "Sign typed data (EIP-712)?",
|
||
origin,
|
||
body: "The site is asking you to sign a structured message. Verify the domain matches the site you're on — a mismatched domain is the classic phishing tell.",
|
||
rows: [
|
||
{ label: "Domain", value: domainSummary },
|
||
{ label: "Primary type", value: String(td.primaryType || "") },
|
||
{ label: "Message", value: preview, mono: true },
|
||
{ label: "Address", value: rt.adapter.snapshot().address, mono: true },
|
||
],
|
||
actions: [{ id: "sign", label: "Sign", primary: true }],
|
||
});
|
||
if (pick !== "sign") throw new Error("user rejected");
|
||
return rt.adapter.signTypedDataDigest(digest);
|
||
});
|
||
});
|
||
api.onMessage("eth.switchChain", async (p, m) => {
|
||
fromPage(m);
|
||
const wantHex = String(p && p.chainId || "").toLowerCase();
|
||
const wantId = Number(wantHex);
|
||
if (!Number.isFinite(wantId) || wantId <= 0) throw new Error("bad chainId");
|
||
// Find any wallet already on that chain and select it.
|
||
for (const rt of ctx.runtimes.values()) {
|
||
if (rt.entry.chain !== "eth" || rt.phase !== "ready") continue;
|
||
const snap = rt.adapter.snapshot();
|
||
if (Number(snap.chainId) === wantId) {
|
||
api.storage.set("selectedWalletId", rt.entry.id);
|
||
emitState();
|
||
return null;
|
||
}
|
||
}
|
||
// EIP-3326: throw the well-known "chain not added" code so dapps fall
|
||
// back to wallet_addEthereumChain.
|
||
const err = new Error(`Aegis: chainId ${wantHex} is not added. Ask via wallet_addEthereumChain.`);
|
||
err.code = 4902;
|
||
throw err;
|
||
});
|
||
// EIP-3085: dapp asks Aegis to add a new EVM chain. On approval, we
|
||
// persist the chain config and create a wallet on it under the same
|
||
// vault-derived key. Existing addresses on that chain remain visible on
|
||
// whatever wallet they were funded on — a chain add doesn't move any
|
||
// key material, just registers the network.
|
||
api.onMessage("eth.addChain", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
const spec = (p && p.params) || {};
|
||
const chainIdHex = String(spec.chainId || "").toLowerCase();
|
||
const chainId = Number(chainIdHex);
|
||
if (!chainIdHex.startsWith("0x") || !Number.isFinite(chainId) || chainId <= 0) {
|
||
throw new Error("wallet_addEthereumChain: chainId must be a positive hex integer (e.g. '0x89')");
|
||
}
|
||
const chainName = String(spec.chainName || "").trim() || `EVM #${chainId}`;
|
||
const rpcUrls = Array.isArray(spec.rpcUrls) ? spec.rpcUrls.filter((u) => /^https?:\/\//i.test(u)) : [];
|
||
const rpcUrl = rpcUrls[0];
|
||
if (!rpcUrl) throw new Error("wallet_addEthereumChain: at least one https rpcUrls entry is required");
|
||
const explorerBase = Array.isArray(spec.blockExplorerUrls) && spec.blockExplorerUrls[0]
|
||
? String(spec.blockExplorerUrls[0]).replace(/\/+$/, "")
|
||
: null;
|
||
const nc = spec.nativeCurrency || {};
|
||
const ticker = String(nc.symbol || "ETH").slice(0, 6).toUpperCase();
|
||
// Reject if a wallet on this chain already exists — no-op success per
|
||
// EIP-3085 conventions.
|
||
const existing = walletEntries().find((w) => w.chain === "eth"
|
||
&& (w.network === CUSTOM_ETH_PREFIX + chainId
|
||
|| (chainMeta("eth", w.network)?.chainId === chainId)));
|
||
if (existing) {
|
||
// Auto-connect the origin to this wallet — dapps expect the returned
|
||
// provider to be pointed at the added chain immediately.
|
||
const perms = permissions(api);
|
||
perms[origin] = { ...(perms[origin] || {}), eth: { readAddress: true, chainId } };
|
||
api.storage.set("permissions", perms);
|
||
api.storage.set("selectedWalletId", existing.id);
|
||
emitState();
|
||
return null;
|
||
}
|
||
return withOriginLock(origin, async () => {
|
||
const pick = await api.approvalModal({
|
||
title: "Add an Ethereum chain?",
|
||
origin,
|
||
body: "The site is asking to add a new EVM network to Aegis. Verify the RPC and chain ID — a malicious 'chain add' can point you at a fraudulent RPC that intercepts your reads or signs.",
|
||
rows: [
|
||
{ label: "Chain name", value: chainName },
|
||
{ label: "Chain ID", value: `${chainId} (${chainIdHex})` },
|
||
{ label: "Native ticker", value: ticker },
|
||
{ label: "RPC", value: rpcUrl, mono: true },
|
||
{ label: "Explorer", value: explorerBase || "(none)", mono: true },
|
||
],
|
||
actions: [{ id: "add", label: "Add chain", primary: true }],
|
||
});
|
||
if (pick !== "add") throw new Error("user rejected");
|
||
// Persist chain config + create a wallet on it.
|
||
const chains = customEthChains(api);
|
||
chains[String(chainId)] = {
|
||
chainId, chainName, rpcUrl,
|
||
explorerTx: explorerBase ? explorerBase + "/tx/" : "",
|
||
explorerAddr: explorerBase ? explorerBase + "/address/" : "",
|
||
ticker, addedAt: Date.now(), addedByOrigin: origin,
|
||
};
|
||
api.storage.set("customEthChains", chains);
|
||
const network = CUSTOM_ETH_PREFIX + chainId;
|
||
const meta = chainMeta("eth", network);
|
||
const list = walletEntries().slice();
|
||
const index = nextIndex(list, meta);
|
||
const purpose = meta.purposePrefix + index;
|
||
const id = makeWalletId(meta, index);
|
||
const label = `${chainName} — ${meta.short}`;
|
||
const entry = { id, label, chain: "eth", network, purpose, createdAt: Date.now() };
|
||
list.push(entry);
|
||
writeWallets(api, list);
|
||
api.storage.set("selectedWalletId", id);
|
||
// Grant the origin read access on this chain by default (they just
|
||
// approved adding it — implicit consent to also see the address).
|
||
const perms = permissions(api);
|
||
perms[origin] = { ...(perms[origin] || {}), eth: { readAddress: true, chainId } };
|
||
api.storage.set("permissions", perms);
|
||
ctx.runtimes.set(id, { entry, phase: "locked", error: null, adapter: null });
|
||
emitState();
|
||
await mountWallet(entry);
|
||
return null;
|
||
});
|
||
});
|
||
// Cheap state peek — used by the main-world bridge right after a switch
|
||
// or add to emit accountsChanged / chainChanged without needing another
|
||
// approval overlay. Only returns the wallet the origin already sees.
|
||
api.onMessage("eth.state", (_p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!ethConnectedFor(origin)) return { address: null, chainIdHex: "0x0", networkVersion: "0" };
|
||
const rt = activeEthRuntime();
|
||
const snap = rt.adapter.snapshot();
|
||
return {
|
||
address: snap.address,
|
||
chainIdHex: "0x" + Number(snap.chainId).toString(16),
|
||
networkVersion: String(snap.chainId),
|
||
};
|
||
});
|
||
// Read passthrough: forward eth_getBalance / eth_call / etc. to the
|
||
// wallet's own configured RPC. Nothing here reveals the private key.
|
||
api.onMessage("eth.rpc", async (p, m) => {
|
||
fromPage(m);
|
||
const rt = activeEthRuntime();
|
||
const method = String(p && p.method || "");
|
||
const params = (p && p.params) || [];
|
||
if (!/^eth_|^net_|^web3_/.test(method)) throw new Error("Aegis: only eth_/net_/web3_ read methods are passed through");
|
||
return rt.adapter._client.call(method, params);
|
||
});
|
||
|
||
// ---- Solana (wallet-adapter) ------------------------------------------
|
||
function activeSolRuntime() {
|
||
const selId = selectedWalletId();
|
||
const selRt = selId && ctx.runtimes.get(selId);
|
||
if (selRt && selRt.entry.chain === "sol" && selRt.phase === "ready") return selRt;
|
||
for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "sol" && rt.phase === "ready") return rt;
|
||
throw new Error("no Solana wallet available — add one in the Aegis sidebar");
|
||
}
|
||
function solConnectedFor(origin) {
|
||
const p = permissions(api)[origin];
|
||
return !!(p && p.sol && p.sol.readAddress);
|
||
}
|
||
api.onMessage("sol.connect", async (_p, m) => {
|
||
const origin = fromPage(m);
|
||
const rt = activeSolRuntime();
|
||
const snap = rt.adapter.snapshot();
|
||
if (solConnectedFor(origin)) return { address: snap.address, network: snap.network };
|
||
return withOriginLock(origin, async () => {
|
||
const pick = await api.approvalModal({
|
||
title: "Connect this site to your Solana wallet?",
|
||
origin,
|
||
body: "The site will see this address and can build transactions for you to sign.",
|
||
rows: [
|
||
{ label: "Address", value: snap.address, mono: true },
|
||
{ label: "Network", value: snap.network === "mainnet" ? "Mainnet-beta" : "Devnet" },
|
||
{ label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` },
|
||
],
|
||
actions: [{ id: "allow", label: "Connect", primary: true }],
|
||
checkbox: { id: "always", label: "Always allow this site to see this address" },
|
||
});
|
||
if (!pick.startsWith("allow")) throw new Error("user rejected");
|
||
if (pick === "allow+always") {
|
||
const perms = permissions(api);
|
||
perms[origin] = { ...(perms[origin] || {}), sol: { readAddress: true, network: snap.network } };
|
||
api.storage.set("permissions", perms);
|
||
emitState();
|
||
}
|
||
return { address: snap.address, network: snap.network };
|
||
});
|
||
});
|
||
api.onMessage("sol.signMessage", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first");
|
||
const rt = activeSolRuntime();
|
||
const b64 = String(p && p.messageB64 || "");
|
||
const bytes = Buffer.from(b64, "base64");
|
||
if (bytes.length > 4096) throw new Error("message too long");
|
||
return withOriginLock(origin, async () => {
|
||
const preview = bytes.every((c) => c >= 0x20 && c < 0x7f) ? bytes.toString("utf8") : `<${bytes.length} bytes: 0x${bytes.toString("hex").slice(0, 60)}…>`;
|
||
const pick = await api.approvalModal({
|
||
title: "Sign a Solana message?",
|
||
origin,
|
||
body: "Signing proves you control this address. It moves no SOL.",
|
||
rows: [
|
||
{ label: "Message", value: preview.length > 400 ? preview.slice(0, 400) + "…" : preview, mono: true },
|
||
{ label: "Address", value: rt.adapter.snapshot().address, mono: true },
|
||
],
|
||
actions: [{ id: "sign", label: "Sign", primary: true }],
|
||
});
|
||
if (pick !== "sign") throw new Error("user rejected");
|
||
return rt.adapter.signMessage(bytes);
|
||
});
|
||
});
|
||
// Dapp-built transaction. The main-world bridge passes the FULL wire
|
||
// (tx.serialize({requireAllSignatures:false, verifySignatures:false}))
|
||
// — signature slots the dapp already filled with partialSign() are
|
||
// preserved; our wallet only overwrites its own slot. That's the only
|
||
// way to sign multi-signer transactions the dapp has partially
|
||
// co-signed (co-signer sigs, ephemeral session keys, etc.).
|
||
api.onMessage("sol.signAndSend", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first");
|
||
const rt = activeSolRuntime();
|
||
const wireB64 = String(p && p.wireB64 || "");
|
||
if (!wireB64) throw new Error("wireB64 required (full serialized transaction)");
|
||
const wire = new Uint8Array(Buffer.from(wireB64, "base64"));
|
||
// Parse: compact-u16(sigCount) || sig[0..64]*sigCount || message
|
||
let off = 0;
|
||
const readCompactU16 = () => {
|
||
let n = 0, shift = 0;
|
||
while (true) {
|
||
const b = wire[off++];
|
||
n |= (b & 0x7f) << shift;
|
||
if ((b & 0x80) === 0) break;
|
||
shift += 7;
|
||
if (shift > 21) throw new Error("compact-u16 too long");
|
||
}
|
||
return n;
|
||
};
|
||
const sigCount = readCompactU16();
|
||
if (sigCount < 1 || sigCount > 32) throw new Error("bad signature count " + sigCount);
|
||
const sigsStart = off;
|
||
const messageStart = sigsStart + sigCount * 64;
|
||
if (wire.length < messageStart) throw new Error("truncated tx wire");
|
||
const messageBytes = wire.slice(messageStart);
|
||
// Parse the message enough to find our pubkey's index in the account
|
||
// list. Layout: header(3) || compactU16(keyCount) || key[32]*keyCount || …
|
||
if (messageBytes.length < 3 + 1 + 32) throw new Error("message too short");
|
||
const numRequiredSigs = messageBytes[0];
|
||
let moff = 3;
|
||
const readKeyCount = () => {
|
||
let n = 0, shift = 0;
|
||
while (true) {
|
||
const b = messageBytes[moff++];
|
||
n |= (b & 0x7f) << shift;
|
||
if ((b & 0x80) === 0) break;
|
||
shift += 7;
|
||
}
|
||
return n;
|
||
};
|
||
const keyCount = readKeyCount();
|
||
if (keyCount < 1 || keyCount > 64) throw new Error("bad account key count");
|
||
// Find our public key among the key list.
|
||
const ourPub = rt.adapter._pub;
|
||
let ourIndex = -1;
|
||
for (let i = 0; i < keyCount; i++) {
|
||
const key = messageBytes.subarray(moff + i * 32, moff + (i + 1) * 32);
|
||
let eq = true;
|
||
for (let j = 0; j < 32; j++) if (key[j] !== ourPub[j]) { eq = false; break; }
|
||
if (eq) { ourIndex = i; break; }
|
||
}
|
||
if (ourIndex < 0) throw new Error("this wallet's key is not among the transaction's account keys");
|
||
if (ourIndex >= numRequiredSigs) throw new Error(`this wallet's key is not a required signer (index ${ourIndex}, requiredSigs ${numRequiredSigs})`);
|
||
|
||
return withOriginLock(origin, async () => {
|
||
const snap = rt.adapter.snapshot();
|
||
const otherSigners = numRequiredSigs > 1 ? numRequiredSigs - 1 : 0;
|
||
const pick = await api.approvalModal({
|
||
title: "Sign + send a Solana transaction?",
|
||
origin,
|
||
body: "The site built this transaction. Aegis can't decode arbitrary Solana instructions in this rev — verify the site before signing.",
|
||
rows: [
|
||
{ label: "Message size", value: `${messageBytes.length} bytes` },
|
||
{ label: "Required signers", value: otherSigners
|
||
? `${numRequiredSigs} — you (slot #${ourIndex}) + ${otherSigners} other${otherSigners === 1 ? "" : "s"}`
|
||
: "1 — you" },
|
||
{ label: "Address", value: snap.address, mono: true },
|
||
{ label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` },
|
||
],
|
||
actions: [{ id: "send", label: "Sign & send", primary: true }],
|
||
});
|
||
if (pick !== "send") throw new Error("user rejected");
|
||
// Sign the message and patch our slot. Any partial signatures already
|
||
// in the wire (from tx.partialSign()) at other slots are preserved.
|
||
const sigInfo = rt.adapter.signMessage(messageBytes);
|
||
const sigBytes = ctx.d.base58check.decodeBase58(sigInfo.signature);
|
||
if (sigBytes.length !== 64) throw new Error("bad ed25519 signature length");
|
||
const wireOut = new Uint8Array(wire); // copy so we don't mutate caller
|
||
wireOut.set(sigBytes, sigsStart + ourIndex * 64);
|
||
const wireB58 = ctx.d.base58check.encodeBase58(wireOut);
|
||
const txid = await rt.adapter._client.call("sendTransaction", [wireB58]);
|
||
if (typeof txid !== "string" || !txid.length) throw new Error("bad txid from RPC: " + JSON.stringify(txid));
|
||
setTimeout(() => rt.adapter.refresh().catch(() => {}), 4000);
|
||
return { txid };
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---- activate ---------------------------------------------------------------
|
||
|
||
module.exports = {
|
||
activate(api) {
|
||
// 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.
|
||
api.registerSidebarPanel({ id: "main", title: "Wallet", page: "panel.html" });
|
||
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(),
|
||
}),
|
||
wc: null, // WizardConnect manager, initialised when deps load
|
||
};
|
||
migrateLegacyStorage(api);
|
||
registerPanelMessages(api);
|
||
registerPageMessages(api);
|
||
// Restore the user's opt-in choice from storage. Off by default so a
|
||
// fresh install never hits CoinGecko without asking.
|
||
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();
|
||
uiReady.then(() => loadDeps(api)).then((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) => {
|
||
const dappName = payload.request?.transaction?.userPrompt || "dapp";
|
||
const pick = await api.approvalModal({
|
||
title: `Sign transaction for ${dappName}`,
|
||
body: buildWcApprovalBody(payload),
|
||
approve: "Sign",
|
||
reject: "Reject",
|
||
});
|
||
return { approved: pick === "approve" };
|
||
},
|
||
});
|
||
c.wc.onStateChange(() => emitState());
|
||
return mountAllWallets();
|
||
}).catch((e) => {
|
||
if (ctx !== c) return;
|
||
api.log("startup failed:", e?.message);
|
||
emitState();
|
||
});
|
||
},
|
||
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();
|
||
},
|
||
};
|