// 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 ""; } } const CHAIN_CFGS = { eth: { ticker: "ETH", decimals: 18, networks: { mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://eth.llamarpc.com", explorerAddr: "https://etherscan.io/address/", explorerTx: "https://etherscan.io/tx/" }, 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/" }, }, // 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(); }, }, 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); return { mint: contract, symbol: ti?.symbol || "?", name: ti?.name || "", decimals: Number.isFinite(Number(ti?.decimals)) ? Number(ti.decimals) : 0, known: !!ti, balance, }; }).sort((a, b) => (b.known - a.known) || (a.symbol || "").localeCompare(b.symbol || "")); }, }, 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); }, }, }; 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 }; 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 }; };