From 296d9781ebda479a0123fba56b0a4d9f4fc60e41 Mon Sep 17 00:00:00 2001 From: kevin <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:14:49 -0600 Subject: [PATCH 1/8] fix(swapper): flag cross-account trades for unsupported swappers (#12457) --- src/assets/translations/en/main.json | 1 + .../TradeInput/getQuoteErrorTranslation.ts | 2 ++ .../swapper/helpers/validateTradeQuote.ts | 27 +++++++++++++++++++ src/state/apis/swapper/types.ts | 1 + src/state/slices/tradeQuoteSlice/constants.ts | 1 + 5 files changed, 32 insertions(+) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 78ebba7b238..c52f024f6b4 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -1042,6 +1042,7 @@ "quoteExpired": "Quote expired, try again.", "quoteUnsupportedChain": "Chain not supported", "quoteCrossChainNotSupported": "Cross chain not supported", + "crossAccountNotSupported": "Cross-account not supported", "networkFeeEstimateFailed": "Failed to estimate network fee", "assetNotSupportedByWallet": "%{assetSymbol}.%{chainSymbol} not supported by wallet", "intermediaryAssetNotSupportedByWallet": "Intermediary asset %{assetSymbol}.%{chainSymbol} not supported by wallet", diff --git a/src/components/MultiHopTrade/components/TradeInput/getQuoteErrorTranslation.ts b/src/components/MultiHopTrade/components/TradeInput/getQuoteErrorTranslation.ts index 4f33fd0acb7..620c1a9b744 100644 --- a/src/components/MultiHopTrade/components/TradeInput/getQuoteErrorTranslation.ts +++ b/src/components/MultiHopTrade/components/TradeInput/getQuoteErrorTranslation.ts @@ -37,6 +37,8 @@ export const getQuoteErrorTranslation = ( return 'trade.errors.insufficientFundsForProtocolFee' case TradeQuoteValidationError.IntermediaryAssetNotNotSupportedByWallet: return 'trade.errors.intermediaryAssetNotSupportedByWallet' + case TradeQuoteValidationError.CrossAccountNotSupported: + return 'trade.errors.crossAccountNotSupported' case SwapperTradeQuoteError.SellAmountBelowMinimum: return tradeQuoteError.meta ? 'trade.errors.amountTooSmall' diff --git a/src/state/apis/swapper/helpers/validateTradeQuote.ts b/src/state/apis/swapper/helpers/validateTradeQuote.ts index 47cb7aa3223..ee819b855cf 100644 --- a/src/state/apis/swapper/helpers/validateTradeQuote.ts +++ b/src/state/apis/swapper/helpers/validateTradeQuote.ts @@ -21,6 +21,7 @@ import { TradeQuoteValidationError, TradeQuoteWarning } from '../types' import { isMultiHopTradeQuote, isMultiHopTradeRate } from '@/components/MultiHopTrade/utils' import { bn, bnOrZero } from '@/lib/bignumber/bignumber' import { assertGetChainAdapter, assertUnreachable, isTruthy } from '@/lib/utils' +import { isCrossAccountTradeSupported } from '@/state/helpers' import type { ReduxState } from '@/state/reducer' import { selectPortfolioAccountBalances, @@ -32,10 +33,12 @@ import { selectAssets, selectFeeAssetById, selectPortfolioAccountIdByNumberByChainId, + selectPortfolioAccountMetadataByAccountId, } from '@/state/slices/selectors' import { selectFirstHopSellAccountId, selectInputSellAmountCryptoPrecision, + selectLastHopBuyAccountId, selectSecondHopSellAccountId, } from '@/state/slices/tradeInputSlice/selectors' import { getTotalProtocolFeeByAssetForStep } from '@/state/slices/tradeQuoteSlice/helpers' @@ -168,6 +171,26 @@ export const validateTradeQuote = ( const firstHopSellAccountId = selectFirstHopSellAccountId(state) const secondHopSellAccountId = selectSecondHopSellAccountId(state) + // A trade is cross-account when the sell and receive accounts belong to different account numbers. + // Account number (not accountId) is the correct discriminator: the same account number maps to the + // same address across EVM chains, so a normal L1<->L2 bridge (e.g. account 0 -> account 0) is NOT + // cross-account, whereas account 0 -> account 1 is. Some swappers (e.g. Arbitrum Bridge) surface a + // rate but cannot fulfill a cross-account trade, so we flag it here to block execution while still + // surfacing the rate. + const buyAccountId = selectLastHopBuyAccountId(state) + const sellAccountNumber = firstHopSellAccountId + ? selectPortfolioAccountMetadataByAccountId(state, { accountId: firstHopSellAccountId }) + ?.bip44Params?.accountNumber + : undefined + const buyAccountNumber = buyAccountId + ? selectPortfolioAccountMetadataByAccountId(state, { accountId: buyAccountId })?.bip44Params + ?.accountNumber + : undefined + const isCrossAccountTrade = + sellAccountNumber !== undefined && + buyAccountNumber !== undefined && + sellAccountNumber !== buyAccountNumber + const firstHopFeeAssetBalance = selectPortfolioCryptoBalanceByFilter(state, { assetId: firstHopSellFeeAsset?.assetId, accountId: firstHopSellAccountId ?? '', @@ -336,6 +359,10 @@ export const validateTradeQuote = ( }, feesExceedsSellAmount && { error: TradeQuoteValidationError.SellAmountBelowTradeFee }, invalidQuoteSellAmount && { error: TradeQuoteValidationError.QuoteSellAmountInvalid }, + isCrossAccountTrade && + !isCrossAccountTradeSupported(swapperName) && { + error: TradeQuoteValidationError.CrossAccountNotSupported, + }, ...insufficientBalanceForProtocolFeesErrors, ].filter(isTruthy), diff --git a/src/state/apis/swapper/types.ts b/src/state/apis/swapper/types.ts index 24cb71f933c..7d0e1069397 100644 --- a/src/state/apis/swapper/types.ts +++ b/src/state/apis/swapper/types.ts @@ -30,6 +30,7 @@ export enum TradeQuoteValidationError { InsufficientSecondHopFeeAssetBalance = 'InsufficientSecondHopFeeAssetBalance', InsufficientFundsForProtocolFee = 'InsufficientFundsForProtocolFee', IntermediaryAssetNotNotSupportedByWallet = 'IntermediaryAssetNotNotSupportedByWallet', + CrossAccountNotSupported = 'CrossAccountNotSupported', QuoteSellAmountInvalid = 'QuoteSellAmountInvalid', QueryFailed = 'QueryFailed', UnknownError = 'UnknownError', diff --git a/src/state/slices/tradeQuoteSlice/constants.ts b/src/state/slices/tradeQuoteSlice/constants.ts index 7e4768b817e..9c161efc936 100644 --- a/src/state/slices/tradeQuoteSlice/constants.ts +++ b/src/state/slices/tradeQuoteSlice/constants.ts @@ -56,4 +56,5 @@ export const SWAPPER_USER_ERRORS = [ TradeQuoteValidationError.InsufficientFirstHopFeeAssetBalance, TradeQuoteValidationError.InsufficientSecondHopFeeAssetBalance, TradeQuoteValidationError.InsufficientFundsForProtocolFee, + TradeQuoteValidationError.CrossAccountNotSupported, ] From 88ebef73fa005c5e3f258b90f275a6a3d0c228de Mon Sep 17 00:00:00 2001 From: kevin <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:28:27 -0600 Subject: [PATCH 2/8] fix(chainflip): raise timeout for deposit channel creation (#12458) --- .../swapper/src/swappers/ChainflipSwapper/utils/helpers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/swapper/src/swappers/ChainflipSwapper/utils/helpers.ts b/packages/swapper/src/swappers/ChainflipSwapper/utils/helpers.ts index 77808c5a6c8..fad477246e8 100644 --- a/packages/swapper/src/swappers/ChainflipSwapper/utils/helpers.ts +++ b/packages/swapper/src/swappers/ChainflipSwapper/utils/helpers.ts @@ -126,7 +126,8 @@ export const getChainFlipSwap = ({ swapUrl += `&chunkIntervalBlocks=${chunkIntervalBlocks}` } - return chainflipService.get(swapUrl) + // Opening a deposit channel can spike past the default 10s timeout (notably on Tron). + return chainflipService.get(swapUrl, { timeout: 30_000 }) } const fetchChainFlipAssets = async ({ From 3c3b7f0ad747885cbb2a3736236caec71ec3a13a Mon Sep 17 00:00:00 2001 From: kevin <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:47:17 -0600 Subject: [PATCH 3/8] fix(swapper): fix Sun.io Tron swaps (INVALID_VERSION_LEN + OUT_OF_ENERGY) (#12459) --- .env | 1 + .../src/tron/TRON_FEE_ESTIMATION_ISSUES.md | 205 ------- .../src/tron/TronChainAdapter.ts | 25 +- packages/public-api/src/config.ts | 1 + packages/public-api/src/env.ts | 1 + packages/public-api/src/swapperDeps.ts | 8 +- .../src/swappers/SunioSwapper/INTEGRATION.md | 168 ------ .../src/swappers/SunioSwapper/endpoints.ts | 62 +- .../utils/buildSwapContractCall.ts | 37 ++ .../utils/buildSwapRouteParameters.ts | 12 +- .../utils/buildSwapTransaction.ts | 96 --- .../swappers/SunioSwapper/utils/constants.ts | 7 + .../utils/estimateSunioNetworkFee.ts | 117 ++++ .../SunioSwapper/utils/getQuoteOrRate.ts | 96 +-- .../utils/getSunioTransactionFees.ts | 42 ++ .../src/thorchain-utils/getL1RateOrQuote.ts | 3 + .../tron/THORCHAIN_TRON_INTEGRATION.md | 550 ------------------ .../tron/getTronTransactionFees.ts | 12 +- packages/swapper/src/types.ts | 1 + packages/unchained-client/src/tron/api.ts | 28 +- .../hooks/useAllowanceApproval.tsx | 9 +- .../TradeConfirm/hooks/useTradeExecution.tsx | 5 + src/config.ts | 1 + .../useIsTronAddressActivated.ts | 7 +- src/lib/utils/tron.ts | 7 +- src/lib/utils/tron/approve.ts | 8 +- src/lib/utils/tron/getAllowance.ts | 9 +- src/plugins/tron/index.tsx | 10 +- 28 files changed, 349 insertions(+), 1179 deletions(-) delete mode 100644 packages/chain-adapters/src/tron/TRON_FEE_ESTIMATION_ISSUES.md delete mode 100644 packages/swapper/src/swappers/SunioSwapper/INTEGRATION.md create mode 100644 packages/swapper/src/swappers/SunioSwapper/utils/buildSwapContractCall.ts delete mode 100644 packages/swapper/src/swappers/SunioSwapper/utils/buildSwapTransaction.ts create mode 100644 packages/swapper/src/swappers/SunioSwapper/utils/estimateSunioNetworkFee.ts create mode 100644 packages/swapper/src/swappers/SunioSwapper/utils/getSunioTransactionFees.ts delete mode 100644 packages/swapper/src/thorchain-utils/tron/THORCHAIN_TRON_INTEGRATION.md 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, }) }, ], From 230ac45201e3a7cc8e7defe512a4992a768d61e6 Mon Sep 17 00:00:00 2001 From: kevin <35275952+kaladinlight@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:17:24 -0600 Subject: [PATCH 4/8] fix(tron): scope account-activation warning to self-sends (#12461) --- src/assets/translations/en/main.json | 2 +- .../Modals/Send/views/SendAmountDetails.tsx | 33 ++++++++++++++----- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index c52f024f6b4..5f01cfdd09a 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2191,7 +2191,7 @@ }, "tron": { "recipientNotActivated": { - "sendWarning": "This address hasn't been activated on Tron yet. Your tokens will arrive, but the recipient won't be able to move them until they also receive some TRX." + "sendWarning": "This account hasn't been activated on Tron yet. Your tokens will arrive, but you won't be able to move or swap them out until the account holds some TRX to cover fees." } }, "transactionHistory": { diff --git a/src/components/Modals/Send/views/SendAmountDetails.tsx b/src/components/Modals/Send/views/SendAmountDetails.tsx index d16d22f384f..412a93f9adc 100644 --- a/src/components/Modals/Send/views/SendAmountDetails.tsx +++ b/src/components/Modals/Send/views/SendAmountDetails.tsx @@ -21,6 +21,7 @@ import { fromAssetId, starknetChainId, tronAssetId, + tronChainId, } from '@shapeshiftoss/caip' import { useMutation } from '@tanstack/react-query' import get from 'lodash/get' @@ -57,6 +58,7 @@ import { isStarknetChainAdapter } from '@/lib/utils/starknet' import { selectAssetById, selectPortfolioAccountMetadataByAccountId, + selectWalletAccountIds, } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' @@ -118,6 +120,19 @@ export const SendAmountDetails = () => { const { data: isTronRecipientActivated } = useIsTronAddressActivated(to, asset?.chainId) + const walletAccountIds = useAppSelector(selectWalletAccountIds) + // The activation warning is only actionable when funding your own fresh Tron account - it tells + // you that you'll need TRX before you can move/swap the tokens out. Sending to third-party + // addresses (incl. exchange/bridge deposit addresses like Chainflip, which sweep funds + // regardless of activation) would only produce false alarms, so scope it to self-sends. + const isToOwnTronAccount = useMemo(() => { + if (!to) return false + return walletAccountIds.some(walletAccountId => { + const { chainId, account } = fromAccountId(walletAccountId) + return chainId === tronChainId && account === to + }) + }, [walletAccountIds, to]) + const deployAccountMutation = useMutation({ mutationFn: async () => { if (!accountId || !wallet) throw new Error('Missing account or wallet') @@ -408,14 +423,16 @@ export const SendAmountDetails = () => { )} - {isTronRecipientActivated === false && asset?.assetId !== tronAssetId && ( - - - - - - - )} + {isTronRecipientActivated === false && + asset?.assetId !== tronAssetId && + isToOwnTronAccount && ( + + + + + + + )} {!hasNoAccountForAsset && ( From 0e47c661d2e65099293b51111ba5b0cb9b251b8d Mon Sep 17 00:00:00 2001 From: kevin <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:49:02 -0600 Subject: [PATCH 5/8] feat(public-api): route browser partner lookups through the keyed gateway (#12464) --- .env | 1 + .env.development | 6 +- TODO.md | 527 ++++++++++++++++++ packages/public-api/.env.example | 1 + packages/public-api/src/env.ts | 1 + packages/public-api/src/index.ts | 3 + .../public-api/src/lib/fetchSwapService.ts | 7 +- packages/public-api/src/middleware/auth.ts | 2 +- .../src/routes/partner/getPartner.ts | 83 +++ .../public-api/src/routes/partner/index.ts | 1 + .../public-api/src/routes/partner/types.ts | 21 + .../public-api/src/routes/status/utils.ts | 2 +- src/config.ts | 1 + .../useAffiliateTracking.ts | 11 +- 14 files changed, 654 insertions(+), 13 deletions(-) create mode 100644 TODO.md create mode 100644 packages/public-api/src/routes/partner/getPartner.ts create mode 100644 packages/public-api/src/routes/partner/index.ts create mode 100644 packages/public-api/src/routes/partner/types.ts diff --git a/.env b/.env index be4d84da8b8..4dd4f90ced3 100644 --- a/.env +++ b/.env @@ -351,6 +351,7 @@ VITE_TENDERLY_PROJECT_SLUG=project VITE_SWAPS_SERVER_URL=https://api.swap-service.shapeshift.com VITE_USER_SERVER_URL=https://api.user-service.shapeshift.com VITE_NOTIFICATIONS_SERVER_URL=https://api.notifications-service.shapeshift.com +VITE_PUBLIC_API_URL=https://api.shapeshift.com # Yield.xyz VITE_YIELD_XYZ_API_KEY=06903960-e442-4870-81eb-03ff3ad4c035 diff --git a/.env.development b/.env.development index c6399c767d6..9f5bd818cf2 100644 --- a/.env.development +++ b/.env.development @@ -97,14 +97,10 @@ VITE_THORCHAIN_MIDGARD_URL=https://dev-api.thorchain.shapeshift.com/midgard/v2 VITE_MAYACHAIN_MIDGARD_URL=https://dev-api.mayachain.shapeshift.com/midgard/v2 # Webservices -# Remote dev servers (default - works for all developers) VITE_SWAPS_SERVER_URL=https://dev-api.swap-service.shapeshift.com VITE_USER_SERVER_URL=https://dev-api.user-service.shapeshift.com VITE_NOTIFICATIONS_SERVER_URL=https://dev-api.notifications-service.shapeshift.com -# Local development via Vite proxy (uncomment to use localhost backend) -# VITE_SWAPS_SERVER_URL=/swaps-api -# VITE_USER_SERVER_URL=/user-api -# VITE_NOTIFICATIONS_SERVER_URL=/notifications-api +VITE_PUBLIC_API_URL=https://dev-api.shapeshift.com # Proxy API VITE_PROXY_API_BASE_URL=https://dev-api.proxy.shapeshift.com diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000000..0ea7cbbcd7b --- /dev/null +++ b/TODO.md @@ -0,0 +1,527 @@ +# TODO + +Untracked working notes — pending work pulled from auto-memory plus the in-flight +refactor identified in this session. Not for commit. + +--- + +## Refactor: split `*TransactionMetadata` from `SwapperSpecificMetadata` + +**Origin**: 2026-05-08 review of `packages/swapper/src/types.ts` while threading +THORChain transaction data through the public-api. + +**Problem**: today's `*TransactionMetadata` structs (e.g. `RelayTransactionMetadata`, +`DebridgeTransactionMetadata`, `nearIntentsSpecific`) bag two unrelated concerns +into a single shape: + +1. **Build-time tx params** — `to`, `data`, `value`, `gasLimit`, `psbt`, `opReturnData` +2. **Post-submission tracking tokens** — `relayId`, `orderId`, `isSameChainSwap`, etc. + +The same struct lives on both `TradeQuoteStep` (where the build half is consumed) +and on `Swap.metadata` / `StoredQuote.metadata` via `SwapperSpecificMetadata` (where +only the tracking half is consumed). Each phase ignores the irrelevant half. + +**Cruft confirmed**: `acrossTransactionMetadata` is copied into +`SwapperSpecificMetadata` (`packages/public-api/src/routes/quote/getQuote.ts:202`) +but `AcrossSwapper.checkTradeStatus` (`endpoints.ts:71-140`) never reads it — it +polls by tx hash. Field is fully dead post-submission. + +**Plan**: + +1. Split each `*TransactionMetadata` into `*TxBuild` (step-only) and + `*Tracking` (swap-only). +2. Keep build halves on `TradeQuoteStep`. Keep tracking halves on + `SwapperSpecificMetadata`. +3. Update all `step.TransactionMetadata` consumers + (`packages/swapper/src/swappers/*/endpoints.ts`, + `packages/public-api/src/routes/quote/extractTransactionData.ts`) to read + from the build half. +4. Update all `swap.metadata.TransactionMetadata` consumers + (`RelaySwapper/endpoints.ts:189-223`, `DebridgeSwapper/endpoints.ts:140`, + `useSwapActionSubscriber.tsx:256`) to read from the tracking half. +5. Drop `acrossTransactionMetadata` from `SwapperSpecificMetadata` entirely + + stop copying it in `getQuote.ts`. + +**Why bother**: real, modest readability win — types tell the truth about +lifecycle. Step has only what's needed to build a tx; swap has only what's needed +to track one. `thorchainTransactionMetadata` (just added) is already on the clean +side of this and stays step-only. + +**Cost**: medium churn. Touches all `*Swapper` packages plus `public-api`. +Defer until the THORChain widget integration work has settled. + +--- + +## Quote expiry enforcement (widget + public-api contract) + +**Origin**: 2026-05-08 discussion of why the swap-widget doesn't call +`swapper.getUnsigned*Transaction` at sign time. Conclusion: trusting the +public-api response is correct (external consumers can't reach the swapper +directly), but staleness must be guarded by an enforced quote expiry. + +**Problem**: today's expiry data is plumbed but not load-bearing. + +- THORNode returns an `expiry` on `/quote/swap`; we capture it on + `thorchainTransactionMetadata.expiry` + (`packages/swapper/src/types.ts:427`, set in + `packages/swapper/src/thorchain-utils/getL1RateOrQuote.ts:301,348`). +- Public-api response carries an envelope-level `expiresAt` + (`packages/public-api/src/routes/quote/types.ts:152`), but it's the + **server cache TTL** (`QuoteStore.QUOTE_TTL_MS` = 15 min), not the + THORChain vault expiry. Server TTL can outlast the network's accept window. +- `QuoteStepSchema` exposes no expiry — external API consumers can't see + the binding deadline even if they want to honor it. +- Widget mirrors `expiresAt` in its types + (`packages/swap-widget/src/types/index.ts:222`) but never reads it: no + countdown, no pre-sign guard, no auto re-quote. + +**Risk**: signing against a rotated THORChain vault (funds lost/delayed) +or against stale fees. + +**Plan**: + +1. Surface per-step expiry on the public-api response. Either lift + `thorchainTransactionMetadata.expiry` onto the step, or compute envelope + `expiresAt = min(server TTL, all step expiries)` so the envelope is the + binding deadline. Latter keeps the contract surface small. +2. Widget: block sign when `Date.now() >= expiresAt`, auto-refetch quote, + show a countdown in the UI. +3. Document the contract for external API consumers in the public-api + README: "do not sign after `expiresAt`; re-fetch the quote." + +**Note**: the existing "getQuote: quote expiry mismatch" item in the +edge-cases list (response says 1 min, stored 15 min) is a related but +distinct bug — this section is the broader design fix that subsumes it. + +--- + +## THORChain widget integration — remaining gaps + +- **swap-widget execution hook**: `useSwapExecution.ts` throws "not yet + supported" for `utxo_psbt` and `cosmos` `transactionData.type` values. +- **THORChain native (RUNE/TCY/RUJI)** — uses `MsgDeposit` with no `to` + address. Current `CosmosTransactionData` type doesn't model this shape, so + cosmos execution can't dispatch THORChain native swaps. + +--- + +## swap-widget execution/approval refactor (deferred) + +`useSwapExecution.ts` and `useSwapApproval.ts` should move from `useEffect`-driven +("watch for `stateValue === 'executing'` and react") to imperative async functions +returned for direct `onClick` invocation. Local `isPending` replaces the +`executingRef`/`approvingRef` guards; try/catch at the call site replaces the +actor-send error dispatch. + +**Why**: execution is a user-initiated mutation. Modeling it as effect-driven +inverts the call flow, requires refs to guard against dep-array re-fires that +wouldn't exist outside an effect, and forces awkward eslint dep choices. +Discussed and parked 2026-05-07 after cleaning up the existing hook (THORChain +pass-through + dispatch on `transactionData.type` + `getErrorMessage` helper). + +**Before refactoring, verify**: + +1. Who currently sends `FETCH_QUOTE` / `APPROVE`? The machine auto-transitions + `quoting → executing` on `QUOTE_SUCCESS` and `approving → executing` on + `APPROVAL_SUCCESS` (`packages/swap-widget/src/machines/swapMachine.ts`). If + the UX is "quote success auto-fires execution" with no second confirm click, + the dispatcher of `QUOTE_SUCCESS` / `APPROVAL_SUCCESS` must also invoke the + new imperative `executeSwap()`, or the user gains a click they didn't have. +2. Is the `executing` machine state referenced for UI elsewhere (e.g. + "Submitting..." in `SwapWidget.tsx`)? If so, either keep tracking via local + `isPending` on the button or send an `EXECUTE_START` event into the machine + before awaiting. + +**Target shape**: + +```ts +export const useSwapExecution = () => { + const actorRef = SwapMachineCtx.useActorRef() + const { walletClient, walletAddress, bitcoin, solana } = useSwapWallet() + return useCallback(async () => { /* same body */ }, [actorRef, walletClient, walletAddress, bitcoin, solana]) +} +``` + +Caller: `const executeSwap = useSwapExecution()` → +`