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(`
+ rows.push(`
@@ -2140,7 +2217,7 @@ function renderWalletStrip() { - + ${removeBtn}
`); } @@ -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 + ? `
${assets.map((a) => ` +
+ ${esc(a.symbol)}${a.name ? ` ${esc(a.name)}` : ""} + ${esc(a.id.slice(0, 8))}…${esc(a.id.slice(-6))} + ${esc(a.amount)} +
`).join("")}
` + : ""; return `
${logoSvg(meta.logo, 14)} ${esc(displayAddr || "—")} ${fullAddr ? `` : ``} ${esc(shortName)} ${esc(bal)}${esc(meta.ticker)} - + + ${assetChip} - + ${rowRemove} -
`; +
${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