theseus/bundled-addons/aegis/lib/chain-generic-imported.js

432 lines
22 KiB
JavaScript
Raw Normal View History

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
// 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);
},
chore(aegis): 0.9.2 — Receive tab reorder, one balance, token history 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 <TICKER>" 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.
2026-09-23 01:17:33 +02:00
// 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 }) {
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
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,
chore(aegis): 0.9.2 — Receive tab reorder, one balance, token history 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 <TICKER>" 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.
2026-09-23 01:17:33 +02:00
asset: null, // null = the chain's native coin
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
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;
// 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 };
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
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 };
};