Solana: a ComputeBudget price the site added was paid on top of the base fee but shown as "program ComputeB...: not decoded", so a transaction could burn the balance in priority fees behind a plain Sign button. The maximum network fee is now a row, and Assign, durable nonces, Approve/ApproveChecked, SetAuthority, closing a token account to someone else and very high fees get a warning and the danger button. signAndSend also requires one signature slot per required signer. Ethereum: only allowances of 2^255 and up counted as unlimited; 2^96 and up now does, and Permit2 approve, increaseApproval, NFT safeTransferFrom and multicall are decoded. The estimate shown was gas x the node's price even when the site set a far higher tip, which is what is actually paid; it now uses base fee + the real tip (or the full legacy gasPrice) and warns when the site's fee is far above the network's or a fifth of the balance. A personal_sign over 32 raw bytes is a hash a Safe or an order book will take as approval of something unseen, so it gets the danger button and the PIN. Tron: TRC10 sent along with a contract call (call_token_value) was never read, contract types Aegis does not decode were shown by name as if harmless, a truncated TRC20 call rendered as "undefined", and the validity window was hidden. Those are now shown, flagged or refused; the TronGrid draft check also refuses a memo, a permission id or an expiration more than a day away.
184 lines
9.6 KiB
JavaScript
184 lines
9.6 KiB
JavaScript
// 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;
|
|
}
|
|
// A singular field that appears twice is legal protobuf: the parser keeps
|
|
// the LAST copy (and merges repeated sub-messages). java-tron does that, so
|
|
// reading the first copy would show the user one recipient and amount while
|
|
// the chain executes another — and Any.value is opaque bytes, so the signed
|
|
// hash is the same either way. Refuse instead of guessing, and refuse a
|
|
// known field sent with the wrong wire type for the same reason.
|
|
const one = (fields, n, wire) => {
|
|
const hits = fields.filter((f) => f.field === n);
|
|
if (hits.length > 1) throw new Error(`field ${n} appears ${hits.length} times; refusing an ambiguous transaction`);
|
|
if (hits[0] && hits[0].wire !== wire) throw new Error(`field ${n} has wire type ${hits[0].wire}, expected ${wire}`);
|
|
return hits[0];
|
|
};
|
|
const bytesOf = (fields, n) => { const f = one(fields, n, 2); return f ? f.value : null; };
|
|
const numOf = (fields, n) => { const f = one(fields, n, 0); return f ? 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;
|
|
// An allowance of 2^96 base units or more is unlimited in all but name.
|
|
if (out.amount != null && /^(approve|increaseAllowance)$/.test(out.name) && out.amount >= (1n << 96n)) out.unlimited = true;
|
|
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);
|
|
// TRC10 tokens sent along with the call (call_token_value / token_id).
|
|
// These were never read, so a call could carry the user's TRC10
|
|
// balance to the contract while the overlay showed only the TRC20 call.
|
|
c.tokenValue = numOf(vf, 5) ?? 0n;
|
|
c.tokenId = numOf(vf, 6) ?? null;
|
|
} else {
|
|
c.owner = addr(bytesOf(vf, 1));
|
|
// Everything else is shown by name only; the caller treats it as
|
|
// something Aegis could not read.
|
|
c.undecoded = true;
|
|
}
|
|
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 };
|
|
};
|