Ported from Digibyte.X/dgb-wallet @ 989a2696.
BIP32 builds the master key as HMAC-SHA512("Bitcoin seed", seed). DigiByte's
official 2018-19 Android/iOS wallets were BreadWallet forks and used
"DigiByte seed" as the HMAC key instead, so the same twelve words produce a
completely different key tree — every address differs, and a standard scan
finds nothing at all. Anyone importing one of those seeds into Aegis got a
valid, empty address and a zero balance with no way to tell why. Same shape
as the Bitcoin.com coin-type-0 problem.
- rootFromSeed() in the vendored lib/dgb/core/hd.js takes an optional HMAC
key, matching upstream, and exports HMAC_BITCOIN_SEED /
HMAC_DIGIBYTE_SEED.
- lib/import-derive.js grows the same option, since that is what actually
derives the address on an import, and importWallet records the variant on
the spec so the entry says which tree its stored address came from.
- The path scanner added in 0.24.0 now covers DigiByte: all four purposes
under the standard key, plus m/0' and BIP44 under the legacy one. So the
answer to "which tree holds my coins" is a scan rather than a guess, and
the Use button carries the key along with the path.
Verified against an independent HMAC-SHA512 computation, and end to end: the
same mnemonic gives DG1Khh…N1i under the standard key and DMWQ1g…PHse under
the DigiByte key, both valid, with m/0' deriving DGAf4M…Wyn.
Also fixes a coupling this exposed: lib/import-derive.js derives taproot
addresses but never called initEccLib, relying on chain-btc.js (and formerly
lib/dgb/core/address.js, before it went lazy here) doing it at load time. It
now installs the schnorr backend itself, once, so its taproot output no
longer depends on an unrelated module's import order.
The gap-limit change in 0a5d7ade (scan gap 200/100 for DigiScope parity) is
upstream-only for now — Aegis's imported DGB adapter watches a single stored
address rather than scanning a gap.
3399 lines
159 KiB
JavaScript
3399 lines
159 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";
|
||
|
||
// 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 });
|
||
// 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 <userData>/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,
|
||
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 ------------------------------------------------------------
|
||
|
||
// 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) {
|
||
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);
|
||
},
|
||
all: () => ({ ...load() }),
|
||
};
|
||
try {
|
||
api.storage = cached;
|
||
return api.storage === cached;
|
||
} catch { return false; }
|
||
}
|
||
|
||
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)));
|
||
}
|
||
|
||
// 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/<branch>/<i>: 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/<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,
|
||
// 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);
|
||
}
|
||
|
||
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>";
|
||
}
|
||
|
||
// ---- 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()); }
|
||
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);
|
||
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:<hex>`) —
|
||
// 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: <hex>" (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);
|
||
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);
|
||
});
|
||
|
||
// ---- 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);
|
||
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 destAddr = destRt.adapter.snapshot().address;
|
||
if (!destAddr) throw new Error("destination wallet has no receive address");
|
||
// sourceIds are the wallets the user CHECKED in the preview. Defaults
|
||
// to every eligible sibling if the panel omits the field (safety net,
|
||
// shouldn't happen in normal flow).
|
||
const requested = Array.isArray(p?.sourceIds) && p.sourceIds.length
|
||
? new Set(p.sourceIds.map(String))
|
||
: null;
|
||
const sources = walletEntries().filter((w) =>
|
||
w.chain === destEntry.chain &&
|
||
w.network === destEntry.network &&
|
||
w.id !== destId &&
|
||
(!requested || requested.has(w.id)),
|
||
);
|
||
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 }));
|
||
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);
|
||
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");
|
||
}
|
||
|
||
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,
|
||
};
|
||
});
|
||
|
||
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;
|
||
});
|
||
|
||
// ---- 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.
|
||
api.onMessage("pinBlobGet", (_p, m) => {
|
||
fromPanel(m);
|
||
const b = api.storage.get("aegis/pin/v1", null);
|
||
return (b && typeof b === "object") ? b : null;
|
||
});
|
||
api.onMessage("pinBlobSet", (p, m) => {
|
||
fromPanel(m);
|
||
const blob = p && p.blob;
|
||
if (!blob || typeof blob !== "object") throw new Error("blob required");
|
||
if (typeof blob.salt !== "string" || typeof blob.iv !== "string" || typeof blob.ct !== "string" || typeof blob.iters !== "number") {
|
||
throw new Error("blob shape invalid");
|
||
}
|
||
api.storage.set("aegis/pin/v1", { salt: blob.salt, iv: blob.iv, ct: blob.ct, iters: blob.iters });
|
||
return true;
|
||
});
|
||
api.onMessage("pinBlobClear", (_p, m) => {
|
||
fromPanel(m);
|
||
api.storage.set("aegis/pin/v1", null);
|
||
api.storage.set("aegis/pin/failCount", 0);
|
||
// 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;
|
||
});
|
||
// Track failed PIN attempts in the addon so a panel reload cannot bypass
|
||
// rate-limiting by dropping panel-side counters.
|
||
api.onMessage("pinFailInc", (_p, m) => {
|
||
fromPanel(m);
|
||
const cur = Number(api.storage.get("aegis/pin/failCount", 0)) || 0;
|
||
const next = cur + 1;
|
||
api.storage.set("aegis/pin/failCount", next);
|
||
api.storage.set("aegis/pin/failLast", Date.now());
|
||
return { count: next, at: Date.now() };
|
||
});
|
||
api.onMessage("pinFailReset", (_p, m) => {
|
||
fromPanel(m);
|
||
api.storage.set("aegis/pin/failCount", 0);
|
||
api.storage.set("aegis/pin/failLast", 0);
|
||
return true;
|
||
});
|
||
api.onMessage("pinFailStatus", (_p, m) => {
|
||
fromPanel(m);
|
||
return {
|
||
count: Number(api.storage.get("aegis/pin/failCount", 0)) || 0,
|
||
last: Number(api.storage.get("aegis/pin/failLast", 0)) || 0,
|
||
};
|
||
});
|
||
|
||
// 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() };
|
||
});
|
||
// Called only after the panel has actually decrypted the PIN blob, which
|
||
// is proof of the PIN and not merely a claim about it.
|
||
api.onMessage("pinGateSatisfied", (_p, m) => {
|
||
fromPanel(m);
|
||
api.storage.set("aegis/pin/gate", { lastOkAt: Date.now(), bootId: BOOT_ID });
|
||
return true;
|
||
});
|
||
|
||
api.onMessage("securityGet", (_p, m) => {
|
||
fromPanel(m);
|
||
const cfg = api.storage.get("aegis/security/v1", {}) || {};
|
||
return {
|
||
hasPin: !!api.storage.get("aegis/pin/v1", null),
|
||
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),
|
||
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 : {}; }
|
||
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();
|
||
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 (!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) {
|
||
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 (!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: 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);
|
||
});
|
||
});
|
||
// 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 { <categoryHex>: {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 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" });
|
||
// 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);
|
||
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);
|
||
// 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();
|
||
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 tryAutoUnlock(api).then(() => 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();
|
||
},
|
||
};
|