// 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 }; };