// 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 }; };