// 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 }) { // Own copy: the caller may wipe its buffer once this returns. root32 = new Uint8Array(root32); 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). // `pairing` is what Aegis itself recorded when this connection was // made ({ origin } — the page origin the host verified, or "panel"), // never what the dapp says about itself: the dapp's userPrompt and // name are free text it controls. async signTransaction(request, pairing = null) { // 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, pairing, }); 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 origins = new Map(); // walletId -> Map (persisted) const busy = new Set(); // connection ids with a sign request on screen 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); const o = origins.get(walletId) || new Map(); for (const u of [...o.keys()]) if (!list.includes(u)) o.delete(u); api.storage.set(`wc/${walletId}/origins`, Object.fromEntries(o)); } function uriOf(mgr, connectionId) { const all = typeof mgr.getConnections === "function" ? Object.values(mgr.getConnections() || {}) : [...(mgr.connections?.values?.() || [])]; return all.find((c) => c.id === connectionId)?.uri || null; } 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 }) => { // One request per connection on screen at a time: a paired dapp can // send over the relay whenever it likes, with no tab open, and must not // be able to stack overlays. if (busy.has(connectionId)) { try { await mgr.sendSignError(connectionId, request?.sequence, "another request from this dapp is waiting for the user"); } catch {} return; } busy.add(connectionId); try { const uri = uriOf(mgr, connectionId); const pairing = (uri && origins.get(walletId)?.get(uri)) || null; const { signedTransaction } = await adapter.signTransaction(request, pairing); 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 {} } finally { busy.delete(connectionId); } }); // Restore persisted pairings. uris.set(walletId, new Set(api.storage.get(`wc/${walletId}/uris`, []) || [])); const savedOrigins = api.storage.get(`wc/${walletId}/origins`, {}) || {}; origins.set(walletId, new Map(Object.entries(savedOrigins).filter(([, v]) => v && typeof v.origin === "string"))); 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); origins.delete(walletId); } // `origin`: the verified page origin that asked to pair, or "panel" when // the user pasted the code into Aegis themselves. async function connectUri(walletId, uri, origin = "panel") { 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); const o = origins.get(walletId) || new Map(); o.set(trimmed, { origin: String(origin || "panel"), at: Date.now() }); origins.set(walletId, o); 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(); } // Revoking a site in Aegis also ends the WizardConnect pairings it made. async function disconnectOrigin(origin) { let n = 0; for (const [walletId, o] of origins) { const mgr = managers.get(walletId); for (const [uri, rec] of [...o]) { if (rec.origin !== origin) continue; const conn = mgr ? (typeof mgr.getConnections === "function" ? Object.values(mgr.getConnections() || {}) : []).find((c) => c.uri === uri) : null; if (conn) { try { await mgr.disconnect(conn.id); } catch {} } uris.get(walletId)?.delete(uri); o.delete(uri); persist(walletId); n++; } } if (n) fireStateChange(); return n; } function snapshot() { const out = {}; for (const [walletId, mgr] of managers) { // getConnections() is the documented accessor and returns a plain // {id: RelayConnectionState} record. We used to walk mgr.connections // (a private Map) directly, which works but is one library refactor // away from silently returning nothing. const states = typeof mgr.getConnections === "function" ? Object.values(mgr.getConnections() || {}) : [...(mgr.connections?.values?.() || [])]; const list = states.map((c) => ({ id: c.id, uri: c.uri, // `label` is the library's own "dapp name once known, otherwise // Connecting…", so it's the right thing to show while a pairing // is still settling. label: c.label || null, dappName: c.dappName || null, dappIcon: c.dappIcon || null, pairedFrom: origins.get(walletId)?.get(c.uri)?.origin || null, // RelayStatus is an OBJECT: { status: "connected" | "reconnecting" // | "disconnected" | "session_deleted" }. Reading `.kind` (which // does not exist) fell through to String(object) and put the // literal "[object Object]" in the panel's status field. status: typeof c.status === "string" ? c.status : (c.status?.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, disconnectOrigin, snapshot, onStateChange }; };