// Minimal protobuf wire decoder for Tron's `Transaction.raw` — what a dapp // hands us as `raw_data_hex`. The approval overlay MUST be built from these // bytes (the thing that actually gets signed), never from the dapp's // `raw_data` JSON, which can say anything. // // Field numbers from protocol/core/Tron.proto: // Transaction.raw: 1 ref_block_bytes, 3 ref_block_num, 4 ref_block_hash, // 8 expiration, 9 auths, 10 data, 11 contract (repeated), 12 scripts, // 14 timestamp, 18 fee_limit // Transaction.Contract: 1 type (enum), 2 parameter (google.protobuf.Any), // 3 provider, 4 ContractName, 5 Permission_id // Any: 1 type_url (string), 2 value (bytes) // TransferContract: 1 owner_address, 2 to_address, 3 amount // TransferAssetContract: 1 asset_name, 2 owner_address, 3 to_address, 4 amount // TriggerSmartContract: 1 owner_address, 2 contract_address, 3 call_value, // 4 data, 5 call_token_value, 6 token_id // Every other contract type puts owner_address in field 1. const CONTRACT_TYPES = { 0: "AccountCreateContract", 1: "TransferContract", 2: "TransferAssetContract", 3: "VoteAssetContract", 4: "VoteWitnessContract", 5: "WitnessCreateContract", 6: "AssetIssueContract", 8: "WitnessUpdateContract", 9: "ParticipateAssetIssueContract", 10: "AccountUpdateContract", 11: "FreezeBalanceContract", 12: "UnfreezeBalanceContract", 13: "WithdrawBalanceContract", 14: "UnfreezeAssetContract", 15: "UpdateAssetContract", 16: "ProposalCreateContract", 17: "ProposalApproveContract", 18: "ProposalDeleteContract", 19: "SetAccountIdContract", 20: "CustomContract", 30: "CreateSmartContract", 31: "TriggerSmartContract", 33: "UpdateSettingContract", 41: "ExchangeCreateContract", 42: "ExchangeInjectContract", 43: "ExchangeWithdrawContract", 44: "ExchangeTransactionContract", 45: "UpdateEnergyLimitContract", 46: "AccountPermissionUpdateContract", 48: "ClearABIContract", 49: "UpdateBrokerageContract", 51: "ShieldedTransferContract", 52: "MarketSellAssetContract", 53: "MarketCancelOrderContract", 54: "FreezeBalanceV2Contract", 55: "UnfreezeBalanceV2Contract", 56: "WithdrawExpireUnfreezeContract", 57: "DelegateResourceContract", 58: "UnDelegateResourceContract", 59: "CancelAllUnfreezeV2Contract", }; // Contract types that hand control of the account or its resources to a // third party — the overlay stresses these. const DANGEROUS_TYPES = new Set([46, 57]); const TRC20_SELECTORS = { a9059cbb: { name: "transfer", args: ["to", "amount"] }, "095ea7b3": { name: "approve", args: ["spender", "amount"] }, "23b872dd": { name: "transferFrom", args: ["from", "to", "amount"] }, "39509351": { name: "increaseAllowance", args: ["spender", "amount"] }, }; const UINT256_MAX = (1n << 256n) - 1n; module.exports = function makeTronDecode({ sha256, base58check }) { const hexToBytes = (h) => { const s = String(h || "").replace(/^0x/i, ""); if (!/^[0-9a-f]*$/i.test(s) || s.length % 2) throw new Error("raw_data_hex is not hex"); 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; }; const bytesToHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); // Split a message into { field, wire, value } records. Varints come back // as BigInt, length-delimited fields as Uint8Array views. function readFields(bytes) { const out = []; let off = 0; const varint = () => { let v = 0n, shift = 0n; for (;;) { if (off >= bytes.length) throw new Error("truncated varint"); const b = bytes[off++]; v |= BigInt(b & 0x7f) << shift; if ((b & 0x80) === 0) return v; shift += 7n; if (shift > 70n) throw new Error("varint too long"); } }; while (off < bytes.length) { const key = varint(); const field = Number(key >> 3n), wire = Number(key & 7n); if (wire === 0) out.push({ field, wire, value: varint() }); else if (wire === 1) { if (off + 8 > bytes.length) throw new Error("truncated fixed64"); out.push({ field, wire, value: bytes.subarray(off, off + 8) }); off += 8; } else if (wire === 2) { const n = Number(varint()); if (off + n > bytes.length) throw new Error("truncated bytes"); out.push({ field, wire, value: bytes.subarray(off, off + n) }); off += n; } else if (wire === 5) { if (off + 4 > bytes.length) throw new Error("truncated fixed32"); out.push({ field, wire, value: bytes.subarray(off, off + 4) }); off += 4; } else throw new Error("unsupported wire type " + wire); } return out; } const one = (fields, n) => fields.find((f) => f.field === n); const bytesOf = (fields, n) => { const f = one(fields, n); return f && f.wire === 2 ? f.value : null; }; const numOf = (fields, n) => { const f = one(fields, n); return f && f.wire === 0 ? f.value : null; }; // 21-byte Tron address (0x41 || h20) → "T…" base58check. Anything else is // returned as hex so the overlay never hides a malformed field. function addr(b) { if (!b) return null; if (b.length === 21 && b[0] === 0x41) { try { return base58check.encodeCheck(b); } catch {} } return "0x" + bytesToHex(b); } // EVM-style 32-byte word → Tron address (last 20 bytes, 0x41 prefix). function wordToAddr(word) { const out = new Uint8Array(21); out[0] = 0x41; out.set(word.subarray(12, 32), 1); return addr(out); } const wordToBig = (word) => { let v = 0n; for (const x of word) v = (v << 8n) | BigInt(x); return v; }; function decodeTrc20(data) { if (!data || data.length < 4) return null; const sel = bytesToHex(data.subarray(0, 4)); const spec = TRC20_SELECTORS[sel]; if (!spec) return { selector: sel, name: null }; const words = []; for (let i = 4; i + 32 <= data.length; i += 32) words.push(data.subarray(i, i + 32)); if (words.length < spec.args.length) return { selector: sel, name: spec.name, malformed: true }; const out = { selector: sel, name: spec.name }; spec.args.forEach((a, i) => { out[a] = a === "amount" ? wordToBig(words[i]) : wordToAddr(words[i]); }); if (out.amount != null) out.unlimited = out.amount >= (1n << 255n) || out.amount === UINT256_MAX; return out; } function decodeContract(fields) { const typeNum = Number(numOf(fields, 1) ?? 0n); const typeName = CONTRACT_TYPES[typeNum] || `Contract#${typeNum}`; const any = bytesOf(fields, 2); const c = { type: typeNum, typeName, dangerous: DANGEROUS_TYPES.has(typeNum), owner: null, permissionId: Number(numOf(fields, 5) ?? 0n) }; if (!any) return c; const anyFields = readFields(any); const value = bytesOf(anyFields, 2); if (!value) return c; const vf = readFields(value); if (typeNum === 1) { // TransferContract c.owner = addr(bytesOf(vf, 1)); c.to = addr(bytesOf(vf, 2)); c.amount = numOf(vf, 3) ?? 0n; } else if (typeNum === 2) { // TransferAssetContract const name = bytesOf(vf, 1); c.assetName = name ? Buffer.from(name).toString("utf8") : null; c.owner = addr(bytesOf(vf, 2)); c.to = addr(bytesOf(vf, 3)); c.amount = numOf(vf, 4) ?? 0n; } else if (typeNum === 31) { // TriggerSmartContract c.owner = addr(bytesOf(vf, 1)); c.contract = addr(bytesOf(vf, 2)); c.callValue = numOf(vf, 3) ?? 0n; const data = bytesOf(vf, 4); c.data = data ? bytesToHex(data) : ""; c.call = decodeTrc20(data); } else { c.owner = addr(bytesOf(vf, 1)); } return c; } // Full decode. Throws on malformed input so the caller refuses to sign. function decodeRawData(rawDataHex) { const bytes = hexToBytes(rawDataHex); if (!bytes.length) throw new Error("raw_data_hex is empty"); const fields = readFields(bytes); const contracts = fields.filter((f) => f.field === 11 && f.wire === 2).map((f) => decodeContract(readFields(f.value))); if (!contracts.length) throw new Error("raw_data_hex carries no contract"); const memo = bytesOf(fields, 10); return { txid: bytesToHex(sha256(bytes)), contracts, expiration: Number(numOf(fields, 8) ?? 0n), timestamp: Number(numOf(fields, 14) ?? 0n), feeLimit: numOf(fields, 18) ?? null, memo: memo ? Buffer.from(memo).toString("utf8") : null, }; } return { decodeRawData, decodeTrc20, readFields, CONTRACT_TYPES }; };