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.
This commit is contained in:
Local Dev 2026-09-22 21:52:54 +02:00
parent 30fbe73d21
commit 8785ecc7cf
3 changed files with 136 additions and 3 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "aegis", "id": "aegis",
"name": "Aegis Wallet", "name": "Aegis Wallet",
"version": "0.8.6", "version": "0.8.7",
"category": "plugin", "category": "plugin",
"description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.", "description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.",
"author": "Silent Mode", "author": "Silent Mode",

View file

@ -10,6 +10,24 @@
module.exports = function makeGenericImportedAdapter() { 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 = { const CHAIN_CFGS = {
eth: { eth: {
ticker: "ETH", decimals: 18, ticker: "ETH", decimals: 18,
@ -30,7 +48,11 @@ module.exports = function makeGenericImportedAdapter() {
ticker: "TRX", decimals: 6, ticker: "TRX", decimals: 6,
networks: { networks: {
mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://api.trongrid.io", explorerAddr: "https://tronscan.org/#/address/", explorerTx: "https://tronscan.org/#/transaction/" }, mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://api.trongrid.io", explorerAddr: "https://tronscan.org/#/address/", explorerTx: "https://tronscan.org/#/transaction/" },
nile: { id: "nile", label: "Nile testnet", rpc: "https://api.nileex.io", explorerAddr: "https://nile.tronscan.org/#/address/", explorerTx: "https://nile.tronscan.org/#/transaction/", testnet: true, faucet: "https://nileex.io/join/getJoinPage" }, // 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). // Tron HTTP API returns account.balance in SUN (10^-6 TRX).
async fetchBalance({ rpc, address }) { async fetchBalance({ rpc, address }) {
@ -40,6 +62,74 @@ module.exports = function makeGenericImportedAdapter() {
const j = await r.json(); const j = await r.json();
return String(j?.balance || 0); 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: { sol: {
ticker: "SOL", decimals: 9, ticker: "SOL", decimals: 9,
@ -72,6 +162,7 @@ module.exports = function makeGenericImportedAdapter() {
this._state = { this._state = {
balance: { confirmed: "0", unconfirmed: "0" }, balance: { confirmed: "0", unconfirmed: "0" },
history: [], history: [],
tokens: [],
scanning: false, scanning: false,
error: null, error: null,
}; };
@ -95,6 +186,7 @@ module.exports = function makeGenericImportedAdapter() {
addressPath: null, addressPath: null,
balance: this._state.balance, balance: this._state.balance,
history: this._state.history, history: this._state.history,
tokens: this._state.tokens,
scanning: this._state.scanning, scanning: this._state.scanning,
error: this._state.error, error: this._state.error,
server: this._net.rpc, server: this._net.rpc,
@ -109,9 +201,23 @@ module.exports = function makeGenericImportedAdapter() {
async refresh() { async refresh() {
this._state.scanning = true; this._emit(); this._state.scanning = true; this._emit();
const opts = { rpc: this._net.rpc, address: this._address };
try { try {
const confirmed = await this._cfg.fetchBalance({ rpc: this._net.rpc, address: this._address }); // 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" }; 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; this._state.error = null;
} catch (e) { } catch (e) {
this._state.error = e?.message || String(e); this._state.error = e?.message || String(e);

View file

@ -2941,6 +2941,33 @@ function renderTokens() {
}).catch(() => {}); }).catch(() => {});
return; return;
} }
// TRX wallets: TRC20 balances. Read-only — Aegis has no TRC20 spend
// path yet, so there's no Send button here (unlike SPL above).
if (chain() === "trx") {
const tokens = s?.tokens || [];
card.hidden = tokens.length === 0;
const hintEl = $("tokensHint");
if (hintEl) hintEl.textContent = "TRC20 tokens held by this wallet. Read-only in this build — use the explorer link to move them.";
if (!tokens.length) return;
el.innerHTML = tokens.map((t) => {
const dec = Number(t.decimals) || 0;
const short = String(t.mint || "");
const addrLine = `${esc(short.slice(0, 10))}…${esc(short.slice(-6))}`;
// Unknown contracts have no decimals, so a raw integer would be a
// misleading "balance". Say so instead of inventing a number.
const title = t.known
? `${esc(t.symbol)}${t.name ? ' <span class="hint">' + esc(t.name) + "</span>" : ""}`
: `<span class="hint">Unknown token</span>`;
const amount = t.known
? esc(fmtTokenAmount(t.balance, dec))
: `<span class="hint mono" title="No registry entry for this contract — raw on-chain amount, decimals unknown">${esc(t.balance)} raw</span>`;
return `<div class="tx" style="grid-template-columns:1fr auto;cursor:default;align-items:center">
<div><div>${title}</div><div class="hint mono">${addrLine}</div></div>
<div class="amt2 in" style="align-self:center;text-align:right">${amount}</div>
</div>`;
}).join("");
return;
}
card.hidden = true; card.hidden = true;
} }
// Same shape as index.js's fmtTokenAmount — string-safe for u64 SPL amounts. // Same shape as index.js's fmtTokenAmount — string-safe for u64 SPL amounts.