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 @@
+
+
+
From
+
+
+
Asset
diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js
index 4097e9b5..25f89521 100644
--- a/bundled-addons/aegis/panel.js
+++ b/bundled-addons/aegis/panel.js
@@ -1053,6 +1053,16 @@ function openWalletManageModal(w) {
// Only BCH imports can be promoted: the other imported adapters still
// throw "read-only" from plan(), so there is no sweep to run.
const canPromote = w.kind === "imported" && w.chain === "bch";
+ // Per-purpose defaults. WizardConnect is BCH-only and a WIF import has no
+ // xpub to pair with, so the row explains itself rather than offering a
+ // choice that must fail.
+ const roles = (state && state.roles) || {};
+ const isPay = roles.payments === w.id;
+ const isWc = roles.wizardconnect === w.id;
+ const wcOk = w.chain === "bch" && !w.wcBlocked;
+ const wcWhy = w.chain !== "bch"
+ ? "WizardConnect pairs Bitcoin Cash wallets only."
+ : (w.wcBlocked || "");
overlay.innerHTML = `
@@ -1070,6 +1080,18 @@ function openWalletManageModal(w) {
Advanced. Changing this switches to a different set of addresses under the same wallet seed.
` : ""}
+
+
Use this wallet for
+
+
+ ${wcOk ? "" : `
${esc(wcWhy)}
`}
+
${canPromote ? `
WizardConnect
This wallet came from a single private key, so it can't pair with dapps — WizardConnect hands them an xpub to derive addresses from, and one key is not a key tree. Promoting derives a proper wallet from your vault and sweeps this one into it.
@@ -1100,6 +1122,17 @@ function openWalletManageModal(w) {
if (canSetPath && nextPath && nextPath !== (w.accountPath || "")) {
state = await S.invoke("setAccountPath", { id: w.id, accountPath: nextPath });
}
+ // Roles. Only send a change: unticking clears the role, but only when
+ // this wallet is the one currently holding it, so closing the modal on
+ // some other wallet can't unset someone else's default.
+ const wantPay = !!overlay.querySelector("#mwRolePay")?.checked;
+ const wantWc = !!overlay.querySelector("#mwRoleWc")?.checked;
+ if (wantPay !== isPay) {
+ state = await S.invoke("setWalletRole", { role: "payments", walletId: wantPay ? w.id : null });
+ }
+ if (wcOk && wantWc !== isWc) {
+ state = await S.invoke("setWalletRole", { role: "wizardconnect", walletId: wantWc ? w.id : null });
+ }
close();
fillPicker();
render();
@@ -2215,7 +2248,12 @@ function renderConnectPane(bchWallets) {
// 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("");
+ // Same precedence as the page-initiated pairing in index.js — the wallet
+ // nominated for WizardConnect, else whatever the panel is showing. Two
+ // different rules here and there is how a pairing surprises someone.
+ const wcRole = (state && state.roles && state.roles.wizardconnect) || null;
+ const preferId = (pairable.find((w) => w.id === wcRole) || pairable.find((w) => w.id === state.selectedWalletId) || pairable[0] || {}).id || null;
+ const options = readyBch.map((w) => ``).join("");
// Greying an option out with "can't pair" and giving no reason anywhere on
// the page reads as a broken wallet rather than a property of how it was
// added. Show the reasons whenever ANY wallet is blocked — the old note
@@ -2877,9 +2915,21 @@ function renderInlineCoinList(el, groupKey, group) {
const rowMenu = locked
? `🔒`
: ``;
+ // Role badges, so which wallet pays and which one dapps get is legible
+ // from the list instead of only from inside the manage modal. They share
+ // the label's grid column via a flex wrapper: the label keeps its
+ // ellipsis, the badge stays whole.
+ const roles = (state && state.roles) || {};
+ const badges = [
+ roles.payments === w.id ? `pay` : "",
+ roles.wizardconnect === w.id ? `wc` : "",
+ ].join("");
+ const labelCell = `${esc(w.label || shortName || "—")}`;
return `
${logoSvg(meta.logo, 14)}
- ${esc(w.label || shortName || "—")}
+ ${badges
+ ? `${labelCell}${badges}`
+ : labelCell}
${fullAddr ? `` : ``}
${esc(bal)}${esc(meta.ticker)}${rowMenu}
@@ -3339,6 +3389,7 @@ function render() {
cur && w.chain === cur.chain && w.network === cur.network && w.id !== state.selectedWalletId,
);
const showToggle = others.length > 0;
+ paintSendFrom(cur, others);
// Send keeps its toggle. Receive's became a button in the address actions
// (0.18.0), so it is shown/counted the same way but is not a toggle.
for (const [wrapId, cntId] of [["sendModeToggle", "sendConsolidateCount"], ["rcvConsolidateBtn", "rcvConsolidateCount"]]) {
@@ -3721,6 +3772,58 @@ function fmtTokenAmountExact(rawStr, decimals) {
return (neg ? "-" : "") + pad.slice(0, pad.length - d) + (frac ? "." + frac : "");
}
+// ---- Send: which address the money leaves from -----------------------------
+// Aegis models one address per wallet entry, so "send from" is the same
+// question as "which of this coin's wallets". Picking one switches the panel
+// selection rather than carrying a separate source: planSend and send resolve
+// the wallet host-side from the selection, and a second notion of "current"
+// would be two answers to one question, with the approval dialog free to
+// disagree with the form.
+function paintSendFrom(cur, others) {
+ const field = $("sendFromField"), box = $("sendFrom"), hint = $("sendFromHint");
+ if (!field || !box) return;
+ if (!cur || !others.length) { field.hidden = true; return; }
+ // List order, not "current first" — the order matches the wallet list the
+ // user already knows, so the same address sits in the same place each time.
+ const mine = (state?.wallets || []).filter((w) => w.chain === cur.chain && w.network === cur.network);
+ const roles = (state && state.roles) || {};
+ // Rebuild only when the options or balances actually change, so a re-render
+ // mid-typing doesn't reset a select under the cursor.
+ const key = mine.map((w) => `${w.id}:${walletBalanceUnits(w) || 0}:${roles.payments === w.id ? "p" : ""}`).join("|");
+ if (box.dataset.key !== key) {
+ box.dataset.key = key;
+ box.innerHTML = mine.map((w) => {
+ const bal = fmtBig(walletBalanceUnits(w) || 0, w.decimals);
+ const a = stripAddrPrefix(String(w.address || ""));
+ const tail = a ? " · " + (a.length > 16 ? a.slice(0, 8) + "…" + a.slice(-5) : a) : "";
+ const star = roles.payments === w.id ? " ★" : "";
+ return ``;
+ }).join("");
+ box.onchange = async () => {
+ const id = box.value;
+ if (!id || id === state.selectedWalletId) return;
+ try {
+ state = await S.invoke("selectWallet", { id });
+ settingsFilled = false;
+ // A different source means the costed plan is about other UTXOs.
+ lastPlan = null;
+ render();
+ schedulePlan();
+ } catch (e) {
+ const m = $("sendMsg"); m.className = "msg err"; m.textContent = cleanErr(e); m.hidden = false;
+ }
+ };
+ }
+ box.value = cur.id;
+ const payW = mine.find((w) => roles.payments === w.id);
+ if (hint) {
+ hint.textContent = payW
+ ? (payW.id === cur.id ? "Your default wallet for payments." : `★ ${payW.label} is your default for payments.`)
+ : "Pick a default for payments in a wallet's ⋯ menu.";
+ }
+ field.hidden = false;
+}
+
function applyUnitPicker() {
const s = sel(); if (!s) return;
if (!unit) unit = "big";