mountWallet zeroed the vault root after mounting, but the WizardConnect adapter kept a reference to that same buffer and read it again for every new pairing's relay key. Every pairing made after mount therefore got a Nostr identity derived from 32 zero bytes and the pairing URI alone, so anyone who saw the URI (the QR, a script on the dapp page) could read the relay traffic, xpubs included, and speak as the wallet. The adapter now gets, and keeps, its own copy. The signing overlay and the PIN request named the dapp by its own userPrompt, so a dapp paired once could present itself as any site. The pairing origin the host verified is now recorded per URI and shown instead; the dapp's text is a quoted row with invisible and bidi characters removed. One sign request per connection may be on screen at a time, and revoking a site in Aegis ends its pairings too.
267 lines
12 KiB
JavaScript
267 lines
12 KiB
JavaScript
// 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 }) {
|
|
// 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<uri> (persisted)
|
|
const origins = new Map(); // walletId -> Map<uri, { origin, at }> (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 };
|
|
};
|