diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json
index a309ea62..ce07ff29 100644
--- a/bundled-addons/aegis/addon.json
+++ b/bundled-addons/aegis/addon.json
@@ -1,7 +1,7 @@
{
"id": "aegis",
"name": "Aegis Wallet",
- "version": "0.8.7",
+ "version": "0.8.8",
"category": "plugin",
"description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.",
"author": "Silent Mode",
diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js
index a7a7eeb7..c6ca833e 100644
--- a/bundled-addons/aegis/index.js
+++ b/bundled-addons/aegis/index.js
@@ -509,6 +509,40 @@ async function mountWallet(entry) {
importId: entry.importId,
});
adapter.schedulePoll(20_000);
+ // Imported BCH wallets were never registered with WizardConnect —
+ // startForWallet only ran in the vault-derived branch. They showed
+ // up in the "Sign with" picker (they mount as ready) and then failed
+ // on pair with "no manager". Register them here too.
+ //
+ // WC derives a child-key tree, so this needs a seed: mnemonic/seed
+ // imports qualify, WIF single-key imports never can. registerWcEligible
+ // records which is which so the panel can say so up front instead of
+ // offering a pairing that cannot work.
+ if (c.wc) {
+ c.api.vault.imports.signer(entry.importId).then((blob) => {
+ if (ctx !== c) return;
+ if (!blob || blob.kind !== "seed" || !blob.seed) {
+ wcIneligible.set(entry.id, "This wallet was imported from a single private key. WizardConnect needs a seed phrase to derive the per-dapp keys it signs with.");
+ emitStateForWallet(entry.id);
+ return;
+ }
+ const seedHex = String(blob.seed).trim();
+ if (!/^[0-9a-f]+$/i.test(seedHex) || seedHex.length < 32) {
+ wcIneligible.set(entry.id, "Imported seed material is not in a form WizardConnect can derive from.");
+ emitStateForWallet(entry.id);
+ return;
+ }
+ const root = new Uint8Array(seedHex.match(/../g).map((x) => parseInt(x, 16)));
+ return c.wc.startForWallet({
+ walletId: entry.id, label: entry.label,
+ root32: root, accountPath: entry.accountPath || "m/44'/145'/0'",
+ }).finally(() => { try { root.fill(0); } catch {} });
+ }).catch((e) => {
+ c.api.log(`[${entry.id}] wc start (imported):`, e?.message || e);
+ wcIneligible.set(entry.id, cleanWcErr(e));
+ emitStateForWallet(entry.id);
+ });
+ }
} else if (entry.chain === "btc" || entry.chain === "dgb") {
adapter = new c.d.utxoImportedAdapter.UtxoImportedWallet({
...commonOpts, chain: entry.chain, address: entry.importedAddress,
@@ -675,6 +709,7 @@ function unmountWallet(walletId) {
const rt = ctx.runtimes.get(walletId);
if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} }
if (ctx.wc) { try { ctx.wc.stopForWallet(walletId); } catch {} }
+ wcIneligible.delete(walletId);
ctx.runtimes.delete(walletId);
}
@@ -749,6 +784,18 @@ function overallPhase() {
return "ready";
}
+// Per-wallet reason a BCH wallet can't do WizardConnect even though it's
+// mounted and ready — today that's single-key (WIF) imports, which have no
+// seed to derive a per-dapp key tree from. Surfaced in walletSummary so the
+// picker can grey them out instead of offering a pairing that must fail.
+const wcIneligible = new Map();
+function cleanWcErr(e) {
+ const m = e?.message || String(e);
+ return /locked|vault/i.test(m)
+ ? "Unlock the password vault to use WizardConnect with this wallet."
+ : m;
+}
+
function walletSummary(w) {
const meta = chainMeta(w.chain, w.network);
const rt = ctx.runtimes.get(w.id);
@@ -765,8 +812,15 @@ function walletSummary(w) {
// stay null so the picker knows whether to render the mono path line.
accountPath: w.accountPath || snap?.accountPath || null,
balance: snap?.balance || { confirmed: 0, unconfirmed: 0 },
+ // Per-wallet assets, so the coin drilldown can show what each ADDRESS
+ // holds instead of only the selected wallet's. `tokens` is the account-
+ // model shape (SPL / TRC20); `tokenBalances` is BCH CashTokens, keyed
+ // by category. Both stay null/empty for chains that have neither.
+ tokens: Array.isArray(snap?.tokens) ? snap.tokens : [],
+ tokenBalances: snap?.tokenBalances || null,
phase: rt?.phase || "locked",
error: rt?.error || null,
+ wcBlocked: w.chain === "bch" ? (wcIneligible.get(w.id) || null) : null,
};
}
@@ -1531,6 +1585,66 @@ function registerPanelMessages(api) {
return fullState();
});
+ // ---- WizardConnect from the page (0.8.8) --------------------------------
+ //
+ // WC was built for cross-device pairing: the dapp renders a QR, a phone
+ // scans it. Same-device that means copying a wiz:// string out of one
+ // tab and into the wallet by hand. These two handlers back the
+ // window.wizardconnect bridge so a dapp can hand Aegis the URI it has
+ // already generated, and the user just approves.
+
+ // Which BCH wallets can actually pair right now. Used by the page bridge
+ // AND by isReady() so a dapp can decide between "hand it to Aegis" and
+ // "render the QR" before it commits to either.
+ function wcPairableWallets() {
+ if (!ctx.wc) return [];
+ return walletEntries()
+ .filter((w) => w.chain === "bch")
+ .map((w) => ({ entry: w, rt: ctx.runtimes.get(w.id) }))
+ .filter(({ entry, rt }) => rt && rt.phase === "ready" && !wcIneligible.has(entry.id))
+ .map(({ entry }) => entry);
+ }
+
+ api.onMessage("wcPageReady", async (_p, m) => {
+ fromPage(m);
+ // Deliberately coarse: a page learns only whether pairing is possible,
+ // never how many wallets exist or what they are.
+ return { available: !!ctx.wc, pairable: wcPairableWallets().length > 0 };
+ });
+
+ api.onMessage("wcConnectFromPage", async (p, m) => {
+ const origin = fromPage(m);
+ if (!ctx.wc) throw new Error("WizardConnect is still starting up — try again in a moment");
+ const uri = String(p && p.uri || "").trim();
+ // Validate before showing any UI so a malformed or hostile value can't
+ // put a confusing approval in front of the user.
+ if (!/^wiz:\/\//i.test(uri)) throw new Error("not a WizardConnect URI");
+ if (uri.length > 4096) throw new Error("WizardConnect URI is implausibly long");
+ const candidates = wcPairableWallets();
+ if (!candidates.length) {
+ throw new Error("No Bitcoin Cash wallet is ready to pair. Unlock the Aegis vault (or add a BCH wallet) and try again.");
+ }
+ // Prefer the selected wallet when it qualifies, so the approval matches
+ // whatever the user currently sees in the panel.
+ const selId = selectedWalletId();
+ const chosen = candidates.find((w) => w.id === selId) || candidates[0];
+ return withOriginLock(origin, async () => {
+ const pick = await api.approvalModal({
+ title: "Pair this site with your wallet?",
+ origin,
+ body: "The site will be able to ask Aegis to sign Bitcoin Cash transactions over WizardConnect. Every signature still needs your approval — pairing on its own moves no funds.",
+ rows: [
+ { label: "Wallet", value: `${chosen.label}` },
+ { label: "Pairing code", value: uri.slice(0, 48) + (uri.length > 48 ? "…" : ""), mono: true },
+ ],
+ actions: [{ id: "allow", label: "Pair", primary: true }],
+ });
+ if (!String(pick || "").startsWith("allow")) throw new Error("pairing declined");
+ await ctx.wc.connectUri(chosen.id, uri);
+ return { paired: true, wallet: chosen.label };
+ });
+ });
+
// Reorder wallets by an explicit ID list. Silently drops IDs that are
// not in the current wallet set (removed since the panel last read);
// appends any wallets missing from `order` to the end of the list so a
diff --git a/bundled-addons/aegis/panel.html b/bundled-addons/aegis/panel.html
index 2671fcdc..0c7a6a80 100644
--- a/bundled-addons/aegis/panel.html
+++ b/bundled-addons/aegis/panel.html
@@ -432,6 +432,21 @@
.wstrip .warow .wact { background: transparent; border: 0; color: var(--dim); cursor: pointer;
padding: 2px 5px; border-radius: 4px; font-size: 12.5px; line-height: 1; }
.wstrip .warow .wact:hover { color: var(--acid, #d6ff3d); background: rgba(255,255,255,.06); }
+ /* Destructive strip action (remove wallet). Stays quiet until hovered so
+ a row of icons doesn't read as a row of warnings. */
+ .wstrip .wact.wactdel:hover { color: var(--danger, #f6768a); background: rgb(from var(--danger, #f6768a) r g b / .12); }
+ /* Per-address asset list (0.8.8). The count chip expands the row into a
+ nested list of what that ONE address holds beyond the native coin. */
+ .wstrip .waassets { background: rgba(255,255,255,.06); border: 0; color: var(--dim); cursor: pointer;
+ font: inherit; font-size: 10px; padding: 1px 6px; border-radius: 999px; line-height: 1.5; }
+ .wstrip .waassets:hover, .wstrip .waassets.on { color: var(--acid, #d6ff3d); background: rgb(from var(--acid, #d6ff3d) r g b / .14); }
+ .wstrip .waassetlist { padding: 2px 6px 6px 26px; display: flex; flex-direction: column; gap: 2px; }
+ .wstrip .waasset { display: grid; grid-template-columns: minmax(0,1fr) auto auto; gap: 8px; align-items: baseline;
+ font-size: 11px; color: var(--mut); padding: 2px 4px; border-radius: 4px; }
+ .wstrip .waasset:hover { background: rgba(255,255,255,.04); }
+ .wstrip .waasset .waaname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .wstrip .waasset .waaid { color: var(--dim); font-size: 10px; }
+ .wstrip .waasset .waaamt { font-variant-numeric: tabular-nums; color: var(--ink); }
/* Coin drilldown header: shows the coin's per-unit price + running
total of the addresses below, so the aggregate context isn't lost
when the user is deep in the per-address view. */
diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js
index b8a52f0e..d4fa9faf 100644
--- a/bundled-addons/aegis/panel.js
+++ b/bundled-addons/aegis/panel.js
@@ -893,6 +893,29 @@ function aegisAlert(message, opts) {
});
}
+// Shared remove-wallet flow, used by the strip's 🗑 buttons and the coin
+// drilldown. Confirms first, then unlinks. On-chain funds are untouched —
+// this only drops Aegis's record of the wallet.
+async function removeWalletWithConfirm(id) {
+ const w = (state?.wallets || []).find((x) => x.id === id);
+ if (!w) return false;
+ if (w.isDefault || w.isLegacy) {
+ await aegisAlert("This is the default wallet — it holds legacy funds and can't be removed.", { title: "Can't remove", icon: "🔒" });
+ return false;
+ }
+ const ok = await aegisConfirm({
+ title: `Remove "${w.label}"?`,
+ danger: true,
+ confirmLabel: "Remove wallet",
+ body: `On-chain funds stay exactly where they are — this only unlinks the wallet from Aegis.
You can add it back later on the same coin + network to derive the same addresses ${w.kind === "imported" ? "(or re-import it, since this one was imported)" : "from your vault seed"}.`,
+ });
+ if (!ok) return false;
+ state = await S.invoke("removeWallet", { id });
+ settingsFilled = false;
+ render();
+ return true;
+}
+
// Import modal — M.1 UX. Paste mnemonic + path OR WIF, choose network + label
// + category. Backend derives cashaddr and stores signer material in
// wallet-imports.enc (design §3.2). Modal is a plain overlay div injected
@@ -1829,12 +1852,31 @@ function paintRcvMode() {
function renderConnectPane(bchWallets) {
const readyBch = bchWallets.filter((w) => w.phase === "ready");
if (!readyBch.length) {
+ // 0.8.8: the locked branch used to be a dead end — it told the user to
+ // unlock the vault but gave them nothing to click, and the panel chrome
+ // stays visible whenever an imported wallet is mounted (those skip the
+ // vault), so this is reachable without the lock screen ever showing.
+ // Inline the same unlock form the lock screen uses.
+ const locked = bchWallets.length > 0;
return `
WizardConnect pairs Aegis with a BCH dapp (Cauldron, Moria, or any site built on the SDK).
-
${bchWallets.length ? "Unlock your password vault first — WizardConnect uses your BCH keys to sign." : "Add a BCH wallet first via the Add tab, then come back."}
+ ${locked ? `
+
WizardConnect signs with your BCH keys, so the password vault has to be unlocked first.
+
+
+
+
+
+ ` : `
Add a BCH wallet first via the Add tab, then come back.
`}
`;
}
- const options = readyBch.map((w) => ``).join("");
+ // Wallets with no derivable seed (WIF single-key imports) can't pair at
+ // all, so they're disabled rather than silently failing on Connect.
+ const pairable = readyBch.filter((w) => !w.wcBlocked);
+ const options = readyBch.map((w) => ``).join("");
+ const blockedNote = (!pairable.length && readyBch.length)
+ ? `
${esc(readyBch[0].wcBlocked)}
`
+ : "";
// Flatten all connected dapps (across BCH wallets) into one list — the
// user thinks "my dapps", not "dapps per wallet".
const rows = [];
@@ -1850,7 +1892,8 @@ function renderConnectPane(bchWallets) {
`).join("")
: `
No dapps paired yet.
`;
return `
-
Paste a wiz:// URI from a BCH dapp's Connect dialog. Aegis will sign every request after your approval.
+
Dapps that support Aegis directly can hand the pairing over with one click — no copying. Otherwise paste a wiz:// URI from the dapp's Connect dialog. Aegis signs every request after your approval.
+ ${blockedNote}
Sign with
@@ -1868,6 +1911,31 @@ function renderConnectPane(bchWallets) {
}
function wireConnectPane() {
+ // Locked-vault branch: unlock in place, then re-render the pane so the
+ // pairing form replaces the gate without the user reopening the picker.
+ const unlockBtn = document.getElementById("pkConnectUnlockBtn");
+ if (unlockBtn) {
+ const doUnlock = async () => {
+ const pwEl = document.getElementById("pkConnectUnlockPw");
+ const msg = document.getElementById("pkConnectUnlockMsg");
+ msg.hidden = true;
+ const pw = pwEl.value;
+ if (!pw) return;
+ try {
+ state = await S.invoke("vaultUnlock", { masterPassword: pw });
+ pwEl.value = "";
+ render();
+ fillPicker();
+ } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
+ };
+ unlockBtn.addEventListener("click", (e) => { e.stopPropagation(); doUnlock(); });
+ const pwEl = document.getElementById("pkConnectUnlockPw");
+ if (pwEl) {
+ pwEl.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") doUnlock(); });
+ try { pwEl.focus(); } catch {}
+ }
+ return;
+ }
const btn = document.getElementById("pkConnectBtn"); if (!btn) return;
btn.addEventListener("click", async () => {
const walletId = document.getElementById("pkConnectWallet").value;
@@ -2116,13 +2184,22 @@ function renderWalletStrip() {
// still get no chip.
const walletsChip = single ? "" : `${gw.length} ▾`;
- // Actions row: on multi-wallet rows the ✎ / ⚙ target the ACTIVE
+ // Actions row: on multi-wallet rows the ✎ / 🗑 target the ACTIVE
// wallet (not "the group") so the buttons still do something specific
// without needing a second click.
+ //
+ // 0.8.8: the second button used to be ⚙, which just selected the wallet
+ // and opened the global Settings tab — the same destination for every
+ // coin, so it read as a per-coin control that wasn't one. Removing a
+ // wallet is the action people actually wanted there.
const editAttr = `data-wedit="${esc(walletId)}"`;
- const setAttr = `data-wsettings="${esc(walletId)}"`;
+ const activeW = gw.find((w) => w.id === walletId);
+ const canRemove = !(activeW?.isDefault || activeW?.isLegacy);
+ const removeBtn = canRemove
+ ? ``
+ : `🔒`;
- rows.push(`
`);
}
@@ -2167,14 +2244,24 @@ function renderWalletStrip() {
renderWalletStrip();
return;
}
- if (row.dataset.wstripid) {
- const id = row.dataset.wstripid;
- if (id === selId) return;
- try { state = await S.invoke("selectWallet", { id }); settingsFilled = false; render(); }
- catch (er) { showErr(cleanErr(er)); }
- } else if (row.dataset.openlist) {
+ // 0.8.8: clicking a coin opens that coin's page (addresses + assets)
+ // instead of only flipping the selection and leaving the list in place.
+ // Selecting still happens, so Send/Receive/History follow the coin the
+ // user just opened — but the strip now navigates, which is what a row
+ // with a balance and a chevron looks like it should do.
+ if (row.dataset.openlist) {
stripView = { mode: "addresses", groupKey: row.dataset.openlist };
renderWalletStrip();
+ return;
+ }
+ if (row.dataset.wstripid) {
+ const id = row.dataset.wstripid;
+ const key = row.dataset.groupkey || null;
+ try {
+ if (id !== selId) { state = await S.invoke("selectWallet", { id }); settingsFilled = false; }
+ if (key) stripView = { mode: "addresses", groupKey: key };
+ render();
+ } catch (er) { showErr(cleanErr(er)); }
}
}));
// Ticker click on a multi-network chain → pop the network dropdown.
@@ -2189,17 +2276,11 @@ function renderWalletStrip() {
const w = (state?.wallets || []).find((x) => x.id === b.dataset.wedit);
if (w) openWalletManageModal(w);
}));
- el.querySelectorAll(".wact[data-wsettings]").forEach((b) => b.addEventListener("click", async (e) => {
+ el.querySelectorAll(".wact[data-wremove]").forEach((b) => b.addEventListener("click", async (e) => {
e.stopPropagation();
- const id = b.dataset.wsettings;
- try {
- if (id !== state?.selectedWalletId) {
- state = await S.invoke("selectWallet", { id });
- settingsFilled = false;
- }
- showTab("settings");
- render();
- } catch (er) { showErr(cleanErr(er)); }
+ const id = b.dataset.wremove;
+ try { await removeWalletWithConfirm(id); }
+ catch (er) { showErr(cleanErr(er)); }
}));
el.querySelectorAll(".wact[data-openlist]").forEach((b) => b.addEventListener("click", (e) => {
e.stopPropagation();
@@ -2346,7 +2427,46 @@ function wireStripDragDrop(el, chainGroups) {
// Inline replacement for the modal address list. Rendered directly into
// the wallet strip element when stripView.mode === "addresses". Header
// row has a back arrow (returns to the coins summary) and the coin's
-// name/logo; body rows show one wallet each with balance + inline ✎ / ⚙.
+// Which address rows have their asset list expanded, in the coin drilldown.
+// Panel-session only — a drilldown is a transient view, so there's nothing
+// worth persisting across restarts.
+const expandedAddrAssets = new Set();
+
+// Normalise a wallet's assets into one row shape the drilldown can render,
+// regardless of which chain family it came from:
+// account-model (TRC20 / SPL) → w.tokens: [{mint, symbol, name, decimals, balance}]
+// BCH CashTokens → w.tokenBalances: { : {fungible, nfts[]} }
+// Returns [{ id, symbol, name, amount }] with amount already formatted.
+function assetsForWallet(w) {
+ const out = [];
+ for (const t of (w.tokens || [])) {
+ const dec = Number(t.decimals) || 0;
+ out.push({
+ id: String(t.mint || ""),
+ symbol: t.symbol || "?",
+ name: t.name || "",
+ // Unknown contracts have no decimals — show the raw integer rather
+ // than a number invented from an assumed scale.
+ amount: t.known === false ? `${t.balance} raw` : fmtTokenAmount(t.balance, dec),
+ });
+ }
+ const tb = w.tokenBalances || {};
+ for (const cat of Object.keys(tb)) {
+ const b = tb[cat] || {};
+ const fungible = String(b.fungible || "0");
+ const nftCount = Array.isArray(b.nfts) ? b.nfts.length : 0;
+ const parts = [];
+ // Decimals live in BCMR, which the drilldown doesn't fetch — show the
+ // raw fungible amount and let the Tokens card do the named rendering.
+ if (fungible !== "0") parts.push(fungible);
+ if (nftCount) parts.push(`${nftCount} NFT${nftCount === 1 ? "" : "s"}`);
+ if (!parts.length) continue;
+ out.push({ id: cat, symbol: "CashToken", name: "", amount: parts.join(" · ") });
+ }
+ return out;
+}
+
+// name/logo; body rows show one wallet each with balance + inline ✎ / 🗑.
function renderInlineCoinList(el, groupKey, group) {
const { meta, wallets: gw } = group;
const selId = state?.selectedWalletId;
@@ -2379,17 +2499,39 @@ function renderInlineCoinList(el, groupKey, group) {
const shortName = shortLabel(w.label || "", 8);
// Fiat is dropped from the row to keep everything on one line; the
// aggregate coin fiat still shows in the drilldown header above.
+ // 0.8.8: per-address assets. Each row can expand to show what THIS
+ // address holds beyond the native coin — TRC20/SPL via `tokens`, BCH
+ // CashTokens via `tokenBalances`. Rows with nothing extra get no
+ // chevron so the list stays quiet for plain wallets.
+ const assets = assetsForWallet(w);
+ const expanded = expandedAddrAssets.has(w.id);
+ const assetChip = assets.length
+ ? ``
+ : "";
+ const canRemoveRow = !(w.isDefault || w.isLegacy);
+ const rowRemove = canRemoveRow
+ ? ``
+ : `🔒`;
+ const assetRows = expanded && assets.length
+ ? `
${assetRows}`;
}).join("");
// Surface any adapter errors from the wallets in this group. If a fetch
// is failing (RPC unreachable, CORS block, rate limit) the display would
@@ -2472,16 +2614,27 @@ function renderInlineCoinList(el, groupKey, group) {
const w = (state?.wallets || []).find((x) => x.id === b.dataset.lpedit);
if (w) openWalletManageModal(w);
}));
- el.querySelectorAll("[data-lpset]").forEach((b) => b.addEventListener("click", async (e) => {
+ el.querySelectorAll("[data-lpremove]").forEach((b) => b.addEventListener("click", async (e) => {
e.stopPropagation();
- const id = b.dataset.lpset;
try {
- if (id !== state?.selectedWalletId) { state = await S.invoke("selectWallet", { id }); settingsFilled = false; }
- stripView = { mode: "coins", groupKey: null };
- showTab("settings");
- render();
+ const gone = await removeWalletWithConfirm(b.dataset.lpremove);
+ // Removing the last address under this coin leaves the drilldown
+ // pointing at an empty group — fall back to the coin list.
+ if (gone) {
+ const left = (state?.wallets || []).filter((w) => w.id !== b.dataset.lpremove && group.wallets.some((g) => g.id === w.id));
+ if (!left.length) stripView = { mode: "coins", groupKey: null };
+ renderWalletStrip();
+ }
} catch (er) { showErr(cleanErr(er)); }
}));
+ // Per-address asset list toggle.
+ el.querySelectorAll("[data-lpassets]").forEach((b) => b.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const id = b.dataset.lpassets;
+ if (expandedAddrAssets.has(id)) expandedAddrAssets.delete(id);
+ else expandedAddrAssets.add(id);
+ renderWalletStrip();
+ }));
el.querySelector("#stripAddMore").addEventListener("click", async () => {
// Add another wallet of the same coin+network directly, without
// opening the picker sheet — the user is already inside this coin's
diff --git a/bundled-addons/aegis/wallet-inject.js b/bundled-addons/aegis/wallet-inject.js
index ae4d1f2b..0287e5c5 100644
--- a/bundled-addons/aegis/wallet-inject.js
+++ b/bundled-addons/aegis/wallet-inject.js
@@ -141,6 +141,36 @@ const tronLink = {
theseus.contextBridge.exposeInMainWorld("tronWeb", tronWeb);
theseus.contextBridge.exposeInMainWorld("tronLink", tronLink);
+// -------- WizardConnect bridge (window.wizardconnect), everywhere -----------
+//
+// WizardConnect is a Nostr-relay pairing protocol built for CROSS-device use:
+// the dapp calls initiateDappRelay(), gets a wiz:// uri, and renders it as a
+// QR for a phone wallet to scan. On the same device that QR round-trip is
+// pure friction — the dapp and Aegis are in the same browser.
+//
+// The SDK has no in-page wallet discovery, so this is Silent Mode's own
+// surface. A dapp keeps its existing initiateDappRelay() call and simply
+// hands us the uri it already generated:
+//
+// const { uri } = initiateDappRelay(onStatus);
+// if (window.wizardconnect) await window.wizardconnect.connect(uri);
+// else renderQr(uri); // unchanged fallback
+//
+// connect() resolves once the user approves the pairing in Aegis and the
+// key exchange completes, and rejects if they decline. Nothing is paired
+// without an explicit approval, and we never read the page to find a uri —
+// the dapp hands it to us.
+const wizardconnect = {
+ isAegis: true,
+ version: "1.0.0",
+ // Present so a dapp can tell "wallet is installed" from "wallet is
+ // installed but has no BCH wallet ready to pair with" before it decides
+ // whether to fall back to a QR.
+ isReady: () => call("wcPageReady"),
+ connect: (uri) => call("wcConnectFromPage", { uri: String(uri ?? "") }),
+};
+theseus.contextBridge.exposeInMainWorld("wizardconnect", wizardconnect);
+
// -------- Main-world bridges (window.ethereum, window.solana) ---------------
//
// EIP-1193 (Ethereum) and the Solana wallet-adapter both expect the wallet
@@ -491,6 +521,23 @@ const mainWorldSource = `(function () {
announce();
window.addEventListener("eip6963:requestProvider", announce);
} catch {}
+
+ // Same announce/request handshake for WizardConnect. The WC SDK defines
+ // no discovery mechanism at all, so we borrow EIP-6963's shape: a dapp
+ // that wants to support several WC wallets dispatches
+ // "wizardconnect:requestProvider" and collects the announcements instead
+ // of reaching for window.wizardconnect and finding whoever won the race.
+ // window.wizardconnect stays for the simple single-wallet case.
+ try {
+ const wcInfo = { uuid: crypto.randomUUID(), name: "Aegis", icon: "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpolygon points='16,2 29,9 29,23 16,30 3,23 3,9' fill='none' stroke='%23d6ff3d' stroke-width='2.5'/%3E%3Ccircle cx='16' cy='16' r='4.3' fill='none' stroke='%23d6ff3d' stroke-width='1.6'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23d6ff3d'/%3E%3C/svg%3E", rdns: "st.silentmode.aegis" };
+ const announceWc = () => {
+ const provider = window.wizardconnect;
+ if (!provider) return;
+ window.dispatchEvent(new CustomEvent("wizardconnect:announceProvider", { detail: Object.freeze({ info: wcInfo, provider }) }));
+ };
+ announceWc();
+ window.addEventListener("wizardconnect:requestProvider", announceWc);
+ } catch {}
})();`;
// Actually push the script into the main world. Doing this at