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.
353 lines
15 KiB
JavaScript
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 };
|
|
};
|