197 lines
7.9 KiB
JavaScript
197 lines
7.9 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 }) {
|
||
|
|
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 };
|
||
|
|
};
|