From 7a71b08cf7bb8ebf9f88b2f76d6dc6fafd6b1054 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 23 Sep 2026 00:24:42 +0200 Subject: [PATCH 01/47] =?UTF-8?q?fix(aegis):=200.9.1=20=E2=80=94=20an=20un?= =?UTF-8?q?set=20custom=20RPC=20no=20longer=20becomes=20the=20text=20"unde?= =?UTF-8?q?fined"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounting an imported TRX/ETH/SOL wallet read the optional custom RPC as String(stored || undefined), so a wallet with no override got the literal "undefined" as its server: every history and token fetch went to "undefined/v1/accounts/…" and the panel showed "undefined" under the balance. Only a real https URL overrides the network default now, and the adapter itself rejects anything that does not look like one. --- bundled-addons/aegis/addon.json | 2 +- bundled-addons/aegis/index.js | 7 ++++++- bundled-addons/aegis/lib/chain-generic-imported.js | 5 ++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index 59cfa0c6..63f66378 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.9.0", + "version": "0.9.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/index.js b/bundled-addons/aegis/index.js index 002e684c..c27f5d48 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -551,7 +551,12 @@ async function mountWallet(entry) { } else if (entry.chain === "eth" || entry.chain === "trx" || entry.chain === "sol") { adapter = new c.d.genericImportedAdapter.GenericImportedWallet({ ...commonOpts, chain: entry.chain, address: entry.importedAddress, - rpcUrl: String(c.api.storage.get(`wallets/${entry.id}/rpcUrl`, "") || undefined), + // Only a real custom URL overrides the network default. The old + // String(x || undefined) turned an unset override into the text + // "undefined", which is truthy — so every imported TRX/ETH/SOL wallet + // without a custom RPC fetched "undefined/v1/accounts/…" and showed + // "undefined" as its server. + rpcUrl: String(c.api.storage.get(`wallets/${entry.id}/rpcUrl`, "") || "").trim() || undefined, }); adapter.schedulePoll(20_000); } else if (entry.chain === "sc") { diff --git a/bundled-addons/aegis/lib/chain-generic-imported.js b/bundled-addons/aegis/lib/chain-generic-imported.js index b52419c3..c170c433 100644 --- a/bundled-addons/aegis/lib/chain-generic-imported.js +++ b/bundled-addons/aegis/lib/chain-generic-imported.js @@ -301,7 +301,10 @@ module.exports = function makeGenericImportedAdapter() { this.chain = chain; this.network = network; this._cfg = cfg; - this._net = { ...net, rpc: rpcUrl || net.rpc }; + // A custom RPC must look like one; anything else (empty, "undefined", + // a stray non-URL) falls back to the network default. + const customRpc = typeof rpcUrl === "string" && /^https?:\/\/\S+$/i.test(rpcUrl.trim()) ? rpcUrl.trim() : null; + this._net = { ...net, rpc: customRpc || net.rpc }; this.log = log; this.onChange = onChange; this._address = address; From 1be352dad6a594e1cd7d63b37087aef1fe161ca1 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 23 Sep 2026 01:17:33 +0200 Subject: [PATCH 02/47] =?UTF-8?q?chore(aegis):=200.9.2=20=E2=80=94=20Recei?= =?UTF-8?q?ve=20tab=20reorder,=20one=20balance,=20token=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Receive was ordered QR → address → tokens, so 200px of always-on QR sat above the thing people came for and pushed the asset list off screen. - Address first, with Copy and a QR button; the QR expands inline and the choice sticks, because someone who receives by QR wants it every time and someone who copies never does. - Explorer and Faucet moved up to the header status row. They act on the selected wallet, not on the act of receiving, and down there they competed with Copy for the one row that gets used. - Assets card renamed from Tokens and now leads with the native coin, so "what does this wallet hold" is one list rather than two places. - "+ Add another " moved below the address list. The balance appeared three times — header, drilldown subtitle, address row. Now once in the header; the subtitle keeps only the per-unit price, and the per-address amount returns when a coin actually has more than one address to compare. The address row drops its truncated address (the full one is at the top of Receive) and keeps the wallet name. The per-address asset list added in 0.8.8 duplicated the Assets card and is removed — assets live in one place. Token amounts were unreadable: an 18-decimal balance rendered as 60000000.000000005435817984. Capped to 8 decimals with thousands separators, exact value on hover. A symbol claimed by more than one contract now carries a LOOK-ALIKE tag. The test wallet holds four different contracts all calling themselves "Test USDT" — spam mints borrowing a trusted ticker so a careless send lands on the wrong one. We can't tell which is genuine, so we mark every member of the clash rather than guessing. History showed native transfers only, so a wallet that had only ever moved USDT looked empty. TRC20 transfers are merged in newest-first, each carrying its own decimals and ticker (rendering a token against the chain's scale would be off by orders of magnitude). Both feeds are on by default; the checkboxes narrow rather than opt in, and the last one checked can't be unchecked into an empty list. --- bundled-addons/aegis/addon.json | 2 +- .../aegis/lib/chain-generic-imported.js | 41 +++- bundled-addons/aegis/panel.html | 51 +++- bundled-addons/aegis/panel.js | 221 +++++++++++------- 4 files changed, 220 insertions(+), 95 deletions(-) diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index 63f66378..7ff71e12 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.9.1", + "version": "0.9.2", "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/lib/chain-generic-imported.js b/bundled-addons/aegis/lib/chain-generic-imported.js index c170c433..9b4ff497 100644 --- a/bundled-addons/aegis/lib/chain-generic-imported.js +++ b/bundled-addons/aegis/lib/chain-generic-imported.js @@ -150,7 +150,45 @@ module.exports = function makeGenericImportedAdapter() { const j = await r.json(); return String(j?.balance || 0); }, - async fetchHistory({ rpc, address }) { + // Native TRX transfers AND TRC20 token transfers, merged newest-first + // and each tagged with `asset` so the History tab can filter. Token + // movement is invisible in the native feed — a wallet that only ever + // moved USDT looked like it had no history at all. + async fetchHistory(opts) { + const [native, tokens] = await Promise.all([ + CHAIN_CFGS.trx._fetchNativeHistory(opts), + CHAIN_CFGS.trx._fetchTokenHistory(opts).catch(() => []), + ]); + return [...native, ...tokens] + .sort((a, b) => (b.time || 0) - (a.time || 0)) + .slice(0, 40); + }, + async _fetchTokenHistory({ rpc, address }) { + const r = await fetch(`${rpc.replace(/\/+$/, "")}/v1/accounts/${encodeURIComponent(address)}/transactions/trc20?limit=25`); + if (!r.ok) throw new Error(`Tron trc20 history HTTP ${r.status}`); + const j = await r.json(); + const list = Array.isArray(j?.data) ? j.data : []; + const me = String(address); + return list.map((t) => { + const ti = t.token_info || {}; + const outgoing = String(t.from || "") === me; + const raw = String(t.value || "0"); + return { + txid: t.transaction_id, + time: Math.floor(Number(t.block_timestamp || 0) / 1000), + confirmations: 1, + status: "confirmed", + // Token amounts are in the TOKEN's own decimals, not the + // chain's, so they travel with their own scale rather than + // being rendered against the native one. + delta: (outgoing ? "-" : "") + raw, + assetDecimals: Number.isFinite(Number(ti.decimals)) ? Number(ti.decimals) : 0, + asset: cleanTokenText(ti.symbol) || "token", + kind: "TRC20", + }; + }).filter((t) => t.txid); + }, + async _fetchNativeHistory({ rpc, address }) { const r = await fetch(`${rpc.replace(/\/+$/, "")}/v1/accounts/${encodeURIComponent(address)}/transactions?limit=25`); if (!r.ok) throw new Error(`Tron history HTTP ${r.status}`); const j = await r.json(); @@ -171,6 +209,7 @@ module.exports = function makeGenericImportedAdapter() { status: ok ? "confirmed" : "failed", // Aegis renders `delta` in the wallet's base unit (sun here). delta: c?.type === "TransferContract" ? (outgoing ? -amount : amount) : 0, + asset: null, // null = the chain's native coin kind: c?.type || "Contract", }; }).filter((t) => t.txid); diff --git a/bundled-addons/aegis/panel.html b/bundled-addons/aegis/panel.html index da094776..44291c13 100644 --- a/bundled-addons/aegis/panel.html +++ b/bundled-addons/aegis/panel.html @@ -35,8 +35,8 @@ .bal { margin-top: 8px; font-variant-numeric: tabular-nums; } .bal .big { font-size: 22px; font-weight: 650; letter-spacing: .2px; } .bal .big small { font-size: 13px; color: var(--mut); font-weight: 500; margin-left: 4px; } - .bal .sub { color: var(--dim); font-size: 11.5px; display: flex; justify-content: space-between; gap: 8px; } - .bal .sub .netlbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .bal .sub { color: var(--dim); font-size: 11.5px; display: flex; align-items: center; gap: 8px; } + .bal .sub .netlbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } /* Full-panel sheet — fills most of the sidebar but stops above the footer so the aegis.x brand + version chip stay visible. Users opening + should still know which build they're on and be able @@ -79,6 +79,16 @@ #drop .brnet.on { background: rgb(from var(--acid, #d6ff3d) r g b / .14); border-color: rgb(from var(--acid, #d6ff3d) r g b / .55); color: var(--acid, #d6ff3d); font-weight: 600; } #drop .brnet .hint { color: currentColor; opacity: .65; font-size: 11px; } + .histfilter { display: flex; align-items: center; gap: 14px; padding: 0 2px 10px; + border-bottom: 1px solid var(--line); margin-bottom: 8px; font-size: 12px; } + .histfilter[hidden] { display: none; } + /* Assets card rows (0.9.2). The native coin leads the list; a symbol + claimed by several contracts gets a LOOK-ALIKE tag so a borrowed + ticker can't pass for the real token. */ + .tx.asset { padding: 7px 8px; border-radius: 6px; } + .tx.asset:hover { background: rgba(255,255,255,.04); } + .tx.asset.native { background: rgb(from var(--acid, #d6ff3d) r g b / .06); } + .ttag.tdupe { background: rgba(224,90,90,.18); color: #f6768a; cursor: help; } .ttag { display: inline-block; font-size: 9.5px; letter-spacing: .06em; padding: 1px 5px; border-radius: 3px; background: rgba(224,179,65,.18); color: #e0b341; font-weight: 700; vertical-align: middle; margin-left: 2px; } /* 0.8.4: hNet became a proper chip on its own row (.netrow) so the @@ -99,6 +109,13 @@ #drop .catgroup { padding: 4px 6px 2px 10px; color: var(--dim); font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; } #drop .row.unowned { opacity: .8; } #drop .row.unowned .v { color: var(--acid, #d6ff3d); font-weight: 600; font-size: 11px; } + /* Per-wallet actions promoted into the header's status row (0.9.2). */ + .hdracts { margin-left: auto; display: inline-flex; gap: 4px; } + .hdract { background: transparent; border: 1px solid var(--line); color: var(--dim); + border-radius: 999px; padding: 1px 9px; font: inherit; font-size: 10.5px; + cursor: pointer; line-height: 1.5; } + .hdract:hover { border-color: var(--acid, #d6ff3d); color: var(--acid, #d6ff3d); } + .hdract[hidden] { display: none; } .fiat { color: var(--dim); font-size: 12.5px; margin-left: 10px; font-weight: 500; letter-spacing: .2px; } .portfolio { margin-top: 6px; color: var(--mut); font-size: 11.5px; } .portfolio b { color: var(--ink); font-weight: 600; } @@ -198,7 +215,8 @@ .btn.danger { color: var(--danger); } .btn.sm { padding: 4px 9px; font-size: 12px; } .actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; } - .qrwrap { display: grid; place-items: center; padding: 12px; background: #fff; border-radius: 10px; margin-bottom: 12px; } + .qrwrap { display: grid; place-items: center; padding: 12px; background: #fff; border-radius: 10px; margin-top: 12px; } + .qrwrap[hidden] { display: none; } canvas { image-rendering: pixelated; } input[type=text], input[type=number], input[type=password], textarea { width: 100%; padding: 7px 9px; border-radius: 7px; background: var(--panel); border: 1px solid var(--line); color: var(--ink); font: inherit; font-size: 13px; outline: none; } @@ -567,6 +585,14 @@
connecting… + + + + +
@@ -595,19 +621,22 @@
-
+
Receiving address
—
+ - -
+
@@ -679,6 +708,14 @@
diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index e3ede775..f331a1ea 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -2162,8 +2162,6 @@ function paintRcvView() { function paintRcvMode() { const normal = $("rcvNormal"); const cons = $("rcvConsolidate"); if (!normal || !cons) return; - const buttons = document.querySelectorAll("[data-rcv-mode]"); - buttons.forEach((b) => b.classList.toggle("on", b.dataset.rcvMode === rcvMode)); normal.hidden = rcvMode !== "receive"; cons.hidden = rcvMode !== "consolidate"; if (rcvMode === "consolidate") { @@ -3336,7 +3334,9 @@ function render() { cur && w.chain === cur.chain && w.network === cur.network && w.id !== state.selectedWalletId, ); const showToggle = others.length > 0; - for (const [wrapId, cntId] of [["sendModeToggle", "sendConsolidateCount"], ["rcvModeToggle", "rcvConsolidateCount"]]) { + // 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"]]) { const wrap = $(wrapId); if (!wrap) continue; wrap.hidden = !showToggle; if (showToggle) { @@ -3967,18 +3967,26 @@ document.querySelectorAll("[data-send-mode]").forEach((b) => b.addEventListener( consolidateInlineHost = null; // force re-render on next switch paintSendMode(); })); +// Consolidate, now an address action rather than a view toggle. Entering the +// view forces a re-render of the inline host so the preview is costed fresh +// each time, the same thing the old toggle did. +if ($("rcvConsolidateBtn")) $("rcvConsolidateBtn").addEventListener("click", () => { + rcvMode = "consolidate"; + consolidateInlineHost = null; + paintRcvMode(); +}); +if ($("rcvConsolidateBack")) $("rcvConsolidateBack").addEventListener("click", () => { + rcvMode = "receive"; + consolidateInlineHost = null; + paintRcvMode(); + paintRcvView(); +}); // Address & QR / Assets — the persistent pair inside the Receive body. document.querySelectorAll("[data-rcv-view]").forEach((b) => b.addEventListener("click", () => { rcvView = b.dataset.rcvView === "assets" ? "assets" : "address"; try { localStorage.setItem("aegis/rcvView", rcvView); } catch (_e) {} paintRcvView(); })); -document.querySelectorAll("[data-rcv-mode]").forEach((b) => b.addEventListener("click", () => { - rcvMode = b.dataset.rcvMode; - consolidateInlineHost = null; - paintRcvMode(); - paintRcvView(); -})); ["sendTo", "sendAmt"].forEach((id) => $(id).addEventListener("input", () => { if (id === "sendAmt" && sendMax) return; if (id === "sendAmt") updateSendFiatPreview(); From 9bb9086ff577f77dc219107ba60a87fc053d5636 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Tue, 29 Sep 2026 00:40:46 +0200 Subject: [PATCH 25/47] =?UTF-8?q?feat(aegis):=200.19.0=20=E2=80=94=20three?= =?UTF-8?q?=20chips:=20Address,=20Assets,=20Certificates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assets was one list holding two different kinds of thing. A fungible balance answers "how much do I have"; a non-fungible one answers "which ones do I hold", and they want different rows — an amount against a symbol, versus a commitment and a capability. On a real chipnet wallet that meant 46 rows where 32 of them were only ever going to say "1 NFT". Split by what each category actually holds, so a category carrying both appears in both — which is honest, because it really does hold both. Assets counts categories; Certificates counts individual certificates, since "3" should mean three things you hold rather than three groups. Certificate rows carry the commitment, because it is the only thing that distinguishes two certificates of the same category, and a MINTING or MUTABLE tag, because "can still issue others" versus "is fixed" is worth seeing without opening anything. A `none` capability is left unlabelled rather than adding noise to every row. "Certificates", not "NFTs": these are membership, licence and record tokens, and NFT carries collectible-market baggage that misdescribes them. Ids and data keys stay `nft` — that is the CashTokens protocol field, so it is protocol name inside and product name on screen. Two things caught while building it. `draw({})` was overwriting the "no assets" empty state with an empty string, which is now the common case since purely-non-fungible categories no longer appear there. And certificate rows printed the category twice, as the title and again on the right — the right column now only carries it when the row has a real name to lead with. Metadata is looked up for every category, not just the fungible ones, so naming a token also names its certificates. --- bundled-addons/aegis/addon.json | 2 +- bundled-addons/aegis/panel.html | 23 +++++++- bundled-addons/aegis/panel.js | 95 +++++++++++++++++++++++++++++---- 3 files changed, 107 insertions(+), 13 deletions(-) diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index f50300d8..0153d390 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.18.0", + "version": "0.19.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/panel.html b/bundled-addons/aegis/panel.html index 6b70b579..c454abf6 100644 --- a/bundled-addons/aegis/panel.html +++ b/bundled-addons/aegis/panel.html @@ -780,9 +780,23 @@ Chips give one shape for every coin and a visible answer either way. (0.9.2 had already reordered the stack for the same reason — this replaces that workaround.) --> +
- + +
@@ -812,6 +826,13 @@
Tokens held by this wallet.
+ + + diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 25f89521..39db123f 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -37,10 +37,12 @@ let sendAsset = null; // header. Cleared whenever a fresh render is triggered by a wallet change // so the strip snaps back to the summary. let stripView = { mode: "coins", groupKey: null }; -// Cached security state ({ hasPin, requirePinForSending }). Populated on -// startup and refreshed after any pin/security invoke — used both by the -// lock screen (PIN vs. password) and the Settings General card. -let securityState = { hasPin: false, requirePinForSending: false }; +// Cached security state ({ hasPin, requirePinForSending, requirePinForReveal }). +// Populated on startup and refreshed after any pin/security invoke — used by +// the lock screen (PIN vs. password), the Settings General card, and the +// reveal gate. requirePinForReveal defaults TRUE, here and in the host, so a +// failed read never lands on the permissive setting. +let securityState = { hasPin: false, requirePinForSending: false, requirePinForReveal: true }; let securityLoaded = false; // Cached session config: whether the vault stays unlocked across Theseus // restarts (safeStorage-backed) and how many idle minutes trigger an @@ -344,7 +346,7 @@ async function refreshSecurityState() { try { securityState = await S.invoke("securityGet"); securityLoaded = true; - } catch { securityState = { hasPin: false, requirePinForSending: false }; securityLoaded = true; } + } catch { securityState = { hasPin: false, requirePinForSending: false, requirePinForReveal: true }; securityLoaded = true; } return securityState; } async function refreshSessionState() { @@ -1092,6 +1094,11 @@ function openWalletManageModal(w) { ${wcOk ? "" : `
${esc(wcWhy)}
`} +
+
Secret key
+
Everything needed to spend this wallet elsewhere. Aegis asks for your PIN (or master password) first.
+ +
${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.
@@ -1138,6 +1145,7 @@ function openWalletManageModal(w) { render(); } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } }); + overlay.querySelector("#mwReveal").addEventListener("click", () => openRevealSecretModal(w)); // Promote: preview the sweep (costed without creating anything), confirm // with the real numbers, then create + sweep in one host call. The confirm // carries the amounts because this moves the wallet's entire balance. @@ -4368,6 +4376,10 @@ async function renderGeneralSecurity() { : "Off — Aegis asks for the master password every time."; if (line) line.hidden = !hasPin; if (rp) rp.checked = !!securityState.requirePinForSending; + // Both PIN policies are meaningless without a PIN to use. + const revLine = $("gsRequirePinRevealLine"), rpr = $("gsRequirePinReveal"); + if (revLine) revLine.hidden = !hasPin; + if (rpr) rpr.checked = !!securityState.requirePinForReveal; renderSessionSettings(); } @@ -4479,6 +4491,16 @@ $("gsRequirePin") && $("gsRequirePin").addEventListener("change", async () => { aegisAlert("Could not save setting: " + cleanErr(e)); } }); +$("gsRequirePinReveal") && $("gsRequirePinReveal").addEventListener("change", async () => { + const on = $("gsRequirePinReveal").checked; + try { + securityState = await S.invoke("securitySet", { requirePinForReveal: on }); + renderGeneralSecurity(); + } catch (e) { + $("gsRequirePinReveal").checked = !on; + aegisAlert("Could not save setting: " + cleanErr(e)); + } +}); $("gsOpenPasswords") && $("gsOpenPasswords").addEventListener("click", () => { // Route through the addon so it can pass the section slug back to // Theseus (main-process gates section-hint validation). @@ -4614,6 +4636,196 @@ function promptMasterPassword({ title, subtitle }) { }); } +// What each secret form actually IS, and where it can actually be restored. +// This copy matters more than it looks: none of these are a BIP39 recovery +// phrase, because Aegis never stores one. An import converts the words to a +// seed and throws the words away; a vault wallet is HKDF(vault root, purpose) +// and never had words. Someone who writes down what we show here and believes +// it is a 12-word phrase has not backed anything up, so each form says what +// it is in its own name and the note says what to do with it. +const SECRET_FORMS = { + wif: { + title: "Private key (WIF)", + note: "This single key controls this one address and nothing else. Any Bitcoin Cash wallet that accepts a WIF key can import it.", + }, + privhex: { + title: "Private key (hex)", + note: "This single key controls this one account. Most ETH / TRX / SOL wallets accept a raw hex private key.", + }, + seed: { + title: "Wallet seed (hex)", + note: "There is no word list to show. You imported a recovery phrase, and Aegis converted it to this seed and discarded the words — that conversion is one-way, so the phrase cannot be recovered from here or from anywhere else in Aegis. Keep the original phrase wherever you first wrote it down. This seed plus the derivation path below is a complete backup of the wallet, and Aegis can take it back under Add → Import → Seed (hex).", + }, + vault: { + title: "Wallet key (hex)", + note: "This wallet has no recovery phrase of its own — it is derived from your Theseus vault, so your real backup is the vault's master password. The key below restores this one wallet via Add → Import → Seed (hex), and the account xprv, where shown, is accepted by most other HD wallets.", + }, +}; + +// Reveal one wallet's secret. Gated by authorizeForSecret, hidden until +// asked for a second time, and never left on screen after the modal closes. +async function openRevealSecretModal(w) { + // Two shapes reach here: a wallet summary from the manage modal, which + // keys the id as `id`, and state.selected from the Settings buttons, which + // keys it as `walletId`. Accept both rather than silently doing nothing. + const walletId = w && (w.id || w.walletId); + if (!walletId) return; + const pw = await authorizeForSecret(`Reveal the secret key for "${(w && w.label) || "this wallet"}".`); + if (!pw) return; + let r; + try { r = await S.invoke("revealSecret", { walletId, masterPassword: pw }); } + catch (e) { await aegisAlert(cleanErr(e), { title: "Could not reveal", icon: "⚠️" }); return; } + const form = SECRET_FORMS[r.form] || { title: "Secret", note: "" }; + const overlay = document.createElement("div"); + overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:flex-start;justify-content:center;z-index:99999;padding-top:16px"; + const field = (label, value, danger) => value + ? `
${esc(label)}
+
${esc(value)}
` + : ""; + overlay.innerHTML = ` +
+
+
${esc(form.title)}
+ +
+
${esc(r.label || "")} · ${esc(r.coinLabel || "")} · ${esc(r.networkLabel || "")}
+
Anyone who sees this can spend everything in this wallet. Nobody legitimate will ever ask you for it — not support, not Silent Mode.
+
${esc(form.note)}
+
+
+
+ +
`; + document.body.appendChild(overlay); + // Wipe the rendered secret out of the DOM on the way out rather than + // relying on the node being dropped — the modal is the only place it + // exists in the renderer, so clearing it is cheap and exact. + const close = () => { + try { overlay.querySelector("#rvShown").innerHTML = ""; } catch {} + try { overlay.remove(); } catch {} + }; + overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); + overlay.querySelector("#rvClose").addEventListener("click", close); + overlay.querySelector("#rvShow").addEventListener("click", () => { + overlay.querySelector("#rvHidden").hidden = true; + overlay.querySelector("#rvShown").hidden = false; + }); + overlay.querySelector("#rvHide").addEventListener("click", close); + overlay.querySelector("#rvCopy").addEventListener("click", async (e) => { + try { await navigator.clipboard.writeText(r.secret); flash(e.currentTarget, "Copied"); } + catch { await aegisAlert("Could not reach the clipboard."); } + }); +} + +// How many wrong PINs before a sensitive reveal stops asking for the PIN and +// asks for the master password instead. Lower than PIN_MAX_FAILS on purpose: +// someone fumbling their own PIN gets a way through that does not cost them a +// 15-minute lockout, and someone guessing is pushed onto the credential that +// is actually hard to guess. The global counter is NOT reset on the way +// across, so guesses still accumulate toward the lockout. +const REVEAL_PIN_MAX_FAILS = 3; + +// Prove entitlement to see a secret, and hand back the master password — +// which is what the host verifies before it parts with anything. The PIN blob +// wraps that same password, so both routes end at the same proof and the host +// never has to take the panel's word for it. +// +// Returns the master password, or null if the user backed out. +async function authorizeForSecret(subtitle) { + if (!securityLoaded) await refreshSecurityState(); + const usePin = securityState.requirePinForReveal && securityState.hasPin; + if (!usePin) { + // No PIN configured, or the user turned the PIN prompt off. Either way + // the master password is the gate — never nothing. + return promptMasterPassword({ title: "Confirm master password", subtitle }); + } + const remain = await pinLockoutRemainingMs(); + if (remain > 0) { + // Locked out of the PIN, but the password is a separate credential and + // the lockout exists to stop PIN guessing, not to lock the owner out. + return promptMasterPassword({ + title: "Confirm master password", + subtitle: `PIN entry is locked for ${Math.ceil(remain / 60000)} min. ${subtitle || ""}`.trim(), + }); + } + const pin = await capturePinForSecret(subtitle); + if (pin === null) return null; // cancelled + if (pin === "__fallback__") { + return promptMasterPassword({ + title: "Confirm master password", + subtitle: `${REVEAL_PIN_MAX_FAILS} wrong PIN attempts. ${subtitle || ""}`.trim(), + }); + } + return pin; +} + +// PIN pad that resolves with the DECRYPTED MASTER PASSWORD on success, null +// on cancel, or "__fallback__" once the user has burned REVEAL_PIN_MAX_FAILS +// attempts. Separate from verifyPinInteractively because that one only +// answers yes/no and throws the password away. +function capturePinForSecret(subtitle) { + return new Promise((resolve) => { + const wrap = document.createElement("div"); + wrap.className = "pinmodal"; + wrap.innerHTML = ` +
+

Confirm with PIN

+
${esc(subtitle || "")}
+
+
${"".repeat(6)}
+
+ ${[1,2,3,4,5,6,7,8,9].map((n) => ``).join("")} + + + +
+
+
+
+ + +
+
`; + document.body.appendChild(wrap); + const done = (v) => { try { wrap.remove(); } catch {} resolve(v); }; + wrap.querySelector("#rsCancel").addEventListener("click", () => done(null)); + wrap.querySelector("#rsUsePw").addEventListener("click", () => done("__fallback__")); + let tries = 0; + setupPinPad({ + dots: $("rsDots"), keys: $("rsKeys"), err: $("rsErr"), + onComplete: async (pin) => { + try { + const blob = await S.invoke("pinBlobGet"); + if (!blob) throw new Error("no PIN configured"); + const master = await pinDecryptMaster(pin, blob); + await S.invoke("pinFailReset").catch(() => {}); + done(master); + return "ok"; + } catch (e) { + tries++; + // Keep feeding the shared counter: these are real PIN guesses and + // they should still count toward the 15 min lockout. + await S.invoke("pinFailInc").catch(() => ({ count: 0 })); + if (tries >= REVEAL_PIN_MAX_FAILS) { done("__fallback__"); return "ok"; } + const left = REVEAL_PIN_MAX_FAILS - tries; + $("rsErr").textContent = `Wrong PIN. ${left} attempt${left === 1 ? "" : "s"} left, then Aegis will ask for your master password.`; + return "reset"; + } + }, + }); + }); +} + // 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. @@ -4851,10 +5063,10 @@ $("showXpub").addEventListener("click", async () => { try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("recovery").innerHTML = recoveryHtml(r); } catch (e) { $("recovery").textContent = cleanErr(e); } }); -$("showXprv").addEventListener("click", async () => { - try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("recovery").innerHTML = recoveryHtml(r); } - catch (e) { $("recovery").textContent = cleanErr(e); } -}); +// Every "show the secret" button goes through the one gated path. They used +// to call recovery({reveal:true}), which handed back the xprv behind nothing +// but an approval click and refused imported wallets outright. +$("showXprv").addEventListener("click", () => openRevealSecretModal(sel())); function recoveryHtml(r) { let h = `
Account path
${esc(r.accountPath)}
Account xpub
${esc(r.xpub)}
`; if (r.xprv) h += `
Account private key (xprv)
${esc(r.xprv)}
`; @@ -4879,14 +5091,7 @@ $("applyWalletdUrl").addEventListener("click", async () => { settingsFilled = false; fillSettings(); render(); flash($("applyWalletdUrl"), "Applied"); } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } }); -$("showScSeed").addEventListener("click", async () => { - try { - const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); - $("scRecovery").innerHTML = - `
First address (index 0)
${esc(r.xpub || "")}
` + - (r.xprv ? `
Wallet seed (hex)
${esc(r.xprv)}
` : ""); - } catch (e) { $("scRecovery").textContent = cleanErr(e); } -}); +$("showScSeed").addEventListener("click", () => openRevealSecretModal(sel())); // Family-picker helper used by both DGB and BTC. Prefix is "Dgb" or "Btc": // the DOM IDs are #setFamily + #setPath. @@ -4931,10 +5136,7 @@ $("showBtcXpub").addEventListener("click", async () => { try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("btcRecovery").innerHTML = recoveryHtml(r); } catch (e) { $("btcRecovery").textContent = cleanErr(e); } }); -$("showBtcXprv").addEventListener("click", async () => { - try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("btcRecovery").innerHTML = recoveryHtml(r); } - catch (e) { $("btcRecovery").textContent = cleanErr(e); } -}); +$("showBtcXprv").addEventListener("click", () => openRevealSecretModal(sel())); // ETH / SOL: RPC URL. $("applyEthRpc").addEventListener("click", async () => { @@ -4951,31 +5153,13 @@ $("applySolRpc").addEventListener("click", async () => { settingsFilled = false; fillSettings(); render(); flash($("applySolRpc"), "Applied"); } catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; } }); -$("showEthKey").addEventListener("click", async () => { - try { - const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); - $("ethRecovery").innerHTML = - `
Address
${esc(sel().address || "")}
` + - `
Public key (uncompressed hex)
${esc(r.xpub || "")}
` + - (r.xprv ? `
Private key (hex)
${esc(r.xprv)}
` : ""); - } catch (e) { $("ethRecovery").textContent = cleanErr(e); } -}); -$("showSolKey").addEventListener("click", async () => { - try { - const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); - $("solRecovery").innerHTML = - `
Address (public key, base58)
${esc(r.xpub || "")}
` + - (r.xprv ? `
Wallet seed (hex, 32 bytes)
${esc(r.xprv)}
` : ""); - } catch (e) { $("solRecovery").textContent = cleanErr(e); } -}); +$("showEthKey").addEventListener("click", () => openRevealSecretModal(sel())); +$("showSolKey").addEventListener("click", () => openRevealSecretModal(sel())); $("showDgbXpub").addEventListener("click", async () => { try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("dgbRecovery").innerHTML = recoveryHtml(r); } catch (e) { $("dgbRecovery").textContent = cleanErr(e); } }); -$("showDgbXprv").addEventListener("click", async () => { - try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("dgbRecovery").innerHTML = recoveryHtml(r); } - catch (e) { $("dgbRecovery").textContent = cleanErr(e); } -}); +$("showDgbXprv").addEventListener("click", () => openRevealSecretModal(sel())); // ---- WizardConnect (BCH only) --------------------------------------------- function renderWcSites() { From c82aad16f3494d5036bba0ed02c901db7f7dd785 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Fri, 2 Oct 2026 00:15:06 +0200 Subject: [PATCH 29/47] =?UTF-8?q?fix(aegis):=200.23.0=20=E2=80=94=20chipne?= =?UTF-8?q?t=20wallets=20were=20in=20the=20pairing=20list,=20just=20buried?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as "WizardConnect pairing is done only via Mainnet, chipnet is not available on UI". Chipnet pairing works and is not filtered anywhere: the Connect pane reads state.wallets unfiltered, wcPairableWallets() keys only on chain/phase/eligibility, and startForWallet registers every BCH wallet whatever its network. The profile even holds a live wc:// URI against bch-chipnet-0, so a chipnet wallet has already paired. What it actually was: the picker listed wallets in registry order, and a bulk keystore import put fifteen unpairable chipnet WIF wallets ahead of the two chipnet HD wallets that pair fine. Every visible row was a greyed-out "can't pair" testnet entry, so the list read as "chipnet is unsupported" while the working options sat at positions 17 and 19 of 19. Group the select instead: "Can pair" first, "Cannot pair" after. The reasons stay visible — they were asked for, and a wallet greyed out with no explanation reads as broken — but they no longer hide the wallets that work. Only a WIF import is ineligible, because hdwalletv1 hands the dapp an xpub and one key is not a key tree. Promote to HD (0.11.0) remains the fix for those. --- bundled-addons/aegis/addon.json | 2 +- bundled-addons/aegis/panel.js | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index b074ce38..f36c65f1 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.22.0", + "version": "0.23.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/panel.js b/bundled-addons/aegis/panel.js index 39db123f..f32b409c 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -2256,18 +2256,28 @@ 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 blocked = readyBch.filter((w) => w.wcBlocked); // 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(""); + const opt = (w) => ``; + // Pairable first, under a heading, whenever anything is blocked. In + // registry order a bulk keystore import puts fifteen unpairable WIF + // wallets ahead of the two chipnet ones that pair fine, and a list whose + // visible rows are all greyed-out testnet entries reads as "chipnet isn't + // supported" — which is not true and never was. Grouping keeps the + // reasons visible without letting them bury the working options. + const options = blocked.length + ? `${pairable.map(opt).join("")}` + + `${blocked.map(opt).join("")}` + : pairable.map(opt).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 // only appeared when nothing at all could pair, so a user with one good // mainnet wallet and ten WIF-imported chipnet ones saw no explanation. - const blocked = readyBch.filter((w) => w.wcBlocked); const reasons = [...new Set(blocked.map((w) => w.wcBlocked))].map(esc).join(" "); const blockedNote = !blocked.length ? "" From 0b4ddee8c5b0c1b0b30d90b357e39f771dc8cf8e Mon Sep 17 00:00:00 2001 From: Local Dev Date: Fri, 2 Oct 2026 00:39:54 +0200 Subject: [PATCH 30/47] =?UTF-8?q?feat(aegis):=200.24.0=20=E2=80=94=20ask?= =?UTF-8?q?=20the=20chain=20which=20derivation=20path=20holds=20the=20fund?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A seed import offered exactly one prefilled path and no alternatives, so a seed from a wallet on a different path derived a valid but empty address and reported a 0 balance. Nothing distinguished "wrong path" from "empty wallet", which is why repeated imports of a funded wallet all looked identical. Both real cases hit it. Bitcoin.com derives mainnet BCH from coin type 0' — its Copay lineage predates the 145' split — while Aegis prefilled m/44'/145'/0'/0/0. And chipnet tooling derives from 145', while Aegis prefilled BIP44's testnet 1'. - A preset dropdown per coin and network, each entry naming the wallets that path belongs to, with the free-text field kept for anything unlisted. - "Check which path has my funds" derives each candidate and asks Electrum for balance and history, five addresses deep per candidate so a wallet whose first address is spent clean is still found. Results are listed with balances and a one-click Use; when nothing matches, it says so instead of implying the wallet is empty. - The chipnet IMPORT prefill becomes 145'. The vault-derived chipnet account path stays m/44'/1'/0' — changing that would move the addresses of wallets that already exist. The scan derives, queries and returns counts; it stores nothing, and the mnemonic crosses the same boundary importWallet already uses. --- bundled-addons/aegis/addon.json | 2 +- bundled-addons/aegis/index.js | 101 ++++++++++++++++++++++++++++ bundled-addons/aegis/panel.js | 113 +++++++++++++++++++++++++++++++- 3 files changed, 212 insertions(+), 4 deletions(-) diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index f36c65f1..4e7b6d76 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.23.0", + "version": "0.24.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 a85d3042..8bf656bb 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -2058,6 +2058,107 @@ function registerPanelMessages(api) { return fullState(); }); + // ---- seed import: which derivation path actually holds the funds? ------- + // The import form prefilled exactly one path, so a seed from a wallet that + // used a different one imported a valid, empty address and reported 0 with + // no way to tell "wrong path" from "empty wallet". Both real cases hit it: + // Bitcoin.com derives mainnet BCH from coin type 0' (its Copay lineage + // predates the 145' split), and chipnet tooling generally uses 145' rather + // than BIP44's testnet 1' — which is what Aegis defaulted chipnet to. + // + // So ask the chain instead of guessing. Each candidate is scanned for + // history and balance, and the panel reports what it found. + const SEED_PATH_CANDIDATES = { + "bch/mainnet": [ + { path: "m/44'/145'/0'/0/0", note: "BCH standard — Electron Cash, Zapit, newer Bitcoin.com" }, + { path: "m/44'/0'/0'/0/0", note: "BTC coin type — Bitcoin.com, Copay, BitPay" }, + { path: "m/44'/145'/0'", note: "account node — some QR exports" }, + { path: "m/0'/0/0", note: "pre-BIP44" }, + ], + "bch/chipnet": [ + { path: "m/44'/145'/0'/0/0", note: "BCH coin type on chipnet — most chipnet tooling" }, + { path: "m/44'/1'/0'/0/0", note: "BIP44 testnet coin type" }, + { path: "m/44'/145'/0'", note: "account node" }, + ], + }; + api.onMessage("seedPathCandidates", (p, m) => { + fromPanel(m); + const key = `${String(p && p.chain || "")}/${String(p && p.network || "")}`; + return { key, candidates: SEED_PATH_CANDIDATES[key] || [] }; + }); + + api.onMessage("scanSeedPaths", async (p, m) => { + fromPanel(m); + const chain = String(p && p.chain || "bch"); + const network = String(p && p.network || ""); + const cands = SEED_PATH_CANDIDATES[`${chain}/${network}`]; + if (!cands) throw new Error(`Path scanning is not available for ${chain} ${network} yet.`); + const mnemonic = String(p && p.mnemonic || "").trim(); + if (!mnemonic) throw new Error("seed phrase required"); + // Same conversion importWallet does. Never stored here — this handler + // derives, queries and returns counts, nothing else. + let seedHex; + try { seedHex = ctx.d.derive.mnemonicToSeedHex(mnemonic); } + catch (e) { throw new Error("That does not look like a BIP39 seed phrase: " + (e?.message || e)); } + + const prefix = network === "mainnet" ? "bitcoincash" : "bchtest"; + const servers = network === "mainnet" + ? bchServerList(api) + : ["wss://chipnet.imaginary.cash:50004", "wss://chipnet.bch.ninja:50004"]; + const client = new ctx.d.electrum.Client(servers); + // Electrum keys a script by sha256(scriptPubKey), little-endian. + const scripthashOf = (addr) => { + const d = ctx.d.cashaddr.decode(addr); + const h = Buffer.from(d.hash); + const spk = d.type === 1 + ? Buffer.concat([Buffer.from([0xa9, 0x14]), h, Buffer.from([0x87])]) + : Buffer.concat([Buffer.from([0x76, 0xa9, 0x14]), h, Buffer.from([0x88, 0xac])]); + return Buffer.from(ctx.d.sha256(new Uint8Array(spk))).reverse().toString("hex"); + }; + // Look a few addresses deep per candidate: a wallet whose first receive + // address is spent clean still has history, and funds often sit further + // along the branch. + const DEPTH = 5; + const out = []; + try { + for (const c of cands) { + const acct = wcAccountPath(c.path) || c.path; + const probes = []; + for (let i = 0; i < DEPTH; i++) probes.push(`${acct}/0/${i}`); + // The exact path the user would be importing, in case it is a leaf + // outside the account/0/i shape we probe. + if (!probes.includes(c.path) && /\/\d+$/.test(c.path)) probes.unshift(c.path); + let balance = 0, txCount = 0, firstAddress = null, fundedAddress = null; + for (const [idx, pth] of probes.entries()) { + let addr; + try { addr = deriveCashaddrFromSeed(seedHex, pth, prefix); } + catch { continue; } + if (idx === 0 || firstAddress === null) firstAddress = firstAddress || addr; + const sh = scripthashOf(addr); + try { + const bal = await client.call("blockchain.scripthash.get_balance", [sh]); + const got = Number(bal?.confirmed || 0) + Number(bal?.unconfirmed || 0); + if (got > 0 && !fundedAddress) fundedAddress = addr; + balance += got; + const hist = await client.call("blockchain.scripthash.get_history", [sh]); + txCount += Array.isArray(hist) ? hist.length : 0; + } catch (e) { api.log("scanSeedPaths:", pth, e?.message || e); } + } + out.push({ + path: c.path, note: c.note, accountPath: acct, + firstAddress, fundedAddress, balance, txCount, scanned: probes.length, + }); + } + } finally { try { client.disconnect(); } catch {} } + const meta = chainMeta(chain, network); + return { + chain, network, decimals: meta?.decimals || 8, ticker: meta?.ticker || "BCH", + results: out, + // Rank for the panel: funds first, then any history at all. + best: out.slice().sort((a, b) => (b.balance - a.balance) || (b.txCount - a.txCount))[0]?.path || null, + }; + }); + api.onMessage("permissions", (_p, m) => { fromPanel(m); return permissions(api); }); api.onMessage("revoke", (p, m) => { fromPanel(m); diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index f32b409c..3552fca8 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -1426,7 +1426,15 @@ const IMPORT_COIN_CONFIG = { bch: { label: "Bitcoin Cash", logo: "bch", networks: [ - { id: "chipnet", label: "Chipnet testnet", defaultPath: "m/44'/1'/0'/0/0", testnet: true }, + // Chipnet prefills the BCH coin type, not BIP44's testnet 1'. Chipnet + // is a BCH testnet and the tooling around it (keystore exports, + // faucets, test wallets) overwhelmingly derives from 145'; prefilling + // 1' sent every chipnet seed import to an empty address. 1' is still + // one of the presets, and the scan finds whichever is real. + // NOTE: this is the IMPORT prefill only. Vault-derived chipnet wallets + // keep m/44'/1'/0' (chain-bch.js) — changing that would move the + // addresses of wallets that already exist. + { id: "chipnet", label: "Chipnet testnet", defaultPath: "m/44'/145'/0'/0/0", testnet: true }, { id: "mainnet", label: "Mainnet", defaultPath: "m/44'/145'/0'/0/0" }, ], formats: [ @@ -1733,7 +1741,17 @@ function openImportModal(initialChain) {
Derivation path
+ + + +
- + +
+
+ —— + +
+ +
+ +
@@ -3133,7 +3135,6 @@ function renderInlineCoinList(el, groupKey, group) {
`; const back = () => { stripView = { mode: "coins", groupKey: null }; renderWalletStrip(); }; - el.querySelector("#stripBack").addEventListener("click", back); el.querySelector("#stripBackX").addEventListener("click", back); // Manual refresh: force every wallet under this coin+network to // re-poll now. Handy when a testnet faucet just delivered or a From 57177a3439b7b8a108638abe49a76fd2ec43a914 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Fri, 2 Oct 2026 20:34:01 +0200 Subject: [PATCH 34/47] fix(aegis): refresh was a silent no-op, and reported success either way Three separate reasons the Refresh button looked dead: - wallet.js refresh() returned immediately when state.scanning was set, so a manual press during a background poll did nothing at all. A forced refresh now awaits the in-flight pass and then does real work; background polls still yield. scanning is only ever written inside doRefresh, which only refresh() calls, so scanning implies a pending inflight to wait on. - The adapters catch their own fetch failures onto state.error instead of rejecting, so awaiting refresh() proved nothing and refreshChain reported ok:true for a wallet that had just failed against a dead server. It now reads the snapshot back. - Success changed only a tooltip. Balances that were already current left the screen identical, which is indistinguishable from a broken button. It now flashes a result and says how many wallets were refreshed, or how many failed and why. Version bumped once for this batch; not published yet. --- bundled-addons/aegis/addon.json | 2 +- bundled-addons/aegis/index.js | 11 +++++++++-- bundled-addons/aegis/lib/wallet.js | 16 +++++++++++++++- bundled-addons/aegis/panel.js | 22 ++++++++++++++++++---- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index 18c26c52..680d428a 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.25.2", + "version": "0.26.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 8bf656bb..f696f5b0 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -1348,8 +1348,15 @@ function registerPanelMessages(api) { await Promise.all(targets.map(async (w) => { const rt = ctx.runtimes.get(w.id); if (!rt || !rt.adapter) { out.push({ id: w.id, ok: false, error: "adapter not mounted" }); return; } - try { await rt.adapter.refresh(true); out.push({ id: w.id, ok: true }); } - catch (e) { out.push({ id: w.id, ok: false, error: e?.message || String(e) }); } + try { + await rt.adapter.refresh(true); + // The adapters catch their own fetch failures onto state.error rather + // than rejecting, so awaiting refresh() proves nothing. Read the + // snapshot back, or a dead server reports a successful refresh. + let snapErr = null; + try { snapErr = rt.adapter.snapshot()?.error || null; } catch { snapErr = null; } + out.push(snapErr ? { id: w.id, ok: false, error: snapErr } : { id: w.id, ok: true }); + } catch (e) { out.push({ id: w.id, ok: false, error: e?.message || String(e) }); } })); return { chain, network, results: out }; }); diff --git a/bundled-addons/aegis/lib/wallet.js b/bundled-addons/aegis/lib/wallet.js index c28bc313..7ab3b373 100644 --- a/bundled-addons/aegis/lib/wallet.js +++ b/bundled-addons/aegis/lib/wallet.js @@ -282,8 +282,22 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora return null; } + // A manual refresh used to return here the moment a background poll was + // in flight, and refreshChain reported ok:true for it — so the Refresh + // button no-opped and claimed success, which is exactly when a user is + // most likely to press it. A forced refresh now waits for the in-flight + // pass and then does real work; a background poll still yields. + let inflight = null; async function refresh(full = false) { - if (state.scanning) return; + if (state.scanning) { + if (!full) return; + try { await inflight; } catch { /* its own error is already on state */ } + if (state.scanning) return; // another forced pass won the race + } + inflight = doRefresh(full); + return inflight; + } + async function doRefresh(full) { state.scanning = true; state.error = null; onChange(); try { if (full || !state.watched.size) await scan(); diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 77a7f3c0..98a15319 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -3145,16 +3145,30 @@ function renderInlineCoinList(el, groupKey, group) { refreshBtn.dataset.spinning = "1"; const prev = refreshBtn.textContent; refreshBtn.textContent = "…"; + // Say what happened. A tooltip-only result is indistinguishable from the + // button doing nothing, which is how this one came to be reported as + // broken even when it worked: balances that were already current changed + // nothing on screen. + let mark = null; try { const r = await S.invoke("refreshChain", { chain: gw[0]?.chain, network: gw[0]?.network }); const failed = (r?.results || []).filter((x) => !x.ok); - if (failed.length) refreshBtn.title = "Refresh failed: " + failed.map((f) => f.error).join("; "); - else refreshBtn.title = "Refresh balances now"; + if (failed.length) { + refreshBtn.title = `Refresh failed on ${failed.length} of ${r.results.length}: ` + failed.map((f) => f.error).join("; "); + mark = "⚠"; + } else { + refreshBtn.title = `Refreshed ${r.results.length} wallet${r.results.length === 1 ? "" : "s"}`; + mark = "✓"; + } } catch (e) { - refreshBtn.title = "Refresh failed: " + (e?.message || e); + refreshBtn.title = "Refresh failed: " + cleanErr(e); + mark = "⚠"; } finally { - refreshBtn.textContent = prev; delete refreshBtn.dataset.spinning; + if (mark) { + refreshBtn.textContent = mark; + setTimeout(() => { if (refreshBtn.isConnected) refreshBtn.textContent = prev; }, 1200); + } else refreshBtn.textContent = prev; } }); el.querySelectorAll("[data-listpick]").forEach((row) => row.addEventListener("click", async (e) => { From 123b7ad8e960815f86fa3343771b7d42836d2992 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Fri, 2 Oct 2026 20:45:53 +0200 Subject: [PATCH 35/47] fix(aegis): imported chipnet wallets linked to the dead web explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every history row and Explorer button on an imported chipnet wallet opened https://chipnet.imaginary.cash/tx/… , whose web interface has been returning 502 for weeks. chain-bch.js was moved to our own explorer and this adapter was missed — which is most chipnet wallets in practice, since a keystore bulk import produces imported ones. Points at https://aegis.x/explorer/chipnet/ like the HD adapter. The host's Electrum endpoint on :50004 is a separate thing and is still in use. --- bundled-addons/aegis/lib/chain-bch-imported.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bundled-addons/aegis/lib/chain-bch-imported.js b/bundled-addons/aegis/lib/chain-bch-imported.js index c65505c9..421b4d06 100644 --- a/bundled-addons/aegis/lib/chain-bch-imported.js +++ b/bundled-addons/aegis/lib/chain-bch-imported.js @@ -35,8 +35,14 @@ module.exports = function makeImportedBchAdapter({ }, chipnet: { id: "chipnet", label: "Chipnet testnet", prefix: "bchtest", wifVersion: 0xef, - explorerTx: "https://chipnet.imaginary.cash/tx/", - explorerAddr: "https://chipnet.imaginary.cash/address/", + // chipnet.imaginary.cash's WEB explorer has been returning 502 for + // weeks, so every history row and Explorer button on an imported + // chipnet wallet led to a dead page. chain-bch.js was moved to our own + // explorer and this adapter was missed — which is most of them, since + // a keystore bulk import produces imported wallets. + // Its Electrum endpoint on :50004 is unrelated and still in use below. + explorerTx: "https://aegis.x/explorer/chipnet/?tx=", + explorerAddr: "https://aegis.x/explorer/chipnet/?addr=", defaultServers: [ "wss://chipnet.imaginary.cash:50004", "wss://chipnet.bch.ninja:50004", From be05fe67d4f35c646ad83d3c7886d39b69ed3a14 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Fri, 2 Oct 2026 20:46:36 +0200 Subject: [PATCH 36/47] feat(aegis): choose when Aegis asks for the PIN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: after a restart Aegis opens without asking for a PIN, then demands one as soon as you click something. That came from the only control being requirePinForSending — safeStorage remembers the master password, so the wallet reopens unlocked, and the PIN prompt then ambushes the first action. Asking at the door is coherent. Asking nothing is coherent. Asking once the user is already inside is not. "Ask for PIN" is now four independent triggers, because wanting one at startup and again per transaction is a normal combination: - On each browser restart (compares a per-process boot id; a timestamp cannot tell a restart from a long idle) - On each wallet launch (hide/show — one panel load is one launch) - Every 6 hours - Each transaction With none ticked, an open wallet is never interrupted again. The decision is made host-side: the panel reloads on every hide/show and must not be the thing that remembers a gate was cleared. A clearance is only recorded after the panel has actually decrypted the PIN blob, which is proof rather than a claim, and ticking a trigger does not fire it retroactively. restart defaults ON for a wallet that otherwise reopens fully unlocked, and transaction inherits the old requirePinForSending so nobody loses a gate they had chosen. Removing the PIN clears every trigger and the clearance record. --- bundled-addons/aegis/index.js | 90 ++++++++++++++++++++++++ bundled-addons/aegis/panel.html | 28 ++++++-- bundled-addons/aegis/panel.js | 119 ++++++++++++++++++++++++-------- 3 files changed, 201 insertions(+), 36 deletions(-) diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index f696f5b0..43de247a 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -18,6 +18,14 @@ const path = require("node:path"); const fs = require("node:fs"); const LEGACY_BCH_PURPOSE = "bchwallet/mainnet/0"; + +// New each time the add-on's main process starts, i.e. once per Theseus +// launch. Comparing it against the id stored the last time a PIN was +// accepted is how "ask again after a browser restart" is detected — a +// timestamp cannot tell a restart from a long idle, and the panel cannot be +// trusted to report its own restarts. +const BOOT_ID = require("node:crypto").randomBytes(8).toString("hex"); +const PIN_INTERVAL_MS = 6 * 60 * 60 * 1000; const LEGACY_BCH_WALLET_ID = "bch-default"; let ctx = null; @@ -2200,6 +2208,9 @@ function registerPanelMessages(api) { fromPanel(m); api.storage.set("aegis/pin/v1", null); api.storage.set("aegis/pin/failCount", 0); + // Drop the gate record as well, so enrolling a new PIN later starts from + // "not yet satisfied" rather than inheriting the old PIN's clearance. + api.storage.set("aegis/pin/gate", null); return true; }); // Track failed PIN attempts in the addon so a panel reload cannot bypass @@ -2226,11 +2237,74 @@ function registerPanelMessages(api) { }; }); + // When to ask for the PIN. These are independent triggers, not a single + // mode: wanting one at startup and one per transaction is a normal + // combination. Previously the only control was requirePinForSending, which + // produced the behaviour the user reported — Aegis opens unlocked after a + // restart (safeStorage remembered the password) and then demands a PIN the + // moment you touch something. Asking at the door or not at all is + // coherent; asking only once you are inside is not. + // + // `restart` defaults ON for a wallet that otherwise reopens fully unlocked. + // `transaction` inherits the old requirePinForSending so nobody silently + // loses a gate they had chosen. + function pinPolicy() { + const cfg = api.storage.get("aegis/security/v1", {}) || {}; + const on = (cfg.pinOn && typeof cfg.pinOn === "object") ? cfg.pinOn : null; + return { + restart: on ? !!on.restart : true, + launch: on ? !!on.launch : false, + interval: on ? !!on.interval : false, + transaction: on ? !!on.transaction : !!cfg.requirePinForSending, + }; + } + const pinGate = () => { + const g = api.storage.get("aegis/pin/gate", null); + return (g && typeof g === "object") ? g : {}; + }; + + // Does the user have to prove the PIN right now? Decided host-side: the + // panel reloads freely and must not be the thing that remembers whether a + // gate was already satisfied. + function pinNeeded(event) { + if (!api.storage.get("aegis/pin/v1", null)) return { needPin: false, reason: "no-pin" }; + const on = pinPolicy(); + const g = pinGate(); + if (on.interval) { + // Never satisfied, or satisfied too long ago. Checked for every event + // so a six-hour expiry also lands on the next transaction. + if (!g.lastOkAt || (Date.now() - Number(g.lastOkAt)) > PIN_INTERVAL_MS) { + return { needPin: true, reason: "interval" }; + } + } + if (event === "transaction" && on.transaction) return { needPin: true, reason: "transaction" }; + if (event === "panel-load") { + if (on.launch) return { needPin: true, reason: "launch" }; + if (on.restart && g.bootId !== BOOT_ID) return { needPin: true, reason: "restart" }; + } + return { needPin: false, reason: "satisfied" }; + } + + api.onMessage("pinGateStatus", (p, m) => { + fromPanel(m); + const event = String(p && p.event || "panel-load"); + return { ...pinNeeded(event), event, policy: pinPolicy() }; + }); + // Called only after the panel has actually decrypted the PIN blob, which + // is proof of the PIN and not merely a claim about it. + api.onMessage("pinGateSatisfied", (_p, m) => { + fromPanel(m); + api.storage.set("aegis/pin/gate", { lastOkAt: Date.now(), bootId: BOOT_ID }); + return true; + }); + api.onMessage("securityGet", (_p, m) => { fromPanel(m); const cfg = api.storage.get("aegis/security/v1", {}) || {}; return { hasPin: !!api.storage.get("aegis/pin/v1", null), + pinOn: pinPolicy(), + pinIntervalHours: PIN_INTERVAL_MS / 3600000, requirePinForSending: !!cfg.requirePinForSending, // Defaults ON (note the !== false), unlike the send flag: a send is // already fronted by an approval overlay, whereas revealing a key is @@ -2246,9 +2320,25 @@ function registerPanelMessages(api) { const next = { ...cur }; if (p && typeof p.requirePinForSending === "boolean") next.requirePinForSending = p.requirePinForSending; if (p && typeof p.requirePinForReveal === "boolean") next.requirePinForReveal = p.requirePinForReveal; + if (p && p.pinOn && typeof p.pinOn === "object") { + // Write the whole set from the current policy plus the keys given, so + // the first edit materialises the migrated defaults instead of leaving + // three triggers undefined and one set. + const cur = pinPolicy(); + const merged = { ...cur }; + for (const k of ["restart", "launch", "interval", "transaction"]) { + if (typeof p.pinOn[k] === "boolean") merged[k] = p.pinOn[k]; + } + next.pinOn = merged; + // The legacy flag now lives in pinOn.transaction; keep them in step so + // an older build reading this store still gates sends the same way. + next.requirePinForSending = merged.transaction; + } api.storage.set("aegis/security/v1", next); return { hasPin: !!api.storage.get("aegis/pin/v1", null), + pinOn: pinPolicy(), + pinIntervalHours: PIN_INTERVAL_MS / 3600000, requirePinForSending: !!next.requirePinForSending, requirePinForReveal: next.requirePinForReveal !== false, }; diff --git a/bundled-addons/aegis/panel.html b/bundled-addons/aegis/panel.html index ae997e6a..594e5478 100644 --- a/bundled-addons/aegis/panel.html +++ b/bundled-addons/aegis/panel.html @@ -121,6 +121,13 @@ /* Network selector — indicator and switch in one control, directly under the balance and present whenever a wallet is. The active chip IS the "you are here" marker the status row used to carry. */ + /* The "Ask for PIN" trigger list. Stacked under its own label rather than + one switch per row: four rows of switches read as four unrelated + settings, when they are one question with four answers. */ + .gsec .gline .pinon { display: flex; flex-direction: column; gap: 5px; margin-top: 7px; } + .gsec .gline .pinon label { display: flex; align-items: center; gap: 7px; font-size: 12px; + color: var(--ink); cursor: pointer; font-weight: 400; } + .gsec .gline .pinon input { margin: 0; } .netsel { display: flex; gap: 6px; margin-top: 12px; padding: 0 2px; flex-wrap: wrap; } .netsel[hidden] { display: none; } .netselchip { background: transparent; border: 1px solid var(--line); color: var(--mut); @@ -990,13 +997,22 @@ -