diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index e71bfa7e..4f2c53f3 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.20.0", + "version": "0.21.0", "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 cb329b27..be96c8d9 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -880,6 +880,42 @@ function buildWcApprovalBody(payload) { + "
" + bc + " Signs with SIGHASH_ALL|FORKID|UTXOS.
"; } +// ---- per-purpose default wallets ------------------------------------------- +// The wallet you pay from and the wallet you hand to a dapp are not +// necessarily the same one, and until now both fell out of whatever happened +// to be selected in the panel. That is why a WizardConnect pairing could land +// on an address the user did not recognise: with the selected wallet +// ineligible, pairing silently took the first one in list order. +// +// A role points at a wallet id. An id whose wallet has since been removed +// reads back as null rather than being trusted, so a stale pointer can never +// quietly redirect a payment. +const WALLET_ROLES = ["payments", "wizardconnect"]; + +function walletRoles() { + const raw = ctx.api.storage.get("aegis/roles/v1", null); + const list = readWallets(ctx.api) || []; + const out = {}; + for (const role of WALLET_ROLES) { + const id = raw && typeof raw === "object" ? String(raw[role] || "") : ""; + out[role] = id && list.some((w) => w.id === id) ? id : null; + } + return out; +} + +function setWalletRole(role, walletId) { + if (!WALLET_ROLES.includes(role)) throw new Error("unknown role: " + role); + const next = walletRoles(); + if (walletId == null || walletId === "") next[role] = null; + else { + const list = readWallets(ctx.api) || []; + if (!list.some((w) => w.id === walletId)) throw new Error("no such wallet"); + next[role] = String(walletId); + } + ctx.api.storage.set("aegis/roles/v1", next); + return next; +} + // ---- state / snapshot ------------------------------------------------------- function selectedWalletId() { @@ -887,6 +923,10 @@ function selectedWalletId() { if (!list.length) return null; const saved = String(ctx.api.storage.get("selectedWalletId", "") || ""); if (saved && list.some((w) => w.id === saved)) return saved; + // Nothing picked yet this session. The wallet the user nominated for + // payments is a better opening guess than list order. + const pay = walletRoles().payments; + if (pay) return pay; const dflt = list.find((w) => w.isDefault) || list[0]; return dflt.id; } @@ -974,6 +1014,7 @@ function fullState() { return { overallPhase: overallPhase(), selectedWalletId: selectedWalletId(), + roles: walletRoles(), wallets: walletEntries().map(walletSummary), selected: snapshotForSelected(), bchServers: { @@ -1008,6 +1049,15 @@ function fromPage(m) { return m.origin; } +// Head and tail of an address, with any "bitcoincash:"/"bchtest:" prefix +// dropped — the prefix is the same on every row, so it costs width without +// helping anyone tell two addresses apart. +function shortAddr(addr) { + const s = String(addr || ""); + const body = s.includes(":") ? s.slice(s.indexOf(":") + 1) : s; + return body.length <= 20 ? body : body.slice(0, 10) + "…" + body.slice(-6); +} + const fmtBch = (sats) => (Number(sats) / 1e8).toFixed(8).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1"); const fmtTrx = (sun) => (Number(sun) / 1e6).toFixed(6).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1"); function fmtValue(units, decimals) { @@ -1842,22 +1892,61 @@ function registerPanelMessages(api) { 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. + // Which wallet to offer first: the one nominated for WizardConnect, else + // the one the panel is showing, else list order. Whatever wins is only a + // default — with more than one candidate the approval lets the user say. + const wcRole = walletRoles().wizardconnect; const selId = selectedWalletId(); - const chosen = candidates.find((w) => w.id === selId) || candidates[0]; + const preferred = candidates.find((w) => w.id === wcRole) + || candidates.find((w) => w.id === selId) + || candidates[0]; + // Default first: approval.html renders options in order, so the browser + // selects the head of the list. + const ordered = [preferred, ...candidates.filter((w) => w.id !== preferred.id)]; + const addrOf = (w) => { + try { return ctx.runtimes.get(w.id)?.adapter?.snapshot()?.address || ""; } + catch { return ""; } + }; + // The address, not just the label — "I don't recognise the connected + // wallet" is the failure this dialog has to prevent, and a label the user + // never chose ("BCH wallet 2") does not prevent it. + const describe = (w) => { + const a = addrOf(w); + return a ? `${w.label} · ${shortAddr(a)}` : w.label; + }; + const choose = ordered.length > 1; + // With a choice on offer the dropdown is the only honest statement of + // which wallet pairs: the rows are static, so a "Wallet: X" line above a + // select the user just changed to Y would contradict itself. Each option + // therefore carries its own short address, and the full-address row shows + // only when there is nothing to choose. + const rows = choose + ? [{ label: "Pairing code", value: uri.slice(0, 48) + (uri.length > 48 ? "…" : ""), mono: true }] + : [ + { label: "Wallet", value: preferred.label, strong: true }, + { label: "Address", value: addrOf(preferred) || "—", mono: true }, + { label: "Pairing code", value: uri.slice(0, 48) + (uri.length > 48 ? "…" : ""), mono: true }, + ]; 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 }, - ], + rows, actions: [{ id: "allow", label: "Pair", primary: true }], + select: choose ? { + id: "wallet", label: "Pair with", + options: ordered.map((w) => ({ value: w.id, label: describe(w) })), + } : null, }); - if (!String(pick || "").startsWith("allow")) throw new Error("pairing declined"); + const [action, ...flags] = String(pick || "").split("+"); + if (action !== "allow") throw new Error("pairing declined"); + const picked = flags.find((f) => f.startsWith("wallet=")); + const pickedId = picked ? picked.slice(7) : ""; + // Re-check against the candidate list: main validates the value came + // from the options we offered, but the wallet could have gone away + // while the dialog was open. + const chosen = candidates.find((w) => w.id === pickedId) || preferred; await ctx.wc.connectUri(chosen.id, uri); return { paired: true, wallet: chosen.label }; }); @@ -1883,6 +1972,20 @@ function registerPanelMessages(api) { return fullState(); }); + // Which wallet each purpose reaches for. Panel-only: a page must never be + // able to read the whole wallet set, let alone re-point a role at one. + api.onMessage("walletRoles", (_p, m) => { fromPanel(m); return walletRoles(); }); + api.onMessage("setWalletRole", (p, m) => { + fromPanel(m); + const role = String(p && p.role || ""); + const id = p && p.walletId ? String(p.walletId) : null; + setWalletRole(role, id); + emitState(); + // Full state, not just the role map — the panel assigns this straight + // onto `state`, and the badges it drives live on the wallet rows. + return fullState(); + }); + api.onMessage("permissions", (_p, m) => { fromPanel(m); return permissions(api); }); api.onMessage("revoke", (p, m) => { fromPanel(m); diff --git a/bundled-addons/aegis/panel.html b/bundled-addons/aegis/panel.html index c454abf6..7c0ba50f 100644 --- a/bundled-addons/aegis/panel.html +++ b/bundled-addons/aegis/panel.html @@ -152,6 +152,13 @@ .portfolio b { color: var(--ink); font-weight: 600; } .portfolio[hidden] { display: none; } .picker-actions { display: flex; align-items: center; gap: 6px; } + /* "this is the one I pay from" / "this is the one dapps get". Deliberately + quiet — it labels a row, it is not a control, and a wallet list where + every row shouts is a list nobody reads. */ + .rolebadge { font-size: 10px; line-height: 1; text-transform: uppercase; letter-spacing: .04em; + border: 1px solid var(--line); color: var(--mut); border-radius: 4px; + padding: 2px 4px; white-space: nowrap; } + .rolebadge.pay { color: var(--acid); border-color: color-mix(in srgb, var(--acid) 40%, var(--line)); } .chip { background: transparent; border: 1px solid var(--line); color: var(--mut); border-radius: 6px; padding: 0 8px; height: 22px; cursor: pointer; font: inherit; font-size: 13.5px; line-height: 1; display: inline-flex; align-items: center; justify-content: center; } @@ -860,6 +867,16 @@ + +