theseus/bundled-addons/aegis/lib/chain-generic-imported.js
Local Dev 8785ecc7cf chore(aegis): 0.8.7 — imported Tron wallets get history + TRC20 tokens
Imported Tron wallets pointed at api.nileex.io, which only serves the
/wallet/* JSON-RPC family and 404s all of /v1/. That REST family is
where transaction history and the trc20 balance map live, so an
imported Nile wallet showed a native balance and nothing else. The
built-in Tron adapter was already on nile.trongrid.io, which is why
only imports were affected.

Switches the imported Nile endpoint to nile.trongrid.io and fills in
the two features that were never implemented for imported account-
model wallets:

- History via /v1/accounts/<addr>/transactions, with the signed delta
  computed by comparing owner_address against the wallet's own address
  in 41-hex form (the feed returns hex regardless of visible:true).
- TRC20 balances via /v1/accounts/<addr>, joined against token_info
  harvested from recent trc20 transfers to recover symbol + decimals.

Both are best-effort so a chain with no keyless feed can't blank a
wallet whose balance fetch succeeded. Contracts with no registry entry
render as "Unknown token" with a raw amount rather than a number
invented from assumed decimals, and named tokens sort above them so
airdrop spam can't bury real holdings.
2026-09-22 21:52:54 +02:00

243 lines
11 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 ""; }
}
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 };
};