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

@ -1,243 +1,389 @@
// Generic single-address read-only imported adapter for account-model // Generic single-address read-only imported adapter for account-model
// chains. One config-driven runtime handles ETH-family, Tron, and Solana // chains. One config-driven runtime handles ETH-family, Tron, and Solana
// balance polling — every chain differs only in the RPC verb and the // balance polling — every chain differs only in the RPC verb and the
// JSON path to the balance number. // JSON path to the balance number.
// //
// The adapter mirrors the public shape every Aegis chain runtime exposes // The adapter mirrors the public shape every Aegis chain runtime exposes
// (snapshot, refresh, plan, signAndBroadcast, dispose) so mountWallet // (snapshot, refresh, plan, signAndBroadcast, dispose) so mountWallet
// stays chain-agnostic. planSend/send throw a "read-only" error until // stays chain-agnostic. planSend/send throw a "read-only" error until
// M.1b delivers the sign path per chain. // M.1b delivers the sign path per chain.
module.exports = function makeGenericImportedAdapter() { module.exports = function makeGenericImportedAdapter() {
// base58check T… -> 41-prefixed hex, for comparing against the raw // base58check T… -> 41-prefixed hex, for comparing against the raw
// owner_address/to_address fields the /v1 tx feed returns. // owner_address/to_address fields the /v1 tx feed returns.
const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
function tronAddrToHex(b58) { function tronAddrToHex(b58) {
try { try {
let n = 0n; let n = 0n;
for (const ch of String(b58)) { for (const ch of String(b58)) {
const i = B58.indexOf(ch); const i = B58.indexOf(ch);
if (i < 0) return ""; if (i < 0) return "";
n = n * 58n + BigInt(i); n = n * 58n + BigInt(i);
} }
let hex = n.toString(16); let hex = n.toString(16);
if (hex.length % 2) hex = "0" + hex; if (hex.length % 2) hex = "0" + hex;
// 25 bytes = 21 payload + 4 checksum; drop the checksum. // 25 bytes = 21 payload + 4 checksum; drop the checksum.
return hex.padStart(50, "0").slice(0, 42); return hex.padStart(50, "0").slice(0, 42);
} catch { return ""; } } catch { return ""; }
} }
const CHAIN_CFGS = { // Airdrop spam is the norm on public addresses — a real test address came
eth: { // back with 855 ERC-20s and 3078 SPL mints. Rendering all of those in a
ticker: "ETH", decimals: 18, // sidebar is useless, so every fetchTokens caps its list. Sorting puts
networks: { // named/known tokens first, so the cap drops spam before it drops
mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://eth.llamarpc.com", explorerAddr: "https://etherscan.io/address/", explorerTx: "https://etherscan.io/tx/" }, // anything the user recognises.
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/" }, const TOKEN_CAP = 50;
},
// JSON-RPC eth_getBalance → hex-string wei. // Token names are attacker-controlled. Scam mints ship symbols that are
async fetchBalance({ rpc, address }) { // blank, pure whitespace, zero-width characters, or carry bidi overrides
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" }, // to make one string render as another. Strip the invisible classes, cap
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBalance", params: [address, "latest"] }) }); // the length, and return "" when nothing legible survives so the caller
const j = await r.json(); // can mark the token unknown instead of rendering an empty-looking row
const hex = String(j?.result || "0x0").replace(/^0x/, ""); // that borrows trust from the ones above it.
return BigInt("0x" + hex).toString(); // 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
trx: { // tool to mangle.
ticker: "TRX", decimals: 6, const INVISIBLE_RANGES = [
networks: { [0x0000, 0x001f], [0x007f, 0x009f], // C0 / C1 controls
mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://api.trongrid.io", explorerAddr: "https://tronscan.org/#/address/", explorerTx: "https://tronscan.org/#/transaction/" }, [0x200b, 0x200f], // zero-width space..RTL mark
// nile.trongrid.io, NOT api.nileex.io: nileex only serves the [0x202a, 0x202e], // bidi embedding / override
// /wallet/* JSON-RPC family and 404s the whole /v1/ REST family, [0x2060, 0x206f], // word joiner, invisible operators
// which is where transaction history and the trc20 token list [0xfeff, 0xfeff], // BOM / zero-width no-break space
// live. Balance worked, everything else silently came back empty. ];
nile: { id: "nile", label: "Nile testnet", rpc: "https://nile.trongrid.io", explorerAddr: "https://nile.tronscan.org/#/address/", explorerTx: "https://nile.tronscan.org/#/transaction/", testnet: true, faucet: "https://nileex.io/join/getJoinPage" }, function cleanTokenText(s) {
}, let out = "";
// Tron HTTP API returns account.balance in SUN (10^-6 TRX). for (const ch of String(s == null ? "" : s)) {
async fetchBalance({ rpc, address }) { const cp = ch.codePointAt(0);
const r = await fetch(rpc.replace(/\/+$/, "") + "/wallet/getaccount", { method: "POST", if (INVISIBLE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi)) continue;
headers: { "content-type": "application/json" }, out += ch;
body: JSON.stringify({ address, visible: true }) }); }
const j = await r.json(); return out.replace(/\s+/g, " ").trim().slice(0, 32);
return String(j?.balance || 0); }
},
async fetchHistory({ rpc, address }) { const CHAIN_CFGS = {
const r = await fetch(`${rpc.replace(/\/+$/, "")}/v1/accounts/${encodeURIComponent(address)}/transactions?limit=25`); eth: {
if (!r.ok) throw new Error(`Tron history HTTP ${r.status}`); ticker: "ETH", decimals: 18,
const j = await r.json(); networks: {
const list = Array.isArray(j?.data) ? j.data : []; // `indexer` is a keyless Blockscout instance. The JSON-RPC endpoints
return list.map((t) => { // above serve balances but have no history or token concept at all —
const c = t?.raw_data?.contract?.[0]; // that's why imported ETH wallets showed a balance and nothing else.
const v = c?.parameter?.value || {}; // Etherscan V2 would need an API key; Blockscout does not.
const ownerHex = String(v.owner_address || ""); // publicnode, not llamarpc: llamarpc was answering 525 with an HTML
// owner/to come back as 41-prefixed hex regardless of visible. // error page, which surfaced as a JSON parse error and a 0 balance.
const mineHex = tronAddrToHex(address); 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/" },
const outgoing = !!mineHex && ownerHex.toLowerCase() === mineHex.toLowerCase(); 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/" },
const amount = Number(v.amount || 0); },
const ok = Array.isArray(t.ret) ? t.ret[0]?.contractRet === "SUCCESS" : true; // JSON-RPC eth_getBalance → hex-string wei.
return { async fetchBalance({ rpc, address }) {
txid: t.txID || t.txid, const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" },
time: Math.floor((t.block_timestamp || t.raw_data?.timestamp || 0) / 1000), body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBalance", params: [address, "latest"] }) });
confirmations: ok ? 1 : 0, const j = await r.json();
status: ok ? "confirmed" : "failed", const hex = String(j?.result || "0x0").replace(/^0x/, "");
// Aegis renders `delta` in the wallet's base unit (sun here). return BigInt("0x" + hex).toString();
delta: c?.type === "TransferContract" ? (outgoing ? -amount : amount) : 0, },
kind: c?.type || "Contract", async fetchHistory({ address, net }) {
}; if (!net?.indexer) return null;
}).filter((t) => t.txid); 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}`);
// TRC20 balances live on the /v1 REST family. The balance map is const j = await r.json();
// contract -> raw amount with no symbol/decimals, so we join it const items = Array.isArray(j?.items) ? j.items : [];
// against token_info from recent transfers to name what we can. const me = String(address).toLowerCase();
async fetchTokens({ rpc, address }) { return items.slice(0, 25).map((t) => {
const base = rpc.replace(/\/+$/, ""); const from = String(t.from?.hash || "").toLowerCase();
const r = await fetch(`${base}/v1/accounts/${encodeURIComponent(address)}`); const wei = BigInt(String(t.value || "0"));
if (!r.ok) throw new Error(`Tron account HTTP ${r.status}`); const outgoing = from === me;
const j = await r.json(); // A mempool tx comes back as {result:"pending", status:null,
const acct = Array.isArray(j?.data) ? j.data[0] : j?.data; // timestamp:null}. Reading that as `status !== "ok" → failed`
const raw = Array.isArray(acct?.trc20) ? acct.trc20 : []; // showed pending sends as failures, which is the one thing a
const balances = new Map(); // wallet must never get wrong.
for (const entry of raw) { const pending = t.result === "pending" || t.status == null;
for (const [contract, amt] of Object.entries(entry || {})) { return {
if (String(amt) !== "0") balances.set(contract, String(amt)); txid: t.hash,
} time: Math.floor(new Date(t.timestamp || 0).getTime() / 1000) || 0,
} confirmations: Number(t.confirmations) || 0,
if (!balances.size) return []; status: pending ? "pending" : (t.status === "ok" ? "confirmed" : "failed"),
const info = new Map(); // Keep wei exact — 18 decimals overflows a JS number.
try { delta: (outgoing ? -wei : wei).toString(),
const tr = await fetch(`${base}/v1/accounts/${encodeURIComponent(address)}/transactions/trc20?limit=100`); kind: t.method || "Transfer",
if (tr.ok) { };
const tj = await tr.json(); }).filter((t) => t.txid);
for (const t of (Array.isArray(tj?.data) ? tj.data : [])) { },
const ti = t?.token_info; async fetchTokens({ address, net }) {
if (ti && ti.address && !info.has(ti.address)) info.set(ti.address, ti); 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}`);
} catch { /* names are a nicety; balances still render */ } const j = await r.json();
// Named tokens first: an address that's been airdrop-spammed can const list = Array.isArray(j) ? j : [];
// hold dozens of contracts we have no token_info for, and those return list.map((e) => {
// would otherwise bury the ones the user actually cares about. const t = e?.token || {};
return Array.from(balances, ([contract, balance]) => { const symbol = cleanTokenText(t.symbol);
const ti = info.get(contract); return {
return { mint: t.address_hash || t.address || "",
mint: contract, symbol: symbol || "?",
symbol: ti?.symbol || "?", name: cleanTokenText(t.name),
name: ti?.name || "", decimals: Number(t.decimals) || 0,
decimals: Number.isFinite(Number(ti?.decimals)) ? Number(ti.decimals) : 0, known: !!symbol,
known: !!ti, balance: String(e.value ?? "0"),
balance, };
}; }).filter((t) => t.mint && t.balance !== "0")
}).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: { },
ticker: "SOL", decimals: 9, trx: {
networks: { ticker: "TRX", decimals: 6,
mainnet: { id: "mainnet", label: "Mainnet-beta", rpc: "https://api.mainnet-beta.solana.com", explorerAddr: "https://explorer.solana.com/address/", explorerTx: "https://explorer.solana.com/tx/" }, networks: {
devnet: { id: "devnet", label: "Devnet", rpc: "https://api.devnet.solana.com", explorerAddr: "https://explorer.solana.com/address/", explorerTx: "https://explorer.solana.com/tx/", explorerSuffix: "?cluster=devnet", testnet: true, faucet: "https://faucet.solana.com/" }, mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://api.trongrid.io", explorerAddr: "https://tronscan.org/#/address/", explorerTx: "https://tronscan.org/#/transaction/" },
}, // nile.trongrid.io, NOT api.nileex.io: nileex only serves the
// Solana JSON-RPC getBalance returns lamports as a number. // /wallet/* JSON-RPC family and 404s the whole /v1/ REST family,
async fetchBalance({ rpc, address }) { // which is where transaction history and the trc20 token list
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" }, // live. Balance worked, everything else silently came back empty.
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getBalance", params: [address] }) }); nile: { id: "nile", label: "Nile testnet", rpc: "https://nile.trongrid.io", explorerAddr: "https://nile.tronscan.org/#/address/", explorerTx: "https://nile.tronscan.org/#/transaction/", testnet: true, faucet: "https://nileex.io/join/getJoinPage" },
const j = await r.json(); },
return String(j?.result?.value || 0); // Tron HTTP API returns account.balance in SUN (10^-6 TRX).
}, async fetchBalance({ rpc, address }) {
}, const r = await fetch(rpc.replace(/\/+$/, "") + "/wallet/getaccount", { method: "POST",
}; headers: { "content-type": "application/json" },
body: JSON.stringify({ address, visible: true }) });
class GenericImportedWallet { const j = await r.json();
constructor({ chain, network, address, log = () => {}, onChange = () => {}, rpcUrl } = {}) { return String(j?.balance || 0);
const cfg = CHAIN_CFGS[chain]; if (!cfg) throw new Error(`chain-generic-imported: unknown chain ${chain}`); },
const net = cfg.networks[network]; if (!net) throw new Error(`chain-generic-imported: ${chain} has no network ${network}`); async fetchHistory({ rpc, address }) {
if (!address) throw new Error("address required"); const r = await fetch(`${rpc.replace(/\/+$/, "")}/v1/accounts/${encodeURIComponent(address)}/transactions?limit=25`);
this.chain = chain; if (!r.ok) throw new Error(`Tron history HTTP ${r.status}`);
this.network = network; const j = await r.json();
this._cfg = cfg; const list = Array.isArray(j?.data) ? j.data : [];
this._net = { ...net, rpc: rpcUrl || net.rpc }; return list.map((t) => {
this.log = log; const c = t?.raw_data?.contract?.[0];
this.onChange = onChange; const v = c?.parameter?.value || {};
this._address = address; const ownerHex = String(v.owner_address || "");
this._state = { // owner/to come back as 41-prefixed hex regardless of visible.
balance: { confirmed: "0", unconfirmed: "0" }, const mineHex = tronAddrToHex(address);
history: [], const outgoing = !!mineHex && ownerHex.toLowerCase() === mineHex.toLowerCase();
tokens: [], const amount = Number(v.amount || 0);
scanning: false, const ok = Array.isArray(t.ret) ? t.ret[0]?.contractRet === "SUCCESS" : true;
error: null, return {
}; txid: t.txID || t.txid,
this._pollTimer = null; time: Math.floor((t.block_timestamp || t.raw_data?.timestamp || 0) / 1000),
} confirmations: ok ? 1 : 0,
status: ok ? "confirmed" : "failed",
setServers() { /* no-op: this adapter uses HTTP RPC, not electrum */ } // Aegis renders `delta` in the wallet's base unit (sun here).
schedulePoll(ms) { delta: c?.type === "TransferContract" ? (outgoing ? -amount : amount) : 0,
clearTimeout(this._pollTimer); kind: c?.type || "Contract",
this._pollTimer = setTimeout(() => { this.refresh(false).catch(() => {}); this.schedulePoll(ms); }, ms); };
} }).filter((t) => t.txid);
},
_emit() { try { this.onChange(); } catch {} } // TRC20 balances live on the /v1 REST family. The balance map is
// contract -> raw amount with no symbol/decimals, so we join it
snapshot() { // against token_info from recent transfers to name what we can.
return { async fetchTokens({ rpc, address }) {
chain: this.chain, network: this.network, const base = rpc.replace(/\/+$/, "");
ticker: this._cfg.ticker, decimals: this._cfg.decimals, const r = await fetch(`${base}/v1/accounts/${encodeURIComponent(address)}`);
address: this._address, if (!r.ok) throw new Error(`Tron account HTTP ${r.status}`);
addressIndex: 0, const j = await r.json();
addressPath: null, const acct = Array.isArray(j?.data) ? j.data[0] : j?.data;
balance: this._state.balance, const raw = Array.isArray(acct?.trc20) ? acct.trc20 : [];
history: this._state.history, const balances = new Map();
tokens: this._state.tokens, for (const entry of raw) {
scanning: this._state.scanning, for (const [contract, amt] of Object.entries(entry || {})) {
error: this._state.error, if (String(amt) !== "0") balances.set(contract, String(amt));
server: this._net.rpc, }
rpcUrl: this._net.rpc, }
imported: true, if (!balances.size) return [];
explorerAddr: this._net.explorerAddr, const info = new Map();
explorerTx: this._net.explorerTx, try {
explorerSuffix: this._net.explorerSuffix || "", const tr = await fetch(`${base}/v1/accounts/${encodeURIComponent(address)}/transactions/trc20?limit=100`);
faucet: this._net.faucet || null, if (tr.ok) {
}; const tj = await tr.json();
} for (const t of (Array.isArray(tj?.data) ? tj.data : [])) {
const ti = t?.token_info;
async refresh() { if (ti && ti.address && !info.has(ti.address)) info.set(ti.address, ti);
this._state.scanning = true; this._emit(); }
const opts = { rpc: this._net.rpc, address: this._address }; }
try { } catch { /* names are a nicety; balances still render */ }
// Only the balance is load-bearing — history and tokens are // Named tokens first: an address that's been airdrop-spammed can
// best-effort so one 404 on a chain that has no keyless feed // hold dozens of contracts we have no token_info for, and those
// doesn't blank the wallet. // would otherwise bury the ones the user actually cares about.
const [confirmed, history, tokens] = await Promise.all([ return Array.from(balances, ([contract, balance]) => {
this._cfg.fetchBalance(opts), const ti = info.get(contract);
this._cfg.fetchHistory const symbol = cleanTokenText(ti?.symbol);
? this._cfg.fetchHistory(opts).catch((e) => { this.log("history failed:", e?.message || e); return null; }) return {
: Promise.resolve(null), mint: contract,
this._cfg.fetchTokens symbol: symbol || "?",
? this._cfg.fetchTokens(opts).catch((e) => { this.log("tokens failed:", e?.message || e); return null; }) name: cleanTokenText(ti?.name),
: Promise.resolve(null), decimals: Number.isFinite(Number(ti?.decimals)) ? Number(ti.decimals) : 0,
]); known: !!ti && !!symbol,
this._state.balance = { confirmed: String(confirmed || 0), unconfirmed: "0" }; balance,
if (Array.isArray(history)) this._state.history = history; };
if (Array.isArray(tokens)) this._state.tokens = tokens; }).sort((a, b) => (b.known - a.known) || (a.symbol || "").localeCompare(b.symbol || ""))
this._state.error = null; .slice(0, TOKEN_CAP);
} catch (e) { },
this._state.error = e?.message || String(e); },
} finally { sol: {
this._state.scanning = false; ticker: "SOL", decimals: 9,
this._emit(); networks: {
} mainnet: { id: "mainnet", label: "Mainnet-beta", rpc: "https://api.mainnet-beta.solana.com", explorerAddr: "https://explorer.solana.com/address/", explorerTx: "https://explorer.solana.com/tx/" },
} devnet: { id: "devnet", label: "Devnet", rpc: "https://api.devnet.solana.com", explorerAddr: "https://explorer.solana.com/address/", explorerTx: "https://explorer.solana.com/tx/", explorerSuffix: "?cluster=devnet", testnet: true, faucet: "https://faucet.solana.com/" },
},
nextAddress() { return { address: this._address, index: 0 }; } // Solana JSON-RPC getBalance returns lamports as a number.
current() { return { address: this._address, index: 0, branch: 0, path: null }; } async fetchBalance({ rpc, address }) {
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" },
plan() { throw new Error(`Imported ${this.chain.toUpperCase()} wallets are read-only in this build. Spending support ships in the next Aegis update.`); } body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getBalance", params: [address] }) });
signAndBroadcast() { throw new Error("read-only"); } const j = await r.json();
signMessage() { throw new Error("read-only"); } return String(j?.result?.value || 0);
},
recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import." }; } // getSignaturesForAddress is keyless on the public RPC. It gives us
// the ledger of signatures touching this address but NOT the amounts —
dispose() { clearTimeout(this._pollTimer); } // 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.
return { GenericImportedWallet, CHAIN_CFGS }; 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);
},
},
};
class GenericImportedWallet {
constructor({ chain, network, address, log = () => {}, onChange = () => {}, rpcUrl } = {}) {
const cfg = CHAIN_CFGS[chain]; if (!cfg) throw new Error(`chain-generic-imported: unknown chain ${chain}`);
const net = cfg.networks[network]; if (!net) throw new Error(`chain-generic-imported: ${chain} has no network ${network}`);
if (!address) throw new Error("address required");
this.chain = chain;
this.network = network;
this._cfg = cfg;
this._net = { ...net, rpc: rpcUrl || net.rpc };
this.log = log;
this.onChange = onChange;
this._address = address;
this._state = {
balance: { confirmed: "0", unconfirmed: "0" },
history: [],
tokens: [],
scanning: false,
error: null,
};
this._pollTimer = null;
}
setServers() { /* no-op: this adapter uses HTTP RPC, not electrum */ }
schedulePoll(ms) {
clearTimeout(this._pollTimer);
this._pollTimer = setTimeout(() => { this.refresh(false).catch(() => {}); this.schedulePoll(ms); }, ms);
}
_emit() { try { this.onChange(); } catch {} }
snapshot() {
return {
chain: this.chain, network: this.network,
ticker: this._cfg.ticker, decimals: this._cfg.decimals,
address: this._address,
addressIndex: 0,
addressPath: null,
balance: this._state.balance,
history: this._state.history,
tokens: this._state.tokens,
scanning: this._state.scanning,
error: this._state.error,
server: this._net.rpc,
rpcUrl: this._net.rpc,
imported: true,
explorerAddr: this._net.explorerAddr,
explorerTx: this._net.explorerTx,
explorerSuffix: this._net.explorerSuffix || "",
faucet: this._net.faucet || null,
};
}
async refresh() {
this._state.scanning = true; this._emit();
const opts = { rpc: this._net.rpc, address: this._address, net: this._net };
try {
// Only the balance is load-bearing — history and tokens are
// best-effort so one 404 on a chain that has no keyless feed
// doesn't blank the wallet.
const [confirmed, history, tokens] = await Promise.all([
this._cfg.fetchBalance(opts),
this._cfg.fetchHistory
? this._cfg.fetchHistory(opts).catch((e) => { this.log("history failed:", e?.message || e); return null; })
: Promise.resolve(null),
this._cfg.fetchTokens
? this._cfg.fetchTokens(opts).catch((e) => { this.log("tokens failed:", e?.message || e); return null; })
: Promise.resolve(null),
]);
this._state.balance = { confirmed: String(confirmed || 0), unconfirmed: "0" };
if (Array.isArray(history)) this._state.history = history;
if (Array.isArray(tokens)) this._state.tokens = tokens;
this._state.error = null;
} catch (e) {
this._state.error = e?.message || String(e);
} finally {
this._state.scanning = false;
this._emit();
}
}
nextAddress() { return { address: this._address, index: 0 }; }
current() { return { address: this._address, index: 0, branch: 0, path: null }; }
plan() { throw new Error(`Imported ${this.chain.toUpperCase()} wallets are read-only in this build. Spending support ships in the next Aegis update.`); }
signAndBroadcast() { throw new Error("read-only"); }
signMessage() { throw new Error("read-only"); }
recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import." }; }
dispose() { clearTimeout(this._pollTimer); }
}
return { GenericImportedWallet, CHAIN_CFGS };
};

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>`;