diff --git a/.env b/.env index dacbf5ca7c3..be4d84da8b8 100644 --- a/.env +++ b/.env @@ -232,6 +232,7 @@ VITE_STARKNET_NODE_URL=https://rpc.starknet.lava.build VITE_STORY_NODE_URL=https://mainnet.storyrpc.io VITE_SUI_NODE_URL=https://fullnode.mainnet.sui.io:443 VITE_TON_NODE_URL=https://toncenter.com/api/v2/jsonRPC +VITE_TRON_GRID_API_KEY=17430894-392e-44e8-b015-4a9c4fe17546 VITE_TRON_NODE_URL=https://api.trongrid.io VITE_UNICHAIN_NODE_URL=https://mainnet.unichain.org VITE_WORLDCHAIN_NODE_URL=https://worldchain-mainnet.g.alchemy.com/public diff --git a/packages/chain-adapters/src/tron/TRON_FEE_ESTIMATION_ISSUES.md b/packages/chain-adapters/src/tron/TRON_FEE_ESTIMATION_ISSUES.md deleted file mode 100644 index 3278683fec0..00000000000 --- a/packages/chain-adapters/src/tron/TRON_FEE_ESTIMATION_ISSUES.md +++ /dev/null @@ -1,205 +0,0 @@ -# TRON Fee Estimation Issues & Findings - -## Critical Issue: Inaccurate Fee Estimation for TRC20 Tokens - -### Current Implementation Problems - -**File:** `packages/chain-adapters/src/tron/TronChainAdapter.ts:361-384` - -The `getFeeData()` method returns **FIXED fees of 0.268 TRX** for ALL transactions: - -```typescript -async getFeeData(_input: GetFeeDataInput) { - const { fast, average, slow, estimatedBandwidth } = await this.providers.http.getPriorityFees() - // getPriorityFees() returns FIXED 268,000 SUN (0.268 TRX) - // Ignores _input completely - doesn't check TRC20 vs TRX! -} -``` - -**File:** `packages/unchained-client/src/tron/api.ts:247-276` - -```typescript -async getPriorityFees() { - const estimatedBytes = 268 // FIXED value - const baseFee = String(estimatedBytes * bandwidthPrice) - // Returns same fee for TRX and TRC20! -} -``` - -### Real-World Costs - -| Transaction Type | getFeeData Returns | Actual Cost | Error Margin | -|-----------------|-------------------|-------------|--------------| -| TRX transfer | 0.268 TRX | 0.268 TRX | ✅ Correct | -| TRC20 transfer (no memo) | 0.268 TRX | **6.4-13 TRX** | ❌ 24-48x underestimate | -| TRC20 transfer (with memo) | 0.268 TRX | **8-15 TRX** | ❌ 30-56x underestimate | - -### Impact on Users - -1. **UI Shows Misleading Fees** - - User sees "~$0.05 fee" in UI - - Reality: ~$1.50-$3.00 fee - - Transaction broadcasts and fails on-chain - - User loses ~3-4 TRX in partial execution - -2. **Failed On-Chain Transactions** - - Example: `dcd71c73fb3de9d79d6d3ff78fb3da7a5b9b8fd1c3e72e0c7bf1badff9332a51` - - Result: `OUT_OF_ENERGY` - - Used 32,128 energy, paid 3.56 TRX, then failed - - Account started with 0.25 TRX, needed 7-8 TRX - -3. **Thorchain Swaps Fail** - - Memo adds 1 TRX fee (`getMemoFee` network parameter) - - User doesn't see this in fee preview - - Gets `BANDWITH_ERROR` (misleading - actually insufficient TRX for energy) - -## Cost Breakdown for TRC20 Transfers - -### Network Parameters (2025) -```json -{ - "getEnergyFee": 100, // 100 SUN per energy unit - "getTransactionFee": 1000, // 1,000 SUN per bandwidth byte - "getMemoFee": 1000000, // 1 TRX if raw_data.data present - "getFreeNetLimit": 600 // Daily free bandwidth -} -``` - -### TRC20 USDT Transfer Costs - -**Without Memo:** -- Energy: 64,000-130,000 units × 100 SUN = **6.4-13 TRX** -- Bandwidth: 345 bytes × 1,000 SUN = **0.345 TRX** -- **Total: 6.7-13.3 TRX** - -**With Memo (Thorchain):** -- Energy: 64,000-130,000 units × 100 SUN = **6.4-13 TRX** -- Bandwidth: 405 bytes × 1,000 SUN = **0.405 TRX** -- Memo fee: **1 TRX** (fixed network parameter) -- **Total: 7.8-14.4 TRX** - -*Energy cost varies based on recipient:* -- Has USDT balance: ~64k energy (~6.4 TRX) -- Empty USDT balance: ~130k energy (~13 TRX) - -## TODO: Required Improvements - -### 1. Fix getFeeData() to Estimate Real Costs - -**Unchained-client already has the methods!** - -File: `packages/unchained-client/src/tron/api.ts` -- ✅ `estimateTRC20TransferFee()` - Estimates energy for TRC20 (lines 217-245) -- ✅ `estimateFees()` - Estimates bandwidth for TRX (lines 203-215) -- ✅ `getChainPrices()` - Gets live energy/bandwidth prices (lines 188-201) - -**What needs to be done:** - -```typescript -async getFeeData(input: GetFeeDataInput) { - const { to, value, chainSpecific: { contractAddress, memo } = {} } = input - - let energyFee = 0 - let bandwidthFee = 0 - - if (contractAddress) { - // TRC20: Estimate energy - const feeEstimate = await this.providers.http.estimateTRC20TransferFee({ - contractAddress, - from: to, // placeholder - to, - amount: value, - }) - energyFee = Number(feeEstimate) - } - - // Build transaction to get accurate bandwidth - const tronWeb = new TronWeb({ fullHost: this.rpcUrl }) - let tx = contractAddress - ? await this.buildTRC20Tx(...) - : await tronWeb.transactionBuilder.sendTrx(to, value, to) - - if (memo) { - tx = await tronWeb.transactionBuilder.addUpdateData(tx, memo, 'utf8') - } - - // Calculate bandwidth - const txBytes = tx.raw_data_hex.length / 2 - const { bandwidthPrice } = await this.getChainPrices() - bandwidthFee = txBytes * bandwidthPrice - - // Add memo fee - const memoFee = memo ? 1_000_000 : 0 - - const totalFee = energyFee + bandwidthFee + memoFee - - return { - fast: { txFee: String(totalFee), chainSpecific: { bandwidth: String(txBytes) } }, - average: { txFee: String(totalFee), chainSpecific: { bandwidth: String(txBytes) } }, - slow: { txFee: String(totalFee), chainSpecific: { bandwidth: String(txBytes) } }, - } -} -``` - -### 2. Prevent Insufficient Balance Broadcasts - -Before broadcasting, check: -```typescript -const accountBalance = await this.getBalance(from) -const estimatedFee = await this.getFeeData(...) - -if (accountBalance < estimatedFee.fast.txFee) { - throw new Error( - `Insufficient TRX balance. Need ${estimatedFee.fast.txFee} SUN, have ${accountBalance} SUN` - ) -} -``` - -### 3. Better Error Messages - -Current: `"Account resource insufficient error"` (cryptic) - -Should be: -- `"Insufficient TRX for TRC20 transfer. Need ~8 TRX for energy costs, have 0.25 TRX"` -- `"Need 10-15 TRX for TRC20 swap with memo (energy + bandwidth + memo fee)"` - -### 4. UI Fee Display Improvements - -Show breakdown: -``` -Estimated Fees: - Energy: 6.4 TRX - Bandwidth: 0.4 TRX - Memo: 1 TRX - Total: ~7.8 TRX -``` - -## Evidence - -### Failed Transactions (Insufficient Balance) -- `dcd71c73fb3de9d79d6d3ff78fb3da7a5b9b8fd1c3e72e0c7bf1badff9332a51` - - Account: 0.25 TRX - - Paid 3.56 TRX in fees before failing - - Result: `OUT_OF_ENERGY` - -- `e7ffaf590ea20e715e1956438aa507c2916870afb95b54cdc054527ccd9246ab` - - Paid 3.81 TRX before failing - - Result: `OUT_OF_ENERGY` - -### Successful Transactions (Sufficient Balance) -- `5AAD9FD5501B860C1C38FB362D6D92212DEB328CC10BD24C18A5CD90CDD75320` - - Fee: 7.8 TRX - - Energy: 64,285 units - - Bandwidth: Covered by free daily - - Result: `SUCCESS` - -## References - -- TRON Resource Model: https://developers.tron.network/docs/resource-model -- TronWeb estimateEnergy: https://tronweb.network/docu/docs/API%20List/transactionBuilder/estimateEnergy/ -- SwapKit TRON implementation: https://github.com/swapkit/SwapKit/tree/develop/packages/toolboxes/src/tron -- Network Parameters: `getMemoFee: 1000000`, `getEnergyFee: 100`, `getTransactionFee: 1000` - -## Priority - -**HIGH** - Users are losing TRX on failed transactions due to inaccurate fee estimates. diff --git a/packages/chain-adapters/src/tron/TronChainAdapter.ts b/packages/chain-adapters/src/tron/TronChainAdapter.ts index 24fed48b05c..38fb201dc59 100644 --- a/packages/chain-adapters/src/tron/TronChainAdapter.ts +++ b/packages/chain-adapters/src/tron/TronChainAdapter.ts @@ -40,6 +40,7 @@ export interface ChainAdapterArgs { http: unchained.tron.TronApi } rpcUrl: string + apiKey?: string } export class ChainAdapter implements IChainAdapter { @@ -57,11 +58,13 @@ export class ChainAdapter implements IChainAdapter { } protected readonly rpcUrl: string + private readonly apiKey: string private requestQueue: PQueue constructor(args: ChainAdapterArgs) { this.providers = args.providers this.rpcUrl = args.rpcUrl + this.apiKey = args.apiKey ?? '' this.requestQueue = new PQueue({ intervalCap: 1, interval: 400, @@ -69,6 +72,10 @@ export class ChainAdapter implements IChainAdapter { }) } + private get tronGridHeaders(): Record { + return this.apiKey ? { 'TRON-PRO-API-KEY': this.apiKey } : {} + } + private assertSupportsChain(wallet: HDWallet): asserts wallet is TronWallet { if (!supportsTron(wallet)) { throw new ChainAdapterError(`wallet does not support: ${this.getDisplayName()}`, { @@ -192,6 +199,7 @@ export class ChainAdapter implements IChainAdapter { // Create TronWeb instance once and reuse const tronWeb = new TronWeb({ fullHost: this.rpcUrl, + headers: this.tronGridHeaders, }) let txData: TronUnsignedTx @@ -239,7 +247,7 @@ export class ChainAdapter implements IChainAdapter { () => fetch(`${this.rpcUrl}/wallet/createtransaction`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...this.tronGridHeaders }, body: JSON.stringify(requestBody), }), { throwOnTimeout: true }, @@ -324,7 +332,7 @@ export class ChainAdapter implements IChainAdapter { () => fetch(`${this.rpcUrl}/wallet/triggersmartcontract`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...this.tronGridHeaders }, body: JSON.stringify(requestBody), }), { throwOnTimeout: true }, @@ -456,22 +464,17 @@ export class ChainAdapter implements IChainAdapter { } } - // TODO: CRITICAL - Fix fee estimation for TRC20 tokens - // Current implementation returns FIXED 0.268 TRX for all transactions - // Reality: TRC20 transfers cost 6-15 TRX (energy + bandwidth + memo) - // This causes UI to show wrong fees and transactions to fail on-chain - // See TRON_FEE_ESTIMATION_ISSUES.md for detailed analysis and fix async getFeeData( input: GetFeeDataInput, ): Promise> { try { const { to, value, chainSpecific: { from, contractAddress, memo } = {} } = input - // Get live network prices from chain parameters - const tronWeb = new TronWeb({ fullHost: this.rpcUrl }) + const tronWeb = new TronWeb({ fullHost: this.rpcUrl, headers: this.tronGridHeaders }) const params = await this.requestQueue.add(() => tronWeb.trx.getChainParameters(), { throwOnTimeout: true, }) + const bandwidthPrice = params.find(p => p.key === 'getTransactionFee')?.value ?? 1000 const energyPrice = params.find(p => p.key === 'getEnergyFee')?.value ?? 100 @@ -540,7 +543,7 @@ export class ChainAdapter implements IChainAdapter { () => fetch(`${this.rpcUrl}/wallet/getaccount`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...this.tronGridHeaders }, body: JSON.stringify({ address: to, visible: true, @@ -731,7 +734,7 @@ export class ChainAdapter implements IChainAdapter { const TRANSFER_EVENT_SIGNATURE = 'ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' const ZERO_ADDRESS = 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb' - const tronWeb = new TronWeb({ fullHost: this.rpcUrl }) + const tronWeb = new TronWeb({ fullHost: this.rpcUrl, headers: this.tronGridHeaders }) for (const log of tx.log) { try { diff --git a/packages/public-api/src/config.ts b/packages/public-api/src/config.ts index 9b7d1c51174..20d15324e04 100644 --- a/packages/public-api/src/config.ts +++ b/packages/public-api/src/config.ts @@ -33,6 +33,7 @@ export const getServerConfig = (): SwapperConfig => ({ VITE_TENDERLY_API_KEY: env.TENDERLY_API_KEY, VITE_TENDERLY_ACCOUNT_SLUG: env.TENDERLY_ACCOUNT_SLUG, VITE_TENDERLY_PROJECT_SLUG: env.TENDERLY_PROJECT_SLUG, + VITE_TRON_GRID_API_KEY: env.TRON_GRID_API_KEY, VITE_SUI_NODE_URL: env.SUI_NODE_URL, VITE_ACROSS_API_URL: env.ACROSS_API_URL, VITE_ACROSS_INTEGRATOR_ID: env.ACROSS_INTEGRATOR_ID, diff --git a/packages/public-api/src/env.ts b/packages/public-api/src/env.ts index 1a58f60fda5..919553b3f20 100644 --- a/packages/public-api/src/env.ts +++ b/packages/public-api/src/env.ts @@ -84,6 +84,7 @@ const envSchema = z.object({ TENDERLY_API_KEY: z.string().min(1), TENDERLY_ACCOUNT_SLUG: z.string().min(1), TENDERLY_PROJECT_SLUG: z.string().min(1), + TRON_GRID_API_KEY: z.string().default(''), // Feature flags FEATURE_THORCHAINSWAP_LONGTAIL: flag, diff --git a/packages/public-api/src/swapperDeps.ts b/packages/public-api/src/swapperDeps.ts index 0ee1385b464..67e89c2367d 100644 --- a/packages/public-api/src/swapperDeps.ts +++ b/packages/public-api/src/swapperDeps.ts @@ -248,8 +248,14 @@ const solanaAdapter = new adapters.solana.ChainAdapter({ }) const tronAdapter = new adapters.tron.ChainAdapter({ - providers: { http: new unchained.tron.TronApi({ rpcUrl: env.TRON_NODE_URL }) }, + providers: { + http: new unchained.tron.TronApi({ + rpcUrl: env.TRON_NODE_URL, + apiKey: env.TRON_GRID_API_KEY, + }), + }, rpcUrl: env.TRON_NODE_URL, + apiKey: env.TRON_GRID_API_KEY, }) const suiAdapter = new adapters.sui.ChainAdapter({ rpcUrl: env.SUI_NODE_URL }) diff --git a/packages/swapper/src/swappers/SunioSwapper/INTEGRATION.md b/packages/swapper/src/swappers/SunioSwapper/INTEGRATION.md deleted file mode 100644 index 84097020af1..00000000000 --- a/packages/swapper/src/swappers/SunioSwapper/INTEGRATION.md +++ /dev/null @@ -1,168 +0,0 @@ -# Sun.io Integration - -## Overview -- **Website**: https://sun.io -- **API Docs**: https://docs.sun.io/developers/swap/smart-router -- **Supported Chains**: TRON only -- **Type**: TRON Direct Smart Contract Execution via HTTP Quote API - -## API Details - -### Quote Endpoint -- **Base URL**: `https://rot.endjgfsv.link/swap/router` -- **Method**: GET -- **Authentication**: None required (public API) -- **Rate Limiting**: No observed limits - -**NOTE**: The `rot.endjgfsv.link` domain appears unusual, but it's the official Sun.io backend API. -This was verified by inspecting XHR requests from sun.io's own frontend application. -The sun.io frontend makes requests to this endpoint with `origin: https://sun.io`. - -### Query Parameters - -- `fromToken` - TRC20 token contract address (e.g., TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t for USDT) -- `toToken` - TRC20 token contract address -- `amountIn` - Amount to swap in token base units -- `typeList` - Comma-separated DEX types: `SUNSWAP_V1,SUNSWAP_V2,SUNSWAP_V3,PSM,CURVE` - -### Example Request - -```bash -curl 'https://rot.endjgfsv.link/swap/router?fromToken=TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t&toToken=TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8&amountIn=1000000&typeList=SUNSWAP_V1,SUNSWAP_V2,SUNSWAP_V3,PSM,CURVE' -``` - -### Response Format - -```json -{ - "code": 0, - "message": "SUCCESS", - "data": [{ - "amountIn": "1.000000", - "amountOut": "1.071122", - "inUsd": "1.000023900000000000000000", - "outUsd": "1.070933370295836840000000", - "impact": "-0.002174", - "fee": "0.003000", - "tokens": ["TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8"], - "symbols": ["USDT", "USDC"], - "poolFees": ["0", "0"], - "poolVersions": ["v2"], - "stepAmountsOut": ["1.071122"] - }] -} -``` - -The API returns multiple routes sorted by best price (first route is best). - -## Implementation Details - -### Chain Support -Sun.io operates **exclusively on TRON blockchain** for TRC-20 token swaps. - -### Native Token Handling -- Native TRX: `T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb` -- Wrapped TRX (WTRX): `TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR` - -### Transaction Building -Unlike EVM swappers, Sun.io requires building TRON smart contract transactions: -1. API returns routing information (tokens, pool versions, fees) -2. Build `swapExactInput` call to smart router contract `TCFNp179Lg46D16zKoumd4Poa2WFFdtqYj` -3. Use TronWeb's `triggerSmartContract` to construct unsigned transaction -4. Sign and broadcast via TRON chain adapter - -### Smart Contract Function - -The swap executes via SunSwap's Smart Exchange Router: -```solidity -function swapExactInput( - address[] calldata path, - string[] calldata poolVersion, - uint256[] calldata versionLen, - uint24[] calldata fees, - SwapData calldata data -) external nonReentrant payable returns (uint256[] memory amountsOut) -``` - -Where `SwapData` is: -```solidity -struct SwapData { - uint256 amountIn; - uint256 amountOutMin; // With slippage applied - address recipient; - uint256 deadline; -} -``` - -### Route Parameters Mapping - -From API response to contract parameters: -- `tokens[]` → `path[]` -- `poolVersions[]` → `poolVersion[]` (e.g., ["v2", "v3"]) -- Calculate `versionLen[]` from path length and pool count -- `poolFees[]` → `fees[]` (converted to uint24) - -### Slippage Application - -Sun.io API returns `amountOut` without slippage. We apply slippage when building the transaction: -```typescript -amountOutMin = amountOut * (1 - slippageTolerancePercentageDecimal) -``` - -Default slippage: **0.5%** (0.005 decimal) - -### Fee Estimation - -Network fees use TRON chain adapter's `getFeeData()`: -- Returns `txFee` in SUN (smallest unit of TRX) -- Typical swap fee: ~14-30 TRX depending on route complexity - -## Gotchas - -### 1. Amounts are Human-Readable -The API returns amounts in **human-readable format** (e.g., "1.071122"), NOT base units. -Must multiply by `10^precision` to convert to crypto base units. - -### 2. Multi-Hop Routes -API can return multi-hop routes (e.g., USDC → WTRX → USDT). -The `tokens[]` array includes ALL tokens in the path, including intermediaries. - -### 3. TronWeb Transaction Building -Must use TronWeb library to build smart contract calls - this is specific to TRON and different from EVM chains. - -### 4. No Affiliate Fee Support -Sun.io API doesn't support affiliate fees - we pass `affiliateBps` but it's ignored. - -### 5. Address Format -TRON addresses start with 'T' and use Base58 encoding (not EIP-55 checksum like EVM). - -## Testing Notes - -**Test Pairs** (high liquidity on TRON): -- USDT (TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t) ↔ USDC (TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8) -- TRX (T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb) ↔ USDT - -**Verify**: -- Quote amounts match API response (after precision conversion) -- Slippage is applied correctly in `amountOutMin` -- Transaction can be signed by TRON wallet -- Network fees are reasonable (~14-30 TRX) - -## Known Issues - -1. **Status Checking**: Currently returns default status. Full TRON transaction status polling not implemented. -2. **Cross-Account**: Not supported (same as most single-chain swappers) - -## References -- [Sun.io Smart Router Docs](https://docs.sun.io/developers/swap/smart-router) -- [SunSwap Contracts](https://github.com/sun-protocol/smart-exchange-router) -- [TronWeb Documentation](https://tronweb.network/docu/docs/intro/) - -## API Discovery - -The `rot.endjgfsv.link` endpoint was discovered by: -1. Inspecting network traffic from sun.io web application -2. Observing XHR requests with `origin: https://sun.io` header -3. Testing and verifying responses match expected swap data - -This appears to be Sun.io's internal aggregator API used by their frontend. diff --git a/packages/swapper/src/swappers/SunioSwapper/endpoints.ts b/packages/swapper/src/swappers/SunioSwapper/endpoints.ts index fc73d55f543..0e5f16140e8 100644 --- a/packages/swapper/src/swappers/SunioSwapper/endpoints.ts +++ b/packages/swapper/src/swappers/SunioSwapper/endpoints.ts @@ -4,7 +4,6 @@ import { toAddressNList } from '@shapeshiftoss/chain-adapters' import { TxStatus } from '@shapeshiftoss/unchained-client' import { TronWeb } from 'tronweb' -import { getTronTransactionFees } from '../../tron-utils/getTronTransactionFees' import type { CommonTradeQuoteInput, GetTradeRateInput, @@ -22,24 +21,16 @@ import { } from '../../utils' import { getSunioTradeQuote } from './getSunioTradeQuote/getSunioTradeQuote' import { getSunioTradeRate } from './getSunioTradeRate/getSunioTradeRate' +import { + buildSwapExactInputParameters, + SUNIO_SWAP_EXACT_INPUT_SELECTOR, +} from './utils/buildSwapContractCall' import { buildSwapRouteParameters } from './utils/buildSwapRouteParameters' import { SUNIO_SMART_ROUTER_CONTRACT } from './utils/constants' +import { getSunioTransactionFees } from './utils/getSunioTransactionFees' const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) -const convertAddressesToEvmFormat = (value: unknown): unknown => { - if (Array.isArray(value)) { - return value.map(v => convertAddressesToEvmFormat(v)) - } - - if (typeof value === 'string' && value.startsWith('T') && TronWeb.isAddress(value)) { - const hex = TronWeb.address.toHex(value) - return hex.replace(/^41/, '0x') - } - - return value -} - export const sunioApi: SwapperApi = { getTradeQuote: async ( input: GetTronTradeQuoteInput | CommonTradeQuoteInput, @@ -63,6 +54,7 @@ export const sunioApi: SwapperApi = { from, slippageTolerancePercentageDecimal, assertGetTronChainAdapter, + config, } = args if (!isExecutableTradeQuote(tradeQuote)) { @@ -82,6 +74,9 @@ export const sunioApi: SwapperApi = { const tronWeb = new TronWeb({ fullHost: rpcUrl, + headers: config.VITE_TRON_GRID_API_KEY + ? { 'TRON-PRO-API-KEY': config.VITE_TRON_GRID_API_KEY } + : {}, }) const routeParams = buildSwapRouteParameters( @@ -92,24 +87,7 @@ export const sunioApi: SwapperApi = { slippageTolerancePercentageDecimal, ) - const parameters = [ - { type: 'address[]', value: routeParams.path }, - { type: 'string[]', value: routeParams.poolVersion }, - { type: 'uint256[]', value: routeParams.versionLen }, - { type: 'uint24[]', value: routeParams.fees }, - { - type: 'tuple(uint256,uint256,address,uint256)', - value: convertAddressesToEvmFormat([ - routeParams.swapData.amountIn, - routeParams.swapData.amountOutMin, - routeParams.swapData.recipient, - routeParams.swapData.deadline, - ]), - }, - ] - - const functionSelector = - 'swapExactInput(address[],string[],uint256[],uint24[],(uint256,uint256,address,uint256))' + const parameters = buildSwapExactInputParameters(routeParams) const isSellingNativeTrx = step.sellAsset.assetId === tronAssetId const callValue = isSellingNativeTrx @@ -123,7 +101,7 @@ export const sunioApi: SwapperApi = { const txData = await tronWeb.transactionBuilder.triggerSmartContract( SUNIO_SMART_ROUTER_CONTRACT, - functionSelector, + SUNIO_SWAP_EXACT_INPUT_SELECTOR, options, parameters, from, @@ -165,7 +143,7 @@ export const sunioApi: SwapperApi = { } }, - getTronTransactionFees, + getTronTransactionFees: getSunioTransactionFees, checkTradeStatus: async ({ txHash, assertGetTronChainAdapter }) => { try { @@ -181,13 +159,15 @@ export const sunioApi: SwapperApi = { const contractRet = tx.ret?.[0]?.contractRet - // Only mark as confirmed if SUCCESS AND has confirmations (in a block) - const status = - contractRet === 'SUCCESS' && tx.confirmations > 0 - ? TxStatus.Confirmed - : contractRet === 'REVERT' - ? TxStatus.Failed - : TxStatus.Pending + // Tron reports many failure codes (REVERT, OUT_OF_ENERGY, OUT_OF_TIME, ...). A missing + // contractRet means it isn't mined yet; any non-SUCCESS value is a terminal failure. + const status = (() => { + if (!contractRet) return TxStatus.Pending + if (contractRet === 'SUCCESS') { + return tx.confirmations > 0 ? TxStatus.Confirmed : TxStatus.Pending + } + return TxStatus.Failed + })() return { status, diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/buildSwapContractCall.ts b/packages/swapper/src/swappers/SunioSwapper/utils/buildSwapContractCall.ts new file mode 100644 index 00000000000..766aa2026fa --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/utils/buildSwapContractCall.ts @@ -0,0 +1,37 @@ +import { TronWeb } from 'tronweb' + +import type { SwapRouteParameters } from './buildSwapRouteParameters' + +export const SUNIO_SWAP_EXACT_INPUT_SELECTOR = + 'swapExactInput(address[],string[],uint256[],uint24[],(uint256,uint256,address,uint256))' + +const convertAddressesToEvmFormat = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(v => convertAddressesToEvmFormat(v)) + } + + if (typeof value === 'string' && value.startsWith('T') && TronWeb.isAddress(value)) { + const hex = TronWeb.address.toHex(value) + return hex.replace(/^41/, '0x') + } + + return value +} + +// Parameter list for the SmartExchangeRouter swapExactInput call, shared between fee +// estimation and execution so both encode the call identically. +export const buildSwapExactInputParameters = (routeParams: SwapRouteParameters) => [ + { type: 'address[]', value: routeParams.path }, + { type: 'string[]', value: routeParams.poolVersion }, + { type: 'uint256[]', value: routeParams.versionLen }, + { type: 'uint24[]', value: routeParams.fees }, + { + type: 'tuple(uint256,uint256,address,uint256)', + value: convertAddressesToEvmFormat([ + routeParams.swapData.amountIn, + routeParams.swapData.amountOutMin, + routeParams.swapData.recipient, + routeParams.swapData.deadline, + ]), + }, +] diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/buildSwapRouteParameters.ts b/packages/swapper/src/swappers/SunioSwapper/utils/buildSwapRouteParameters.ts index 458a45fd18c..f5d77c143c4 100644 --- a/packages/swapper/src/swappers/SunioSwapper/utils/buildSwapRouteParameters.ts +++ b/packages/swapper/src/swappers/SunioSwapper/utils/buildSwapRouteParameters.ts @@ -26,12 +26,12 @@ export const buildSwapRouteParameters = ( const poolVersion = route.poolVersions - const versionLen = poolVersion.map((_, index) => { - if (index === poolVersion.length - 1) { - return path.length - index - } - return 2 - }) + // The SmartExchangeRouter expects sum(versionLen) === path.length: the first + // pool segment consumes 2 tokens (input + output) and each subsequent pool + // reuses the previous output, consuming 1 new token. This relies on the Sun.io + // API returning poolVersions per-hop (poolVersions.length === tokens.length - 1), + // which it does — the router also accepts this un-collapsed form for same-version hops. + const versionLen = poolVersion.map((_, index) => (index === 0 ? 2 : 1)) const fees = route.poolFees.map(fee => Number(fee)) diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/buildSwapTransaction.ts b/packages/swapper/src/swappers/SunioSwapper/utils/buildSwapTransaction.ts deleted file mode 100644 index 12e820dd0f8..00000000000 --- a/packages/swapper/src/swappers/SunioSwapper/utils/buildSwapTransaction.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { TronWeb } from 'tronweb' - -import type { SunioRoute } from '../types' -import { SUNIO_SMART_ROUTER_CONTRACT } from './constants' - -const convertAddressesToEvmFormat = (value: unknown): unknown => { - if (Array.isArray(value)) { - return value.map(v => convertAddressesToEvmFormat(v)) - } - - if (typeof value === 'string' && value.startsWith('T') && TronWeb.isAddress(value)) { - const hex = TronWeb.address.toHex(value) - return hex.replace(/^41/, '0x') - } - - return value -} - -export type BuildSwapTransactionArgs = { - route: SunioRoute - from: string - to: string - sellAmountCryptoBaseUnit: string - minBuyAmountCryptoBaseUnit: string - rpcUrl: string - deadline?: number -} - -export const buildSunioSwapTransaction = async (args: BuildSwapTransactionArgs): Promise => { - const { - route, - from, - to, - sellAmountCryptoBaseUnit, - minBuyAmountCryptoBaseUnit, - rpcUrl, - deadline, - } = args - - const tronWeb = new TronWeb({ - fullHost: rpcUrl, - }) - - const path = route.tokens - - const poolVersion = route.poolVersions - - const versionLen = Array(poolVersion.length).fill(2) - - const fees = route.poolFees.map(fee => Number(fee)) - - const swapData = { - amountIn: sellAmountCryptoBaseUnit, - amountOutMin: minBuyAmountCryptoBaseUnit, - recipient: to, - deadline: deadline ?? Math.floor(Date.now() / 1000) + 60 * 20, - } - - const parameters = [ - { type: 'address[]', value: path }, - { type: 'string[]', value: poolVersion }, - { type: 'uint256[]', value: versionLen }, - { type: 'uint24[]', value: fees }, - { - type: 'tuple(uint256,uint256,address,uint256)', - value: convertAddressesToEvmFormat([ - swapData.amountIn, - swapData.amountOutMin, - swapData.recipient, - swapData.deadline, - ]), - }, - ] - - const functionSelector = - 'swapExactInput(address[],string[],uint256[],uint24[],(uint256,uint256,address,uint256))' - - const options = { - feeLimit: 100_000_000, - callValue: 0, - } - - const txData = await tronWeb.transactionBuilder.triggerSmartContract( - SUNIO_SMART_ROUTER_CONTRACT, - functionSelector, - options, - parameters, - from, - ) - - if (!txData.result || !txData.result.result) { - throw new Error('[Sun.io] Failed to build swap transaction') - } - - return txData.transaction -} diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/constants.ts b/packages/swapper/src/swappers/SunioSwapper/utils/constants.ts index 3407537abe1..ee41b082999 100644 --- a/packages/swapper/src/swappers/SunioSwapper/utils/constants.ts +++ b/packages/swapper/src/swappers/SunioSwapper/utils/constants.ts @@ -13,6 +13,13 @@ export const SUNIO_SMART_ROUTER_CONTRACT = 'TCFNp179Lg46D16zKoumd4Poa2WFFdtqYj' export const DEFAULT_SLIPPAGE_PERCENTAGE = '0.005' +// Energy fallbacks for when we can't simulate a swap (no address, or the simulation reverts +// pre-approval for TRC20 sells). Set to observed average energy so the 1.2x margin applied at +// estimate time lifts them to cover the worst case (native ~245k, TRC20 ~415k due to the extra +// transferFrom token pull) without double-counting safety. +export const SUNIO_FALLBACK_SWAP_ENERGY_NATIVE = 215_000 +export const SUNIO_FALLBACK_SWAP_ENERGY_TRC20 = 375_000 + export const SUNIO_DEX_TYPES = 'PSM,CURVE,CURVE_COMBINATION,WTRX,SUNSWAP_V1,SUNSWAP_V2,SUNSWAP_V3' as const diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/estimateSunioNetworkFee.ts b/packages/swapper/src/swappers/SunioSwapper/utils/estimateSunioNetworkFee.ts new file mode 100644 index 00000000000..568998f0a21 --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/utils/estimateSunioNetworkFee.ts @@ -0,0 +1,117 @@ +import { bn } from '@shapeshiftoss/utils' +import { TronWeb } from 'tronweb' + +import type { SunioRoute } from '../types' +import { + buildSwapExactInputParameters, + SUNIO_SWAP_EXACT_INPUT_SELECTOR, +} from './buildSwapContractCall' +import { buildSwapRouteParameters } from './buildSwapRouteParameters' +import { + DEFAULT_SLIPPAGE_PERCENTAGE, + SUNIO_FALLBACK_SWAP_ENERGY_NATIVE, + SUNIO_FALLBACK_SWAP_ENERGY_TRC20, + SUNIO_SMART_ROUTER_CONTRACT, + SUNIO_TRON_NATIVE_ADDRESS, +} from './constants' + +type EstimateSunioNetworkFeeArgs = { + rpcUrl: string + apiKey: string + route: SunioRoute + sellAmountCryptoBaseUnit: string + isSellingNativeTrx: boolean + address: string | undefined + slippageTolerancePercentageDecimal: string | undefined +} + +// Estimates the network fee (in TRX base units) for a Sun.io swap. The router sponsors only ~1% +// of energy (origin_energy_usage); the user pays the rest, so we simulate the real swapExactInput +// call when we have an address to estimate from. The simulation reverts before approval for TRC20 +// sells (and when the account lacks the sell balance), so we fall back to a conservative per-sell- +// type constant. Used both at quote time and re-run at confirm time, where a now-granted allowance +// lets the simulation produce the true cost. +export const estimateSunioNetworkFeeCryptoBaseUnit = async ({ + rpcUrl, + apiKey, + route, + sellAmountCryptoBaseUnit, + isSellingNativeTrx, + address, + slippageTolerancePercentageDecimal, +}: EstimateSunioNetworkFeeArgs): Promise => { + const tronGridHeaders: Record = apiKey ? { 'TRON-PRO-API-KEY': apiKey } : {} + const tronWeb = new TronWeb({ fullHost: rpcUrl, headers: tronGridHeaders }) + + // Live network prices, defaulting if the node is unavailable so we still produce a fee + const { bandwidthPrice, energyPrice } = await (async () => { + try { + const params = await tronWeb.trx.getChainParameters() + return { + bandwidthPrice: params.find(p => p.key === 'getTransactionFee')?.value ?? 1000, + energyPrice: params.find(p => p.key === 'getEnergyFee')?.value ?? 100, + } + } catch { + return { bandwidthPrice: 1000, energyPrice: 100 } + } + })() + + // Account activation (1 TRX) is only charged when the recipient receives native TRX, mirroring + // the chain adapter. A TRC20 buy doesn't incur the flat activation fee (its account-creation + // cost is folded into energy). Only checkable with an address. + const isBuyingNativeTrx = route.tokens[route.tokens.length - 1] === SUNIO_TRON_NATIVE_ADDRESS + const accountActivationFee = await (async () => { + if (!address || !isBuyingNativeTrx) return 0 + try { + const recipientInfoResponse = await fetch(`${rpcUrl}/wallet/getaccount`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...tronGridHeaders }, + body: JSON.stringify({ address, visible: true }), + }) + const recipientInfo = await recipientInfoResponse.json() + const recipientExists = recipientInfo && Object.keys(recipientInfo).length > 1 + return recipientExists ? 0 : 1_000_000 // 1 TRX + } catch { + return 0 + } + })() + + const energyUsed = await (async () => { + // TRC20 sells cost far more energy than native sells (the extra transferFrom token pull) + const fallbackEnergy = isSellingNativeTrx + ? SUNIO_FALLBACK_SWAP_ENERGY_NATIVE + : SUNIO_FALLBACK_SWAP_ENERGY_TRC20 + if (!address) return fallbackEnergy + try { + const routeParams = buildSwapRouteParameters( + route, + sellAmountCryptoBaseUnit, + '0', + address, + slippageTolerancePercentageDecimal ?? DEFAULT_SLIPPAGE_PERCENTAGE, + ) + + const callValue = isSellingNativeTrx ? Number(sellAmountCryptoBaseUnit) : 0 + + const result = await tronWeb.transactionBuilder.triggerConstantContract( + SUNIO_SMART_ROUTER_CONTRACT, + SUNIO_SWAP_EXACT_INPUT_SELECTOR, + { callValue }, + buildSwapExactInputParameters(routeParams), + address, + ) + // A reverted simulation (e.g. TRC20 sell pre-approval) can still return a small energy_used; + // only trust it when the call actually succeeded, otherwise use the conservative fallback. + if (result?.result?.result !== true || result.energy_used == null) return fallbackEnergy + return result.energy_used + } catch { + return fallbackEnergy + } + })() + + // 1.2x safety margin for energy price/usage variance between estimate and execution + const energyFee = Math.ceil(energyUsed * energyPrice * 1.2) + const bandwidthFee = 1100 * bandwidthPrice + + return bn(energyFee).plus(bandwidthFee).plus(accountActivationFee).toFixed(0) +} diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts b/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts index 43290c299ad..6d68ec9c865 100644 --- a/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts +++ b/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts @@ -2,7 +2,6 @@ import { tronChainId } from '@shapeshiftoss/caip' import { BigAmount, bn, contractAddressOrUndefined } from '@shapeshiftoss/utils' import type { Result } from '@sniptt/monads' import { Err, Ok } from '@sniptt/monads' -import { TronWeb } from 'tronweb' import type { CommonTradeQuoteInput, @@ -17,6 +16,7 @@ import { SwapperName, TradeQuoteError } from '../../../types' import { getInputOutputRate, makeSwapErrorRight } from '../../../utils' import { buildAffiliateFee } from '../../utils/affiliateFee' import { DEFAULT_SLIPPAGE_PERCENTAGE, SUNIO_SMART_ROUTER_CONTRACT } from './constants' +import { estimateSunioNetworkFeeCryptoBaseUnit } from './estimateSunioNetworkFee' import { fetchSunioQuote } from './fetchFromSunio' import { isSupportedChainId } from './helpers/helpers' import { sunioServiceFactory } from './sunioService' @@ -115,89 +115,23 @@ export async function getQuoteOrRate( ) } - // Fetch network fees for both quotes and rates (when wallet connected) - let networkFeeCryptoBaseUnit: string | undefined = undefined - - // Estimate fees when we have an address to estimate from - if (receiveAddress) { + const networkFeeCryptoBaseUnit = await (async () => { try { - const contractAddress = contractAddressOrUndefined(sellAsset.assetId) - const isSellingNativeTrx = !contractAddress - - const tronWeb = new TronWeb({ fullHost: deps.config.VITE_TRON_NODE_URL }) - - // Get chain parameters for pricing - const params = await tronWeb.trx.getChainParameters() - const bandwidthPrice = params.find(p => p.key === 'getTransactionFee')?.value ?? 1000 - const energyPrice = params.find(p => p.key === 'getEnergyFee')?.value ?? 100 - - // Check if recipient needs activation (applies to all swaps) - let accountActivationFee = 0 - try { - const recipientInfoResponse = await fetch( - `${deps.config.VITE_TRON_NODE_URL}/wallet/getaccount`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ address: receiveAddress, visible: true }), - }, - ) - const recipientInfo = await recipientInfoResponse.json() - const recipientExists = recipientInfo && Object.keys(recipientInfo).length > 1 - if (!recipientExists) { - accountActivationFee = 1_000_000 // 1 TRX - } - } catch { - // Ignore activation check errors - } - - // For native TRX swaps, Sun.io uses a contract call with value - // We need to estimate energy for the swap contract, not just bandwidth - if (isSellingNativeTrx) { - try { - // Sun.io contract owner provides most energy (~117k), users only pay ~2k - // Use fixed 2k energy estimate instead of querying (which returns total 120k) - const energyUsed = 2000 // User pays ~2k energy, contract covers the rest - const energyFee = energyUsed * energyPrice // No multiplier - contract provides energy - - // Estimate bandwidth for contract call (much larger than simple transfer) - const bandwidthFee = 1100 * bandwidthPrice // ~1100 bytes for contract call (with safety buffer) - - networkFeeCryptoBaseUnit = bn(energyFee) - .plus(bandwidthFee) - .plus(accountActivationFee) - .toFixed(0) - } catch (estimationError) { - // Fallback estimate: ~2k energy + ~1100 bytes bandwidth + activation fee - const fallbackEnergyFee = 2000 * energyPrice - const fallbackBandwidthFee = 1100 * bandwidthPrice - networkFeeCryptoBaseUnit = bn(fallbackEnergyFee) - .plus(fallbackBandwidthFee) - .plus(accountActivationFee) - .toFixed(0) - } - } else { - // For TRC-20 swaps through Sun.io router - // Same as TRX: contract owner provides most energy, user pays ~2k - // Sun.io provides ~217k energy, user pays ~2k - const energyFee = 2000 * energyPrice - const bandwidthFee = 1100 * bandwidthPrice - - networkFeeCryptoBaseUnit = bn(energyFee) - .plus(bandwidthFee) - .plus(accountActivationFee) - .toFixed(0) - } + return await estimateSunioNetworkFeeCryptoBaseUnit({ + rpcUrl: deps.config.VITE_TRON_NODE_URL, + apiKey: deps.config.VITE_TRON_GRID_API_KEY, + route: bestRoute, + sellAmountCryptoBaseUnit: sellAmountIncludingProtocolFeesCryptoBaseUnit, + isSellingNativeTrx: !contractAddressOrUndefined(sellAsset.assetId), + address: receiveAddress, + slippageTolerancePercentageDecimal, + }) } catch (error) { - // For rates, fall back to '0' on estimation failure - // For quotes, let it error (required for accurate swap) - if (!isQuote) { - networkFeeCryptoBaseUnit = '0' - } else { - throw error - } + // For rates, fall back to '0' on unexpected failure; quotes require an accurate fee + if (!isQuote) return '0' + throw error } - } + })() const buyAmountCryptoBaseUnit = BigAmount.fromPrecision({ value: bestRoute.amountOut, diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/getSunioTransactionFees.ts b/packages/swapper/src/swappers/SunioSwapper/utils/getSunioTransactionFees.ts new file mode 100644 index 00000000000..9dc8b35e9ba --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/utils/getSunioTransactionFees.ts @@ -0,0 +1,42 @@ +import { tronAssetId, tronChainId } from '@shapeshiftoss/caip' + +import type { GetUnsignedTronTransactionArgs } from '../../../types' +import { getExecutableTradeStep, isExecutableTradeQuote } from '../../../utils' +import { estimateSunioNetworkFeeCryptoBaseUnit } from './estimateSunioNetworkFee' + +export const getSunioTransactionFees = async ({ + tradeQuote, + stepIndex, + from, + slippageTolerancePercentageDecimal, + assertGetTronChainAdapter, + config, +}: GetUnsignedTronTransactionArgs): Promise => { + if (!isExecutableTradeQuote(tradeQuote)) throw new Error('Unable to execute a trade rate quote') + + const step = getExecutableTradeStep(tradeQuote, stepIndex) + const storedNetworkFeeCryptoBaseUnit = step.feeData.networkFeeCryptoBaseUnit + const route = step.sunioTransactionMetadata?.route + + if (!route) { + if (!storedNetworkFeeCryptoBaseUnit) throw new Error('Missing network fee in quote') + return storedNetworkFeeCryptoBaseUnit + } + + try { + const adapter = assertGetTronChainAdapter(tronChainId) + + return await estimateSunioNetworkFeeCryptoBaseUnit({ + rpcUrl: adapter.httpProvider.getRpcUrl(), + apiKey: config.VITE_TRON_GRID_API_KEY, + route, + sellAmountCryptoBaseUnit: step.sellAmountIncludingProtocolFeesCryptoBaseUnit, + isSellingNativeTrx: step.sellAsset.assetId === tronAssetId, + address: from, + slippageTolerancePercentageDecimal, + }) + } catch { + if (!storedNetworkFeeCryptoBaseUnit) throw new Error('Missing network fee in quote') + return storedNetworkFeeCryptoBaseUnit + } +} diff --git a/packages/swapper/src/thorchain-utils/getL1RateOrQuote.ts b/packages/swapper/src/thorchain-utils/getL1RateOrQuote.ts index 533a1b09ca4..b090299cc3c 100644 --- a/packages/swapper/src/thorchain-utils/getL1RateOrQuote.ts +++ b/packages/swapper/src/thorchain-utils/getL1RateOrQuote.ts @@ -553,6 +553,9 @@ export const getL1RateOrQuote = async ( // Estimate fees using the receive address for accurate energy calculation const tronWeb = new TronWeb({ fullHost: deps.config.VITE_TRON_NODE_URL, + headers: deps.config.VITE_TRON_GRID_API_KEY + ? { 'TRON-PRO-API-KEY': deps.config.VITE_TRON_GRID_API_KEY } + : {}, }) const params = await tronWeb.trx.getChainParameters() const bandwidthPrice = params.find(p => p.key === 'getTransactionFee')?.value ?? 1000 diff --git a/packages/swapper/src/thorchain-utils/tron/THORCHAIN_TRON_INTEGRATION.md b/packages/swapper/src/thorchain-utils/tron/THORCHAIN_TRON_INTEGRATION.md deleted file mode 100644 index 89ce0e185d5..00000000000 --- a/packages/swapper/src/thorchain-utils/tron/THORCHAIN_TRON_INTEGRATION.md +++ /dev/null @@ -1,550 +0,0 @@ -# Thorchain TRON Integration - -## Overview - -This document covers the implementation of TRON support for the Thorchain swapper, enabling cross-chain swaps between TRON assets (TRX, TRC20 tokens) and other Thorchain-supported chains. - -## Architecture - -TRON follows the **UTXO-style pattern** for Thorchain integration (like BTC, DOGE, LTC), **NOT the EVM pattern**. - -### Key Differences from EVM Chains - -| Aspect | EVM Chains | TRON | -|--------|-----------|------| -| Router Contract | ✅ Required | ❌ Not used | -| Transaction Type | `depositWithExpiry()` call | Direct transfer to vault | -| Memo Location | Calldata parameter | `raw_data.data` field | -| Memo Encoding | ABI-encoded | UTF-8 hex string | -| Fee Handling | Gas limit | Energy + Bandwidth | - -### Transaction Flow - -1. User initiates swap (e.g., TRON.USDT → BTC.BTC) -2. Get Thorchain quote with memo (e.g., `"SWAP:BTC.BTC:bc1q..."`) -3. Get vault address from Thorchain inbound_addresses API -4. Build transaction: Transfer to vault WITH memo -5. Sign and broadcast to TRON network -6. Thorchain detects inbound tx, reads memo, executes swap - -## Implementation Details - -### 1. Asset Mapping - -**File:** `scripts/generateTradableAssetMap/utils.ts` - -Added TRON to asset generation: -```typescript -enum Chain { - // ...existing chains - TRON = 'TRON', -} - -const chainToChainId: Record = { - // ...existing mappings - [Chain.TRON]: tronChainId, -} - -// Added TRC20 token standard -case KnownChainIds.TronMainnet: - return ASSET_NAMESPACE.trc20 -``` - -**Generated Assets:** -- `TRON.TRX` → `tron:0x2b6653dc/slip44:195` -- `TRON.USDT-TR7NHQJEKQXGTCI8Q8ZY4PL8OTSZGJLJ6T` → `tron:0x2b6653dc/trc20:tr7nhqjekqxgtci8q8zy4pl8otszgjlj6t` - -### 2. Memo Support in Chain Adapter - -**File:** `packages/chain-adapters/src/tron/types.ts` - -```typescript -export type BuildTxInput = { - contractAddress?: string - memo?: string // Added for Thorchain -} -``` - -**File:** `packages/chain-adapters/src/tron/TronChainAdapter.ts` - -```typescript -async buildSendApiTransaction(input: BuildSendApiTxInput) { - const { chainSpecific: { contractAddress, memo } = {} } = input - - // Build TRX or TRC20 transaction - let txData = await this.buildTransaction(...) - - // Add memo if provided - if (memo) { - txData = await tronWeb.transactionBuilder.addUpdateData(txData, memo, 'utf8') - } - - return { addressNList, rawDataHex, transaction: txData } -} -``` - -**How Memo Works:** -- Uses TronWeb's `addUpdateData()` method -- Encodes memo as UTF-8 hex string -- Stored in `raw_data.data` field -- Visible on TronScan and readable by Thorchain -- Adds 1 TRX fee (`getMemoFee` network parameter) - -### 3. Thorchain Utils Module - -**Location:** `packages/swapper/src/thorchain-utils/tron/` - -#### getThorTxData.ts -```typescript -// Gets vault address from Thorchain inbound_addresses API -export const getThorTxData = async ({ sellAsset, config, swapperName }) => { - const daemonUrl = getDaemonUrl(config, swapperName) - const res = await getInboundAddressDataForChain(daemonUrl, sellAsset.assetId, false, swapperName) - const { address: vault } = res.unwrap() - return { vault } -} -``` - -**Thorchain Inbound Address:** -```json -{ - "chain": "TRON", - "address": "TGGwikcdG1xAeftPWpS7jpomLTobTV7BGY", - "router": null, // No router for TRON! - "gas_rate": "25387800", - "outbound_fee": "158419800" -} -``` - -#### getUnsignedTronTransaction.ts -```typescript -export const getUnsignedTronTransaction = async (args, swapperName) => { - const { memo } = tradeQuote - const { vault } = await getThorTxData(...) - const contractAddress = contractAddressOrUndefined(sellAsset.assetId) - - return adapter.buildSendApiTransaction({ - to: vault, - from, - value: sellAmountIncludingProtocolFeesCryptoBaseUnit, - accountNumber, - chainSpecific: { - contractAddress, // For TRC20 tokens - memo, // Thorchain swap memo - }, - }) -} -``` - -**Contract Address Extraction:** -- Native TRX: `undefined` -- TRC20 USDT: `tr7nhqjekqxgtci8q8zy4pl8otszgjlj6t` -- Uses `contractAddressOrUndefined()` utility - -#### getTronTransactionFees.ts -```typescript -export const getTronTransactionFees = async (args, swapperName) => { - const { vault } = await getThorTxData(...) - const contractAddress = contractAddressOrUndefined(sellAsset.assetId) - const rpcUrl = config.VITE_TRON_NODE_URL - - const tronWeb = new TronWeb({ fullHost: rpcUrl }) - - if (contractAddress) { - // TRC20: Estimate energy - const { energyPrice } = await getChainPrices(rpcUrl) - const result = await tronWeb.transactionBuilder.triggerConstantContract( - contractAddress, - 'transfer(address,uint256)', - {}, - [ - { type: 'address', value: vault }, - { type: 'uint256', value: sellAmountIncludingProtocolFeesCryptoBaseUnit }, - ], - vault, - ) - const energyUsed = result.energy_used ?? 65000 - return String(energyUsed * energyPrice) - } else { - // TRX: Calculate bandwidth - const { bandwidthPrice } = await getChainPrices(rpcUrl) - let tx = await tronWeb.transactionBuilder.sendTrx(vault, amount, vault) - const txWithMemo = await tronWeb.transactionBuilder.addUpdateData(tx, memo, 'utf8') - const totalBytes = (txWithMemo.raw_data_hex.length / 2) + 65 - return String(totalBytes * bandwidthPrice) - } -} -``` - -**Fee Components:** -- **Energy (TRC20 only)**: Smart contract execution cost - - Recipient has balance: ~64k units × 100 SUN = ~6.4 TRX - - Recipient empty: ~130k units × 100 SUN = ~13 TRX -- **Bandwidth**: Transaction size cost - - ~345-405 bytes × 1,000 SUN = ~0.35-0.4 TRX - - Daily free: 600 units (enough for 1-2 TRC20 txs) -- **Memo Fee**: Fixed network parameter - - 1 TRX if `raw_data.data` present - -### 4. Integration Points - -**File:** `packages/swapper/src/thorchain-utils/getL1RateOrQuote.ts:443-482` - -```typescript -case CHAIN_NAMESPACE.Tron: { - const maybeRoutes = await Promise.allSettled( - perRouteValues.map((route): Promise => { - const memo = getMemo(route) - - // For rate quotes (no wallet), can't calculate fees - const networkFeeCryptoBaseUnit = undefined - - return Promise.resolve( - makeThorTradeRateOrQuote({ - route, - allowanceContract: '0x0', // not applicable to TRON - memo, - feeData: { - networkFeeCryptoBaseUnit, - protocolFees: getProtocolFees(route.quote), - }, - }), - ) - }), - ) - // ... error handling -} -``` - -**File:** `packages/swapper/src/swappers/ThorchainSwapper/endpoints.ts` - -```typescript -export const thorchainApi: SwapperApi = { - getTradeRate, - getTradeQuote, - // ... EVM, UTXO, Cosmos methods - getUnsignedTronTransaction: input => tron.getUnsignedTronTransaction(input, swapperName), - getTronTransactionFees: input => tron.getTronTransactionFees(input, swapperName), - // ... other methods -} -``` - -**File:** `packages/swapper/src/swappers/ThorchainSwapper/ThorchainSwapper.ts` - -```typescript -export const thorchainSwapper: Swapper = { - executeEvmTransaction, - executeCosmosSdkTransaction: (txToSign, { signAndBroadcastTransaction }) => - signAndBroadcastTransaction(txToSign), - executeUtxoTransaction: (txToSign, { signAndBroadcastTransaction }) => - signAndBroadcastTransaction(txToSign), - executeTronTransaction: (txToSign, { signAndBroadcastTransaction }) => - signAndBroadcastTransaction(txToSign), -} -``` - -**File:** `packages/swapper/src/types.ts` - -```typescript -export type SwapperConfig = { - // ... existing config - VITE_TRON_NODE_URL: string, // Added for TRON RPC - // ... other config -} -``` - -## Available Pools - -**Thorchain Mainnet:** -- `TRON.TRX` - Native TRX (decimals: 6, short_code: "tr") -- `TRON.USDT-TR7NHQJEKQXGTCI8Q8ZY4PL8OTSZGJLJ6T` - Tether USDT - -**API Endpoints:** -- Pools: `https://thornode.ninerealms.com/thorchain/pools` -- Inbound addresses: `https://thornode.ninerealms.com/thorchain/inbound_addresses` -- Quote: `https://thornode.ninerealms.com/thorchain/quote/swap` (POST) - -## Testing & Validation - -### Successful On-Chain Examples - -**Example 1:** TRON.USDT → Other chain -- TX: `5AAD9FD5501B860C1C38FB362D6D92212DEB328CC10BD24C18A5CD90CDD75320` -- From: `TCTKeM5P8CUD6jVq9Xr7DgQgewrtkaAKnx` -- To: `TGGwikcdG1xAeftPWpS7jpomLTobTV7BGY` (vault) -- Amount: 1,308,110 USDT -- Memo: `TRADE+:thor14mh37ua4vkyur0l5ra297a4la6tmf95mt96a55` -- Fee: 7.8 TRX -- Energy: 64,285 units -- Result: ✅ SUCCESS - -**Example 2:** TRON.USDT swap -- TX: `78055EA7A360B7EEDBEADD95EB70E45B2A9022CB9C60165E4A4FDB3E8FE8283B` -- Memo: `=:b:bc1q7hg034hvvy2wxpvs5yhs3wyva7ncxam4hvcxa6:1170359/1/0:sto:0` -- Fee limit: 100 TRX -- Result: ✅ SUCCESS - -### Transaction Structure Verification - -**Verified via on-chain transactions:** -```json -{ - "raw_data": { - "data": "3d3a...", // Memo in hex (UTF-8 encoded) - "fee_limit": 100000000, // 100 TRX standard - "contract": [{ - "type": "TriggerSmartContract", - "parameter": { - "value": { - "data": "a9059cbb...", // TRC20 transfer(address,uint256) calldata - "owner_address": "...", // Sender - "contract_address": "41a614f803..." // USDT contract - } - } - }] - } -} -``` - -**Key Observations:** -- ✅ `addUpdateData()` preserves `fee_limit` (tested with actual txs) -- ✅ Memo goes in `raw_data.data` field -- ✅ TRC20 calldata in `contract[0].parameter.value.data` -- ✅ Both coexist without conflicts - -## Common Issues & Solutions - -### Issue 1: "BANDWITH_ERROR" / "Account resource insufficient" - -**Symptoms:** -```json -{ - "code": "BANDWITH_ERROR", - "message": "Account resource insufficient error." -} -``` - -**Root Cause:** -Insufficient liquid TRX balance in sender account. This error is **misleading** - it's not about bandwidth, it's about TRX balance. - -**Requirements:** -- TRC20 transfer without memo: ~6-13 TRX -- TRC20 transfer with memo: ~8-15 TRX -- TRX transfer with memo: ~1-2 TRX - -**Solution:** -Ensure sender has **10-15 TRX liquid (unfrozen) balance** for TRC20 swaps. - -### Issue 2: OUT_OF_ENERGY Mid-Execution - -**Symptoms:** -Transaction broadcasts, appears on-chain, but fails with: -``` -"result": "OUT_OF_ENERGY" -"resMessage": "Not enough energy for 'PUSH20' operation executing" -``` - -**Root Cause:** -Started with insufficient TRX, burned what it had, then ran out mid-execution. - -**Example:** -- Account: 0.25 TRX -- Started burning for energy -- Used 3.5 TRX worth, ran out -- Transaction failed on-chain - -**Solution:** -Same as Issue 1 - ensure sufficient balance BEFORE initiating. - -### Issue 3: Inaccurate Fee Display in UI - -**Root Cause:** -`TronChainAdapter.getFeeData()` returns fixed 0.268 TRX for all transactions (see `TRON_FEE_ESTIMATION_ISSUES.md`). - -**Impact:** -- User sees: "~$0.05 fee" -- Reality: "~$1.50-$3.00 fee" -- User underfunds account, transaction fails - -**Solution:** -Fix `getFeeData()` to properly estimate TRC20 energy costs (tracked in TODOs). - -## Cost Analysis - -### TRC20 Transfer (USDT) Costs - -**Energy:** -- Recipient has USDT: 64,000 units × 100 SUN = **6.4 TRX** -- Recipient empty: 130,000 units × 100 SUN = **13 TRX** - -**Bandwidth:** -- Base tx: ~268 bytes -- With memo: ~345-405 bytes -- Cost: 345-405 × 1,000 SUN = **0.35-0.4 TRX** -- Can use daily free 600 units - -**Memo Fee:** -- Fixed: **1 TRX** (if `raw_data.data` present) -- Network parameter: `getMemoFee: 1000000` - -**Total for Thorchain Swap:** -- Best case: 6.4 + 0.4 + 1 = **~7.8 TRX** -- Worst case: 13 + 0.4 + 1 = **~14.4 TRX** - -### TRX Transfer (Native) Costs - -**Bandwidth:** -- Base tx: ~268 bytes -- With memo: ~325 bytes -- Cost: 325 × 1,000 SUN = **0.325 TRX** - -**Memo Fee:** -- Fixed: **1 TRX** - -**Total for Thorchain Swap:** -- **~1.3-1.5 TRX** - -## Network Parameters (2025) - -Fetched from `https://api.trongrid.io/wallet/getchainparameters`: - -```json -{ - "getEnergyFee": 100, // 100 SUN per energy unit - "getTransactionFee": 1000, // 1,000 SUN per bandwidth byte - "getMemoFee": 1000000, // 1 TRX for transactions with data - "getFreeNetLimit": 600, // Daily free bandwidth per account - "getMaxFeeLimit": 15000000000 // Max fee limit: 15,000 TRX -} -``` - -**Daily Free Resources:** -- Bandwidth: 600 units (enough for ~1-2 TRC20 txs) -- Energy: 0 (must stake TRX or burn) - -## Code Verification - -### Compared Against SwapKit Implementation - -**SwapKit's TRON Thorchain Implementation:** -```typescript -// From: github.com/swapkit/SwapKit/packages/toolboxes/src/tron/toolbox.ts - -const addTxData = async ({ transaction, memo }) => { - const transactionWithMemo = memo - ? await tronWeb.transactionBuilder.addUpdateData(transaction, memo, "utf8") - : transaction - return transactionWithMemo -} - -// For TRC20 -const options = { callValue: 0, feeLimit: calculateFeeLimit() } // 100 TRX -const { transaction } = await tronWeb.transactionBuilder.triggerSmartContract( - contractAddress, - "transfer(address,uint256)", - options, - parameter, - sender, -) -const txWithData = addTxData({ memo, transaction }) -``` - -**Our Implementation:** ✅ **Identical pattern** - -### Compared Against Successful Thorchain Transactions - -**On-Chain Transaction Analysis:** -- Fee limit: 100 TRX (standard) -- Memo encoding: UTF-8 hex in `raw_data.data` -- No `txLocal` option needed -- `addUpdateData()` preserves `fee_limit` correctly - -**Our Implementation:** ✅ **Matches successful txs** - -## Known Limitations - -### 1. Fee Estimation (Inherited from Base TRON Implementation) - -**Current:** Returns fixed 0.268 TRX for all transactions -**Impact:** Users see wrong fees, transactions fail -**Status:** Documented in `TRON_FEE_ESTIMATION_ISSUES.md` -**Fix:** Tracked in TODOs in `TronChainAdapter.ts:358-370` - -### 2. Thorchain Quote API - -**Status:** Returns "Not Implemented" for TRON -**Impact:** Must use `/inbound_addresses` + manual memo construction -**Workaround:** Use standard Thorchain quote endpoint (works despite error) - -### 3. Minimum Balance Requirements - -**TRC20 Swaps:** 10-15 TRX liquid balance required -**TRX Swaps:** 2-3 TRX liquid balance required -**Not Enforced:** Adapter doesn't check balance before broadcasting - -## File Changes Summary - -### Modified (8 files) -1. `packages/chain-adapters/src/tron/TronChainAdapter.ts` - Added memo handling -2. `packages/chain-adapters/src/tron/types.ts` - Added memo field -3. `packages/swapper/src/swappers/ThorchainSwapper/endpoints.ts` - Added TRON methods -4. `packages/swapper/src/swappers/ThorchainSwapper/ThorchainSwapper.ts` - Added executeTronTransaction -5. `packages/swapper/src/swappers/ThorchainSwapper/generated/generatedTradableAssetMap.json` - Added TRON assets -6. `packages/swapper/src/thorchain-utils/getL1RateOrQuote.ts` - Added TRON handler -7. `packages/swapper/src/thorchain-utils/index.ts` - Exported tron module -8. `packages/swapper/src/types.ts` - Added VITE_TRON_NODE_URL -9. `scripts/generateTradableAssetMap/utils.ts` - Added TRON chain mapping - -### Added (4 files) -1. `packages/swapper/src/thorchain-utils/tron/getThorTxData.ts` -2. `packages/swapper/src/thorchain-utils/tron/getUnsignedTronTransaction.ts` -3. `packages/swapper/src/thorchain-utils/tron/getTronTransactionFees.ts` -4. `packages/swapper/src/thorchain-utils/tron/index.ts` - -## Testing Checklist - -### Pre-Testing Requirements -- [ ] Account has 15+ TRX liquid balance -- [ ] VITE_TRON_NODE_URL configured in environment -- [ ] Thorchain pools showing TRON assets - -### Test Cases -- [ ] TRON.TRX → BTC.BTC swap -- [ ] TRON.USDT → ETH.ETH swap -- [ ] BTC.BTC → TRON.TRX swap (outbound to TRON) -- [ ] ETH.ETH → TRON.USDT swap -- [ ] Verify memo appears on TronScan -- [ ] Verify Thorchain detects inbound tx -- [ ] Check fee estimation accuracy - -### Expected Results -- Transaction broadcasts successfully -- Appears on TronScan with memo visible -- Thorchain processes swap -- User receives output asset -- Actual fee matches estimate (once getFeeData fixed) - -## References - -- **Thorchain TRON Pools:** https://thornode.ninerealms.com/thorchain/pools (search "TRON") -- **Thorchain Dev Docs:** https://dev.thorchain.org/concepts/memo-length-reduction.html -- **TRON Resource Model:** https://developers.tron.network/docs/resource-model -- **TronWeb Docs:** https://tronweb.network/docu/docs/intro/ -- **SwapKit TRON:** https://github.com/swapkit/SwapKit/tree/develop/packages/toolboxes/src/tron -- **On-Chain Explorer:** https://tronscan.org/ - -## Future Improvements - -1. **Fix getFeeData()** - See `TRON_FEE_ESTIMATION_ISSUES.md` -2. **Add Balance Validation** - Check sufficient TRX before broadcasting -3. **Better Error Messages** - "Need 10 TRX for USDT swap" vs "Account resource insufficient" -4. **Dynamic FeeLimit Calculation** - Adjust based on actual energy estimate -5. **Energy Optimization** - Suggest staking TRX for frequent swappers - -## Notes - -- TRON transactions are **irreversible** once broadcast -- Failed transactions **still cost TRX** (energy/bandwidth burned) -- Frozen TRX **cannot** be used for transaction fees -- Each failed attempt burns ~3-4 TRX before running out -- Always test with small amounts first diff --git a/packages/swapper/src/thorchain-utils/tron/getTronTransactionFees.ts b/packages/swapper/src/thorchain-utils/tron/getTronTransactionFees.ts index 3b8ec2998ef..a07cb6beea3 100644 --- a/packages/swapper/src/thorchain-utils/tron/getTronTransactionFees.ts +++ b/packages/swapper/src/thorchain-utils/tron/getTronTransactionFees.ts @@ -8,9 +8,10 @@ import { getThorTxData } from './getThorTxData' const getChainPrices = async ( rpcUrl: string, + headers: Record, ): Promise<{ bandwidthPrice: number; energyPrice: number }> => { try { - const tronWeb = new TronWeb({ fullHost: rpcUrl }) + const tronWeb = new TronWeb({ fullHost: rpcUrl, headers }) const params = await tronWeb.trx.getChainParameters() const bandwidthPrice = params.find(p => p.key === 'getTransactionFee')?.value ?? 1000 const energyPrice = params.find(p => p.key === 'getEnergyFee')?.value ?? 420 @@ -38,13 +39,16 @@ export const getTronTransactionFees = async ( const contractAddress = contractAddressOrUndefined(sellAsset.assetId) const rpcUrl = config.VITE_TRON_NODE_URL + const tronGridHeaders: Record = config.VITE_TRON_GRID_API_KEY + ? { 'TRON-PRO-API-KEY': config.VITE_TRON_GRID_API_KEY } + : {} try { - const tronWeb = new TronWeb({ fullHost: rpcUrl }) + const tronWeb = new TronWeb({ fullHost: rpcUrl, headers: tronGridHeaders }) if (contractAddress) { // TRC20 transfer - estimate energy cost - const { energyPrice } = await getChainPrices(rpcUrl) + const { energyPrice } = await getChainPrices(rpcUrl, tronGridHeaders) const result = await tronWeb.transactionBuilder.triggerConstantContract( contractAddress, @@ -63,7 +67,7 @@ export const getTronTransactionFees = async ( return String(feeInSun) } else { // TRX transfer with memo - build transaction to get accurate size - const { bandwidthPrice } = await getChainPrices(rpcUrl) + const { bandwidthPrice } = await getChainPrices(rpcUrl, tronGridHeaders) let tx = await tronWeb.transactionBuilder.sendTrx( vault, diff --git a/packages/swapper/src/types.ts b/packages/swapper/src/types.ts index b61bcfd8717..0cdf85daa90 100644 --- a/packages/swapper/src/types.ts +++ b/packages/swapper/src/types.ts @@ -58,6 +58,7 @@ export type SwapperConfig = { VITE_THORCHAIN_NODE_URL: string VITE_MAYACHAIN_NODE_URL: string VITE_TRON_NODE_URL: string + VITE_TRON_GRID_API_KEY: string VITE_FEATURE_THORCHAINSWAP_LONGTAIL: boolean VITE_FEATURE_THORCHAINSWAP_L1_TO_LONGTAIL: boolean VITE_THORCHAIN_MIDGARD_URL: string diff --git a/packages/unchained-client/src/tron/api.ts b/packages/unchained-client/src/tron/api.ts index 9c7c150d6f3..c79002c0347 100644 --- a/packages/unchained-client/src/tron/api.ts +++ b/packages/unchained-client/src/tron/api.ts @@ -4,22 +4,32 @@ import type { TronAccount, TronBlock, TronTx } from './types' export interface TronApiConfig { rpcUrl: string + apiKey?: string } export class TronApi { private readonly rpcUrl: string + private readonly apiKey: string private tronWeb: TronWeb | null = null private requestQueue: Promise = Promise.resolve() private readonly minRequestInterval = 1_500 constructor(config: TronApiConfig) { this.rpcUrl = config.rpcUrl + this.apiKey = config.apiKey ?? '' } getRpcUrl(): string { return this.rpcUrl } + private get tronGridHeaders(): Record { + return { + 'Content-Type': 'application/json', + ...(this.apiKey ? { 'TRON-PRO-API-KEY': this.apiKey } : {}), + } + } + private async throttle(): Promise { // Queue the request and wait for all previous requests to complete const currentRequest = this.requestQueue.then(async () => { @@ -32,7 +42,10 @@ export class TronApi { private getTronWeb(): TronWeb { if (!this.tronWeb) { - this.tronWeb = new TronWeb({ fullHost: this.rpcUrl }) + this.tronWeb = new TronWeb({ + fullHost: this.rpcUrl, + headers: this.apiKey ? { 'TRON-PRO-API-KEY': this.apiKey } : {}, + }) } return this.tronWeb } @@ -46,7 +59,7 @@ export class TronApi { const response = await fetch(`${this.rpcUrl}/wallet/getaccount`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this.tronGridHeaders, body: JSON.stringify({ address: params.pubkey, visible: true }), }) @@ -68,7 +81,9 @@ export class TronApi { try { await this.throttle() - const trc20Response = await fetch(`${this.rpcUrl}/v1/accounts/${params.pubkey}`) + const trc20Response = await fetch(`${this.rpcUrl}/v1/accounts/${params.pubkey}`, { + headers: this.tronGridHeaders, + }) const trc20Data = await trc20Response.json() if (trc20Data.data?.[0]?.trc20 && Array.isArray(trc20Data.data[0].trc20)) { @@ -97,6 +112,7 @@ export class TronApi { const trc20TxResponse = await fetch( `${this.rpcUrl}/v1/accounts/${params.pubkey}/transactions/trc20?limit=200&only_to=true`, + { headers: this.tronGridHeaders }, ) if (trc20TxResponse.ok) { @@ -161,12 +177,12 @@ export class TronApi { const [txResponse, infoResponse] = await Promise.all([ fetch(`${this.rpcUrl}/wallet/gettransactionbyid`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this.tronGridHeaders, body: JSON.stringify({ value: params.txid, visible: true }), }), fetch(`${this.rpcUrl}/wallet/gettransactioninfobyid`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this.tronGridHeaders, body: JSON.stringify({ value: params.txid, visible: true }), }), ]) @@ -222,7 +238,7 @@ export class TronApi { const response = await fetch(`${this.rpcUrl}/wallet/getblockbynum`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this.tronGridHeaders, body: JSON.stringify({ num: params.height }), }) diff --git a/src/components/MultiHopTrade/components/TradeConfirm/hooks/useAllowanceApproval.tsx b/src/components/MultiHopTrade/components/TradeConfirm/hooks/useAllowanceApproval.tsx index 2e6a2ae94e4..e3af119c4f5 100644 --- a/src/components/MultiHopTrade/components/TradeConfirm/hooks/useAllowanceApproval.tsx +++ b/src/components/MultiHopTrade/components/TradeConfirm/hooks/useAllowanceApproval.tsx @@ -7,6 +7,7 @@ import { useMutation } from '@tanstack/react-query' import { useEffect, useMemo } from 'react' import type { Hash } from 'viem' +import { getConfig } from '@/config' import type { AllowanceType } from '@/hooks/queries/useApprovalFees' import { getApprovalAmountCryptoBaseUnit, useApprovalFees } from '@/hooks/queries/useApprovalFees' import { useIsAllowanceApprovalRequired } from '@/hooks/queries/useIsAllowanceApprovalRequired' @@ -120,6 +121,7 @@ export const useAllowanceApproval = ( m.assertGetTronChainAdapter(tronChainId), ) const rpcUrl = adapter.httpProvider.getRpcUrl() + const apiKey = getConfig().VITE_TRON_GRID_API_KEY // Poll for transaction confirmation (TRON doesn't have waitForTransactionReceipt) let confirmed = false @@ -133,7 +135,10 @@ export const useAllowanceApproval = ( attempts < 20 ? '/wallet/gettransactionbyid' : '/walletsolidity/gettransactionbyid' const response = await fetch(`${rpcUrl}${endpoint}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { 'TRON-PRO-API-KEY': apiKey } : {}), + }, body: JSON.stringify({ value: txHash }), }) @@ -148,6 +153,8 @@ export const useAllowanceApproval = ( } // If no contractRet yet, continue polling } + // Non-OK responses (incl. 403/429 rate-limit, which TronGrid also uses for auth) are + // intentionally swallowed so transient throttling keeps polling rather than failing. } catch (err) { // Continue polling on errors unless it's a failure if (err instanceof Error && err.message.includes('Transaction failed')) { diff --git a/src/components/MultiHopTrade/components/TradeConfirm/hooks/useTradeExecution.tsx b/src/components/MultiHopTrade/components/TradeConfirm/hooks/useTradeExecution.tsx index 2cc5e8d96dd..a3cc6cfcfc8 100644 --- a/src/components/MultiHopTrade/components/TradeConfirm/hooks/useTradeExecution.tsx +++ b/src/components/MultiHopTrade/components/TradeConfirm/hooks/useTradeExecution.tsx @@ -30,6 +30,7 @@ import { useActionCenterContext } from '@/components/Layout/Header/ActionCenter/ import { SwapNotification } from '@/components/Layout/Header/ActionCenter/components/Notifications/SwapNotification' import { getMixpanelEventData } from '@/components/MultiHopTrade/helpers' import { TradeRoutePaths } from '@/components/MultiHopTrade/types' +import { queryClient } from '@/context/QueryClientProvider/queryClient' import { useErrorToast } from '@/hooks/useErrorToast/useErrorToast' import { useNotificationToast } from '@/hooks/useNotificationToast' import { useWallet } from '@/hooks/useWallet/useWallet' @@ -368,6 +369,10 @@ export const useTradeExecution = ( }), ) + // Drop cached allowances so the next swap's approval/balance check reads fresh on-chain + // state rather than a stale pre-swap value. + queryClient.removeQueries({ queryKey: ['allowanceCryptoBaseUnit'] }) + const isLastHop = hopIndex === tradeQuote.steps.length - 1 if (isLastHop && !hasMixpanelSuccessOrFailFiredRef.current) { trackMixpanelEvent(MixPanelEvent.TradeSuccess, eventDataSnapshot) diff --git a/src/config.ts b/src/config.ts index 4f2bd463f77..e6715d53757 100644 --- a/src/config.ts +++ b/src/config.ts @@ -88,6 +88,7 @@ const validators = { VITE_JITO_BLOCK_ENGINE_URL: url(), VITE_STARKNET_NODE_URL: url(), VITE_TRON_NODE_URL: url(), + VITE_TRON_GRID_API_KEY: str({ default: '' }), VITE_SUI_NODE_URL: url(), VITE_TON_NODE_URL: url(), VITE_NEAR_NODE_URL: url(), diff --git a/src/hooks/useIsTronAddressActivated/useIsTronAddressActivated.ts b/src/hooks/useIsTronAddressActivated/useIsTronAddressActivated.ts index 56a491c1e98..3528780b389 100644 --- a/src/hooks/useIsTronAddressActivated/useIsTronAddressActivated.ts +++ b/src/hooks/useIsTronAddressActivated/useIsTronAddressActivated.ts @@ -2,6 +2,7 @@ import type { ChainId } from '@shapeshiftoss/caip' import { tronChainId } from '@shapeshiftoss/caip' import { useQuery } from '@tanstack/react-query' +import { getConfig } from '@/config' import { assertGetTronChainAdapter } from '@/lib/utils/tron' const checkTronAddressActivated = async ( @@ -14,10 +15,14 @@ const checkTronAddressActivated = async ( try { const adapter = assertGetTronChainAdapter(chainId) const rpcUrl = adapter.httpProvider.getRpcUrl() + const apiKey = getConfig().VITE_TRON_GRID_API_KEY const response = await fetch(`${rpcUrl}/wallet/getaccount`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { 'TRON-PRO-API-KEY': apiKey } : {}), + }, body: JSON.stringify({ address: to, visible: true }), }) diff --git a/src/lib/utils/tron.ts b/src/lib/utils/tron.ts index 77f8c4727a7..4d64724f63c 100644 --- a/src/lib/utils/tron.ts +++ b/src/lib/utils/tron.ts @@ -4,6 +4,7 @@ import type { tron } from '@shapeshiftoss/chain-adapters' import type { KnownChainIds } from '@shapeshiftoss/types' import { TxStatus } from '@shapeshiftoss/unchained-client' +import { getConfig } from '@/config' import { getChainAdapterManager } from '@/context/PluginProvider/chainAdapterSingleton' export const isTronChainAdapter = (chainAdapter: unknown): chainAdapter is tron.ChainAdapter => { @@ -29,10 +30,14 @@ export const assertGetTronChainAdapter = (chainId: ChainId | KnownChainIds): tro export const getTronTransactionStatus = async (txHash: string): Promise => { const adapter = assertGetTronChainAdapter(tronChainId) const rpcUrl = adapter.httpProvider.getRpcUrl() + const apiKey = getConfig().VITE_TRON_GRID_API_KEY const response = await fetch(`${rpcUrl}/walletsolidity/gettransactionbyid`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { 'TRON-PRO-API-KEY': apiKey } : {}), + }, body: JSON.stringify({ value: txHash, }), diff --git a/src/lib/utils/tron/approve.ts b/src/lib/utils/tron/approve.ts index 9b6ff442c2f..721a16e55e3 100644 --- a/src/lib/utils/tron/approve.ts +++ b/src/lib/utils/tron/approve.ts @@ -6,6 +6,8 @@ import { TronWeb } from 'tronweb' import { assertGetTronChainAdapter } from '..' import type { ApproveTronInputWithWallet } from './types' +import { getConfig } from '@/config' + export const approveTron = async ({ assetId, spender, @@ -18,8 +20,10 @@ export const approveTron = async ({ const adapter = assertGetTronChainAdapter(chainId) const rpcUrl = adapter.httpProvider.getRpcUrl() + const apiKey = getConfig().VITE_TRON_GRID_API_KEY + const tronGridHeaders: Record = apiKey ? { 'TRON-PRO-API-KEY': apiKey } : {} - const tronWeb = new TronWeb({ fullHost: rpcUrl }) + const tronWeb = new TronWeb({ fullHost: rpcUrl, headers: tronGridHeaders }) // Build approve transaction const parameters = [ @@ -91,7 +95,7 @@ export const approveTron = async ({ const broadcastResponse = await fetch(`${rpcUrl}/wallet/broadcasttransaction`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...tronGridHeaders }, body: JSON.stringify(broadcastTx), }) diff --git a/src/lib/utils/tron/getAllowance.ts b/src/lib/utils/tron/getAllowance.ts index 0b5698737c8..d5720dfa2bd 100644 --- a/src/lib/utils/tron/getAllowance.ts +++ b/src/lib/utils/tron/getAllowance.ts @@ -4,6 +4,8 @@ import { TronWeb } from 'tronweb' import { assertGetTronChainAdapter } from '..' +import { getConfig } from '@/config' + type GetTrc20AllowanceArgs = { address: string spender: string @@ -29,9 +31,14 @@ export const getTrc20Allowance = async ({ // Pad to 32 bytes (64 hex chars) each const parameter = ownerHex.padStart(64, '0') + spenderHex.padStart(64, '0') + const apiKey = getConfig().VITE_TRON_GRID_API_KEY + const response = await fetch(`${rpcUrl}/wallet/triggerconstantcontract`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { 'TRON-PRO-API-KEY': apiKey } : {}), + }, body: JSON.stringify({ owner_address: from, contract_address: address, diff --git a/src/plugins/tron/index.tsx b/src/plugins/tron/index.tsx index 8a9917a3c30..324f3ee1298 100644 --- a/src/plugins/tron/index.tsx +++ b/src/plugins/tron/index.tsx @@ -18,13 +18,15 @@ export default function register(): Plugins { [ KnownChainIds.TronMainnet, () => { - const http = new unchained.tron.TronApi({ - rpcUrl: getConfig().VITE_TRON_NODE_URL, - }) + const rpcUrl = getConfig().VITE_TRON_NODE_URL + const apiKey = getConfig().VITE_TRON_GRID_API_KEY + + const http = new unchained.tron.TronApi({ rpcUrl, apiKey }) return new tron.ChainAdapter({ providers: { http }, - rpcUrl: getConfig().VITE_TRON_NODE_URL, + rpcUrl, + apiKey, }) }, ],