feat(theseus/aegis): 0.6.1 — in-panel vault setup/unlock, BCH wallet imports, opt-in fiat prices, WizardConnect
Aegis Wallet 0.4.4 → 0.6.1:
- Vault lifecycle from the wallet gate. The locked / not-yet-created states
now show a master-password form (with optional BIP39 mnemonic on setup)
instead of redirecting users to Settings › Passwords. New
api.vault.lifecycle {status, setup, unlock, lock} in addons-host, gated by
the existing "vault-derive" capability. api.openSettings(section) also
added; settings.html honours a #section hash on open.
- Imported BCH wallets (design M.1a, read-only). Paste a mnemonic + BIP44
path or a WIF; the cashaddr is derived in the add-on, the signer material
goes to a separate wallet-imports.enc via api.vault.imports {list, add,
remove, signer}. Argus password-vault gains createImports / unlockImports /
saveImports with its own KDF salt so the imports key is disjoint from the
passwords key. lib/chain-bch-imported.js is a single-address Electrum
adapter; spend support is deferred to M.1b.
- Opt-in USD prices via CoinGecko (lib/prices.js), off by default, persisted
in add-on storage. Fiat lines under balances, in the wallet picker, and a
portfolio total when 2+ wallets are open. Settings tab is now reachable
while the vault is locked so the toggle is always available.
- WizardConnect wallet-side pairing for BCH wallets (lib/wc.js, lib/wc-sign.js).
@wizardconnect/{core,wallet} are loaded dynamically via api.import to stay
on the right side of LGPL §4d. Sign requests go through approvalModal and
are restricted to P2PKH inputs with SIGHASH_ALL|FORKID|UTXOS.
- DGB adapter load is now soft-fail: when Aegis runs from userData/addons the
bundled ESM can't resolve peer deps, so DGB becomes unavailable instead of
taking the whole add-on down.
This commit is contained in:
parent
7405e444e7
commit
992c02ea89
10 changed files with 1286 additions and 28 deletions
|
|
@ -154,7 +154,7 @@ function validateManifest(raw, folderName) {
|
||||||
// Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest
|
// Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest
|
||||||
// of the app queries via `getActive()` / `getInstalled()`.
|
// of the app queries via `getActive()` / `getInstalled()`.
|
||||||
class AddonHost {
|
class AddonHost {
|
||||||
constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, captureTab, saveCapture }) {
|
constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, vaultImports, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, openSettings, captureTab, saveCapture }) {
|
||||||
this.addonsDir = addonsDir;
|
this.addonsDir = addonsDir;
|
||||||
this.dataDir = dataDir;
|
this.dataDir = dataDir;
|
||||||
this.isDisabled = isDisabled || (() => false);
|
this.isDisabled = isDisabled || (() => false);
|
||||||
|
|
@ -164,6 +164,15 @@ class AddonHost {
|
||||||
// Capability hooks injected by main. Each is (args..., addonId) so main
|
// Capability hooks injected by main. Each is (args..., addonId) so main
|
||||||
// can log/gate per add-on. Missing hook = capability unavailable.
|
// can log/gate per add-on. Missing hook = capability unavailable.
|
||||||
this._vaultDerive = typeof vaultDerive === "function" ? vaultDerive : null;
|
this._vaultDerive = typeof vaultDerive === "function" ? vaultDerive : null;
|
||||||
|
// vaultImports: main-process shim {list, add, remove, signer} that owns
|
||||||
|
// wallet-imports.enc. Same trust tier as vaultDerive — an add-on that
|
||||||
|
// holds vault-derive can also see imports (design §3.2 co-tenancy).
|
||||||
|
this._vaultImports = vaultImports && typeof vaultImports.list === "function" ? vaultImports : null;
|
||||||
|
// vaultLifecycle: main-process shim {status, setup, unlock, lock} so the
|
||||||
|
// wallet add-on can drive vault setup/unlock without redirecting users
|
||||||
|
// to Settings > Passwords. Same "vault-derive" capability gate.
|
||||||
|
this._vaultLifecycle = arguments[0].vaultLifecycle && typeof arguments[0].vaultLifecycle.unlock === "function"
|
||||||
|
? arguments[0].vaultLifecycle : null;
|
||||||
this._approvalModal = typeof approvalModal === "function" ? approvalModal : null;
|
this._approvalModal = typeof approvalModal === "function" ? approvalModal : null;
|
||||||
this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null;
|
this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null;
|
||||||
// Add-ons live outside the app's node_modules tree, so a bare require()
|
// Add-ons live outside the app's node_modules tree, so a bare require()
|
||||||
|
|
@ -177,6 +186,10 @@ class AddonHost {
|
||||||
// open-tab: opens one of the add-on's own HTML files as a full Theseus tab.
|
// open-tab: opens one of the add-on's own HTML files as a full Theseus tab.
|
||||||
// Signature: (addonId, relPath, queryString) => Promise<void>.
|
// Signature: (addonId, relPath, queryString) => Promise<void>.
|
||||||
this._openAddonTab = typeof openAddonTab === "function" ? openAddonTab : null;
|
this._openAddonTab = typeof openAddonTab === "function" ? openAddonTab : null;
|
||||||
|
// openSettings: opens Theseus's Settings tab, optionally scrolled to a
|
||||||
|
// named section (e.g. "passwords"). Uses the same IPC route the picker
|
||||||
|
// uses for "Search settings…". Signature: (section?: string) => void.
|
||||||
|
this._openSettings = typeof openSettings === "function" ? openSettings : null;
|
||||||
// Session-proxy hook — injected by main so add-ons can swap the default
|
// Session-proxy hook — injected by main so add-ons can swap the default
|
||||||
// session's proxy rules (e.g. a "route everything through my VPS" add-on).
|
// session's proxy rules (e.g. a "route everything through my VPS" add-on).
|
||||||
// Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise<void>
|
// Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise<void>
|
||||||
|
|
@ -195,6 +208,42 @@ class AddonHost {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// api.vault.lifecycle namespace: unlock/setup/status/lock the vault. Same
|
||||||
|
// "vault-derive" cap. Purpose: let the wallet add-on drive vault setup from
|
||||||
|
// its own gate instead of redirecting users to Settings > Passwords.
|
||||||
|
_makeLifecycleApi(manifest) {
|
||||||
|
const requireCap = () => {
|
||||||
|
if (!manifest.capabilities.includes("vault-derive")) {
|
||||||
|
throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`);
|
||||||
|
}
|
||||||
|
if (!this._vaultLifecycle) throw new Error("vault.lifecycle unavailable (host not wired)");
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
status: async () => { requireCap(); return this._vaultLifecycle.status(); },
|
||||||
|
unlock: async (pw) => { requireCap(); return this._vaultLifecycle.unlock(String(pw || ""), manifest.id); },
|
||||||
|
setup: async (pw, seedSource) => { requireCap(); return this._vaultLifecycle.setup(String(pw || ""), seedSource, manifest.id); },
|
||||||
|
lock: async () => { requireCap(); return this._vaultLifecycle.lock(manifest.id); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// api.vault.imports namespace factory. Gated by the "vault-derive" cap
|
||||||
|
// because the two surfaces sit at the same trust tier (design §3.2). If
|
||||||
|
// main didn't wire the vaultImports shim, calls throw a clear error.
|
||||||
|
_makeImportsApi(manifest) {
|
||||||
|
const requireCap = () => {
|
||||||
|
if (!manifest.capabilities.includes("vault-derive")) {
|
||||||
|
throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`);
|
||||||
|
}
|
||||||
|
if (!this._vaultImports) throw new Error("vault.imports unavailable (host not wired)");
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
list: async () => { requireCap(); return this._vaultImports.list(); },
|
||||||
|
add: async (spec) => { requireCap(); return this._vaultImports.add(spec, manifest.id); },
|
||||||
|
remove: async (id) => { requireCap(); return this._vaultImports.remove(String(id || ""), manifest.id); },
|
||||||
|
signer: async (id) => { requireCap(); return this._vaultImports.signer(String(id || ""), manifest.id); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
discoverAndActivate() {
|
discoverAndActivate() {
|
||||||
this.ensureDirs();
|
this.ensureDirs();
|
||||||
this._deactivateAll();
|
this._deactivateAll();
|
||||||
|
|
@ -374,6 +423,15 @@ class AddonHost {
|
||||||
}
|
}
|
||||||
return this._openAddonTab(manifest.id, s, qs);
|
return this._openAddonTab(manifest.id, s, qs);
|
||||||
},
|
},
|
||||||
|
// Open Theseus's Settings tab, optionally scrolled to a named section
|
||||||
|
// (validated against a known list in main). No capability needed —
|
||||||
|
// it's the same thing the user could do from the ⋮ menu, just a
|
||||||
|
// one-click shortcut so add-ons can point users at the right place
|
||||||
|
// (e.g. Aegis's "Set up vault" gate → Passwords).
|
||||||
|
openSettings: (section) => {
|
||||||
|
if (!this._openSettings) throw new Error("openSettings unavailable (host not wired)");
|
||||||
|
this._openSettings(typeof section === "string" ? section : "");
|
||||||
|
},
|
||||||
// Panel ↔ activate() messaging. Panels (and, for page-inject add-ons,
|
// Panel ↔ activate() messaging. Panels (and, for page-inject add-ons,
|
||||||
// injected page bridges) call into the add-on with a message name +
|
// injected page bridges) call into the add-on with a message name +
|
||||||
// one JSON payload; the handler's return value goes back as the
|
// one JSON payload; the handler's return value goes back as the
|
||||||
|
|
@ -413,6 +471,8 @@ class AddonHost {
|
||||||
}
|
}
|
||||||
return this._vaultDerive(p, manifest.id);
|
return this._vaultDerive(p, manifest.id);
|
||||||
},
|
},
|
||||||
|
imports: this._makeImportsApi(manifest),
|
||||||
|
lifecycle: this._makeLifecycleApi(manifest),
|
||||||
},
|
},
|
||||||
// approval-modal: ask the user. Resolves to the chosen action id, or
|
// approval-modal: ask the user. Resolves to the chosen action id, or
|
||||||
// "cancel" (Escape / mask click / window closed). With `checkbox` set
|
// "cancel" (Escape / mask click / window closed). With `checkbox` set
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "aegis",
|
"id": "aegis",
|
||||||
"name": "Aegis Wallet",
|
"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.",
|
"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",
|
"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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ async function loadDeps(api) {
|
||||||
const { secp256k1 } = await api.import("@noble/curves/secp256k1.js");
|
const { secp256k1 } = await api.import("@noble/curves/secp256k1.js");
|
||||||
const { ed25519 } = await api.import("@noble/curves/ed25519.js");
|
const { ed25519 } = await api.import("@noble/curves/ed25519.js");
|
||||||
const { sha256 } = await api.import("@noble/hashes/sha2.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 { ripemd160 } = await api.import("@noble/hashes/legacy.js");
|
||||||
const { keccak_256 } = await api.import("@noble/hashes/sha3.js");
|
const { keccak_256 } = await api.import("@noble/hashes/sha3.js");
|
||||||
const { blake2b } = await api.import("@noble/hashes/blake2.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
|
// bip32, ecpair, @bitcoinerlab/secp256k1) are CommonJS and reachable via
|
||||||
// api.require from the Theseus dependency tree.
|
// api.require from the Theseus dependency tree.
|
||||||
const { pathToFileURL } = require("node:url");
|
const { pathToFileURL } = require("node:url");
|
||||||
const dgbCore = await import(pathToFileURL(path.join(api.folder, "lib/dgb/core/index.js")).href);
|
// DGB's bundled ESM packages import their peer deps by bare specifier
|
||||||
const dgbPsbt = await import(pathToFileURL(path.join(api.folder, "lib/dgb/psbt/index.js")).href);
|
// ("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 bitcoinjs = api.require("bitcoinjs-lib");
|
||||||
const { BIP32Factory } = api.require("bip32");
|
const { BIP32Factory } = api.require("bip32");
|
||||||
const { ECPairFactory } = api.require("ecpair");
|
const { ECPairFactory } = api.require("ecpair");
|
||||||
const ecc = api.require("@bitcoinerlab/secp256k1");
|
const ecc = api.require("@bitcoinerlab/secp256k1");
|
||||||
const dgbAdapter = require("./lib/chain-dgb.js")({
|
if (dgbCore && dgbPsbt) {
|
||||||
dgbCore, dgbPsbt, bitcoinjs,
|
dgbAdapter = require("./lib/chain-dgb.js")({
|
||||||
bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, ecc,
|
dgbCore, dgbPsbt, bitcoinjs,
|
||||||
sha256, electrum,
|
bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, ecc,
|
||||||
});
|
sha256, electrum,
|
||||||
|
});
|
||||||
|
}
|
||||||
const btcAdapter = require("./lib/chain-btc.js")({
|
const btcAdapter = require("./lib/chain-btc.js")({
|
||||||
bitcoinjs, bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, ecc,
|
bitcoinjs, bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, ecc,
|
||||||
sha256, electrum,
|
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,
|
cashaddr, keysLib, tx, electrum, base58check,
|
||||||
bchAdapter, tronAdapter, siaAdapter, dgbAdapter, ethAdapter, solAdapter, btcAdapter,
|
bchAdapter, tronAdapter, siaAdapter, dgbAdapter, ethAdapter, solAdapter, btcAdapter,
|
||||||
dgbCore, dgbPsbt, bitcoinjs, ecc, eip712 };
|
importedBchAdapter, bip39,
|
||||||
|
dgbCore, dgbPsbt, bitcoinjs, ecc, eip712,
|
||||||
|
wcCore, wcWallet, libauth };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- servers ---------------------------------------------------------------
|
// ---- servers ---------------------------------------------------------------
|
||||||
|
|
@ -443,6 +471,30 @@ async function mountWallet(entry) {
|
||||||
rt.phase = "locked"; rt.error = null;
|
rt.phase = "locked"; rt.error = null;
|
||||||
c.runtimes.set(entry.id, rt);
|
c.runtimes.set(entry.id, rt);
|
||||||
emitState();
|
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;
|
let root;
|
||||||
try {
|
try {
|
||||||
root = await c.api.vault.derive(entry.purpose);
|
root = await c.api.vault.derive(entry.purpose);
|
||||||
|
|
@ -490,6 +542,7 @@ async function mountWallet(entry) {
|
||||||
});
|
});
|
||||||
if (walletdUrl) adapter.startPolling();
|
if (walletdUrl) adapter.startPolling();
|
||||||
} else if (entry.chain === "dgb") {
|
} 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, {
|
adapter = new c.d.dgbAdapter.DgbWallet(root, {
|
||||||
walletId: entry.id,
|
walletId: entry.id,
|
||||||
storage: c.api.storage,
|
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
|
// Kick a first fetch. Errors here don't fail the mount — the panel shows
|
||||||
// them per-wallet via snapshot.error.
|
// them per-wallet via snapshot.error.
|
||||||
adapter.refresh(true).catch((e) => c.api.log(`[${entry.id}] initial refresh:`, e?.message || e));
|
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);
|
emitStateForWallet(entry.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
rt.phase = "error";
|
rt.phase = "error";
|
||||||
|
|
@ -551,9 +610,50 @@ async function mountWallet(entry) {
|
||||||
function unmountWallet(walletId) {
|
function unmountWallet(walletId) {
|
||||||
const rt = ctx.runtimes.get(walletId);
|
const rt = ctx.runtimes.get(walletId);
|
||||||
if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} }
|
if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} }
|
||||||
|
if (ctx.wc) { try { ctx.wc.stopForWallet(walletId); } catch {} }
|
||||||
ctx.runtimes.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.
|
||||||
|
|
||||||
|
function deriveCashaddrFromSeed(seedHex, path, prefix) {
|
||||||
|
const d = ctx.d;
|
||||||
|
const seed = new Uint8Array(seedHex.length / 2);
|
||||||
|
for (let i = 0; i < seed.length; i++) seed[i] = parseInt(seedHex.substr(i * 2, 2), 16);
|
||||||
|
const root = d.HDKey.fromMasterSeed(seed);
|
||||||
|
const node = root.derive(path);
|
||||||
|
const h160 = d.ripemd160(d.sha256(node.publicKey));
|
||||||
|
return d.cashaddr.encode(prefix, 0, h160);
|
||||||
|
}
|
||||||
|
function deriveCashaddrFromWif(wif, prefix) {
|
||||||
|
const d = ctx.d;
|
||||||
|
// WIF layout: base58check(networkByte || privkey32 || [compressionByte 0x01])
|
||||||
|
const raw = d.base58check.decode(wif);
|
||||||
|
if (raw.length !== 33 && raw.length !== 34) throw new Error(`bad WIF length ${raw.length}`);
|
||||||
|
// First byte is version (network); we allow any — BCH mainnet uses 0x80,
|
||||||
|
// testnet 0xEF. Both round-trip through the same address derivation below.
|
||||||
|
const priv = raw.slice(1, 33);
|
||||||
|
const compressed = raw.length === 34; // trailing 0x01 marker
|
||||||
|
const pub = d.secp256k1.getPublicKey(priv, compressed);
|
||||||
|
const h160 = d.ripemd160(d.sha256(pub));
|
||||||
|
return d.cashaddr.encode(prefix, 0, h160);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWcApprovalBody(payload) {
|
||||||
|
const req = payload?.request || {};
|
||||||
|
const tx = req.transaction || {};
|
||||||
|
const dappName = tx.userPrompt || "unknown dapp";
|
||||||
|
const inputCount = Array.isArray(req.inputPaths) ? req.inputPaths.length : "?";
|
||||||
|
const bc = tx.broadcast ? "Dapp will broadcast after signing." : "Signed hex returned to dapp.";
|
||||||
|
const walletName = payload?.label || payload?.walletId || "";
|
||||||
|
return "<div><b>" + dappName + "</b> requests a BCH transaction signature.</div>"
|
||||||
|
+ "<div>Wallet: <b>" + walletName + "</b> · " + inputCount + " input(s).</div>"
|
||||||
|
+ "<div class=hint>" + bc + " Signs with SIGHASH_ALL|FORKID|UTXOS.</div>";
|
||||||
|
}
|
||||||
|
|
||||||
// ---- state / snapshot -------------------------------------------------------
|
// ---- state / snapshot -------------------------------------------------------
|
||||||
|
|
||||||
function selectedWalletId() {
|
function selectedWalletId() {
|
||||||
|
|
@ -629,6 +729,8 @@ function fullState() {
|
||||||
custom: Array.isArray(ctx.api.storage.get("servers", null)),
|
custom: Array.isArray(ctx.api.storage.get("servers", null)),
|
||||||
},
|
},
|
||||||
coins: coinsForPanel(),
|
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);
|
await mountWallet(entry);
|
||||||
return fullState();
|
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) => {
|
api.onMessage("removeWallet", (p, m) => {
|
||||||
fromPanel(m);
|
fromPanel(m);
|
||||||
const id = String(p && p.id || "");
|
const id = String(p && p.id || "");
|
||||||
|
|
@ -759,6 +954,9 @@ function registerPanelMessages(api) {
|
||||||
return snapshotForSelected();
|
return snapshotForSelected();
|
||||||
});
|
});
|
||||||
api.onMessage("openUrl", (p, m) => { fromPanel(m); api.openTab(String(p && p.url || "")); return true; });
|
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) => {
|
api.onMessage("setBchServers", (p, m) => {
|
||||||
fromPanel(m);
|
fromPanel(m);
|
||||||
|
|
@ -927,6 +1125,37 @@ function registerPanelMessages(api) {
|
||||||
return out;
|
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("permissions", (_p, m) => { fromPanel(m); return permissions(api); });
|
||||||
api.onMessage("revoke", (p, m) => {
|
api.onMessage("revoke", (p, m) => {
|
||||||
fromPanel(m);
|
fromPanel(m);
|
||||||
|
|
@ -1632,13 +1861,43 @@ module.exports = {
|
||||||
api,
|
api,
|
||||||
d: null,
|
d: null,
|
||||||
runtimes: new Map(), // walletId -> { entry, phase, error, adapter }
|
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);
|
migrateLegacyStorage(api);
|
||||||
registerPanelMessages(api);
|
registerPanelMessages(api);
|
||||||
registerPageMessages(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) => {
|
loadDeps(api).then((d) => {
|
||||||
if (ctx !== c) return;
|
if (ctx !== c) return;
|
||||||
c.d = d;
|
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();
|
return mountAllWallets();
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
if (ctx !== c) return;
|
if (ctx !== c) return;
|
||||||
|
|
@ -1649,6 +1908,7 @@ module.exports = {
|
||||||
deactivate() {
|
deactivate() {
|
||||||
const c = ctx; ctx = null;
|
const c = ctx; ctx = null;
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
|
try { c.priceFeed && c.priceFeed.dispose(); } catch {}
|
||||||
for (const rt of c.runtimes.values()) {
|
for (const rt of c.runtimes.values()) {
|
||||||
try { rt.adapter && rt.adapter.dispose(); } catch {}
|
try { rt.adapter && rt.adapter.dispose(); } catch {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
146
bundled-addons/aegis/lib/chain-bch-imported.js
Normal file
146
bundled-addons/aegis/lib/chain-bch-imported.js
Normal file
|
|
@ -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 };
|
||||||
|
};
|
||||||
98
bundled-addons/aegis/lib/prices.js
Normal file
98
bundled-addons/aegis/lib/prices.js
Normal file
|
|
@ -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: {}, // { <chain>: 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; },
|
||||||
|
};
|
||||||
|
};
|
||||||
95
bundled-addons/aegis/lib/wc-sign.js
Normal file
95
bundled-addons/aegis/lib/wc-sign.js
Normal file
|
|
@ -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: <sig+hashtype> <pubkey>
|
||||||
|
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 };
|
||||||
196
bundled-addons/aegis/lib/wc.js
Normal file
196
bundled-addons/aegis/lib/wc.js
Normal file
|
|
@ -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(
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none'>
|
||||||
|
<polygon points='16,2 28,9 28,23 16,30 4,23 4,9' fill='#0a0a0d' stroke='#D6FF3D' stroke-width='1.6' stroke-linejoin='round'/>
|
||||||
|
<circle cx='16' cy='16' r='4.5' fill='none' stroke='#D6FF3D' stroke-width='1.4'/>
|
||||||
|
<circle cx='16' cy='16' r='1.6' fill='#D6FF3D'/>
|
||||||
|
</svg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
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<uri> (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 };
|
||||||
|
};
|
||||||
|
|
@ -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 .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 .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 { 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 .row.on { background: rgb(from var(--acid) r g b / .10); }
|
||||||
#drop .coinrow .caret { color: var(--dim); font-size: 11px; }
|
#drop .coinrow .caret { color: var(--dim); font-size: 11px; }
|
||||||
#drop hr { border: 0; border-top: 1px solid var(--line); margin: 6px 0; }
|
#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;
|
.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; }
|
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; }
|
#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; }
|
.picker-actions { display: flex; align-items: center; gap: 6px; }
|
||||||
.chip { background: transparent; border: 1px solid var(--line); color: var(--mut); border-radius: 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;
|
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; }
|
.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; }
|
.qrwrap { display: grid; place-items: center; padding: 12px; background: #fff; border-radius: 10px; margin-bottom: 12px; }
|
||||||
canvas { image-rendering: pixelated; }
|
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; }
|
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); }
|
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; }
|
textarea { resize: vertical; min-height: 96px; font: 12px/1.45 ui-monospace, Consolas, monospace; }
|
||||||
.field { margin-bottom: 12px; }
|
.field { margin-bottom: 12px; }
|
||||||
.hint { color: var(--dim); font-size: 11.5px; margin-top: 4px; }
|
.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 { display: flex; gap: 6px; }
|
||||||
.amt input { flex: 1; }
|
.amt input { flex: 1; }
|
||||||
.unit { display: flex; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; }
|
.unit { display: flex; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; }
|
||||||
|
|
@ -136,11 +143,15 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="drop" hidden></div>
|
<div id="drop" hidden></div>
|
||||||
<div class="bal">
|
<div class="bal">
|
||||||
<div class="big"><span id="balMain">—</span><small id="balTicker">—</small></div>
|
<div class="big">
|
||||||
|
<span id="balMain">—</span><small id="balTicker">—</small>
|
||||||
|
<span id="balFiat" class="fiat" hidden></span>
|
||||||
|
</div>
|
||||||
<div class="sub">
|
<div class="sub">
|
||||||
<span class="netlbl" id="netlbl">connecting…</span>
|
<span class="netlbl" id="netlbl">connecting…</span>
|
||||||
<span class="net"><span class="dot" id="dot"></span></span>
|
<span class="net"><span class="dot" id="dot"></span></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="portfolio" class="portfolio" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<nav>
|
<nav>
|
||||||
|
|
@ -243,6 +254,23 @@
|
||||||
<button class="btn danger" id="showXprv">Show account private key</button>
|
<button class="btn danger" id="showXprv">Show account private key</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top:16px">
|
||||||
|
<div class="lbl">WizardConnect</div>
|
||||||
|
<div class="hint" style="margin-bottom:8px">
|
||||||
|
Pair this BCH wallet with a dapp that speaks the WizardConnect protocol
|
||||||
|
(Cauldron, Moria, or any site built on the SDK). Paste the <span class="mono">wiz://</span>
|
||||||
|
URI the dapp shows in its Connect dialog. Aegis signs every transaction only after you approve it.
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin-top:8px">
|
||||||
|
<input type="text" id="wcUri" spellcheck="false" placeholder="wiz://?p=…&s=…">
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn primary" id="wcConnectBtn">Connect</button>
|
||||||
|
</div>
|
||||||
|
<div class="msg" id="wcMsg" hidden></div>
|
||||||
|
<div class="kv" id="wcSites" style="margin-top:8px"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="trxSettings" hidden>
|
<div id="trxSettings" hidden>
|
||||||
|
|
@ -359,6 +387,24 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top:16px">
|
||||||
|
<div class="lbl">Fiat prices</div>
|
||||||
|
<div class="hint" style="margin-bottom:10px">
|
||||||
|
Off by default. When enabled, Aegis fetches USD prices for the seven supported coins from
|
||||||
|
<span class="mono">api.coingecko.com</span> every 5 minutes while the panel is open.
|
||||||
|
One HTTP request per interval, no keys and no address data — but CoinGecko can see your IP,
|
||||||
|
which is a signal that a wallet is open on this machine.
|
||||||
|
</div>
|
||||||
|
<div class="actions" style="align-items:center;gap:12px">
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="pricesToggle">
|
||||||
|
<span>Enable USD prices</span>
|
||||||
|
</label>
|
||||||
|
<button class="btn sm" id="refreshPrices" hidden>Refresh now</button>
|
||||||
|
<span class="hint" id="pricesStatus" style="margin-left:auto"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-top:16px">
|
<div class="card" style="margin-top:16px">
|
||||||
<div class="lbl">Connected sites</div>
|
<div class="lbl">Connected sites</div>
|
||||||
<div class="hint">Sites allowed to see your address, allowances for silent BCH payments, and Tron dapps you've connected. Message signing always asks.</div>
|
<div class="hint">Sites allowed to see your address, allowances for silent BCH payments, and Tron dapps you've connected. Message signing always asks.</div>
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,49 @@ function explorerHref(base, id) {
|
||||||
}
|
}
|
||||||
function bigUnitLabel() { return ticker(); }
|
function bigUnitLabel() { return ticker(); }
|
||||||
|
|
||||||
|
// ---- fiat helpers ----------------------------------------------------------
|
||||||
|
// Prices live in state.prices.{enabled, prices, fetchedAt}. When disabled
|
||||||
|
// or missing, fiat helpers return null and the caller renders nothing.
|
||||||
|
function priceFor(chain) {
|
||||||
|
if (!state?.prices?.enabled) return null;
|
||||||
|
return state.prices.prices?.[chain] ?? null;
|
||||||
|
}
|
||||||
|
// Convert native units (sats/lamports/wei/…) to a USD number, BigInt-safe
|
||||||
|
// for wide-decimals coins (SC=24, ETH=18) that overflow Number.
|
||||||
|
function usdOf(chain, units, decimals) {
|
||||||
|
const price = priceFor(chain);
|
||||||
|
if (price == null || !units) return null;
|
||||||
|
const d = Number(decimals) || 0;
|
||||||
|
if (typeof units === "string" && /^-?\d+$/.test(units)) {
|
||||||
|
// BigInt-safe: divide the units by 10^d first via BigInt, then use
|
||||||
|
// the fractional remainder as a Number multiplier for the last dp.
|
||||||
|
const neg = units.startsWith("-");
|
||||||
|
const abs = neg ? units.slice(1) : units;
|
||||||
|
const base = 10n ** BigInt(d);
|
||||||
|
const bi = BigInt(abs);
|
||||||
|
const whole = Number(bi / base);
|
||||||
|
const frac = Number(bi % base) / Number(base);
|
||||||
|
return (neg ? -1 : 1) * (whole + frac) * price;
|
||||||
|
}
|
||||||
|
const n = Number(units) / Math.pow(10, d);
|
||||||
|
return n * price;
|
||||||
|
}
|
||||||
|
// Format a USD value for the UI. < $0.01 → "< $0.01", < $10 → 2dp, else
|
||||||
|
// grouped whole dollars with ".xx" fine detail. Skeleton "≈ $—" when the
|
||||||
|
// feed is enabled but hasn't returned yet.
|
||||||
|
function fmtFiat(usd) {
|
||||||
|
if (usd == null) return null;
|
||||||
|
if (usd === 0) return "$0.00";
|
||||||
|
if (Math.abs(usd) < 0.01) return "< $0.01";
|
||||||
|
if (Math.abs(usd) < 10) return "$" + usd.toFixed(2);
|
||||||
|
const int = Math.floor(usd);
|
||||||
|
const frac = Math.abs(usd - int).toFixed(2).slice(1);
|
||||||
|
return "$" + int.toLocaleString("en-US") + frac;
|
||||||
|
}
|
||||||
|
function fiatSkeleton() {
|
||||||
|
return state?.prices?.enabled ? "≈ $—" : null;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- tabs ------------------------------------------------------------------
|
// ---- tabs ------------------------------------------------------------------
|
||||||
|
|
||||||
document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab)));
|
document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab)));
|
||||||
|
|
@ -122,6 +165,14 @@ function showTab(name) {
|
||||||
document.querySelectorAll("main section").forEach((s) => { s.hidden = s.id !== "tab-" + name; });
|
document.querySelectorAll("main section").forEach((s) => { s.hidden = s.id !== "tab-" + name; });
|
||||||
if (name === "settings") { settingsFilled = false; fillSettings(); }
|
if (name === "settings") { settingsFilled = false; fillSettings(); }
|
||||||
if (name === "send") applyUnitPicker();
|
if (name === "send") applyUnitPicker();
|
||||||
|
// Settings tab is always usable (fiat prices are a global setting); every
|
||||||
|
// other tab is gated by the wallet-ready state. Re-run gate visibility so
|
||||||
|
// switching TO or AWAY FROM Settings while locked does the right thing.
|
||||||
|
const s = sel();
|
||||||
|
const ready = s && s.phase === "ready";
|
||||||
|
const onSettings = name === "settings";
|
||||||
|
$("tabs").hidden = !(ready || onSettings);
|
||||||
|
$("gate").hidden = ready || onSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- wallet picker (two-step add) ------------------------------------------
|
// ---- wallet picker (two-step add) ------------------------------------------
|
||||||
|
|
@ -155,12 +206,20 @@ function fillPicker() {
|
||||||
const coins = state?.coins || [];
|
const coins = state?.coins || [];
|
||||||
const rowsHtml = wallets.map((w) => {
|
const rowsHtml = wallets.map((w) => {
|
||||||
const on = w.id === state.selectedWalletId ? "on" : "";
|
const on = w.id === state.selectedWalletId ? "on" : "";
|
||||||
const bal = w.balance ? fmtBig(w.balance.confirmed || 0, w.decimals) + " " + w.ticker : "—";
|
const totalUnits = w.balance ? (typeof w.balance.confirmed === "string"
|
||||||
|
? (BigInt(w.balance.confirmed || "0") + BigInt(w.balance.unconfirmed || "0")).toString()
|
||||||
|
: (w.balance.confirmed || 0) + (w.balance.unconfirmed || 0)) : 0;
|
||||||
|
const bal = w.balance ? fmtBig(totalUnits, w.decimals) + " " + w.ticker : "—";
|
||||||
|
// Fiat sits on a second line under the native balance, right-aligned.
|
||||||
|
// Testnet coins mirror mainnet prices, so we don't dim them.
|
||||||
|
const usd = usdOf(w.chain, totalUnits, w.decimals);
|
||||||
|
const fiat = fmtFiat(usd);
|
||||||
|
const fiatLine = fiat ? `<div class="fs">${esc(fiat)}</div>` : "";
|
||||||
const sub = `${esc(w.coinLabel)} · ${esc(w.networkLabel)}${w.testnet ? " " + testnetTag() : ""}`;
|
const sub = `${esc(w.coinLabel)} · ${esc(w.networkLabel)}${w.testnet ? " " + testnetTag() : ""}`;
|
||||||
return `<div class="row ${on}" data-select="${esc(w.id)}">
|
return `<div class="row ${on}" data-select="${esc(w.id)}">
|
||||||
${logoSvg(w.logo, 22)}
|
${logoSvg(w.logo, 22)}
|
||||||
<div class="m"><div class="l">${esc(w.label)}</div><div class="s">${sub}</div></div>
|
<div class="m"><div class="l">${esc(w.label)}</div><div class="s">${sub}</div></div>
|
||||||
<div class="v">${esc(bal)}</div>
|
<div class="v"><div>${esc(bal)}</div>${fiatLine}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
// "Add wallet" is a two-step flyout: first show coins, then that coin's
|
// "Add wallet" is a two-step flyout: first show coins, then that coin's
|
||||||
|
|
@ -183,7 +242,13 @@ function fillPicker() {
|
||||||
}).join("");
|
}).join("");
|
||||||
d.innerHTML =
|
d.innerHTML =
|
||||||
rowsHtml +
|
rowsHtml +
|
||||||
`<hr><div class="addhdr">+ Add wallet</div>${coinRows}`;
|
`<hr><div class="addhdr">+ Add wallet</div>${coinRows}` +
|
||||||
|
`<hr><div class="addhdr">↓ Import existing (BCH)</div>
|
||||||
|
<div class="coinrow" id="picker-import-bch">
|
||||||
|
${logoSvg("bch", 22)}
|
||||||
|
<div class="m"><div class="l">Import a BCH wallet</div><div class="s">Paste a BIP39 mnemonic + derivation path, or a WIF</div></div>
|
||||||
|
<div class="caret">›</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
d.querySelectorAll("[data-select]").forEach((r) => r.addEventListener("click", async () => {
|
d.querySelectorAll("[data-select]").forEach((r) => r.addEventListener("click", async () => {
|
||||||
d.hidden = true;
|
d.hidden = true;
|
||||||
|
|
@ -204,6 +269,112 @@ function fillPicker() {
|
||||||
try { state = await S.invoke("addWallet", { chain: c, network: n }); settingsFilled = false; render(); }
|
try { state = await S.invoke("addWallet", { chain: c, network: n }); settingsFilled = false; render(); }
|
||||||
catch (e) { showErr(cleanErr(e)); }
|
catch (e) { showErr(cleanErr(e)); }
|
||||||
}));
|
}));
|
||||||
|
const impBtn = $("picker-import-bch");
|
||||||
|
if (impBtn) impBtn.addEventListener("click", () => { d.hidden = true; openImportModal("bch"); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import modal — M.1 UX. Paste mnemonic + path OR WIF, choose network + label
|
||||||
|
// + category. Backend derives cashaddr and stores signer material in
|
||||||
|
// wallet-imports.enc (design §3.2). Modal is a plain overlay div injected
|
||||||
|
// into the panel body so it works over any tab.
|
||||||
|
function openImportModal(chain) {
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;z-index:9999;padding-top:24px";
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div style="width:min(94vw,380px);background:var(--panel,#12161e);border:1px solid var(--line,#2a2f38);border-radius:10px;padding:14px 14px 12px;box-shadow:0 10px 40px rgba(0,0,0,.4)">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
|
||||||
|
${logoSvg("bch", 22)}
|
||||||
|
<div style="font-weight:600;flex:1">Import a BCH wallet</div>
|
||||||
|
<button class="btn sm" id="imClose" type="button">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint" style="margin-bottom:10px">Key material stays in Theseus's vault (wallet-imports.enc). Aegis derives only the address and shows the balance — spending support ships next.</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="lbl">Network</div>
|
||||||
|
<div style="display:flex;gap:12px;font-size:12.5px;margin-top:4px">
|
||||||
|
<label><input type="radio" name="imNet" value="chipnet" checked> Chipnet testnet</label>
|
||||||
|
<label><input type="radio" name="imNet" value="mainnet"> Mainnet</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="lbl">Source</div>
|
||||||
|
<div style="display:flex;gap:12px;font-size:12.5px;margin-top:4px">
|
||||||
|
<label><input type="radio" name="imKind" value="mnemonic" checked> BIP39 mnemonic</label>
|
||||||
|
<label><input type="radio" name="imKind" value="wif"> WIF</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field" id="imMnemonicField">
|
||||||
|
<div class="lbl">Mnemonic (12/24 words)</div>
|
||||||
|
<textarea id="imMnemonic" spellcheck="false" rows="2" style="font-family:ui-monospace,monospace;font-size:12px" placeholder="paste the seed phrase"></textarea>
|
||||||
|
<div class="lbl" style="margin-top:6px">Derivation path</div>
|
||||||
|
<input type="text" id="imPath" spellcheck="false" placeholder="m/44'/1'/0'/0/0">
|
||||||
|
<div class="hint">Default: <span class="mono">m/44'/1'/0'/0/0</span> for chipnet, <span class="mono">m/44'/145'/0'/0/0</span> for mainnet.</div>
|
||||||
|
</div>
|
||||||
|
<div class="field" id="imWifField" hidden>
|
||||||
|
<div class="lbl">WIF private key</div>
|
||||||
|
<input type="text" id="imWif" spellcheck="false" placeholder="Kx… or Lz… (base58check)">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="lbl">Label</div>
|
||||||
|
<input type="text" id="imLabel" placeholder="e.g. Sirius.x · BNS name buyer">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="lbl">Category</div>
|
||||||
|
<select id="imCategory" style="width:100%;padding:7px 9px;border-radius:7px;background:var(--panel);border:1px solid var(--line);color:var(--ink);font-size:13px">
|
||||||
|
<option value="operational">operational</option>
|
||||||
|
<option value="bns">bns</option>
|
||||||
|
<option value="bns-infra">bns-infra</option>
|
||||||
|
<option value="chipnet-test">chipnet-test</option>
|
||||||
|
<option value="hd-general">hd-general</option>
|
||||||
|
<option value="primary">primary</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="msg err" id="imMsg" hidden style="margin-top:8px"></div>
|
||||||
|
<div class="actions" style="justify-content:flex-end;margin-top:10px">
|
||||||
|
<button class="btn" id="imCancel">Cancel</button>
|
||||||
|
<button class="btn primary" id="imGo">Import</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
const close = () => { try { overlay.remove(); } catch {} };
|
||||||
|
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
||||||
|
overlay.querySelector("#imClose").addEventListener("click", close);
|
||||||
|
overlay.querySelector("#imCancel").addEventListener("click", close);
|
||||||
|
// Toggle mnemonic vs WIF fields.
|
||||||
|
overlay.querySelectorAll('input[name="imKind"]').forEach((r) => r.addEventListener("change", () => {
|
||||||
|
const kind = overlay.querySelector('input[name="imKind"]:checked').value;
|
||||||
|
overlay.querySelector("#imMnemonicField").hidden = kind !== "mnemonic";
|
||||||
|
overlay.querySelector("#imWifField").hidden = kind !== "wif";
|
||||||
|
}));
|
||||||
|
// Update default path on network switch.
|
||||||
|
const setDefaultPath = () => {
|
||||||
|
const net = overlay.querySelector('input[name="imNet"]:checked').value;
|
||||||
|
const path = overlay.querySelector("#imPath");
|
||||||
|
if (!path.value.trim()) path.value = net === "mainnet" ? "m/44'/145'/0'/0/0" : "m/44'/1'/0'/0/0";
|
||||||
|
};
|
||||||
|
overlay.querySelectorAll('input[name="imNet"]').forEach((r) => r.addEventListener("change", setDefaultPath));
|
||||||
|
setDefaultPath();
|
||||||
|
overlay.querySelector("#imGo").addEventListener("click", async () => {
|
||||||
|
const msg = overlay.querySelector("#imMsg"); msg.hidden = true;
|
||||||
|
const network = overlay.querySelector('input[name="imNet"]:checked').value;
|
||||||
|
const kind = overlay.querySelector('input[name="imKind"]:checked').value;
|
||||||
|
const label = overlay.querySelector("#imLabel").value.trim();
|
||||||
|
const category = overlay.querySelector("#imCategory").value;
|
||||||
|
if (!label) { msg.textContent = "Label required."; msg.hidden = false; return; }
|
||||||
|
const payload = { chain: "bch", network, label, category };
|
||||||
|
if (kind === "mnemonic") {
|
||||||
|
payload.mnemonic = overlay.querySelector("#imMnemonic").value.trim();
|
||||||
|
payload.path = overlay.querySelector("#imPath").value.trim();
|
||||||
|
if (!payload.mnemonic) { msg.textContent = "Mnemonic required."; msg.hidden = false; return; }
|
||||||
|
} else {
|
||||||
|
payload.wif = overlay.querySelector("#imWif").value.trim();
|
||||||
|
if (!payload.wif) { msg.textContent = "WIF required."; msg.hidden = false; return; }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
state = await S.invoke("importWallet", payload);
|
||||||
|
close();
|
||||||
|
render();
|
||||||
|
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function showErr(text) {
|
function showErr(text) {
|
||||||
const box = $("gate");
|
const box = $("gate");
|
||||||
|
|
@ -217,9 +388,13 @@ function render() {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
const s = sel();
|
const s = sel();
|
||||||
const ready = s && s.phase === "ready";
|
const ready = s && s.phase === "ready";
|
||||||
$("tabs").hidden = !ready;
|
// Settings is the only always-usable tab (fiat prices, connected sites
|
||||||
|
// — nothing needs a live wallet). Every other tab is gated.
|
||||||
|
const onSettings = tab === "settings";
|
||||||
|
$("tabs").hidden = !(ready || onSettings);
|
||||||
const gate = $("gate");
|
const gate = $("gate");
|
||||||
gate.hidden = ready;
|
gate.hidden = ready || onSettings;
|
||||||
|
if (onSettings) fillSettings();
|
||||||
// Header: replace the badge slot with the coin's SVG and show
|
// Header: replace the badge slot with the coin's SVG and show
|
||||||
// <wallet label> <coin · network + optional TEST tag>
|
// <wallet label> <coin · network + optional TEST tag>
|
||||||
$("hBadge").innerHTML = s?.meta?.logo ? logoSvg(s.meta.logo, 22) : logoSvg(null, 22);
|
$("hBadge").innerHTML = s?.meta?.logo ? logoSvg(s.meta.logo, 22) : logoSvg(null, 22);
|
||||||
|
|
@ -229,17 +404,39 @@ function render() {
|
||||||
: "";
|
: "";
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
const copy = {
|
const copy = {
|
||||||
locked: ["🔒", "Unlock your password vault to open the wallet.", "Settings › Passwords. Aegis derives its keys from the vault seed, so there is nothing separate to unlock."],
|
locked: ["🔒", "Unlock your password vault to open the wallet.", "Aegis derives its keys from the vault seed, so there is nothing separate to unlock — the vault is your wallet."],
|
||||||
nosetup: ["🗝", "Set up a password vault to create your wallet.", "Settings › Passwords › Set up. Use a recovery phrase there and every wallet in Aegis can be recreated from it on any machine."],
|
nosetup: ["🗝", "Set up a password vault to create your wallet.", "Pick a master password on the next screen. Every Aegis wallet is derived from it — the same master password on another machine recreates the same addresses."],
|
||||||
error: ["⚠", "This wallet could not start.", s?.error || ""],
|
error: ["⚠", "This wallet could not start.", s?.error || ""],
|
||||||
empty: ["🛡", "No wallets yet.", "Aegis derives every wallet from your Theseus password vault — there's no separate seed to import. Pick a coin below to create your first one."],
|
empty: ["🛡", "No wallets yet.", "Aegis derives every wallet from your Theseus password vault — there's no separate seed to import. Pick a coin below to create your first one."],
|
||||||
}[s?.phase || "locked"] || ["…", "Starting…", ""];
|
}[s?.phase || "locked"] || ["…", "Starting…", ""];
|
||||||
const showAddButton = s?.phase === "empty";
|
const phase = s?.phase;
|
||||||
gate.innerHTML = `<div class="big">${copy[0]}</div><div><b>${esc(copy[1])}</b></div><div class="hint" style="margin-top:8px">${esc(copy[2])}</div>`
|
let form = "";
|
||||||
+ (showAddButton ? `<div class="actions" style="justify-content:center;margin-top:16px"><button class="btn primary" id="gateAddWallet">+ Add your first wallet</button></div>` : "");
|
if (phase === "nosetup") {
|
||||||
// Wire the empty-state button — opens the header picker with the Add
|
form = `
|
||||||
// section pre-expanded so the user can pick a coin in one click.
|
<div class="gateform" style="margin-top:14px;display:flex;flex-direction:column;gap:8px;text-align:left">
|
||||||
if (showAddButton) {
|
<input type="password" id="gateSetupPw" placeholder="Master password (4+ chars)" autocomplete="new-password">
|
||||||
|
<input type="password" id="gateSetupPw2" placeholder="Confirm master password" autocomplete="new-password">
|
||||||
|
<textarea id="gateSetupMnemonic" placeholder="BIP39 mnemonic — optional, 12 or 24 words" rows="2" spellcheck="false" style="font-family:ui-monospace,monospace;font-size:12px"></textarea>
|
||||||
|
<div class="hint">Optional. Paste a mnemonic to derive your vault from an existing seed (Ariadne mobile, another Theseus profile, etc.). Leave empty for a fresh independent seed.</div>
|
||||||
|
<div class="actions" style="justify-content:center;margin-top:6px">
|
||||||
|
<button class="btn primary" id="gateSetupBtn">Create vault</button>
|
||||||
|
</div>
|
||||||
|
<div class="msg err" id="gateSetupMsg" hidden></div>
|
||||||
|
</div>`;
|
||||||
|
} else if (phase === "locked") {
|
||||||
|
form = `
|
||||||
|
<div class="gateform" style="margin-top:14px;display:flex;flex-direction:column;gap:8px;text-align:left">
|
||||||
|
<input type="password" id="gateUnlockPw" placeholder="Master password" autocomplete="current-password" autofocus>
|
||||||
|
<div class="actions" style="justify-content:center">
|
||||||
|
<button class="btn primary" id="gateUnlockBtn">Unlock</button>
|
||||||
|
</div>
|
||||||
|
<div class="msg err" id="gateUnlockMsg" hidden></div>
|
||||||
|
</div>`;
|
||||||
|
} else if (phase === "empty") {
|
||||||
|
form = `<div class="actions" style="justify-content:center;margin-top:16px"><button class="btn primary" id="gateAddWallet">+ Add your first wallet</button></div>`;
|
||||||
|
}
|
||||||
|
gate.innerHTML = `<div class="big">${copy[0]}</div><div><b>${esc(copy[1])}</b></div><div class="hint" style="margin-top:8px">${esc(copy[2])}</div>${form}`;
|
||||||
|
if (phase === "empty") {
|
||||||
const btn = $("gateAddWallet");
|
const btn = $("gateAddWallet");
|
||||||
if (btn) btn.addEventListener("click", () => {
|
if (btn) btn.addEventListener("click", () => {
|
||||||
const d = $("drop");
|
const d = $("drop");
|
||||||
|
|
@ -248,6 +445,34 @@ function render() {
|
||||||
setTimeout(() => { const first = d.querySelector(".coinrow"); if (first) first.click(); }, 0);
|
setTimeout(() => { const first = d.querySelector(".coinrow"); if (first) first.click(); }, 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (phase === "locked") {
|
||||||
|
const doUnlock = async () => {
|
||||||
|
const pw = $("gateUnlockPw").value;
|
||||||
|
const msg = $("gateUnlockMsg"); msg.hidden = true;
|
||||||
|
if (!pw) return;
|
||||||
|
try { state = await S.invoke("vaultUnlock", { masterPassword: pw }); render(); }
|
||||||
|
catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
|
||||||
|
};
|
||||||
|
$("gateUnlockBtn").addEventListener("click", doUnlock);
|
||||||
|
$("gateUnlockPw").addEventListener("keydown", (e) => { if (e.key === "Enter") doUnlock(); });
|
||||||
|
try { $("gateUnlockPw").focus(); } catch {}
|
||||||
|
}
|
||||||
|
if (phase === "nosetup") {
|
||||||
|
const doSetup = async () => {
|
||||||
|
const pw = $("gateSetupPw").value;
|
||||||
|
const pw2 = $("gateSetupPw2").value;
|
||||||
|
const mnemonic = $("gateSetupMnemonic").value.trim();
|
||||||
|
const msg = $("gateSetupMsg"); msg.hidden = true;
|
||||||
|
if (!pw || pw.length < 4) { msg.textContent = "Master password must be 4+ characters."; msg.hidden = false; return; }
|
||||||
|
if (pw !== pw2) { msg.textContent = "Master passwords don't match."; msg.hidden = false; return; }
|
||||||
|
const seedSource = mnemonic ? { kind: "mnemonic", mnemonic } : { kind: "random" };
|
||||||
|
try {
|
||||||
|
state = await S.invoke("vaultSetup", { masterPassword: pw, seedSource });
|
||||||
|
render();
|
||||||
|
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
|
||||||
|
};
|
||||||
|
$("gateSetupBtn").addEventListener("click", doSetup);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const dot = $("dot");
|
const dot = $("dot");
|
||||||
dot.className = "dot " + (s?.server ? (s?.scanning ? "busy" : "on") : "");
|
dot.className = "dot " + (s?.server ? (s?.scanning ? "busy" : "on") : "");
|
||||||
|
|
@ -268,9 +493,16 @@ function render() {
|
||||||
$("balTicker").textContent = s.meta.ticker;
|
$("balTicker").textContent = s.meta.ticker;
|
||||||
const uc = s.balance?.unconfirmed;
|
const uc = s.balance?.unconfirmed;
|
||||||
if (uc && uc !== "0" && uc !== 0) $("netlbl").textContent += ` · ${fmtBig(uc)} unconfirmed`;
|
if (uc && uc !== "0" && uc !== 0) $("netlbl").textContent += ` · ${fmtBig(uc)} unconfirmed`;
|
||||||
|
// Fiat under the native amount (opt-in, might be null while loading).
|
||||||
|
const usd = usdOf(chain(), total, decimals());
|
||||||
|
const fiat = fmtFiat(usd) || fiatSkeleton();
|
||||||
|
$("balFiat").textContent = fiat || "";
|
||||||
|
$("balFiat").hidden = !fiat;
|
||||||
} else {
|
} else {
|
||||||
$("balMain").textContent = "—"; $("balTicker").textContent = "";
|
$("balMain").textContent = "—"; $("balTicker").textContent = "";
|
||||||
|
$("balFiat").hidden = true;
|
||||||
}
|
}
|
||||||
|
renderPortfolio();
|
||||||
if (!ready) return;
|
if (!ready) return;
|
||||||
const addr = s.address || "";
|
const addr = s.address || "";
|
||||||
if ($("addr").textContent !== addr) {
|
if ($("addr").textContent !== addr) {
|
||||||
|
|
@ -288,6 +520,33 @@ function render() {
|
||||||
renderHistory();
|
renderHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sum every wallet's confirmed+unconfirmed × price and show "≈ $X across N
|
||||||
|
// wallets" under the header. Only rendered when prices are on AND there are
|
||||||
|
// two or more wallets (a single wallet's fiat already sits in #balFiat).
|
||||||
|
function renderPortfolio() {
|
||||||
|
const el = $("portfolio");
|
||||||
|
const wallets = state?.wallets || [];
|
||||||
|
if (!state?.prices?.enabled || wallets.length < 2) { el.hidden = true; return; }
|
||||||
|
let total = 0, priced = 0;
|
||||||
|
for (const w of wallets) {
|
||||||
|
const b = w.balance;
|
||||||
|
if (!b) continue;
|
||||||
|
const units = (typeof b.confirmed === "string")
|
||||||
|
? (BigInt(b.confirmed || "0") + BigInt(b.unconfirmed || "0")).toString()
|
||||||
|
: (b.confirmed || 0) + (b.unconfirmed || 0);
|
||||||
|
const usd = usdOf(w.chain, units, w.decimals);
|
||||||
|
if (usd != null) { total += usd; priced++; }
|
||||||
|
}
|
||||||
|
if (!priced) {
|
||||||
|
el.hidden = false;
|
||||||
|
el.innerHTML = `Portfolio: <b>${esc(fiatSkeleton() || "—")}</b>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const noun = wallets.length === 1 ? "wallet" : "wallets";
|
||||||
|
el.hidden = false;
|
||||||
|
el.innerHTML = `Portfolio: <b>${esc(fmtFiat(total))}</b> across ${wallets.length} ${noun}`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderTokens() {
|
function renderTokens() {
|
||||||
const s = sel();
|
const s = sel();
|
||||||
const tokens = (chain() === "sol" && s?.tokens) || [];
|
const tokens = (chain() === "sol" && s?.tokens) || [];
|
||||||
|
|
@ -550,7 +809,23 @@ $("sendBtn").addEventListener("click", async () => {
|
||||||
// ---- settings --------------------------------------------------------------
|
// ---- settings --------------------------------------------------------------
|
||||||
|
|
||||||
function fillSettings() {
|
function fillSettings() {
|
||||||
const s = sel(); if (!s) return;
|
// Global settings (fiat prices, connected sites) render even when there
|
||||||
|
// is no active wallet / the vault is locked.
|
||||||
|
renderPricesSetting();
|
||||||
|
renderSites();
|
||||||
|
const s = sel();
|
||||||
|
const chainSetup = !!s && s.phase === "ready";
|
||||||
|
$("walletManage").hidden = !chainSetup;
|
||||||
|
if (!chainSetup) {
|
||||||
|
$("bchSettings").hidden = true;
|
||||||
|
$("trxSettings").hidden = true;
|
||||||
|
$("scSettings").hidden = true;
|
||||||
|
$("dgbSettings").hidden = true;
|
||||||
|
$("btcSettings").hidden = true;
|
||||||
|
$("ethSettings").hidden = true;
|
||||||
|
$("solSettings").hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
$("bchSettings").hidden = chain() !== "bch";
|
$("bchSettings").hidden = chain() !== "bch";
|
||||||
$("trxSettings").hidden = chain() !== "trx";
|
$("trxSettings").hidden = chain() !== "trx";
|
||||||
$("scSettings").hidden = chain() !== "sc";
|
$("scSettings").hidden = chain() !== "sc";
|
||||||
|
|
@ -574,6 +849,7 @@ function fillSettings() {
|
||||||
? "Chipnet uses bundled defaults in this build."
|
? "Chipnet uses bundled defaults in this build."
|
||||||
: (state.bchServers?.custom ? "Custom list." : "Bundled defaults.") + (s.server ? " Connected to " + hostOf(s.server) + "." : " Not connected.");
|
: (state.bchServers?.custom ? "Custom list." : "Bundled defaults.") + (s.server ? " Connected to " + hostOf(s.server) + "." : " Not connected.");
|
||||||
$("purpose").textContent = "silentmode/addons/" + (s.purpose || "");
|
$("purpose").textContent = "silentmode/addons/" + (s.purpose || "");
|
||||||
|
renderWcSites();
|
||||||
} else if (chain() === "trx") {
|
} else if (chain() === "trx") {
|
||||||
$("trxPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
|
$("trxPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
|
||||||
} else if (chain() === "sc") {
|
} else if (chain() === "sc") {
|
||||||
|
|
@ -612,7 +888,27 @@ function fillSettings() {
|
||||||
$("solPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
|
$("solPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
|
||||||
$("solRecovery").innerHTML = "";
|
$("solRecovery").innerHTML = "";
|
||||||
}
|
}
|
||||||
renderSites();
|
}
|
||||||
|
|
||||||
|
// Reflect the current price feed state into the Settings toggle + status
|
||||||
|
// line. Called from fillSettings() and whenever fresh state arrives.
|
||||||
|
function renderPricesSetting() {
|
||||||
|
const p = state?.prices;
|
||||||
|
const toggle = $("pricesToggle");
|
||||||
|
if (!toggle) return;
|
||||||
|
toggle.checked = !!p?.enabled;
|
||||||
|
$("refreshPrices").hidden = !p?.enabled;
|
||||||
|
const st = $("pricesStatus");
|
||||||
|
if (!p?.enabled) { st.textContent = "Disabled — no requests made."; return; }
|
||||||
|
if (p.loading) { st.textContent = "Fetching…"; return; }
|
||||||
|
if (p.error) { st.textContent = "Error: " + p.error; return; }
|
||||||
|
if (p.fetchedAt) {
|
||||||
|
const secs = Math.round((Date.now() - p.fetchedAt) / 1000);
|
||||||
|
const when = secs < 60 ? `${secs}s ago` : `${Math.round(secs / 60)}m ago`;
|
||||||
|
st.textContent = `Updated ${when} · ${Object.keys(p.prices || {}).length} coins.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
st.textContent = "Enabled — first fetch pending.";
|
||||||
}
|
}
|
||||||
async function renderSites() {
|
async function renderSites() {
|
||||||
let perms = {};
|
let perms = {};
|
||||||
|
|
@ -799,6 +1095,61 @@ $("showDgbXprv").addEventListener("click", async () => {
|
||||||
catch (e) { $("dgbRecovery").textContent = cleanErr(e); }
|
catch (e) { $("dgbRecovery").textContent = cleanErr(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- WizardConnect (BCH only) ---------------------------------------------
|
||||||
|
function renderWcSites() {
|
||||||
|
const el = $("wcSites"); if (!el) return;
|
||||||
|
const walletId = state?.selectedWalletId;
|
||||||
|
const conns = (state?.wc && state.wc[walletId]) || [];
|
||||||
|
if (!conns.length) { el.innerHTML = `<div class="hint">No dapps paired yet.</div>`; return; }
|
||||||
|
el.innerHTML = conns.map((c) => {
|
||||||
|
const label = c.dappName || "(pairing…)";
|
||||||
|
const iconHtml = c.dappIcon ? `<img src="${esc(c.dappIcon)}" style="width:18px;height:18px;border-radius:4px" onerror="this.hidden=true">` : "";
|
||||||
|
return `<div class="tx" style="grid-template-columns:auto 1fr auto;cursor:default;align-items:center">
|
||||||
|
<div>${iconHtml}</div>
|
||||||
|
<div><div>${esc(label)}</div><div class="hint mono">${esc((c.uri || "").slice(0, 46))}…</div></div>
|
||||||
|
<button class="btn sm" data-wcconn="${esc(c.id)}">Disconnect</button>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
el.querySelectorAll("button[data-wcconn]").forEach((b) => b.addEventListener("click", async () => {
|
||||||
|
try { state = await S.invoke("wcDisconnect", { walletId, connId: b.dataset.wcconn }); render(); }
|
||||||
|
catch (e) { const m = $("wcMsg"); m.className = "msg err"; m.textContent = cleanErr(e); m.hidden = false; }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
$("wcConnectBtn").addEventListener("click", async () => {
|
||||||
|
const walletId = state?.selectedWalletId;
|
||||||
|
const uri = $("wcUri").value.trim();
|
||||||
|
const m = $("wcMsg"); m.hidden = true;
|
||||||
|
if (!uri) return;
|
||||||
|
try {
|
||||||
|
state = await S.invoke("wcConnect", { walletId, uri });
|
||||||
|
$("wcUri").value = "";
|
||||||
|
m.className = "msg ok"; m.textContent = "Pairing…"; m.hidden = false;
|
||||||
|
render();
|
||||||
|
} catch (e) {
|
||||||
|
m.className = "msg err"; m.textContent = cleanErr(e); m.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- prices toggle ---------------------------------------------------------
|
||||||
|
$("pricesToggle").addEventListener("change", async () => {
|
||||||
|
const on = $("pricesToggle").checked;
|
||||||
|
try {
|
||||||
|
state = await S.invoke("setPricesEnabled", { enabled: on });
|
||||||
|
render(); if (tab === "settings") renderPricesSetting();
|
||||||
|
} catch (e) {
|
||||||
|
// Roll the checkbox back if the host rejected the change.
|
||||||
|
$("pricesToggle").checked = !on;
|
||||||
|
$("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$("refreshPrices").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await S.invoke("refreshPrices");
|
||||||
|
// The host emits a state event on completion; the render will pick it up.
|
||||||
|
renderPricesSetting();
|
||||||
|
} catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; }
|
||||||
|
});
|
||||||
|
|
||||||
// ---- boot ------------------------------------------------------------------
|
// ---- boot ------------------------------------------------------------------
|
||||||
S.on("state", (s) => { state = s; render(); if (tab === "settings") fillSettings(); });
|
S.on("state", (s) => { state = s; render(); if (tab === "settings") fillSettings(); });
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
|
||||||
|
|
@ -626,6 +626,12 @@
|
||||||
// Main-process asks us to jump to a section (e.g. picker's "Search settings…"
|
// Main-process asks us to jump to a section (e.g. picker's "Search settings…"
|
||||||
// click routes to the Search section instead of the General default).
|
// click routes to the Search section instead of the General default).
|
||||||
if (C && C.onFocusSection) C.onFocusSection((sec) => showSection(sec));
|
if (C && C.onFocusSection) C.onFocusSection((sec) => showSection(sec));
|
||||||
|
// Land on the section named by `settings.html#<sec>` when opened via main
|
||||||
|
// (e.g. Aegis's "Set up vault" button → open-settings with "passwords").
|
||||||
|
try {
|
||||||
|
const initSec = String(location.hash || "").replace(/^#/, "").toLowerCase();
|
||||||
|
if (initSec) showSection(initSec);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const TOGGLES = ["restoreSession", "backgroundThrottle", "blockCamera", "blockMicrophone", "hideMediaDevices",
|
const TOGGLES = ["restoreSession", "backgroundThrottle", "blockCamera", "blockMicrophone", "hideMediaDevices",
|
||||||
"clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit"];
|
"clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit"];
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue