Since Theseus 0.3.80 the vault PIN can be 6 to 8 digits, but Aegis's pads still submitted at six, so anyone with a 7- or 8-digit PIN got "wrong length" from every Aegis prompt and had to use the master password. The lock-screen, transaction and reveal pads now draw as many dots as the host reports (pinLength) and submit at that length; a wrong length is explained instead of shown as a wrong PIN. Aegis's own PIN on older hosts stays at six.
4821 lines
245 KiB
JavaScript
4821 lines
245 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 });
|
||
// Tron raw_data_hex decoder — the approval overlay for dapp-built Tron
|
||
// transactions is built from these bytes, never from the dapp's JSON.
|
||
const tronDecode = require("./lib/tron-decode.js")({ sha256, base58check });
|
||
// Solana uses the raw base58 alphabet (no checksum), which lives inside
|
||
// base58check as encodeBase58 / decodeBase58 — expose them under a
|
||
// `{encode, decode}` shape the SOL adapter reads from.
|
||
const solBase58 = { encode: base58check.encodeBase58, decode: base58check.decodeBase58 };
|
||
const solAdapter = require("./lib/chain-sol.js")({ ed25519, base58: solBase58, sha256 });
|
||
// DGB delegates address derivation + PSBT to the vendored @dgb-wallet/*
|
||
// packages under lib/dgb/. Those are ESM; the peer deps (bitcoinjs-lib,
|
||
// bip32, ecpair, @bitcoinerlab/secp256k1) are CommonJS and reachable via
|
||
// api.require from the Theseus dependency tree.
|
||
const { pathToFileURL } = require("node:url");
|
||
// DGB's bundled ESM packages import their peer deps by bare specifier
|
||
// ("bip32", "bitcoinjs-lib", …). When aegis is loaded from a copy in
|
||
// userData/addons/, Node's ESM resolver can't reach Theseus's node_modules
|
||
// from that path — so the import throws. Wrap it: DGB just becomes
|
||
// unavailable, the rest of Aegis keeps working.
|
||
const bitcoinjs = api.require("bitcoinjs-lib");
|
||
const { BIP32Factory } = api.require("bip32");
|
||
const { ECPairFactory } = api.require("ecpair");
|
||
const ecc = api.require("@bitcoinerlab/secp256k1");
|
||
|
||
let dgbCore = null, dgbPsbt = null, dgbAdapter = null;
|
||
try {
|
||
// Inject before importing anything under lib/dgb/. Those modules used to
|
||
// import "bitcoinjs-lib" and friends as bare specifiers, which only
|
||
// resolves when the add-on sits inside the app tree — so DigiByte worked
|
||
// on a fresh install and vanished after the first OTA update, where Aegis
|
||
// runs from <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, tronDecode,
|
||
wcCore, wcWallet, libauth };
|
||
}
|
||
|
||
// ---- servers ---------------------------------------------------------------
|
||
|
||
function bchDefaultServers(api) {
|
||
try { return JSON.parse(fs.readFileSync(path.join(api.folder, "electrum-servers.json"), "utf8")); }
|
||
catch { return []; }
|
||
}
|
||
function bchServerList(api) {
|
||
const custom = api.storage.get("servers", null);
|
||
return Array.isArray(custom) && custom.length ? custom : bchDefaultServers(api);
|
||
}
|
||
|
||
// ---- chain registry --------------------------------------------------------
|
||
// Two-level structure so the panel can present coin-then-network as separate
|
||
// picks. `coin` fields are chain-wide; `networks[<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, onSet) {
|
||
const raw = api.storage;
|
||
if (!raw || typeof raw.all !== "function") return false;
|
||
let mem = null;
|
||
const load = () => {
|
||
if (!mem) { try { mem = raw.all() || {}; } catch { mem = {}; } }
|
||
return mem;
|
||
};
|
||
const cached = {
|
||
get: (key, fallback = null) => {
|
||
const s = load();
|
||
return Object.prototype.hasOwnProperty.call(s, key) ? s[key] : fallback;
|
||
},
|
||
set: (key, value) => {
|
||
load()[key] = value;
|
||
raw.set(key, value);
|
||
if (onSet) try { onSet(key); } catch {}
|
||
},
|
||
all: () => ({ ...load() }),
|
||
};
|
||
try {
|
||
api.storage = cached;
|
||
return api.storage === cached;
|
||
} catch { return false; }
|
||
}
|
||
|
||
// Aegis starts on first use (addon.json "activation": "on-demand"). Two
|
||
// things only work while it runs, so while either is on it asks the host to
|
||
// start it at launch: a live WizardConnect pairing (nobody else listens on
|
||
// the relays for the dapp's sign requests) and "stay unlocked" (that
|
||
// auto-unlock also opens Settings › Passwords). Pairings of a removed wallet
|
||
// stay in storage, so only wallets still in the list count.
|
||
function needsLaunchStart(api) {
|
||
const ids = new Set((readWallets(api) || []).map((w) => w.id));
|
||
const all = api.storage.all ? api.storage.all() : {};
|
||
for (const [k, v] of Object.entries(all)) {
|
||
const m = /^wc\/(.+)\/uris$/.exec(k);
|
||
if (m && ids.has(m[1]) && Array.isArray(v) && v.length) return true;
|
||
}
|
||
const cfg = api.storage.get("aegis/session/cfg", null);
|
||
const blob = api.storage.get("aegis/session/enc", null);
|
||
return !!(cfg && cfg.lockOnClose === false && blob && blob.encPwB64);
|
||
}
|
||
let launchStartAsked = null;
|
||
function syncLaunchStart(api) {
|
||
if (typeof api.startAtLaunch !== "function") return; // host predates on-demand
|
||
const on = needsLaunchStart(api);
|
||
if (on === launchStartAsked) return;
|
||
launchStartAsked = on;
|
||
try { api.startAtLaunch(on); } catch (e) { api.log("startAtLaunch:", e?.message || e); }
|
||
}
|
||
const LAUNCH_START_KEYS = /^(wallets$|wc\/|aegis\/session\/)/;
|
||
|
||
function readWallets(api) {
|
||
const raw = api.storage.get("wallets", null);
|
||
return Array.isArray(raw) ? raw : null;
|
||
}
|
||
function writeWallets(api, list) { api.storage.set("wallets", list); }
|
||
|
||
// Bring pre-multi-wallet storage forward: create the legacy BCH default entry
|
||
// and rehome its receiveCursor / txCache under the new per-wallet subkey.
|
||
function migrateLegacyStorage(api) {
|
||
if (readWallets(api)) return; // already multi-wallet
|
||
const legacyAccountPath = String(api.storage.get("accountPath", "") || "").trim() || "m/44'/145'/0'";
|
||
const wallets = [{
|
||
id: LEGACY_BCH_WALLET_ID,
|
||
label: "BCH — main",
|
||
chain: "bch",
|
||
network: "mainnet",
|
||
purpose: LEGACY_BCH_PURPOSE,
|
||
accountPath: legacyAccountPath,
|
||
isDefault: true,
|
||
isLegacy: true,
|
||
createdAt: 0,
|
||
}];
|
||
writeWallets(api, wallets);
|
||
api.storage.set("selectedWalletId", LEGACY_BCH_WALLET_ID);
|
||
// Move per-wallet state under the scoped prefix used by chain-bch.js.
|
||
const prefix = `wallets/${LEGACY_BCH_WALLET_ID}/`;
|
||
for (const legacyKey of ["receiveCursor", "txCache"]) {
|
||
const v = api.storage.get(legacyKey, null);
|
||
if (v !== null && api.storage.get(prefix + legacyKey, null) === null) {
|
||
api.storage.set(prefix + legacyKey, v);
|
||
}
|
||
}
|
||
api.log("migrated legacy BCH wallet into multi-wallet layout");
|
||
}
|
||
|
||
// The lowest index not in use. It used to be highest + 1, so removing a
|
||
// wallet retired its index for good: the keys were still derivable from the
|
||
// vault, but nothing in the UI could ever produce that wallet again, and
|
||
// whatever was on it was out of reach. Now "Add wallet" after a removal
|
||
// brings the same wallet (same keys, same address) back.
|
||
function nextIndex(wallets, meta) {
|
||
const used = new Set();
|
||
for (const w of wallets) {
|
||
if (chainKey(w.chain, w.network) !== chainKey(meta.chain, meta.network)) continue;
|
||
if (w.isLegacy) continue;
|
||
const m = /\/(\d+)$/.exec(w.purpose || "");
|
||
const n = m ? Number(m[1]) : NaN;
|
||
if (Number.isFinite(n)) used.add(n);
|
||
}
|
||
let i = meta.startIndex;
|
||
while (used.has(i)) i++;
|
||
return i;
|
||
}
|
||
|
||
function makeWalletId(meta, index) {
|
||
const n = String(meta.network).replace(/[^a-z0-9]/gi, "");
|
||
return `${meta.chain}-${n}-${index}`;
|
||
}
|
||
|
||
function autoLabel(meta, wallets) {
|
||
const same = wallets.filter((w) => chainKey(w.chain, w.network) === chainKey(meta.chain, meta.network));
|
||
if (!same.length) return meta.short;
|
||
return `${meta.short} #${same.length + 1}`;
|
||
}
|
||
|
||
// ---- runtime wallet map -----------------------------------------------------
|
||
// A "runtime" is a mounted wallet: its adapter instance plus phase + error.
|
||
// activate() derives all of them in parallel once the vault unlocks.
|
||
|
||
async function mountAllWallets() {
|
||
const c = ctx;
|
||
const walletList = readWallets(c.api) || [];
|
||
for (const w of walletList) {
|
||
if (!c.runtimes.has(w.id)) c.runtimes.set(w.id, { entry: w, phase: "locked", error: null, adapter: null });
|
||
}
|
||
emitState();
|
||
await Promise.all(walletList.map((w) => mountWallet(w)));
|
||
}
|
||
|
||
// An import stores the FULL leaf path the user typed (m/44'/1'/0'/0/0) in
|
||
// entry.accountPath, because that is what derived the single address the
|
||
// strip shows. WizardConnect wants the BIP44 *account* node instead — it
|
||
// appends the branch (receive/change/defi) and the address index itself.
|
||
// Handing it the leaf made it derive m/44'/1'/0'/0/0/<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.
|
||
// A copy: `root` is zeroed in the finally below, and the adapter
|
||
// reads its root again for every new pairing's relay key. Sharing
|
||
// the buffer gave every pairing made after mount a relay key derived
|
||
// from 32 zero bytes, i.e. from the pairing URI alone.
|
||
root32: new Uint8Array(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}`);
|
||
// Version byte must be this network's (0x80 mainnet, 0xEF testnet) and a
|
||
// 34-byte WIF must end in the 0x01 compression flag; anything else used to
|
||
// be accepted and could derive an address the key's owner never used.
|
||
const want = prefix === "bitcoincash" ? 0x80 : 0xef;
|
||
if (raw[0] !== want) throw new Error(`this WIF is for another network (version 0x${raw[0].toString(16)})`);
|
||
if (raw.length === 34 && raw[33] !== 0x01) throw new Error("bad WIF compression flag");
|
||
const priv = raw.slice(1, 33);
|
||
const compressed = raw.length === 34; // trailing 0x01 marker
|
||
const pub = d.secp256k1.getPublicKey(priv, compressed);
|
||
const h160 = d.ripemd160(d.sha256(pub));
|
||
return d.cashaddr.encode(prefix, 0, h160);
|
||
}
|
||
|
||
// Plain-text body + rows for the host overlay (it escapes everything, so
|
||
// markup would show up literally). Outputs are decoded best-effort from the
|
||
// libauth transaction the dapp sent: P2PKH / P2SH locking bytecode → cashaddr.
|
||
// The WC message nests the WcSignTransactionRequest under .transaction, so
|
||
// the libauth tx itself is request.transaction.transaction (see wc-sign.js).
|
||
function buildWcApproval(payload) {
|
||
const req = payload?.request || {};
|
||
const tx = req.transaction || {};
|
||
// Who is asking is what Aegis recorded when the pairing was made: the page
|
||
// origin the host verified, or the panel when the user pasted the code.
|
||
// The dapp's userPrompt is its own free text, shown only as a quote — it
|
||
// used to be the overlay's origin and the PIN request's name, so a paired
|
||
// dapp could present itself as any site.
|
||
const paired = payload?.pairing && typeof payload.pairing.origin === "string" ? payload.pairing.origin : null;
|
||
const dappName = paired === "panel" ? "a dapp you paired in Aegis by pasting its code"
|
||
: paired ? paired
|
||
: "a dapp paired before Aegis recorded where pairings came from";
|
||
const says = plainLabel(tx.userPrompt || "", 160);
|
||
const walletName = payload?.label || payload?.walletId || "";
|
||
const network = ctx.runtimes.get(payload?.walletId)?.entry?.network;
|
||
const prefix = network === "chipnet" ? "bchtest" : "bitcoincash";
|
||
let inner = tx.transaction;
|
||
if (typeof inner === "string") {
|
||
try {
|
||
const lib = ctx.d.libauth;
|
||
const dec = (lib.decodeTransactionCommon || lib.decodeTransaction)(ctx.d.tx.fromHex(inner));
|
||
inner = typeof dec === "string" ? null : dec;
|
||
} catch { inner = null; }
|
||
}
|
||
// Refuse what cannot be shown. The signer only takes this shape (see
|
||
// wc-sign.js), and an overlay without outputs or spent inputs is no
|
||
// approval at all.
|
||
if (!inner || !Array.isArray(inner.inputs) || !Array.isArray(inner.outputs) || !inner.outputs.length
|
||
|| !Array.isArray(tx.sourceOutputs) || tx.sourceOutputs.length !== inner.inputs.length) {
|
||
return { unreadable: true, dappName };
|
||
}
|
||
// Every output is listed; a request with more than fit on an overlay is
|
||
// refused rather than summarised as "… and N more".
|
||
if (inner.outputs.length > 30 || tx.sourceOutputs.length > 30) return { unreadable: true, dappName };
|
||
// Scripts this wallet has handed out, to tell change from a payment.
|
||
const ownScripts = new Set();
|
||
try {
|
||
const w = ctx.runtimes.get(payload?.walletId)?.adapter?._wallet;
|
||
for (const e of (w?.state?.watched?.values?.() || [])) if (e && e.script) ownScripts.add(Buffer.from(e.script).toString("hex"));
|
||
} catch {}
|
||
let inSum = null;
|
||
const inputCount = Array.isArray(req.inputPaths) ? req.inputPaths.length : (Array.isArray(inner?.inputs) ? inner.inputs.length : "?");
|
||
const rows = [{ label: "Wallet", value: walletName }, { label: "Inputs", value: String(inputCount) }];
|
||
if (says) rows.unshift({ label: "The dapp says", value: `“${says}”` });
|
||
// What the wallet is putting IN. The outputs alone never showed that a
|
||
// transaction spends the user's tokens — and with the token prefix now
|
||
// signed correctly (wc-sign.js) such a transaction is valid, so the
|
||
// tokens leaving must be on the overlay.
|
||
const hexOf = (v) => (typeof v === "string" ? v.toLowerCase() : Buffer.from(v || []).toString("hex"));
|
||
const tokenText = (t) => {
|
||
if (!t) return "";
|
||
const cat = hexOf(t.category);
|
||
let amt = "0";
|
||
try { amt = BigInt(t.amount ?? 0).toString(); } catch {}
|
||
const nft = t.nft ? ` + NFT (${String(t.nft.capability || "none")})` : "";
|
||
return ` + token ${cat.slice(0, 8)}…${cat.slice(-4)}: ${amt} units${nft}`;
|
||
};
|
||
try {
|
||
const srcs = Array.isArray(tx.sourceOutputs) ? tx.sourceOutputs : [];
|
||
if (srcs.length) {
|
||
let inTotal = 0n;
|
||
const tokenLines = [];
|
||
srcs.forEach((o, i) => {
|
||
try { inTotal += BigInt(o.valueSatoshis ?? 0); } catch {}
|
||
inSum = inTotal;
|
||
if (o.token) tokenLines.push(`input #${i + 1}${tokenText(o.token)}`);
|
||
});
|
||
rows.push({ label: "Spending", value: `${fmtBch(Number(inTotal))} BCH from ${srcs.length} input${srcs.length === 1 ? "" : "s"}` });
|
||
if (tokenLines.length) rows.push({ label: "Tokens spent", value: tokenLines.join("\n"), mono: true, strong: true });
|
||
}
|
||
} catch {}
|
||
try {
|
||
const outs = inner?.outputs || [];
|
||
let total = 0n;
|
||
for (const o of outs) { try { total += BigInt(o.valueSatoshis ?? 0); } catch {} }
|
||
outs.forEach((o, i) => {
|
||
const lb = o.lockingBytecode;
|
||
const hexScript = typeof lb === "string" ? lb.toLowerCase() : Buffer.from(lb || []).toString("hex");
|
||
let addr = null;
|
||
if (/^76a914[0-9a-f]{40}88ac$/.test(hexScript)) addr = ctx.d.cashaddr.encode(prefix, 0, ctx.d.tx.fromHex(hexScript.slice(6, 46)));
|
||
else if (/^a914[0-9a-f]{40}87$/.test(hexScript)) addr = ctx.d.cashaddr.encode(prefix, 1, ctx.d.tx.fromHex(hexScript.slice(4, 44)));
|
||
const v = BigInt(o.valueSatoshis ?? 0);
|
||
const token = tokenText(o.token);
|
||
const mine = ownScripts.has(hexScript) ? " (your address)" : "";
|
||
rows.push({ label: `Output #${i + 1}`, value: `${fmtBch(Number(v))} BCH${token} → ${addr || (hexScript.startsWith("6a") ? "OP_RETURN data" : "script " + hexScript.slice(0, 24) + "…")}${mine}`, mono: true });
|
||
});
|
||
if (outs.length) rows.push({ label: "Total out", value: `${fmtBch(Number(total))} BCH`, strong: true });
|
||
// The fee is what the inputs carry beyond the outputs. The values are
|
||
// trustworthy (SIGHASH_UTXOS commits to them), so this is exact.
|
||
if (inSum != null) {
|
||
const fee = inSum - total;
|
||
rows.push({ label: "Network fee", value: fee >= 0n ? `${fmtBch(Number(fee))} BCH` : "negative — the transaction cannot be valid", strong: fee > 100000n });
|
||
if (fee > 100000n) rows.unshift({ label: "Warning", value: `This transaction pays ${fmtBch(Number(fee))} BCH in fees — far more than a normal transaction.`, strong: true });
|
||
}
|
||
} catch {}
|
||
rows.push({ label: "After signing", value: tx.broadcast ? "the dapp broadcasts it" : "signed hex is returned to the dapp" });
|
||
rows.push({ label: "Sighash", value: "ALL | FORKID | UTXOS" });
|
||
return { body: `A site paired over WizardConnect asks you to sign a Bitcoin Cash transaction. Paired from: ${dappName}.`, rows, dappName, risky: rows.some((r) => r.label === "Warning" || r.label === "Tokens spent"), origin: paired && paired !== "panel" ? paired : "WizardConnect" };
|
||
}
|
||
|
||
// ---- per-purpose default wallets -------------------------------------------
|
||
// The wallet you pay from and the wallet you hand to a dapp are not
|
||
// necessarily the same one, and until now both fell out of whatever happened
|
||
// to be selected in the panel. That is why a WizardConnect pairing could land
|
||
// on an address the user did not recognise: with the selected wallet
|
||
// ineligible, pairing silently took the first one in list order.
|
||
//
|
||
// A role points at a wallet id. An id whose wallet has since been removed
|
||
// reads back as null rather than being trusted, so a stale pointer can never
|
||
// quietly redirect a payment.
|
||
const WALLET_ROLES = ["payments", "wizardconnect"];
|
||
|
||
function walletRoles() {
|
||
const raw = ctx.api.storage.get("aegis/roles/v1", null);
|
||
const list = readWallets(ctx.api) || [];
|
||
const out = {};
|
||
for (const role of WALLET_ROLES) {
|
||
const id = raw && typeof raw === "object" ? String(raw[role] || "") : "";
|
||
out[role] = id && list.some((w) => w.id === id) ? id : null;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function setWalletRole(role, walletId) {
|
||
if (!WALLET_ROLES.includes(role)) throw new Error("unknown role: " + role);
|
||
const next = walletRoles();
|
||
if (walletId == null || walletId === "") next[role] = null;
|
||
else {
|
||
const list = readWallets(ctx.api) || [];
|
||
if (!list.some((w) => w.id === walletId)) throw new Error("no such wallet");
|
||
next[role] = String(walletId);
|
||
}
|
||
ctx.api.storage.set("aegis/roles/v1", next);
|
||
return next;
|
||
}
|
||
|
||
// ---- state / snapshot -------------------------------------------------------
|
||
|
||
function selectedWalletId() {
|
||
const list = readWallets(ctx.api) || [];
|
||
if (!list.length) return null;
|
||
const saved = String(ctx.api.storage.get("selectedWalletId", "") || "");
|
||
if (saved && list.some((w) => w.id === saved)) return saved;
|
||
// Nothing picked yet this session. The wallet the user nominated for
|
||
// payments is a better opening guess than list order.
|
||
const pay = walletRoles().payments;
|
||
if (pay) return pay;
|
||
const dflt = list.find((w) => w.isDefault) || list[0];
|
||
return dflt.id;
|
||
}
|
||
|
||
function walletEntries() { return readWallets(ctx.api) || []; }
|
||
|
||
function overallPhase() {
|
||
// If ANY wallet is nosetup, treat the whole addon as nosetup — the user
|
||
// hasn't unlocked / set up the vault, so no wallet can work.
|
||
const runtimes = [...ctx.runtimes.values()];
|
||
if (!runtimes.length) return "locked";
|
||
if (runtimes.some((r) => r.phase === "nosetup")) return "nosetup";
|
||
if (runtimes.some((r) => r.phase === "locked")) return "locked";
|
||
return "ready";
|
||
}
|
||
|
||
// Per-wallet reason a BCH wallet can't do WizardConnect even though it's
|
||
// mounted and ready — today that's single-key (WIF) imports, which have no
|
||
// seed to derive a per-dapp key tree from. Surfaced in walletSummary so the
|
||
// picker can grey them out instead of offering a pairing that must fail.
|
||
const wcIneligible = new Map();
|
||
function wcErrReason(e) {
|
||
const m = e?.message || String(e);
|
||
return /locked|vault/i.test(m)
|
||
? { short: "vault locked", detail: "Unlock the password vault to use WizardConnect with this wallet." }
|
||
: { short: "unavailable", detail: m };
|
||
}
|
||
|
||
function walletSummary(w) {
|
||
const meta = chainMeta(w.chain, w.network);
|
||
const rt = ctx.runtimes.get(w.id);
|
||
const snap = rt && rt.adapter ? rt.adapter.snapshot() : null;
|
||
return {
|
||
id: w.id, label: w.label, chain: w.chain, network: w.network, isDefault: !!w.isDefault, isLegacy: !!w.isLegacy,
|
||
kind: w.kind || null,
|
||
logo: meta?.logo || null, color: meta?.color || "#888",
|
||
coinLabel: meta?.coinLabel || w.chain, networkLabel: meta?.networkLabel || w.network, testnet: !!meta?.testnet,
|
||
ticker: meta?.ticker || "?", short: meta?.short || w.chain, decimals: meta?.decimals || 8,
|
||
address: snap?.address || null,
|
||
// Prefer the wallet-registry's stored accountPath; fall back to whatever
|
||
// the runtime derived (default when the user hasn't overridden). Nulls
|
||
// stay null so the picker knows whether to render the mono path line.
|
||
accountPath: w.accountPath || snap?.accountPath || null,
|
||
balance: snap?.balance || { confirmed: 0, unconfirmed: 0 },
|
||
// Per-wallet assets, so the coin drilldown can show what each ADDRESS
|
||
// holds instead of only the selected wallet's. `tokens` is the account-
|
||
// model shape (SPL / TRC20); `tokenBalances` is BCH CashTokens, keyed
|
||
// by category. Both stay null/empty for chains that have neither.
|
||
tokens: Array.isArray(snap?.tokens) ? snap.tokens : [],
|
||
tokenBalances: snap?.tokenBalances || null,
|
||
phase: rt?.phase || "locked",
|
||
error: rt?.error || null,
|
||
wcBlocked: w.chain === "bch" ? (wcIneligible.get(w.id)?.detail || null) : null,
|
||
wcBlockedShort: w.chain === "bch" ? (wcIneligible.get(w.id)?.short || null) : null,
|
||
};
|
||
}
|
||
|
||
function snapshotForSelected() {
|
||
const id = selectedWalletId();
|
||
if (!id) return { phase: "empty" };
|
||
const rt = ctx.runtimes.get(id);
|
||
const entry = walletEntries().find((w) => w.id === id);
|
||
const meta = entry ? chainMeta(entry.chain, entry.network) : null;
|
||
const base = {
|
||
walletId: id,
|
||
label: entry?.label,
|
||
chain: entry?.chain,
|
||
network: entry?.network,
|
||
isLegacy: !!entry?.isLegacy,
|
||
kind: entry?.kind || null,
|
||
meta: meta ? {
|
||
logo: meta.logo, color: meta.color, short: meta.short, ticker: meta.ticker, decimals: meta.decimals,
|
||
coinLabel: meta.coinLabel, networkLabel: meta.networkLabel, testnet: meta.testnet,
|
||
addressFamilies: meta.addressFamilies, defaultAccountPath: meta.defaultAccountPath,
|
||
} : null,
|
||
supportsMessageSign: !!meta?.supportsMessageSign,
|
||
phase: rt?.phase || "locked",
|
||
error: rt?.error || null,
|
||
};
|
||
if (rt && rt.adapter) Object.assign(base, rt.adapter.snapshot());
|
||
return base;
|
||
}
|
||
|
||
function fullState() {
|
||
return {
|
||
overallPhase: overallPhase(),
|
||
selectedWalletId: selectedWalletId(),
|
||
roles: walletRoles(),
|
||
wallets: walletEntries().map(walletSummary),
|
||
selected: snapshotForSelected(),
|
||
bchServers: {
|
||
list: bchServerList(ctx.api),
|
||
custom: Array.isArray(ctx.api.storage.get("servers", null)),
|
||
},
|
||
coins: coinsForPanel(),
|
||
prices: ctx.priceFeed ? ctx.priceFeed.snapshot() : { enabled: false, prices: {} },
|
||
wc: ctx.wc ? ctx.wc.snapshot() : {},
|
||
};
|
||
}
|
||
|
||
function emitState() { try { ctx.api.emit("state", fullState()); } catch {} }
|
||
function emitStateForWallet(id) {
|
||
// Any wallet change fans out to the panel with the full state so the
|
||
// wallet list balances update in the header too.
|
||
if (!ctx || !ctx.runtimes.has(id)) return;
|
||
emitState();
|
||
}
|
||
|
||
// ---- panel messages ---------------------------------------------------------
|
||
|
||
function requireWallet(id) {
|
||
const rt = ctx.runtimes.get(id);
|
||
if (!rt || rt.phase !== "ready" || !rt.adapter) throw new Error("wallet is not ready (vault locked?)");
|
||
return rt;
|
||
}
|
||
function requireSelected() { return requireWallet(selectedWalletId()); }
|
||
|
||
// ---- PIN: sealed blob + host-verified transaction clearance ---------------
|
||
// The PIN blob is the vault master password wrapped under a 6-digit PIN. In
|
||
// plain add-on storage that made the master password exactly as strong as a
|
||
// million PBKDF2 guesses to anyone holding a copy of the profile (a backup,
|
||
// another machine, a disk image) — the panel's lockout counter never sees an
|
||
// offline guess. It is therefore sealed with the OS keystore (DPAPI /
|
||
// Keychain / libsecret) before it touches disk, the same as Theseus's own
|
||
// vault PIN: a copied file is useless without this OS account.
|
||
//
|
||
// There is no unsealed fallback any more: without a real OS keystore a PIN is
|
||
// not stored at all (pinSet refuses) and the master password is asked for
|
||
// instead. On Linux, safeStorage reports "available" even on its basic_text
|
||
// backend, whose key is a constant compiled into Chromium — that counts as
|
||
// unsealed. A plain blob from an older build is sealed the first time it is
|
||
// read, and the fact that it once sat on disk in the clear is remembered so
|
||
// the panel can tell the user to change the master password (see
|
||
// PIN_EXPOSED_KEY); where it cannot be sealed it is deleted.
|
||
//
|
||
// The OS seal does not stop someone running as this OS user, nor a disk
|
||
// image plus the Windows password. Where a TPM is available the PIN is
|
||
// therefore also the authorization value of a TPM key (lib/tpm-pin.js), and
|
||
// the chip's own lockout limits guesses to ~144 a day however the blob is
|
||
// obtained. See pinWrap.
|
||
const PIN_BLOB_KEY = "aegis/pin/v1";
|
||
const PIN_EXPOSED_KEY = "aegis/pin/legacyExposure";
|
||
const tpmPin = require("./lib/tpm-pin.js");
|
||
function osSealUsable(ss) {
|
||
try {
|
||
if (!ss || !ss.isEncryptionAvailable()) return false;
|
||
if (process.platform === "linux") {
|
||
const backend = typeof ss.getSelectedStorageBackend === "function" ? ss.getSelectedStorageBackend() : "unknown";
|
||
if (backend === "basic_text" || backend === "unknown") return false;
|
||
}
|
||
return true;
|
||
} catch { return false; }
|
||
}
|
||
// → the sealed record, or null when this system has no keystore worth the name.
|
||
function sealPinBlob(api, blob) {
|
||
const ss = safeStorageOr(api);
|
||
if (!osSealUsable(ss)) return null;
|
||
try { return { v: 2, sealed: ss.encryptString(JSON.stringify(blob)).toString("base64") }; }
|
||
catch { return null; }
|
||
}
|
||
function openPinBlob(api) {
|
||
const b = api.storage.get(PIN_BLOB_KEY, null);
|
||
if (!b || typeof b !== "object") return null;
|
||
if (b.sealed) {
|
||
const ss = safeStorageOr(api);
|
||
if (!ss) return null;
|
||
try {
|
||
const plain = JSON.parse(ss.decryptString(Buffer.from(String(b.sealed), "base64")));
|
||
return (plain && typeof plain === "object") ? plain : null;
|
||
} catch { return null; } // sealed under another OS account: the PIN is simply gone
|
||
}
|
||
// A plain blob from before 0.31: the master password sat on disk behind
|
||
// only a 6-digit PIN. Seal it (or drop it), and remember that it was ever
|
||
// there — copies of the profile made before now still hold it.
|
||
const plain = { salt: b.salt, iv: b.iv, ct: b.ct, iters: b.iters };
|
||
if (!api.storage.get(PIN_EXPOSED_KEY, null)) api.storage.set(PIN_EXPOSED_KEY, { at: Date.now(), iters: Number(b.iters) || 0 });
|
||
const sealed = sealPinBlob(api, plain);
|
||
if (sealed) { api.storage.set(PIN_BLOB_KEY, sealed); return plain; }
|
||
api.storage.set(PIN_BLOB_KEY, null);
|
||
return null;
|
||
}
|
||
|
||
// The PIN is checked here, never in the panel. The panel used to fetch the
|
||
// blob and decrypt it itself, then report its own failures — so the lockout
|
||
// counted only the guesses a well-behaved panel chose to report, and anything
|
||
// that could run in the panel could take the blob and search all million
|
||
// PINs offline. Now the blob stays in this process and every guess is
|
||
// counted before it is tried. PIN_MAX_FAILS wrong guesses lock the PIN for
|
||
// PIN_LOCKOUT_MS (the panel shows the same 15-minute lockout as before);
|
||
// every further wrong guess locks it again. A correct PIN or a master
|
||
// password the vault accepts clears the count.
|
||
// The blob format is unchanged: PBKDF2-SHA256(pin) -> AES-256-GCM, hex, tag
|
||
// appended to the ciphertext, as WebCrypto wrote it.
|
||
const PIN_ITERS = 600_000;
|
||
const PIN_MAX_FAILS = 5;
|
||
const PIN_LOCKOUT_MS = 15 * 60 * 1000;
|
||
const PIN_RE = /^\d{6}$/;
|
||
const nodeCrypto = require("node:crypto");
|
||
const pinKey = (pin, saltHex, iters) => new Promise((resolve, reject) =>
|
||
nodeCrypto.pbkdf2(String(pin), Buffer.from(saltHex, "hex"), iters, 32, "sha256", (e, k) => (e ? reject(e) : resolve(k))));
|
||
// With a TPM, `hw` is { keyName, wrapped, secret } from tpmPin.create and the
|
||
// AES key needs both the chip's secret and PBKDF2(pin) (tpmPin.mixKey); the
|
||
// blob records which TPM key to ask. Without one the blob is PBKDF2 only.
|
||
async function pinWrap(pin, masterPassword, hw = null) {
|
||
const salt = nodeCrypto.randomBytes(16).toString("hex");
|
||
const iv = nodeCrypto.randomBytes(12);
|
||
let key = await pinKey(pin, salt, PIN_ITERS);
|
||
if (hw) key = tpmPin.mixKey(hw.secret, key);
|
||
const c = nodeCrypto.createCipheriv("aes-256-gcm", key, iv);
|
||
const ct = Buffer.concat([c.update(String(masterPassword), "utf8"), c.final(), c.getAuthTag()]);
|
||
const out = { salt, iv: iv.toString("hex"), ct: ct.toString("hex"), iters: PIN_ITERS };
|
||
if (hw) out.hw = { kind: "tpm", key: hw.keyName, wrapped: hw.wrapped };
|
||
return out;
|
||
}
|
||
// `tpmSecret` is what the TPM released for this PIN (required when blob.hw).
|
||
async function pinUnwrapBlob(pin, blob, tpmSecret = null) {
|
||
const ct = Buffer.from(String(blob.ct), "hex");
|
||
let key = await pinKey(pin, blob.salt, Number(blob.iters) || PIN_ITERS);
|
||
if (blob.hw) {
|
||
if (!tpmSecret) throw new Error("TPM secret required");
|
||
key = tpmPin.mixKey(tpmSecret, key);
|
||
}
|
||
const d = nodeCrypto.createDecipheriv("aes-256-gcm", key, Buffer.from(String(blob.iv), "hex"));
|
||
d.setAuthTag(ct.subarray(ct.length - 16));
|
||
return Buffer.concat([d.update(ct.subarray(0, ct.length - 16)), d.final()]).toString("utf8");
|
||
}
|
||
// A TPM key for a new PIN, or null where there is none (not Windows, no TPM,
|
||
// PowerShell blocked by policy). Never throws: the PIN then works as before,
|
||
// and pinStatus says honestly that it is software-only.
|
||
let tpmUnavailableThisBoot = false;
|
||
async function tryTpmKey(api, pin) {
|
||
if (!tpmPin.supported() || tpmUnavailableThisBoot) return null;
|
||
try { return await tpmPin.create(pin); }
|
||
catch (e) { tpmUnavailableThisBoot = true; api.log("PIN: no TPM key:", e?.message || e); return null; }
|
||
}
|
||
// ---- one PIN: the Theseus vault PIN ---------------------------------------
|
||
// On a host with api.vault.pin (features.vaultPin) there is one PIN in
|
||
// Theseus: the vault PIN, checked in Theseus main against one strike
|
||
// counter, also used by Settings, the unlock prompt and Pithos. Aegis keeps
|
||
// its PIN pads and their wording and sends the digits there; the host opens
|
||
// the vault and answers only whether the PIN was right, so the master
|
||
// password never reaches Aegis. What the panel gets back in place of the
|
||
// password is a single-use proof (mintPinProof), which the handlers that
|
||
// used to verify a password accept instead. On older hosts (0.3.74-0.3.76)
|
||
// Aegis keeps its own PIN blob (aegis/pin/v1) exactly as before.
|
||
const hostPinApi = (api) => (api && api.features && api.features.vaultPin && api.vault && api.vault.pin) ? api.vault.pin : null;
|
||
let hostPinCache = { pinSet: false, hardware: null, storable: true, at: 0 };
|
||
async function refreshHostPin(api) {
|
||
const hp = hostPinApi(api);
|
||
if (!hp) return null;
|
||
try { const st = await hp.status(); hostPinCache = { ...st, at: Date.now() }; } catch (e) { api.log("vault PIN status:", e?.message || e); }
|
||
return hostPinCache;
|
||
}
|
||
// Is there a PIN to ask for? Aegis's own (old hosts, or not yet migrated)
|
||
// or the vault PIN.
|
||
function hasPinNow(api) {
|
||
return !!api.storage.get(PIN_BLOB_KEY, null) || (!!hostPinApi(api) && !!hostPinCache.pinSet);
|
||
}
|
||
const PIN_PROOF_TTL_MS = 120_000;
|
||
const pinProofs = new Map(); // token -> expiry
|
||
function mintPinProof() {
|
||
const t = "pinproof:" + nodeCrypto.randomBytes(24).toString("hex");
|
||
for (const [k, exp] of pinProofs) if (exp < Date.now()) pinProofs.delete(k);
|
||
pinProofs.set(t, Date.now() + PIN_PROOF_TTL_MS);
|
||
return t;
|
||
}
|
||
function takePinProof(t) {
|
||
const exp = pinProofs.get(t);
|
||
if (!exp) return false;
|
||
pinProofs.delete(t);
|
||
return exp > Date.now();
|
||
}
|
||
// Where a handler used to check a master password: accept either the
|
||
// password (checked by the vault) or a fresh PIN proof minted above.
|
||
// Answers "pin" or "master"; throws when neither holds.
|
||
async function verifyPasswordOrProof(api, pw) {
|
||
const s = String(pw || "");
|
||
if (s.startsWith("pinproof:")) {
|
||
if (!takePinProof(s)) throw new Error("PIN proof rejected");
|
||
return "pin";
|
||
}
|
||
await api.vault.lifecycle.unlock(s);
|
||
noteMasterVerified(api);
|
||
return "master";
|
||
}
|
||
|
||
function pinFails(api) {
|
||
const n = Number(api.storage.get("aegis/pin/failCount", 0)) || 0;
|
||
const last = Number(api.storage.get("aegis/pin/failLast", 0)) || 0;
|
||
const lockedMs = n >= PIN_MAX_FAILS ? Math.max(0, PIN_LOCKOUT_MS - (Date.now() - last)) : 0;
|
||
return { fails: n, last, lockedMs };
|
||
}
|
||
// A master password the vault accepted. Clears the PIN strikes, as a
|
||
// correct PIN does.
|
||
function noteMasterVerified(api) {
|
||
api.storage.set("aegis/pin/failCount", 0);
|
||
api.storage.set("aegis/pin/failLast", 0);
|
||
}
|
||
|
||
// "Ask for PIN on every transaction" used to be decided by the host and
|
||
// enforced by nobody: `send` never checked it, and a dapp-initiated
|
||
// transaction had no PIN step at all. A clearance is now a short-lived,
|
||
// single-use fact recorded only after the host itself has verified the
|
||
// master password the PIN unwraps (pinGateSatisfied). Panel sends consume
|
||
// one; dapp transactions ask the open panel for one and wait.
|
||
//
|
||
// A clearance belongs to the thing the PIN was entered for. It used to be
|
||
// one global window: any PIN proof (opening the wallet, a settings toggle)
|
||
// let the next dapp transaction from any site through, a waiting dapp could
|
||
// take the clearance the user had just made for their own send, and with
|
||
// two sites waiting the second one's request was lost. Now:
|
||
// - each waiting dapp transaction has its own id, and only a proof that
|
||
// names that id releases it;
|
||
// - a proof given for "transaction" in the panel clears the panel's next
|
||
// send only;
|
||
// - any other proof clears nothing.
|
||
const TX_CLEARANCE_MS = 90_000;
|
||
const DAPP_PIN_WAIT_MS = 120_000;
|
||
let panelClearanceUntil = 0;
|
||
const pendingPinRequests = new Map(); // id -> { id, origin, what, at, cleared }
|
||
function txPinOwed() {
|
||
try { return !!(ctx && ctx.pinNeeded && ctx.pinNeeded("transaction").needPin); }
|
||
catch { return true; } // the safe direction for a lock is closed
|
||
}
|
||
function requirePanelTxPin() {
|
||
if (!txPinOwed()) return;
|
||
if (Date.now() < panelClearanceUntil) { panelClearanceUntil = 0; return; }
|
||
throw new Error("PIN required — confirm your PIN to continue");
|
||
}
|
||
async function requireDappTxPin(origin, what) {
|
||
await refreshHostPin(ctx.api);
|
||
if (!txPinOwed()) return;
|
||
const req = { id: nodeCrypto.randomBytes(8).toString("hex"), origin: String(origin || ""), what: String(what || "transaction"), at: Date.now(), cleared: false };
|
||
pendingPinRequests.set(req.id, req);
|
||
try {
|
||
try { ctx.api.emit("pinRequest", { id: req.id, origin: req.origin, what: req.what, at: req.at }); } catch {}
|
||
const deadline = Date.now() + DAPP_PIN_WAIT_MS;
|
||
while (Date.now() < deadline) {
|
||
await new Promise((r) => setTimeout(r, 250));
|
||
if (!ctx) break;
|
||
if (req.cleared) return;
|
||
}
|
||
} finally { pendingPinRequests.delete(req.id); }
|
||
throw new Error("Aegis asks for your PIN on every transaction. Open the Aegis panel, confirm your PIN there, then try again.");
|
||
}
|
||
|
||
// Signed text shown on an approval overlay. Long messages are cut for the
|
||
// overlay's sake, but never silently: the cut says how much is missing, so a
|
||
// benign-looking opening cannot hide what the rest commits the user to.
|
||
function previewText(text, max = 1500) {
|
||
const s = String(text);
|
||
const cut = s.length > max ? `${s.slice(0, max)}\n… [${s.length - max} more characters are NOT shown but will be signed]` : s;
|
||
return showInvisibles(cut);
|
||
}
|
||
// Characters that change how text LOOKS without being visible themselves:
|
||
// C0/C1 controls (newline and tab excepted), zero-width characters, and the
|
||
// bidi overrides/isolates that can make "0x…dead" render as "0x…daed" or
|
||
// reorder an overlay line. Built from code points so no editor or tool can
|
||
// silently turn the escapes into the characters themselves.
|
||
const INVISIBLE_RE = (() => {
|
||
const r = [[0x00, 0x08], [0x0b, 0x0c], [0x0e, 0x1f], [0x7f, 0x9f], [0x061c, 0x061c], [0x180e, 0x180e],
|
||
[0x200b, 0x200f], [0x2028, 0x202e], [0x2060, 0x2064], [0x2066, 0x206f], [0xfeff, 0xfeff]];
|
||
const esc = (c) => String.fromCharCode(92) + "u" + c.toString(16).padStart(4, "0");
|
||
return new RegExp("[" + r.map(([a, b]) => (a === b ? esc(a) : esc(a) + "-" + esc(b))).join("") + "]", "g");
|
||
})();
|
||
// Signed text keeps every character, so the overlay names the invisible ones.
|
||
function showInvisibles(s) {
|
||
return String(s).replace(INVISIBLE_RE, (c) => `⟨U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}⟩`);
|
||
}
|
||
// A name or label that someone else chose (a dapp, a token registry): one
|
||
// line, nothing invisible, bounded.
|
||
function plainLabel(v, max = 80) {
|
||
return String(v ?? "").replace(INVISIBLE_RE, "").replace(/\s+/g, " ").trim().slice(0, max);
|
||
}
|
||
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");
|
||
// Exact: integer base units are formatted as strings. Float division showed
|
||
// 0.1 ETH as 0.100000000000000006, rounded 1 ETH + 1 wei to "1", and put
|
||
// Sia's 24-decimal amounts in exponent notation.
|
||
function fmtValue(units, decimals) {
|
||
const t = typeof units === "bigint" ? units.toString() : String(units ?? "0").trim();
|
||
if (/^-?\d+$/.test(t)) return fmtTokenAmount(t, decimals);
|
||
const n = Number(units) / Math.pow(10, decimals);
|
||
return n.toFixed(Math.min(20, 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 || "");
|
||
// A PIN proof means Theseus has already opened the vault with the PIN.
|
||
await verifyPasswordOrProof(api, 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);
|
||
await refreshHostPin(api);
|
||
requirePanelTxPin();
|
||
// Same rule as send: the token send names the wallet it was reviewed for.
|
||
if (!p || !p.walletId || String(p.walletId) !== selectedWalletId()) {
|
||
throw new Error("the selected wallet changed — review the send and try again");
|
||
}
|
||
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);
|
||
await refreshHostPin(api);
|
||
requirePanelTxPin();
|
||
// The panel names the wallet its form was built for. If the selection
|
||
// moved since (another surface, a late state push), refuse rather than
|
||
// send this amount from a different wallet.
|
||
// Required, not optional: a send that does not say which wallet it was
|
||
// reviewed for cannot be checked against the selection.
|
||
if (!p || !p.walletId) throw new Error("send names no wallet — review the send and try again");
|
||
if (String(p.walletId) !== selectedWalletId()) {
|
||
throw new Error("the selected wallet changed — review the send and try again");
|
||
}
|
||
const rt = requireSelected();
|
||
const plan = await Promise.resolve(rt.adapter.plan(p || {}));
|
||
const d = describePlan(plan, rt.entry.chain, rt.entry.network);
|
||
const meta = chainMeta(rt.entry.chain, rt.entry.network);
|
||
const rows = [
|
||
{ label: "To", value: d.recipients[0].to, mono: true },
|
||
{ label: "Amount", value: `${fmtValue(d.recipients[0].value, meta.decimals)} ${meta.ticker}`, strong: true },
|
||
{ label: "Fee", value: rt.entry.chain === "bch" ? `${plan.fee} sat (${plan.feeRate} sat/B)` : `${fmtValue(plan.fee, meta.decimals)} ${meta.ticker}` },
|
||
{ label: "Total", value: `${fmtValue(d.total, meta.decimals)} ${meta.ticker}` },
|
||
{ label: "Wallet", value: `${rt.entry.label} — ${meta.coinLabel} · ${meta.networkLabel}` },
|
||
];
|
||
const pick = await api.approvalModal({
|
||
title: `Send ${meta.ticker}?`,
|
||
origin: "Aegis wallet panel",
|
||
rows,
|
||
actions: [{ id: "send", label: "Send", primary: true }],
|
||
});
|
||
if (pick !== "send") throw new Error("cancelled");
|
||
return rt.adapter.signAndBroadcast(plan);
|
||
});
|
||
|
||
// ---- Balance consolidation ------------------------------------------------
|
||
// Batch send-max from every same-chain/same-network wallet (or a subset the
|
||
// user picked with checkboxes) into the currently-selected wallet. Runs in
|
||
// two phases:
|
||
// consolidatePreview — dry-run plan() per source; returns balance/fee/
|
||
// net/error so the panel renders a preview list
|
||
// with checkboxes without asking the user to
|
||
// approve anything yet.
|
||
// consolidateIntoSelected — signs + broadcasts one send per chosen source.
|
||
// The panel shows a single upfront confirmation
|
||
// (with the total to move and total fees); Theseus's
|
||
// per-tx approval overlay is skipped because the
|
||
// batch itself is the user's explicit intent.
|
||
api.onMessage("consolidatePreview", async (_p, m) => {
|
||
fromPanel(m);
|
||
const destId = selectedWalletId();
|
||
if (!destId) throw new Error("no wallet selected");
|
||
const destEntry = walletEntries().find((w) => w.id === destId);
|
||
if (!destEntry) throw new Error("selected wallet not found");
|
||
const destRt = ctx.runtimes.get(destId);
|
||
if (!destRt?.adapter) throw new Error("destination wallet not ready");
|
||
const destSnap = destRt.adapter.snapshot();
|
||
const destAddr = destSnap.address;
|
||
if (!destAddr) throw new Error("destination wallet has no receive address");
|
||
const sources = walletEntries().filter((w) =>
|
||
w.chain === destEntry.chain &&
|
||
w.network === destEntry.network &&
|
||
w.id !== destId,
|
||
);
|
||
const items = [];
|
||
for (const src of sources) {
|
||
const rt = ctx.runtimes.get(src.id);
|
||
const snap = rt?.adapter?.snapshot?.() || {};
|
||
const bal = snap.balance || {};
|
||
const totalUnits = typeof bal.confirmed === "string"
|
||
? (BigInt(bal.confirmed || "0") + BigInt(bal.unconfirmed || "0")).toString()
|
||
: String((bal.confirmed || 0) + (bal.unconfirmed || 0));
|
||
const base = {
|
||
walletId: src.id, label: src.label,
|
||
address: snap.address || null,
|
||
balance: totalUnits,
|
||
fee: null, net: null, error: null, eligible: false,
|
||
};
|
||
if (!rt?.adapter) { items.push({ ...base, error: "adapter not mounted" }); continue; }
|
||
if (typeof rt.adapter.plan !== "function") { items.push({ ...base, error: "adapter has no plan()" }); continue; }
|
||
// Dry-run send-max to the destination. plan() throws on empty /
|
||
// dust-only wallets — that's the "nothing to sweep" case and it
|
||
// reads as an error string per source in the preview.
|
||
try {
|
||
const plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true }));
|
||
const fee = String(plan.fee ?? 0);
|
||
const net = String(plan.recipients?.[0]?.value ?? 0);
|
||
items.push({ ...base, fee, net, eligible: true });
|
||
} catch (e) {
|
||
items.push({ ...base, error: e?.message || String(e) });
|
||
}
|
||
}
|
||
const meta = chainMeta(destEntry.chain, destEntry.network) || null;
|
||
return {
|
||
destinationWalletId: destId,
|
||
destinationLabel: destEntry.label,
|
||
destinationAddress: destAddr,
|
||
chain: destEntry.chain,
|
||
network: destEntry.network,
|
||
ticker: meta?.ticker || "",
|
||
decimals: meta?.decimals || 8,
|
||
sources: items,
|
||
};
|
||
});
|
||
|
||
api.onMessage("consolidateIntoSelected", async (p, m) => {
|
||
fromPanel(m);
|
||
await refreshHostPin(api);
|
||
requirePanelTxPin();
|
||
const destId = selectedWalletId();
|
||
if (!destId) throw new Error("no wallet selected");
|
||
// The destination is the wallet the PREVIEW was drawn for, not whatever
|
||
// is selected by the time the button is pressed: a preview painted for A
|
||
// followed by a switch to B used to sweep everything into B.
|
||
if (!p || !p.destinationWalletId || String(p.destinationWalletId) !== destId) {
|
||
throw new Error("the selected wallet changed since this preview — reopen Consolidate");
|
||
}
|
||
const destEntry = walletEntries().find((w) => w.id === destId);
|
||
if (!destEntry) throw new Error("selected wallet not found");
|
||
const destRt = ctx.runtimes.get(destId);
|
||
if (!destRt?.adapter) throw new Error("destination wallet not ready");
|
||
const destAddr = destRt.adapter.snapshot().address;
|
||
if (!destAddr) throw new Error("destination wallet has no receive address");
|
||
// sourceIds are the wallets the user CHECKED. They are required: an
|
||
// omitted list used to mean "every sibling wallet".
|
||
if (!Array.isArray(p?.sourceIds) || !p.sourceIds.length) throw new Error("choose at least one wallet to consolidate");
|
||
const requested = new Set(p.sourceIds.map(String));
|
||
const sources = walletEntries().filter((w) =>
|
||
w.chain === destEntry.chain &&
|
||
w.network === destEntry.network &&
|
||
w.id !== destId &&
|
||
requested.has(w.id),
|
||
);
|
||
if (!sources.length) throw new Error("none of the chosen wallets can be consolidated into this one");
|
||
|
||
// Plan every sweep first, then put the WHOLE batch on the host overlay.
|
||
// This emptied wallets on the panel's say-so alone; every other spend
|
||
// in Aegis is confirmed on a surface the panel cannot draw.
|
||
const meta = chainMeta(destEntry.chain, destEntry.network);
|
||
const dec = meta?.decimals ?? 8;
|
||
const planned = [];
|
||
const results = [];
|
||
for (const src of sources) {
|
||
const rt = ctx.runtimes.get(src.id);
|
||
if (!rt?.adapter || typeof rt.adapter.plan !== "function") {
|
||
results.push({ walletId: src.id, label: src.label, ok: false, error: "adapter not mounted" });
|
||
continue;
|
||
}
|
||
try {
|
||
const plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true }));
|
||
planned.push({ src, rt, plan });
|
||
} catch (e) {
|
||
results.push({ walletId: src.id, label: src.label, ok: false, error: e?.message || String(e) });
|
||
}
|
||
}
|
||
if (planned.length) {
|
||
let totalNet = 0n, totalFee = 0n;
|
||
const rows = planned.slice(0, 12).map(({ src, plan }) => {
|
||
const net = BigInt(String(plan.recipients?.[0]?.value ?? 0));
|
||
const fee = BigInt(String(plan.fee ?? 0));
|
||
totalNet += net; totalFee += fee;
|
||
return { label: "Empty", value: `${src.label} — ${fmtValue(net.toString(), dec)} ${meta?.ticker || ""}`, mono: true };
|
||
});
|
||
for (const { plan } of planned.slice(12)) {
|
||
totalNet += BigInt(String(plan.recipients?.[0]?.value ?? 0));
|
||
totalFee += BigInt(String(plan.fee ?? 0));
|
||
}
|
||
if (planned.length > 12) rows.push({ label: "…", value: `and ${planned.length - 12} more wallets` });
|
||
rows.push({ label: "Into", value: `${destEntry.label} — ${destAddr}`, mono: true });
|
||
rows.push({ label: "Arrives", value: `${fmtValue(totalNet.toString(), dec)} ${meta?.ticker || ""}`, strong: true });
|
||
rows.push({ label: "Fees", value: `${fmtValue(totalFee.toString(), dec)} ${meta?.ticker || ""} across ${planned.length} transaction${planned.length === 1 ? "" : "s"}` });
|
||
const pick = await api.approvalModal({
|
||
title: `Consolidate ${planned.length} wallet${planned.length === 1 ? "" : "s"}?`,
|
||
origin: "Aegis wallet panel",
|
||
body: "Each wallet listed is emptied into the destination in its own transaction. This cannot be undone.",
|
||
rows,
|
||
actions: [{ id: "send", label: "Consolidate", primary: true }],
|
||
});
|
||
if (pick !== "send") throw new Error("cancelled");
|
||
}
|
||
for (const { src, rt, plan } of planned) {
|
||
try {
|
||
const r = await rt.adapter.signAndBroadcast(plan);
|
||
results.push({
|
||
walletId: src.id, label: src.label, ok: true,
|
||
txid: r?.txid || null,
|
||
sent: String(plan.recipients?.[0]?.value ?? 0),
|
||
fee: String(plan.fee ?? 0),
|
||
});
|
||
} catch (e) {
|
||
results.push({ walletId: src.id, label: src.label, ok: false, error: e?.message || String(e) });
|
||
}
|
||
}
|
||
// Force a refresh on the destination so its balance jumps once the txs
|
||
// reach the network's mempool. Silent-fail — panel will pick up state
|
||
// on the next state emit anyway.
|
||
try { if (typeof destRt.adapter.refresh === "function") destRt.adapter.refresh(false); } catch {}
|
||
return {
|
||
destinationWalletId: destId,
|
||
destinationAddress: destAddr,
|
||
chain: destEntry.chain,
|
||
network: destEntry.network,
|
||
results,
|
||
};
|
||
});
|
||
|
||
// ---- promote an import to an HD wallet ----------------------------------
|
||
//
|
||
// A wallet imported from a single private key cannot do WizardConnect: the
|
||
// handshake ships BIP32 xpubs so the dapp can derive addresses on its own,
|
||
// and a lone key has no chain code to build one from. Nothing Aegis can do
|
||
// locally fixes that — manufacturing a parent whose child equals a given
|
||
// key means inverting HMAC-SHA512. The way out is to stop being a
|
||
// single-key wallet: derive a proper HD wallet from the vault and sweep the
|
||
// imported key into it.
|
||
//
|
||
// Only BCH imports can do this. The BTC/DGB/ETH/TRX/SOL imported adapters
|
||
// still throw "read-only" from plan(), so there is no sweep to run.
|
||
function promotableImport(walletId) {
|
||
const entry = walletEntries().find((w) => w.id === String(walletId || ""));
|
||
if (!entry) throw new Error("unknown wallet");
|
||
if (entry.kind !== "imported") throw new Error("this wallet is already derived from your vault");
|
||
if (entry.chain !== "bch") {
|
||
throw new Error(`Imported ${String(entry.chain).toUpperCase()} wallets are still read-only, so there is nothing to sweep with yet. Only BCH imports can be promoted today.`);
|
||
}
|
||
const rt = ctx.runtimes.get(entry.id);
|
||
if (!rt?.adapter || rt.phase !== "ready") throw new Error("wallet is still loading — try again in a moment");
|
||
return { entry, rt };
|
||
}
|
||
|
||
// Open the wallet as a full Theseus tab. It loads panel.html — the very
|
||
// same file the sidebar uses — with ?surface=web, because an add-on's own
|
||
// tab is handed the same window.silentmode surface and main dispatches it
|
||
// as from:"panel". So this is a re-layout, not a second wallet: one set of
|
||
// handlers, one UI, no drift between the two.
|
||
api.onMessage("openFullScreen", (_p, m) => {
|
||
fromPanel(m);
|
||
if (typeof api.openTab !== "function") {
|
||
throw new Error("This Theseus build can't open add-on tabs — update Theseus.");
|
||
}
|
||
api.openTab("panel.html", { query: { surface: "web" } });
|
||
return true;
|
||
});
|
||
|
||
api.onMessage("promotePreview", async (p, m) => {
|
||
fromPanel(m);
|
||
const { entry, rt } = promotableImport(p && p.walletId);
|
||
const snap = rt.adapter.snapshot();
|
||
const meta = chainMeta(entry.chain, entry.network);
|
||
// Cost the sweep WITHOUT creating the destination wallet first, so
|
||
// cancelling the preview leaves nothing behind. A send-max to our own
|
||
// address spends the same UTXOs into the same single P2PKH output, so
|
||
// the fee is identical to the real sweep — only the output's 20-byte
|
||
// hash differs, and that does not change the transaction's size.
|
||
let fee = null, net = null, error = null;
|
||
try {
|
||
const plan = await Promise.resolve(rt.adapter.plan({ to: snap.address, sendMax: true }));
|
||
fee = String(plan.fee ?? 0);
|
||
net = String(plan.recipients?.[0]?.value ?? 0);
|
||
} catch (e) { error = e?.message || String(e); }
|
||
return {
|
||
walletId: entry.id, label: entry.label,
|
||
chain: entry.chain, network: entry.network,
|
||
networkLabel: meta?.networkLabel || entry.network,
|
||
ticker: meta?.ticker || "", decimals: meta?.decimals || 8,
|
||
address: snap.address || null,
|
||
balance: snap.balance || null,
|
||
fee, net, error,
|
||
suggestedLabel: `${entry.label} (HD)`,
|
||
};
|
||
});
|
||
|
||
api.onMessage("promoteToHd", async (p, m) => {
|
||
fromPanel(m);
|
||
await refreshHostPin(api);
|
||
requirePanelTxPin();
|
||
const { entry, rt } = promotableImport(p && p.walletId);
|
||
const dest = await createVaultWallet({
|
||
chain: entry.chain, network: entry.network,
|
||
label: String(p && p.label || "") || `${entry.label} (HD)`,
|
||
});
|
||
const destRt = ctx.runtimes.get(dest.id);
|
||
const destAddr = destRt?.adapter?.snapshot?.()?.address;
|
||
if (!destAddr) throw new Error("the new wallet came up without a receive address — nothing was moved");
|
||
|
||
let plan;
|
||
try {
|
||
plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true }));
|
||
} catch (e) {
|
||
// Nothing was broadcast, so the empty wallet we just made is pure
|
||
// litter — take it back out. Past this point we keep it even on
|
||
// failure, because a transaction may already be on the wire and its
|
||
// destination must stay visible.
|
||
try { unmountWallet(dest.id); writeWallets(ctx.api, walletEntries().filter((w) => w.id !== dest.id)); } catch {}
|
||
ctx.api.storage.set("selectedWalletId", entry.id);
|
||
emitState();
|
||
throw e;
|
||
}
|
||
|
||
const sent = String(plan.recipients?.[0]?.value ?? 0);
|
||
const fee = String(plan.fee ?? 0);
|
||
const r = await rt.adapter.signAndBroadcast(plan);
|
||
// The import keeps its key on purpose. The sweep is unconfirmed for now,
|
||
// and anyone who still has the old address can pay into it — removing
|
||
// the key here would strand those coins. The panel offers removal as a
|
||
// separate step once the balance has actually gone to zero.
|
||
try { rt.adapter.refresh(false); } catch {}
|
||
try { destRt.adapter.refresh(false); } catch {}
|
||
emitState();
|
||
return {
|
||
ok: true, txid: r?.txid || null, sent, fee,
|
||
fromWalletId: entry.id, fromLabel: entry.label,
|
||
newWalletId: dest.id, newWalletLabel: dest.label, newAddress: destAddr,
|
||
ticker: chainMeta(entry.chain, entry.network)?.ticker || "",
|
||
decimals: chainMeta(entry.chain, entry.network)?.decimals || 8,
|
||
};
|
||
});
|
||
|
||
api.onMessage("recovery", async (p, m) => {
|
||
fromPanel(m);
|
||
const id = String(p && p.id || selectedWalletId());
|
||
const rt = requireWallet(id);
|
||
if (typeof rt.adapter.recovery !== "function") throw new Error("this chain does not expose recovery details");
|
||
const r = rt.adapter.recovery();
|
||
// Public material only. Revealing a secret goes through revealSecret,
|
||
// which proves the master password first — this used to hand back the
|
||
// xprv behind nothing but an approval click.
|
||
return { accountPath: r.accountPath, xpub: r.xpub, purpose: rt.entry.purpose };
|
||
});
|
||
|
||
// ---- reveal a wallet's secret ------------------------------------------
|
||
// Authorisation is the master password, and it is verified HERE rather
|
||
// than taken on trust from the panel: the PIN route never touches
|
||
// vaultUnlock, so without this check the only thing between a secret and
|
||
// the screen would be panel-side logic. The panel gets the password either
|
||
// by decrypting the PIN blob (which wraps exactly this) or by asking.
|
||
//
|
||
// What comes back is NOT a recovery phrase, because no BIP39 mnemonic is
|
||
// stored anywhere in Aegis or the Theseus vault. An import keeps
|
||
// mnemonicToSeedHex(words) and discards the words (PBKDF2, one-way), and a
|
||
// vault wallet is HKDF(vault root, purpose) and never had words of its
|
||
// own — password-vault.js is explicit that the seed is never persisted.
|
||
// Calling any of this a "recovery phrase" in the UI would be a lie that
|
||
// costs someone their backup, so every form names itself and says where it
|
||
// can actually be restored.
|
||
api.onMessage("revealSecret", async (p, m) => {
|
||
fromPanel(m);
|
||
const pw = String((p && p.masterPassword) || "");
|
||
if (!pw) throw new Error("master password required");
|
||
try { await verifyPasswordOrProof(api, pw); }
|
||
catch (e) {
|
||
if (/PIN proof rejected/.test(e?.message || "")) throw new Error("PIN proof rejected — enter the PIN again");
|
||
// A missing or unset-up vault is a structural failure, not a bad
|
||
// password — don't accuse the user of mistyping something that was
|
||
// never going to work.
|
||
const msg = e?.message || String(e);
|
||
if (/no vault|not set up/i.test(msg)) throw new Error(msg);
|
||
throw new Error("wrong master password");
|
||
}
|
||
noteMasterVerified(api);
|
||
|
||
const id = String((p && p.walletId) || selectedWalletId() || "");
|
||
const entry = walletEntries().find((w) => w.id === id);
|
||
if (!entry) throw new Error("no such wallet");
|
||
const meta = chainMeta(entry.chain, entry.network);
|
||
const base = {
|
||
walletId: entry.id, label: entry.label, chain: entry.chain,
|
||
coinLabel: meta?.coinLabel || entry.chain,
|
||
networkLabel: meta?.networkLabel || entry.network,
|
||
address: ctx.runtimes.get(entry.id)?.adapter?.snapshot()?.address || null,
|
||
};
|
||
|
||
if (entry.kind === "imported") {
|
||
if (!api.vault?.imports || typeof api.vault.imports.signer !== "function") {
|
||
throw new Error("this build cannot read imported keys");
|
||
}
|
||
const blob = await api.vault.imports.signer(entry.importId);
|
||
if (blob.kind === "wif") {
|
||
// ETH/TRX/SOL raw hex keys ride in the wif slot behind a scheme
|
||
// prefix (see importWallet) — unwrap so the user sees the key.
|
||
const w = String(blob.wif || "");
|
||
const PRIVHEX = "aegis-privhex:";
|
||
return w.startsWith(PRIVHEX)
|
||
? { ...base, form: "privhex", secret: w.slice(PRIVHEX.length) }
|
||
: { ...base, form: "wif", secret: w };
|
||
}
|
||
if (blob.kind === "seed") {
|
||
return { ...base, form: "seed", secret: String(blob.seed || ""), path: blob.path || null };
|
||
}
|
||
throw new Error("unknown import kind: " + blob.kind);
|
||
}
|
||
|
||
// Vault-derived. Two genuinely useful forms: the account xprv, which
|
||
// other HD wallets accept, and the 32-byte purpose root Aegis derives
|
||
// from. Sia's recovery() calls its seed "xprv" and it is the same bytes
|
||
// as the root, so don't report it twice.
|
||
let xprv = null, xpub = null, accountPath = null;
|
||
const rt = ctx.runtimes.get(entry.id);
|
||
if (rt && rt.adapter && typeof rt.adapter.recovery === "function") {
|
||
try {
|
||
const r = rt.adapter.recovery();
|
||
xpub = r.xpub || null;
|
||
accountPath = r.accountPath || null;
|
||
if (entry.chain !== "sc") xprv = r.xprv || null;
|
||
} catch { /* public material is a bonus, not the point */ }
|
||
}
|
||
const root = await api.vault.derive(entry.purpose);
|
||
let rootHex = "";
|
||
try { rootHex = Array.from(root, (b) => b.toString(16).padStart(2, "0")).join(""); }
|
||
finally { try { root.fill(0); } catch {} }
|
||
return { ...base, form: "vault", secret: rootHex, purpose: entry.purpose, xprv, xpub, accountPath };
|
||
});
|
||
|
||
// Opt-in USD prices. Persist the choice so restart doesn't silently
|
||
// disable it, and kick a fetch immediately when switched on.
|
||
api.onMessage("setPricesEnabled", async (p, m) => {
|
||
fromPanel(m);
|
||
const on = !!(p && p.enabled);
|
||
api.storage.set("pricesEnabled", on);
|
||
if (ctx.priceFeed) await ctx.priceFeed.setEnabled(on);
|
||
return fullState();
|
||
});
|
||
api.onMessage("refreshPrices", async (_p, m) => {
|
||
fromPanel(m);
|
||
if (ctx.priceFeed) await ctx.priceFeed.refresh();
|
||
return fullState();
|
||
});
|
||
api.onMessage("setPricesSource", async (p, m) => {
|
||
fromPanel(m);
|
||
const id = String(p && p.source || "").trim();
|
||
if (!id) throw new Error("source required");
|
||
api.storage.set("pricesSource", id);
|
||
if (ctx.priceFeed) await ctx.priceFeed.setSource(id);
|
||
return fullState();
|
||
});
|
||
|
||
// WizardConnect: pair a wiz:// URI with a specific BCH wallet.
|
||
api.onMessage("wcConnect", async (p, m) => {
|
||
fromPanel(m);
|
||
if (!ctx.wc) throw new Error("WizardConnect not ready");
|
||
const walletId = String(p && p.walletId || "");
|
||
const uri = String(p && p.uri || "");
|
||
await ctx.wc.connectUri(walletId, uri, "panel");
|
||
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, origin);
|
||
return { paired: true, wallet: chosen.label };
|
||
});
|
||
});
|
||
|
||
// Reorder wallets by an explicit ID list. Silently drops IDs that are
|
||
// not in the current wallet set (removed since the panel last read);
|
||
// appends any wallets missing from `order` to the end of the list so a
|
||
// stale panel reorder cannot make a wallet vanish from the strip.
|
||
api.onMessage("reorderWallets", (p, m) => {
|
||
fromPanel(m);
|
||
const order = Array.isArray(p && p.order) ? p.order.map(String) : [];
|
||
const current = walletEntries();
|
||
const byId = new Map(current.map((w) => [w.id, w]));
|
||
const next = [];
|
||
const seen = new Set();
|
||
for (const id of order) {
|
||
if (byId.has(id) && !seen.has(id)) { next.push(byId.get(id)); seen.add(id); }
|
||
}
|
||
for (const w of current) if (!seen.has(w.id)) next.push(w);
|
||
writeWallets(api, next);
|
||
emitState();
|
||
return fullState();
|
||
});
|
||
|
||
// Which wallet each purpose reaches for. Panel-only: a page must never be
|
||
// able to read the whole wallet set, let alone re-point a role at one.
|
||
api.onMessage("walletRoles", (_p, m) => { fromPanel(m); return walletRoles(); });
|
||
api.onMessage("setWalletRole", (p, m) => {
|
||
fromPanel(m);
|
||
const role = String(p && p.role || "");
|
||
const id = p && p.walletId ? String(p.walletId) : null;
|
||
setWalletRole(role, id);
|
||
emitState();
|
||
// Full state, not just the role map — the panel assigns this straight
|
||
// onto `state`, and the badges it drives live on the wallet rows.
|
||
return fullState();
|
||
});
|
||
|
||
// ---- seed import: which derivation path actually holds the funds? -------
|
||
// The import form prefilled exactly one path, so a seed from a wallet that
|
||
// used a different one imported a valid, empty address and reported 0 with
|
||
// no way to tell "wrong path" from "empty wallet". Both real cases hit it:
|
||
// Bitcoin.com derives mainnet BCH from coin type 0' (its Copay lineage
|
||
// predates the 145' split), and chipnet tooling generally uses 145' rather
|
||
// than BIP44's testnet 1' — which is what Aegis defaulted chipnet to.
|
||
//
|
||
// So ask the chain instead of guessing. Each candidate is scanned for
|
||
// history and balance, and the panel reports what it found.
|
||
const SEED_PATH_CANDIDATES = {
|
||
"bch/mainnet": [
|
||
{ path: "m/44'/145'/0'/0/0", note: "BCH standard — Electron Cash, Zapit, newer Bitcoin.com" },
|
||
{ path: "m/44'/0'/0'/0/0", note: "BTC coin type — Bitcoin.com, Copay, BitPay" },
|
||
{ path: "m/44'/145'/0'", note: "account node — some QR exports" },
|
||
{ path: "m/0'/0/0", note: "pre-BIP44" },
|
||
],
|
||
// DigiByte. Both master-key variants are covered, because the 2018-19
|
||
// official mobile wallets (BreadWallet forks) used HMAC "DigiByte seed"
|
||
// instead of BIP32's "Bitcoin seed" — the same words then produce a
|
||
// completely different key tree and a standard scan finds nothing.
|
||
// Finding from Digibyte.X/dgb-wallet @ 989a2696.
|
||
"dgb/mainnet": [
|
||
{ path: "m/84'/20'/0'/0/0", note: "BIP84 native SegWit — dgb1q…, the modern default" },
|
||
{ path: "m/44'/20'/0'/0/0", note: "BIP44 legacy — D…" },
|
||
{ path: "m/49'/20'/0'/0/0", note: "BIP49 wrapped SegWit — S…" },
|
||
{ path: "m/86'/20'/0'/0/0", note: "BIP86 Taproot — dgb1p…" },
|
||
{ path: "m/0'/0/0", note: "official 2018-19 mobile wallet", hmacKey: "DigiByte seed" },
|
||
{ path: "m/44'/20'/0'/0/0", note: "BIP44 with the 2018-19 mobile master key", hmacKey: "DigiByte seed" },
|
||
],
|
||
"bch/chipnet": [
|
||
{ path: "m/44'/145'/0'/0/0", note: "BCH coin type on chipnet — most chipnet tooling" },
|
||
{ path: "m/44'/1'/0'/0/0", note: "BIP44 testnet coin type" },
|
||
{ path: "m/44'/145'/0'", note: "account node" },
|
||
],
|
||
};
|
||
api.onMessage("seedPathCandidates", (p, m) => {
|
||
fromPanel(m);
|
||
const key = `${String(p && p.chain || "")}/${String(p && p.network || "")}`;
|
||
return { key, candidates: (SEED_PATH_CANDIDATES[key] || []).map((c) => ({ ...c })) };
|
||
});
|
||
|
||
api.onMessage("scanSeedPaths", async (p, m) => {
|
||
fromPanel(m);
|
||
const chain = String(p && p.chain || "bch");
|
||
const network = String(p && p.network || "");
|
||
const cands = SEED_PATH_CANDIDATES[`${chain}/${network}`];
|
||
if (!cands) throw new Error(`Path scanning is not available for ${chain} ${network} yet.`);
|
||
const mnemonic = String(p && p.mnemonic || "").trim();
|
||
if (!mnemonic) throw new Error("seed phrase required");
|
||
// Same conversion importWallet does. Never stored here — this handler
|
||
// derives, queries and returns counts, nothing else.
|
||
let seedHex;
|
||
try { seedHex = ctx.d.derive.mnemonicToSeedHex(mnemonic); }
|
||
catch (e) { throw new Error("That does not look like a BIP39 seed phrase: " + (e?.message || e)); }
|
||
|
||
// Per-chain: where to ask, how to turn a path into an address, and how to
|
||
// key a script for Electrum. Everything below is chain-agnostic.
|
||
let servers, addressAt, scripthashOf;
|
||
if (chain === "bch") {
|
||
const prefix = network === "mainnet" ? "bitcoincash" : "bchtest";
|
||
servers = network === "mainnet"
|
||
? bchServerList(api)
|
||
: ["wss://chipnet.imaginary.cash:50004", "wss://chipnet.bch.ninja:50004"];
|
||
addressAt = (pth) => deriveCashaddrFromSeed(seedHex, pth, prefix);
|
||
scripthashOf = (addr) => {
|
||
const d = ctx.d.cashaddr.decode(addr);
|
||
const h = Buffer.from(d.hash);
|
||
const spk = d.type === 1
|
||
? Buffer.concat([Buffer.from([0xa9, 0x14]), h, Buffer.from([0x87])])
|
||
: Buffer.concat([Buffer.from([0x76, 0xa9, 0x14]), h, Buffer.from([0x88, 0xac])]);
|
||
return Buffer.from(ctx.d.sha256(new Uint8Array(spk))).reverse().toString("hex");
|
||
};
|
||
} else if (chain === "dgb") {
|
||
if (!ctx.d.dgbCore) throw new Error("the DigiByte modules did not load, so paths cannot be scanned");
|
||
servers = ["wss://electrum1.cipig.net:20063", "wss://electrum2.cipig.net:20063"];
|
||
// hmacKey rides on the candidate, so both master-key variants are
|
||
// scanned in the same pass.
|
||
addressAt = (pth, hmacKey) => ctx.d.derive.dgb.fromSeed(seedHex, pth, hmacKey);
|
||
scripthashOf = (addr) => {
|
||
// bitcoinjs knows every DGB output type (D…, S…, dgb1q…, dgb1p…),
|
||
// so let it build the scriptPubKey rather than hand-rolling four.
|
||
const spk = ctx.d.bitcoinjs.address.toOutputScript(addr, ctx.d.dgbCore.digibyte);
|
||
return Buffer.from(ctx.d.sha256(new Uint8Array(spk))).reverse().toString("hex");
|
||
};
|
||
} else {
|
||
throw new Error(`Path scanning is not available for ${chain} yet.`);
|
||
}
|
||
const client = new ctx.d.electrum.Client(servers);
|
||
// Look a few addresses deep per candidate: a wallet whose first receive
|
||
// address is spent clean still has history, and funds often sit further
|
||
// along the branch.
|
||
const DEPTH = 5;
|
||
const out = [];
|
||
try {
|
||
for (const c of cands) {
|
||
const acct = wcAccountPath(c.path) || c.path;
|
||
const probes = [];
|
||
for (let i = 0; i < DEPTH; i++) probes.push(`${acct}/0/${i}`);
|
||
// The exact path the user would be importing, in case it is a leaf
|
||
// outside the account/0/i shape we probe.
|
||
if (!probes.includes(c.path) && /\/\d+$/.test(c.path)) probes.unshift(c.path);
|
||
let balance = 0, txCount = 0, firstAddress = null, fundedAddress = null;
|
||
for (const [idx, pth] of probes.entries()) {
|
||
let addr;
|
||
try { addr = addressAt(pth, c.hmacKey); }
|
||
catch { continue; }
|
||
if (idx === 0 || firstAddress === null) firstAddress = firstAddress || addr;
|
||
const sh = scripthashOf(addr);
|
||
try {
|
||
const bal = await client.call("blockchain.scripthash.get_balance", [sh]);
|
||
const got = Number(bal?.confirmed || 0) + Number(bal?.unconfirmed || 0);
|
||
if (got > 0 && !fundedAddress) fundedAddress = addr;
|
||
balance += got;
|
||
const hist = await client.call("blockchain.scripthash.get_history", [sh]);
|
||
txCount += Array.isArray(hist) ? hist.length : 0;
|
||
} catch (e) { api.log("scanSeedPaths:", pth, e?.message || e); }
|
||
}
|
||
out.push({
|
||
path: c.path, note: c.note, accountPath: acct, hmacKey: c.hmacKey || null,
|
||
firstAddress, fundedAddress, balance, txCount, scanned: probes.length,
|
||
});
|
||
}
|
||
} finally { try { client.disconnect(); } catch {} }
|
||
const meta = chainMeta(chain, network);
|
||
return {
|
||
chain, network, decimals: meta?.decimals || 8, ticker: meta?.ticker || "BCH",
|
||
results: out,
|
||
// Rank for the panel: funds first, then any history at all.
|
||
best: out.slice().sort((a, b) => (b.balance - a.balance) || (b.txCount - a.txCount))[0]?.path || null,
|
||
};
|
||
});
|
||
|
||
// ---- who can see the wallet --------------------------------------------
|
||
const injectState = () => ({
|
||
supported: !!(api.features && api.features.pageInjectPolicy),
|
||
mode: injectMode(api),
|
||
origins: injectAllowList(api),
|
||
});
|
||
api.onMessage("injectPolicyGet", (_p, m) => { fromPanel(m); return injectState(); });
|
||
api.onMessage("injectPolicySet", (p, m) => {
|
||
fromPanel(m);
|
||
if (!(api.features && api.features.pageInjectPolicy)) throw new Error("this Theseus cannot limit which sites see the wallet — update Theseus");
|
||
api.storage.set(INJECT_MODE_KEY, p && p.mode === "allowed" ? "allowed" : "all");
|
||
syncInjectPolicy(api);
|
||
return injectState();
|
||
});
|
||
// Enable one site. With no origin given it is the site in the active tab,
|
||
// which the host reports — the panel never has to type or guess a URL.
|
||
api.onMessage("injectAllowSite", async (p, m) => {
|
||
fromPanel(m);
|
||
let origin = normalizeOrigin(p && p.origin);
|
||
if (!origin && typeof api.scanActiveTabForUris === "function") {
|
||
try { origin = normalizeOrigin((await api.scanActiveTabForUris({ scheme: "wiz", limit: 1 })).origin); } catch {}
|
||
}
|
||
if (!origin) throw new Error("Open the site in a tab first — there is no web page in the active tab.");
|
||
const list = injectAllowList(api);
|
||
if (!list.includes(origin)) list.push(origin);
|
||
api.storage.set(INJECT_ALLOW_KEY, list);
|
||
syncInjectPolicy(api);
|
||
return { origin, ...injectState() };
|
||
});
|
||
api.onMessage("injectRemoveSite", (p, m) => {
|
||
fromPanel(m);
|
||
const origin = String(p && p.origin || "");
|
||
api.storage.set(INJECT_ALLOW_KEY, injectAllowList(api).filter((o) => o !== origin));
|
||
syncInjectPolicy(api);
|
||
return injectState();
|
||
});
|
||
try { syncInjectPolicy(api); } catch {}
|
||
|
||
api.onMessage("permissions", (_p, m) => { fromPanel(m); return grantsForPanel(api); });
|
||
api.onMessage("revoke", (p, m) => {
|
||
fromPanel(m);
|
||
revokeOrigin(api, String(p && p.origin || ""));
|
||
emitState();
|
||
return grantsForPanel(api);
|
||
});
|
||
|
||
// ---- security: quick-access PIN + policy flags ------------------------
|
||
// The PIN blob is a WebCrypto AES-GCM ciphertext of the master password,
|
||
// derived from PBKDF2(pin, salt). Panel handles the actual encryption /
|
||
// decryption inside its iframe — the master password never crosses the
|
||
// process boundary except via vaultUnlock. These handlers only shuttle
|
||
// the opaque blob + a small policy object in and out of api.storage.
|
||
// Set or replace the PIN. The master password is checked against the vault
|
||
// first, and the blob is built here, so the panel never holds one.
|
||
// Aegis's own PIN, gone: blob, its TPM key, its counters.
|
||
const dropOwnPin = () => {
|
||
const old = openPinBlob(api);
|
||
if (old?.hw?.key) tpmPin.remove(old.hw.key).catch(() => {});
|
||
api.storage.set(PIN_BLOB_KEY, null);
|
||
api.storage.set("aegis/pin/failCount", 0);
|
||
api.storage.set("aegis/pin/failLast", 0);
|
||
};
|
||
// A PIN entered correctly against Aegis's own blob while Theseus already
|
||
// has a different vault PIN: the user is asked once which one to keep.
|
||
let pendingPinMigration = null; // { pin, pw, until }
|
||
api.onMessage("pinMigrateResolve", async (p, m) => {
|
||
fromPanel(m);
|
||
const hp = hostPinApi(api);
|
||
const mig = pendingPinMigration;
|
||
pendingPinMigration = null;
|
||
if (!hp || !mig || mig.until < Date.now()) throw new Error("nothing to migrate");
|
||
if (p && p.replace === true) await hp.set(mig.pin, mig.pw);
|
||
dropOwnPin();
|
||
await refreshHostPin(api);
|
||
return { ok: true };
|
||
});
|
||
|
||
api.onMessage("pinSet", async (p, m) => {
|
||
fromPanel(m);
|
||
const pin = String((p && p.pin) || "");
|
||
const pw = String((p && p.masterPassword) || "");
|
||
if (!PIN_RE.test(pin)) throw new Error("the PIN must be 6 digits");
|
||
if (!pw) throw new Error("master password required");
|
||
const hp = hostPinApi(api);
|
||
if (hp) {
|
||
// The one PIN: Theseus checks the master password and seals the PIN.
|
||
let r;
|
||
try { r = await hp.set(pin, pw); }
|
||
catch (e) { throw new Error(/wrong master password/.test(e?.message || "") ? "wrong master password" : (e?.message || String(e))); }
|
||
noteMasterVerified(api);
|
||
dropOwnPin();
|
||
await refreshHostPin(api);
|
||
return { ok: true, hardware: r && r.hardware || null, unified: true };
|
||
}
|
||
try { await api.vault.lifecycle.unlock(pw); }
|
||
catch { throw new Error("wrong master password"); }
|
||
noteMasterVerified(api);
|
||
if (!osSealUsable(safeStorageOr(api))) {
|
||
throw new Error("this system has no protected keystore, so a PIN cannot be stored safely; Aegis will ask for the master password instead");
|
||
}
|
||
const old = openPinBlob(api);
|
||
const hw = await tryTpmKey(api, pin);
|
||
const sealed = sealPinBlob(api, await pinWrap(pin, pw, hw));
|
||
if (!sealed) { if (hw) tpmPin.remove(hw.keyName).catch(() => {}); throw new Error("the PIN could not be sealed by the OS keystore"); }
|
||
api.storage.set(PIN_BLOB_KEY, sealed);
|
||
if (old?.hw?.key && old.hw.key !== hw?.keyName) tpmPin.remove(old.hw.key).catch(() => {});
|
||
return { ok: true, hardware: hw ? "tpm" : "none" };
|
||
});
|
||
// Try a PIN. Counts the guess before trying it, so a crash or a closed
|
||
// panel mid-check still costs an attempt. Answers
|
||
// { ok: true, masterPassword } | { ok: false, remaining, lockedMs }
|
||
api.onMessage("pinUnwrap", async (p, m) => {
|
||
fromPanel(m);
|
||
const pin = String((p && p.pin) || "");
|
||
const hp = hostPinApi(api);
|
||
if (hp && !api.storage.get(PIN_BLOB_KEY, null)) {
|
||
// The one PIN. Theseus counts the guess, opens the vault on success,
|
||
// and keeps the master password to itself.
|
||
const r = await hp.unlock(pin);
|
||
await refreshHostPin(api);
|
||
if (r && r.ok) return { ok: true, masterPassword: mintPinProof() };
|
||
if (r && r.code === "no-pin") throw new Error("no PIN is set");
|
||
// Not a guess (Theseus spends no strike on it): the pad had the wrong
|
||
// number of digits for this PIN.
|
||
if (r && r.code === "wrong-length") {
|
||
return { ok: false, remaining: Number(r.remaining) || 0, lockedMs: 0, error: `Your PIN has ${Number(r.length) || hostPinCache.length || "a different number of"} digits. Reopen this prompt and try again.` };
|
||
}
|
||
return { ok: false, remaining: Number(r?.remaining) || 0, lockedMs: Number(r?.lockedMs) || 0, error: r?.error || undefined };
|
||
}
|
||
const blob = openPinBlob(api);
|
||
if (!blob) throw new Error("no PIN is set");
|
||
const st = pinFails(api);
|
||
if (st.lockedMs > 0) return { ok: false, remaining: 0, lockedMs: st.lockedMs };
|
||
api.storage.set("aegis/pin/failCount", st.fails + 1);
|
||
api.storage.set("aegis/pin/failLast", Date.now());
|
||
let pw = null;
|
||
if (PIN_RE.test(pin)) {
|
||
let secret = null;
|
||
if (blob.hw) {
|
||
// The chip decides first. Its own counter is the limit that holds
|
||
// even against someone who resets ours (it lives in this same file).
|
||
const r = await tpmPin.open(blob.hw.key, blob.hw.wrapped, pin);
|
||
if (r.ok) secret = r.secret;
|
||
else if (r.code === "locked" || r.code === "error") {
|
||
// Not a verdict on the PIN: give this one attempt back (only this
|
||
// one — restoring the earlier count would also undo guesses made
|
||
// in parallel meanwhile).
|
||
api.storage.set("aegis/pin/failCount", Math.max(0, pinFails(api).fails - 1));
|
||
if (st.fails === 0) api.storage.set("aegis/pin/failLast", st.last);
|
||
return { ok: false, remaining: Math.max(0, PIN_MAX_FAILS - st.fails), lockedMs: 0, hwLocked: r.code === "locked",
|
||
error: r.code === "locked" ? "The security chip is refusing PINs for a few minutes after too many wrong ones. Use the master password, or wait." : "The security chip did not answer. Use the master password." };
|
||
} else if (r.code === "missing") {
|
||
// The TPM key is gone (profile copied to another machine, TPM
|
||
// cleared): this PIN can never open again.
|
||
api.storage.set(PIN_BLOB_KEY, null);
|
||
api.storage.set("aegis/pin/failCount", 0);
|
||
api.storage.set("aegis/pin/failLast", 0);
|
||
return { ok: false, remaining: 0, lockedMs: 0, pinGone: true, error: "This PIN was tied to a security chip that no longer has its key. Enter the master password and set the PIN again." };
|
||
}
|
||
}
|
||
if (!blob.hw || secret) { try { pw = await pinUnwrapBlob(pin, blob, secret); } catch { pw = null; } }
|
||
}
|
||
if (pw == null) {
|
||
const now = pinFails(api);
|
||
return { ok: false, remaining: Math.max(0, PIN_MAX_FAILS - now.fails), lockedMs: now.lockedMs };
|
||
}
|
||
api.storage.set("aegis/pin/failCount", 0);
|
||
api.storage.set("aegis/pin/failLast", 0);
|
||
// A blob from an older build (200k iterations, from before sealing, or
|
||
// without a TPM key on a machine that has one) is re-made now, under a
|
||
// fresh salt, while the PIN is at hand.
|
||
if (!blob.hw && ((Number(blob.iters) || 0) < PIN_ITERS || (tpmPin.supported() && !tpmUnavailableThisBoot))) {
|
||
try {
|
||
const hw = await tryTpmKey(api, pin);
|
||
if ((Number(blob.iters) || 0) < PIN_ITERS || hw) {
|
||
const sealed = sealPinBlob(api, await pinWrap(pin, pw, hw));
|
||
if (sealed) api.storage.set(PIN_BLOB_KEY, sealed);
|
||
else if (hw) tpmPin.remove(hw.keyName).catch(() => {});
|
||
}
|
||
} catch (e) { api.log("PIN re-wrap:", e?.message || e); }
|
||
}
|
||
if (hp) {
|
||
// Migrate the old Aegis PIN to the one PIN on its first correct entry.
|
||
try { await api.vault.lifecycle.unlock(pw); }
|
||
catch { return { ok: false, remaining: 0, lockedMs: 0, error: "Your PIN is out of date. Enter the master password, then set a new PIN." }; }
|
||
const hs = await refreshHostPin(api);
|
||
if (!hs || !hs.pinSet) {
|
||
try { await hp.set(pin, pw); dropOwnPin(); await refreshHostPin(api); api.log("Aegis PIN moved to the Theseus vault PIN"); }
|
||
catch (e) { api.log("PIN migration:", e?.message || e); }
|
||
return { ok: true, masterPassword: mintPinProof(), migrated: true };
|
||
}
|
||
pendingPinMigration = { pin, pw, until: Date.now() + PIN_PROOF_TTL_MS };
|
||
return { ok: true, masterPassword: mintPinProof(), pinConflict: true };
|
||
}
|
||
return { ok: true, masterPassword: pw };
|
||
});
|
||
api.onMessage("pinStatus", async (_p, m) => {
|
||
fromPanel(m);
|
||
if (hostPinApi(api) && !api.storage.get(PIN_BLOB_KEY, null)) {
|
||
const hs = (await refreshHostPin(api)) || {};
|
||
return { hasPin: !!hs.pinSet, fails: hs.fails || 0, last: hs.last || 0, maxFails: hs.maxFails || PIN_MAX_FAILS, lockedMs: hs.lockedMs || 0, unified: true, ...pinProtection() };
|
||
}
|
||
const f = pinFails(api);
|
||
return { hasPin: !!api.storage.get(PIN_BLOB_KEY, null), fails: f.fails, last: f.last, maxFails: PIN_MAX_FAILS, lockedMs: f.lockedMs, ...pinProtection() };
|
||
});
|
||
api.onMessage("pinBlobClear", async (_p, m) => {
|
||
fromPanel(m);
|
||
const hp = hostPinApi(api);
|
||
if (hp) { try { await hp.clear(); } catch (e) { api.log("vault PIN clear:", e?.message || e); } await refreshHostPin(api); }
|
||
const old = openPinBlob(api);
|
||
if (old?.hw?.key) tpmPin.remove(old.hw.key).catch(() => {});
|
||
api.storage.set("aegis/pin/v1", null);
|
||
api.storage.set("aegis/pin/failCount", 0);
|
||
api.storage.set("aegis/pin/failLast", 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;
|
||
});
|
||
|
||
// 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,
|
||
};
|
||
}
|
||
// What actually protects the PIN on this machine, for honest wording in the
|
||
// panel: "tpm" = the chip limits guesses; "none" = only PBKDF2 + the OS
|
||
// keystore, which falls to anyone who can run as this user.
|
||
function pinProtection() {
|
||
const blob = api.storage.get(PIN_BLOB_KEY, null) ? openPinBlob(api) : null;
|
||
const exposed = api.storage.get(PIN_EXPOSED_KEY, null);
|
||
const host = hostPinApi(api) && !blob ? hostPinCache : null;
|
||
return {
|
||
pinHardware: blob ? (blob.hw ? "tpm" : "none") : host ? (host.pinSet ? host.hardware || "none" : null) : null,
|
||
pinStorable: host ? host.storable !== false : osSealUsable(safeStorageOr(api)),
|
||
pinUnified: !!hostPinApi(api),
|
||
pinLength: host && host.pinSet && Number(host.length) >= 6 && Number(host.length) <= 8 ? Number(host.length) : 6,
|
||
pinLegacyExposure: exposed && !exposed.dismissed ? { at: exposed.at } : null,
|
||
};
|
||
}
|
||
api.onMessage("pinExposureDismiss", (_p, m) => {
|
||
fromPanel(m);
|
||
const e = api.storage.get(PIN_EXPOSED_KEY, null);
|
||
if (e) api.storage.set(PIN_EXPOSED_KEY, { ...e, dismissed: Date.now() });
|
||
return true;
|
||
});
|
||
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 (!hasPinNow(api)) 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", async (p, m) => {
|
||
fromPanel(m);
|
||
await refreshHostPin(api);
|
||
const event = String(p && p.event || "panel-load");
|
||
return { ...pinNeeded(event), event, policy: pinPolicy() };
|
||
});
|
||
// The panel decrypts the PIN blob and hands over what was inside it. That
|
||
// is the vault master password, and the host checks it against the vault
|
||
// itself — so a gate is cleared by proof the host verified, not by the
|
||
// panel saying so. The same proof opens a single-use transaction
|
||
// clearance (see requirePanelTxPin / requireDappTxPin).
|
||
api.onMessage("pinGateSatisfied", async (p, m) => {
|
||
fromPanel(m);
|
||
const pw = String((p && p.masterPassword) || "");
|
||
if (!pw) throw new Error("PIN proof required");
|
||
if (!api.vault?.lifecycle || typeof api.vault.lifecycle.unlock !== "function") throw new Error("this build cannot verify a PIN");
|
||
try { await verifyPasswordOrProof(api, pw); }
|
||
catch { throw new Error("PIN proof rejected"); }
|
||
api.storage.set("aegis/pin/gate", { lastOkAt: Date.now(), bootId: BOOT_ID });
|
||
// What this proof clears (see requireDappTxPin).
|
||
const forRequest = String((p && p.requestId) || "");
|
||
if (forRequest) {
|
||
const req = pendingPinRequests.get(forRequest);
|
||
if (!req) throw new Error("that request is no longer waiting");
|
||
req.cleared = true;
|
||
} else if (String((p && p.event) || "") === "transaction") {
|
||
panelClearanceUntil = Date.now() + TX_CLEARANCE_MS;
|
||
}
|
||
return true;
|
||
});
|
||
// A panel opened while a dapp transaction is waiting for the PIN picks the
|
||
// request up here; one already open gets the "pinRequest" event instead.
|
||
api.onMessage("pinRequestPending", (_p, m) => {
|
||
fromPanel(m);
|
||
// The oldest waiting request that is not answered yet.
|
||
for (const r of pendingPinRequests.values()) if (!r.cleared) return { id: r.id, origin: r.origin, what: r.what, at: r.at };
|
||
return null;
|
||
});
|
||
ctx.pinNeeded = pinNeeded;
|
||
// Seal a plain blob left by an older build now, not when the panel next
|
||
// happens to open.
|
||
try { openPinBlob(api); } catch {}
|
||
|
||
api.onMessage("securityGet", async (_p, m) => {
|
||
fromPanel(m);
|
||
await refreshHostPin(api);
|
||
const cfg = api.storage.get("aegis/security/v1", {}) || {};
|
||
return {
|
||
hasPin: hasPinNow(api),
|
||
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,
|
||
...pinProtection(),
|
||
};
|
||
});
|
||
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: hasPinNow(api),
|
||
pinOn: pinPolicy(),
|
||
pinIntervalHours: PIN_INTERVAL_MS / 3600000,
|
||
requirePinForSending: !!next.requirePinForSending,
|
||
requirePinForReveal: next.requirePinForReveal !== false,
|
||
...pinProtection(),
|
||
};
|
||
});
|
||
|
||
// ---- 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", async (p, m) => {
|
||
fromPanel(m);
|
||
const pw = String(p && p.masterPassword || "");
|
||
if (!pw) throw new Error("master password required");
|
||
const ss = safeStorageOr(api);
|
||
// Same bar as the PIN: Linux's basic_text "keystore" is a constant key,
|
||
// i.e. the master password in the clear.
|
||
if (!osSealUsable(ss)) throw new Error("OS keystore unavailable — remember-me needs Windows DPAPI / macOS Keychain / libsecret");
|
||
// Store only a password the vault accepts.
|
||
try { await api.vault.lifecycle.unlock(pw); }
|
||
catch { throw new Error("wrong master password"); }
|
||
noteMasterVerified(api);
|
||
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: osSealUsable(ss),
|
||
};
|
||
}
|
||
|
||
// 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 (!osSealUsable(ss)) {
|
||
// Sealed under no real keystore: it is the master password in the
|
||
// clear on disk. Never use it; remove it.
|
||
api.storage.set("aegis/session/enc", null);
|
||
return;
|
||
}
|
||
try {
|
||
const pw = ss.decryptString(Buffer.from(blob.encPwB64, "base64"));
|
||
await api.vault.lifecycle.unlock(pw);
|
||
api.log("auto-unlocked via safeStorage session");
|
||
} catch (e) {
|
||
api.log("auto-unlock failed:", e?.message || e);
|
||
// Drop the stale blob so we don't retry every launch.
|
||
api.storage.set("aegis/session/enc", null);
|
||
}
|
||
}
|
||
|
||
// One "describePlan" is enough for both chains because plan() returns a
|
||
// common shape: {recipients:[{to,value}], fee, feeRate, total, inputs:[]…}.
|
||
function describePlan(plan) {
|
||
const sent = plan.recipients.reduce((a, r) => a + r.value, 0);
|
||
return {
|
||
recipients: plan.recipients, fee: plan.fee, feeRate: plan.feeRate,
|
||
inputs: (plan.inputs || []).length, change: plan.change || null,
|
||
total: plan.total != null ? plan.total : (sent + plan.fee),
|
||
};
|
||
}
|
||
|
||
// ---- dapp bridges (page → activate()) --------------------------------------
|
||
// Permission model stays the same as the single-wallet build for BCH:
|
||
// { [origin]: { readAddress:true, sendTx:{capSats,usedSats,grantedAt},
|
||
// trx: { readAddress:true, network } } }
|
||
// The BCH bridge always talks to the LEGACY default BCH wallet (the .x pages
|
||
// pre-date multi-wallet and cannot pick between them). The Tron bridge talks
|
||
// to the currently-SELECTED Tron wallet; if none is selected, requests fail.
|
||
const BCH_ALLOWANCES = [100000, 1000000, 10000000]; // 0.001, 0.01, 0.1 BCH
|
||
const pendingByOrigin = new Set();
|
||
function permissions(api) { const p = api.storage.get("permissions", {}); return p && typeof p === "object" ? p : {}; }
|
||
|
||
// ---- grants: session + persisted ------------------------------------------
|
||
// A plain "Connect" grants the origin for this browser session (memory only);
|
||
// ticking "Always allow" persists it under api.storage "permissions". Both
|
||
// look identical to the handlers via grantFor(). Scopes: "bch" (the
|
||
// window.bitcoincash bridge keeps its flat readAddress), "eth", "sol", "trx".
|
||
// ETH grants also carry the origin's chainId — chain is per origin, never a
|
||
// global the sidebar or another site can flip under a connected dapp.
|
||
// Before this, a plain "Connect" stored nothing, so the very next call from
|
||
// the site failed with "not connected".
|
||
const sessionGrants = new Map(); // origin -> { readAddress?, eth?, sol?, trx? }
|
||
function grantFor(api, origin, scope) {
|
||
const persisted = permissions(api)[origin] || {};
|
||
const session = sessionGrants.get(origin) || {};
|
||
if (scope === "bch") {
|
||
if (persisted.readAddress) return { readAddress: true, persisted: true };
|
||
if (session.readAddress) return { readAddress: true, persisted: false };
|
||
return null;
|
||
}
|
||
const p = persisted[scope], s = session[scope];
|
||
if (p && p.readAddress) return { ...p, persisted: true };
|
||
if (s && s.readAddress) return { ...s, persisted: false };
|
||
return null;
|
||
}
|
||
function setGrant(api, origin, scope, value, persist) {
|
||
const wrap = scope === "bch" ? value : { [scope]: value };
|
||
if (persist) {
|
||
const perms = permissions(api);
|
||
perms[origin] = { ...(perms[origin] || {}), ...wrap };
|
||
api.storage.set("permissions", perms);
|
||
syncInjectPolicy(api);
|
||
} else {
|
||
sessionGrants.set(origin, { ...(sessionGrants.get(origin) || {}), ...wrap });
|
||
}
|
||
emitState();
|
||
}
|
||
// Patch fields (e.g. chainId) on whichever grant the origin currently holds.
|
||
function patchGrant(api, origin, scope, patch) {
|
||
const g = grantFor(api, origin, scope);
|
||
if (!g) return false;
|
||
const { persisted, ...rest } = g;
|
||
setGrant(api, origin, scope, { ...rest, ...patch }, persisted);
|
||
return true;
|
||
}
|
||
// A connected site talks to the wallet the user approved for it — recorded
|
||
// as walletId + address in the grant — and to no other. The bridges used to
|
||
// resolve "the first ready wallet on this chain" or the sidebar selection on
|
||
// every call, so a site the user connected to wallet B was silently served
|
||
// wallet A, and selecting another wallet in the sidebar re-pointed every
|
||
// connected Solana/Tron site to it (and told it the new address).
|
||
// Grants made before this carry no walletId; they are bound to whatever
|
||
// they resolve to the first time they are used.
|
||
function boundRuntime(api, origin, scope, chain) {
|
||
const g = origin ? grantFor(api, origin, scope) : null;
|
||
if (!g || !g.walletId) return null;
|
||
const rt = ctx.runtimes.get(g.walletId);
|
||
if (!rt || rt.entry.chain !== chain || rt.phase !== "ready") {
|
||
throw new Error("the wallet this site was connected to is not available (removed or locked) — reconnect the site");
|
||
}
|
||
return rt;
|
||
}
|
||
function bindGrant(api, origin, scope, rt) {
|
||
const g = origin ? grantFor(api, origin, scope) : null;
|
||
if (g && !g.walletId) patchGrant(api, origin, scope, { walletId: rt.entry.id, address: rt.adapter.snapshot().address });
|
||
return rt;
|
||
}
|
||
// An unconnected site that asked for a chain (addChain / switch before
|
||
// connect) gets it remembered for its eventual eth_requestAccounts — no
|
||
// address is disclosed by this.
|
||
function rememberPendingChain(origin, chainId) {
|
||
const cur = sessionGrants.get(origin) || {};
|
||
sessionGrants.set(origin, { ...cur, eth: { ...(cur.eth || {}), chainId } });
|
||
}
|
||
function revokeOrigin(api, origin) {
|
||
const perms = permissions(api);
|
||
delete perms[origin];
|
||
api.storage.set("permissions", perms);
|
||
sessionGrants.delete(origin);
|
||
syncInjectPolicy(api);
|
||
// A revoked site keeps no WizardConnect pairing either.
|
||
if (ctx?.wc?.disconnectOrigin) ctx.wc.disconnectOrigin(origin).catch(() => {});
|
||
}
|
||
|
||
// ---- who can see the wallet ------------------------------------------------
|
||
// The bridges used to go into every https page, which tells every page that
|
||
// this browser carries a wallet (and which one) before the user has done
|
||
// anything. In "allowed" mode Theseus injects only into origins listed in
|
||
// the reserved storage key "__pageInjectPolicy" — it reads that key itself,
|
||
// at document start, even while Aegis has not been started yet. The list is
|
||
// the sites the user enabled plus every site with a saved connection, so
|
||
// turning the mode on never breaks a dapp already in use.
|
||
const INJECT_MODE_KEY = "aegis/inject/mode";
|
||
const INJECT_ALLOW_KEY = "aegis/inject/allow";
|
||
function injectAllowList(api) {
|
||
const v = api.storage.get(INJECT_ALLOW_KEY, []);
|
||
return Array.isArray(v) ? v.filter((o) => typeof o === "string") : [];
|
||
}
|
||
function injectMode(api) { return api.storage.get(INJECT_MODE_KEY, "all") === "allowed" ? "allowed" : "all"; }
|
||
function syncInjectPolicy(api) {
|
||
const origins = Array.from(new Set([...injectAllowList(api), ...Object.keys(permissions(api))]));
|
||
api.storage.set("__pageInjectPolicy", { mode: injectMode(api), origins });
|
||
}
|
||
function normalizeOrigin(raw) {
|
||
try {
|
||
const u = new URL(String(raw || "").replace(/^bns:\/\//i, "https://"));
|
||
if (u.protocol !== "https:" && u.protocol !== "http:") return null;
|
||
return `${u.protocol}//${u.host}`;
|
||
} catch { return null; }
|
||
}
|
||
// Panel view: persisted grants plus session-only ones. An origin with no
|
||
// persisted entry is flagged `session`; one with both gets the session
|
||
// scopes merged in under the persisted ones.
|
||
function grantsForPanel(api) {
|
||
const out = JSON.parse(JSON.stringify(permissions(api)));
|
||
for (const [origin, g] of sessionGrants) {
|
||
const hasGrant = g.readAddress || g.eth?.readAddress || g.sol?.readAddress || g.trx?.readAddress;
|
||
if (!hasGrant) continue;
|
||
out[origin] = out[origin] ? { ...g, ...out[origin] } : { ...g, session: true };
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Tron: only transactions Aegis itself signed may be broadcast through the
|
||
// bridge — a dapp can't use the wallet as a relay for foreign signatures.
|
||
const signedTronTxids = new Set();
|
||
function rememberSignedTron(txid) {
|
||
signedTronTxids.add(txid);
|
||
if (signedTronTxids.size > 200) signedTronTxids.delete(signedTronTxids.values().next().value);
|
||
}
|
||
|
||
// Dapp message bytes: personal_sign carries hex-encoded bytes (ethers, viem,
|
||
// wagmi); a plain string is UTF-8 (older dapps, our own panel).
|
||
function bytesFromDappMessage(raw) {
|
||
const s = raw == null ? "" : String(raw);
|
||
if (/^0x([0-9a-f]{2})*$/i.test(s)) return new Uint8Array(Buffer.from(s.slice(2), "hex"));
|
||
return new TextEncoder().encode(s);
|
||
}
|
||
// Overlay preview: the text if it is clean UTF-8, else a length + hex prefix.
|
||
function previewBytes(bytes, max = 400) {
|
||
let text = null;
|
||
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch {}
|
||
if (text != null && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text)) {
|
||
return previewText(text, Math.max(max, 1500));
|
||
}
|
||
return `<${bytes.length} bytes: 0x${Buffer.from(bytes.subarray(0, 30)).toString("hex")}${bytes.length > 30 ? "…" : ""}>`;
|
||
}
|
||
|
||
// ---- Solana message parsing ------------------------------------------------
|
||
const SOL_TOKEN_PROGRAMS = new Set([
|
||
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", // SPL Token
|
||
"TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", // Token-2022
|
||
]);
|
||
// Full wire: compact-u16(sigCount) || sig[64]*sigCount || message.
|
||
function splitSolWire(wire) {
|
||
let off = 0;
|
||
const compact = () => {
|
||
let n = 0, shift = 0;
|
||
for (;;) {
|
||
if (off >= wire.length) throw new Error("truncated tx wire");
|
||
const b = wire[off++];
|
||
n |= (b & 0x7f) << shift;
|
||
if ((b & 0x80) === 0) break;
|
||
shift += 7;
|
||
if (shift > 14) throw new Error("compact-u16 too long");
|
||
}
|
||
return n;
|
||
};
|
||
const sigCount = compact();
|
||
if (sigCount < 1 || sigCount > 32) throw new Error("bad signature count " + sigCount);
|
||
const sigsStart = off;
|
||
const messageStart = sigsStart + sigCount * 64;
|
||
if (wire.length < messageStart) throw new Error("truncated tx wire");
|
||
return { sigCount, sigsStart, messageBytes: wire.slice(messageStart) };
|
||
}
|
||
// Structural parse of a legacy or v0 message. `ok:false` means the bytes
|
||
// cannot be a message — used both to sign transactions and to REFUSE
|
||
// transaction-shaped payloads handed to signMessage.
|
||
function parseSolMessage(msg, ourPub) {
|
||
try {
|
||
let off = 0, version = null;
|
||
if (!(msg instanceof Uint8Array) || msg.length < 3 + 1 + 32 + 32) throw new Error("too short");
|
||
if (msg[0] & 0x80) {
|
||
version = msg[0] & 0x7f; off = 1;
|
||
if (version !== 0) throw new Error("unsupported message version " + version);
|
||
}
|
||
const numRequiredSigs = msg[off], numReadonlySigned = msg[off + 1], numReadonlyUnsigned = msg[off + 2];
|
||
off += 3;
|
||
const compact = () => {
|
||
let n = 0, shift = 0;
|
||
for (;;) {
|
||
if (off >= msg.length) throw new Error("truncated");
|
||
const b = msg[off++];
|
||
n |= (b & 0x7f) << shift;
|
||
if ((b & 0x80) === 0) break;
|
||
shift += 7;
|
||
if (shift > 14) throw new Error("bad compact-u16");
|
||
}
|
||
return n;
|
||
};
|
||
const keyCount = compact();
|
||
if (keyCount < 1 || keyCount > 256) throw new Error("bad account key count");
|
||
if (numRequiredSigs < 1 || numRequiredSigs > keyCount) throw new Error("bad header");
|
||
if (numReadonlySigned > numRequiredSigs || numReadonlyUnsigned > keyCount - numRequiredSigs) throw new Error("bad header");
|
||
if (msg.length < off + keyCount * 32 + 32) throw new Error("truncated keys");
|
||
const keys = [];
|
||
for (let i = 0; i < keyCount; i++) { keys.push(msg.subarray(off, off + 32)); off += 32; }
|
||
const blockhash = msg.subarray(off, off + 32); off += 32;
|
||
const ixCount = compact();
|
||
const instructions = [];
|
||
for (let i = 0; i < ixCount; i++) {
|
||
if (off >= msg.length) throw new Error("truncated instruction");
|
||
const programIdx = msg[off++];
|
||
const na = compact(); const accounts = [];
|
||
for (let k = 0; k < na; k++) { if (off >= msg.length) throw new Error("truncated accounts"); accounts.push(msg[off++]); }
|
||
const nd = compact();
|
||
if (off + nd > msg.length) throw new Error("truncated instruction data");
|
||
const data = msg.subarray(off, off + nd); off += nd;
|
||
instructions.push({ programIdx, accounts, data });
|
||
}
|
||
let lookupTables = 0;
|
||
if (version === 0) lookupTables = compact(); // static keys suffice for the signer checks
|
||
let ourIndex = -1;
|
||
if (ourPub) {
|
||
for (let i = 0; i < keyCount; i++) {
|
||
let eq = true;
|
||
for (let j = 0; j < 32; j++) if (keys[i][j] !== ourPub[j]) { eq = false; break; }
|
||
if (eq) { ourIndex = i; break; }
|
||
}
|
||
}
|
||
return { ok: true, version, numRequiredSigs, keys, blockhash, instructions, lookupTables, ourIndex, length: msg.length };
|
||
} catch (e) {
|
||
return { ok: false, error: e?.message || String(e) };
|
||
}
|
||
}
|
||
// Overlay rows: System transfers and SPL transfer/approve/set-authority are
|
||
// decoded; everything else is named by program so the user at least sees
|
||
// how much of the transaction Aegis could not read.
|
||
// Overlay rows for a Solana message, plus what makes it risky. The
|
||
// network fee is shown in full: a dapp-added ComputeBudget price is paid on
|
||
// top of the base fee and used to be invisible ("program ComputeB…: not
|
||
// decoded"), so a transaction could burn the balance in priority fees.
|
||
// `rows.risks` lists what deserves the danger button; `rows.feeLamports`
|
||
// is the most this transaction can cost in fees.
|
||
const SOL_COMPUTE_BUDGET = "ComputeBudget111111111111111111111111111111";
|
||
const SOL_SYSTEM = "11111111111111111111111111111111";
|
||
function solInstructionRows(parsed, balanceLamports = null) {
|
||
const b58 = (b) => ctx.d.base58check.encodeBase58(b);
|
||
const u64le = (d, o) => { let v = 0n; for (let i = 7; i >= 0; i--) v = (v << 8n) | BigInt(d[o + i] || 0); return v; };
|
||
const u32le = (d, o) => (d[o] | (d[o + 1] << 8) | (d[o + 2] << 16) | (d[o + 3] << 24)) >>> 0;
|
||
const ours = parsed.ourIndex >= 0 ? b58(parsed.keys[parsed.ourIndex]) : null;
|
||
const rows = [];
|
||
const risks = [];
|
||
let unitLimit = null, unitPrice = 0n, undecoded = 0, budgetIxs = 0;
|
||
const lines = parsed.instructions.map((ix, i) => {
|
||
if (ix.programIdx >= parsed.keys.length) { undecoded++; return `${i + 1}. program from a lookup table (not decoded)`; }
|
||
const progB58 = b58(parsed.keys[ix.programIdx]);
|
||
const acct = (n) => {
|
||
const idx = ix.accounts[n];
|
||
return idx == null ? "?" : (idx < parsed.keys.length ? b58(parsed.keys[idx]) : `lookup-table account #${idx}`);
|
||
};
|
||
const d = ix.data;
|
||
const n = i + 1;
|
||
if (progB58 === SOL_COMPUTE_BUDGET && d.length >= 1) {
|
||
budgetIxs++;
|
||
if (d[0] === 2 && d.length >= 5) { unitLimit = u32le(d, 1); return `${n}. Compute limit ${unitLimit.toLocaleString("en-US")} units`; }
|
||
if (d[0] === 3 && d.length >= 9) { unitPrice = u64le(d, 1); return `${n}. Priority fee ${unitPrice.toString()} micro-lamports per unit`; }
|
||
if (d[0] === 1) return `${n}. Heap frame request`;
|
||
if (d[0] === 4) return `${n}. Account data limit`;
|
||
return `${n}. Compute budget (${d.length} bytes, not decoded)`;
|
||
}
|
||
if (progB58 === SOL_SYSTEM && d.length >= 4) {
|
||
const t = u32le(d, 0);
|
||
if (t === 2 && d.length >= 12) return `${n}. System transfer ${fmtTokenAmount(u64le(d, 4), 9)} SOL → ${acct(1)}`;
|
||
if (t === 0 && d.length >= 12) return `${n}. Create account ${acct(1)} funded with ${fmtTokenAmount(u64le(d, 4), 9)} SOL`;
|
||
if (t === 11 && d.length >= 12) return `${n}. System transfer ${fmtTokenAmount(u64le(d, 4), 9)} SOL → ${acct(2)}`;
|
||
if (t === 1 || t === 10) {
|
||
risks.push("reassigns an account to another program");
|
||
return `${n}. ASSIGN account ${acct(0)} to another program — it hands over control of that account`;
|
||
}
|
||
if (t === 4) {
|
||
risks.push("uses a durable nonce: the signed transaction never expires and can be sent whenever the site likes");
|
||
return `${n}. Advance durable nonce — this signature stays valid indefinitely`;
|
||
}
|
||
if (t === 8 || t === 9) return `${n}. Allocate account space`;
|
||
undecoded++;
|
||
return `${n}. System instruction ${t} (not decoded)`;
|
||
}
|
||
if (SOL_TOKEN_PROGRAMS.has(progB58) && d.length >= 1) {
|
||
const t = d[0];
|
||
if (t === 3 && d.length >= 9) return `${n}. Token transfer ${u64le(d, 1).toString()} units → ${acct(1)}`;
|
||
if (t === 12 && d.length >= 10) return `${n}. Token transfer ${fmtTokenAmount(u64le(d, 1), d[9])} → ${acct(2)}`;
|
||
if ((t === 4 && d.length >= 9) || (t === 13 && d.length >= 10)) {
|
||
risks.push("approves another account to spend tokens");
|
||
const amt = t === 13 ? fmtTokenAmount(u64le(d, 1), d[9]) : u64le(d, 1).toString() + " units";
|
||
return `${n}. Token APPROVE: delegate ${acct(t === 13 ? 2 : 1)} may spend ${amt}`;
|
||
}
|
||
if (t === 6) { risks.push("changes a token account's authority"); return `${n}. Token SET AUTHORITY on ${acct(0)} — hands the account to someone else`; }
|
||
if (t === 9) {
|
||
const dest = acct(1);
|
||
if (dest !== ours) risks.push("closes a token account and sends its SOL to someone else");
|
||
return `${n}. CLOSE token account ${acct(0)}; its SOL goes to ${dest === ours ? "you" : dest}`;
|
||
}
|
||
if (t === 5) return `${n}. Revoke a token delegate`;
|
||
if (t === 8 || t === 15) return `${n}. BURN tokens from ${acct(0)}`;
|
||
if (t === 1 || t === 16 || t === 18) return `${n}. Initialize token account ${acct(0)}`;
|
||
undecoded++;
|
||
return `${n}. Token instruction ${t} (not decoded)`;
|
||
}
|
||
undecoded++;
|
||
return `${n}. program ${progB58.slice(0, 8)}…: ${d.length} bytes, ${ix.accounts.length} account${ix.accounts.length === 1 ? "" : "s"} (not decoded)`;
|
||
});
|
||
rows.push({ label: parsed.instructions.length === 1 ? "Instruction" : "Instructions", value: lines.join("\n") || "(none)", mono: true });
|
||
// Fee: 5000 lamports per signature, plus limit × price. With no explicit
|
||
// limit the runtime allows 200k units per non-budget instruction, up to 1.4M.
|
||
const limit = BigInt(unitLimit != null ? Math.min(unitLimit, 1_400_000) : Math.min(1_400_000, 200_000 * Math.max(1, parsed.instructions.length - budgetIxs)));
|
||
const priority = (limit * unitPrice + 999_999n) / 1_000_000n;
|
||
const feeLamports = 5000n * BigInt(Math.max(1, parsed.numRequiredSigs)) + priority;
|
||
rows.push({ label: "Network fee (max)", value: `${fmtTokenAmount(feeLamports, 9)} SOL${priority > 0n ? ` (incl. ${fmtTokenAmount(priority, 9)} SOL priority fee set by the site)` : ""}`, strong: priority > 0n });
|
||
const bal = balanceLamports != null ? BigInt(balanceLamports) : null;
|
||
if (priority > 50_000_000n || (bal != null && bal > 0n && feeLamports * 10n > bal)) {
|
||
risks.push(`carries an unusually high network fee (${fmtTokenAmount(feeLamports, 9)} SOL)`);
|
||
}
|
||
if (parsed.version === 0) {
|
||
rows.push({ label: "Format", value: `v0 · ${parsed.lookupTables} address-lookup table${parsed.lookupTables === 1 ? "" : "s"} (accounts inside them are not shown)` });
|
||
}
|
||
const others = parsed.numRequiredSigs - 1;
|
||
rows.push({ label: "Signers", value: others ? `${parsed.numRequiredSigs} — you (slot #${parsed.ourIndex}) + ${others} other${others === 1 ? "" : "s"}` : "1 — you" });
|
||
if (risks.length) rows.unshift({ label: "Warning", value: [...new Set(risks)].map((r) => "• This transaction " + r + ".").join("\n"), strong: true });
|
||
rows.risks = risks;
|
||
rows.undecoded = undecoded;
|
||
rows.feeLamports = feeLamports;
|
||
return rows;
|
||
}
|
||
|
||
const ETH_RPC_PRECONNECT = new Set(["eth_chainId", "net_version", "eth_blockNumber"]);
|
||
const ETH_RPC_NEVER = /^(eth_sign|eth_signTransaction|eth_sendTransaction|eth_accounts|eth_requestAccounts|eth_coinbase|eth_signTypedData|eth_newFilter|eth_newBlockFilter|eth_newPendingTransactionFilter|eth_uninstallFilter|eth_getFilterChanges|eth_getFilterLogs|eth_subscribe|eth_unsubscribe|eth_mining|eth_submit|eth_getWork)/;
|
||
|
||
// EIP-2612 Permit, DAI-style permit, and Uniswap Permit2 (PermitSingle /
|
||
// PermitBatch / PermitTransferFrom / PermitBatchTransferFrom). Returns the
|
||
// overlay rows that say who may spend what until when, or null when the
|
||
// typed data is not a spending approval.
|
||
// What the signature actually covers. The EIP-712 encoder reads only the
|
||
// fields each type declares; anything else in `message` is ignored by the
|
||
// digest but was read by the overlay, so a dapp could add a decoy
|
||
// `allowed: false` or `details: {amount: "1"}` beside an unlimited permit and
|
||
// have the overlay show the decoy. Everything shown is built from this view.
|
||
function signedView(td) {
|
||
const types = (td && typeof td.types === "object" && td.types) || {};
|
||
let dropped = 0;
|
||
const walk = (type, v) => {
|
||
const arr = /\[\d*\]$/.exec(type);
|
||
if (arr) {
|
||
const inner = type.slice(0, arr.index);
|
||
return Array.isArray(v) ? v.map((x) => walk(inner, x)) : v;
|
||
}
|
||
const fields = types[type];
|
||
if (!Array.isArray(fields)) return v; // atomic
|
||
const src = (v && typeof v === "object") ? v : {};
|
||
for (const k of Object.keys(src)) if (!fields.some((f) => f && f.name === k)) dropped++;
|
||
const out = {};
|
||
for (const f of fields) if (f && typeof f.name === "string") out[f.name] = walk(String(f.type), src[f.name]);
|
||
return out;
|
||
};
|
||
return { message: walk(String(td?.primaryType || ""), td?.message), dropped };
|
||
}
|
||
|
||
// The chain check applies only when the domain actually signs a chainId.
|
||
function domDeclaredEarly(td) {
|
||
return Array.isArray(td?.types?.EIP712Domain) && td.types.EIP712Domain.some((x) => x && x.name === "chainId");
|
||
}
|
||
// What a non-Permit typed-data signature can do, judged from its declared
|
||
// structure rather than its name. The name list (Seaport, SafeTx, …) missed
|
||
// every marketplace, relayer and account-abstraction type it did not know,
|
||
// and those got a primary "Sign" button and no PIN. Here every signed field
|
||
// is listed with its type, and the payload counts as asset-moving when it
|
||
// names an address other than the user's and the verifying contract
|
||
// together with an amount-like number, or carries raw bytes (an arbitrary
|
||
// call). Text-only payloads (logins, votes) stay ordinary.
|
||
function assessTypedData(td, view, ownAddress) {
|
||
const types = (td && typeof td.types === "object" && td.types) || {};
|
||
const own = String(ownAddress || "").toLowerCase();
|
||
const vc = String(td?.domain?.verifyingContract || "").toLowerCase();
|
||
const fields = [];
|
||
let foreignAddr = false, amount = false, rawBytes = false, numbers = false, addresses = false;
|
||
const AMOUNTISH = /amount|value|price|wad|qty|quantity|fee|tip|salary|reward|consideration|offer|balance|allowance/i;
|
||
const DEADLINEISH = /deadline|expir|valid(to|until|before)|endtime|end_time|until/i;
|
||
const walk = (type, v, path, depth) => {
|
||
if (fields.length > 200 || depth > 8) return;
|
||
const arr = /^(.+)\[(\d*)\]$/.exec(type);
|
||
if (arr) { (Array.isArray(v) ? v : []).forEach((x, i) => walk(arr[1], x, `${path}[${i}]`, depth + 1)); return; }
|
||
if (Array.isArray(types[type])) { for (const fd of types[type]) if (fd && typeof fd.name === "string") walk(String(fd.type), v && typeof v === "object" ? v[fd.name] : undefined, path ? `${path}.${fd.name}` : fd.name, depth + 1); return; }
|
||
const name = path.split(".").pop().replace(/\[\d+\]$/, "");
|
||
let shown = String(v);
|
||
if (type === "address") {
|
||
addresses = true;
|
||
const a = String(v).toLowerCase();
|
||
if (a !== own && a !== vc && !/^0x0{40}$/.test(a)) foreignAddr = true;
|
||
shown = String(v) + (a === own ? " (you)" : a === vc ? " (the verifying contract)" : "");
|
||
} else if (/^u?int\d*$/.test(type)) {
|
||
numbers = true;
|
||
let n = null; try { n = BigInt(v); } catch {}
|
||
if (n != null) {
|
||
if (DEADLINEISH.test(name) && n > 0n) shown = n >= 4102444800n ? `${n} (never expires in practice)` : `${n} (${new Date(Number(n) * 1000).toISOString().slice(0, 16).replace("T", " ")} UTC)`;
|
||
else if (n >= (1n << 96n)) { shown = `${n} (≈ unlimited)`; amount = true; }
|
||
else shown = n.toString();
|
||
if (AMOUNTISH.test(name) && n > 0n) amount = true;
|
||
}
|
||
} else if (type === "bytes") {
|
||
const h = String(v || "").replace(/^0x/i, "");
|
||
if (h.length) rawBytes = true;
|
||
shown = h.length ? `0x${h.slice(0, 64)}${h.length > 64 ? "…" : ""} (${h.length / 2} bytes)` : "(empty)";
|
||
} else if (type === "string") {
|
||
shown = JSON.stringify(plainLabel(v, 200));
|
||
}
|
||
fields.push(`${path}: ${shown} [${type}]`);
|
||
};
|
||
walk(String(td?.primaryType || ""), view.message, "", 0);
|
||
const movesAssets = (foreignAddr && amount) || rawBytes;
|
||
return { fields, movesAssets, plain: !addresses && !numbers };
|
||
}
|
||
|
||
function describePermit(td) {
|
||
const primary = String(td?.primaryType || "");
|
||
if (!/^Permit/.test(primary)) return null;
|
||
const msg = signedView(td).message || {};
|
||
const declared = (t) => (Array.isArray(td?.types?.[t]) ? td.types[t].map((f) => f && f.name) : []);
|
||
const pf = declared(primary);
|
||
const has = (...names) => names.every((n) => pf.includes(n));
|
||
const UNLIMITED = 1n << 159n; // ≥ half of uint160 covers Permit2's max and every uint256 max
|
||
// No token has a supply anywhere near this, so an amount this large is an
|
||
// unlimited approval in all but name (2^96 ≈ 7.9e28 base units: 79 billion
|
||
// tokens at 18 decimals).
|
||
const HUGE = 1n << 96n;
|
||
const big = (v) => { try { return BigInt(v); } catch { return null; } };
|
||
const amountText = (v) => {
|
||
const b = big(v);
|
||
if (b === null) return String(v);
|
||
if (b >= UNLIMITED) return "UNLIMITED (∞)";
|
||
return b.toString() + " units" + (b >= HUGE ? " (effectively unlimited)" : "");
|
||
};
|
||
const when = (v) => {
|
||
const b = big(v);
|
||
if (b === null) return String(v);
|
||
if (b >= 4102444800n) return "never expires"; // 2100-01-01 and beyond
|
||
try { return new Date(Number(b) * 1000).toISOString().replace("T", " ").slice(0, 16) + " UTC"; } catch { return b.toString(); }
|
||
};
|
||
const rows = [];
|
||
let risky = false;
|
||
const spender = has("spender") ? msg.spender : null;
|
||
rows.push({ label: "Spender", value: spender ? String(spender) : "(none named — anyone holding this signature)", mono: true, strong: true });
|
||
if (!spender) risky = true;
|
||
const items = [];
|
||
// The variant is chosen by the declared type, never by what the message
|
||
// happens to carry.
|
||
if (has("holder", "spender", "nonce", "expiry", "allowed")) {
|
||
// DAI-style: read the bool exactly as lib/eip712.js encodes it.
|
||
const allowed = msg.allowed === true || msg.allowed === 1 || msg.allowed === "true" || msg.allowed === "1";
|
||
items.push({ token: td.domain?.verifyingContract, amount: allowed ? UNLIMITED : 0n });
|
||
// DAI treats expiry 0 as "no expiry", not 1970.
|
||
rows.push({ label: "Valid until", value: big(msg.expiry) === 0n ? "never expires (expiry 0)" : when(msg.expiry) });
|
||
} else if (has("owner", "spender", "value", "deadline")) {
|
||
// EIP-2612.
|
||
items.push({ token: td.domain?.verifyingContract, amount: msg.value });
|
||
rows.push({ label: "Valid until", value: when(msg.deadline) });
|
||
} else if (has("details", "spender", "sigDeadline")) {
|
||
// Permit2 PermitSingle / PermitBatch.
|
||
const list = Array.isArray(msg.details) ? msg.details : [msg.details];
|
||
for (const d of list) items.push({ token: d?.token, amount: d?.amount, expiration: d?.expiration });
|
||
rows.push({ label: "Signature valid until", value: when(msg.sigDeadline) });
|
||
} else if (has("permitted", "spender", "deadline")) {
|
||
// Permit2 PermitTransferFrom / PermitBatchTransferFrom (and *Witness*).
|
||
const list = Array.isArray(msg.permitted) ? msg.permitted : [msg.permitted];
|
||
for (const d of list) items.push({ token: d?.token, amount: d?.amount });
|
||
rows.push({ label: "Signature valid until", value: when(msg.deadline) });
|
||
}
|
||
items.slice(0, 10).forEach((it, i) => {
|
||
const b = big(it.amount);
|
||
if (b === null || b >= HUGE) risky = true;
|
||
rows.push({
|
||
label: items.length > 1 ? `Token #${i + 1}` : "Token",
|
||
value: `${it.token || "?"} — ${amountText(it.amount)}${it.expiration != null ? ` · allowance ${when(it.expiration)}` : ""}`,
|
||
mono: true,
|
||
});
|
||
});
|
||
if (items.length > 10) { rows.push({ label: "Tokens", value: `… and ${items.length - 10} more` }); risky = true; }
|
||
if (!items.length) { rows.push({ label: "Token", value: "(not a permit shape Aegis can read — treat as unlimited)" }); risky = true; }
|
||
return { rows, risky };
|
||
}
|
||
|
||
async function withOriginLock(origin, fn) {
|
||
if (pendingByOrigin.has(origin)) throw new Error("a wallet request from this site is already waiting for approval");
|
||
pendingByOrigin.add(origin);
|
||
try { return await fn(); } finally { pendingByOrigin.delete(origin); }
|
||
}
|
||
|
||
// Only .x sites (BCNR-native TLD) get the BCH bridge, matching the pre-
|
||
// multi-wallet gate. Widening the manifest to https://*/* makes the Tron
|
||
// bridge available everywhere.
|
||
// Any ordinary web origin may talk to the BCH bridge. The old test here
|
||
// required a ".x" hostname, which locked out every third-party BCH dapp
|
||
// (Cauldron, bch.guru) and our own dapps on other TLDs — while the TRX / ETH
|
||
// / SOL bridges and WizardConnect accepted any origin all along. The real
|
||
// protection is downstream and unchanged: an approval overlay on every
|
||
// action plus per-origin permissions. This only keeps non-web schemes out.
|
||
function isDappOrigin(origin) {
|
||
try { const u = new URL(origin); return u.protocol === "https:" || u.protocol === "http:"; }
|
||
catch { return false; }
|
||
}
|
||
function legacyBchRuntime() {
|
||
const rt = ctx.runtimes.get(LEGACY_BCH_WALLET_ID);
|
||
if (!rt || rt.phase !== "ready" || !rt.adapter) throw new Error("wallet is not ready (vault locked?)");
|
||
return rt;
|
||
}
|
||
// The Tron bridge routes to the currently-selected wallet if it is Tron;
|
||
// otherwise it looks for the first ready Tron wallet on the selected network
|
||
// hint; else rejects with "no tron wallet".
|
||
function activeTronRuntime(origin) {
|
||
const api = ctx.api;
|
||
const bound = boundRuntime(api, origin, "trx", "trx");
|
||
if (bound) return bound;
|
||
const selId = selectedWalletId();
|
||
const selRt = selId && ctx.runtimes.get(selId);
|
||
if (selRt && selRt.entry.chain === "trx" && selRt.phase === "ready") return bindGrant(api, origin, "trx", selRt);
|
||
for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "trx" && rt.phase === "ready") return bindGrant(api, origin, "trx", rt);
|
||
throw new Error("no Tron wallet available — add one in the Aegis sidebar");
|
||
}
|
||
|
||
function registerPageMessages(api) {
|
||
// ---- BCH bridge (unchanged behavior; wallet source is legacy default) ----
|
||
api.onMessage("getAddress", async (_p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!isDappOrigin(origin)) throw new Error("this page cannot use the wallet bridge");
|
||
const rt = legacyBchRuntime();
|
||
if (grantFor(api, origin, "bch")) return rt.adapter.current().address;
|
||
return withOriginLock(origin, async () => {
|
||
const pick = await api.approvalModal({
|
||
title: "Share your Bitcoin Cash address?",
|
||
origin,
|
||
body: "The site will see your current receiving address and can look up its balance and history on the public chain.",
|
||
rows: [{ label: "Address", value: rt.adapter.current().address, mono: true }],
|
||
actions: [{ id: "allow", label: "Share", primary: true }],
|
||
checkbox: { id: "always", label: "Always allow this site to see my address" },
|
||
});
|
||
if (!pick.startsWith("allow")) throw new Error("user rejected");
|
||
setGrant(api, origin, "bch", { readAddress: true }, pick === "allow+always");
|
||
return rt.adapter.current().address;
|
||
});
|
||
});
|
||
api.onMessage("signAndSend", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!isDappOrigin(origin)) throw new Error("this page cannot use the wallet bridge");
|
||
const rt = legacyBchRuntime();
|
||
return withOriginLock(origin, async () => {
|
||
let plan;
|
||
try { plan = rt.adapter.plan(p || {}); }
|
||
catch (e) { throw new Error(/insufficient funds|too small/i.test(e?.message) ? "insufficient funds" : e?.message || String(e)); }
|
||
const d = describePlan(plan);
|
||
if (d.recipients.length > 8) throw new Error("too many outputs");
|
||
const perms = permissions(api);
|
||
const budget = perms[origin] && perms[origin].sendTx;
|
||
const remaining = budget ? Math.max(0, (budget.capSats | 0) - (budget.usedSats | 0)) : 0;
|
||
// The PIN wait below can last two minutes, during which the user may
|
||
// revoke the site or other permissions may change. Everything after it
|
||
// re-reads permissions and changes only this origin's sendTx; writing
|
||
// back the snapshot taken here used to restore a revoked allowance and
|
||
// wipe any other change made meanwhile.
|
||
const patchSendTx = (fn) => {
|
||
const now = permissions(api);
|
||
const cur = { ...(now[origin] || {}) };
|
||
const next = fn(cur.sendTx);
|
||
if (!next && !now[origin]) return; // revoked meanwhile: nothing to change
|
||
if (next) cur.sendTx = next; else delete cur.sendTx;
|
||
now[origin] = cur;
|
||
api.storage.set("permissions", now);
|
||
};
|
||
// A payment carrying data for the chain is never silent.
|
||
if (budget && d.total <= remaining && !plan.memo) {
|
||
// An allowance skips the overlay, not the PIN the user asked for on
|
||
// every transaction.
|
||
await requireDappTxPin(origin, "Bitcoin Cash payment");
|
||
const fresh = (permissions(api)[origin] || {}).sendTx;
|
||
const left = fresh ? Math.max(0, (fresh.capSats | 0) - (fresh.usedSats | 0)) : 0;
|
||
if (!fresh || fresh.grantedAt !== budget.grantedAt || d.total > left) throw new Error("this site's allowance changed while waiting for the PIN; ask again");
|
||
const r = await rt.adapter.signAndBroadcast(plan);
|
||
patchSendTx((t) => (t ? { ...t, usedSats: (t.usedSats | 0) + d.total } : t));
|
||
emitState();
|
||
api.log(`silent send ${d.total} sat for ${origin}, ${left - 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" });
|
||
// The site's OP_RETURN data is written on-chain under this payment.
|
||
if (plan.memo) rows.push({ label: "Data (OP_RETURN)", value: previewText(plan.memo, 220), mono: true });
|
||
const pick = await api.approvalModal({
|
||
title: "Send Bitcoin Cash?",
|
||
origin,
|
||
body: budget
|
||
? `This payment is over what is left of the site's allowance (${fmtBch(remaining)} BCH). Check the address and amount.`
|
||
: "This site is asking your wallet to pay. Check the address and amount.",
|
||
rows,
|
||
actions: [{ id: "send", label: "Send", primary: true }],
|
||
select: {
|
||
id: "cap", label: "Afterwards",
|
||
options: [{ value: "", label: "ask every time" }, ...BCH_ALLOWANCES.map((s) => ({ value: String(s), label: `allow up to ${fmtBch(s)} BCH more without asking` }))],
|
||
},
|
||
});
|
||
const [action, ...flags] = pick.split("+");
|
||
if (action !== "send") throw new Error("user rejected");
|
||
await requireDappTxPin(origin, "Bitcoin Cash payment");
|
||
const cap = flags.find((f) => f.startsWith("cap="));
|
||
const capSats = cap ? Number(cap.slice(4)) : 0;
|
||
if (BCH_ALLOWANCES.includes(capSats)) patchSendTx(() => ({ capSats, usedSats: 0, grantedAt: Date.now() }));
|
||
else if (budget) patchSendTx(() => null);
|
||
emitState();
|
||
const r = await rt.adapter.signAndBroadcast(plan);
|
||
return { txid: r.txid };
|
||
});
|
||
});
|
||
api.onMessage("signMessage", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!isDappOrigin(origin)) throw new Error("this page cannot use the wallet bridge");
|
||
const rt = legacyBchRuntime();
|
||
const message = String(p && p.message != null ? p.message : "");
|
||
if (message.length > 4096) throw new Error("message too long");
|
||
return withOriginLock(origin, async () => {
|
||
const pick = await api.approvalModal({
|
||
title: "Sign a message?",
|
||
origin,
|
||
body: "Signing proves you control the address below. It moves no coins.",
|
||
rows: [
|
||
{ label: "Message", value: previewText(message), mono: true },
|
||
{ label: "Address", value: rt.adapter.current().address, mono: true },
|
||
],
|
||
actions: [{ id: "sign", label: "Sign", primary: true }],
|
||
});
|
||
if (pick !== "sign") throw new Error("user rejected");
|
||
return rt.adapter.signMessage(message);
|
||
});
|
||
});
|
||
// BCH message verification (BIP-137). Panel-only path: given a message,
|
||
// a base64 signature, and an address, return { valid, address,
|
||
// recoveredHash }. No approval modal (nothing spendable happens), no
|
||
// wallet lookup — pure crypto against the given address.
|
||
// Panel → BCMR resolver. Batched: pass an array of category hex strings,
|
||
// get back { <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(origin);
|
||
const snap = rt.adapter.snapshot();
|
||
if (grantFor(api, origin, "trx")) return { code: 200, address: snap.address, network: snap.network };
|
||
return withOriginLock(origin, async () => {
|
||
const pick = await api.approvalModal({
|
||
title: "Connect this site to your Tron wallet?",
|
||
origin,
|
||
body: "The site will see this address and can build transactions for you to sign.",
|
||
rows: [
|
||
{ label: "Address", value: snap.address, mono: true },
|
||
{ label: "Network", value: snap.network === "nile" ? "Nile testnet" : "Tron mainnet" },
|
||
{ label: "Wallet", value: `${rt.entry.label} — Tron · ${snap.network === "nile" ? "Nile testnet" : "Mainnet"}` },
|
||
],
|
||
actions: [{ id: "allow", label: "Connect", primary: true }],
|
||
checkbox: { id: "always", label: "Always allow this site to see this address" },
|
||
});
|
||
if (!pick.startsWith("allow")) throw new Error("user rejected");
|
||
setGrant(api, origin, "trx", { readAddress: true, network: snap.network, walletId: rt.entry.id, address: snap.address }, pick === "allow+always");
|
||
return { code: 200, address: snap.address, network: snap.network };
|
||
});
|
||
});
|
||
api.onMessage("trx.getAccount", (_p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!grantFor(api, origin, "trx")) throw new Error("not connected — call tron_requestAccounts first");
|
||
const rt = activeTronRuntime(origin);
|
||
const snap = rt.adapter.snapshot();
|
||
return { address: snap.address, network: snap.network };
|
||
});
|
||
// Sign an arbitrary raw_data_hex the dapp built (with its own tronWeb).
|
||
// Everything the overlay shows is decoded from raw_data_hex — the bytes
|
||
// that get signed. The overlay used to read the dapp's raw_data JSON,
|
||
// which can say "1 TRX to X" over bytes that do something else entirely.
|
||
api.onMessage("trx.signTransaction", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
const rt = activeTronRuntime(origin);
|
||
if (!grantFor(api, origin, "trx")) throw new Error("not connected — call tron_requestAccounts first");
|
||
const tx = p && p.transaction;
|
||
if (!tx || typeof tx !== "object" || !tx.raw_data_hex) throw new Error("bad transaction");
|
||
// The dapp's raw_data JSON and txID are cross-checked, never trusted.
|
||
let decoded;
|
||
try { decoded = ctx.d.tronDecode.decodeRawData(tx.raw_data_hex); }
|
||
catch (e) { throw new Error("cannot decode raw_data_hex: " + (e?.message || e)); }
|
||
if (tx.txID && String(tx.txID).toLowerCase() !== decoded.txid) throw new Error("txID does not match raw_data_hex");
|
||
const me = rt.adapter.address;
|
||
for (const c of decoded.contracts) {
|
||
if (!c.owner) throw new Error(`cannot read the owner of ${c.typeName}; refusing to sign`);
|
||
if (c.owner !== me) throw new Error(`transaction owner ${c.owner} is not this wallet`);
|
||
// A known method with missing arguments would show "undefined".
|
||
if (c.call && c.call.malformed) throw new Error(`the ${c.call.name} call is truncated; refusing to sign`);
|
||
}
|
||
return withOriginLock(origin, async () => {
|
||
const rows = [];
|
||
decoded.contracts.forEach((c, i) => {
|
||
const n = decoded.contracts.length > 1 ? ` #${i + 1}` : "";
|
||
rows.push({ label: "Type" + n, value: c.typeName + (c.dangerous ? " — grants control to another account" : "") });
|
||
if (c.type === 1) {
|
||
rows.push({ label: "To", value: c.to || "(none)", mono: true });
|
||
rows.push({ label: "Amount", value: `${fmtTrx(Number(c.amount))} TRX`, strong: true });
|
||
} else if (c.type === 2) {
|
||
rows.push({ label: "To", value: c.to || "(none)", mono: true });
|
||
rows.push({ label: "Amount", value: `${String(c.amount)} units of asset ${c.assetName || "?"}`, strong: true });
|
||
} else if (c.type === 31) {
|
||
rows.push({ label: "Contract", value: c.contract || "(none)", mono: true });
|
||
const call = c.call;
|
||
if (call && call.name === "transfer") rows.push({ label: "Call", value: `transfer ${call.unlimited ? "UNLIMITED" : String(call.amount)} token units to ${call.to}`, strong: true });
|
||
else if (call && (call.name === "approve" || call.name === "increaseAllowance")) rows.push({ label: "Call", value: `${call.name}: let ${call.spender} spend ${call.unlimited ? "UNLIMITED (∞)" : String(call.amount)} token units`, strong: true });
|
||
else if (call && call.name === "transferFrom") rows.push({ label: "Call", value: `transferFrom ${call.from} → ${call.to}: ${call.unlimited ? "UNLIMITED" : String(call.amount)} units`, strong: true });
|
||
else rows.push({ label: "Call", value: call && call.selector ? `unknown method 0x${call.selector} (${c.data.length / 2} bytes, not decoded)` : "(no call data)", mono: true });
|
||
if (c.callValue) rows.push({ label: "TRX attached", value: `${fmtTrx(Number(c.callValue))} TRX` });
|
||
if (c.tokenValue) rows.push({ label: "TRC10 attached", value: `${String(c.tokenValue)} units of token #${String(c.tokenId ?? "?")}`, strong: true });
|
||
} else {
|
||
rows.push({ label: "Details", value: "not decoded — Aegis cannot show what this transaction does", strong: true });
|
||
}
|
||
});
|
||
if (decoded.feeLimit != null) rows.push({ label: "Fee limit", value: `${fmtTrx(Number(decoded.feeLimit))} TRX` });
|
||
if (decoded.memo) rows.push({ label: "Memo", value: previewText(decoded.memo, 200), mono: true });
|
||
if (decoded.expiration) {
|
||
const left = decoded.expiration - Date.now();
|
||
rows.push({ label: "Valid until", value: new Date(decoded.expiration).toISOString().replace("T", " ").slice(0, 16) + " UTC" + (left > 24 * 3600e3 ? " — unusually long; the site can broadcast it any time until then" : "") });
|
||
}
|
||
rows.push({ label: "Tx ID", value: decoded.txid, mono: true });
|
||
rows.push({ label: "Wallet", value: `${rt.entry.label} — Tron · ${rt.adapter.snapshot().network === "nile" ? "Nile testnet" : "Mainnet"}` });
|
||
const risky = decoded.contracts.some((c) => c.dangerous || c.undecoded || (c.tokenValue && c.tokenValue > 0n) || (c.call && c.call.unlimited))
|
||
|| (decoded.expiration && decoded.expiration - Date.now() > 24 * 3600e3);
|
||
const pick = await api.approvalModal({
|
||
title: "Sign a Tron transaction?",
|
||
origin,
|
||
body: risky
|
||
? "WARNING: this transaction does something Aegis flags as risky (open-ended control over funds, tokens sent along with a call, a type Aegis cannot read, or a very long validity). Only sign if you fully trust this site."
|
||
: "Decoded from the bytes that will be signed. Check the type, amount and destination.",
|
||
rows,
|
||
actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }],
|
||
});
|
||
if (pick !== "sign") throw new Error("user rejected");
|
||
await requireDappTxPin(origin, "Tron transaction");
|
||
const sig = rt.adapter.signRawData(tx.raw_data_hex);
|
||
rememberSignedTron(decoded.txid);
|
||
return { ...tx, txID: decoded.txid, signature: [sig] };
|
||
});
|
||
});
|
||
api.onMessage("trx.sendRawTransaction", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
const rt = activeTronRuntime(origin);
|
||
if (!grantFor(api, origin, "trx")) throw new Error("not connected — call tron_requestAccounts first");
|
||
const signedTx = p && p.transaction;
|
||
if (!signedTx || !signedTx.raw_data_hex || !Array.isArray(signedTx.signature)) throw new Error("bad signed tx");
|
||
// No approval here — the sign step carried it. But only what Aegis
|
||
// signed goes out: the bridge is not a relay for foreign signatures.
|
||
let txid;
|
||
try { txid = ctx.d.tronDecode.decodeRawData(signedTx.raw_data_hex).txid; }
|
||
catch (e) { throw new Error("cannot decode raw_data_hex: " + (e?.message || e)); }
|
||
if (!signedTronTxids.has(txid)) throw new Error("refusing to broadcast a transaction this wallet did not sign");
|
||
return rt.adapter.broadcastSignedTx({ ...signedTx, txID: txid });
|
||
});
|
||
api.onMessage("trx.signMessageV2", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
const rt = activeTronRuntime(origin);
|
||
if (!grantFor(api, origin, "trx")) throw new Error("not connected — call tron_requestAccounts first");
|
||
const message = String(p && p.message != null ? p.message : "");
|
||
if (message.length > 4096) throw new Error("message too long");
|
||
return withOriginLock(origin, async () => {
|
||
const snap = rt.adapter.snapshot();
|
||
const pick = await api.approvalModal({
|
||
title: "Sign a Tron message?",
|
||
origin,
|
||
body: "Signing proves you control this address. It moves no coins.",
|
||
rows: [
|
||
{ label: "Message", value: previewText(message), mono: true },
|
||
{ label: "Address", value: snap.address, mono: true },
|
||
],
|
||
actions: [{ id: "sign", label: "Sign", primary: true }],
|
||
});
|
||
if (pick !== "sign") throw new Error("user rejected");
|
||
return rt.adapter.signMessageV2(message);
|
||
});
|
||
});
|
||
|
||
// ---- Ethereum (EIP-1193) ---------------------------------------------
|
||
function ethRuntimeForChain(chainId) {
|
||
for (const rt of ctx.runtimes.values()) {
|
||
if (rt.entry.chain !== "eth" || rt.phase !== "ready") continue;
|
||
if (Number(rt.adapter.snapshot().chainId) === Number(chainId)) return rt;
|
||
}
|
||
return null;
|
||
}
|
||
// The wallet a given origin talks to. Chain is per origin — fixed at
|
||
// connect, changed only by that origin's own wallet_switchEthereumChain /
|
||
// wallet_addEthereumChain. The sidebar selection is a UI preference and
|
||
// never redirects a connected dapp. Unconnected origins get the chain
|
||
// they asked for before connecting, else the selected ETH wallet.
|
||
function activeEthRuntime(origin) {
|
||
const bound = boundRuntime(api, origin, "eth", "eth");
|
||
if (bound) return bound;
|
||
const g = origin ? grantFor(api, origin, "eth") : null;
|
||
const wanted = g?.chainId ?? (origin ? sessionGrants.get(origin)?.eth?.chainId : null);
|
||
if (wanted != null) { const rt = ethRuntimeForChain(wanted); if (rt) return bindGrant(api, origin, "eth", rt); }
|
||
const selId = selectedWalletId();
|
||
const selRt = selId && ctx.runtimes.get(selId);
|
||
if (selRt && selRt.entry.chain === "eth" && selRt.phase === "ready") return bindGrant(api, origin, "eth", selRt);
|
||
for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "eth" && rt.phase === "ready") return bindGrant(api, origin, "eth", rt);
|
||
throw new Error("no Ethereum wallet available — add one in the Aegis sidebar");
|
||
}
|
||
// Move a CONNECTED site to the wallet on another chain. The same address
|
||
// moves silently; a different one is a new disclosure and is asked for —
|
||
// otherwise a connected site could enumerate every EVM wallet's address by
|
||
// looping wallet_switchEthereumChain over chain ids and reading eth.state.
|
||
async function moveEthGrant(origin, chainId, consented = false) {
|
||
const g = grantFor(api, origin, "eth");
|
||
if (!g) return false;
|
||
const cur = (() => { try { return activeEthRuntime(origin); } catch { return null; } })();
|
||
const curAddr = String(g.address || cur?.adapter.snapshot().address || "").toLowerCase();
|
||
let target = null;
|
||
for (const rt of ctx.runtimes.values()) {
|
||
if (rt.entry.chain !== "eth" || rt.phase !== "ready" || Number(rt.adapter.snapshot().chainId) !== Number(chainId)) continue;
|
||
if (rt.adapter.snapshot().address.toLowerCase() === curAddr) { target = rt; break; }
|
||
if (!target) target = rt;
|
||
}
|
||
if (!target) return false;
|
||
const snap = target.adapter.snapshot();
|
||
if (snap.address.toLowerCase() !== curAddr && !consented) {
|
||
const ask = (opts) => withOriginLock(origin, () => api.approvalModal(opts));
|
||
const pick = await ask(({
|
||
title: "Let this site see another Ethereum address?",
|
||
origin,
|
||
body: "The site asked to switch networks. On that network Aegis uses a different wallet, so switching shows the site this address too.",
|
||
rows: [
|
||
{ label: "Address", value: snap.address, mono: true },
|
||
{ label: "Network", value: chainMeta("eth", target.entry.network)?.label || snap.network },
|
||
{ label: "Wallet", value: target.entry.label },
|
||
],
|
||
actions: [{ id: "allow", label: "Switch", primary: true }],
|
||
}));
|
||
if (!String(pick || "").startsWith("allow")) throw ethError("user rejected the network switch", 4001);
|
||
}
|
||
patchGrant(api, origin, "eth", { chainId: Number(chainId), walletId: target.entry.id, address: snap.address });
|
||
return true;
|
||
}
|
||
function ethConnectedFor(origin) { return !!grantFor(api, origin, "eth"); }
|
||
function ethError(message, code) { const e = new Error(message); e.code = code; return e; }
|
||
api.onMessage("eth.requestAccounts", async (_p, m) => {
|
||
const origin = fromPage(m);
|
||
const rt = activeEthRuntime(origin);
|
||
const snap = rt.adapter.snapshot();
|
||
const chainIdHex = "0x" + Number(snap.chainId).toString(16);
|
||
const networkVersion = String(snap.chainId);
|
||
if (ethConnectedFor(origin)) return { address: snap.address, chainIdHex, networkVersion };
|
||
return withOriginLock(origin, async () => {
|
||
const pick = await api.approvalModal({
|
||
title: "Connect this site to your Ethereum wallet?",
|
||
origin,
|
||
body: "The site will see this address and can build transactions for you to sign.",
|
||
rows: [
|
||
{ label: "Address", value: snap.address, mono: true },
|
||
{ label: "Network", value: chainMeta("eth", rt.entry.network)?.label || snap.network },
|
||
{ label: "Wallet", value: `${rt.entry.label} — Ethereum · ${snap.network}` },
|
||
],
|
||
actions: [{ id: "allow", label: "Connect", primary: true }],
|
||
checkbox: { id: "always", label: "Always allow this site to see this address" },
|
||
});
|
||
if (!pick.startsWith("allow")) throw new Error("user rejected");
|
||
setGrant(api, origin, "eth", { readAddress: true, chainId: snap.chainId, walletId: rt.entry.id, address: snap.address }, pick === "allow+always");
|
||
return { address: snap.address, chainIdHex, networkVersion };
|
||
});
|
||
});
|
||
api.onMessage("eth.personalSign", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first");
|
||
const rt = activeEthRuntime(origin);
|
||
// ethers / viem / wagmi send hex-encoded bytes; signing that hex as
|
||
// literal text produced signatures no dapp could verify. The overlay
|
||
// shows the decoded text.
|
||
const bytes = bytesFromDappMessage(p && p.message);
|
||
if (bytes.length > 16384) throw new Error("message too long");
|
||
// Exactly 32 bytes that are not readable text is a hash, and contracts
|
||
// accept a personal_sign over a hash as approval of whatever it hashes:
|
||
// a Gnosis Safe takes it for its safeTxHash (v > 30), many order books
|
||
// and relayers verify toEthSignedMessageHash(orderHash). That is not a
|
||
// login message, so it gets the danger button and the PIN.
|
||
let textual = false;
|
||
try { new TextDecoder("utf-8", { fatal: true }).decode(bytes); textual = !INVISIBLE_RE.test(Buffer.from(bytes).toString("utf8")); } catch {}
|
||
INVISIBLE_RE.lastIndex = 0;
|
||
const hashLike = bytes.length === 32 && !textual;
|
||
return withOriginLock(origin, async () => {
|
||
const pick = await api.approvalModal({
|
||
title: hashLike ? "Sign a 32-byte hash?" : "Sign an Ethereum message?",
|
||
origin,
|
||
body: hashLike
|
||
? "WARNING: this is not a readable message but a 32-byte hash. Contracts such as Safe wallets and order books accept a signature over a hash as approval of whatever it stands for — a transaction or a trade Aegis cannot show you. Only sign if you know exactly what this hash is."
|
||
: "Signing proves you control this address. It moves no ETH.",
|
||
rows: [
|
||
{ label: "Message", value: previewBytes(bytes), mono: true },
|
||
{ label: "Address", value: rt.adapter.snapshot().address, mono: true },
|
||
],
|
||
actions: [{ id: "sign", label: hashLike ? "Sign anyway" : "Sign", primary: !hashLike, danger: hashLike }],
|
||
});
|
||
if (pick !== "sign") throw new Error("user rejected");
|
||
if (hashLike) await requireDappTxPin(origin, "signature over a 32-byte hash");
|
||
return rt.adapter.signMessage(bytes);
|
||
});
|
||
});
|
||
api.onMessage("eth.sendTransaction", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first");
|
||
const rt = activeEthRuntime(origin);
|
||
const tx = (p && p.tx) || {};
|
||
if (!tx.to) throw new Error("tx.to required");
|
||
if (tx.from && String(tx.from).toLowerCase() !== rt.adapter.address.toLowerCase()) throw new Error("tx.from is not the connected account");
|
||
// MetaMask semantics: every field the dapp set is honoured verbatim —
|
||
// value, data, gas, fee caps, nonce. plan() fills in what is missing.
|
||
// The calldata used to be dropped, so a token transfer went out as a
|
||
// plain 0-value send to the token contract.
|
||
const ethLib = ctx.d.ethAdapter;
|
||
const data = ethLib.normalizeData(tx.data ?? tx.input);
|
||
const valueWei = tx.value ? ethLib.toBig(tx.value).toString() : "0";
|
||
return withOriginLock(origin, async () => {
|
||
const snap = rt.adapter.snapshot();
|
||
const plan = await rt.adapter.plan({
|
||
to: tx.to, amount: valueWei, sendMax: false, data,
|
||
gasLimit: tx.gas ?? tx.gasLimit, maxFeePerGas: tx.maxFeePerGas,
|
||
maxPriorityFeePerGas: tx.maxPriorityFeePerGas, gasPrice: tx.gasPrice, nonce: tx.nonce,
|
||
});
|
||
const meta = chainMeta("eth", rt.entry.network);
|
||
const ticker = snap.ticker || meta.ticker;
|
||
const isCall = data !== "0x";
|
||
const rows = [{ label: "To", value: ethLib.eip55(plan.recipients[0].to), mono: true }];
|
||
let body = `This site is asking your wallet to send ${ticker}.`;
|
||
let risky = false;
|
||
let approval = false;
|
||
if (isCall) {
|
||
let code = "0x";
|
||
try { code = await rt.adapter._client.call("eth_getCode", [plan.recipients[0].to, "latest"]); } catch {}
|
||
rows.push({ label: "Destination", value: code && code !== "0x" ? "smart contract" : "NOT a contract — calldata sent to a plain address does nothing" });
|
||
const call = ethLib.decodeCalldata(data);
|
||
if (call && call.name === "transfer") {
|
||
rows.push({ label: "Call", value: `transfer ${call.unlimited ? "UNLIMITED" : call.amount.toString()} token units to ${call.to}`, strong: true });
|
||
} else if (call && (call.name === "approve" || call.name === "increaseAllowance" || call.name === "increaseApproval")) {
|
||
risky = !!call.unlimited;
|
||
approval = true;
|
||
rows.push({ label: "Call", value: `${call.name}: let ${call.spender} spend ${call.unlimited ? "UNLIMITED (∞)" : call.amount.toString() + " token units (for an NFT contract: token #" + call.amount.toString() + ")"}`, strong: true });
|
||
} else if (call && call.name === "permit2Approve") {
|
||
risky = true;
|
||
approval = true;
|
||
rows.push({ label: "Call", value: `Permit2 approve: ${call.spender} may spend ${call.unlimited ? "UNLIMITED (∞)" : call.amount.toString() + " units"} of token ${call.token} until ${call.deadline ? new Date(Number(call.deadline) * 1000).toISOString().slice(0, 10) : "?"}`, strong: true });
|
||
} else if (call && (call.name === "safeTransferFrom" || call.name === "safeTransferFrom1155")) {
|
||
rows.push({ label: "Call", value: `transfer NFT/token #${call.tokenId?.toString()}${call.amount != null ? " × " + call.amount.toString() : ""}: ${call.from} → ${call.to}`, strong: true });
|
||
} else if (call && call.name === "safeBatchTransferFrom") {
|
||
risky = true;
|
||
rows.push({ label: "Call", value: `batch transfer of several tokens: ${call.from} → ${call.to} (items not decoded)`, strong: true });
|
||
} else if (call && call.name === "multicall") {
|
||
rows.push({ label: "Call", value: `multicall: several calls bundled together (not decoded, ${call.bytes} bytes)`, mono: true });
|
||
} else if (call && call.name === "transferFrom") {
|
||
rows.push({ label: "Call", value: `transferFrom ${call.from} → ${call.to}: ${call.amount.toString()} units`, strong: true });
|
||
} else if (call && call.name === "setApprovalForAll") {
|
||
risky = !!call.approved;
|
||
rows.push({ label: "Call", value: `setApprovalForAll: ${call.approved ? "GRANT" : "revoke"} ${call.operator} control over every NFT in this collection`, strong: true });
|
||
} else if (call && call.name === "permit") {
|
||
risky = !!call.unlimited;
|
||
rows.push({ label: "Call", value: `permit: ${call.spender} may spend ${call.unlimited ? "UNLIMITED (∞)" : call.value.toString()} units`, strong: true });
|
||
} else {
|
||
rows.push({ label: "Call", value: call ? `unknown method 0x${call.selector} (${call.bytes} bytes, not decoded)` : `${(data.length - 2) / 2} bytes of calldata`, mono: true });
|
||
}
|
||
body = risky
|
||
? "WARNING: this call grants another address open-ended control over your tokens. Only sign if you fully trust this site."
|
||
: approval
|
||
? "This call lets another address spend your tokens up to the amount shown. Check the spender and the amount."
|
||
: "This transaction calls a contract. Aegis decoded what it could — check the destination, the call and the value.";
|
||
}
|
||
if (plan.feeWarning) {
|
||
risky = true;
|
||
rows.unshift({ label: "Warning", value: plan.feeWarning, strong: true });
|
||
}
|
||
rows.push({ label: "Amount", value: `${fmtValue(plan.recipients[0].value, meta.decimals)} ${ticker}`, strong: true });
|
||
rows.push({ label: "Fee (est.)", value: `${fmtValue(plan.feeEstimate, meta.decimals)} ${ticker} · max ${fmtValue(plan.fee, meta.decimals)} ${ticker}` });
|
||
rows.push({ label: "Gas", value: `${plan.gasLimit} · nonce ${plan._draft.nonce}` });
|
||
rows.push({ label: "Wallet", value: `${rt.entry.label} — ${meta.label}` });
|
||
const pick = await api.approvalModal({
|
||
title: isCall ? "Send Ethereum contract call?" : "Send Ethereum transaction?",
|
||
origin, body, rows,
|
||
actions: [{ id: "send", label: risky ? "Send anyway" : "Send", primary: !risky, danger: risky }],
|
||
});
|
||
if (pick !== "send") throw new Error("user rejected");
|
||
await requireDappTxPin(origin, "Ethereum transaction");
|
||
const r = await rt.adapter.signAndBroadcast(plan);
|
||
return { txid: r.txid };
|
||
});
|
||
});
|
||
api.onMessage("eth.signTypedData", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first");
|
||
const rt = activeEthRuntime(origin);
|
||
// Dapps send typedData as either a JSON string (older MetaMask spec) or
|
||
// an object (v4). Accept both; the encoder wants an object.
|
||
let td = p && p.typedData;
|
||
if (typeof td === "string") { try { td = JSON.parse(td); } catch (e) { throw new Error("typedData: JSON parse failed: " + e.message); } }
|
||
if (!td || typeof td !== "object") throw new Error("typedData required");
|
||
// Compute the digest first — if the encoder rejects the input the user
|
||
// never sees an approval overlay for a broken payload.
|
||
let digest;
|
||
try { digest = ctx.d.eip712.digest(td); }
|
||
catch (e) { throw new Error("EIP-712 encode failed: " + e.message); }
|
||
// Approval overlay: show domain (name + chain), primary type, and a
|
||
// truncated JSON preview of the message so the user has a fighting
|
||
// chance to spot phishing.
|
||
const dom = td.domain || {};
|
||
const snapTd = rt.adapter.snapshot();
|
||
// A signature for another chain is the standard way to get an approval
|
||
// the user believes is for a testnet or a sidechain. MetaMask refuses a
|
||
// domain whose chainId is not the active chain; so does Aegis.
|
||
if (domDeclaredEarly(td) && dom.chainId != null && dom.chainId !== "") {
|
||
let domChain = null;
|
||
try { domChain = BigInt(dom.chainId); } catch {}
|
||
if (domChain === null || domChain !== BigInt(snapTd.chainId)) {
|
||
throw ethError(`typed data is for chain ${dom.chainId} but this site is connected on chain ${snapTd.chainId}`, 4901);
|
||
}
|
||
}
|
||
// Only the domain fields EIP712Domain declares are hashed. A site could
|
||
// declare [name] alone and still send verifyingContract/chainId, which
|
||
// were shown (and chain-checked) although the signature never covers
|
||
// them; they are now shown as not signed.
|
||
const domDeclared = Array.isArray(td.types?.EIP712Domain) ? td.types.EIP712Domain.map((x) => x && x.name) : [];
|
||
const dd = (k) => (domDeclared.includes(k) ? dom[k] : undefined);
|
||
const domainSummary = [dd("name") && plainLabel(dd("name"), 60), dd("version") && `v${plainLabel(dd("version"), 20)}`, dd("chainId") != null && `chain ${dd("chainId")}`].filter(Boolean).join(" · ") || "(no domain)";
|
||
const undeclaredDomain = Object.keys(dom).filter((k) => !domDeclared.includes(k));
|
||
// Preview only what the signature covers (see signedView).
|
||
const view = signedView(td);
|
||
const assessed = assessTypedData(td, view, snapTd.address);
|
||
const rows = [{ label: "Domain", value: domainSummary }];
|
||
if (dd("verifyingContract")) rows.push({ label: "Contract", value: String(dd("verifyingContract")), mono: true });
|
||
if (undeclaredDomain.length) rows.push({ label: "Not signed", value: `domain field${undeclaredDomain.length === 1 ? "" : "s"} ${undeclaredDomain.join(", ")} (sent but not covered by the signature)` });
|
||
rows.push({ label: "Primary type", value: String(td.primaryType || "") });
|
||
// Off-chain approvals. A Permit / Permit2 signature moves no gas and
|
||
// shows no transaction, yet lets the spender take the tokens later — it
|
||
// is how most wallet drains happen now. Pull the spender, the amounts
|
||
// and the deadline out of the message and say what they mean.
|
||
const permit = describePermit(td);
|
||
if (permit) for (const r of permit.rows) rows.push(r);
|
||
// Marketplace orders and multisig transactions are not permits but move
|
||
// NFTs, tokens or a whole Safe just as surely once signed.
|
||
// The name list now only labels what the structure test already found.
|
||
const MOVES_ASSETS = /^(OrderComponents|BulkOrder|Order|MakerOrder|TakerOrder|SafeTx|SafeMessage|MetaTransaction|ForwardRequest)$/;
|
||
const movesAssets = !permit && (assessed.movesAssets || MOVES_ASSETS.test(String(td.primaryType || "")));
|
||
const fieldText = assessed.fields.slice(0, 60).join("\n") + (assessed.fields.length > 60 ? `\n… ${assessed.fields.length - 60} more fields are NOT shown but will be signed` : "");
|
||
rows.push({ label: "Signed fields", value: previewText(fieldText || "(none)", 4000), mono: true });
|
||
if (view.dropped) rows.push({ label: "Not signed", value: `${view.dropped} field${view.dropped === 1 ? "" : "s"} the site sent are not covered by this signature and are not shown` });
|
||
rows.push({ label: "Address", value: snapTd.address, mono: true });
|
||
const risky = !!(permit && permit.risky) || movesAssets;
|
||
return withOriginLock(origin, async () => {
|
||
const pick = await api.approvalModal({
|
||
title: permit ? "Sign a token spending permit?" : "Sign typed data (EIP-712)?",
|
||
origin,
|
||
body: permit
|
||
? (risky
|
||
? "WARNING: signing this lets the spender below take these tokens at any time, without another prompt and without a transaction from you. Only sign if you fully trust this site."
|
||
: "Signing this lets the spender below move the stated amount of your tokens without a transaction from you.")
|
||
: movesAssets
|
||
? `WARNING: this ${plainLabel(td.primaryType, 60)} names another address together with an amount (or carries raw call data). Signed, it can move your tokens, NFTs or a Safe without another prompt. Only sign if you fully trust this site and every field below is what you expect.`
|
||
: assessed.plain
|
||
? "The site is asking you to sign a structured message with no addresses or amounts in it. Verify the domain matches the site you're on."
|
||
: "The site is asking you to sign structured data that contains addresses or numbers. Verify the domain matches the site you're on and check every field — a mismatched domain is the classic phishing tell.",
|
||
rows,
|
||
actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }],
|
||
});
|
||
if (pick !== "sign") throw new Error("user rejected");
|
||
// A permit, an order or a Safe transaction moves assets as surely as a
|
||
// transaction does.
|
||
if (permit || movesAssets) await requireDappTxPin(origin, permit ? "token spending permit" : "signature that can move assets");
|
||
return rt.adapter.signTypedDataDigest(digest);
|
||
});
|
||
});
|
||
api.onMessage("eth.switchChain", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
const wantHex = String(p && p.chainId || "").toLowerCase();
|
||
const wantId = Number(wantHex);
|
||
if (!Number.isFinite(wantId) || wantId <= 0) throw new Error("bad chainId");
|
||
// EIP-3326: the well-known "chain not added" code makes dapps fall back
|
||
// to wallet_addEthereumChain.
|
||
if (!ethRuntimeForChain(wantId)) throw ethError(`Aegis: chainId ${wantHex} is not added. Ask via wallet_addEthereumChain.`, 4902);
|
||
// Per origin only: THIS site moves to the wallet on that chain. It used
|
||
// to flip the global selected wallet, so any site — connected or not —
|
||
// could move every other connected dapp onto another chain. Unconnected
|
||
// sites just have the preference remembered for their connect prompt.
|
||
if (!(await moveEthGrant(origin, wantId))) rememberPendingChain(origin, wantId);
|
||
return null;
|
||
});
|
||
// EIP-3085: dapp asks Aegis to add a new EVM chain. On approval, we
|
||
// persist the chain config and create a wallet on it under the same
|
||
// vault-derived key. Existing addresses on that chain remain visible on
|
||
// whatever wallet they were funded on — a chain add doesn't move any
|
||
// key material, just registers the network.
|
||
api.onMessage("eth.addChain", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
const spec = (p && p.params) || {};
|
||
const chainIdHex = String(spec.chainId || "").toLowerCase();
|
||
const chainId = Number(chainIdHex);
|
||
if (!chainIdHex.startsWith("0x") || !Number.isFinite(chainId) || chainId <= 0) {
|
||
throw new Error("wallet_addEthereumChain: chainId must be a positive hex integer (e.g. '0x89')");
|
||
}
|
||
const chainName = String(spec.chainName || "").trim() || `EVM #${chainId}`;
|
||
const rpcUrls = Array.isArray(spec.rpcUrls) ? spec.rpcUrls.filter((u) => /^https:\/\//i.test(u)) : [];
|
||
const rpcUrl = rpcUrls[0];
|
||
if (!rpcUrl) throw new Error("wallet_addEthereumChain: at least one https rpcUrls entry is required");
|
||
const explorerBase = Array.isArray(spec.blockExplorerUrls) && spec.blockExplorerUrls[0]
|
||
? String(spec.blockExplorerUrls[0]).replace(/\/+$/, "")
|
||
: null;
|
||
const nc = spec.nativeCurrency || {};
|
||
const ticker = String(nc.symbol || "ETH").slice(0, 6).toUpperCase();
|
||
// Reject if a wallet on this chain already exists — no-op success per
|
||
// EIP-3085 conventions.
|
||
// Built-in networks carry no chainId in chainMeta, so "add chain 1 with
|
||
// my RPC" used to create a second mainnet wallet whose fees, nonce and
|
||
// balance came from the site's RPC. Their ids are refused outright.
|
||
const builtinIds = Object.values(ctx.d?.ethAdapter?.NETWORKS || {}).map((n) => Number(n.chainId));
|
||
if (builtinIds.includes(Number(chainId)) && !walletEntries().some((w) => w.chain === "eth" && Number(ctx.runtimes.get(w.id)?.adapter?.snapshot?.()?.chainId) === Number(chainId))) {
|
||
throw ethError(`chain ${chainId} is built into Aegis; add it in the Aegis panel, not from a site`, 4001);
|
||
}
|
||
const existing = walletEntries().find((w) => w.chain === "eth"
|
||
&& (w.network === CUSTOM_ETH_PREFIX + chainId
|
||
|| (chainMeta("eth", w.network)?.chainId === chainId)
|
||
|| Number(ctx.runtimes.get(w.id)?.adapter?.snapshot?.()?.chainId) === Number(chainId)));
|
||
if (existing) {
|
||
// Already known: behaves like a switch for THIS origin only. Never a
|
||
// grant — this used to persist a connection without any prompt, so
|
||
// any site could read the address by "adding" a chain Aegis already
|
||
// had. An unconnected site gets the chain remembered for its eventual
|
||
// eth_requestAccounts and learns nothing else.
|
||
if (!(await moveEthGrant(origin, chainId))) rememberPendingChain(origin, chainId);
|
||
return null;
|
||
}
|
||
return withOriginLock(origin, async () => {
|
||
const pick = await api.approvalModal({
|
||
title: "Add an Ethereum chain?",
|
||
origin,
|
||
body: "The site is asking to add a new EVM network to Aegis. Verify the RPC and chain ID — a malicious 'chain add' can point you at a fraudulent RPC that intercepts your reads or signs." + (grantFor(api, origin, "eth") ? " Aegis makes a new wallet for it, and a connected site will see that wallet's address." : ""),
|
||
rows: [
|
||
{ label: "Chain name", value: chainName },
|
||
{ label: "Chain ID", value: `${chainId} (${chainIdHex})` },
|
||
{ label: "Native ticker", value: ticker },
|
||
{ label: "RPC", value: rpcUrl, mono: true },
|
||
{ label: "Explorer", value: explorerBase || "(none)", mono: true },
|
||
],
|
||
actions: [{ id: "add", label: "Add chain", primary: true }],
|
||
});
|
||
if (pick !== "add") throw new Error("user rejected");
|
||
// Persist chain config + create a wallet on it.
|
||
const chains = customEthChains(api);
|
||
chains[String(chainId)] = {
|
||
chainId, chainName, rpcUrl,
|
||
explorerTx: explorerBase ? explorerBase + "/tx/" : "",
|
||
explorerAddr: explorerBase ? explorerBase + "/address/" : "",
|
||
ticker, addedAt: Date.now(), addedByOrigin: origin,
|
||
};
|
||
api.storage.set("customEthChains", chains);
|
||
const network = CUSTOM_ETH_PREFIX + chainId;
|
||
const meta = chainMeta("eth", network);
|
||
const list = walletEntries().slice();
|
||
const index = nextIndex(list, meta);
|
||
const purpose = meta.purposePrefix + index;
|
||
const id = makeWalletId(meta, index);
|
||
const label = `${chainName} — ${meta.short}`;
|
||
const entry = { id, label, chain: "eth", network, purpose, createdAt: Date.now() };
|
||
list.push(entry);
|
||
writeWallets(api, list);
|
||
api.storage.set("selectedWalletId", id);
|
||
ctx.runtimes.set(id, { entry, phase: "locked", error: null, adapter: null });
|
||
emitState();
|
||
await mountWallet(entry);
|
||
// Adding a chain is not connecting. A connected site moves to the new
|
||
// chain; an unconnected one has it remembered for its connect prompt.
|
||
if (!(await moveEthGrant(origin, chainId, true))) rememberPendingChain(origin, chainId);
|
||
return null;
|
||
});
|
||
});
|
||
// Cheap state peek — used by the main-world bridge right after a switch
|
||
// or add to emit accountsChanged / chainChanged without needing another
|
||
// approval overlay. Only returns the wallet the origin already sees.
|
||
api.onMessage("eth.state", (_p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!ethConnectedFor(origin)) return { address: null, chainIdHex: "0x0", networkVersion: "0" };
|
||
const rt = activeEthRuntime(origin);
|
||
const snap = rt.adapter.snapshot();
|
||
return {
|
||
address: snap.address,
|
||
chainIdHex: "0x" + Number(snap.chainId).toString(16),
|
||
networkVersion: String(snap.chainId),
|
||
};
|
||
});
|
||
// Read passthrough: forward eth_getBalance / eth_call / etc. to the
|
||
// wallet's own configured RPC. Nothing here reveals the private key.
|
||
api.onMessage("eth.rpc", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
const rt = activeEthRuntime(origin);
|
||
const method = String(p && p.method || "");
|
||
const params = (p && p.params) || [];
|
||
if (!/^eth_|^net_|^web3_/.test(method)) throw new Error("Aegis: only eth_/net_/web3_ read methods are passed through");
|
||
// The user's RPC endpoint is theirs (often a keyed, metered one). A site
|
||
// that has not connected gets the three calls every dapp makes before
|
||
// asking to connect and nothing else; signing, account and filter-state
|
||
// methods are never relayed, and broadcasting needs a connection.
|
||
const connected = ethConnectedFor(origin);
|
||
if (!connected && !ETH_RPC_PRECONNECT.has(method)) throw ethError("not connected — call eth_requestAccounts first", 4100);
|
||
if (ETH_RPC_NEVER.test(method)) throw ethError(`Aegis does not relay ${method}`, 4200);
|
||
return rt.adapter._client.call(method, params);
|
||
});
|
||
|
||
// ---- Solana (wallet-adapter) ------------------------------------------
|
||
function activeSolRuntime(origin) {
|
||
const bound = boundRuntime(api, origin, "sol", "sol");
|
||
if (bound) return bound;
|
||
const selId = selectedWalletId();
|
||
const selRt = selId && ctx.runtimes.get(selId);
|
||
if (selRt && selRt.entry.chain === "sol" && selRt.phase === "ready") return bindGrant(api, origin, "sol", selRt);
|
||
for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "sol" && rt.phase === "ready") return bindGrant(api, origin, "sol", rt);
|
||
throw new Error("no Solana wallet available — add one in the Aegis sidebar");
|
||
}
|
||
function solConnectedFor(origin) { return !!grantFor(api, origin, "sol"); }
|
||
// Is this site already connected? Lets a Wallet Standard "silent" connect
|
||
// (what adapters do on page load to restore a session) succeed without a
|
||
// prompt for a connected site and stay quiet for everyone else.
|
||
api.onMessage("sol.state", (_p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!solConnectedFor(origin)) return { address: null };
|
||
try { const snap = activeSolRuntime(origin).adapter.snapshot(); return { address: snap.address, network: snap.network }; }
|
||
catch { return { address: null }; }
|
||
});
|
||
api.onMessage("sol.connect", async (_p, m) => {
|
||
const origin = fromPage(m);
|
||
const rt = activeSolRuntime(origin);
|
||
const snap = rt.adapter.snapshot();
|
||
if (solConnectedFor(origin)) return { address: snap.address, network: snap.network };
|
||
return withOriginLock(origin, async () => {
|
||
const pick = await api.approvalModal({
|
||
title: "Connect this site to your Solana wallet?",
|
||
origin,
|
||
body: "The site will see this address and can build transactions for you to sign.",
|
||
rows: [
|
||
{ label: "Address", value: snap.address, mono: true },
|
||
{ label: "Network", value: snap.network === "mainnet" ? "Mainnet-beta" : "Devnet" },
|
||
{ label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` },
|
||
],
|
||
actions: [{ id: "allow", label: "Connect", primary: true }],
|
||
checkbox: { id: "always", label: "Always allow this site to see this address" },
|
||
});
|
||
if (!pick.startsWith("allow")) throw new Error("user rejected");
|
||
setGrant(api, origin, "sol", { readAddress: true, network: snap.network, walletId: rt.entry.id, address: snap.address }, pick === "allow+always");
|
||
return { address: snap.address, network: snap.network };
|
||
});
|
||
});
|
||
api.onMessage("sol.signMessage", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first");
|
||
const rt = activeSolRuntime(origin);
|
||
const b64 = String(p && p.messageB64 || "");
|
||
const bytes = new Uint8Array(Buffer.from(b64, "base64"));
|
||
if (bytes.length > 16384) throw new Error("message too long");
|
||
// A "message" that parses as a transaction message would, once signed,
|
||
// be a valid transaction signature behind a "moves no SOL" overlay.
|
||
// Phantom refuses these; so do we.
|
||
if (parseSolMessage(bytes, rt.adapter._pub).ok) {
|
||
throw new Error("refusing to sign: this message is a Solana transaction. Use signTransaction.");
|
||
}
|
||
return withOriginLock(origin, async () => {
|
||
const pick = await api.approvalModal({
|
||
title: "Sign a Solana message?",
|
||
origin,
|
||
body: "Signing proves you control this address. It moves no SOL.",
|
||
rows: [
|
||
{ label: "Message", value: previewBytes(bytes), mono: true },
|
||
{ label: "Address", value: rt.adapter.snapshot().address, mono: true },
|
||
],
|
||
actions: [{ id: "sign", label: "Sign", primary: true }],
|
||
});
|
||
if (pick !== "sign") throw new Error("user rejected");
|
||
return rt.adapter.signBytes(bytes);
|
||
});
|
||
});
|
||
// Dapp-built transaction the dapp will broadcast itself (window.solana
|
||
// .signTransaction). It used to go through signMessage and its "moves no
|
||
// SOL" overlay; it gets its own decoded overlay now: signing IS sending.
|
||
api.onMessage("sol.signTransaction", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first");
|
||
const rt = activeSolRuntime(origin);
|
||
const messageBytes = new Uint8Array(Buffer.from(String(p && p.messageB64 || ""), "base64"));
|
||
const parsed = parseSolMessage(messageBytes, rt.adapter._pub);
|
||
if (!parsed.ok) throw new Error("cannot parse transaction message: " + parsed.error);
|
||
if (parsed.ourIndex < 0) throw new Error("this wallet's key is not among the transaction's account keys");
|
||
if (parsed.ourIndex >= parsed.numRequiredSigs) throw new Error(`this wallet's key is not a required signer (index ${parsed.ourIndex}, requiredSigs ${parsed.numRequiredSigs})`);
|
||
return withOriginLock(origin, async () => {
|
||
const snap = rt.adapter.snapshot();
|
||
const solBal = (() => { const b = snap.balance; const v = b && typeof b === "object" ? (b.confirmed ?? b.lamports) : b; try { return v != null ? BigInt(v) : null; } catch { return null; } })();
|
||
const rows = solInstructionRows(parsed, solBal);
|
||
rows.push({ label: "Address", value: snap.address, mono: true });
|
||
rows.push({ label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` });
|
||
const pick = await api.approvalModal({
|
||
title: "Sign a Solana transaction?",
|
||
origin,
|
||
body: "The site built this transaction and will broadcast it itself — signing it is the same as sending it."
|
||
+ (rows.risks.length ? " Read the warning: this is not an ordinary payment." : rows.undecoded ? " Parts marked 'not decoded' are programs Aegis cannot read." : ""),
|
||
rows,
|
||
actions: [{ id: "sign", label: rows.risks.length ? "Sign anyway" : "Sign", primary: !rows.risks.length, danger: rows.risks.length > 0 }],
|
||
});
|
||
if (pick !== "sign") throw new Error("user rejected");
|
||
await requireDappTxPin(origin, "Solana transaction");
|
||
return rt.adapter.signBytes(messageBytes);
|
||
});
|
||
});
|
||
// Dapp-built transaction. The main-world bridge passes the FULL wire
|
||
// (tx.serialize({requireAllSignatures:false, verifySignatures:false}))
|
||
// — signature slots the dapp already filled with partialSign() are
|
||
// preserved; our wallet only overwrites its own slot. That's the only
|
||
// way to sign multi-signer transactions the dapp has partially
|
||
// co-signed (co-signer sigs, ephemeral session keys, etc.).
|
||
api.onMessage("sol.signAndSend", async (p, m) => {
|
||
const origin = fromPage(m);
|
||
if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first");
|
||
const rt = activeSolRuntime(origin);
|
||
const wireB64 = String(p && p.wireB64 || "");
|
||
if (!wireB64) throw new Error("wireB64 required (full serialized transaction)");
|
||
const wire = new Uint8Array(Buffer.from(wireB64, "base64"));
|
||
const { sigCount, sigsStart, messageBytes } = splitSolWire(wire);
|
||
// Legacy and v0 messages both parse (v0 used to be read with its
|
||
// version byte as the header, so the signer lookup was garbage); our
|
||
// key must be a required signer.
|
||
const parsed = parseSolMessage(messageBytes, rt.adapter._pub);
|
||
if (!parsed.ok) throw new Error("cannot parse transaction message: " + parsed.error);
|
||
const { numRequiredSigs, ourIndex } = parsed;
|
||
if (ourIndex < 0) throw new Error("this wallet's key is not among the transaction's account keys");
|
||
if (ourIndex >= numRequiredSigs) throw new Error(`this wallet's key is not a required signer (index ${ourIndex}, requiredSigs ${numRequiredSigs})`);
|
||
// Our signature is written at sigsStart + ourIndex*64; with fewer slots
|
||
// than signers that would overwrite message bytes.
|
||
if (sigCount !== numRequiredSigs) throw new Error(`the transaction has ${sigCount} signature slots for ${numRequiredSigs} signers`);
|
||
|
||
return withOriginLock(origin, async () => {
|
||
const snap = rt.adapter.snapshot();
|
||
const solBal = (() => { const b = snap.balance; const v = b && typeof b === "object" ? (b.confirmed ?? b.lamports) : b; try { return v != null ? BigInt(v) : null; } catch { return null; } })();
|
||
const rows = solInstructionRows(parsed, solBal);
|
||
rows.push({ label: "Address", value: snap.address, mono: true });
|
||
rows.push({ label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` });
|
||
const pick = await api.approvalModal({
|
||
title: "Sign + send a Solana transaction?",
|
||
origin,
|
||
body: "The site built this transaction. Check every instruction — anything marked 'not decoded' is a program Aegis cannot read."
|
||
+ (rows.risks.length ? " Read the warning: this is not an ordinary payment." : ""),
|
||
rows,
|
||
actions: [{ id: "send", label: rows.risks.length ? "Sign & send anyway" : "Sign & send", primary: !rows.risks.length, danger: rows.risks.length > 0 }],
|
||
});
|
||
if (pick !== "send") throw new Error("user rejected");
|
||
await requireDappTxPin(origin, "Solana transaction");
|
||
// Sign the exact message bytes and patch our slot. signMessage() ran
|
||
// the bytes through String(), so every signature was over "1,2,3,…"
|
||
// and the network rejected it. Partial signatures already in the wire
|
||
// (tx.partialSign()) at other slots are preserved.
|
||
const sigInfo = rt.adapter.signBytes(messageBytes);
|
||
const sigBytes = ctx.d.base58check.decodeBase58(sigInfo.signature);
|
||
if (sigBytes.length !== 64) throw new Error("bad ed25519 signature length");
|
||
const wireOut = new Uint8Array(wire); // copy so we don't mutate caller
|
||
wireOut.set(sigBytes, sigsStart + ourIndex * 64);
|
||
const wireB58 = ctx.d.base58check.encodeBase58(wireOut);
|
||
const txid = await rt.adapter._client.call("sendTransaction", [wireB58]);
|
||
if (typeof txid !== "string" || !txid.length) throw new Error("bad txid from RPC: " + JSON.stringify(txid));
|
||
setTimeout(() => rt.adapter.refresh().catch(() => {}), 4000);
|
||
return { txid };
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---- activate ---------------------------------------------------------------
|
||
|
||
module.exports = {
|
||
// Returns a promise that settles once Aegis can serve calls. Aegis starts
|
||
// on first use, so the call that woke it is the first thing it sees, and
|
||
// the host holds that call (up to 5 s) until this promise settles.
|
||
activate(api) {
|
||
// No explicit icon — inherit manifest.icon (branded shield data URI)
|
||
// so the toolbar dock button renders the aegis.x brand mark instead
|
||
// of a fallback emoji. Same id as the panel addon.json declares, which
|
||
// this replaces.
|
||
api.registerSidebarPanel({ id: "main", title: "Wallet", page: "panel.html" });
|
||
// Serve reads from memory. The host's storage.get() does a readFileSync
|
||
// plus a JSON.parse of the ENTIRE add-on store on every single call, on
|
||
// the Electron main thread. fullState() alone reads it seven-odd times
|
||
// (selectedWalletId, walletRoles, walletEntries, snapshotForSelected,
|
||
// the server list...) and emitState() runs on every adapter change, so a
|
||
// large store made opening Aegis hang the whole browser.
|
||
//
|
||
// Safe because this process is the only writer: Aegis's panel talks to
|
||
// the host over addon messages and never touches addon storage directly.
|
||
// If that ever changes, this cache has to go or be invalidated.
|
||
installStorageCache(api, (key) => { if (LAUNCH_START_KEYS.test(key)) syncLaunchStart(api); });
|
||
const c = ctx = {
|
||
api,
|
||
d: null,
|
||
runtimes: new Map(), // walletId -> { entry, phase, error, adapter }
|
||
priceFeed: require("./lib/prices.js")({
|
||
log: (...a) => api.log("prices", ...a),
|
||
onChange: () => emitState(),
|
||
}),
|
||
// BCMR (CashTokens metadata registry) — resolves category hex to
|
||
// { name, symbol, iconUri, decimals }. Storage-scoped so per-user
|
||
// caches don't stomp each other; disk-cached with 6h TTL.
|
||
bcmr: require("./lib/bcmr.js")({
|
||
storage: api.storage,
|
||
log: (...a) => api.log("bcmr", ...a),
|
||
}),
|
||
wc: null, // WizardConnect manager, initialised when deps load
|
||
};
|
||
migrateLegacyStorage(api);
|
||
registerPanelMessages(api);
|
||
// Learn whether the one PIN is set before the first gate is decided.
|
||
refreshHostPin(api).catch(() => {});
|
||
registerPageMessages(api);
|
||
launchStartAsked = null;
|
||
syncLaunchStart(api);
|
||
// Restore the user's opt-in choice from storage. Off by default so a
|
||
// fresh install never hits any oracle without asking. Source can also
|
||
// be pre-restored so a user who picked Kraken stays on Kraken.
|
||
const savedSource = String(api.storage.get("pricesSource", "") || "").trim();
|
||
if (savedSource) c.priceFeed.setSource(savedSource).catch(() => {});
|
||
if (api.storage.get("pricesEnabled", false)) c.priceFeed.setEnabled(true).catch(() => {});
|
||
// The deps are heavy to evaluate (noble curve precompute, bitcoinjs,
|
||
// libauth, WizardConnect) and that all happens on the main thread. Wait
|
||
// for the browser chrome to paint so the cost never delays Theseus's
|
||
// first frame; older hosts without whenUiReady load immediately.
|
||
const uiReady = typeof api.whenUiReady === "function" ? api.whenUiReady() : Promise.resolve();
|
||
const started = uiReady.then(() => loadDeps(api)).then(async (d) => {
|
||
if (ctx !== c) return;
|
||
c.d = d;
|
||
// Start the WizardConnect manager once deps are ready. Per-wallet
|
||
// adapters get spun up in mountWallet() as each BCH wallet becomes
|
||
// available (only BCH today — hdwalletv1 is BCH-scoped).
|
||
c.wc = require("./lib/wc.js")({
|
||
HDKey: d.HDKey, secp256k1: d.secp256k1, sha256: d.sha256, hkdf: d.hkdf,
|
||
WalletConnectionManager: d.wcWallet.WalletConnectionManager,
|
||
wcCore: d.wcCore, libauth: d.libauth,
|
||
log: (...a) => api.log("wc", ...a),
|
||
api,
|
||
// Bridge sign approvals through the addon's approval-modal capability.
|
||
approvalRequest: async (payload) => {
|
||
// The host overlay only knows `actions`; the old `approve`/`reject`
|
||
// keys fell back to a lone "OK" button whose id never matched, so
|
||
// every WizardConnect signing request was refused, and the HTML
|
||
// body was shown as literal markup.
|
||
const { body, rows, dappName, unreadable, origin: wcOrigin, risky: wcRisky } = buildWcApproval(payload);
|
||
if (unreadable) { api.log(`wc sign: refused an unreadable request from ${dappName}`); return { approved: false }; }
|
||
const pick = await api.approvalModal({
|
||
title: "Sign a Bitcoin Cash transaction (WizardConnect)?",
|
||
origin: wcOrigin,
|
||
body, rows,
|
||
actions: [{ id: "approve", label: wcRisky ? "Sign anyway" : "Sign", primary: !wcRisky, danger: !!wcRisky }],
|
||
});
|
||
if (pick !== "approve") return { approved: false };
|
||
try { await requireDappTxPin(dappName, "Bitcoin Cash transaction (WizardConnect)"); }
|
||
catch (e) { api.log("wc sign:", e?.message || e); return { approved: false }; }
|
||
return { approved: true };
|
||
},
|
||
});
|
||
c.wc.onStateChange(() => emitState());
|
||
await tryAutoUnlock(api);
|
||
// vault.derive() polls until the user unlocks, so with a locked vault
|
||
// mountAllWallets() never settles. Only wait for it when the vault is
|
||
// open; otherwise the waking call gets the usual locked answer now
|
||
// rather than after the host's 5 s timeout.
|
||
const st = await api.vault.lifecycle.status().catch(() => null);
|
||
const mounting = mountAllWallets();
|
||
if (st && st.unlocked) await mounting;
|
||
else mounting.catch((e) => api.log("mount:", e?.message || e));
|
||
});
|
||
started.catch((e) => {
|
||
if (ctx !== c) return;
|
||
api.log("startup failed:", e?.message);
|
||
emitState();
|
||
});
|
||
return started.catch(() => {});
|
||
},
|
||
deactivate() {
|
||
const c = ctx; ctx = null;
|
||
if (!c) return;
|
||
try { c.priceFeed && c.priceFeed.dispose(); } catch {}
|
||
for (const rt of c.runtimes.values()) {
|
||
try { rt.adapter && rt.adapter.dispose(); } catch {}
|
||
}
|
||
c.runtimes.clear();
|
||
},
|
||
};
|