theseus/bundled-addons/aegis/lib/tron-decode.js

164 lines
8.3 KiB
JavaScript
Raw Normal View History

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