Mounting an imported TRX/ETH/SOL wallet read the optional custom RPC as String(stored || undefined), so a wallet with no override got the literal "undefined" as its server: every history and token fetch went to "undefined/v1/accounts/…" and the panel showed "undefined" under the balance. Only a real https URL overrides the network default now, and the adapter itself rejects anything that does not look like one.
392 lines
20 KiB
JavaScript
392 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;
|
|
// 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 };
|
|
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 };
|
|
};
|