diff --git a/addon.json b/addon.json
index 013c125..8abd5d7 100644
--- a/addon.json
+++ b/addon.json
@@ -1,7 +1,7 @@
{
"id": "aegis",
"name": "Aegis Wallet",
- "version": "0.4.4",
+ "version": "0.6.1",
"description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none'%3E%3Cpolygon points='16,2 28,9 28,23 16,30 4,23 4,9' fill='%230a0a0d' stroke='%23D6FF3D' stroke-width='1.6' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='4.5' fill='none' stroke='%23D6FF3D' stroke-width='1.4'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23D6FF3D'/%3E%3C/svg%3E",
diff --git a/index.js b/index.js
index 4bc92fb..c29491e 100644
--- a/index.js
+++ b/index.js
@@ -28,6 +28,7 @@ async function loadDeps(api) {
const { secp256k1 } = await api.import("@noble/curves/secp256k1.js");
const { ed25519 } = await api.import("@noble/curves/ed25519.js");
const { sha256 } = await api.import("@noble/hashes/sha2.js");
+ const { hkdf } = await api.import("@noble/hashes/hkdf.js");
const { ripemd160 } = await api.import("@noble/hashes/legacy.js");
const { keccak_256 } = await api.import("@noble/hashes/sha3.js");
const { blake2b } = await api.import("@noble/hashes/blake2.js");
@@ -57,25 +58,52 @@ async function loadDeps(api) {
// bip32, ecpair, @bitcoinerlab/secp256k1) are CommonJS and reachable via
// api.require from the Theseus dependency tree.
const { pathToFileURL } = require("node:url");
- const dgbCore = await import(pathToFileURL(path.join(api.folder, "lib/dgb/core/index.js")).href);
- const dgbPsbt = await import(pathToFileURL(path.join(api.folder, "lib/dgb/psbt/index.js")).href);
+ // DGB's bundled ESM packages import their peer deps by bare specifier
+ // ("bip32", "bitcoinjs-lib", …). When aegis is loaded from a copy in
+ // userData/addons/, Node's ESM resolver can't reach Theseus's node_modules
+ // from that path — so the import throws. Wrap it: DGB just becomes
+ // unavailable, the rest of Aegis keeps working.
+ let dgbCore = null, dgbPsbt = null, dgbAdapter = null;
+ try {
+ dgbCore = await import(pathToFileURL(path.join(api.folder, "lib/dgb/core/index.js")).href);
+ dgbPsbt = await import(pathToFileURL(path.join(api.folder, "lib/dgb/psbt/index.js")).href);
+ } catch (e) {
+ api.log("dgb unavailable:", e?.message || e);
+ }
const bitcoinjs = api.require("bitcoinjs-lib");
const { BIP32Factory } = api.require("bip32");
const { ECPairFactory } = api.require("ecpair");
const ecc = api.require("@bitcoinerlab/secp256k1");
- const dgbAdapter = require("./lib/chain-dgb.js")({
- dgbCore, dgbPsbt, bitcoinjs,
- bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, ecc,
- sha256, electrum,
- });
+ 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,
});
- return { HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256, blake2b,
+ const bip39 = api.require("bip39");
+ // Imported BCH — a lean read-only adapter for wallets whose key material
+ // lives in Theseus's wallet-imports.enc. Same electrum/cashaddr surface as
+ // the primary BCH adapter but a single fixed address per wallet.
+ const importedBchAdapter = require("./lib/chain-bch-imported.js")({
+ sha256, ripemd160, cashaddr, electrum, WebSocket, tx,
+ });
+ // WizardConnect: LGPL-3.0-or-later. Dynamic-linked via api.import so the
+ // §4d combined-work requirement (dynamic linkage + license notice + source
+ // availability) is met — package sources ship with npm.
+ const wcCore = await api.import("@wizardconnect/core");
+ const wcWallet = await api.import("@wizardconnect/wallet");
+ const libauth = await api.import("@bitauth/libauth");
+ return { HDKey, secp256k1, ed25519, sha256, hkdf, ripemd160, keccak_256, blake2b,
cashaddr, keysLib, tx, electrum, base58check,
bchAdapter, tronAdapter, siaAdapter, dgbAdapter, ethAdapter, solAdapter, btcAdapter,
- dgbCore, dgbPsbt, bitcoinjs, ecc, eip712 };
+ importedBchAdapter, bip39,
+ dgbCore, dgbPsbt, bitcoinjs, ecc, eip712,
+ wcCore, wcWallet, libauth };
}
// ---- servers ---------------------------------------------------------------
@@ -443,6 +471,30 @@ async function mountWallet(entry) {
rt.phase = "locked"; rt.error = null;
c.runtimes.set(entry.id, rt);
emitState();
+
+ // Imported wallets skip the vault-derive/HKDF path entirely: their signer
+ // material is in wallet-imports.enc, and the runtime here is read-only so
+ // it doesn't need the material until spend support ships (M.1b).
+ if (entry.kind === "imported") {
+ try {
+ const adapter = new c.d.importedBchAdapter.ImportedBchWallet({
+ walletId: entry.id, storage: c.api.storage,
+ log: (...a) => c.api.log(`[${entry.id}]`, ...a),
+ onChange: () => emitStateForWallet(entry.id),
+ network: entry.network,
+ cashaddr: entry.importedCashaddr,
+ servers: entry.network === "mainnet" ? bchServerList(c.api) : undefined,
+ });
+ rt.adapter = adapter; rt.phase = "ready";
+ adapter.refresh(true).catch((e) => c.api.log(`[${entry.id}] initial refresh:`, e?.message || e));
+ emitStateForWallet(entry.id);
+ } catch (e) {
+ rt.phase = "error"; rt.error = e?.message || String(e);
+ emitState();
+ }
+ return;
+ }
+
let root;
try {
root = await c.api.vault.derive(entry.purpose);
@@ -490,6 +542,7 @@ async function mountWallet(entry) {
});
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,
@@ -537,6 +590,12 @@ async function mountWallet(entry) {
// Kick a first fetch. Errors here don't fail the mount — the panel shows
// them per-wallet via snapshot.error.
adapter.refresh(true).catch((e) => c.api.log(`[${entry.id}] initial refresh:`, e?.message || e));
+ if (entry.chain === "bch" && c.wc) {
+ c.wc.startForWallet({
+ walletId: entry.id, label: entry.label,
+ root32: root, accountPath: entry.accountPath || "m/44'/145'/0'",
+ }).catch((e) => c.api.log(`[${entry.id}] wc start:`, e?.message || e));
+ }
emitStateForWallet(entry.id);
} catch (e) {
rt.phase = "error";
@@ -551,9 +610,50 @@ async function mountWallet(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 {} }
ctx.runtimes.delete(walletId);
}
+// ---- import derive helpers (client-side, cashaddr only) --------------------
+// The pasted material never leaves this process — main-side stores it once
+// via api.vault.imports.add. These helpers only turn (seed+path) or WIF into
+// a P2PKH cashaddr, which is safe to send back to the panel.
+
+function deriveCashaddrFromSeed(seedHex, path, prefix) {
+ const d = ctx.d;
+ const seed = new Uint8Array(seedHex.length / 2);
+ for (let i = 0; i < seed.length; i++) seed[i] = parseInt(seedHex.substr(i * 2, 2), 16);
+ const root = d.HDKey.fromMasterSeed(seed);
+ const node = root.derive(path);
+ const h160 = d.ripemd160(d.sha256(node.publicKey));
+ return d.cashaddr.encode(prefix, 0, h160);
+}
+function deriveCashaddrFromWif(wif, prefix) {
+ const d = ctx.d;
+ // WIF layout: base58check(networkByte || privkey32 || [compressionByte 0x01])
+ const raw = d.base58check.decode(wif);
+ if (raw.length !== 33 && raw.length !== 34) throw new Error(`bad WIF length ${raw.length}`);
+ // First byte is version (network); we allow any — BCH mainnet uses 0x80,
+ // testnet 0xEF. Both round-trip through the same address derivation below.
+ const priv = raw.slice(1, 33);
+ const compressed = raw.length === 34; // trailing 0x01 marker
+ const pub = d.secp256k1.getPublicKey(priv, compressed);
+ const h160 = d.ripemd160(d.sha256(pub));
+ return d.cashaddr.encode(prefix, 0, h160);
+}
+
+function buildWcApprovalBody(payload) {
+ const req = payload?.request || {};
+ const tx = req.transaction || {};
+ const dappName = tx.userPrompt || "unknown dapp";
+ const inputCount = Array.isArray(req.inputPaths) ? req.inputPaths.length : "?";
+ const bc = tx.broadcast ? "Dapp will broadcast after signing." : "Signed hex returned to dapp.";
+ const walletName = payload?.label || payload?.walletId || "";
+ return "
" + dappName + " requests a BCH transaction signature.
"
+ + "Wallet: " + walletName + " · " + inputCount + " input(s).
"
+ + "" + bc + " Signs with SIGHASH_ALL|FORKID|UTXOS.
";
+}
+
// ---- state / snapshot -------------------------------------------------------
function selectedWalletId() {
@@ -629,6 +729,8 @@ function fullState() {
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() : {},
};
}
@@ -718,6 +820,99 @@ function registerPanelMessages(api) {
await mountWallet(entry);
return fullState();
});
+ // Vault lifecycle from inside the wallet panel — no more redirecting the
+ // user to Settings > Passwords. After a successful unlock/setup we remount
+ // every wallet: the vault-derive route is now available.
+ api.onMessage("vaultSetup", async (p, m) => {
+ fromPanel(m);
+ const pw = String(p && p.masterPassword || "");
+ const seedSource = p && p.seedSource;
+ await api.vault.lifecycle.setup(pw, seedSource);
+ await mountAllWallets();
+ return fullState();
+ });
+ api.onMessage("vaultUnlock", async (p, m) => {
+ fromPanel(m);
+ const pw = String(p && p.masterPassword || "");
+ await api.vault.lifecycle.unlock(pw);
+ await mountAllWallets();
+ return fullState();
+ });
+ api.onMessage("vaultStatus", async (_p, m) => {
+ fromPanel(m);
+ return api.vault.lifecycle.status();
+ });
+
+ // ---- wallet import (M.1 of DESIGN-wallet-multi-account-amendment.md) ----
+ // Accepts either a BIP39 mnemonic (12/24 words) + BIP44 path, OR a raw WIF.
+ // Derives the P2PKH cashaddr client-side, stores the signer material via
+ // api.vault.imports.add (main-process holds it), then adds a slim Aegis
+ // wallet entry with kind=imported pointing at the returned importId.
+ api.onMessage("importWallet", async (p, m) => {
+ fromPanel(m);
+ const chain = String(p && p.chain || "bch");
+ if (chain !== "bch") throw new Error("only BCH imports are supported in this build");
+ const network = String(p && p.network || "chipnet");
+ if (network !== "mainnet" && network !== "chipnet") throw new Error(`unsupported network ${network}`);
+ const label = String(p && p.label || "").trim();
+ if (!label) throw new Error("label required");
+ const category = String(p && p.category || "operational").trim();
+ const prefix = network === "mainnet" ? "bitcoincash" : "bchtest";
+ const source = String(p && p.source || "manual-paste");
+
+ let kind, seedHex, path, wif, cashaddrStr;
+ if (p && p.wif) {
+ kind = "wif";
+ wif = String(p.wif).trim();
+ cashaddrStr = deriveCashaddrFromWif(wif, prefix);
+ } else if (p && p.mnemonic) {
+ kind = "seed";
+ const words = String(p.mnemonic).trim().split(/\s+/).length;
+ if (words !== 12 && words !== 15 && words !== 18 && words !== 21 && words !== 24) {
+ throw new Error(`mnemonic must be 12/15/18/21/24 words (got ${words})`);
+ }
+ if (!ctx.d.bip39.validateMnemonic(String(p.mnemonic).trim())) {
+ throw new Error("invalid BIP39 mnemonic (unknown word or bad checksum)");
+ }
+ const seed = ctx.d.bip39.mnemonicToSeedSync(String(p.mnemonic).trim());
+ seedHex = Buffer.from(seed).toString("hex");
+ path = String(p.path || (network === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0"));
+ cashaddrStr = deriveCashaddrFromSeed(seedHex, path, prefix);
+ } else if (p && p.seedHex) {
+ kind = "seed";
+ seedHex = String(p.seedHex).trim().toLowerCase().replace(/^0x/, "");
+ if (!/^[0-9a-f]{64,128}$/.test(seedHex)) throw new Error("seedHex must be 32-64 bytes of hex");
+ path = String(p.path || (network === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0"));
+ cashaddrStr = deriveCashaddrFromSeed(seedHex, path, prefix);
+ } else {
+ throw new Error("supply mnemonic, seedHex, or wif");
+ }
+
+ const spec = { kind, cashaddr: cashaddrStr, label, category, source };
+ if (kind === "seed") { spec.seed = seedHex; spec.path = path; }
+ else { spec.wif = wif; }
+ const { id: importId } = await api.vault.imports.add(spec);
+
+ // Persist as an Aegis wallet entry with kind=imported. Uses a distinct
+ // id prefix so it's obvious in storage that this row references the
+ // imports file rather than a vault-derive purpose.
+ const list = walletEntries().slice();
+ const walletId = `bch-imported-${importId}`;
+ if (list.some((w) => w.id === walletId)) throw new Error("duplicate import id");
+ const entry = {
+ id: walletId, label, chain: "bch", network,
+ kind: "imported", importId, importedCashaddr: cashaddrStr, importedCategory: category,
+ createdAt: Date.now(),
+ };
+ list.push(entry);
+ writeWallets(api, list);
+ api.storage.set("selectedWalletId", walletId);
+ ctx.runtimes.set(walletId, { entry, phase: "locked", error: null, adapter: null });
+ emitState();
+ await mountWallet(entry);
+ return fullState();
+ });
+
api.onMessage("removeWallet", (p, m) => {
fromPanel(m);
const id = String(p && p.id || "");
@@ -759,6 +954,9 @@ function registerPanelMessages(api) {
return snapshotForSelected();
});
api.onMessage("openUrl", (p, m) => { fromPanel(m); api.openTab(String(p && p.url || "")); return true; });
+ // Panel gate uses this to jump to Settings › Passwords when the vault is
+ // locked / not yet created — one-click bridge to Theseus's built-in UI.
+ api.onMessage("openSettings", (p, m) => { fromPanel(m); api.openSettings(String(p && p.section || "")); return true; });
api.onMessage("setBchServers", (p, m) => {
fromPanel(m);
@@ -927,6 +1125,37 @@ function registerPanelMessages(api) {
return out;
});
+ // Opt-in USD prices. Persist the choice so restart doesn't silently
+ // disable it, and kick a fetch immediately when switched on.
+ api.onMessage("setPricesEnabled", async (p, m) => {
+ fromPanel(m);
+ const on = !!(p && p.enabled);
+ api.storage.set("pricesEnabled", on);
+ if (ctx.priceFeed) await ctx.priceFeed.setEnabled(on);
+ return fullState();
+ });
+ api.onMessage("refreshPrices", async (_p, m) => {
+ fromPanel(m);
+ if (ctx.priceFeed) await ctx.priceFeed.refresh();
+ return fullState();
+ });
+
+ // WizardConnect: pair a wiz:// URI with a specific BCH wallet.
+ api.onMessage("wcConnect", async (p, m) => {
+ fromPanel(m);
+ if (!ctx.wc) throw new Error("WizardConnect not ready");
+ const walletId = String(p && p.walletId || "");
+ const uri = String(p && p.uri || "");
+ await ctx.wc.connectUri(walletId, uri);
+ return fullState();
+ });
+ api.onMessage("wcDisconnect", async (p, m) => {
+ fromPanel(m);
+ if (!ctx.wc) throw new Error("WizardConnect not ready");
+ await ctx.wc.disconnect(String(p && p.walletId || ""), String(p && p.connId || ""));
+ return fullState();
+ });
+
api.onMessage("permissions", (_p, m) => { fromPanel(m); return permissions(api); });
api.onMessage("revoke", (p, m) => {
fromPanel(m);
@@ -1632,13 +1861,43 @@ module.exports = {
api,
d: null,
runtimes: new Map(), // walletId -> { entry, phase, error, adapter }
+ priceFeed: require("./lib/prices.js")({
+ log: (...a) => api.log("prices", ...a),
+ onChange: () => emitState(),
+ }),
+ wc: null, // WizardConnect manager, initialised when deps load
};
migrateLegacyStorage(api);
registerPanelMessages(api);
registerPageMessages(api);
+ // Restore the user's opt-in choice from storage. Off by default so a
+ // fresh install never hits CoinGecko without asking.
+ if (api.storage.get("pricesEnabled", false)) c.priceFeed.setEnabled(true).catch(() => {});
loadDeps(api).then((d) => {
if (ctx !== c) return;
c.d = d;
+ // Start the WizardConnect manager once deps are ready. Per-wallet
+ // adapters get spun up in mountWallet() as each BCH wallet becomes
+ // available (only BCH today — hdwalletv1 is BCH-scoped).
+ c.wc = require("./lib/wc.js")({
+ HDKey: d.HDKey, secp256k1: d.secp256k1, sha256: d.sha256, hkdf: d.hkdf,
+ WalletConnectionManager: d.wcWallet.WalletConnectionManager,
+ wcCore: d.wcCore, libauth: d.libauth,
+ log: (...a) => api.log("wc", ...a),
+ api,
+ // Bridge sign approvals through the addon's approval-modal capability.
+ approvalRequest: async (payload) => {
+ const dappName = payload.request?.transaction?.userPrompt || "dapp";
+ const pick = await api.approvalModal({
+ title: `Sign transaction for ${dappName}`,
+ body: buildWcApprovalBody(payload),
+ approve: "Sign",
+ reject: "Reject",
+ });
+ return { approved: pick === "approve" };
+ },
+ });
+ c.wc.onStateChange(() => emitState());
return mountAllWallets();
}).catch((e) => {
if (ctx !== c) return;
@@ -1649,6 +1908,7 @@ module.exports = {
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 {}
}
diff --git a/lib/chain-bch-imported.js b/lib/chain-bch-imported.js
new file mode 100644
index 0000000..13dac81
--- /dev/null
+++ b/lib/chain-bch-imported.js
@@ -0,0 +1,146 @@
+// Imported BCH wallet — single-address, key material lives in Theseus's
+// wallet-imports.enc (design §3.2). This adapter mirrors chain-bch.js's
+// public shape (snapshot, refresh, plan, signAndBroadcast, dispose) but
+// does NOT go through vault.derive + HKDF: derivation is direct from the
+// seed+path or WIF that the user imported.
+//
+// M.1a scope: read-only (balance + history over Electrum). planSend/send
+// throw with a clear message until M.1b lands the sign path.
+
+module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, electrum, WebSocket, tx }) {
+
+ // Same electrum scripthash convention chain-bch uses: sha256(script), byte-
+ // reversed, hex. P2PKH-only for imports today — that's what every entry in
+ // Deviant's keystore is.
+ const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
+ const p2pkhScript = (h160) => Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]);
+ const scripthashOf = (script) => toHex(sha256(script).slice().reverse());
+ const hash160 = (b) => ripemd160(sha256(b));
+
+ const IMPORTED_BCH_NETWORKS = {
+ mainnet: {
+ id: "mainnet", label: "Mainnet", prefix: "bitcoincash",
+ explorerTx: "https://blockchair.com/bitcoin-cash/transaction/",
+ explorerAddr: "https://blockchair.com/bitcoin-cash/address/",
+ defaultServers: [
+ "wss://bch.imaginary.cash:50004",
+ "wss://cashnode.bch.ninja:50004",
+ "wss://electroncash.dk:50004",
+ "wss://fulcrum.jettscythe.xyz:50004",
+ ],
+ },
+ chipnet: {
+ id: "chipnet", label: "Chipnet testnet", prefix: "bchtest",
+ explorerTx: "https://chipnet.imaginary.cash/tx/",
+ explorerAddr: "https://chipnet.imaginary.cash/address/",
+ defaultServers: [
+ "wss://chipnet.imaginary.cash:50004",
+ "wss://chipnet.bch.ninja:50004",
+ ],
+ faucet: "https://tbch.googol.cash/",
+ },
+ };
+
+ // Decode a cashaddr → 20-byte hash160 payload. We stored cashaddr at import
+ // time and use it here to compute the scripthash for Electrum without ever
+ // asking main for the signer material — that only happens at sign time.
+ function h160OfCashaddr(addr) {
+ const clean = String(addr || "").replace(/^bitcoincash:|^bchtest:/, "");
+ const { type, hash } = cashaddr.decode(addr.includes(":") ? addr : "bitcoincash:" + clean);
+ if (type !== 0) throw new Error(`imported wallet must be P2PKH (got type ${type})`);
+ return hash;
+ }
+
+ class ImportedBchWallet {
+ constructor({ walletId, storage, log = () => {}, onChange = () => {}, network = "mainnet", cashaddr: address, servers } = {}) {
+ const net = IMPORTED_BCH_NETWORKS[network];
+ if (!net) throw new Error(`chain-bch-imported: unknown network ${network}`);
+ if (!address) throw new Error("chain-bch-imported: cashaddr required");
+ this.walletId = walletId;
+ this.chain = "bch";
+ this.network = net.id;
+ this._net = net;
+ this.log = log;
+ this.onChange = onChange;
+ this._address = address;
+ this._h160 = h160OfCashaddr(address);
+ this._script = p2pkhScript(this._h160);
+ this._scripthash = scripthashOf(this._script);
+ this._scriptHex = toHex(this._script);
+ this._servers = Array.isArray(servers) && servers.length ? servers : net.defaultServers.slice();
+ this._client = new electrum.Client(this._servers);
+ this._client.onServer = () => this._emit();
+ this._state = {
+ balance: { confirmed: 0, unconfirmed: 0 },
+ history: [],
+ height: 0,
+ scanning: false,
+ error: null,
+ };
+ }
+
+ setServers(list) {
+ this._servers = Array.isArray(list) && list.length ? list : this._net.defaultServers.slice();
+ this._client.setServers(this._servers);
+ }
+
+ _emit() { try { this.onChange(); } catch {} }
+
+ snapshot() {
+ return {
+ chain: "bch",
+ network: this._net.id,
+ ticker: "BCH",
+ decimals: 8,
+ address: this._address,
+ addressIndex: 0,
+ addressPath: null,
+ balance: this._state.balance,
+ height: this._state.height,
+ history: this._state.history,
+ scanning: this._state.scanning,
+ error: this._state.error,
+ server: this._client.url || null,
+ servers: this._servers,
+ imported: true,
+ explorerTx: this._net.explorerTx,
+ explorerAddr: this._net.explorerAddr,
+ faucet: this._net.faucet,
+ };
+ }
+
+ async refresh(full) {
+ this._state.scanning = true; this._emit();
+ try {
+ // Balance for this single scripthash.
+ const bal = await this._client.request("blockchain.scripthash.get_balance", [this._scripthash]);
+ this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) };
+ if (full) {
+ const hist = await this._client.request("blockchain.scripthash.get_history", [this._scripthash]);
+ this._state.history = (hist || []).slice(-50).map((h) => ({
+ txid: h.tx_hash, time: 0, delta: 0, confirmations: h.height > 0 ? 1 : 0,
+ }));
+ }
+ this._state.error = null;
+ } catch (e) {
+ this._state.error = e?.message || String(e);
+ } finally {
+ this._state.scanning = false;
+ this._emit();
+ }
+ }
+
+ nextAddress() { return { address: this._address, index: 0 }; }
+ current() { return { address: this._address, index: 0, branch: 0, path: null, h160: this._h160, script: this._script, scripthash: this._scripthash, scriptHex: this._scriptHex }; }
+
+ plan() { throw new Error("Imported wallets are read-only in this build. Spending support ships in the next Aegis update."); }
+ signAndBroadcast() { throw new Error("Imported wallets are read-only in this build."); }
+ signMessage() { throw new Error("Imported wallets are read-only in this build."); }
+
+ recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import (Deviant keystore or wherever you got the seed/WIF from)." }; }
+
+ dispose() { try { this._client.disconnect(); } catch {} }
+ }
+
+ return { ImportedBchWallet, IMPORTED_BCH_NETWORKS };
+};
diff --git a/lib/prices.js b/lib/prices.js
new file mode 100644
index 0000000..cf015b3
--- /dev/null
+++ b/lib/prices.js
@@ -0,0 +1,98 @@
+// Fiat prices for every Aegis-supported coin — CoinGecko's free /simple/price
+// endpoint, one request covers the lot. Opt-in via Settings so a
+// privacy-conscious user isn't quietly telling CoinGecko when Aegis is open.
+//
+// Cache is in-memory (returned by fullState() → panel). The addon polls
+// every 5 min while enabled; each fetch is cheap (~200 B response) and
+// the free tier tolerates one call/5 min per client easily.
+//
+// Trade-off named in the settings copy: CoinGecko sees the browser's IP
+// + a User-Agent every poll. Not seed-linked, not address-linked, but a
+// data point. Off by default.
+
+const COIN_GECKO_IDS = {
+ bch: "bitcoin-cash",
+ btc: "bitcoin",
+ trx: "tron",
+ eth: "ethereum",
+ sol: "solana",
+ sc: "siacoin",
+ dgb: "digibyte",
+};
+
+const ENDPOINT = "https://api.coingecko.com/api/v3/simple/price";
+const POLL_MS = 5 * 60 * 1000;
+
+module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } = {}) {
+ const state = {
+ enabled: false,
+ prices: {}, // { : usd (number) }
+ fetchedAt: null,
+ error: null,
+ loading: false,
+ };
+ let timer = null;
+
+ async function fetchOnce() {
+ if (!state.enabled) return;
+ state.loading = true; state.error = null; onChange();
+ try {
+ const ids = Object.values(COIN_GECKO_IDS).join(",");
+ const url = `${ENDPOINT}?ids=${encodeURIComponent(ids)}&vs_currencies=usd`;
+ const r = await fetch(url);
+ if (!r.ok) throw new Error(`CoinGecko HTTP ${r.status}`);
+ const body = await r.json();
+ const next = {};
+ for (const [chain, cgId] of Object.entries(COIN_GECKO_IDS)) {
+ const usd = body?.[cgId]?.usd;
+ if (typeof usd === "number") next[chain] = usd;
+ }
+ state.prices = next;
+ state.fetchedAt = Date.now();
+ state.error = null;
+ } catch (e) {
+ state.error = e?.message || String(e);
+ log("price fetch failed:", state.error);
+ } finally {
+ state.loading = false;
+ onChange();
+ }
+ }
+
+ function schedule() {
+ clearTimeout(timer);
+ if (!state.enabled) return;
+ timer = setTimeout(async () => { await fetchOnce(); schedule(); }, POLL_MS);
+ }
+
+ return {
+ // Snapshot for the panel: only what the UI needs.
+ snapshot() {
+ return {
+ enabled: state.enabled,
+ prices: state.prices,
+ fetchedAt: state.fetchedAt,
+ error: state.error,
+ loading: state.loading,
+ };
+ },
+ // Turn the feed on/off. Enabling triggers an immediate fetch so the
+ // panel doesn't wait 5 minutes for the first price.
+ async setEnabled(on) {
+ const changed = !!on !== state.enabled;
+ state.enabled = !!on;
+ if (!state.enabled) {
+ state.prices = {}; state.fetchedAt = null; state.error = null;
+ clearTimeout(timer);
+ if (changed) onChange();
+ return;
+ }
+ onChange();
+ await fetchOnce();
+ schedule();
+ },
+ // Force-refresh — bound to a manual "refresh" button in the panel.
+ refresh() { return fetchOnce(); },
+ dispose() { clearTimeout(timer); state.enabled = false; },
+ };
+};
diff --git a/lib/wc-sign.js b/lib/wc-sign.js
new file mode 100644
index 0000000..5872afc
--- /dev/null
+++ b/lib/wc-sign.js
@@ -0,0 +1,95 @@
+// WizardConnect transaction signing for Aegis.
+//
+// The dapp hands us a full BCH transaction plus its source outputs. Per the
+// WC protocol, we must sign every input with SIGHASH_ALL | FORKID | UTXOS.
+// Any other sighash flag combination MUST be rejected (protocol/security).
+//
+// This module supports P2PKH inputs only. Contract inputs (a source output
+// carrying a `contract` field) are rejected with a clear error — they need
+// script-aware signing that Aegis's BCH runtime doesn't do today.
+
+// SIGHASH byte required for this protocol: SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS
+// = 0x01 | 0x40 | 0x20 = 0x61.
+const REQUIRED_SIGHASH = 0x61;
+
+function toHex(u8) { let s = ""; for (let i = 0; i < u8.length; i++) s += u8[i].toString(16).padStart(2, "0"); return s; }
+function fromHex(h) {
+ const s = String(h || "").replace(/^0x/i, "");
+ const out = new Uint8Array(s.length / 2);
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
+ return out;
+}
+
+function ensureTransaction(txOrHex, libauth) {
+ if (typeof txOrHex === "string") {
+ const dec = libauth.decodeTransactionCommon
+ ? libauth.decodeTransactionCommon(fromHex(txOrHex))
+ : libauth.decodeTransaction(fromHex(txOrHex));
+ if (typeof dec === "string") throw new Error(`wc-sign: bad tx hex — ${dec}`);
+ return dec;
+ }
+ return txOrHex;
+}
+
+async function signTx({ request, account, branches, libauth, secp256k1 }) {
+ const {
+ generateSigningSerializationBCH,
+ hash256, encodeTransaction,
+ } = libauth;
+
+ const tx = ensureTransaction(request.transaction, libauth);
+ const sourceOutputs = (request.sourceOutputs || []).map((o, i) => {
+ if (o.contract) throw new Error(`wc-sign: input ${i} spends a contract — unsupported`);
+ return {
+ lockingBytecode: o.lockingBytecode instanceof Uint8Array ? o.lockingBytecode : fromHex(o.lockingBytecode),
+ valueSatoshis: typeof o.valueSatoshis === "bigint" ? o.valueSatoshis : BigInt(o.valueSatoshis),
+ };
+ });
+ if (sourceOutputs.length !== tx.inputs.length) {
+ throw new Error(`wc-sign: sourceOutputs (${sourceOutputs.length}) ≠ inputs (${tx.inputs.length})`);
+ }
+
+ const inputPathMap = new Map(); // inputIndex -> { branch, addressIndex }
+ for (const [inputIndex, pathName, addressIndex] of (request.inputPaths || [])) {
+ inputPathMap.set(Number(inputIndex), { pathName: String(pathName), addressIndex: Number(addressIndex) });
+ }
+
+ const signedInputs = tx.inputs.map((inp, i) => ({ ...inp }));
+
+ for (let i = 0; i < tx.inputs.length; i++) {
+ const hint = inputPathMap.get(i);
+ if (!hint) throw new Error(`wc-sign: no path for input ${i}`);
+ const branch = branches[hint.pathName];
+ if (!branch) throw new Error(`wc-sign: unknown path "${hint.pathName}"`);
+ const node = branch.deriveChild(hint.addressIndex);
+
+ const preimage = generateSigningSerializationBCH({
+ inputIndex: i,
+ signingSerializationType: new Uint8Array([REQUIRED_SIGHASH]),
+ sourceOutputs,
+ transaction: { ...tx, inputs: signedInputs },
+ });
+ const digest = hash256(preimage);
+ const sig = secp256k1.sign(digest, node.privateKey, { prehash: false, lowS: true, format: "der" });
+ // signature || sighashType byte
+ const sigWithHash = new Uint8Array(sig.length + 1);
+ sigWithHash.set(sig, 0); sigWithHash[sig.length] = REQUIRED_SIGHASH;
+
+ // P2PKH unlocking:
+ const pushSig = new Uint8Array(1 + sigWithHash.length);
+ pushSig[0] = sigWithHash.length;
+ pushSig.set(sigWithHash, 1);
+ const pushPk = new Uint8Array(1 + node.publicKey.length);
+ pushPk[0] = node.publicKey.length;
+ pushPk.set(node.publicKey, 1);
+
+ const unlocking = new Uint8Array(pushSig.length + pushPk.length);
+ unlocking.set(pushSig, 0); unlocking.set(pushPk, pushSig.length);
+ signedInputs[i].unlockingBytecode = unlocking;
+ }
+
+ const encoded = encodeTransaction({ ...tx, inputs: signedInputs });
+ return { signedTransaction: toHex(encoded) };
+}
+
+module.exports = { signTx, REQUIRED_SIGHASH };
diff --git a/lib/wc.js b/lib/wc.js
new file mode 100644
index 0000000..41c03ef
--- /dev/null
+++ b/lib/wc.js
@@ -0,0 +1,196 @@
+// WizardConnect wallet-side bridge for Aegis.
+//
+// Aegis's BCH runtime acts as a WizardConnect wallet: sites we build (dapps)
+// pair via a wiz:// URI, get xpubs for BCH derivation paths, and send us
+// sign requests that we route through the existing approval-modal capability.
+//
+// LGPL boundary: @wizardconnect/{core,wallet} are dynamic-linked via
+// api.import(); we do not statically embed them. Their sources live at
+// https://github.com/whiterun-labs/wizardconnect (also on npm) and their
+// LICENSE / copyright headers are shipped by npm inside the package.
+//
+// Docs: https://docs.riftenlabs.com/wizardconnect/
+
+const WC_PATH_RECEIVE = "receive"; // m/44'/145'/0'/0
+const WC_PATH_CHANGE = "change"; // m/44'/145'/0'/1
+const WC_PATH_CAULDRON = "defi"; // m/44'/145'/0'/7 (BCH DEX ecosystem)
+
+const WALLET_ICON = "data:image/svg+xml;utf8," + encodeURIComponent(
+ ``
+);
+
+module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnectionManager, wcCore, libauth, log = () => {}, api, approvalRequest }) {
+
+ // ---- WalletAdapter --------------------------------------------------------
+ //
+ // Bound to one BCH runtime. Uses its 32-byte root to reproduce the account
+ // HDKey and to derive per-URI relay identities via HKDF, so reconnecting
+ // yields the same Nostr identity (dapp recognises us on reload).
+ function makeAdapter({ root32, accountPath, walletId, label }) {
+ const account = HDKey.fromMasterSeed(root32).derive(accountPath);
+ const branches = new Map();
+ const branchFor = (childIndex) => {
+ let b = branches.get(childIndex);
+ if (!b) { b = account.deriveChild(childIndex); branches.set(childIndex, b); }
+ return b;
+ };
+ // WC path enum → BCH child index (identity mapping today; enum members
+ // hold the numeric child index directly — see docs/protocol.
+ const childOf = (path) => Number(path);
+
+ return {
+ walletName: label ? `Aegis · ${label}` : "Aegis",
+ walletIcon: WALLET_ICON,
+
+ // Stable identity per pairing URI. HKDF salt binds it to this wallet's
+ // root, info binds it to the URI, so:
+ // - reconnecting to the same URI = same Nostr identity
+ // - two different URIs = uncorrelatable identities (privacy)
+ getRelayPrivateKey(uri) {
+ const salt = new TextEncoder().encode("aegis/wc/relay/v1");
+ const info = new TextEncoder().encode(uri);
+ // 32 bytes for a Nostr secp256k1 private key.
+ return hkdf(sha256, root32, salt, info, 32);
+ },
+
+ getPublicKey(path, index) {
+ const branch = branchFor(childOf(path));
+ const node = branch.deriveChild(Number(index));
+ return node.publicKey; // 33 bytes compressed
+ },
+
+ getXpub(path) {
+ return branchFor(childOf(path)).publicExtendedKey;
+ },
+
+ // Sign a transaction the dapp has already assembled. See signTx.js for
+ // the heavy lifting (SIGHASH_ALL|FORKID|UTXOS enforcement, libauth
+ // preimage + secp256k1 der/lowS signatures).
+ async signTransaction(request) {
+ // Route through approval-modal first — the user always sees what
+ // they're signing before any private key touches the request.
+ if (!approvalRequest) throw new Error("no approval channel");
+ const decision = await approvalRequest({
+ kind: "wc-sign",
+ walletId, label,
+ request,
+ });
+ if (!decision?.approved) throw new Error("cancelled");
+ const { signTx } = require("./wc-sign.js");
+ return signTx({
+ request,
+ account,
+ branches: { receive: branchFor(0), change: branchFor(1), defi: branchFor(7) },
+ libauth, secp256k1,
+ });
+ },
+ };
+ }
+
+ // ---- connection tracker --------------------------------------------------
+
+ // One manager per BCH wallet. We keep them in a per-walletId map so the
+ // panel can show "Wallet A connected to 2 dapps, Wallet B to none" etc.
+ const managers = new Map(); // walletId -> WalletConnectionManager
+ const uris = new Map(); // walletId -> Set (persisted)
+ const listeners = new Set(); // () => void — panel resubscribes on state change
+
+ function fireStateChange() { for (const fn of listeners) try { fn(); } catch {} }
+
+ function persist(walletId) {
+ const list = [...(uris.get(walletId) || new Set())];
+ api.storage.set(`wc/${walletId}/uris`, list);
+ }
+
+ async function startForWallet({ walletId, label, root32, accountPath }) {
+ if (managers.has(walletId)) return managers.get(walletId);
+ const adapter = makeAdapter({ root32, accountPath, walletId, label });
+ const mgr = new WalletConnectionManager(adapter);
+ managers.set(walletId, mgr);
+
+ mgr.on("connectionsChanged", fireStateChange);
+ mgr.on("connectionStatusChanged", fireStateChange);
+ mgr.on("remoteDisconnect", (connId, reason) => {
+ log(`wc[${walletId}] remote disconnect ${connId}: ${reason}`);
+ fireStateChange();
+ });
+ mgr.on("pendingSignRequest", async ({ connectionId, request }) => {
+ try {
+ const { signedTransaction } = await adapter.signTransaction(request);
+ await mgr.sendSignResponse(connectionId, request.sequence, signedTransaction);
+ } catch (e) {
+ log(`wc[${walletId}] sign failed:`, e?.message || e);
+ try { await mgr.sendSignError(connectionId, request.sequence, cleanErrForDapp(e)); } catch {}
+ }
+ });
+
+ // Restore persisted pairings.
+ uris.set(walletId, new Set(api.storage.get(`wc/${walletId}/uris`, []) || []));
+ for (const uri of uris.get(walletId)) {
+ try { mgr.connect(uri); } catch (e) { log(`wc[${walletId}] reconnect failed:`, e?.message); }
+ }
+ return mgr;
+ }
+
+ function stopForWallet(walletId) {
+ const mgr = managers.get(walletId); if (!mgr) return;
+ try { mgr.disconnectAll?.(); } catch {}
+ managers.delete(walletId);
+ uris.delete(walletId);
+ }
+
+ async function connectUri(walletId, uri) {
+ const mgr = managers.get(walletId);
+ if (!mgr) throw new Error("wc: wallet not ready");
+ const trimmed = String(uri || "").trim();
+ if (!/^wiz:\/\//i.test(trimmed)) throw new Error("wc: URI must start with wiz://");
+ const id = mgr.connect(trimmed);
+ const set = uris.get(walletId) || new Set();
+ set.add(trimmed);
+ uris.set(walletId, set);
+ persist(walletId);
+ fireStateChange();
+ return id;
+ }
+
+ async function disconnect(walletId, connId) {
+ const mgr = managers.get(walletId); if (!mgr) return;
+ try { await mgr.disconnect(connId); } catch {}
+ // Trim the persisted URI so the next start doesn't re-add it.
+ const conn = [...(mgr.connections?.values?.() || [])].find((c) => c.id === connId);
+ if (conn?.uri) {
+ const set = uris.get(walletId); if (set) { set.delete(conn.uri); persist(walletId); }
+ }
+ fireStateChange();
+ }
+
+ function snapshot() {
+ const out = {};
+ for (const [walletId, mgr] of managers) {
+ const list = [...(mgr.connections?.values?.() || [])].map((c) => ({
+ id: c.id,
+ uri: c.uri,
+ dappName: c.dappName || null,
+ dappIcon: c.dappIcon || null,
+ status: c.status?.kind || String(c.status || "unknown"),
+ connectedAt: c.connectedAt || null,
+ }));
+ out[walletId] = list;
+ }
+ return out;
+ }
+
+ function onStateChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }
+
+ function cleanErrForDapp(e) {
+ const m = String(e?.message || e);
+ if (m === "cancelled") return "user rejected";
+ return m.replace(/\n[\s\S]*$/, "").slice(0, 200);
+ }
+
+ return { startForWallet, stopForWallet, connectUri, disconnect, snapshot, onStateChange };
+};
diff --git a/panel.html b/panel.html
index 44d477e..ec790a2 100644
--- a/panel.html
+++ b/panel.html
@@ -46,6 +46,7 @@
#drop .row .m .l, #drop .coinrow .m .l { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#drop .row .m .s, #drop .coinrow .m .s { color: var(--dim); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#drop .row .v { color: var(--mut); font-size: 12px; font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
+ #drop .row .v .fs { color: var(--dim); font-size: 11px; margin-top: 2px; }
#drop .row.on { background: rgb(from var(--acid) r g b / .10); }
#drop .coinrow .caret { color: var(--dim); font-size: 11px; }
#drop hr { border: 0; border-top: 1px solid var(--line); margin: 6px 0; }
@@ -57,6 +58,10 @@
.ttag { display: inline-block; font-size: 9.5px; letter-spacing: .06em; padding: 1px 5px; border-radius: 3px;
background: rgba(224,179,65,.18); color: #e0b341; font-weight: 700; vertical-align: middle; margin-left: 2px; }
#hNet { color: var(--dim); font-size: 11px; margin-left: 4px; font-weight: 500; }
+ .fiat { color: var(--dim); font-size: 12.5px; margin-left: 10px; font-weight: 500; letter-spacing: .2px; }
+ .portfolio { margin-top: 6px; color: var(--mut); font-size: 11.5px; }
+ .portfolio b { color: var(--ink); font-weight: 600; }
+ .portfolio[hidden] { display: none; }
.picker-actions { display: flex; align-items: center; gap: 6px; }
.chip { background: transparent; border: 1px solid var(--line); color: var(--mut); border-radius: 6px;
padding: 0 8px; height: 22px; cursor: pointer; font: inherit; font-size: 13.5px; line-height: 1;
@@ -82,12 +87,14 @@
.actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.qrwrap { display: grid; place-items: center; padding: 12px; background: #fff; border-radius: 10px; margin-bottom: 12px; }
canvas { image-rendering: pixelated; }
- input[type=text], input[type=number], textarea { width: 100%; padding: 7px 9px; border-radius: 7px; background: var(--panel);
+ input[type=text], input[type=number], input[type=password], textarea { width: 100%; padding: 7px 9px; border-radius: 7px; background: var(--panel);
border: 1px solid var(--line); color: var(--ink); font: inherit; font-size: 13px; outline: none; }
input:focus, textarea:focus { border-color: rgb(from var(--acid) r g b / .5); }
textarea { resize: vertical; min-height: 96px; font: 12px/1.45 ui-monospace, Consolas, monospace; }
.field { margin-bottom: 12px; }
.hint { color: var(--dim); font-size: 11.5px; margin-top: 4px; }
+ .switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; font-size: 12.5px; color: var(--ink); }
+ .switch input { accent-color: var(--brand, #d6ff3d); width: 15px; height: 15px; margin: 0; }
.amt { display: flex; gap: 6px; }
.amt input { flex: 1; }
.unit { display: flex; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; }
@@ -136,11 +143,15 @@
-
——
+
+ ——
+
+
connecting…
+