theseus/bundled-addons/aegis/lib/chain-tron.js
Local Dev 2e54bf5e5a Ship Theseus 0.3.28 5d15508b (Aegis update card + DevTools in tab sidebar + real favicons)
Setup    5d15508bba929f1f074c052ac933863eadf6eb8e56984ebd5a1af75e80626643
Portable a5d346b97f5a13d85fa3bd301a72075ddb82fe636d7b1a51840ffd5a16d879f4

Bundled since 0.3.27:

32d4b75 - Aegis (bchwallet) gains its own update card in Settings >
General beside Ariadne. Check for updates hits the same signed OTA
endpoint the boot timer uses; Restart to apply appears when a signed
newer version is staged. Uses the existing addons-check-updates + a
new app-restart IPC. New Aegis versions ship without a Theseus release.

32d4b75 (same commit) - DevTools (F12 / Ctrl+Shift+I) opens docked to
the right of the tab (mode: 'right') instead of a detached window.
Matches stock Chrome. Users who prefer detached can drag out via the
DevTools own toolbar.

b71c925 - Search-engine favicons in Settings > Search now use Google's
/s2/favicons service — DuckDuckGo's ip3 source returned 404 for enough
hosts (Brave, Bing, Yandex, etc.) that half the list was falling
through to the emoji placeholder.

Deployed. Verified LIVE 0.3.28.
2026-09-08 18:17:25 +02:00

353 lines
15 KiB
JavaScript

// Tron chain adapter (mainnet + Nile testnet). One address per wallet, in the
// TronLink style: seed for this wallet → BIP32 → m/44'/195'/0'/0/0 → secp256k1
// private key → uncompressed pubkey (drop 0x04) → keccak256 last 20 bytes →
// prepend 0x41 → base58check → "T..." address.
//
// Balance and history come from TronGrid (read; no key required). Send is
// build-locally-signed-locally-broadcast-remotely:
// POST /wallet/createtransaction {owner_address, to_address, amount, visible:true}
// → { raw_data, raw_data_hex, txID, ... }
// sig = secp256k1.sign(sha256(raw_data_hex), privateKey, {format:"recovered"})
// → 65 bytes [r||s||recid]
// POST /wallet/broadcasttransaction {raw_data, raw_data_hex, txID, signature:[hexSig]}
// → { result: true/false, txid/message }
//
// Nile and mainnet share the address format (both prefix 0x41). The RPC host
// differs. Coin type 195 is used for both — the derivation-path *string* is
// identical; the difference between mainnet and Nile is which sub-wallet the
// user picked, which vault-derive purpose seeded it, and which RPC we hit.
// Two addresses; a mainnet wallet's key cannot accidentally sign for Nile
// (different key material entirely; the check is enforced at the seed layer).
const NETWORKS = {
mainnet: {
id: "mainnet",
label: "Tron",
ticker: "TRX",
rpcBase: "https://api.trongrid.io",
explorerTx: "https://tronscan.org/#/transaction/",
explorerAddr: "https://tronscan.org/#/address/",
faucet: null,
},
nile: {
id: "nile",
label: "Tron Nile testnet",
ticker: "TRX",
rpcBase: "https://nile.trongrid.io",
explorerTx: "https://nile.tronscan.org/#/transaction/",
explorerAddr: "https://nile.tronscan.org/#/address/",
faucet: "https://nileex.io/join/getJoinPage",
},
};
module.exports = function makeTronAdapter({ HDKey, secp256k1, sha256, keccak_256, base58check }) {
if (!HDKey || !secp256k1 || !sha256 || !keccak_256 || !base58check) {
throw new Error("chain-tron: missing dep");
}
const bytesToHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const hexToBytes = (h) => {
const s = String(h).replace(/^0x/i, "");
if (s.length % 2) throw new Error("hex: odd length");
const out = new Uint8Array(s.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
return out;
};
// Derive the raw key material for a single Tron account.
// root32: 32-byte HKDF child from api.vault.derive.
// returns: { privateKey (Uint8Array 32), publicKey (Uint8Array 65 uncompressed),
// addressBytes (Uint8Array 21, prefix 0x41 || h20), address (base58) }
function deriveAccount(root32) {
const master = HDKey.fromMasterSeed(root32);
// Tron follows Ethereum-style non-hardened branch/index: m/44'/195'/0'/0/0.
const node = master.derive("m/44'/195'/0'/0/0");
const priv = node.privateKey;
// Uncompressed pub is 65 bytes with a 0x04 prefix; drop it to feed keccak256.
const pubUncompressed = secp256k1.getPublicKey(priv, false);
const inner = pubUncompressed.slice(1); // 64 bytes
const kh = keccak_256(inner); // 32 bytes
const h20 = kh.slice(kh.length - 20);
const addrBytes = new Uint8Array(21);
addrBytes[0] = 0x41;
addrBytes.set(h20, 1);
const address = base58check.encodeCheck(addrBytes);
return { privateKey: priv, publicKey: pubUncompressed, addressBytes: addrBytes, address, node };
}
// "T..." → 21-byte payload (0x41 || h20). Throws on bad checksum / prefix.
function decodeAddress(str) {
const payload = base58check.decodeCheck(String(str).trim());
if (payload.length !== 21) throw new Error("bad address length");
if (payload[0] !== 0x41) throw new Error("bad address prefix (want 0x41 / T…)");
return payload;
}
function hexToAddress(hex) {
const b = hexToBytes(hex);
if (b.length !== 21 || b[0] !== 0x41) throw new Error("bad address hex");
return base58check.encodeCheck(b);
}
function makeClient(networkId) {
const net = NETWORKS[networkId];
if (!net) throw new Error(`unknown Tron network ${networkId}`);
async function rpc(pathPart, body) {
const url = net.rpcBase + pathPart;
let r;
try { r = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body || {}) }); }
catch (e) { throw new Error(`network: ${e?.message || e}`); }
if (!r.ok) throw new Error(`${pathPart}: HTTP ${r.status}`);
return r.json();
}
async function get(pathPart) {
const url = net.rpcBase + pathPart;
const r = await fetch(url);
if (!r.ok) throw new Error(`${pathPart}: HTTP ${r.status}`);
return r.json();
}
return { net, rpc, get };
}
class TronWallet {
constructor(root32, networkId, { storage, log = () => {}, onChange = () => {} } = {}) {
this.storage = storage;
this.log = log;
this.onChange = onChange;
this.client = makeClient(networkId);
this.net = this.client.net;
const acc = deriveAccount(root32);
// Root is stored so future revs can rotate accounts; the derived priv
// is what actually signs. Both wiped in dispose().
this._root = new Uint8Array(root32);
this._priv = acc.privateKey;
this.publicKey = acc.publicKey;
this.addressBytes = acc.addressBytes;
this.address = acc.address;
this.state = {
balance: { confirmed: 0, unconfirmed: 0 },
history: [],
height: 0,
scanning: false,
error: null,
server: this.net.rpcBase,
};
this._pollTimer = null;
}
// Public snapshot (no key material).
snapshot() {
return {
chain: "trx",
network: this.net.id,
ticker: this.net.ticker,
address: this.address,
addressIndex: 0,
addressPath: "m/44'/195'/0'/0/0",
balance: this.state.balance,
height: this.state.height,
history: this.state.history,
scanning: this.state.scanning,
error: this.state.error,
server: this.state.server,
explorerTx: this.net.explorerTx,
explorerAddr: this.net.explorerAddr,
faucet: this.net.faucet,
decimals: 6,
};
}
async refresh(_full = false) {
if (this.state.scanning) return;
this.state.scanning = true; this.state.error = null; this.onChange();
try {
const [acc, txs] = await Promise.all([this._fetchAccount(), this._fetchHistory()]);
// TronGrid returns balance in "sun" (1 TRX = 1_000_000 sun).
const sun = Number(acc?.balance || 0);
this.state.balance = { confirmed: sun, unconfirmed: 0 };
this.state.history = txs;
// Block height comes from any recent tx or a getnowblock call.
try {
const nb = await this.client.rpc("/wallet/getnowblock", {});
this.state.height = Number(nb?.block_header?.raw_data?.number || 0);
} catch {}
} catch (e) {
this.state.error = e?.message || String(e);
this.log("refresh failed:", this.state.error);
} finally {
this.state.scanning = false;
this.onChange();
}
}
schedulePoll(ms = 20_000) {
clearTimeout(this._pollTimer);
this._pollTimer = setTimeout(() => this.refresh(false).finally(() => this.schedulePoll(ms)), ms);
}
async _fetchAccount() {
const body = { address: this.address, visible: true };
// /wallet/getaccount returns {} for never-funded addresses.
return this.client.rpc("/wallet/getaccount", body);
}
async _fetchHistory() {
const url = `/v1/accounts/${encodeURIComponent(this.address)}/transactions?limit=25`;
let raw;
try { raw = await this.client.get(url); } catch (e) { this.log("history failed:", e?.message || e); return []; }
const list = Array.isArray(raw?.data) ? raw.data : [];
const now = Math.floor(Date.now() / 1000);
return list.map((t) => this._describeTx(t, now)).filter(Boolean);
}
_describeTx(t, nowSec) {
const contract = t?.raw_data?.contract?.[0];
const type = contract?.type;
const value = contract?.parameter?.value || {};
const txID = t.txID || t.txid;
const time = Math.floor((t.block_timestamp || t.raw_data?.timestamp || 0) / 1000);
const conf = t.ret && Array.isArray(t.ret) ? (t.ret[0]?.contractRet === "SUCCESS" ? 1 : -1) : 0;
if (type === "TransferContract") {
// owner_address / to_address come as hex here (prefixed 41), regardless
// of visible:true (that flag only affects some endpoints).
let ownerB58 = "", toB58 = "";
try { ownerB58 = value.owner_address ? hexToAddress(value.owner_address) : ""; } catch {}
try { toB58 = value.to_address ? hexToAddress(value.to_address) : ""; } catch {}
const amount = Number(value.amount || 0);
const inc = toB58 === this.address;
return {
txid: txID,
delta: inc ? amount : -amount,
to: inc ? null : toB58,
from: inc ? ownerB58 : null,
fee: t.net_fee || t.energy_fee || null,
time: time || 0,
confirmations: conf > 0 ? 1 : (conf < 0 ? 0 : 0),
status: conf > 0 ? "confirmed" : (conf < 0 ? "failed" : "pending"),
kind: "transfer",
};
}
// Non-transfer contracts (delegation, votes, TRC20) — surface as a
// neutral entry so users see something happened without exaggerating.
return {
txid: txID,
delta: 0,
to: null, from: null, fee: null,
time: time || 0,
confirmations: conf > 0 ? 1 : 0,
status: conf > 0 ? "confirmed" : "other",
kind: type || "contract",
};
}
// Preview a send: no side effects, no signature. Returns the fields the
// panel needs plus the raw createtransaction result cached under _draft
// so signAndBroadcast doesn't re-fetch.
async plan({ to, amount, sendMax }) {
if (!to) throw new Error("recipient required");
const dest = decodeAddress(to);
const balance = this.state.balance.confirmed;
// Tron doesn't have a fee-market for plain TRX transfers between accounts
// that carry enough bandwidth. For untouched addresses the network
// consumes a fixed 100_000 sun (0.1 TRX) burn from the sender if no
// free bandwidth is available. We show that as an upper-bound estimate.
const FEE_EST = 100_000;
let value;
if (sendMax) {
value = Math.max(0, balance - FEE_EST);
} else {
value = Math.round(Number(amount) || 0);
}
if (!(value > 0)) throw new Error("amount must be > 0 sun");
if (value + FEE_EST > balance) throw new Error("insufficient funds");
const body = { owner_address: this.address, to_address: base58check.encodeCheck(dest), amount: value, visible: true };
const draft = await this.client.rpc("/wallet/createtransaction", body);
if (draft?.Error || !draft?.raw_data_hex) {
throw new Error("createtransaction: " + (draft?.Error || "empty response"));
}
const plan = {
recipients: [{ to: base58check.encodeCheck(dest), value }],
fee: FEE_EST,
feeRate: 1,
total: value + FEE_EST,
inputs: [],
_draft: draft,
};
return plan;
}
// Sign the raw_data_hex bytes with the wallet key and POST the signed tx.
async signAndBroadcast(plan) {
const draft = plan && plan._draft;
if (!draft || !draft.raw_data_hex) throw new Error("bad plan");
const rawBytes = hexToBytes(draft.raw_data_hex);
const digest = sha256(rawBytes);
const sig = secp256k1.sign(digest, this._priv, { prehash: false, lowS: false, format: "recovered" });
// Recovered format from noble is 65 bytes: [recid || r(32) || s(32)]. Tron
// wants [r(32) || s(32) || recid]. Reorder in place.
const trxSig = new Uint8Array(65);
trxSig.set(sig.subarray(1), 0);
trxSig[64] = sig[0];
const body = {
raw_data: draft.raw_data,
raw_data_hex: draft.raw_data_hex,
txID: draft.txID,
visible: true,
signature: [bytesToHex(trxSig)],
};
const r = await this.client.rpc("/wallet/broadcasttransaction", body);
if (r?.result !== true) {
const msg = r?.message ? Buffer.from(r.message, "hex").toString("utf8") : (r?.code || "broadcast rejected");
throw new Error("broadcast: " + msg);
}
// Refresh soon so history/balance catch up.
setTimeout(() => this.refresh(false), 2500);
return { txid: draft.txID };
}
// BIP137-style signing isn't standard on Tron; dapps use signMessageV2
// (tronWeb.trx.signMessageV2), which is a raw ECDSA over sha256 of the
// message bytes with a Tron prefix "\x19TRON Signed Message:\n32". Kept
// simple here: signMessageV2 always asks the user.
signMessageV2(message) {
const enc = new TextEncoder();
const bodyBytes = enc.encode(String(message));
const prefix = enc.encode("\x19TRON Signed Message:\n" + bodyBytes.length);
const buf = new Uint8Array(prefix.length + bodyBytes.length);
buf.set(prefix, 0); buf.set(bodyBytes, prefix.length);
const digest = keccak_256(buf);
const sig = secp256k1.sign(digest, this._priv, { prehash: false, lowS: false, format: "recovered" });
const out = new Uint8Array(65);
out.set(sig.subarray(1), 0);
out[64] = sig[0] + 27; // Ethereum-style v = 27 + recid
return { address: this.address, signature: "0x" + bytesToHex(out) };
}
// Sign an arbitrary raw_data_hex the dapp built with its own tronWeb.
// The caller must have already presented an approval overlay.
signRawData(rawDataHex) {
const rawBytes = hexToBytes(rawDataHex);
const digest = sha256(rawBytes);
const sig = secp256k1.sign(digest, this._priv, { prehash: false, lowS: false, format: "recovered" });
const trxSig = new Uint8Array(65);
trxSig.set(sig.subarray(1), 0);
trxSig[64] = sig[0];
return bytesToHex(trxSig);
}
async broadcastSignedTx(signedTx) {
const r = await this.client.rpc("/wallet/broadcasttransaction", signedTx);
if (r?.result !== true) {
const msg = r?.message ? Buffer.from(r.message, "hex").toString("utf8") : (r?.code || "broadcast rejected");
throw new Error("broadcast: " + msg);
}
return { txid: signedTx.txID };
}
dispose() {
clearTimeout(this._pollTimer);
try { this._priv && this._priv.fill(0); } catch {}
try { this._root && this._root.fill(0); } catch {}
}
}
return { TronWallet, deriveAccount, decodeAddress, hexToAddress, NETWORKS };
};