// WizardConnect wallet-side bridge for Aegis. // // Aegis's BCH runtime acts as a WizardConnect wallet: sites we build (dapps) // pair via a wiz:// URI, get xpubs for BCH derivation paths, and send us // sign requests that we route through the existing approval-modal capability. // // LGPL boundary: @wizardconnect/{core,wallet} are dynamic-linked via // api.import(); we do not statically embed them. Their sources live at // https://github.com/whiterun-labs/wizardconnect (also on npm) and their // LICENSE / copyright headers are shipped by npm inside the package. // // Docs: https://docs.riftenlabs.com/wizardconnect/ const WC_PATH_RECEIVE = "receive"; // m/44'/145'/0'/0 const WC_PATH_CHANGE = "change"; // m/44'/145'/0'/1 const WC_PATH_CAULDRON = "defi"; // m/44'/145'/0'/7 (BCH DEX ecosystem) const WALLET_ICON = "data:image/svg+xml;utf8," + encodeURIComponent( ` ` ); module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnectionManager, wcCore, libauth, log = () => {}, api, approvalRequest }) { // ---- WalletAdapter -------------------------------------------------------- // // Bound to one BCH runtime. Uses its 32-byte root to reproduce the account // HDKey and to derive per-URI relay identities via HKDF, so reconnecting // yields the same Nostr identity (dapp recognises us on reload). function makeAdapter({ root32, accountPath, walletId, label }) { const account = HDKey.fromMasterSeed(root32).derive(accountPath); const branches = new Map(); const branchFor = (childIndex) => { let b = branches.get(childIndex); if (!b) { b = account.deriveChild(childIndex); branches.set(childIndex, b); } return b; }; // WC path enum → BCH child index (identity mapping today; enum members // hold the numeric child index directly — see docs/protocol. const childOf = (path) => Number(path); return { walletName: label ? `Aegis · ${label}` : "Aegis", walletIcon: WALLET_ICON, // Stable identity per pairing URI. HKDF salt binds it to this wallet's // root, info binds it to the URI, so: // - reconnecting to the same URI = same Nostr identity // - two different URIs = uncorrelatable identities (privacy) getRelayPrivateKey(uri) { const salt = new TextEncoder().encode("aegis/wc/relay/v1"); const info = new TextEncoder().encode(uri); // 32 bytes for a Nostr secp256k1 private key. return hkdf(sha256, root32, salt, info, 32); }, getPublicKey(path, index) { const branch = branchFor(childOf(path)); const node = branch.deriveChild(Number(index)); return node.publicKey; // 33 bytes compressed }, getXpub(path) { return branchFor(childOf(path)).publicExtendedKey; }, // Sign a transaction the dapp has already assembled. See signTx.js for // the heavy lifting (SIGHASH_ALL|FORKID|UTXOS enforcement, libauth // preimage + secp256k1 der/lowS signatures). async signTransaction(request) { // Route through approval-modal first — the user always sees what // they're signing before any private key touches the request. if (!approvalRequest) throw new Error("no approval channel"); const decision = await approvalRequest({ kind: "wc-sign", walletId, label, request, }); if (!decision?.approved) throw new Error("cancelled"); const { signTx } = require("./wc-sign.js"); return signTx({ request, account, branches: { receive: branchFor(0), change: branchFor(1), defi: branchFor(7) }, libauth, secp256k1, }); }, }; } // ---- connection tracker -------------------------------------------------- // One manager per BCH wallet. We keep them in a per-walletId map so the // panel can show "Wallet A connected to 2 dapps, Wallet B to none" etc. const managers = new Map(); // walletId -> WalletConnectionManager const uris = new Map(); // walletId -> Set (persisted) const listeners = new Set(); // () => void — panel resubscribes on state change function fireStateChange() { for (const fn of listeners) try { fn(); } catch {} } function persist(walletId) { const list = [...(uris.get(walletId) || new Set())]; api.storage.set(`wc/${walletId}/uris`, list); } async function startForWallet({ walletId, label, root32, accountPath }) { if (managers.has(walletId)) return managers.get(walletId); const adapter = makeAdapter({ root32, accountPath, walletId, label }); const mgr = new WalletConnectionManager(adapter); managers.set(walletId, mgr); mgr.on("connectionsChanged", fireStateChange); mgr.on("connectionStatusChanged", fireStateChange); mgr.on("remoteDisconnect", (connId, reason) => { log(`wc[${walletId}] remote disconnect ${connId}: ${reason}`); fireStateChange(); }); mgr.on("pendingSignRequest", async ({ connectionId, request }) => { try { const { signedTransaction } = await adapter.signTransaction(request); await mgr.sendSignResponse(connectionId, request.sequence, signedTransaction); } catch (e) { log(`wc[${walletId}] sign failed:`, e?.message || e); try { await mgr.sendSignError(connectionId, request.sequence, cleanErrForDapp(e)); } catch {} } }); // Restore persisted pairings. uris.set(walletId, new Set(api.storage.get(`wc/${walletId}/uris`, []) || [])); for (const uri of uris.get(walletId)) { try { mgr.connect(uri); } catch (e) { log(`wc[${walletId}] reconnect failed:`, e?.message); } } return mgr; } function stopForWallet(walletId) { const mgr = managers.get(walletId); if (!mgr) return; try { mgr.disconnectAll?.(); } catch {} managers.delete(walletId); uris.delete(walletId); } async function connectUri(walletId, uri) { const mgr = managers.get(walletId); if (!mgr) throw new Error("wc: wallet not ready"); const trimmed = String(uri || "").trim(); if (!/^wiz:\/\//i.test(trimmed)) throw new Error("wc: URI must start with wiz://"); const id = mgr.connect(trimmed); const set = uris.get(walletId) || new Set(); set.add(trimmed); uris.set(walletId, set); persist(walletId); fireStateChange(); return id; } async function disconnect(walletId, connId) { const mgr = managers.get(walletId); if (!mgr) return; try { await mgr.disconnect(connId); } catch {} // Trim the persisted URI so the next start doesn't re-add it. const conn = [...(mgr.connections?.values?.() || [])].find((c) => c.id === connId); if (conn?.uri) { const set = uris.get(walletId); if (set) { set.delete(conn.uri); persist(walletId); } } fireStateChange(); } function snapshot() { const out = {}; for (const [walletId, mgr] of managers) { const list = [...(mgr.connections?.values?.() || [])].map((c) => ({ id: c.id, uri: c.uri, dappName: c.dappName || null, dappIcon: c.dappIcon || null, status: c.status?.kind || String(c.status || "unknown"), connectedAt: c.connectedAt || null, })); out[walletId] = list; } return out; } function onStateChange(fn) { listeners.add(fn); return () => listeners.delete(fn); } function cleanErrForDapp(e) { const m = String(e?.message || e); if (m === "cancelled") return "user rejected"; return m.replace(/\n[\s\S]*$/, "").slice(0, 200); } return { startForWallet, stopForWallet, connectUri, disconnect, snapshot, onStateChange }; };