diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json
index 680d428a..02188848 100644
--- a/bundled-addons/aegis/addon.json
+++ b/bundled-addons/aegis/addon.json
@@ -1,7 +1,7 @@
{
"id": "aegis",
"name": "Aegis Wallet",
- "version": "0.26.0",
+ "version": "0.26.1",
"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/panel.js b/bundled-addons/aegis/panel.js
index 52c7b8da..819ec08b 100644
--- a/bundled-addons/aegis/panel.js
+++ b/bundled-addons/aegis/panel.js
@@ -3286,6 +3286,8 @@ function renderLockScreen(phase) {
const body = $("lockBody");
if (phase === "nosetup") {
title.textContent = "Set up Aegis";
+ if (body.dataset.mode === "setup") return;
+ body.dataset.mode = "setup";
sub.textContent = "Pick a master password — every Aegis wallet is derived from it. The same master password on another machine recreates the same addresses.";
body.innerHTML = `
@@ -3323,6 +3325,13 @@ function renderLockScreen(phase) {
title.textContent = "Unlock Aegis";
sub.textContent = "Aegis derives its keys from your Theseus vault. There's nothing separate to unlock — the vault is your wallet.";
if (hasPin && !forcePw) {
+ // render() runs on every state push — balance polls fire it every couple
+ // of seconds — and this used to rebuild body.innerHTML each time, wiping
+ // the pad DOM and its digit buffer out from under someone mid-entry.
+ // That is the "resets after two digits" report: the reset is timed to the
+ // poll, not to the keypress count. Rebuild only when the mode changes.
+ if (body.dataset.mode === "pin") return;
+ body.dataset.mode = "pin";
body.innerHTML = `
${"".repeat(6)}
@@ -3336,9 +3345,9 @@ function renderLockScreen(phase) {
`;
setupPinPad({
- dots: $("lockPinDots"),
- keys: $("lockPinKeys"),
- err: $("lockPinErr"),
+ dots: body.querySelector("#lockPinDots"),
+ keys: body.querySelector("#lockPinKeys"),
+ err: body.querySelector("#lockPinErr"),
onComplete: async (pin) => {
const remain = await pinLockoutRemainingMs();
if (remain > 0) {
@@ -3367,7 +3376,10 @@ function renderLockScreen(phase) {
if ($("lockGoSettingsFromPin")) $("lockGoSettingsFromPin").addEventListener("click", () => showTab("settings"));
return;
}
- // Master-password entry.
+ // Master-password entry. Guarded for the same reason as the pad above:
+ // rebuilding on every state push erased a half-typed password.
+ if (body.dataset.mode === "pw") return;
+ body.dataset.mode = "pw";
body.innerHTML = `
@@ -3405,6 +3417,12 @@ function renderLockScreen(phase) {
// typed and must return "ok" (leave state) or "reset" (clear back to
// empty). Rendered by renderLockScreen for the unlock flow and by the
// PIN modal helper for set / verify flows.
+// IMPORTANT: pass `dots`/`keys`/`err` as elements scoped to the pad's own
+// container, never via getElementById. Each pad's markup hard-codes its ids,
+// so two pads alive at once (easy: a load-time gate plus a transaction gate)
+// are duplicate ids — a global lookup then returns the FIRST one, and this
+// function binds a second click handler to the wrong pad's buttons. The
+// symptom is a pad that appends two digits per press and resets after three.
function setupPinPad({ dots, keys, err, onComplete }) {
let buf = "";
const paint = () => {
@@ -3463,6 +3481,11 @@ function render() {
// Settings is the only always-usable tab (fiat prices, connected sites
// — nothing needs a live wallet). Every other tab is gated.
const onSettings = tab === "settings";
+ // An owed PIN hides everything, Settings included: unlike the vault lock
+ // (where Settings must stay reachable to configure a PIN in the first
+ // place), the PIN is already configured here and letting Settings through
+ // would be a way around the door.
+ if (pinGateBlocked) { paintPinDoor(); return; }
$("tabs").hidden = !(ready || onSettings);
const gate = $("gate");
gate.hidden = ready || onSettings || fullLock;
@@ -4619,7 +4642,9 @@ function openPinModal({ title, subtitle, mode }) {
wrap.addEventListener("click", (e) => { if (e.target === wrap) close(null); });
wrap.querySelector("#pmCancel").addEventListener("click", () => close(null));
setupPinPad({
- dots: $("pmDots"), keys: $("pmKeys"), err: $("pmErr"),
+ // Scoped to this overlay, not looked up by global id — see the note
+ // on setupPinPad about duplicate ids across simultaneous pads.
+ dots: wrap.querySelector("#pmDots"), keys: wrap.querySelector("#pmKeys"), err: wrap.querySelector("#pmErr"),
onComplete: async (pin) => {
if (mode === "confirm" && first.pin == null) {
first.pin = pin;
@@ -4949,7 +4974,15 @@ const PIN_GATE_COPY = {
// Gate the panel itself on load. Runs once per panel script load, which is
// also once per hide/show, so "on each wallet launch" is simply this check
// with that trigger enabled.
+//
+// This must finish BEFORE the first render(). Asking afterwards was the
+// original complaint in a new costume: the wallet painted, balances and all,
+// and the pad then appeared on top of a panel the user could already read.
+// `pinGateBlocked` keeps the chrome hidden for as long as the PIN is owed,
+// so a cancelled or failed prompt leaves a closed door rather than an open
+// wallet.
let pinGateChecked = false;
+let pinGateBlocked = false;
async function pinGateOnLoad() {
if (pinGateChecked) return;
pinGateChecked = true;
@@ -4959,7 +4992,44 @@ async function pinGateOnLoad() {
// is already in the way, and stacking a PIN prompt on top of it is two
// locks on one door.
if (state && (state.overallPhase === "locked" || state.overallPhase === "nosetup")) return;
- await pinGate("panel-load");
+ let st;
+ try { st = await S.invoke("pinGateStatus", { event: "panel-load" }); }
+ catch { st = { needPin: true, reason: "unknown" }; }
+ if (!st.needPin) return;
+
+ pinGateBlocked = true;
+ paintPinDoor(st.reason);
+ // Keep asking until it is satisfied. Cancelling does not open the wallet;
+ // it leaves the door shut with a button to try again, which is the only
+ // honest outcome for "a PIN is required here".
+ for (;;) {
+ const ok = await verifyPinInteractively(PIN_GATE_COPY[st.reason] || "Confirm with your PIN.");
+ if (ok) break;
+ const again = await aegisConfirm({
+ title: "PIN required",
+ icon: "🔒",
+ confirmLabel: "Enter PIN",
+ cancelLabel: "Leave locked",
+ body: "Aegis stays locked until the PIN is entered. You can also unlock with your master password from Settings.",
+ });
+ if (!again) return; // stays blocked; door remains shut
+ }
+ try { await S.invoke("pinGateSatisfied"); } catch { /* re-asks next load */ }
+ pinGateBlocked = false;
+ render();
+}
+
+// The closed door shown behind the pad, so there is never a moment where the
+// wallet is readable but unauthenticated.
+function paintPinDoor(reason) {
+ const g = $("gate");
+ if (!g) return;
+ document.querySelector("header").hidden = true;
+ document.querySelector("nav").hidden = true;
+ const strip = $("walletStrip"); if (strip) strip.hidden = true;
+ const ls = $("lockScreen"); if (ls) ls.hidden = true;
+ g.hidden = false;
+ g.innerHTML = `
🔒
${esc(PIN_GATE_COPY[reason] || "Confirm with your PIN.")}
`;
}
// How many wrong PINs before a sensitive reveal stops asking for the PIN and
@@ -5037,7 +5107,7 @@ function capturePinForSecret(subtitle) {
wrap.querySelector("#rsUsePw").addEventListener("click", () => done("__fallback__"));
let tries = 0;
setupPinPad({
- dots: $("rsDots"), keys: $("rsKeys"), err: $("rsErr"),
+ dots: wrap.querySelector("#rsDots"), keys: wrap.querySelector("#rsKeys"), err: wrap.querySelector("#rsErr"),
onComplete: async (pin) => {
try {
const blob = await S.invoke("pinBlobGet");
@@ -5064,7 +5134,18 @@ function capturePinForSecret(subtitle) {
// Ask the user to prove they know the PIN. Uses the same lockout counter
// as the unlock flow so an attacker can't drain guesses via a spammed
// Send button. Returns true on match, false on cancel / lockout / bad PIN.
-async function verifyPinInteractively(subtitle) {
+// Only one PIN prompt at a time. Two concurrent prompts are asking the same
+// question, so the second joins the first instead of stacking a second pad
+// over it — which is also how duplicate pad ids came to collide.
+let pinPromptInFlight = null;
+function verifyPinInteractively(subtitle) {
+ if (pinPromptInFlight) return pinPromptInFlight;
+ pinPromptInFlight = verifyPinInteractivelyOnce(subtitle)
+ .finally(() => { pinPromptInFlight = null; });
+ return pinPromptInFlight;
+}
+
+async function verifyPinInteractivelyOnce(subtitle) {
const remain = await pinLockoutRemainingMs();
if (remain > 0) {
aegisAlert(`PIN entry is locked for ${Math.ceil(remain / 60000)} min. Use "Remove" in Settings or wait it out.`);
@@ -5095,7 +5176,7 @@ async function verifyPinInteractively(subtitle) {
const done = (v) => { try { wrap.remove(); } catch {} resolve(v); };
wrap.querySelector("#vpCancel").addEventListener("click", () => done(false));
setupPinPad({
- dots: $("vpDots"), keys: $("vpKeys"), err: $("vpErr"),
+ dots: wrap.querySelector("#vpDots"), keys: wrap.querySelector("#vpKeys"), err: wrap.querySelector("#vpErr"),
onComplete: async (pin) => {
try {
const blob = await S.invoke("pinBlobGet");
@@ -5456,20 +5537,32 @@ $("refreshPrices").addEventListener("click", async () => {
});
// ---- boot ------------------------------------------------------------------
-S.on("state", (s) => { state = s; render(); if (tab === "settings") fillSettings(); });
+S.on("state", (s) => {
+ state = s;
+ render();
+ // render() returns early while a PIN is owed; don't fill a hidden Settings
+ // tab behind the door either.
+ if (tab === "settings" && !pinGateBlocked) fillSettings();
+});
(async () => {
// Load security + session state first so the very first render() knows
// whether to paint the PIN pad on the lock screen and what idle-lock
// timer to arm once the vault is open.
try { await refreshSecurityState(); } catch {}
try { await refreshSessionState(); } catch {}
- try { state = await S.invoke("state"); render(); }
- catch (e) { $("gate").hidden = false; $("gate").innerHTML = `
`;
+ return;
+ }
+ // Settle the PIN BEFORE the first paint. Awaited on purpose: rendering the
+ // wallet first and prompting afterwards is exactly the behaviour being
+ // fixed, and a fire-and-forget call also let a second prompt open on top
+ // of this one.
+ await pinGateOnLoad();
+ render();
bindIdleAutoLock();
- // Ask for the PIN at the door if the policy says so, rather than letting
- // the wallet open and then interrupting the first thing the user clicks.
- // This runs once per panel load, which is also once per hide/show.
- pinGateOnLoad();
})();
// Persistent footer: aegis.x brand link + version marker + update check.