theseus/bundled-addons/aegis/lib/chain-generic-imported.js
Local Dev 64bc26ecf6 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.
2026-09-23 00:05:14 +02:00

389 lines
20 KiB
JavaScript

// Generic single-address read-only imported adapter for account-model
// chains. One config-driven runtime handles ETH-family, Tron, and Solana
// balance polling — every chain differs only in the RPC verb and the
// JSON path to the balance number.
//
// The adapter mirrors the public shape every Aegis chain runtime exposes
// (snapshot, refresh, plan, signAndBroadcast, dispose) so mountWallet
// stays chain-agnostic. planSend/send throw a "read-only" error until
// M.1b delivers the sign path per chain.
module.exports = function makeGenericImportedAdapter() {
// base58check T… -> 41-prefixed hex, for comparing against the raw
// owner_address/to_address fields the /v1 tx feed returns.
const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
function tronAddrToHex(b58) {
try {
let n = 0n;
for (const ch of String(b58)) {
const i = B58.indexOf(ch);
if (i < 0) return "";
n = n * 58n + BigInt(i);
}
let hex = n.toString(16);
if (hex.length % 2) hex = "0" + hex;
// 25 bytes = 21 payload + 4 checksum; drop the checksum.
return hex.padStart(50, "0").slice(0, 42);
} 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 = {
eth: {
ticker: "ETH", decimals: 18,
networks: {
// `indexer` is a keyless Blockscout instance. The JSON-RPC endpoints
// 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.
async fetchBalance({ rpc, address }) {
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBalance", params: [address, "latest"] }) });
const j = await r.json();
const hex = String(j?.result || "0x0").replace(/^0x/, "");
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: {
ticker: "TRX", decimals: 6,
networks: {
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
// /wallet/* JSON-RPC family and 404s the whole /v1/ REST family,
// which is where transaction history and the trc20 token list
// 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" },
},
// 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 }) });
const j = await r.json();
return String(j?.balance || 0);
},
async fetchHistory({ 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();
const list = Array.isArray(j?.data) ? j.data : [];
return list.map((t) => {
const c = t?.raw_data?.contract?.[0];
const v = c?.parameter?.value || {};
const ownerHex = String(v.owner_address || "");
// owner/to come back as 41-prefixed hex regardless of visible.
const mineHex = tronAddrToHex(address);
const outgoing = !!mineHex && ownerHex.toLowerCase() === mineHex.toLowerCase();
const amount = Number(v.amount || 0);
const ok = Array.isArray(t.ret) ? t.ret[0]?.contractRet === "SUCCESS" : true;
return {
txid: t.txID || t.txid,
time: Math.floor((t.block_timestamp || t.raw_data?.timestamp || 0) / 1000),
confirmations: ok ? 1 : 0,
status: ok ? "confirmed" : "failed",
// Aegis renders `delta` in the wallet's base unit (sun here).
delta: c?.type === "TransferContract" ? (outgoing ? -amount : amount) : 0,
kind: c?.type || "Contract",
};
}).filter((t) => t.txid);
},
// TRC20 balances live on the /v1 REST family. The balance map is
// contract -> raw amount with no symbol/decimals, so we join it
// against token_info from recent transfers to name what we can.
async fetchTokens({ rpc, address }) {
const base = rpc.replace(/\/+$/, "");
const r = await fetch(`${base}/v1/accounts/${encodeURIComponent(address)}`);
if (!r.ok) throw new Error(`Tron account HTTP ${r.status}`);
const j = await r.json();
const acct = Array.isArray(j?.data) ? j.data[0] : j?.data;
const raw = Array.isArray(acct?.trc20) ? acct.trc20 : [];
const balances = new Map();
for (const entry of raw) {
for (const [contract, amt] of Object.entries(entry || {})) {
if (String(amt) !== "0") balances.set(contract, String(amt));
}
}
if (!balances.size) return [];
const info = new Map();
try {
const tr = await fetch(`${base}/v1/accounts/${encodeURIComponent(address)}/transactions/trc20?limit=100`);
if (tr.ok) {
const tj = await tr.json();
for (const t of (Array.isArray(tj?.data) ? tj.data : [])) {
const ti = t?.token_info;
if (ti && ti.address && !info.has(ti.address)) info.set(ti.address, ti);
}
}
} catch { /* names are a nicety; balances still render */ }
// Named tokens first: an address that's been airdrop-spammed can
// hold dozens of contracts we have no token_info for, and those
// would otherwise bury the ones the user actually cares about.
return Array.from(balances, ([contract, balance]) => {
const ti = info.get(contract);
const symbol = cleanTokenText(ti?.symbol);
return {
mint: contract,
symbol: symbol || "?",
name: cleanTokenText(ti?.name),
decimals: Number.isFinite(Number(ti?.decimals)) ? Number(ti.decimals) : 0,
known: !!ti && !!symbol,
balance,
};
}).sort((a, b) => (b.known - a.known) || (a.symbol || "").localeCompare(b.symbol || ""))
.slice(0, TOKEN_CAP);
},
},
sol: {
ticker: "SOL", decimals: 9,
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/" },
},
// Solana JSON-RPC getBalance returns lamports as a number.
async fetchBalance({ rpc, address }) {
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getBalance", params: [address] }) });
const j = await r.json();
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);
},
},
};
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 };
};