SPB Git

Repository Public

tron.ts 11.2 KB 192 lines typescript

1// TRON family plugin: deterministic `Transaction.raw` protobuf encoder for exactly two shapes —2// a native TRX transfer (TransferContract) and an allowlisted TRC-20 `transfer(address,uint256)` (TriggerSmartContract).3// Pure module: no network access. The encoding is proven byte-identical to tronweb (tests) and to java-tron full nodes4// (scripts/smoke-tron.mts). Protobuf layout: https://github.com/tronprotocol/protocol/blob/master/core/Tron.proto5import { secp256k1 } from '@noble/curves/secp256k1.js';6import { sha256 } from '@noble/hashes/sha2.js';7import { keccak_256 } from '@noble/hashes/sha3.js';8import { createBase58check } from '@scure/base';9import { baseUnits } from '../amounts.ts';10import type { FamilyPlugin, KeyHandle, Signed } from '../families.ts';11import type { Intent } from '../intent.ts';12import type { NetworkDef } from '../networks.ts';1314const b58c = createBase58check(sha256);15const hex = (b: Uint8Array) => Buffer.from(b).toString('hex');16const unhex = (s: string) => Uint8Array.from(Buffer.from(s, 'hex'));17const enc = new TextEncoder();1819/** Bandwidth burn price (chain parameter getTransactionFee), sun per byte. The API adapter refuses to prepare if the chain disagrees. */20export const TRON_BANDWIDTH_PRICE = 1000n;21/** java-tron adds this to the serialized size when charging bandwidth (Constant.MAX_RESULT_SIZE_IN_TX). */22export const TRON_RESULT_OVERHEAD = 64;23/** Burned by the sender when a TRX transfer creates the destination account (getCreateNewAccountFeeInSystemContract). */24export const TRON_ACTIVATION_FEE = 1_000_000n;25/** Added to the activation fee when the sender has no staked bandwidth for the creating transaction (getCreateAccountFee). */26export const TRON_CREATE_ACCOUNT_BANDWIDTH_FEE = 100_000n;27/** Protocol ceiling for fee_limit (getMaxFeeLimit), in sun. */28export const TRON_MAX_FEE_LIMIT = 15_000_000_000n;29const INT64_MAX = (1n << 63n) - 1n;30const DAY_MS = 86_400_000n;31const SKEW_MS = 600_000n; // tolerated distance between the chain timestamps and the approval window32const TRANSFER_SELECTOR = 'a9059cbb'; // keccak256("transfer(address,uint256)")[0:4]33const X_NATIVE = ['expiration', 'ref_block_bytes', 'ref_block_hash', 'timestamp'];34const X_TOKEN = [...X_NATIVE, 'fee_limit'].sort();3536/** base58check address -> 21 bytes (0x41 || 20-byte account id). Throws unless it is the canonical mainnet form. */37export function tronAddressBytes(addr: string): Uint8Array {38  if (!/^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(addr)) throw new Error('invalid TRON address');39  let raw: Uint8Array;40  try { raw = b58c.decode(addr); } catch { throw new Error('invalid TRON address checksum'); }41  if (raw.length !== 21 || raw[0] !== 0x41) throw new Error('invalid TRON address');42  if (b58c.encode(raw) !== addr) throw new Error('non-canonical TRON address');43  if (raw.subarray(1).every((b) => b === 0)) throw new Error('zero address');44  return raw;45}46export const tronAddressFromBytes = (raw21: Uint8Array): string => {47  if (raw21.length !== 21 || raw21[0] !== 0x41) throw new Error('invalid TRON address bytes');48  return b58c.encode(raw21);49};50/** 33-byte compressed (or 65-byte uncompressed) secp256k1 key -> address: base58check(0x41 || keccak256(X||Y)[12:]). */51export function tronAddressFromPublicKey(publicKey: Uint8Array): string {52  const un = secp256k1.Point.fromBytes(publicKey).toBytes(false); // 0x04 || X || Y53  const raw = new Uint8Array(21);54  raw[0] = 0x41;55  raw.set(keccak_256(un.subarray(1)).subarray(12), 1);56  return b58c.encode(raw);57}5859// ---- minimal protobuf writer (proto3: varint and length-delimited fields only) ----60function varint(n: bigint): number[] {61  if (n < 0n || n > INT64_MAX) throw new Error('int64 out of range');62  const out: number[] = [];63  for (;;) { const b = Number(n & 0x7fn); n >>= 7n; if (n === 0n) { out.push(b); return out; } out.push(b | 0x80); }64}65const cat = (...a: (Uint8Array | number[])[]): Uint8Array => {66  const out = new Uint8Array(a.reduce((n, p) => n + p.length, 0));67  let o = 0;68  for (const p of a) { out.set(p, o); o += p.length; }69  return out;70};71const fVarint = (field: number, v: bigint) => cat(varint(BigInt(field << 3)), varint(v));72const fBytes = (field: number, v: Uint8Array) => cat(varint(BigInt((field << 3) | 2)), varint(BigInt(v.length)), v);7374/** ABI call data of `transfer(address,uint256)`: selector || 32-byte address word || 32-byte amount word. */75export function trc20TransferData(to: string, amount: bigint): Uint8Array {76  if (amount <= 0n || amount >= 1n << 256n) throw new Error('amount out of range');77  const data = new Uint8Array(68);78  data.set(unhex(TRANSFER_SELECTOR), 0);79  data.set(tronAddressBytes(to).subarray(1), 16);80  data.set(unhex(amount.toString(16).padStart(64, '0')), 36);81  return data;82}8384export interface TronTx {85  kind: 'TransferContract' | 'TriggerSmartContract';86  /** serialized Transaction.raw */87  rawData: Uint8Array;88  /** sha256(rawData) — the transaction id and the digest that is signed */89  txid: Uint8Array;90  /** fee_limit in sun (0 for a native transfer) */91  feeLimit: bigint;92  /** bytes charged as bandwidth once signed with one 65-byte signature (serialized Transaction + 64) */93  bandwidthBytes: number;94  /** most the transaction itself can burn: fee_limit + bandwidth paid in TRX (activation of the recipient not included) */95  worstFee: bigint;96}9798const dec = (v: string | undefined, name: string): bigint => {99  if (v === undefined || !/^(0|[1-9]\d{0,18})$/.test(v)) throw new Error(`x.${name} must be a decimal integer`);100  const n = BigInt(v);101  if (n > INT64_MAX) throw new Error(`x.${name} out of range`);102  return n;103};104105/** Rebuild the exact `Transaction.raw` bytes from an intent. Throws on anything that is not one of the two supported shapes. */106export function buildTronTx(i: Intent, net: NetworkDef): TronTx {107  if (net.family !== 'tron' || i.network !== net.id) throw new Error('not a TRON intent');108  if (i.chainRef !== net.chainRef) throw new Error('chain identity mismatch (genesis block id)');109  if (i.evm || i.btc || i.sol) throw new Error('foreign chain section in a TRON intent');110  if (i.memo !== undefined) throw new Error('memo is not supported on TRON transfers');111  if (!i.x) throw new Error('missing TRON parameters');112  const asset = i.asset === 'native' ? net.native : net.tokens.find((t) => t.id === i.asset);113  if (!asset) throw new Error(`asset not allowlisted on ${net.id}: ${i.asset}`);114  if (asset.decimals !== i.decimals || asset.symbol !== i.symbol) throw new Error('asset metadata mismatch');115  const native = asset.id === 'native';116117  const keys = Object.keys(i.x).sort();118  const want = native ? X_NATIVE : X_TOKEN;119  if (keys.length !== want.length || keys.some((k, n) => k !== want[n])) throw new Error(`TRON parameters must be exactly: ${want.join(', ')}`);120  const x = i.x as Record<string, string>;121  if (!/^[0-9a-f]{4}$/.test(x.ref_block_bytes!)) throw new Error('x.ref_block_bytes must be 2 bytes of lowercase hex');122  if (!/^[0-9a-f]{16}$/.test(x.ref_block_hash!)) throw new Error('x.ref_block_hash must be 8 bytes of lowercase hex');123  const expiration = dec(x.expiration, 'expiration'), timestamp = dec(x.timestamp, 'timestamp');124  if (timestamp <= 0n || expiration <= timestamp) throw new Error('expiration must be after timestamp');125  if (expiration - timestamp >= DAY_MS) throw new Error('expiration too far (the protocol allows less than 24 h)');126  // the signed transaction must not outlive the approval window by more than the tolerated clock skew127  const created = BigInt(i.createdAt) * 1000n, expires = BigInt(i.expiresAt) * 1000n;128  if (timestamp < created - SKEW_MS || timestamp > created + SKEW_MS) throw new Error('timestamp outside the approval window');129  if (expiration > expires + SKEW_MS) throw new Error('expiration outlives the approval window');130131  const from = tronAddressBytes(i.from), to = tronAddressBytes(i.to);132  if (i.from === i.to) throw new Error('source equals destination');133  if (net.tokens.some((t) => t.id === i.to)) throw new Error('destination is a token contract');134  const amount = baseUnits(i.amount);135  if (amount <= 0n) throw new Error('amount must be positive');136137  let type: number, typeName: TronTx['kind'], param: Uint8Array, feeLimit = 0n;138  if (native) {139    if (amount > INT64_MAX) throw new Error('amount out of range');140    type = 1; typeName = 'TransferContract';141    param = cat(fBytes(1, from), fBytes(2, to), fVarint(3, amount));142  } else {143    feeLimit = dec(x.fee_limit, 'fee_limit');144    if (feeLimit <= 0n || feeLimit > TRON_MAX_FEE_LIMIT) throw new Error('fee_limit out of range');145    type = 31; typeName = 'TriggerSmartContract';146    // owner, contract, (call_value = 0 omitted), data — never any TRX or TRC-10 value attached147    param = cat(fBytes(1, from), fBytes(2, tronAddressBytes(asset.id)), fBytes(4, trc20TransferData(i.to, amount)));148  }149  const any = cat(fBytes(1, enc.encode(`type.googleapis.com/protocol.${typeName}`)), fBytes(2, param));150  const contract = cat(fVarint(1, BigInt(type)), fBytes(2, any));151  const rawData = cat(152    fBytes(1, unhex(x.ref_block_bytes!)), fBytes(4, unhex(x.ref_block_hash!)), fVarint(8, expiration),153    fBytes(11, contract), fVarint(14, timestamp), native ? [] : fVarint(18, feeLimit),154  );155  const bandwidthBytes = tronWire(rawData, new Uint8Array(65)).length + TRON_RESULT_OVERHEAD;156  const worstFee = feeLimit + BigInt(bandwidthBytes) * TRON_BANDWIDTH_PRICE;157  if (worstFee > baseUnits(i.maxFee)) throw new Error('fee exceeds approved maximum');158  return { kind: typeName, rawData, txid: sha256(rawData), feeLimit, bandwidthBytes, worstFee };159}160161/** Serialized `Transaction`: field 1 = raw_data, field 2 = one 65-byte signature (r || s || v). This is what /wallet/broadcasthex takes. */162export function tronWire(rawData: Uint8Array, signature: Uint8Array): Uint8Array {163  if (signature.length !== 65) throw new Error('bad signature length');164  return cat(fBytes(1, rawData), fBytes(2, signature));165}166167export const tronPlugin: FamilyPlugin = {168  family: 'tron',169  curve: 'secp256k1',170  path: (net) => `m/44'/${net.coinType ?? 195}'/0'/0/0`,171  address: (_net, publicKey) => tronAddressFromPublicKey(publicKey),172  normalizeAddress: (_net, addr) => tronAddressFromBytes(tronAddressBytes(addr.trim())),173  build(i, net) {174    const t = buildTronTx(i, net);175    return { signDocs: [t.txid], describe: {176      kind: t.kind, from: i.from, to: i.to, asset: i.asset, amount: i.amount, txid: hex(t.txid), rawDataHex: hex(t.rawData),177      feeLimit: t.feeLimit.toString(), bandwidthBytes: String(t.bandwidthBytes), worstFee: t.worstFee.toString(),178    } };179  },180  sign(i: Intent, net: NetworkDef, key: KeyHandle): Signed {181    const t = buildTronTx(i, net);182    if (key.curve !== 'secp256k1' || !key.signDigest) throw new Error('TRON needs a secp256k1 key');183    if (tronAddressFromPublicKey(key.publicKey) !== i.from) throw new Error('source address is not controlled by this key');184    const s = key.signDigest(t.txid);185    if (s.compact.length !== 64 || (s.recovery !== 0 && s.recovery !== 1)) throw new Error('unexpected signature shape');186    // never release a signature that does not verify against our own public key187    if (!secp256k1.verify(s.compact, t.txid, key.publicKey, { prehash: false, lowS: true })) throw new Error('signature self-check failed');188    const sig = cat(s.compact, [27 + s.recovery]);189    return { raw: hex(tronWire(t.rawData, sig)), txid: hex(t.txid) };190  },191};192
Files main
Menu

Theme