/** * Minimal TRON utilities to replace TronWeb dependency * Covers: message signing support (mock-tx workaround), address conversion, * and full-node HTTP wrappers for building and broadcasting real transactions. */ // --------------------------------------------------------------------------- // Address conversion — base58check ↔ hex, dependency-free. // // Tron addresses have two on-wire forms: // base58check (`T…`): Base58Check of `0x41 ‖ 20-byte body ‖ 4-byte checksum` // hex (`41` + 40 hex chars): the 21-byte payload // // Full-node build calls use hex form (`visible: false`) so strict wallet signers // (Trust Wallet wallet-core WASM) accept the resulting raw_data object; base58 // addresses in raw_data cause those signers to abort silently. // --------------------------------------------------------------------------- const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' function base58DecodeBytes(input: string): Uint8Array { if (input.length === 0) throw new Error('tron: empty address') let value = 0n for (const char of input) { const index = BASE58_ALPHABET.indexOf(char) if (index === -1) throw new Error(`tron: invalid base58 character "${char}"`) value = value * 58n + BigInt(index) } const bytes: number[] = [] while (value > 0n) { bytes.unshift(Number(value & 0xffn)) value >>= 8n } for (const char of input) { if (char !== '1') break bytes.unshift(0) } return Uint8Array.from(bytes) } /** True when `s` is already in 42-char hex form (`41` + 40 hex digits). */ function isHexTronAddress(s: string): boolean { if (s.length !== 42) return false // First two chars must be '4' (0x34) and '1' (0x31) if (s.charCodeAt(0) !== 52 || s.charCodeAt(1) !== 49) return false for (let i = 2; i < 42; i++) { const c = s.charCodeAt(i) if ( !((c >= 48 && c <= 57) || (c >= 97 && c <= 102) || (c >= 65 && c <= 70)) ) return false } return true } /** Convert a base58 Tron address to its 42-char lowercase hex form (`41…`). */ export function tronAddressToHex(address: string): string { const stripped = address.startsWith('0x') ? address.slice(2) : address if (isHexTronAddress(stripped)) return stripped.toLowerCase() const decoded = base58DecodeBytes(address) if (decoded.length !== 25) throw new Error( `tron: expected 25 bytes after base58 decode, got ${decoded.length}` ) const payload = decoded.subarray(0, 21) if (payload[0] !== 0x41) throw new Error('tron: missing 0x41 address prefix') let out = '' for (const byte of payload) out += byte.toString(16).padStart(2, '0') return out } /** 20-byte EVM body (no 0x41 prefix) — used for TRC20 ABI parameters. */ function tronAddressToEvmHex(address: string): string { return tronAddressToHex(address).slice(2) } /** * Verify the 4-byte Base58Check checksum of a Tron address. * Hex addresses pass through unchecked (no checksum embedded). * Matters for TRC20/native recipients: a typo'd base58 address can survive * decode and become a valid-looking but wrong hex destination on-chain. */ async function assertValidTronChecksum(address: string): Promise { const stripped = address.startsWith('0x') ? address.slice(2) : address if (isHexTronAddress(stripped)) return // hex: no checksum to verify const decoded = base58DecodeBytes(address) if (decoded.length !== 25) throw new Error( `tron: expected 25 bytes after base58 decode, got ${decoded.length}` ) const payload = decoded.subarray(0, 21) if (payload[0] !== 0x41) throw new Error('tron: missing 0x41 address prefix') const expected = decoded.subarray(21, 25) const sha256 = async (b: Uint8Array): Promise => new Uint8Array(await crypto.subtle.digest('SHA-256', Uint8Array.from(b))) const round2 = await sha256(await sha256(payload)) for (let i = 0; i < 4; i++) { if (round2[i] !== expected[i]) throw new Error( 'tron: base58 checksum mismatch (possible typo in recipient address)' ) } } /** ABI-encode args for `transfer(address,uint256)`. */ function encodeTrc20Params(to: string, amount: number | bigint): string { const addressWord = tronAddressToEvmHex(to).padStart(64, '0') const amountWord = BigInt(amount).toString(16).padStart(64, '0') return addressWord + amountWord } /** * TronGrid often returns `message` as hex-encoded ASCII. Decode when it looks * like hex AND the result is printable ASCII; otherwise pass through unchanged. */ function decodeHexMessage(message: string | undefined): string { if (!message) return '' if (!/^[0-9a-fA-F]+$/.test(message) || message.length % 2 !== 0) { return message } let decoded = '' for (let i = 0; i < message.length; i += 2) { decoded += String.fromCharCode(parseInt(message.slice(i, i + 2), 16)) } return /^[\x20-\x7e\t\n\r]*$/.test(decoded) ? decoded : message } // --------------------------------------------------------------------------- // TronGrid full-node HTTP wrappers // --------------------------------------------------------------------------- const TRON_ENDPOINTS: Record = { 'tron:0x2b6653dc': 'https://api.trongrid.io', // Mainnet 'tron:0x94a9059e': 'https://api.shasta.trongrid.io', // Shasta testnet 'tron:0xcd8690dc': 'https://nile.trongrid.io' // Nile testnet } const DEFAULT_FEE_LIMIT_SUN = 100_000_000 // 100 TRX function tronGridBaseUrl(networkId: string): string { const url = TRON_ENDPOINTS[networkId] if (!url) throw new Error(`tron-wc: no full-node endpoint for network "${networkId}"`) return url } async function tronGridPost( networkId: string, path: string, body: unknown, timeoutMs?: number ): Promise { const url = `${tronGridBaseUrl(networkId)}${path}` const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), // `fetch` has no default timeout, so an unresponsive node would otherwise // hang the call indefinitely. Two kinds of caller bound it, for different // reasons: a lookup, where abandoning an idempotent read costs nothing // (LOOKUP_TIMEOUT_MS), and a broadcast, where abandoning one is NOT safe on // its own — it leaves the outcome ambiguous, and is only bounded because // the caller resolves that ambiguity against the chain // (BROADCAST_TIMEOUT_MS). Don't add a third without knowing which case it // is in. ...(timeoutMs !== undefined ? { signal: AbortSignal.timeout(timeoutMs) } : {}) }) if (!response.ok) { throw new Error( `tron-wc: ${path} responded ${response.status} ${response.statusText}` ) } return (await response.json()) as T } /** * Minimal shape of an unsigned Tron transaction. * Only txID + raw_data* are load-bearing; the rest passes through opaquely * to whatever the wallet returns after signing. */ export interface UnsignedTronTx { txID?: string raw_data?: unknown raw_data_hex?: string visible?: boolean [key: string]: unknown } /** Build an unsigned native TRX transfer. Amount in SUN. */ export async function buildNativeTronTx( networkId: string, params: { from: string; to: string; amount: number | bigint } ): Promise { const amountNum = Number(params.amount) if (!Number.isSafeInteger(amountNum) || amountNum < 0) { throw new Error( `tron-wc: native transfer amount must be a non-negative safe integer (got ${params.amount})` ) } await assertValidTronChecksum(params.to) const tx = await tronGridPost( networkId, '/wallet/createtransaction', { owner_address: tronAddressToHex(params.from), to_address: tronAddressToHex(params.to), // Node expects a JSON number; native amounts are safe-integers on this path. amount: amountNum, visible: false } ) if (tx.Error) throw new Error(`tron-wc: createtransaction failed — ${tx.Error}`) if (!tx.txID) throw new Error('tron-wc: createtransaction returned no txID') return tx } /** Build an unsigned TRC20 `transfer(address,uint256)` transaction. */ export async function buildTrc20TronTx( networkId: string, params: { from: string to: string amount: number | bigint contractAddress: string } ): Promise { if (typeof params.amount === 'number') { if (!Number.isSafeInteger(params.amount) || params.amount < 0) throw new Error( `tron-wc: TRC20 transfer amount must be a non-negative safe integer (got ${params.amount})` ) } else if (params.amount < 0n) { throw new Error( `tron-wc: TRC20 transfer amount must be non-negative (got ${params.amount})` ) } await assertValidTronChecksum(params.to) await assertValidTronChecksum(params.contractAddress) const response = await tronGridPost<{ result?: { result?: boolean; code?: string; message?: string } transaction?: UnsignedTronTx }>(networkId, '/wallet/triggersmartcontract', { owner_address: tronAddressToHex(params.from), contract_address: tronAddressToHex(params.contractAddress), function_selector: 'transfer(address,uint256)', parameter: encodeTrc20Params(params.to, params.amount), fee_limit: DEFAULT_FEE_LIMIT_SUN, call_value: 0, visible: false }) if (!response.result?.result || !response.transaction?.txID) { throw new Error( decodeHexMessage(response.result?.message) || response.result?.code || 'tron-wc: triggersmartcontract failed' ) } return response.transaction } /** * Build an unsigned `TriggerSmartContract` from pre-encoded calldata. * Used for bridging deposits where the calldata must be forwarded verbatim * (e.g. Relay request-id tag appended after ABI args — see TronContractCallRequest). */ export async function buildContractCallTronTx( networkId: string, params: { from: string contractAddress: string data: string /** SUN to attach for a payable call (a router's `depositNative` takes no * amount argument). Omitted/0 keeps the non-payable behaviour. */ callValue?: number | bigint } ): Promise { await assertValidTronChecksum(params.contractAddress) // Same guard the native path applies: `call_value` is serialized as a JSON // number, so a value past the safe-integer range would lose precision in the // conversion below and broadcast the wrong amount — silently, since a payable // call with the wrong value still succeeds on-chain. const callValue = Number(params.callValue ?? 0) if (!Number.isSafeInteger(callValue) || callValue < 0) { throw new Error( `tron-wc: contract-call callValue must be a non-negative safe integer (got ${params.callValue})` ) } const data = params.data.replace(/^0x/i, '').toLowerCase() if (data.length < 8) throw new Error( 'tron-wc: contract-call data too short — need at least a 4-byte selector' ) if (data.length % 2 !== 0 || !/^[0-9a-f]+$/.test(data)) throw new Error('tron-wc: contract-call data is not valid hex') const response = await tronGridPost<{ result?: { result?: boolean; code?: string; message?: string } transaction?: UnsignedTronTx }>(networkId, '/wallet/triggersmartcontract', { owner_address: tronAddressToHex(params.from), contract_address: tronAddressToHex(params.contractAddress), data, fee_limit: DEFAULT_FEE_LIMIT_SUN, call_value: callValue, visible: false }) if (!response.result?.result || !response.transaction?.txID) { throw new Error( decodeHexMessage(response.result?.message) || response.result?.code || 'tron-wc: triggersmartcontract (contract-call) failed' ) } return response.transaction } /** How long to keep asking before concluding a transaction never landed. * * It has to be an elapsed-time budget, not an attempt count: a miss answers * immediately (~280ms), so three attempts with backoff cover barely three * seconds. `gettransactionbyid` reads from blocks rather than the pending * pool, and the wallet may have broadcast through a node other than the one we * query — on the device run the transaction only became readable 16.3s after * the wallet reported it out. A budget shorter than that cannot catch the case * this exists for, and a probe that reports "never landed" too early is worse * than none: it turns a completed transfer into a confident failure. * * Only ever spent on an error path, after the user has already confirmed. */ const LOOKUP_BUDGET_MS = 20_000 const LOOKUP_INTERVAL_MS = 2000 /** Hard bound on a single lookup, so a hung node cannot stall the budget above * — `fetch` has no timeout of its own. A lookup is an idempotent read, so * abandoning one costs nothing. */ const LOOKUP_TIMEOUT_MS = 5000 /** Bound on a broadcast. Waiting forever is not the safe option it looks like: * a node can accept the transaction and lose the response, and an unbounded * `fetch` then leaves the call pending indefinitely — the funds move and the * recovery probe never even runs. Timing out instead makes the outcome * ambiguous, which is a state the caller already knows how to settle by * asking the chain for the txID it built. */ const BROADCAST_TIMEOUT_MS = 15_000 const wait = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)) /** * Does the node actually hold this transaction? `gettransactionbyid` answers * `{}` for one it has never seen, so a returned `txID` is proof of acceptance * rather than an inference from an error code. * * Also the recovery probe after a failed sign: a wallet that broadcasts before * it answers leaves the transfer on-chain with nothing to tell us about it. */ export async function nodeHasTransaction( networkId: string, txid: string ): Promise { const deadline = Date.now() + LOOKUP_BUDGET_MS for (;;) { try { const found = await tronGridPost<{ txID?: string }>( networkId, '/wallet/gettransactionbyid', { value: txid }, LOOKUP_TIMEOUT_MS ) if (found?.txID === txid) return true } catch { // A lookup failure is not evidence either way: keep asking, then fail closed. } if (Date.now() + LOOKUP_INTERVAL_MS >= deadline) return false await wait(LOOKUP_INTERVAL_MS) } } /** Broadcast a signed Tron transaction. Returns the on-chain txid. */ export async function broadcastTronTx( networkId: string, signedTx: Record ): Promise { const response = await tronGridPost<{ result?: boolean txid?: string code?: string message?: string // A malformed body is reported under `Error`, not `message`/`code` — drop it // and every shape bug downstream surfaces as an unattributable "rejected". Error?: string transaction?: { txID?: string } }>(networkId, '/wallet/broadcasttransaction', signedTx, BROADCAST_TIMEOUT_MS) // Our own id is the reconciliation key: it is the hash of the `raw_data` we // posted, so unlike the echoed one it cannot be influenced by the response. // The node derives the same hash from the same body, so a divergent echo // would mean the body and the id disagree — not something to reconcile by // trusting the node's version. const ownTxId = signedTx['txID'] const txid = (typeof ownTxId === 'string' ? ownTxId : undefined) ?? response.txid ?? response.transaction?.txID // Every ambiguous outcome — a duplicate, a lost response, an unreadable // node — is settled in one place: the caller's recovery probe, which asks // the chain for the txID it built. Reconciling here as well would spend the // lookup budget twice over and double the traffic for one decision. // // A duplicate in particular must not be read as success on its own. That is // Tron's own guidance — "this error does not prove that the transaction is // on-chain … query the original transaction and receipt by txID" — because // java-tron returns the code both from `DupTransactionException` out of // `pushTransaction`, where the node really does hold it, and from a tx-id // cache written BEFORE validation, where an earlier attempt failed and left // its id behind. if (response.result !== true) { throw new Error( decodeHexMessage(response.message) || response.code || response.Error || 'tron-wc: broadcast rejected by node' ) } if (!txid) throw new Error('tron-wc: broadcast returned no txid') return txid } /** * Convert UTF-8 string to hex */ export function stringToHex(str: string): string { let hex = '' for (let i = 0; i < str.length; i++) { const charCode = str.charCodeAt(i) const hexValue = charCode.toString(16) hex += hexValue.padStart(2, '0') } return hex } /** * Trigger smart contract via TronGrid API * This creates a transaction structure without requiring the full TronWeb library */ export async function triggerSmartContract(params: { contractAddress: string functionSelector: string feeLimit: number parameters: Array<{ type: string; value: string | number }> ownerAddress: string }): Promise<{ transaction?: { raw_data: { contract: Array<{ parameter: { value: { data: string owner_address: string contract_address: string } type_url: string } type: string }> ref_block_bytes: string ref_block_hash: string expiration: number fee_limit: number timestamp: number data?: string } txID: string } result?: { result: boolean } transaction_id?: string }> { const { contractAddress, functionSelector, feeLimit, parameters, ownerAddress } = params try { // Call TronGrid API to generate transaction const response = await fetch( 'https://api.trongrid.io/wallet/triggersmartcontract', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contract_address: contractAddress, function_selector: functionSelector, fee_limit: feeLimit, parameter: parameters .map(p => { if (p.type === 'address') { // Convert base58 address to hex for API return { type: p.type, value: p.value } } return p }) .join(','), owner_address: ownerAddress, call_value: 0 }) } ) if (!response.ok) { throw new Error(`TronGrid API error: ${response.statusText}`) } const result = (await response.json()) as { transaction?: { raw_data: { contract: Array<{ parameter: { value: { data: string owner_address: string contract_address: string } type_url: string } type: string }> ref_block_bytes: string ref_block_hash: string expiration: number fee_limit: number timestamp: number data?: string } txID: string } result?: { result: boolean } transaction_id?: string } return result } catch (error) { // If API call fails, create a minimal mock structure // This ensures the code doesn't break even if TronGrid is down // eslint-disable-next-line no-console console.warn( 'TronGrid API call failed, using minimal mock transaction:', error ) return { transaction: { raw_data: { contract: [ { parameter: { value: { data: '', owner_address: ownerAddress, contract_address: contractAddress }, type_url: 'type.googleapis.com/protocol.TriggerSmartContract' }, type: 'TriggerSmartContract' } ], ref_block_bytes: '0000', ref_block_hash: '0000000000000000', expiration: Date.now() + 60000, fee_limit: feeLimit, timestamp: Date.now() }, txID: '0000000000000000000000000000000000000000000000000000000000000000' } } } }