When the relays were unreachable (after a sleep, or a relay offline) the relay client reconnected with no delay at all, re-signing the queued wallet_ready on every lap. On Theseus's main thread that spun a core for an hour, grew the process to 2.5 GB and froze the browser. connect() now waits 2 s, 4 s ... up to 60 s between tries, and starts over once a connection has held for a minute.
306 lines
14 KiB
JavaScript
306 lines
14 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>`
|
|
);
|
|
|
|
// Reconnect backoff for WizardConnect relays.
|
|
//
|
|
// RelayClient.connect() resolves as soon as the subscription is created, before
|
|
// any socket opens. When every relay is unreachable (DNS down after a sleep, a
|
|
// relay offline) the pool closes the subscription at once, the connection
|
|
// manager hears "disconnected" and calls connect() again with no delay — its
|
|
// reconnectInterval only applies when connect() throws, which it never does.
|
|
// Every lap also re-wraps the queued wallet_ready (two Schnorr signatures and
|
|
// two ECDH per relay), all on Theseus's main thread: on 2026-10-07 that spun
|
|
// one core flat out for an hour, grew the process to 2.5 GB and froze the
|
|
// browser. Delaying connect() itself throttles the loop, whoever calls it: the
|
|
// first try is immediate, then 2 s, 4 s ... up to 60 s; a connection that has
|
|
// held for a minute starts the count over.
|
|
const RECONNECT_FIRST_MS = 2000;
|
|
const RECONNECT_MAX_MS = 60_000;
|
|
const RECONNECT_HEALTHY_MS = 60_000;
|
|
function throttleRelayReconnects(wcCore, { now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
const proto = wcCore?.RelayClient?.prototype;
|
|
if (!proto || typeof proto.connect !== "function" || proto.__aegisThrottled) return false;
|
|
const connect = proto.connect;
|
|
proto.connect = async function throttledConnect(...args) {
|
|
const st = this.__aegisReconnect || (this.__aegisReconnect = { last: 0, n: 0 });
|
|
if (now() - st.last > RECONNECT_HEALTHY_MS) st.n = 0;
|
|
if (st.n > 0) {
|
|
const gap = Math.min(RECONNECT_MAX_MS, RECONNECT_FIRST_MS * 2 ** (st.n - 1));
|
|
const wait = st.last + gap - now();
|
|
if (wait > 0) await sleep(wait);
|
|
}
|
|
st.n++;
|
|
st.last = now();
|
|
return connect.apply(this, args);
|
|
};
|
|
proto.__aegisThrottled = true;
|
|
return true;
|
|
}
|
|
|
|
module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnectionManager, wcCore, libauth, log = () => {}, api, approvalRequest }) {
|
|
throttleRelayReconnects(wcCore);
|
|
|
|
// ---- 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 };
|
|
};
|
|
|
|
module.exports.throttleRelayReconnects = throttleRelayReconnects;
|