chore(aegis): 0.8.9 — ETH/SOL imported history + tokens, centred setup screen

Completes the parity work 0.8.7 started for Tron. Imported ETH and SOL
wallets showed a native balance and nothing else, because the JSON-RPC
endpoints they poll have no history or token concept at all.

- ETH history + ERC-20 balances via Blockscout, which needs no API key
  (Etherscan V2 does). Mainnet RPC moves off eth.llamarpc.com, which was
  answering 525 with an HTML error page — that parsed as a JSON error and
  showed as a 0 balance.
- SOL history via getSignaturesForAddress and SPL balances via
  getTokenAccountsByOwner, both keyless on the public RPC.

Three things the live testing turned up:

- A Blockscout mempool entry is {result:"pending", status:null}. Reading
  that as "not ok, therefore failed" showed pending sends as failures.
  Now carries a distinct pending state through to the row.
- History `delta` is now a number, a decimal string, or null. ETH wei
  needs the string (18 decimals overflows a JS number, and Math.abs was
  silently rounding it); Solana's signature feed carries no amount at
  all, and null >= 0 is true, so unknown amounts were rendering as a
  "+" that claimed a receive we cannot verify. Unknown now renders as a
  neutral row instead.
- A real address came back with 855 ERC-20s and 3078 SPL mints, nearly
  all airdrop spam, some with blank, zero-width or bidi-override
  symbols that render as an empty row borrowing trust from its
  neighbours. Token text is sanitised and lists are capped at 50, sorted
  so named tokens survive the cap.

Also: the first-run setup screen forced text-align:left on the form, so
its helper copy ran ragged under a centred mark, title and description.
The form now inherits the centred alignment; the mnemonic box stays
left-aligned on purpose, since centring wrapped seed words makes them
harder to check.
This commit is contained in:
Local Dev 2026-09-23 00:05:14 +02:00
parent 1b74b10fc5
commit 64bc26ecf6
4 changed files with 425 additions and 254 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "aegis", "id": "aegis",
"name": "Aegis Wallet", "name": "Aegis Wallet",
"version": "0.8.8", "version": "0.8.9",
"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

@ -28,12 +28,52 @@ module.exports = function makeGenericImportedAdapter() {
} catch { return ""; } } catch { return ""; }
} }
// Airdrop spam is the norm on public addresses — a real test address came
// back with 855 ERC-20s and 3078 SPL mints. Rendering all of those in a
// sidebar is useless, so every fetchTokens caps its list. Sorting puts
// named/known tokens first, so the cap drops spam before it drops
// anything the user recognises.
const TOKEN_CAP = 50;
// Token names are attacker-controlled. Scam mints ship symbols that are
// blank, pure whitespace, zero-width characters, or carry bidi overrides
// to make one string render as another. Strip the invisible classes, cap
// the length, and return "" when nothing legible survives so the caller
// can mark the token unknown instead of rendering an empty-looking row
// that borrows trust from the ones above it.
// Ranges are listed numerically rather than as a regex character class on
// purpose: a literal class would need these very characters in the source,
// where they are invisible to a reviewer and easy for an editor or a patch
// tool to mangle.
const INVISIBLE_RANGES = [
[0x0000, 0x001f], [0x007f, 0x009f], // C0 / C1 controls
[0x200b, 0x200f], // zero-width space..RTL mark
[0x202a, 0x202e], // bidi embedding / override
[0x2060, 0x206f], // word joiner, invisible operators
[0xfeff, 0xfeff], // BOM / zero-width no-break space
];
function cleanTokenText(s) {
let out = "";
for (const ch of String(s == null ? "" : s)) {
const cp = ch.codePointAt(0);
if (INVISIBLE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi)) continue;
out += ch;
}
return out.replace(/\s+/g, " ").trim().slice(0, 32);
}
const CHAIN_CFGS = { const CHAIN_CFGS = {
eth: { eth: {
ticker: "ETH", decimals: 18, ticker: "ETH", decimals: 18,
networks: { networks: {
mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://eth.llamarpc.com", explorerAddr: "https://etherscan.io/address/", explorerTx: "https://etherscan.io/tx/" }, // `indexer` is a keyless Blockscout instance. The JSON-RPC endpoints
sepolia: { id: "sepolia", label: "Sepolia", rpc: "https://ethereum-sepolia-rpc.publicnode.com", explorerAddr: "https://sepolia.etherscan.io/address/", explorerTx: "https://sepolia.etherscan.io/tx/", testnet: true, faucet: "https://sepoliafaucet.com/" }, // above serve balances but have no history or token concept at all —
// that's why imported ETH wallets showed a balance and nothing else.
// Etherscan V2 would need an API key; Blockscout does not.
// publicnode, not llamarpc: llamarpc was answering 525 with an HTML
// error page, which surfaced as a JSON parse error and a 0 balance.
mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://ethereum-rpc.publicnode.com", indexer: "https://eth.blockscout.com", explorerAddr: "https://etherscan.io/address/", explorerTx: "https://etherscan.io/tx/" },
sepolia: { id: "sepolia", label: "Sepolia", rpc: "https://ethereum-sepolia-rpc.publicnode.com", indexer: "https://eth-sepolia.blockscout.com", explorerAddr: "https://sepolia.etherscan.io/address/", explorerTx: "https://sepolia.etherscan.io/tx/", testnet: true, faucet: "https://sepoliafaucet.com/" },
}, },
// JSON-RPC eth_getBalance → hex-string wei. // JSON-RPC eth_getBalance → hex-string wei.
async fetchBalance({ rpc, address }) { async fetchBalance({ rpc, address }) {
@ -43,6 +83,54 @@ module.exports = function makeGenericImportedAdapter() {
const hex = String(j?.result || "0x0").replace(/^0x/, ""); const hex = String(j?.result || "0x0").replace(/^0x/, "");
return BigInt("0x" + hex).toString(); return BigInt("0x" + hex).toString();
}, },
async fetchHistory({ address, net }) {
if (!net?.indexer) return null;
const r = await fetch(`${net.indexer}/api/v2/addresses/${encodeURIComponent(address)}/transactions`, { headers: { accept: "application/json" } });
if (!r.ok) throw new Error(`Blockscout history HTTP ${r.status}`);
const j = await r.json();
const items = Array.isArray(j?.items) ? j.items : [];
const me = String(address).toLowerCase();
return items.slice(0, 25).map((t) => {
const from = String(t.from?.hash || "").toLowerCase();
const wei = BigInt(String(t.value || "0"));
const outgoing = from === me;
// A mempool tx comes back as {result:"pending", status:null,
// timestamp:null}. Reading that as `status !== "ok" → failed`
// showed pending sends as failures, which is the one thing a
// wallet must never get wrong.
const pending = t.result === "pending" || t.status == null;
return {
txid: t.hash,
time: Math.floor(new Date(t.timestamp || 0).getTime() / 1000) || 0,
confirmations: Number(t.confirmations) || 0,
status: pending ? "pending" : (t.status === "ok" ? "confirmed" : "failed"),
// Keep wei exact — 18 decimals overflows a JS number.
delta: (outgoing ? -wei : wei).toString(),
kind: t.method || "Transfer",
};
}).filter((t) => t.txid);
},
async fetchTokens({ address, net }) {
if (!net?.indexer) return null;
const r = await fetch(`${net.indexer}/api/v2/addresses/${encodeURIComponent(address)}/token-balances`, { headers: { accept: "application/json" } });
if (!r.ok) throw new Error(`Blockscout tokens HTTP ${r.status}`);
const j = await r.json();
const list = Array.isArray(j) ? j : [];
return list.map((e) => {
const t = e?.token || {};
const symbol = cleanTokenText(t.symbol);
return {
mint: t.address_hash || t.address || "",
symbol: symbol || "?",
name: cleanTokenText(t.name),
decimals: Number(t.decimals) || 0,
known: !!symbol,
balance: String(e.value ?? "0"),
};
}).filter((t) => t.mint && t.balance !== "0")
.sort((a, b) => (b.known - a.known) || (a.symbol || "").localeCompare(b.symbol || ""))
.slice(0, TOKEN_CAP);
},
}, },
trx: { trx: {
ticker: "TRX", decimals: 6, ticker: "TRX", decimals: 6,
@ -120,15 +208,17 @@ module.exports = function makeGenericImportedAdapter() {
// would otherwise bury the ones the user actually cares about. // would otherwise bury the ones the user actually cares about.
return Array.from(balances, ([contract, balance]) => { return Array.from(balances, ([contract, balance]) => {
const ti = info.get(contract); const ti = info.get(contract);
const symbol = cleanTokenText(ti?.symbol);
return { return {
mint: contract, mint: contract,
symbol: ti?.symbol || "?", symbol: symbol || "?",
name: ti?.name || "", name: cleanTokenText(ti?.name),
decimals: Number.isFinite(Number(ti?.decimals)) ? Number(ti.decimals) : 0, decimals: Number.isFinite(Number(ti?.decimals)) ? Number(ti.decimals) : 0,
known: !!ti, known: !!ti && !!symbol,
balance, balance,
}; };
}).sort((a, b) => (b.known - a.known) || (a.symbol || "").localeCompare(b.symbol || "")); }).sort((a, b) => (b.known - a.known) || (a.symbol || "").localeCompare(b.symbol || ""))
.slice(0, TOKEN_CAP);
}, },
}, },
sol: { sol: {
@ -144,6 +234,62 @@ module.exports = function makeGenericImportedAdapter() {
const j = await r.json(); const j = await r.json();
return String(j?.result?.value || 0); return String(j?.result?.value || 0);
}, },
// getSignaturesForAddress is keyless on the public RPC. It gives us
// the ledger of signatures touching this address but NOT the amounts —
// that would need a getTransaction per signature (25 extra round trips
// on every poll). We surface the entries with a null delta so the user
// at least sees activity and can open any of them in the explorer.
async fetchHistory({ rpc, address }) {
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getSignaturesForAddress", params: [address, { limit: 25 }] }) });
if (!r.ok) throw new Error(`Solana history HTTP ${r.status}`);
const j = await r.json();
if (j?.error) throw new Error(j.error.message || "getSignaturesForAddress failed");
const list = Array.isArray(j?.result) ? j.result : [];
return list.map((s) => ({
txid: s.signature,
time: Number(s.blockTime) || 0,
confirmations: s.confirmationStatus === "finalized" ? 1 : 0,
status: s.err ? "failed" : "confirmed",
delta: null,
kind: "Transaction",
})).filter((t) => t.txid);
},
// SPL balances via getTokenAccountsByOwner with jsonParsed, matching
// what the built-in Solana adapter does. Symbol/name aren't on-chain
// in the token account, so the mint stands in for the symbol.
async fetchTokens({ rpc, address }) {
const SPL = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
const SPL22 = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
const call = async (programId) => {
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getTokenAccountsByOwner",
params: [address, { programId }, { encoding: "jsonParsed" }] }) });
if (!r.ok) throw new Error(`Solana tokens HTTP ${r.status}`);
const j = await r.json();
if (j?.error) throw new Error(j.error.message || "getTokenAccountsByOwner failed");
return Array.isArray(j?.result?.value) ? j.result.value : [];
};
const accounts = [].concat(...await Promise.all([
call(SPL).catch(() => []),
call(SPL22).catch(() => []),
]));
const out = [];
for (const a of accounts) {
const info = a?.account?.data?.parsed?.info;
const amt = info?.tokenAmount;
if (!info?.mint || !amt || String(amt.amount) === "0") continue;
out.push({
mint: String(info.mint),
symbol: String(info.mint).slice(0, 4) + "…",
name: "",
decimals: Number(amt.decimals) || 0,
known: true, // decimals ARE on-chain here, so the amount is real
balance: String(amt.amount),
});
}
return out.sort((a, b) => (a.mint || "").localeCompare(b.mint || "")).slice(0, TOKEN_CAP);
},
}, },
}; };
@ -201,7 +347,7 @@ module.exports = function makeGenericImportedAdapter() {
async refresh() { async refresh() {
this._state.scanning = true; this._emit(); this._state.scanning = true; this._emit();
const opts = { rpc: this._net.rpc, address: this._address }; const opts = { rpc: this._net.rpc, address: this._address, net: this._net };
try { try {
// Only the balance is load-bearing — history and tokens are // Only the balance is load-bearing — history and tokens are
// best-effort so one 404 on a chain that has no keyless feed // best-effort so one 404 on a chain that has no keyless feed

View file

@ -479,10 +479,17 @@
filter: drop-shadow(0 0 12px rgb(from var(--acid, #d6ff3d) r g b / .35)); } filter: drop-shadow(0 0 12px rgb(from var(--acid, #d6ff3d) r g b / .35)); }
#lockScreen h1 { font: 600 16px/1.3 inherit; margin: 0 0 4px 0; letter-spacing: .2px; } #lockScreen h1 { font: 600 16px/1.3 inherit; margin: 0 0 4px 0; letter-spacing: .2px; }
#lockScreen .subhint { color: var(--mut); font-size: 12px; max-width: 320px; margin: 0 0 20px 0; } #lockScreen .subhint { color: var(--mut); font-size: 12px; max-width: 320px; margin: 0 0 20px 0; }
#lockScreen .lockform { width: min(320px, 100%); display: flex; flex-direction: column; gap: 10px; text-align: left; } /* The form inherits the lock screen's centred alignment — it used to
force text-align:left, which left the setup screen's helper copy
running ragged against a centred title, mark and description. */
#lockScreen .lockform { width: min(320px, 100%); display: flex; flex-direction: column; gap: 10px; text-align: center; }
#lockScreen .lockform input[type=password], #lockScreen .lockform input[type=password],
#lockScreen .lockform input[type=text], #lockScreen .lockform input[type=text] { text-align: center; }
#lockScreen .lockform textarea { text-align: center; } /* The mnemonic stays left-aligned on purpose: 12/24 words wrap across
several lines, and centring makes them ragged on both edges, which is
exactly the wrong thing when someone is checking a seed word by word. */
#lockScreen .lockform textarea { text-align: left; }
#lockScreen .lockform .hint { text-align: center; }
#lockScreen .altline { color: var(--dim); font-size: 11.5px; margin-top: 12px; text-align: center; } #lockScreen .altline { color: var(--dim); font-size: 11.5px; margin-top: 12px; text-align: center; }
#lockScreen .altline a { color: var(--acid, #d6ff3d); cursor: pointer; text-decoration: none; } #lockScreen .altline a { color: var(--acid, #d6ff3d); cursor: pointer; text-decoration: none; }
#lockScreen .altline a:hover { text-decoration: underline; } #lockScreen .altline a:hover { text-decoration: underline; }

View file

@ -3225,17 +3225,35 @@ function renderHistory() {
const list = s?.history || []; const list = s?.history || [];
const el = $("txlist"); const el = $("txlist");
if (!list.length) { el.innerHTML = `<div class="empty">${s?.scanning ? "Syncing…" : "No transactions yet."}</div>`; return; } if (!list.length) { el.innerHTML = `<div class="empty">${s?.scanning ? "Syncing…" : "No transactions yet."}</div>`; return; }
const dec = s?.decimals ?? 8;
el.innerHTML = list.map((t) => { el.innerHTML = list.map((t) => {
const inc = t.delta >= 0; // `delta` arrives in three shapes now: a number (UTXO chains), a decimal
// STRING (ETH — 18 decimals of wei overflows a JS number, so it must
// stay exact), or null (Solana, where the signature feed carries no
// amount and fetching one per tx would be 25 extra round trips a poll).
// Treating null as 0 would render "+ —", claiming a receive we cannot
// actually verify, so unknown amounts get their own neutral branch.
const known = t.delta != null;
const neg = known && String(t.delta).trim().startsWith("-");
const inc = known ? !neg : null;
const when = t.time ? new Date(t.time * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "pending"; const when = t.time ? new Date(t.time * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "pending";
const who = inc ? (t.from ? "from " + shortAddr(t.from) : "") : (t.to ? "to " + shortAddr(t.to) : ""); const who = inc === null ? "" : inc ? (t.from ? "from " + shortAddr(t.from) : "") : (t.to ? "to " + shortAddr(t.to) : "");
const what = (inc ? "Received" : "Sent") + (who ? " " + who : ""); const what = (inc === null ? (t.kind || "Transaction") : inc ? "Received" : "Sent") + (who ? " " + who : "");
const conf = t.confirmations > 0 ? (t.confirmations >= 6 ? "confirmed" : t.confirmations + " conf") : (t.status === "failed" ? "failed" : "unconfirmed"); const conf = t.confirmations > 0 ? (t.confirmations >= 6 ? "confirmed" : t.confirmations + " conf")
const delta = Math.abs(t.delta || 0); : (t.status === "failed" ? "failed" : t.status === "pending" ? "pending" : "unconfirmed");
// Strip the sign as text rather than via Math.abs so a big-decimal
// string keeps every digit.
const magnitude = known ? String(t.delta).trim().replace(/^[-+]/, "") : "";
const isZero = known && /^0*$/.test(magnitude);
const amountHtml = !known ? "—"
: isZero ? "—"
: `${inc ? "+" : "−"}${esc(fmtBig(magnitude, dec))}`;
const icon = inc === null ? "·" : inc ? "↓" : "↑";
const iconCls = inc === null ? "" : inc ? "in" : "out";
return `<div class="tx" data-txid="${esc(t.txid)}" title="${esc(t.txid)}"> return `<div class="tx" data-txid="${esc(t.txid)}" title="${esc(t.txid)}">
<div class="ic ${inc ? "in" : "out"}">${inc ? "↓" : "↑"}</div> <div class="ic ${iconCls}">${icon}</div>
<div class="what">${esc(what)}</div> <div class="what">${esc(what)}</div>
<div class="amt2 ${inc ? "in" : ""}">${inc ? "+" : "−"}${delta ? fmtBig(delta) : "—"}</div> <div class="amt2 ${inc ? "in" : ""}">${amountHtml}</div>
<div class="when">${esc(when)}${t.fee != null ? " · fee " + fmtSmall(t.fee) + " " + smallUnitLabel() : ""}</div> <div class="when">${esc(when)}${t.fee != null ? " · fee " + fmtSmall(t.fee) + " " + smallUnitLabel() : ""}</div>
<div class="conf ${t.confirmations > 0 ? (t.status === "failed" ? "pending" : "") : "pending"}">${esc(conf)}</div> <div class="conf ${t.confirmations > 0 ? (t.status === "failed" ? "pending" : "") : "pending"}">${esc(conf)}</div>
</div>`; </div>`;