theseus/bundled-addons/aegis/lib/wc.js
Local Dev 7b9049f6fc fix(aegis): 0.9.5 — WizardConnect signing actually works
Pairing already worked; signing would have thrown on the first request
a dapp ever sent. Found by testing against the real relay and the real
@wizardconnect/wallet library rather than reading the code.

Two bugs in wc-sign.js, both fatal:

- The WC message nests the whole WcSignTransactionRequest under
  `.transaction`, so the tx is at request.transaction.transaction and
  the spent outputs at request.transaction.sourceOutputs. We read
  request.transaction as the tx and request.sourceOutputs as the
  outputs, so tx.inputs was undefined. index.js already read the nested
  request.transaction.userPrompt for the approval dialog, so only the
  signer had it wrong. The flat shape is still accepted.

- generateSigningSerializationBCH takes TWO positional arguments,
  (compilationContext, {coveredBytecode, signingSerializationType}).
  We passed one merged object, leaving coveredBytecode undefined and
  throwing inside libauth. For P2PKH the covered bytecode is the spent
  output's locking script.

Now verified end to end: a two-input transaction spending from two
different derivation paths signs, decodes, and passes
createVirtualMachineBCH().verify() — consensus-valid, with
SIGHASH_ALL|FORKID|UTXOS (0x61) on every input as the protocol
requires.

Also: RelayStatus is an object ({status: "connected" | "reconnecting" |
"disconnected" | "session_deleted"}), and the snapshot read a
non-existent `.kind`, so every connection reported the literal
"[object Object]". Reads `.status` now, uses the documented
getConnections() accessor instead of the private connections Map, and
carries the library's own `label` ("dapp name once known, otherwise
Connecting…"). The panel shows a tag for anything other than connected
— "reconnecting" is the difference between a pairing that will see the
next signature and one that is dead, which was invisible before.
2026-09-23 03:23:41 +02:00

213 lines
8.8 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) {
// 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,
// 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, snapshot, onStateChange };
};