Previous #4d7300 (0.3.10) was still too light against actual white backgrounds — several tint fills (rgba(214,255,61,X)) and unpatched addon panels were making the effective color feel bright green. Two fixes bundled: 1) Bump --acid in every top-level page's light-media block from #4d7300 to #3a5c00 — same hue, ~7:1 contrast on #ffffff (was ~5.5:1). 2) Add the missing light-media --acid override to the addon panels that were still resolving to #d6ff3d: bchwallet/panel.html, siawallet/panel.html, and screenshot/editor.css (was #b4e024, now #3a5c00 to match). Dark mode unchanged. Tint fills (rgba backgrounds at low alpha) still stay as-is — at 8–15% opacity the specific hue barely matters and the darker foreground now dominates.
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 };
|
|
};
|