import type { TronTransactionRequest, TronNativeTransferRequest, TronTRC20TransferRequest, TronContractCallRequest } from '@meshconnect/uwc-types' import type SignClient from '@walletconnect/sign-client' import type { SessionTypes } from '@walletconnect/types' import { TRON_WC_METHODS } from './tron-methods' import { buildNativeTronTx, buildTrc20TronTx, buildContractCallTronTx, broadcastTronTx, nodeHasTransaction, tronAddressToHex, type UnsignedTronTx } from '../../utils/tron-utils' import { parseError } from '../../utils/error-utils' /** * Session property a wallet sets to opt into the flat `tron_signTransaction` * payload. Absent (the default) means the legacy nested shape. * See https://docs.reown.com/advanced/multichain/rpc-reference/tron-rpc */ const TRON_METHOD_VERSION_PROPERTY = 'tron_method_version' const TRON_METHOD_VERSION_FLAT = 'v1' /** * Wallets answer `tron_signTransaction` in one of three shapes: the full signed * transaction, that transaction wrapped in `{ result }`, or — per the spec's own * example — the signature alone. Peel the wrapper so the caller only deals with * the innermost object. */ function unwrapWalletResult(result: object): Record { const record = result as Record const inner = record['result'] return inner !== null && typeof inner === 'object' ? (inner as Record) : record } /** * Normalize a wallet's signature to what a Tron full node accepts: an array of * unprefixed hex strings. Trust Wallet returns a single `0x`-prefixed string, * which the node rejects with a NullPointerException once it lands in a * broadcast body. */ function normalizeTronSignature(value: unknown): string[] { const raw = Array.isArray(value) ? value : [value] const signatures: string[] = [] for (const entry of raw) { if (typeof entry !== 'string') continue const hex = entry.replace(/^0x/i, '') if (hex.length > 0) signatures.push(hex) } return signatures } /** * `parseError` classifies by throwing, so capture its typed error instead — * the recovery path needs to read the classification before deciding whether * to rethrow, and the rethrow must stay the same typed error callers expect. */ function toConnectorError(error: unknown): unknown { try { parseError(error) } catch (typed) { return typed } return error } export class TronTransactionService { async sendTransaction( client: SignClient, session: SessionTypes.Struct, request: TronTransactionRequest, activeNetworkId: string, activeAddress: string ): Promise { // Hoisted so the recovery path can still reach the txID after a throw: it // is the hash of the raw_data we built, known before anyone signs anything. let builtTxId: string | undefined try { const unsignedTx = await this.buildTransaction( request, activeNetworkId, activeAddress ) builtTxId = typeof unsignedTx.txID === 'string' ? unsignedTx.txID : undefined // The wallet decides the payload shape, not us: sending the flat form to a // wallet that expects `transaction.transaction` leaves it with no // transaction to sign, and it answers with a signature over something we // never built — which then fails to broadcast, or worse, broadcasts wrong. const usesFlatPayload = session.sessionProperties?.[TRON_METHOD_VERSION_PROPERTY] === TRON_METHOD_VERSION_FLAT const params = { address: activeAddress, transaction: usesFlatPayload ? unsignedTx : { transaction: unsignedTx } } const result = await client.request({ topic: session.topic, chainId: activeNetworkId, request: { method: TRON_WC_METHODS.signTransaction, params } }) if (result === null || typeof result !== 'object') { throw new Error( `tron-wc: wallet returned unexpected result for tron_signTransaction (got ${result === null ? 'null' : typeof result})` ) } const walletResult = unwrapWalletResult(result) const signature = normalizeTronSignature(walletResult['signature']) if (signature.length === 0) { throw new Error( 'tron-wc: wallet returned no usable signature for tron_signTransaction' ) } // A wallet must sign the transaction it was handed. A different txID means // different raw_data — a different recipient or amount — so refuse to // broadcast it rather than relay whatever came back. const returnedTxId = walletResult['txID'] if ( typeof returnedTxId === 'string' && typeof unsignedTx.txID === 'string' && returnedTxId !== unsignedTx.txID ) { throw new Error( `tron-wc: wallet signed a different transaction (expected txID ${unsignedTx.txID}, got ${returnedTxId})` ) } // Nothing but the signature is taken from the wallet. The txID check above // only bites when a wallet volunteers one, so trusting a returned // `raw_data` would leave a hole exactly where it matters: a wallet could // echo our txID (or omit it) while returning raw_data for a different // recipient or amount, and the node validates the signature against // whatever raw_data it is handed. Broadcasting only what we built closes // that, and costs nothing — a wallet that signs the transaction it was // given produces an identical body anyway. const signedTx: Record = { ...unsignedTx, signature } return await broadcastTronTx(activeNetworkId, signedTx) } catch (error) { // Last line of defence against a silently completed transfer. A wallet // that broadcasts before it answers can leave the transaction on-chain // while we never receive a signature at all — Trust Wallet does exactly // this: it sends the transaction on the first Confirm, its energy-rental // callback then fails, and it only returns the WalletConnect response if // the user confirms a second time. Close that sheet instead and the funds // have moved with nothing to record them against. // // We know the txID before anyone signs, so ask the chain rather than // trust the error. Finding it can only turn a lost transfer into a // recorded one; not finding it changes nothing and the original error // stands. const typed = toConnectorError(error) if (builtTxId) { // The one place every ambiguous outcome is settled: an unanswered or // expired sign, a declined second prompt, a duplicate, a broadcast // whose response was lost. All of them reduce to the same question — // is the transaction we built on the chain? — so it is asked once, // against one budget. // // Rejections included. A decline is not evidence that nothing was // sent, and it is not evidence that anything sent is already visible // either: `gettransactionbyid` answers from blocks, not the pending // pool, and the wallet may have broadcast through a different node. On // the device run the transaction became readable 16s after the wallet // reported it out, so a single immediate lookup would report a landed // transfer as failed. // // A probe that cannot answer is inconclusive, never a new failure: its // own error must not displace the one the caller needs to see. let landed = false try { landed = await nodeHasTransaction(activeNetworkId, builtTxId) } catch { landed = false } if (landed) return builtTxId } throw typed } } private async buildTransaction( request: TronTransactionRequest, networkId: string, activeAddress: string ): Promise { if ('data' in request) { const r = request as TronContractCallRequest if (tronAddressToHex(r.from) !== tronAddressToHex(activeAddress)) { throw new Error('From address does not match connected wallet') } return buildContractCallTronTx(networkId, r) } if ('contractAddress' in request) { // TronTRC20TransferRequest const r = request as TronTRC20TransferRequest // Normalize before comparing — WC may return hex while the request uses base58. if (tronAddressToHex(r.from) !== tronAddressToHex(activeAddress)) { throw new Error('From address does not match connected wallet') } return buildTrc20TronTx(networkId, r) } // TronNativeTransferRequest const r = request as TronNativeTransferRequest if (tronAddressToHex(r.from) !== tronAddressToHex(activeAddress)) { throw new Error('From address does not match connected wallet') } return buildNativeTronTx(networkId, r) } }