feat(aegis): 0.21.0 — you pick which wallet pays and which one dapps get

A WizardConnect pairing could land on an address the user had never seen.
Pairing took the selected wallet when it qualified and otherwise the first
pairable one in list order, so with the selected wallet ineligible (a WIF
import has no xpub) it silently fell through to whichever wallet happened to
be first. The approval named that wallet by label only, which does not help
when the label is one Aegis generated.

Three changes, one idea: the wallet a purpose uses should be something you
said, not something that fell out of list order.

- Roles. A wallet can be nominated for payments and/or for WizardConnect,
  set from its manage modal and badged on its row. A role that points at a
  removed wallet reads back as null instead of being trusted, so a stale
  pointer can never quietly redirect a payment.
- The pairing approval asks. With more than one candidate it offers a
  dropdown of them, each labelled with its own short address; with only one
  it shows that wallet's full address. The rows are static, so naming a
  wallet above a select the user can change would contradict itself — hence
  one or the other, never both. The panel's own Connect pane now follows the
  same precedence, because two rules for "which wallet" is how a pairing
  surprises someone.
- Send gets a From row listing the wallets on this coin and network, with
  balances. It switches the panel selection rather than carrying a separate
  source: planSend and send resolve the wallet host-side from that, and a
  second notion of "current" would let the form and the approval disagree.

Payments also becomes the opening selection when nothing has been picked yet,
which is what nominating it is for.

No Theseus release needed — approvalModal has supported a select row all
along, and the pick comes back as "allow+wallet=<id>", validated against the
options offered.
This commit is contained in:
Local Dev 2026-10-01 00:59:17 +02:00
parent 3ce36a0184
commit 6bb3c50d0d
4 changed files with 234 additions and 11 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "aegis", "id": "aegis",
"name": "Aegis Wallet", "name": "Aegis Wallet",
"version": "0.20.0", "version": "0.21.0",
"category": "plugin", "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.", "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", "author": "Silent Mode",

View file

@ -880,6 +880,42 @@ function buildWcApprovalBody(payload) {
+ "<div class=hint>" + bc + " Signs with SIGHASH_ALL|FORKID|UTXOS.</div>"; + "<div class=hint>" + bc + " Signs with SIGHASH_ALL|FORKID|UTXOS.</div>";
} }
// ---- 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 ------------------------------------------------------- // ---- state / snapshot -------------------------------------------------------
function selectedWalletId() { function selectedWalletId() {
@ -887,6 +923,10 @@ function selectedWalletId() {
if (!list.length) return null; if (!list.length) return null;
const saved = String(ctx.api.storage.get("selectedWalletId", "") || ""); const saved = String(ctx.api.storage.get("selectedWalletId", "") || "");
if (saved && list.some((w) => w.id === saved)) return saved; 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]; const dflt = list.find((w) => w.isDefault) || list[0];
return dflt.id; return dflt.id;
} }
@ -974,6 +1014,7 @@ function fullState() {
return { return {
overallPhase: overallPhase(), overallPhase: overallPhase(),
selectedWalletId: selectedWalletId(), selectedWalletId: selectedWalletId(),
roles: walletRoles(),
wallets: walletEntries().map(walletSummary), wallets: walletEntries().map(walletSummary),
selected: snapshotForSelected(), selected: snapshotForSelected(),
bchServers: { bchServers: {
@ -1008,6 +1049,15 @@ function fromPage(m) {
return m.origin; 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 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"); const fmtTrx = (sun) => (Number(sun) / 1e6).toFixed(6).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1");
function fmtValue(units, decimals) { function fmtValue(units, decimals) {
@ -1842,22 +1892,61 @@ function registerPanelMessages(api) {
if (!candidates.length) { 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."); 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 // Which wallet to offer first: the one nominated for WizardConnect, else
// whatever the user currently sees in the panel. // 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 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 () => { return withOriginLock(origin, async () => {
const pick = await api.approvalModal({ const pick = await api.approvalModal({
title: "Pair this site with your wallet?", title: "Pair this site with your wallet?",
origin, 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.", 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: [ 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 }], 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); await ctx.wc.connectUri(chosen.id, uri);
return { paired: true, wallet: chosen.label }; return { paired: true, wallet: chosen.label };
}); });
@ -1883,6 +1972,20 @@ function registerPanelMessages(api) {
return fullState(); 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("permissions", (_p, m) => { fromPanel(m); return permissions(api); });
api.onMessage("revoke", (p, m) => { api.onMessage("revoke", (p, m) => {
fromPanel(m); fromPanel(m);

View file

@ -152,6 +152,13 @@
.portfolio b { color: var(--ink); font-weight: 600; } .portfolio b { color: var(--ink); font-weight: 600; }
.portfolio[hidden] { display: none; } .portfolio[hidden] { display: none; }
.picker-actions { display: flex; align-items: center; gap: 6px; } .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; .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; 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; } display: inline-flex; align-items: center; justify-content: center; }
@ -860,6 +867,16 @@
<span class="hint" id="consolidateChipCount"></span> <span class="hint" id="consolidateChipCount"></span>
</button> </button>
</div> </div>
<!-- Which address the money leaves from. Only rendered when this coin
and network actually has more than one wallet, so a single-wallet
setup keeps a form with nothing to decide. Switching it switches
the panel's selected wallet, because that is what planSend/send
spend from — one source of truth, no second notion of "current". -->
<div class="field" id="sendFromField" hidden>
<div class="lbl">From</div>
<select id="sendFrom"></select>
<div class="hint" id="sendFromHint"></div>
</div>
<div class="field" id="sendAssetField" hidden> <div class="field" id="sendAssetField" hidden>
<div class="lbl">Asset</div> <div class="lbl">Asset</div>
<select id="sendAsset"></select> <select id="sendAsset"></select>

View file

@ -1053,6 +1053,16 @@ function openWalletManageModal(w) {
// Only BCH imports can be promoted: the other imported adapters still // Only BCH imports can be promoted: the other imported adapters still
// throw "read-only" from plan(), so there is no sweep to run. // throw "read-only" from plan(), so there is no sweep to run.
const canPromote = w.kind === "imported" && w.chain === "bch"; 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 = ` overlay.innerHTML = `
<div style="width:min(94vw,380px);background:var(--panel,#12161e);border:1px solid var(--line,#2a2f38);border-radius:10px;padding:14px 14px 12px;box-shadow:0 10px 40px rgba(0,0,0,.4)"> <div style="width:min(94vw,380px);background:var(--panel,#12161e);border:1px solid var(--line,#2a2f38);border-radius:10px;padding:14px 14px 12px;box-shadow:0 10px 40px rgba(0,0,0,.4)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
@ -1070,6 +1080,18 @@ function openWalletManageModal(w) {
<input type="text" id="mwPath" value="${esc(w.accountPath || "")}" spellcheck="false" placeholder="m/44'/…"> <input type="text" id="mwPath" value="${esc(w.accountPath || "")}" spellcheck="false" placeholder="m/44'/…">
<div class="hint">Advanced. Changing this switches to a different set of addresses under the same wallet seed.</div> <div class="hint">Advanced. Changing this switches to a different set of addresses under the same wallet seed.</div>
</div>` : ""} </div>` : ""}
<div style="border-top:1px solid var(--line);margin:12px 0 0;padding-top:10px">
<div class="lbl" style="margin-bottom:6px">Use this wallet for</div>
<label style="display:flex;align-items:center;gap:8px;margin-bottom:6px;cursor:pointer">
<input type="checkbox" id="mwRolePay" ${isPay ? "checked" : ""}>
<span>Payments <span class="hint" style="font-weight:normal">— opens here when nothing else is picked</span></span>
</label>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;${wcOk ? "" : "opacity:.55;cursor:default"}">
<input type="checkbox" id="mwRoleWc" ${isWc ? "checked" : ""} ${wcOk ? "" : "disabled"}>
<span>WizardConnect <span class="hint" style="font-weight:normal">— offered first when a site asks to pair</span></span>
</label>
${wcOk ? "" : `<div class="hint" style="margin-top:4px">${esc(wcWhy)}</div>`}
</div>
${canPromote ? `<div style="border-top:1px solid var(--line);margin:12px 0 0;padding-top:10px"> ${canPromote ? `<div style="border-top:1px solid var(--line);margin:12px 0 0;padding-top:10px">
<div class="lbl" style="margin-bottom:4px">WizardConnect</div> <div class="lbl" style="margin-bottom:4px">WizardConnect</div>
<div class="hint" style="margin-bottom:8px">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.</div> <div class="hint" style="margin-bottom:8px">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.</div>
@ -1100,6 +1122,17 @@ function openWalletManageModal(w) {
if (canSetPath && nextPath && nextPath !== (w.accountPath || "")) { if (canSetPath && nextPath && nextPath !== (w.accountPath || "")) {
state = await S.invoke("setAccountPath", { id: w.id, accountPath: nextPath }); 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(); close();
fillPicker(); fillPicker();
render(); render();
@ -2215,7 +2248,12 @@ function renderConnectPane(bchWallets) {
// Wallets with no derivable seed (WIF single-key imports) can't pair at // Wallets with no derivable seed (WIF single-key imports) can't pair at
// all, so they're disabled rather than silently failing on Connect. // all, so they're disabled rather than silently failing on Connect.
const pairable = readyBch.filter((w) => !w.wcBlocked); const pairable = readyBch.filter((w) => !w.wcBlocked);
const options = readyBch.map((w) => `<option value="${esc(w.id)}" ${!w.wcBlocked && w.id === state.selectedWalletId ? "selected" : ""} ${w.wcBlocked ? "disabled" : ""}>${esc(w.label)} · ${esc(w.networkLabel)}${w.wcBlocked ? ` — can't pair (${esc(w.wcBlockedShort || "unsupported")})` : ""}</option>`).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) => `<option value="${esc(w.id)}" ${w.id === preferId ? "selected" : ""} ${w.wcBlocked ? "disabled" : ""}>${esc(w.label)} · ${esc(w.networkLabel)}${w.id === wcRole ? " ★" : ""}${w.wcBlocked ? ` — can't pair (${esc(w.wcBlockedShort || "unsupported")})` : ""}</option>`).join("");
// Greying an option out with "can't pair" and giving no reason anywhere on // 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 // 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 // added. Show the reasons whenever ANY wallet is blocked — the old note
@ -2877,9 +2915,21 @@ function renderInlineCoinList(el, groupKey, group) {
const rowMenu = locked const rowMenu = locked
? `<span class="wact" title="Default wallet — protects legacy funds; cannot be removed" style="opacity:.35;cursor:not-allowed">🔒</span>` ? `<span class="wact" title="Default wallet — protects legacy funds; cannot be removed" style="opacity:.35;cursor:not-allowed">🔒</span>`
: `<button class="wact" data-lpedit="${esc(w.id)}" title="Rename, derivation path, or remove this wallet">⋯</button>`; : `<button class="wact" data-lpedit="${esc(w.id)}" title="Rename, derivation path, or remove this wallet">⋯</button>`;
// 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 ? `<span class="rolebadge pay" title="Default wallet for payments">pay</span>` : "",
roles.wizardconnect === w.id ? `<span class="rolebadge" title="Offered first when a site asks to pair over WizardConnect">wc</span>` : "",
].join("");
const labelCell = `<span class="walabel walabel-wide" title="${esc(w.label || "")}">${esc(w.label || shortName || "—")}</span>`;
return `<div class="warow ${on}" data-listpick="${esc(w.id)}" title="${esc(w.label || fullAddr)}"> return `<div class="warow ${on}" data-listpick="${esc(w.id)}" title="${esc(w.label || fullAddr)}">
<span class="wcell">${logoSvg(meta.logo, 14)}</span> <span class="wcell">${logoSvg(meta.logo, 14)}</span>
<span class="walabel walabel-wide" title="${esc(w.label || "")}">${esc(w.label || shortName || "—")}</span> ${badges
? `<span style="display:flex;align-items:center;gap:4px;min-width:0">${labelCell}${badges}</span>`
: labelCell}
${fullAddr ? `<button class="wacopy" data-lpcopy="${esc(fullAddr)}" title="Copy full address (${esc(fullAddr)})">${COPY_ICON}</button>` : `<span></span>`} ${fullAddr ? `<button class="wacopy" data-lpcopy="${esc(fullAddr)}" title="Copy full address (${esc(fullAddr)})">${COPY_ICON}</button>` : `<span></span>`}
<span class="waamt"><span>${esc(bal)}</span><span class="watkr">${esc(meta.ticker)}</span></span> <span class="waamt"><span>${esc(bal)}</span><span class="watkr">${esc(meta.ticker)}</span></span>
<span style="display:inline-flex;gap:2px;justify-self:end;align-items:center">${rowMenu}</span> <span style="display:inline-flex;gap:2px;justify-self:end;align-items:center">${rowMenu}</span>
@ -3339,6 +3389,7 @@ function render() {
cur && w.chain === cur.chain && w.network === cur.network && w.id !== state.selectedWalletId, cur && w.chain === cur.chain && w.network === cur.network && w.id !== state.selectedWalletId,
); );
const showToggle = others.length > 0; const showToggle = others.length > 0;
paintSendFrom(cur, others);
// Send keeps its toggle. Receive's became a button in the address actions // 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. // (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"]]) { 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 : ""); 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 `<option value="${esc(w.id)}">${esc((w.label || "wallet") + tail)} — ${esc(bal)} ${esc(w.ticker)}${star}</option>`;
}).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() { function applyUnitPicker() {
const s = sel(); if (!s) return; const s = sel(); if (!s) return;
if (!unit) unit = "big"; if (!unit) unit = "big";