theseus/bundled-addons/aegis/lib/wc.js

197 lines
7.9 KiB
JavaScript
Raw Normal View History

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.
2026-09-09 10:33:21 +02:00
// 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 };
};