From 5509f5acc018d6c99a4dda69f287919e48870e3b Mon Sep 17 00:00:00 2001 From: Jibles Date: Fri, 27 Feb 2026 10:19:23 +0700 Subject: [PATCH 1/7] fix: correct Sun.io swap fee estimation and handle all TRON failure states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded 2000 energy estimate with dynamic calculation using actual Sun.io contract parameters (consume_user_resource_percent, origin_energy_limit, energy_factor). Use empirical base energy values with 1.3x safety margin: 195k for TRX→token, 390k for TRC20→token. Fix checkTradeStatus in both Sun.io and ButterSwap to treat any non-SUCCESS contractRet as failed, catching OUT_OF_ENERGY, REVERT, OUT_OF_TIME, TRANSFER_FAILED etc. instead of only checking for REVERT. [shapeshift-saq] Co-Authored-By: Claude Opus 4.6 --- .beads/pr-context.jsonl | 2 + .../ButterSwap/swapperApi/checkTradeStatus.ts | 3 +- .../src/swappers/SunioSwapper/endpoints.ts | 3 +- .../SunioSwapper/utils/getQuoteOrRate.ts | 143 +++++++++++------- 4 files changed, 90 insertions(+), 61 deletions(-) diff --git a/.beads/pr-context.jsonl b/.beads/pr-context.jsonl index ec4cf00c824..0c0e1dbdb02 100644 --- a/.beads/pr-context.jsonl +++ b/.beads/pr-context.jsonl @@ -1,3 +1,5 @@ +{"id":"shapeshift-20c","title":"Improve stemMatch validator with per-language morphology","description":"## Problem\n\nThe translation validator's `stemMatch` function in `scripts/script-utils.js` uses a **generic 70% prefix-chop algorithm** for all inflected languages. This is the single biggest source of false flags in the translation pipeline, causing:\n\n1. **False rejections** that force translators into grammatically awkward constructions to satisfy the validator\n2. **Inflated \"manual review\" counts** — UK had 19 strings flagged as manual review that were actually correct Ukrainian\n3. **Degraded translation quality** — RU agent was forced to use infinitive \"торговать\" mixed with imperatives just to pass validation\n\n### Benchmark Evidence (150 keys × 9 locales)\n\n**Glossary term match rates (skill translations):**\n- \"dust\" (never-translate): 100% — binary check works perfectly\n- \"wallet\": 72% — moderate inflection\n- \"staking\": 67% — moderate inflection \n- \"seed phrase\": 67%\n- \"swap\": 64%\n- \"claim\": **47%** — heavy inflection across cases/conjugations\n- \"deposit\": **40%** — heavy inflection, worst performer\n\nThese low rates are **not translation errors** — they're validator false positives. The translations used correct inflected forms (genitive, dative, imperative) that the naive prefix matcher can't recognize.\n\n### Root Cause\n\nTwo code paths in `stemMatch` (script-utils.js:11-24):\n\n1. **CJK (ja, zh)**: Exact substring match — works fine\n2. **Everything else**: Generic 70% prefix of each word — treats German, Russian, and Turkish identically despite fundamentally different morphology\n\n| Language | Morphology Type | What Changes | 70% Prefix Works? |\n|----------|----------------|--------------|-------------------|\n| de (Germanic) | Compounds + mild case | Articles change, stems stable | Yes, mostly fine |\n| es/fr/pt (Romance) | Verb conjugation + gender | Suffixes change | Borderline |\n| ru/uk (Slavic) | 6 cases + verb aspect | Stem + suffix both change | No — too many false flags |\n| tr (Agglutinative) | Suffix stacking + vowel harmony | Suffixes modify vowels | No — root preserved but suffixes stack deep |\n\n### Affected Files\n\n- `.claude/skills/translate/scripts/script-utils.js` — `stemMatch()` function (lines 11-24), `INFLECTED_LOCALES` set\n- `.claude/skills/translate/scripts/validate.js` — Check 5 (glossary compliance, lines 110-126) calls `stemMatch`\n- `src/assets/translations/glossary.json` — approved term definitions (currently single-form strings per locale)\n\n### Requirements\n\n1. Replace the generic stemMatch with **per-language matching strategies** that account for each language family's morphological patterns\n2. Support **multi-form glossary entries** (arrays alongside strings) for terms with irregular inflections, backward-compatible with existing string entries\n3. Per-language stem ratios: Germanic ~75%, Romance ~65%, Slavic ~55%, Agglutinative ~60%\n4. Per-language **suffix stripping**: strip known grammatical suffixes before matching to find the root (e.g., Russian case endings -а/-у/-е/-ом/-ой, Turkish agglutinative suffixes -ı/-in/-da/-lar, Romance conjugation endings -ado/-ido/-ando/-ción)\n5. Three-tier matching: exact substring → stem prefix → suffix-stripped root. Pass on first hit.\n6. Consider downgrading glossary-approved-translation from \"flag\" to \"info\" severity for inflected locales, so valid inflections don't trigger the review/refine loop\n\n### Acceptance Criteria\n\n- stemMatch has per-language configs (stem ratio + suffix list) for all 7 inflected locales\n- Glossary entries accept `string | string[]` per locale\n- Existing validate.js tests (if any) still pass\n- Re-running the benchmark shows \"claim\" and \"deposit\" match rates above 80%\n- No grammatically awkward forced forms in RU/UK/TR output","status":"closed","priority":2,"issue_type":"task","owner":"premiumjibles@gmail.com","created_at":"2026-02-24T18:38:15.541277548+07:00","created_by":"Jibles","updated_at":"2026-02-24T19:13:48.295324618+07:00","closed_at":"2026-02-24T19:13:48.295324618+07:00","close_reason":"Implemented per-language morphology for stemMatch. Replaced INFLECTED_LOCALES with LOCALE_CONFIGS (per-language stemRatio + suffix lists for de/es/fr/pt/ru/tr/uk). Added stripSuffix helper and 3-tier matching (exact→stem→suffix-stripped). Added multi-form glossary support (string|string[]) for claim/deposit/trade/approve in ru/uk/tr. Downgraded glossary flags to info severity for inflected locales. Updated prepare-locale.js for canonical form extraction and compile-report.js for clean display."} +{"id":"shapeshift-saq","title":"Fix Sun.io swap fee estimation and OUT_OF_ENERGY error handling","description":"## Problem\n\nSun.io TRX trades broadcast despite insufficient gas, fail on-chain with OUT_OF_ENERGY, and then spin as \"pending\" indefinitely in the notification center.\n\nLinear ticket: SS-5573 / GitHub: shapeshift/web#12039\n\nTwo distinct bugs:\n1. **Fee estimation is 45-90x too low** — the UI gas check passes when it shouldn't, so the trade broadcasts and fails on-chain\n2. **checkTradeStatus doesn't detect OUT_OF_ENERGY** — failed trades show as \"pending\" forever instead of showing an error\n\n## Research Findings\n\n### Root Cause 1: Hardcoded energy estimate assumes contract subsidy covers ~98% of energy\n\nThe code at \\`getQuoteOrRate.ts:159\\` hardcodes \\`energyUsed = 2000\\` with the comment \"Sun.io contract owner provides most energy (~117k), users only pay ~2k\". This is wrong.\n\n**Actual contract settings** (queried live from \\`/wallet/getcontract\\`):\n\n SmartExchangeRouter (TCFNp179Lg46D16zKoumd4Poa2WFFdtqYj):\n consume_user_resource_percent: 60 ← user pays 60%, NOT ~2%\n origin_energy_limit: 1,200,000 ← cap on contract's subsidy per call\n energy_factor: 0 ← Dynamic Energy Model not active\n\n**Actual energy usage from live successful transactions:**\n\n| Swap Type | Total Energy | Contract Paid (~40%) | User Paid (~60%) | User Fee (TRX) |\n|---------------|-------------|---------------------|-----------------|----------------|\n| TRC20 swap | 302,639 | 121,055 | 181,584 | 0-5+ TRX |\n| TRX→token | 149,194 | 59,677 | 89,517 | ~2.48 TRX |\n\nCurrent estimate: 2,000 energy = ~0.2 TRX. Actual user cost: 89,000-181,000 energy = 2.5-18 TRX.\n\n**The failed transaction from the bug report confirms this:**\n\n TX: 5660b12d88db18221865a1d480665b257d89177bde292f8b24ce4f6e73511949\n Result: OUT_OF_ENERGY\n Energy total: 10,116\n Origin energy (contract paid): 4,046 (40%)\n User energy: 6,070 (60%)\n Energy fee burned: 0.607 TRX\n Total fee lost: 1.534 TRX\n\n### Root Cause 2: checkTradeStatus only checks for REVERT, not OUT_OF_ENERGY\n\nAt \\`endpoints.ts:188-190\\`, the status check maps \\`REVERT\\` → \\`TxStatus.Failed\\` but \\`OUT_OF_ENERGY\\` falls through to \\`TxStatus.Pending\\`. Other parts of the codebase handle this correctly:\n- \\`src/lib/utils/tron.ts:46\\` — checks for OUT_OF_ENERGY → Failed\n- \\`useAllowanceApproval.tsx:146\\` — checks for REVERT || OUT_OF_ENERGY\n\n### Approaches Considered and Ruled Out\n\n**1. Use Sun.io API fee data** — RULED OUT\nThe Sun.io quote API (\\`/swap/router\\`) returns only routing/pricing data. No energy, gas, bandwidth, or network fee fields exist in the response. The \\`fee\\` field is the protocol swap fee (e.g. 3%), not a network fee. Confirmed by hitting the live API.\n\n**2. Use triggerConstantContract to simulate the swap** — RULED OUT\nWorks for simple \\`transfer(address,uint256)\\` calls (used by our existing \\`estimateTRC20TransferFee\\`), but Sun.io's \\`swapExactInput\\` reverts in simulation because the from address doesn't have token approvals/balances. Swap functions have preconditions that prevent dry-run simulation.\n\n**3. Use estimateEnergy API** — RULED OUT (for now)\nTronGrid (our TRON RPC) has this endpoint disabled (\\`\"this node does not support estimate energy\"\\`). Would require switching to a paid TRON RPC provider like TronQL. Not worth the infrastructure change for this fix.\n\n**4. Method 3 from TRON docs (max_factor)** — RULED OUT\nmax_factor is 3.4x on mainnet. Applied naively it would show 39-80 TRX fees for swaps that actually cost 2.5-18 TRX. This over-penalizes Sun.io in the UI, making it look uncompetitive.\n\n### Chosen Approach: Empirical estimates + live contract parameter query\n\nSince Sun.io's \\`energy_factor = 0\\` (Dynamic Energy Model doesn't apply — usage is 0.008% of the 5B threshold), the energy costs are stable and predictable. We can:\n\n1. Use realistic base energy values derived from live transaction data\n2. Query \\`consume_user_resource_percent\\` and \\`origin_energy_limit\\` from the contract to dynamically calculate the user's share\n3. This auto-adapts if Sun.io changes their contract settings without needing code changes\n\n**Energy formula:**\n adjustedEnergy = baseEnergy × (1 + energy_factor)\n contractShare = min(adjustedEnergy × (1 - consumeUserPercent/100), originEnergyLimit)\n userEnergy = adjustedEnergy - contractShare\n energyFee = userEnergy × energyPrice\n\n**Base energy values (from live txs, apply 1.3x safety margin):**\n- TRX→token swaps: ~150,000 total energy → use 195,000\n- TRC20→token swaps: ~300,000 total energy → use 390,000\n\n## Implementation Spec\n\n### Change 1: Fix fee estimation in getQuoteOrRate.ts\n\nFile: \\`packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts\\` (lines 117-199)\n\nReplace the hardcoded \\`energyUsed = 2000\\` with realistic estimates:\n\n- Query the Sun.io contract's \\`consume_user_resource_percent\\` and \\`origin_energy_limit\\` via \\`/wallet/getcontract\\` (cache this — it changes rarely)\n- Also query \\`energy_factor\\` via \\`/wallet/getcontractinfo\\` (cache alongside)\n- Use base energy of ~150,000 for TRX swaps, ~300,000 for TRC20 swaps (with 1.3x safety margin)\n- Calculate user's share: \\`totalEnergy × consumeUserPercent/100\\`\n- Multiply by live \\`energyPrice\\` (already fetched from chain params)\n- The bandwidth estimate of 1100 bytes is roughly correct based on actual tx sizes seen (~927-1116 bytes) — keep it\n\nConsider caching the contract params in a module-level variable with a TTL (e.g. 1 hour), since they change rarely and we don't want an extra RPC call per quote.\n\n### Change 2: Fix checkTradeStatus in endpoints.ts\n\nFile: \\`packages/swapper/src/swappers/SunioSwapper/endpoints.ts\\` (lines 185-190)\n\nAdd \\`OUT_OF_ENERGY\\` as a failure condition. Current code:\n\n contractRet === 'REVERT' ? TxStatus.Failed : TxStatus.Pending\n\nShould be:\n\n contractRet === 'REVERT' || contractRet === 'OUT_OF_ENERGY' ? TxStatus.Failed : TxStatus.Pending\n\nThis matches the pattern used in \\`src/lib/utils/tron.ts:46\\` and \\`useAllowanceApproval.tsx:146\\`.\n\nAlso consider handling other TRON failure codes: \\`OUT_OF_TIME\\`, \\`JVM_STACK_OVER_FLOW\\`, \\`UNKNOWN\\`, \\`TRANSFER_FAILED\\`, etc. A safe approach: treat any \\`contractRet\\` that isn't \\`SUCCESS\\` and isn't absent/null as Failed (with the exception of no contractRet yet = still pending).\n\n### Change 3 (optional): Also check for OUT_OF_ENERGY in other Tron swappers\n\nCheck if Relay and ButterSwap's checkTradeStatus implementations have the same gap. If they delegate to a shared utility, this may already be covered. If they have their own Tron status checking, apply the same fix.\n\n## Relevant Files\n\n**Primary (must change):**\n- \\`packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts\\` — fee estimation (lines 117-199)\n- \\`packages/swapper/src/swappers/SunioSwapper/endpoints.ts\\` — checkTradeStatus (lines 185-190)\n\n**Reference (correct patterns to follow):**\n- \\`src/lib/utils/tron.ts:46\\` — correct OUT_OF_ENERGY handling\n- \\`src/components/MultiHopTrade/components/TradeConfirm/hooks/useAllowanceApproval.tsx:146\\` — correct OUT_OF_ENERGY handling\n- \\`packages/unchained-client/src/tron/api.ts:248-277\\` — estimateTRC20TransferFee pattern (for reference on how energy estimation is done for simpler calls)\n- \\`packages/chain-adapters/src/tron/TronChainAdapter.ts:457-573\\` — getFeeData (reference for energy calculation with safety margins)\n\n**Context (read for understanding):**\n- \\`packages/swapper/src/swappers/SunioSwapper/types.ts\\` — SunioRoute type (no fee fields from API)\n- \\`packages/swapper/src/swappers/SunioSwapper/utils/constants.ts\\` — contract address, API URL\n- \\`packages/swapper/src/swappers/SunioSwapper/INTEGRATION.md\\` — integration docs\n- \\`src/state/apis/swapper/helpers/validateTradeQuote.ts:211-223\\` — the balance check that uses the fee estimate\n\n**Key external data:**\n- Sun.io SmartExchangeRouter contract: \\`TCFNp179Lg46D16zKoumd4Poa2WFFdtqYj\\`\n- consume_user_resource_percent: 60\n- origin_energy_limit: 1,200,000\n- energy_factor: 0\n- Current energyPrice: 100 sun/unit\n- Current bandwidthPrice: 1,000 sun/byte\n\n## Acceptance Criteria\n\n1. Trade does not broadcast if user has insufficient TRX to cover realistic gas estimate\n2. Fee displayed in UI is within ~1.5x of actual on-chain cost (not 45-90x too low)\n3. If a trade fails on-chain with OUT_OF_ENERGY, the UI shows it as failed (not stuck on \"pending\")\n4. Fee estimation adapts if Sun.io changes contract energy settings (consume_user_resource_percent, origin_energy_limit) without code changes\n5. Existing lint and type-check pass (\\`yarn lint --fix \u0026\u0026 yarn type-check\\`)","status":"in_progress","priority":1,"issue_type":"bug","owner":"premiumjibles@gmail.com","created_at":"2026-02-27T10:09:30.218673815+07:00","created_by":"Jibles","updated_at":"2026-02-27T10:16:36.011826556+07:00"} {"id":"shapeshiftWeb-2f09","title":"Sanity check Ink + Scroll regen data","description":"Verify generatedAssetData.json has entries for both eip155:534352 (Scroll) and eip155:57073 (Ink). Verify relatedAssetIndex.json has inkAssetId in ETH related array. Verify no regressions. Run review-second-class-evm skill.","status":"closed","priority":1,"issue_type":"task","owner":"contact@0xgom.es","created_at":"2026-02-19T13:51:24.013329+01:00","created_by":"gomes-bot","updated_at":"2026-02-19T17:01:37.198079+01:00","closed_at":"2026-02-19T17:01:37.198079+01:00","close_reason":"Popular assets + market data verified working after cache clear. All ink fixes merged, PR #11960 opened.","dependencies":[{"issue_id":"shapeshiftWeb-2f09","depends_on_id":"shapeshiftWeb-cgtg","type":"blocks","created_at":"2026-02-19T13:51:48.716437+01:00","created_by":"gomes-bot"}]} {"id":"shapeshiftWeb-4uq9","title":"Checkout + merge-fix Ink PR #11904","description":"Checkout Ink PR, extract regen data before merge, merge origin/develop with -X theirs to resolve all conflicts in favor of develop. Result: branch has Ink code changes but develop's generated files.","status":"closed","priority":1,"issue_type":"task","owner":"contact@0xgom.es","created_at":"2026-02-19T13:50:52.705351+01:00","created_by":"gomes-bot","updated_at":"2026-02-19T13:53:13.843624+01:00","closed_at":"2026-02-19T13:53:13.843624+01:00","close_reason":"Merged origin/develop with -X theirs, all conflicts resolved"} {"id":"shapeshiftWeb-cgtg","title":"Cherry-pick Ink regen data into develop generated files","description":"Extract Ink (eip155:57073) entries from saved PR generated files. Merge into develop's generatedAssetData.json, relatedAssetIndex.json. Create coingecko adapter. Bump clearAssets migration. Regenerate manifest hashes + brotli/gzip compression.","status":"closed","priority":1,"issue_type":"task","owner":"contact@0xgom.es","created_at":"2026-02-19T13:51:03.136273+01:00","created_by":"gomes-bot","updated_at":"2026-02-19T13:56:30.878339+01:00","closed_at":"2026-02-19T13:56:30.878339+01:00","close_reason":"Added coingecko adapter, index.ts import/export, migration bump 293. User will run yarn generate:asset-data for actual regen.","dependencies":[{"issue_id":"shapeshiftWeb-cgtg","depends_on_id":"shapeshiftWeb-4uq9","type":"blocks","created_at":"2026-02-19T13:51:38.351227+01:00","created_by":"gomes-bot"}]} diff --git a/packages/swapper/src/swappers/ButterSwap/swapperApi/checkTradeStatus.ts b/packages/swapper/src/swappers/ButterSwap/swapperApi/checkTradeStatus.ts index 659b0cf654d..d0a4e0d7e7c 100644 --- a/packages/swapper/src/swappers/ButterSwap/swapperApi/checkTradeStatus.ts +++ b/packages/swapper/src/swappers/ButterSwap/swapperApi/checkTradeStatus.ts @@ -62,11 +62,10 @@ export const checkTradeStatus = async (input: CheckTradeStatusInput): Promise 0 ? TxStatus.Confirmed - : contractRet === 'REVERT' + : contractRet && contractRet !== 'SUCCESS' ? TxStatus.Failed : TxStatus.Pending diff --git a/packages/swapper/src/swappers/SunioSwapper/endpoints.ts b/packages/swapper/src/swappers/SunioSwapper/endpoints.ts index fc73d55f543..7f93fcf8ba0 100644 --- a/packages/swapper/src/swappers/SunioSwapper/endpoints.ts +++ b/packages/swapper/src/swappers/SunioSwapper/endpoints.ts @@ -181,11 +181,10 @@ 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' + : contractRet && contractRet !== 'SUCCESS' ? TxStatus.Failed : TxStatus.Pending diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts b/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts index d6ae1564f95..ae8b5fcb7ac 100644 --- a/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts +++ b/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts @@ -20,6 +20,56 @@ import { fetchSunioQuote } from './fetchFromSunio' import { isSupportedChainId } from './helpers/helpers' import { sunioServiceFactory } from './sunioService' +type ContractParams = { + consumeUserResourcePercent: number + originEnergyLimit: number + energyFactor: number +} + +const CONTRACT_PARAMS_CACHE_TTL_MS = 60 * 60 * 1000 +let cachedContractParams: { params: ContractParams; fetchedAt: number } | undefined + +async function getContractParams(rpcUrl: string): Promise { + const now = Date.now() + if (cachedContractParams && now - cachedContractParams.fetchedAt < CONTRACT_PARAMS_CACHE_TTL_MS) { + return cachedContractParams.params + } + + const [contractResponse, contractInfoResponse] = await Promise.all([ + fetch(`${rpcUrl}/wallet/getcontract`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: SUNIO_SMART_ROUTER_CONTRACT, visible: true }), + }), + fetch(`${rpcUrl}/wallet/getcontractinfo`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: SUNIO_SMART_ROUTER_CONTRACT, visible: true }), + }), + ]) + + const contractData = await contractResponse.json() + const contractInfoData = await contractInfoResponse.json() + + const params: ContractParams = { + consumeUserResourcePercent: contractData.consume_user_resource_percent ?? 60, + originEnergyLimit: contractData.origin_energy_limit ?? 1_200_000, + energyFactor: contractInfoData.contract_state?.energy_factor ?? 0, + } + + cachedContractParams = { params, fetchedAt: now } + return params +} + +function calculateUserEnergy(totalEnergy: number, contractParams: ContractParams): number { + const userShare = totalEnergy * (contractParams.consumeUserResourcePercent / 100) + return Math.min(userShare, contractParams.originEnergyLimit) +} + +const SAFETY_MARGIN = 1.3 +const BASE_ENERGY_TRX_TO_TOKEN = Math.ceil(150_000 * SAFETY_MARGIN) +const BASE_ENERGY_TRC20_TO_TOKEN = Math.ceil(300_000 * SAFETY_MARGIN) + export async function getQuoteOrRate( input: GetTronTradeQuoteInput | CommonTradeQuoteInput, deps: SwapperDeps, @@ -114,84 +164,63 @@ 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) { try { + const rpcUrl = deps.config.VITE_TRON_NODE_URL const contractAddress = contractAddressOrUndefined(sellAsset.assetId) const isSellingNativeTrx = !contractAddress - const tronWeb = new TronWeb({ fullHost: deps.config.VITE_TRON_NODE_URL }) + const tronWeb = new TronWeb({ fullHost: rpcUrl }) + + const [chainParams, contractParams] = await Promise.all([ + tronWeb.trx.getChainParameters(), + getContractParams(rpcUrl), + ]) - // 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 + const bandwidthPrice = chainParams.find(p => p.key === 'getTransactionFee')?.value ?? 1000 + const energyPrice = chainParams.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 recipientInfoResponse = await fetch(`${rpcUrl}/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 + accountActivationFee = 1_000_000 } } 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) - } + const baseEnergy = isSellingNativeTrx + ? BASE_ENERGY_TRX_TO_TOKEN + : BASE_ENERGY_TRC20_TO_TOKEN + + const adjustedEnergy = + contractParams.energyFactor > 0 + ? Math.ceil(baseEnergy * (1 + contractParams.energyFactor)) + : baseEnergy + + const userEnergy = calculateUserEnergy(adjustedEnergy, contractParams) + + const energyFee = userEnergy * energyPrice + const bandwidthFee = 1100 * bandwidthPrice + + networkFeeCryptoBaseUnit = bn(energyFee) + .plus(bandwidthFee) + .plus(accountActivationFee) + .toFixed(0) } catch (error) { - // For rates, fall back to '0' on estimation failure - // For quotes, let it error (required for accurate swap) if (!isQuote) { - networkFeeCryptoBaseUnit = '0' + const fallbackEnergy = BASE_ENERGY_TRC20_TO_TOKEN * 0.6 + const fallbackFee = Math.ceil(fallbackEnergy * 100 + 1100 * 1000) + networkFeeCryptoBaseUnit = String(fallbackFee) } else { throw error } From bfdd2c97b16473d19e88876c6ee9acc2cd393270 Mon Sep 17 00:00:00 2001 From: Jibles Date: Fri, 27 Feb 2026 15:53:31 +0700 Subject: [PATCH 2/7] fix: improve Sun.io fee estimation with caching and always-on estimates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache chain prices (10min TTL) and contract params (1hr TTL) to reduce TronGrid RPC calls and avoid 429 rate limiting - Show fee estimates even without wallet connected (rates) instead of "(unknown)" — only gate the recipient activation check on receiveAddress - Adjust base energy constants based on observed transaction data: TRX→token 85k, TRC20→token 170k with 1.1x safety margin Co-Authored-By: Claude Opus 4.6 --- .../SunioSwapper/utils/getQuoteOrRate.ts | 107 +++++++++++------- 1 file changed, 63 insertions(+), 44 deletions(-) diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts b/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts index ae8b5fcb7ac..d37825b7447 100644 --- a/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts +++ b/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts @@ -26,9 +26,35 @@ type ContractParams = { energyFactor: number } +type ChainPrices = { + bandwidthPrice: number + energyPrice: number +} + const CONTRACT_PARAMS_CACHE_TTL_MS = 60 * 60 * 1000 let cachedContractParams: { params: ContractParams; fetchedAt: number } | undefined +const CHAIN_PRICES_CACHE_TTL_MS = 10 * 60 * 1000 +let cachedChainPrices: { prices: ChainPrices; fetchedAt: number } | undefined + +async function getChainPrices(rpcUrl: string): Promise { + const now = Date.now() + if (cachedChainPrices && now - cachedChainPrices.fetchedAt < CHAIN_PRICES_CACHE_TTL_MS) { + return cachedChainPrices.prices + } + + const tronWeb = new TronWeb({ fullHost: rpcUrl }) + const chainParams = await tronWeb.trx.getChainParameters() + + const prices: ChainPrices = { + bandwidthPrice: chainParams.find(p => p.key === 'getTransactionFee')?.value ?? 1000, + energyPrice: chainParams.find(p => p.key === 'getEnergyFee')?.value ?? 100, + } + + cachedChainPrices = { prices, fetchedAt: now } + return prices +} + async function getContractParams(rpcUrl: string): Promise { const now = Date.now() if (cachedContractParams && now - cachedContractParams.fetchedAt < CONTRACT_PARAMS_CACHE_TTL_MS) { @@ -66,9 +92,9 @@ function calculateUserEnergy(totalEnergy: number, contractParams: ContractParams return Math.min(userShare, contractParams.originEnergyLimit) } -const SAFETY_MARGIN = 1.3 -const BASE_ENERGY_TRX_TO_TOKEN = Math.ceil(150_000 * SAFETY_MARGIN) -const BASE_ENERGY_TRC20_TO_TOKEN = Math.ceil(300_000 * SAFETY_MARGIN) +const SAFETY_MARGIN = 1.1 +const BASE_ENERGY_TRX_TO_TOKEN = Math.ceil(85_000 * SAFETY_MARGIN) +const BASE_ENERGY_TRC20_TO_TOKEN = Math.ceil(170_000 * SAFETY_MARGIN) export async function getQuoteOrRate( input: GetTronTradeQuoteInput | CommonTradeQuoteInput, @@ -166,23 +192,18 @@ export async function getQuoteOrRate( let networkFeeCryptoBaseUnit: string | undefined = undefined - if (receiveAddress) { - try { - const rpcUrl = deps.config.VITE_TRON_NODE_URL - const contractAddress = contractAddressOrUndefined(sellAsset.assetId) - const isSellingNativeTrx = !contractAddress - - const tronWeb = new TronWeb({ fullHost: rpcUrl }) + try { + const rpcUrl = deps.config.VITE_TRON_NODE_URL + const contractAddress = contractAddressOrUndefined(sellAsset.assetId) + const isSellingNativeTrx = !contractAddress - const [chainParams, contractParams] = await Promise.all([ - tronWeb.trx.getChainParameters(), - getContractParams(rpcUrl), - ]) + const [{ bandwidthPrice, energyPrice }, contractParams] = await Promise.all([ + getChainPrices(rpcUrl), + getContractParams(rpcUrl), + ]) - const bandwidthPrice = chainParams.find(p => p.key === 'getTransactionFee')?.value ?? 1000 - const energyPrice = chainParams.find(p => p.key === 'getEnergyFee')?.value ?? 100 - - let accountActivationFee = 0 + let accountActivationFee = 0 + if (receiveAddress) { try { const recipientInfoResponse = await fetch(`${rpcUrl}/wallet/getaccount`, { method: 'POST', @@ -197,33 +218,31 @@ export async function getQuoteOrRate( } catch { // Ignore activation check errors } + } - const baseEnergy = isSellingNativeTrx - ? BASE_ENERGY_TRX_TO_TOKEN - : BASE_ENERGY_TRC20_TO_TOKEN - - const adjustedEnergy = - contractParams.energyFactor > 0 - ? Math.ceil(baseEnergy * (1 + contractParams.energyFactor)) - : baseEnergy - - const userEnergy = calculateUserEnergy(adjustedEnergy, contractParams) - - const energyFee = userEnergy * energyPrice - const bandwidthFee = 1100 * bandwidthPrice - - networkFeeCryptoBaseUnit = bn(energyFee) - .plus(bandwidthFee) - .plus(accountActivationFee) - .toFixed(0) - } catch (error) { - if (!isQuote) { - const fallbackEnergy = BASE_ENERGY_TRC20_TO_TOKEN * 0.6 - const fallbackFee = Math.ceil(fallbackEnergy * 100 + 1100 * 1000) - networkFeeCryptoBaseUnit = String(fallbackFee) - } else { - throw error - } + const baseEnergy = isSellingNativeTrx ? BASE_ENERGY_TRX_TO_TOKEN : BASE_ENERGY_TRC20_TO_TOKEN + + const adjustedEnergy = + contractParams.energyFactor > 0 + ? Math.ceil(baseEnergy * (1 + contractParams.energyFactor)) + : baseEnergy + + const userEnergy = calculateUserEnergy(adjustedEnergy, contractParams) + + const energyFee = userEnergy * energyPrice + const bandwidthFee = 1100 * bandwidthPrice + + networkFeeCryptoBaseUnit = bn(energyFee) + .plus(bandwidthFee) + .plus(accountActivationFee) + .toFixed(0) + } catch (error) { + if (!isQuote) { + const fallbackEnergy = BASE_ENERGY_TRC20_TO_TOKEN * 0.6 + const fallbackFee = Math.ceil(fallbackEnergy * 100 + 1100 * 1000) + networkFeeCryptoBaseUnit = String(fallbackFee) + } else { + throw error } } From 25a588e2b5bc1651f4ed6e81fe41fe630b0f6613 Mon Sep 17 00:00:00 2001 From: Jibles Date: Sun, 1 Mar 2026 20:11:00 +0700 Subject: [PATCH 3/7] feat: add dynamic Sun.io fee estimation via triggerconstantcontract Simulate the actual swap call with triggerconstantcontract to get per-route energy estimates instead of relying on static medians. This gives accurate fees for both simple and complex routes. - Extract convertAddressesToEvmFormat to shared utility - Use real sender address for simulation (quotes only) - Fall back to static estimates for rates (no wallet) or on failure Co-Authored-By: Claude Opus 4.6 --- .beads/pr-context.jsonl | 3 +- .../src/swappers/SunioSwapper/endpoints.ts | 14 +-- .../utils/convertAddressesToEvmFormat.ts | 14 +++ .../SunioSwapper/utils/getQuoteOrRate.ts | 105 ++++++++++++++++-- 4 files changed, 110 insertions(+), 26 deletions(-) create mode 100644 packages/swapper/src/swappers/SunioSwapper/utils/convertAddressesToEvmFormat.ts diff --git a/.beads/pr-context.jsonl b/.beads/pr-context.jsonl index 0c0e1dbdb02..e5545523c4f 100644 --- a/.beads/pr-context.jsonl +++ b/.beads/pr-context.jsonl @@ -1,5 +1,6 @@ +{"id":"shapeshift-185","title":"Use triggerconstantcontract for dynamic Sun.io swap fee estimation","description":"## Context\n\nSun.io swap energy consumption varies dramatically by route complexity:\n- TRX→Token: 77k–336k energy (median 200k)\n- TRC20→Token: 166k–426k energy (median 303k)\n\nOur current static estimates target the median, which means simple single-hop swaps show ~2x overestimated fees while complex multi-hop routes can be underestimated. A user doing a simple TRX→USDT swap sees ~12 TRX estimated but pays ~5.5 TRX.\n\n## Research Findings\n\n### Data from 172 real Sun.io router transactions\n\n**TRX-to-Token (47 txs):**\n| Percentile | Total Energy |\n|---|---|\n| P10 | 118,575 |\n| P25 | 149,194 |\n| P50 | 199,676 |\n| P75 | 238,224 |\n| P90 | 266,769 |\n\n**TRC20-to-Token (125 txs):**\n| Percentile | Total Energy |\n|---|---|\n| P10 | 195,781 |\n| P25 | 277,344 |\n| P50 | 302,542 |\n| P75 | 320,933 |\n| P90 | 359,650 |\n\n### Key technical findings\n- TronGrid does NOT support `estimateenergy` API (returns \"this node does not support estimate energy\")\n- TronGrid DOES support `triggerconstantcontract` which simulates a contract call and returns `energy_used` + `energy_penalty`\n- This is the same approach TronLink and other TRON wallets use for fee estimation\n- The existing codebase already uses `triggerconstantcontract` in `TronApi.estimateTRC20TransferFee()` — follow that pattern\n- Sun.io router contract: `TCFNp179Lg46D16zKoumd4Poa2WFFdtqYj`\n- Contract params: `consume_user_resource_percent=60`, `origin_energy_limit=1,200,000`, `energy_factor=0`\n- Current energy price: 100 sun/unit, bandwidth price: 1000 sun/unit\n\n## Approach\n\nCall `triggerconstantcontract` on the Sun.io router with the actual swap calldata to get a per-route energy estimate. The swap route and encoded calldata are already available from the Sun.io API response (`bestRoute`). The transaction encoding logic already exists in `getUnsignedTronTransaction()` — extract or reuse the ABI encoding to build the call for simulation.\n\n### Flow:\n1. Get Sun.io quote/route (already done before fee estimation)\n2. Encode the swap call using the route data (same ABI encoding as execution)\n3. Call `triggerconstantcontract` with the encoded data to simulate execution\n4. Read `energy_used` from response, add `energy_penalty` if present\n5. Apply `consume_user_resource_percent` (60%) to get user's share\n6. Apply small safety margin (1.1x) for estimation variance\n7. Fall back to current static estimates if simulation fails\n\n### Important considerations:\n- This adds 1 RPC call per quote/rate — must be added to the existing `Promise.all` to avoid extra latency\n- Cache the result per route hash (same route = same energy) with short TTL (~30s)\n- For rates without wallet connected, we still need the calldata to simulate — check if Sun.io route data is sufficient to encode the call without a sender address (may need a dummy sender for simulation)\n- `triggerconstantcontract` does NOT consume energy or cost anything — it's a read-only simulation\n\n## Affected Files\n\n- `packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts` — main fee estimation logic, replace static energy with dynamic\n- `packages/swapper/src/swappers/SunioSwapper/endpoints.ts` — contains `getUnsignedTronTransaction()` with the ABI encoding logic to reference/extract\n- `packages/swapper/src/swappers/SunioSwapper/utils/constants.ts` — Sun.io router contract address and ABI\n- `packages/unchained-client/src/tron/api.ts` — existing `estimateTRC20TransferFee()` pattern to follow for `triggerconstantcontract` usage\n\n## Acceptance Criteria\n\n- [ ] Fee estimation uses `triggerconstantcontract` to simulate the actual swap and get real energy usage\n- [ ] Estimated fee for a simple TRX→token single-hop swap is within 20% of actual on-chain cost\n- [ ] Estimated fee for a complex multi-hop TRC20→token swap is within 20% of actual on-chain cost\n- [ ] Falls back gracefully to current static estimates if simulation fails (429, timeout, etc.)\n- [ ] No additional latency — simulation runs in parallel with other RPC calls\n- [ ] Works for both rates (no wallet) and quotes (with wallet) — use dummy sender if needed for rates\n- [ ] Passes `yarn lint --fix` and `yarn type-check`","status":"open","priority":2,"issue_type":"feature","owner":"premiumjibles@gmail.com","created_at":"2026-02-27T15:49:58.395221393+07:00","created_by":"Jibles","updated_at":"2026-02-27T15:50:29.971573304+07:00"} {"id":"shapeshift-20c","title":"Improve stemMatch validator with per-language morphology","description":"## Problem\n\nThe translation validator's `stemMatch` function in `scripts/script-utils.js` uses a **generic 70% prefix-chop algorithm** for all inflected languages. This is the single biggest source of false flags in the translation pipeline, causing:\n\n1. **False rejections** that force translators into grammatically awkward constructions to satisfy the validator\n2. **Inflated \"manual review\" counts** — UK had 19 strings flagged as manual review that were actually correct Ukrainian\n3. **Degraded translation quality** — RU agent was forced to use infinitive \"торговать\" mixed with imperatives just to pass validation\n\n### Benchmark Evidence (150 keys × 9 locales)\n\n**Glossary term match rates (skill translations):**\n- \"dust\" (never-translate): 100% — binary check works perfectly\n- \"wallet\": 72% — moderate inflection\n- \"staking\": 67% — moderate inflection \n- \"seed phrase\": 67%\n- \"swap\": 64%\n- \"claim\": **47%** — heavy inflection across cases/conjugations\n- \"deposit\": **40%** — heavy inflection, worst performer\n\nThese low rates are **not translation errors** — they're validator false positives. The translations used correct inflected forms (genitive, dative, imperative) that the naive prefix matcher can't recognize.\n\n### Root Cause\n\nTwo code paths in `stemMatch` (script-utils.js:11-24):\n\n1. **CJK (ja, zh)**: Exact substring match — works fine\n2. **Everything else**: Generic 70% prefix of each word — treats German, Russian, and Turkish identically despite fundamentally different morphology\n\n| Language | Morphology Type | What Changes | 70% Prefix Works? |\n|----------|----------------|--------------|-------------------|\n| de (Germanic) | Compounds + mild case | Articles change, stems stable | Yes, mostly fine |\n| es/fr/pt (Romance) | Verb conjugation + gender | Suffixes change | Borderline |\n| ru/uk (Slavic) | 6 cases + verb aspect | Stem + suffix both change | No — too many false flags |\n| tr (Agglutinative) | Suffix stacking + vowel harmony | Suffixes modify vowels | No — root preserved but suffixes stack deep |\n\n### Affected Files\n\n- `.claude/skills/translate/scripts/script-utils.js` — `stemMatch()` function (lines 11-24), `INFLECTED_LOCALES` set\n- `.claude/skills/translate/scripts/validate.js` — Check 5 (glossary compliance, lines 110-126) calls `stemMatch`\n- `src/assets/translations/glossary.json` — approved term definitions (currently single-form strings per locale)\n\n### Requirements\n\n1. Replace the generic stemMatch with **per-language matching strategies** that account for each language family's morphological patterns\n2. Support **multi-form glossary entries** (arrays alongside strings) for terms with irregular inflections, backward-compatible with existing string entries\n3. Per-language stem ratios: Germanic ~75%, Romance ~65%, Slavic ~55%, Agglutinative ~60%\n4. Per-language **suffix stripping**: strip known grammatical suffixes before matching to find the root (e.g., Russian case endings -а/-у/-е/-ом/-ой, Turkish agglutinative suffixes -ı/-in/-da/-lar, Romance conjugation endings -ado/-ido/-ando/-ción)\n5. Three-tier matching: exact substring → stem prefix → suffix-stripped root. Pass on first hit.\n6. Consider downgrading glossary-approved-translation from \"flag\" to \"info\" severity for inflected locales, so valid inflections don't trigger the review/refine loop\n\n### Acceptance Criteria\n\n- stemMatch has per-language configs (stem ratio + suffix list) for all 7 inflected locales\n- Glossary entries accept `string | string[]` per locale\n- Existing validate.js tests (if any) still pass\n- Re-running the benchmark shows \"claim\" and \"deposit\" match rates above 80%\n- No grammatically awkward forced forms in RU/UK/TR output","status":"closed","priority":2,"issue_type":"task","owner":"premiumjibles@gmail.com","created_at":"2026-02-24T18:38:15.541277548+07:00","created_by":"Jibles","updated_at":"2026-02-24T19:13:48.295324618+07:00","closed_at":"2026-02-24T19:13:48.295324618+07:00","close_reason":"Implemented per-language morphology for stemMatch. Replaced INFLECTED_LOCALES with LOCALE_CONFIGS (per-language stemRatio + suffix lists for de/es/fr/pt/ru/tr/uk). Added stripSuffix helper and 3-tier matching (exact→stem→suffix-stripped). Added multi-form glossary support (string|string[]) for claim/deposit/trade/approve in ru/uk/tr. Downgraded glossary flags to info severity for inflected locales. Updated prepare-locale.js for canonical form extraction and compile-report.js for clean display."} -{"id":"shapeshift-saq","title":"Fix Sun.io swap fee estimation and OUT_OF_ENERGY error handling","description":"## Problem\n\nSun.io TRX trades broadcast despite insufficient gas, fail on-chain with OUT_OF_ENERGY, and then spin as \"pending\" indefinitely in the notification center.\n\nLinear ticket: SS-5573 / GitHub: shapeshift/web#12039\n\nTwo distinct bugs:\n1. **Fee estimation is 45-90x too low** — the UI gas check passes when it shouldn't, so the trade broadcasts and fails on-chain\n2. **checkTradeStatus doesn't detect OUT_OF_ENERGY** — failed trades show as \"pending\" forever instead of showing an error\n\n## Research Findings\n\n### Root Cause 1: Hardcoded energy estimate assumes contract subsidy covers ~98% of energy\n\nThe code at \\`getQuoteOrRate.ts:159\\` hardcodes \\`energyUsed = 2000\\` with the comment \"Sun.io contract owner provides most energy (~117k), users only pay ~2k\". This is wrong.\n\n**Actual contract settings** (queried live from \\`/wallet/getcontract\\`):\n\n SmartExchangeRouter (TCFNp179Lg46D16zKoumd4Poa2WFFdtqYj):\n consume_user_resource_percent: 60 ← user pays 60%, NOT ~2%\n origin_energy_limit: 1,200,000 ← cap on contract's subsidy per call\n energy_factor: 0 ← Dynamic Energy Model not active\n\n**Actual energy usage from live successful transactions:**\n\n| Swap Type | Total Energy | Contract Paid (~40%) | User Paid (~60%) | User Fee (TRX) |\n|---------------|-------------|---------------------|-----------------|----------------|\n| TRC20 swap | 302,639 | 121,055 | 181,584 | 0-5+ TRX |\n| TRX→token | 149,194 | 59,677 | 89,517 | ~2.48 TRX |\n\nCurrent estimate: 2,000 energy = ~0.2 TRX. Actual user cost: 89,000-181,000 energy = 2.5-18 TRX.\n\n**The failed transaction from the bug report confirms this:**\n\n TX: 5660b12d88db18221865a1d480665b257d89177bde292f8b24ce4f6e73511949\n Result: OUT_OF_ENERGY\n Energy total: 10,116\n Origin energy (contract paid): 4,046 (40%)\n User energy: 6,070 (60%)\n Energy fee burned: 0.607 TRX\n Total fee lost: 1.534 TRX\n\n### Root Cause 2: checkTradeStatus only checks for REVERT, not OUT_OF_ENERGY\n\nAt \\`endpoints.ts:188-190\\`, the status check maps \\`REVERT\\` → \\`TxStatus.Failed\\` but \\`OUT_OF_ENERGY\\` falls through to \\`TxStatus.Pending\\`. Other parts of the codebase handle this correctly:\n- \\`src/lib/utils/tron.ts:46\\` — checks for OUT_OF_ENERGY → Failed\n- \\`useAllowanceApproval.tsx:146\\` — checks for REVERT || OUT_OF_ENERGY\n\n### Approaches Considered and Ruled Out\n\n**1. Use Sun.io API fee data** — RULED OUT\nThe Sun.io quote API (\\`/swap/router\\`) returns only routing/pricing data. No energy, gas, bandwidth, or network fee fields exist in the response. The \\`fee\\` field is the protocol swap fee (e.g. 3%), not a network fee. Confirmed by hitting the live API.\n\n**2. Use triggerConstantContract to simulate the swap** — RULED OUT\nWorks for simple \\`transfer(address,uint256)\\` calls (used by our existing \\`estimateTRC20TransferFee\\`), but Sun.io's \\`swapExactInput\\` reverts in simulation because the from address doesn't have token approvals/balances. Swap functions have preconditions that prevent dry-run simulation.\n\n**3. Use estimateEnergy API** — RULED OUT (for now)\nTronGrid (our TRON RPC) has this endpoint disabled (\\`\"this node does not support estimate energy\"\\`). Would require switching to a paid TRON RPC provider like TronQL. Not worth the infrastructure change for this fix.\n\n**4. Method 3 from TRON docs (max_factor)** — RULED OUT\nmax_factor is 3.4x on mainnet. Applied naively it would show 39-80 TRX fees for swaps that actually cost 2.5-18 TRX. This over-penalizes Sun.io in the UI, making it look uncompetitive.\n\n### Chosen Approach: Empirical estimates + live contract parameter query\n\nSince Sun.io's \\`energy_factor = 0\\` (Dynamic Energy Model doesn't apply — usage is 0.008% of the 5B threshold), the energy costs are stable and predictable. We can:\n\n1. Use realistic base energy values derived from live transaction data\n2. Query \\`consume_user_resource_percent\\` and \\`origin_energy_limit\\` from the contract to dynamically calculate the user's share\n3. This auto-adapts if Sun.io changes their contract settings without needing code changes\n\n**Energy formula:**\n adjustedEnergy = baseEnergy × (1 + energy_factor)\n contractShare = min(adjustedEnergy × (1 - consumeUserPercent/100), originEnergyLimit)\n userEnergy = adjustedEnergy - contractShare\n energyFee = userEnergy × energyPrice\n\n**Base energy values (from live txs, apply 1.3x safety margin):**\n- TRX→token swaps: ~150,000 total energy → use 195,000\n- TRC20→token swaps: ~300,000 total energy → use 390,000\n\n## Implementation Spec\n\n### Change 1: Fix fee estimation in getQuoteOrRate.ts\n\nFile: \\`packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts\\` (lines 117-199)\n\nReplace the hardcoded \\`energyUsed = 2000\\` with realistic estimates:\n\n- Query the Sun.io contract's \\`consume_user_resource_percent\\` and \\`origin_energy_limit\\` via \\`/wallet/getcontract\\` (cache this — it changes rarely)\n- Also query \\`energy_factor\\` via \\`/wallet/getcontractinfo\\` (cache alongside)\n- Use base energy of ~150,000 for TRX swaps, ~300,000 for TRC20 swaps (with 1.3x safety margin)\n- Calculate user's share: \\`totalEnergy × consumeUserPercent/100\\`\n- Multiply by live \\`energyPrice\\` (already fetched from chain params)\n- The bandwidth estimate of 1100 bytes is roughly correct based on actual tx sizes seen (~927-1116 bytes) — keep it\n\nConsider caching the contract params in a module-level variable with a TTL (e.g. 1 hour), since they change rarely and we don't want an extra RPC call per quote.\n\n### Change 2: Fix checkTradeStatus in endpoints.ts\n\nFile: \\`packages/swapper/src/swappers/SunioSwapper/endpoints.ts\\` (lines 185-190)\n\nAdd \\`OUT_OF_ENERGY\\` as a failure condition. Current code:\n\n contractRet === 'REVERT' ? TxStatus.Failed : TxStatus.Pending\n\nShould be:\n\n contractRet === 'REVERT' || contractRet === 'OUT_OF_ENERGY' ? TxStatus.Failed : TxStatus.Pending\n\nThis matches the pattern used in \\`src/lib/utils/tron.ts:46\\` and \\`useAllowanceApproval.tsx:146\\`.\n\nAlso consider handling other TRON failure codes: \\`OUT_OF_TIME\\`, \\`JVM_STACK_OVER_FLOW\\`, \\`UNKNOWN\\`, \\`TRANSFER_FAILED\\`, etc. A safe approach: treat any \\`contractRet\\` that isn't \\`SUCCESS\\` and isn't absent/null as Failed (with the exception of no contractRet yet = still pending).\n\n### Change 3 (optional): Also check for OUT_OF_ENERGY in other Tron swappers\n\nCheck if Relay and ButterSwap's checkTradeStatus implementations have the same gap. If they delegate to a shared utility, this may already be covered. If they have their own Tron status checking, apply the same fix.\n\n## Relevant Files\n\n**Primary (must change):**\n- \\`packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts\\` — fee estimation (lines 117-199)\n- \\`packages/swapper/src/swappers/SunioSwapper/endpoints.ts\\` — checkTradeStatus (lines 185-190)\n\n**Reference (correct patterns to follow):**\n- \\`src/lib/utils/tron.ts:46\\` — correct OUT_OF_ENERGY handling\n- \\`src/components/MultiHopTrade/components/TradeConfirm/hooks/useAllowanceApproval.tsx:146\\` — correct OUT_OF_ENERGY handling\n- \\`packages/unchained-client/src/tron/api.ts:248-277\\` — estimateTRC20TransferFee pattern (for reference on how energy estimation is done for simpler calls)\n- \\`packages/chain-adapters/src/tron/TronChainAdapter.ts:457-573\\` — getFeeData (reference for energy calculation with safety margins)\n\n**Context (read for understanding):**\n- \\`packages/swapper/src/swappers/SunioSwapper/types.ts\\` — SunioRoute type (no fee fields from API)\n- \\`packages/swapper/src/swappers/SunioSwapper/utils/constants.ts\\` — contract address, API URL\n- \\`packages/swapper/src/swappers/SunioSwapper/INTEGRATION.md\\` — integration docs\n- \\`src/state/apis/swapper/helpers/validateTradeQuote.ts:211-223\\` — the balance check that uses the fee estimate\n\n**Key external data:**\n- Sun.io SmartExchangeRouter contract: \\`TCFNp179Lg46D16zKoumd4Poa2WFFdtqYj\\`\n- consume_user_resource_percent: 60\n- origin_energy_limit: 1,200,000\n- energy_factor: 0\n- Current energyPrice: 100 sun/unit\n- Current bandwidthPrice: 1,000 sun/byte\n\n## Acceptance Criteria\n\n1. Trade does not broadcast if user has insufficient TRX to cover realistic gas estimate\n2. Fee displayed in UI is within ~1.5x of actual on-chain cost (not 45-90x too low)\n3. If a trade fails on-chain with OUT_OF_ENERGY, the UI shows it as failed (not stuck on \"pending\")\n4. Fee estimation adapts if Sun.io changes contract energy settings (consume_user_resource_percent, origin_energy_limit) without code changes\n5. Existing lint and type-check pass (\\`yarn lint --fix \u0026\u0026 yarn type-check\\`)","status":"in_progress","priority":1,"issue_type":"bug","owner":"premiumjibles@gmail.com","created_at":"2026-02-27T10:09:30.218673815+07:00","created_by":"Jibles","updated_at":"2026-02-27T10:16:36.011826556+07:00"} +{"id":"shapeshift-saq","title":"Fix Sun.io swap fee estimation and OUT_OF_ENERGY error handling","description":"## Problem\n\nSun.io TRX trades broadcast despite insufficient gas, fail on-chain with OUT_OF_ENERGY, and then spin as \"pending\" indefinitely in the notification center.\n\nLinear ticket: SS-5573 / GitHub: shapeshift/web#12039\n\nTwo distinct bugs:\n1. **Fee estimation is 45-90x too low** — the UI gas check passes when it shouldn't, so the trade broadcasts and fails on-chain\n2. **checkTradeStatus doesn't detect OUT_OF_ENERGY** — failed trades show as \"pending\" forever instead of showing an error\n\n## Research Findings\n\n### Root Cause 1: Hardcoded energy estimate assumes contract subsidy covers ~98% of energy\n\nThe code at \\`getQuoteOrRate.ts:159\\` hardcodes \\`energyUsed = 2000\\` with the comment \"Sun.io contract owner provides most energy (~117k), users only pay ~2k\". This is wrong.\n\n**Actual contract settings** (queried live from \\`/wallet/getcontract\\`):\n\n SmartExchangeRouter (TCFNp179Lg46D16zKoumd4Poa2WFFdtqYj):\n consume_user_resource_percent: 60 ← user pays 60%, NOT ~2%\n origin_energy_limit: 1,200,000 ← cap on contract's subsidy per call\n energy_factor: 0 ← Dynamic Energy Model not active\n\n**Actual energy usage from live successful transactions:**\n\n| Swap Type | Total Energy | Contract Paid (~40%) | User Paid (~60%) | User Fee (TRX) |\n|---------------|-------------|---------------------|-----------------|----------------|\n| TRC20 swap | 302,639 | 121,055 | 181,584 | 0-5+ TRX |\n| TRX→token | 149,194 | 59,677 | 89,517 | ~2.48 TRX |\n\nCurrent estimate: 2,000 energy = ~0.2 TRX. Actual user cost: 89,000-181,000 energy = 2.5-18 TRX.\n\n**The failed transaction from the bug report confirms this:**\n\n TX: 5660b12d88db18221865a1d480665b257d89177bde292f8b24ce4f6e73511949\n Result: OUT_OF_ENERGY\n Energy total: 10,116\n Origin energy (contract paid): 4,046 (40%)\n User energy: 6,070 (60%)\n Energy fee burned: 0.607 TRX\n Total fee lost: 1.534 TRX\n\n### Root Cause 2: checkTradeStatus only checks for REVERT, not OUT_OF_ENERGY\n\nAt \\`endpoints.ts:188-190\\`, the status check maps \\`REVERT\\` → \\`TxStatus.Failed\\` but \\`OUT_OF_ENERGY\\` falls through to \\`TxStatus.Pending\\`. Other parts of the codebase handle this correctly:\n- \\`src/lib/utils/tron.ts:46\\` — checks for OUT_OF_ENERGY → Failed\n- \\`useAllowanceApproval.tsx:146\\` — checks for REVERT || OUT_OF_ENERGY\n\n### Approaches Considered and Ruled Out\n\n**1. Use Sun.io API fee data** — RULED OUT\nThe Sun.io quote API (\\`/swap/router\\`) returns only routing/pricing data. No energy, gas, bandwidth, or network fee fields exist in the response. The \\`fee\\` field is the protocol swap fee (e.g. 3%), not a network fee. Confirmed by hitting the live API.\n\n**2. Use triggerConstantContract to simulate the swap** — RULED OUT\nWorks for simple \\`transfer(address,uint256)\\` calls (used by our existing \\`estimateTRC20TransferFee\\`), but Sun.io's \\`swapExactInput\\` reverts in simulation because the from address doesn't have token approvals/balances. Swap functions have preconditions that prevent dry-run simulation.\n\n**3. Use estimateEnergy API** — RULED OUT (for now)\nTronGrid (our TRON RPC) has this endpoint disabled (\\`\"this node does not support estimate energy\"\\`). Would require switching to a paid TRON RPC provider like TronQL. Not worth the infrastructure change for this fix.\n\n**4. Method 3 from TRON docs (max_factor)** — RULED OUT\nmax_factor is 3.4x on mainnet. Applied naively it would show 39-80 TRX fees for swaps that actually cost 2.5-18 TRX. This over-penalizes Sun.io in the UI, making it look uncompetitive.\n\n### Chosen Approach: Empirical estimates + live contract parameter query\n\nSince Sun.io's \\`energy_factor = 0\\` (Dynamic Energy Model doesn't apply — usage is 0.008% of the 5B threshold), the energy costs are stable and predictable. We can:\n\n1. Use realistic base energy values derived from live transaction data\n2. Query \\`consume_user_resource_percent\\` and \\`origin_energy_limit\\` from the contract to dynamically calculate the user's share\n3. This auto-adapts if Sun.io changes their contract settings without needing code changes\n\n**Energy formula:**\n adjustedEnergy = baseEnergy × (1 + energy_factor)\n contractShare = min(adjustedEnergy × (1 - consumeUserPercent/100), originEnergyLimit)\n userEnergy = adjustedEnergy - contractShare\n energyFee = userEnergy × energyPrice\n\n**Base energy values (from live txs, apply 1.3x safety margin):**\n- TRX→token swaps: ~150,000 total energy → use 195,000\n- TRC20→token swaps: ~300,000 total energy → use 390,000\n\n## Implementation Spec\n\n### Change 1: Fix fee estimation in getQuoteOrRate.ts\n\nFile: \\`packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts\\` (lines 117-199)\n\nReplace the hardcoded \\`energyUsed = 2000\\` with realistic estimates:\n\n- Query the Sun.io contract's \\`consume_user_resource_percent\\` and \\`origin_energy_limit\\` via \\`/wallet/getcontract\\` (cache this — it changes rarely)\n- Also query \\`energy_factor\\` via \\`/wallet/getcontractinfo\\` (cache alongside)\n- Use base energy of ~150,000 for TRX swaps, ~300,000 for TRC20 swaps (with 1.3x safety margin)\n- Calculate user's share: \\`totalEnergy × consumeUserPercent/100\\`\n- Multiply by live \\`energyPrice\\` (already fetched from chain params)\n- The bandwidth estimate of 1100 bytes is roughly correct based on actual tx sizes seen (~927-1116 bytes) — keep it\n\nConsider caching the contract params in a module-level variable with a TTL (e.g. 1 hour), since they change rarely and we don't want an extra RPC call per quote.\n\n### Change 2: Fix checkTradeStatus in endpoints.ts\n\nFile: \\`packages/swapper/src/swappers/SunioSwapper/endpoints.ts\\` (lines 185-190)\n\nAdd \\`OUT_OF_ENERGY\\` as a failure condition. Current code:\n\n contractRet === 'REVERT' ? TxStatus.Failed : TxStatus.Pending\n\nShould be:\n\n contractRet === 'REVERT' || contractRet === 'OUT_OF_ENERGY' ? TxStatus.Failed : TxStatus.Pending\n\nThis matches the pattern used in \\`src/lib/utils/tron.ts:46\\` and \\`useAllowanceApproval.tsx:146\\`.\n\nAlso consider handling other TRON failure codes: \\`OUT_OF_TIME\\`, \\`JVM_STACK_OVER_FLOW\\`, \\`UNKNOWN\\`, \\`TRANSFER_FAILED\\`, etc. A safe approach: treat any \\`contractRet\\` that isn't \\`SUCCESS\\` and isn't absent/null as Failed (with the exception of no contractRet yet = still pending).\n\n### Change 3 (optional): Also check for OUT_OF_ENERGY in other Tron swappers\n\nCheck if Relay and ButterSwap's checkTradeStatus implementations have the same gap. If they delegate to a shared utility, this may already be covered. If they have their own Tron status checking, apply the same fix.\n\n## Relevant Files\n\n**Primary (must change):**\n- \\`packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts\\` — fee estimation (lines 117-199)\n- \\`packages/swapper/src/swappers/SunioSwapper/endpoints.ts\\` — checkTradeStatus (lines 185-190)\n\n**Reference (correct patterns to follow):**\n- \\`src/lib/utils/tron.ts:46\\` — correct OUT_OF_ENERGY handling\n- \\`src/components/MultiHopTrade/components/TradeConfirm/hooks/useAllowanceApproval.tsx:146\\` — correct OUT_OF_ENERGY handling\n- \\`packages/unchained-client/src/tron/api.ts:248-277\\` — estimateTRC20TransferFee pattern (for reference on how energy estimation is done for simpler calls)\n- \\`packages/chain-adapters/src/tron/TronChainAdapter.ts:457-573\\` — getFeeData (reference for energy calculation with safety margins)\n\n**Context (read for understanding):**\n- \\`packages/swapper/src/swappers/SunioSwapper/types.ts\\` — SunioRoute type (no fee fields from API)\n- \\`packages/swapper/src/swappers/SunioSwapper/utils/constants.ts\\` — contract address, API URL\n- \\`packages/swapper/src/swappers/SunioSwapper/INTEGRATION.md\\` — integration docs\n- \\`src/state/apis/swapper/helpers/validateTradeQuote.ts:211-223\\` — the balance check that uses the fee estimate\n\n**Key external data:**\n- Sun.io SmartExchangeRouter contract: \\`TCFNp179Lg46D16zKoumd4Poa2WFFdtqYj\\`\n- consume_user_resource_percent: 60\n- origin_energy_limit: 1,200,000\n- energy_factor: 0\n- Current energyPrice: 100 sun/unit\n- Current bandwidthPrice: 1,000 sun/byte\n\n## Acceptance Criteria\n\n1. Trade does not broadcast if user has insufficient TRX to cover realistic gas estimate\n2. Fee displayed in UI is within ~1.5x of actual on-chain cost (not 45-90x too low)\n3. If a trade fails on-chain with OUT_OF_ENERGY, the UI shows it as failed (not stuck on \"pending\")\n4. Fee estimation adapts if Sun.io changes contract energy settings (consume_user_resource_percent, origin_energy_limit) without code changes\n5. Existing lint and type-check pass (\\`yarn lint --fix \u0026\u0026 yarn type-check\\`)","status":"closed","priority":1,"issue_type":"bug","owner":"premiumjibles@gmail.com","created_at":"2026-02-27T10:09:30.218673815+07:00","created_by":"Jibles","updated_at":"2026-02-27T10:20:04.19372967+07:00","closed_at":"2026-02-27T10:20:04.19372967+07:00","close_reason":"Implemented dynamic fee estimation using contract params + 1-hour cache, fixed checkTradeStatus in Sun.io and ButterSwap to catch all non-SUCCESS failure states. PR #12045."} {"id":"shapeshiftWeb-2f09","title":"Sanity check Ink + Scroll regen data","description":"Verify generatedAssetData.json has entries for both eip155:534352 (Scroll) and eip155:57073 (Ink). Verify relatedAssetIndex.json has inkAssetId in ETH related array. Verify no regressions. Run review-second-class-evm skill.","status":"closed","priority":1,"issue_type":"task","owner":"contact@0xgom.es","created_at":"2026-02-19T13:51:24.013329+01:00","created_by":"gomes-bot","updated_at":"2026-02-19T17:01:37.198079+01:00","closed_at":"2026-02-19T17:01:37.198079+01:00","close_reason":"Popular assets + market data verified working after cache clear. All ink fixes merged, PR #11960 opened.","dependencies":[{"issue_id":"shapeshiftWeb-2f09","depends_on_id":"shapeshiftWeb-cgtg","type":"blocks","created_at":"2026-02-19T13:51:48.716437+01:00","created_by":"gomes-bot"}]} {"id":"shapeshiftWeb-4uq9","title":"Checkout + merge-fix Ink PR #11904","description":"Checkout Ink PR, extract regen data before merge, merge origin/develop with -X theirs to resolve all conflicts in favor of develop. Result: branch has Ink code changes but develop's generated files.","status":"closed","priority":1,"issue_type":"task","owner":"contact@0xgom.es","created_at":"2026-02-19T13:50:52.705351+01:00","created_by":"gomes-bot","updated_at":"2026-02-19T13:53:13.843624+01:00","closed_at":"2026-02-19T13:53:13.843624+01:00","close_reason":"Merged origin/develop with -X theirs, all conflicts resolved"} {"id":"shapeshiftWeb-cgtg","title":"Cherry-pick Ink regen data into develop generated files","description":"Extract Ink (eip155:57073) entries from saved PR generated files. Merge into develop's generatedAssetData.json, relatedAssetIndex.json. Create coingecko adapter. Bump clearAssets migration. Regenerate manifest hashes + brotli/gzip compression.","status":"closed","priority":1,"issue_type":"task","owner":"contact@0xgom.es","created_at":"2026-02-19T13:51:03.136273+01:00","created_by":"gomes-bot","updated_at":"2026-02-19T13:56:30.878339+01:00","closed_at":"2026-02-19T13:56:30.878339+01:00","close_reason":"Added coingecko adapter, index.ts import/export, migration bump 293. User will run yarn generate:asset-data for actual regen.","dependencies":[{"issue_id":"shapeshiftWeb-cgtg","depends_on_id":"shapeshiftWeb-4uq9","type":"blocks","created_at":"2026-02-19T13:51:38.351227+01:00","created_by":"gomes-bot"}]} diff --git a/packages/swapper/src/swappers/SunioSwapper/endpoints.ts b/packages/swapper/src/swappers/SunioSwapper/endpoints.ts index 7f93fcf8ba0..3121272ab57 100644 --- a/packages/swapper/src/swappers/SunioSwapper/endpoints.ts +++ b/packages/swapper/src/swappers/SunioSwapper/endpoints.ts @@ -24,22 +24,10 @@ import { getSunioTradeQuote } from './getSunioTradeQuote/getSunioTradeQuote' import { getSunioTradeRate } from './getSunioTradeRate/getSunioTradeRate' import { buildSwapRouteParameters } from './utils/buildSwapRouteParameters' import { SUNIO_SMART_ROUTER_CONTRACT } from './utils/constants' +import { convertAddressesToEvmFormat } from './utils/convertAddressesToEvmFormat' 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, diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/convertAddressesToEvmFormat.ts b/packages/swapper/src/swappers/SunioSwapper/utils/convertAddressesToEvmFormat.ts new file mode 100644 index 00000000000..72bbdc241fc --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/utils/convertAddressesToEvmFormat.ts @@ -0,0 +1,14 @@ +import { TronWeb } from 'tronweb' + +export 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 +} diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts b/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts index d37825b7447..19f13105b53 100644 --- a/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts +++ b/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts @@ -15,7 +15,14 @@ import type { } from '../../../types' import { SwapperName, TradeQuoteError } from '../../../types' import { getInputOutputRate, makeSwapErrorRight } from '../../../utils' -import { DEFAULT_SLIPPAGE_PERCENTAGE, SUNIO_SMART_ROUTER_CONTRACT } from './constants' +import type { SunioRoute } from '../types' +import { buildSwapRouteParameters } from './buildSwapRouteParameters' +import { + DEFAULT_SLIPPAGE_PERCENTAGE, + SUNIO_SMART_ROUTER_CONTRACT, + SUNIO_TRON_NATIVE_ADDRESS, +} from './constants' +import { convertAddressesToEvmFormat } from './convertAddressesToEvmFormat' import { fetchSunioQuote } from './fetchFromSunio' import { isSupportedChainId } from './helpers/helpers' import { sunioServiceFactory } from './sunioService' @@ -96,6 +103,64 @@ const SAFETY_MARGIN = 1.1 const BASE_ENERGY_TRX_TO_TOKEN = Math.ceil(85_000 * SAFETY_MARGIN) const BASE_ENERGY_TRC20_TO_TOKEN = Math.ceil(170_000 * SAFETY_MARGIN) +async function simulateSwapEnergy( + route: SunioRoute, + sellAmountCryptoBaseUnit: string, + buyAmountCryptoBaseUnit: string, + senderAddress: string, + rpcUrl: string, +): Promise { + try { + const tronWeb = new TronWeb({ fullHost: rpcUrl }) + + const routeParams = buildSwapRouteParameters( + route, + sellAmountCryptoBaseUnit, + buyAmountCryptoBaseUnit, + senderAddress, + DEFAULT_SLIPPAGE_PERCENTAGE, + ) + + 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 isSellingNativeTrx = !route.tokens[0] || route.tokens[0] === SUNIO_TRON_NATIVE_ADDRESS + const callValue = isSellingNativeTrx ? Number(sellAmountCryptoBaseUnit) : 0 + + const result = await tronWeb.transactionBuilder.triggerConstantContract( + SUNIO_SMART_ROUTER_CONTRACT, + functionSelector, + { callValue }, + parameters, + senderAddress, + ) + + const energyUsed = result.energy_used ?? 0 + const energyPenalty = result.energy_penalty ?? 0 + const totalEnergy = energyUsed + energyPenalty + + return totalEnergy > 0 ? totalEnergy : undefined + } catch { + return undefined + } +} + export async function getQuoteOrRate( input: GetTronTradeQuoteInput | CommonTradeQuoteInput, deps: SwapperDeps, @@ -190,6 +255,11 @@ export async function getQuoteOrRate( ) } + const buyAmountCryptoBaseUnit = BigAmount.fromPrecision({ + value: bestRoute.amountOut, + precision: buyAsset.precision, + }).toBaseUnit() + let networkFeeCryptoBaseUnit: string | undefined = undefined try { @@ -197,9 +267,20 @@ export async function getQuoteOrRate( const contractAddress = contractAddressOrUndefined(sellAsset.assetId) const isSellingNativeTrx = !contractAddress - const [{ bandwidthPrice, energyPrice }, contractParams] = await Promise.all([ + const senderAddress = 'sendAddress' in input ? input.sendAddress : undefined + + const [{ bandwidthPrice, energyPrice }, contractParams, simulatedEnergy] = await Promise.all([ getChainPrices(rpcUrl), getContractParams(rpcUrl), + senderAddress + ? simulateSwapEnergy( + bestRoute, + sellAmountIncludingProtocolFeesCryptoBaseUnit, + buyAmountCryptoBaseUnit, + senderAddress, + rpcUrl, + ) + : Promise.resolve(undefined), ]) let accountActivationFee = 0 @@ -220,12 +301,17 @@ export async function getQuoteOrRate( } } - const baseEnergy = isSellingNativeTrx ? BASE_ENERGY_TRX_TO_TOKEN : BASE_ENERGY_TRC20_TO_TOKEN + const baseEnergy = simulatedEnergy + ? Math.ceil(simulatedEnergy * SAFETY_MARGIN) + : isSellingNativeTrx + ? BASE_ENERGY_TRX_TO_TOKEN + : BASE_ENERGY_TRC20_TO_TOKEN - const adjustedEnergy = - contractParams.energyFactor > 0 - ? Math.ceil(baseEnergy * (1 + contractParams.energyFactor)) - : baseEnergy + const adjustedEnergy = simulatedEnergy + ? baseEnergy + : contractParams.energyFactor > 0 + ? Math.ceil(baseEnergy * (1 + contractParams.energyFactor)) + : baseEnergy const userEnergy = calculateUserEnergy(adjustedEnergy, contractParams) @@ -246,11 +332,6 @@ export async function getQuoteOrRate( } } - const buyAmountCryptoBaseUnit = BigAmount.fromPrecision({ - value: bestRoute.amountOut, - precision: buyAsset.precision, - }).toBaseUnit() - // Calculate protocol fees only for quotes const protocolFeeCryptoBaseUnit = isQuote ? bn(bestRoute.fee).times(sellAmountIncludingProtocolFeesCryptoBaseUnit).toFixed(0) From 0ffb34e497aaa4728e7e5fd9156afc8845845607 Mon Sep 17 00:00:00 2001 From: Jibles Date: Sun, 1 Mar 2026 21:23:14 +0700 Subject: [PATCH 4/7] fix: simplify Sun.io fee estimation to hop-count based static calc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip out dynamic RPC infrastructure (triggerConstantContract simulation, getChainPrices, getContractParams caching) that added complexity without improving accuracy — simulation REVERTs for TRC20 sells (no allowance), and chain params are effectively constant. Replace with simple hop-count scaling using hardcoded constants. This still underestimates for tokens with energy penalties (USDT has 340% penalty) which will be addressed in a follow-up. Co-Authored-By: Claude Opus 4.6 --- .../SunioSwapper/utils/getQuoteOrRate.ts | 248 +++--------------- 1 file changed, 35 insertions(+), 213 deletions(-) diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts b/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts index 19f13105b53..2d0ef8dc3de 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, @@ -15,151 +14,17 @@ import type { } from '../../../types' import { SwapperName, TradeQuoteError } from '../../../types' import { getInputOutputRate, makeSwapErrorRight } from '../../../utils' -import type { SunioRoute } from '../types' -import { buildSwapRouteParameters } from './buildSwapRouteParameters' -import { - DEFAULT_SLIPPAGE_PERCENTAGE, - SUNIO_SMART_ROUTER_CONTRACT, - SUNIO_TRON_NATIVE_ADDRESS, -} from './constants' -import { convertAddressesToEvmFormat } from './convertAddressesToEvmFormat' +import { DEFAULT_SLIPPAGE_PERCENTAGE, SUNIO_SMART_ROUTER_CONTRACT } from './constants' import { fetchSunioQuote } from './fetchFromSunio' import { isSupportedChainId } from './helpers/helpers' import { sunioServiceFactory } from './sunioService' -type ContractParams = { - consumeUserResourcePercent: number - originEnergyLimit: number - energyFactor: number -} - -type ChainPrices = { - bandwidthPrice: number - energyPrice: number -} - -const CONTRACT_PARAMS_CACHE_TTL_MS = 60 * 60 * 1000 -let cachedContractParams: { params: ContractParams; fetchedAt: number } | undefined - -const CHAIN_PRICES_CACHE_TTL_MS = 10 * 60 * 1000 -let cachedChainPrices: { prices: ChainPrices; fetchedAt: number } | undefined - -async function getChainPrices(rpcUrl: string): Promise { - const now = Date.now() - if (cachedChainPrices && now - cachedChainPrices.fetchedAt < CHAIN_PRICES_CACHE_TTL_MS) { - return cachedChainPrices.prices - } - - const tronWeb = new TronWeb({ fullHost: rpcUrl }) - const chainParams = await tronWeb.trx.getChainParameters() - - const prices: ChainPrices = { - bandwidthPrice: chainParams.find(p => p.key === 'getTransactionFee')?.value ?? 1000, - energyPrice: chainParams.find(p => p.key === 'getEnergyFee')?.value ?? 100, - } - - cachedChainPrices = { prices, fetchedAt: now } - return prices -} - -async function getContractParams(rpcUrl: string): Promise { - const now = Date.now() - if (cachedContractParams && now - cachedContractParams.fetchedAt < CONTRACT_PARAMS_CACHE_TTL_MS) { - return cachedContractParams.params - } - - const [contractResponse, contractInfoResponse] = await Promise.all([ - fetch(`${rpcUrl}/wallet/getcontract`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ value: SUNIO_SMART_ROUTER_CONTRACT, visible: true }), - }), - fetch(`${rpcUrl}/wallet/getcontractinfo`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ value: SUNIO_SMART_ROUTER_CONTRACT, visible: true }), - }), - ]) - - const contractData = await contractResponse.json() - const contractInfoData = await contractInfoResponse.json() - - const params: ContractParams = { - consumeUserResourcePercent: contractData.consume_user_resource_percent ?? 60, - originEnergyLimit: contractData.origin_energy_limit ?? 1_200_000, - energyFactor: contractInfoData.contract_state?.energy_factor ?? 0, - } - - cachedContractParams = { params, fetchedAt: now } - return params -} - -function calculateUserEnergy(totalEnergy: number, contractParams: ContractParams): number { - const userShare = totalEnergy * (contractParams.consumeUserResourcePercent / 100) - return Math.min(userShare, contractParams.originEnergyLimit) -} - +const ENERGY_PRICE = 100 +const BANDWIDTH_PRICE = 1000 +const USER_ENERGY_SHARE = 0.6 +const ENERGY_PER_HOP_TRX_SELL = 65_000 +const ENERGY_PER_HOP_TRC20_SELL = 85_000 const SAFETY_MARGIN = 1.1 -const BASE_ENERGY_TRX_TO_TOKEN = Math.ceil(85_000 * SAFETY_MARGIN) -const BASE_ENERGY_TRC20_TO_TOKEN = Math.ceil(170_000 * SAFETY_MARGIN) - -async function simulateSwapEnergy( - route: SunioRoute, - sellAmountCryptoBaseUnit: string, - buyAmountCryptoBaseUnit: string, - senderAddress: string, - rpcUrl: string, -): Promise { - try { - const tronWeb = new TronWeb({ fullHost: rpcUrl }) - - const routeParams = buildSwapRouteParameters( - route, - sellAmountCryptoBaseUnit, - buyAmountCryptoBaseUnit, - senderAddress, - DEFAULT_SLIPPAGE_PERCENTAGE, - ) - - 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 isSellingNativeTrx = !route.tokens[0] || route.tokens[0] === SUNIO_TRON_NATIVE_ADDRESS - const callValue = isSellingNativeTrx ? Number(sellAmountCryptoBaseUnit) : 0 - - const result = await tronWeb.transactionBuilder.triggerConstantContract( - SUNIO_SMART_ROUTER_CONTRACT, - functionSelector, - { callValue }, - parameters, - senderAddress, - ) - - const energyUsed = result.energy_used ?? 0 - const energyPenalty = result.energy_penalty ?? 0 - const totalEnergy = energyUsed + energyPenalty - - return totalEnergy > 0 ? totalEnergy : undefined - } catch { - return undefined - } -} export async function getQuoteOrRate( input: GetTronTradeQuoteInput | CommonTradeQuoteInput, @@ -186,8 +51,6 @@ export async function getQuoteOrRate( slippageTolerancePercentageDecimal, } = input - const { assertGetTronChainAdapter: _assertGetTronChainAdapter } = deps - if (!isSupportedChainId(sellAsset.chainId)) { return Err( makeSwapErrorRight({ @@ -245,7 +108,6 @@ export async function getQuoteOrRate( const isQuote = input.quoteOrRate === 'quote' - // For quotes, receiveAddress is required if (isQuote && !receiveAddress) { return Err( makeSwapErrorRight({ @@ -260,79 +122,39 @@ export async function getQuoteOrRate( precision: buyAsset.precision, }).toBaseUnit() - let networkFeeCryptoBaseUnit: string | undefined = undefined - - try { - const rpcUrl = deps.config.VITE_TRON_NODE_URL - const contractAddress = contractAddressOrUndefined(sellAsset.assetId) - const isSellingNativeTrx = !contractAddress - - const senderAddress = 'sendAddress' in input ? input.sendAddress : undefined - - const [{ bandwidthPrice, energyPrice }, contractParams, simulatedEnergy] = await Promise.all([ - getChainPrices(rpcUrl), - getContractParams(rpcUrl), - senderAddress - ? simulateSwapEnergy( - bestRoute, - sellAmountIncludingProtocolFeesCryptoBaseUnit, - buyAmountCryptoBaseUnit, - senderAddress, - rpcUrl, - ) - : Promise.resolve(undefined), - ]) - - let accountActivationFee = 0 - if (receiveAddress) { - try { - const recipientInfoResponse = await fetch(`${rpcUrl}/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 - } - } catch { - // Ignore activation check errors + const contractAddress = contractAddressOrUndefined(sellAsset.assetId) + const isSellingNativeTrx = !contractAddress + const hopCount = bestRoute.tokens.length - 1 + const energyPerHop = isSellingNativeTrx ? ENERGY_PER_HOP_TRX_SELL : ENERGY_PER_HOP_TRC20_SELL + const totalEnergy = Math.ceil(hopCount * energyPerHop * SAFETY_MARGIN) + const userEnergy = totalEnergy * USER_ENERGY_SHARE + const energyFee = userEnergy * ENERGY_PRICE + const bandwidthFee = 1100 * BANDWIDTH_PRICE + + let accountActivationFee = 0 + if (receiveAddress) { + try { + const rpcUrl = deps.config.VITE_TRON_NODE_URL + const recipientInfoResponse = await fetch(`${rpcUrl}/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 } - } - - const baseEnergy = simulatedEnergy - ? Math.ceil(simulatedEnergy * SAFETY_MARGIN) - : isSellingNativeTrx - ? BASE_ENERGY_TRX_TO_TOKEN - : BASE_ENERGY_TRC20_TO_TOKEN - - const adjustedEnergy = simulatedEnergy - ? baseEnergy - : contractParams.energyFactor > 0 - ? Math.ceil(baseEnergy * (1 + contractParams.energyFactor)) - : baseEnergy - - const userEnergy = calculateUserEnergy(adjustedEnergy, contractParams) - - const energyFee = userEnergy * energyPrice - const bandwidthFee = 1100 * bandwidthPrice - - networkFeeCryptoBaseUnit = bn(energyFee) - .plus(bandwidthFee) - .plus(accountActivationFee) - .toFixed(0) - } catch (error) { - if (!isQuote) { - const fallbackEnergy = BASE_ENERGY_TRC20_TO_TOKEN * 0.6 - const fallbackFee = Math.ceil(fallbackEnergy * 100 + 1100 * 1000) - networkFeeCryptoBaseUnit = String(fallbackFee) - } else { - throw error + } catch { + // Ignore activation check errors } } - // Calculate protocol fees only for quotes + const networkFeeCryptoBaseUnit = bn(energyFee) + .plus(bandwidthFee) + .plus(accountActivationFee) + .toFixed(0) + const protocolFeeCryptoBaseUnit = isQuote ? bn(bestRoute.fee).times(sellAmountIncludingProtocolFeesCryptoBaseUnit).toFixed(0) : '0' From 6019eee512d283028c0c761d1a62029a19a27a68 Mon Sep 17 00:00:00 2001 From: Jibles Date: Sun, 1 Mar 2026 21:32:46 +0700 Subject: [PATCH 5/7] fix: use simulation + energy penalty fallback for Sun.io fee estimation Static hop-count estimation was 2x off for USDT swaps due to TRON's dynamic energy penalty (energy_factor: 34000 = 340% penalty on USDT). Two-tier approach: 1. Try triggerConstantContract simulation (exact energy including penalties) 2. If simulation fails (e.g. TRC20 sell without approval), fetch the sell token's energy_factor and inflate base estimate accordingly Co-Authored-By: Claude Opus 4.6 --- .../SunioSwapper/utils/getQuoteOrRate.ts | 160 +++++++++++++++--- 1 file changed, 137 insertions(+), 23 deletions(-) diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts b/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts index 2d0ef8dc3de..e3a444fbea0 100644 --- a/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts +++ b/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts @@ -2,6 +2,7 @@ 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, @@ -14,17 +15,110 @@ import type { } from '../../../types' import { SwapperName, TradeQuoteError } from '../../../types' import { getInputOutputRate, makeSwapErrorRight } from '../../../utils' -import { DEFAULT_SLIPPAGE_PERCENTAGE, SUNIO_SMART_ROUTER_CONTRACT } from './constants' +import type { SunioRoute } from '../types' +import { + DEFAULT_SLIPPAGE_PERCENTAGE, + SUNIO_SMART_ROUTER_CONTRACT, + SUNIO_TRON_NATIVE_ADDRESS, +} from './constants' +import { convertAddressesToEvmFormat } from './convertAddressesToEvmFormat' import { fetchSunioQuote } from './fetchFromSunio' import { isSupportedChainId } from './helpers/helpers' import { sunioServiceFactory } from './sunioService' const ENERGY_PRICE = 100 -const BANDWIDTH_PRICE = 1000 const USER_ENERGY_SHARE = 0.6 -const ENERGY_PER_HOP_TRX_SELL = 65_000 -const ENERGY_PER_HOP_TRC20_SELL = 85_000 -const SAFETY_MARGIN = 1.1 +const BASE_ENERGY_PER_HOP = 65_000 +const TOKEN_ENERGY_SHARE = 0.5 +const ENERGY_FACTOR_CACHE_TTL_MS = 6 * 60 * 60 * 1000 + +const energyFactorCache = new Map() + +const simulateSwapEnergy = async ( + route: SunioRoute, + sellAmountCryptoBaseUnit: string, + senderAddress: string, + isSellingNativeTrx: boolean, + rpcUrl: string, +): Promise => { + try { + 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: '0', + recipient: senderAddress, + 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 result = await tronWeb.transactionBuilder.triggerConstantContract( + SUNIO_SMART_ROUTER_CONTRACT, + functionSelector, + { callValue: isSellingNativeTrx ? Number(sellAmountCryptoBaseUnit) : 0 }, + parameters, + senderAddress, + ) + + if (!result?.energy_used) return undefined + + return result.energy_used + (result.energy_penalty ?? 0) + } catch { + return undefined + } +} + +const getTokenEnergyFactor = async ( + rawContractAddress: string, + rpcUrl: string, +): Promise => { + const contractAddress = rawContractAddress.toLowerCase() + const now = Date.now() + const cached = energyFactorCache.get(contractAddress) + if (cached && now < cached.expiry) return cached.value + + try { + const response = await fetch(`${rpcUrl}/wallet/getcontractinfo`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: rawContractAddress, visible: true }), + }) + const data = await response.json() + const energyFactor: number = data?.contract_state?.energy_factor ?? 0 + + energyFactorCache.set(contractAddress, { + value: energyFactor, + expiry: now + ENERGY_FACTOR_CACHE_TTL_MS, + }) + + return energyFactor + } catch { + return 0 + } +} export async function getQuoteOrRate( input: GetTronTradeQuoteInput | CommonTradeQuoteInput, @@ -45,6 +139,7 @@ export async function getQuoteOrRate( sellAsset, buyAsset, sellAmountIncludingProtocolFeesCryptoBaseUnit, + sendAddress, receiveAddress, accountNumber, affiliateBps, @@ -125,31 +220,50 @@ export async function getQuoteOrRate( const contractAddress = contractAddressOrUndefined(sellAsset.assetId) const isSellingNativeTrx = !contractAddress const hopCount = bestRoute.tokens.length - 1 - const energyPerHop = isSellingNativeTrx ? ENERGY_PER_HOP_TRX_SELL : ENERGY_PER_HOP_TRC20_SELL - const totalEnergy = Math.ceil(hopCount * energyPerHop * SAFETY_MARGIN) - const userEnergy = totalEnergy * USER_ENERGY_SHARE - const energyFee = userEnergy * ENERGY_PRICE - const bandwidthFee = 1100 * BANDWIDTH_PRICE + const rpcUrl = deps.config.VITE_TRON_NODE_URL + + const simulationPromise = sendAddress + ? simulateSwapEnergy( + bestRoute, + sellAmountIncludingProtocolFeesCryptoBaseUnit, + sendAddress, + isSellingNativeTrx, + rpcUrl, + ) + : Promise.resolve(undefined) - let accountActivationFee = 0 - if (receiveAddress) { - try { - const rpcUrl = deps.config.VITE_TRON_NODE_URL - const recipientInfoResponse = await fetch(`${rpcUrl}/wallet/getaccount`, { + const accountActivationPromise = receiveAddress + ? fetch(`${rpcUrl}/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 - } - } catch { - // Ignore activation check errors - } + .then(res => res.json()) + .then(info => (info && Object.keys(info).length > 1 ? 0 : 1_000_000)) + .catch(() => 0) + : Promise.resolve(0) + + const [simulatedEnergy, accountActivationFee] = await Promise.all([ + simulationPromise, + accountActivationPromise, + ]) + + let totalEnergy: number + if (simulatedEnergy) { + totalEnergy = simulatedEnergy + } else { + const sellTokenAddress = contractAddress ?? SUNIO_TRON_NATIVE_ADDRESS + const energyFactor = isSellingNativeTrx + ? 0 + : await getTokenEnergyFactor(sellTokenAddress, rpcUrl) + const penaltyMultiplier = 1 + (TOKEN_ENERGY_SHARE * energyFactor) / 10000 + totalEnergy = Math.ceil(BASE_ENERGY_PER_HOP * hopCount * penaltyMultiplier) } + const userEnergy = totalEnergy * USER_ENERGY_SHARE + const energyFee = userEnergy * ENERGY_PRICE + const bandwidthFee = 1_100_000 + const networkFeeCryptoBaseUnit = bn(energyFee) .plus(bandwidthFee) .plus(accountActivationFee) From 7835480d38fc0c0fcd066c0dfb4830b72831a645 Mon Sep 17 00:00:00 2001 From: Jibles Date: Sun, 1 Mar 2026 21:38:32 +0700 Subject: [PATCH 6/7] fix: increase Sun.io fee estimation safety margin from 10% to 20% Estimates were slightly under actual fees. Bump SAFETY_MARGIN to 1.2 to provide a more conservative buffer on both simulation and fallback energy calculations. Co-Authored-By: Claude Opus 4.6 --- .../swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts b/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts index e3a444fbea0..2535061382c 100644 --- a/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts +++ b/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts @@ -30,6 +30,7 @@ const ENERGY_PRICE = 100 const USER_ENERGY_SHARE = 0.6 const BASE_ENERGY_PER_HOP = 65_000 const TOKEN_ENERGY_SHARE = 0.5 +const SAFETY_MARGIN = 1.2 const ENERGY_FACTOR_CACHE_TTL_MS = 6 * 60 * 60 * 1000 const energyFactorCache = new Map() @@ -260,7 +261,7 @@ export async function getQuoteOrRate( totalEnergy = Math.ceil(BASE_ENERGY_PER_HOP * hopCount * penaltyMultiplier) } - const userEnergy = totalEnergy * USER_ENERGY_SHARE + const userEnergy = Math.ceil(totalEnergy * SAFETY_MARGIN) * USER_ENERGY_SHARE const energyFee = userEnergy * ENERGY_PRICE const bandwidthFee = 1_100_000 From 06b158214d069582d8bea5975a364d5ef73732cf Mon Sep 17 00:00:00 2001 From: Jibles Date: Mon, 2 Mar 2026 21:27:36 +0700 Subject: [PATCH 7/7] migrate to new tron rpc --- .beads/pr-context.jsonl | 2 +- .env | 1 + .env.development | 1 + headers/csps/chains/tron.ts | 2 +- packages/public-api/src/config.ts | 1 + .../SunioSwapper/utils/getQuoteOrRate.ts | 95 +++++-------------- packages/swapper/src/types.ts | 1 + src/config.ts | 1 + 8 files changed, 32 insertions(+), 72 deletions(-) diff --git a/.beads/pr-context.jsonl b/.beads/pr-context.jsonl index e5545523c4f..e4ae58a7404 100644 --- a/.beads/pr-context.jsonl +++ b/.beads/pr-context.jsonl @@ -1,4 +1,4 @@ -{"id":"shapeshift-185","title":"Use triggerconstantcontract for dynamic Sun.io swap fee estimation","description":"## Context\n\nSun.io swap energy consumption varies dramatically by route complexity:\n- TRX→Token: 77k–336k energy (median 200k)\n- TRC20→Token: 166k–426k energy (median 303k)\n\nOur current static estimates target the median, which means simple single-hop swaps show ~2x overestimated fees while complex multi-hop routes can be underestimated. A user doing a simple TRX→USDT swap sees ~12 TRX estimated but pays ~5.5 TRX.\n\n## Research Findings\n\n### Data from 172 real Sun.io router transactions\n\n**TRX-to-Token (47 txs):**\n| Percentile | Total Energy |\n|---|---|\n| P10 | 118,575 |\n| P25 | 149,194 |\n| P50 | 199,676 |\n| P75 | 238,224 |\n| P90 | 266,769 |\n\n**TRC20-to-Token (125 txs):**\n| Percentile | Total Energy |\n|---|---|\n| P10 | 195,781 |\n| P25 | 277,344 |\n| P50 | 302,542 |\n| P75 | 320,933 |\n| P90 | 359,650 |\n\n### Key technical findings\n- TronGrid does NOT support `estimateenergy` API (returns \"this node does not support estimate energy\")\n- TronGrid DOES support `triggerconstantcontract` which simulates a contract call and returns `energy_used` + `energy_penalty`\n- This is the same approach TronLink and other TRON wallets use for fee estimation\n- The existing codebase already uses `triggerconstantcontract` in `TronApi.estimateTRC20TransferFee()` — follow that pattern\n- Sun.io router contract: `TCFNp179Lg46D16zKoumd4Poa2WFFdtqYj`\n- Contract params: `consume_user_resource_percent=60`, `origin_energy_limit=1,200,000`, `energy_factor=0`\n- Current energy price: 100 sun/unit, bandwidth price: 1000 sun/unit\n\n## Approach\n\nCall `triggerconstantcontract` on the Sun.io router with the actual swap calldata to get a per-route energy estimate. The swap route and encoded calldata are already available from the Sun.io API response (`bestRoute`). The transaction encoding logic already exists in `getUnsignedTronTransaction()` — extract or reuse the ABI encoding to build the call for simulation.\n\n### Flow:\n1. Get Sun.io quote/route (already done before fee estimation)\n2. Encode the swap call using the route data (same ABI encoding as execution)\n3. Call `triggerconstantcontract` with the encoded data to simulate execution\n4. Read `energy_used` from response, add `energy_penalty` if present\n5. Apply `consume_user_resource_percent` (60%) to get user's share\n6. Apply small safety margin (1.1x) for estimation variance\n7. Fall back to current static estimates if simulation fails\n\n### Important considerations:\n- This adds 1 RPC call per quote/rate — must be added to the existing `Promise.all` to avoid extra latency\n- Cache the result per route hash (same route = same energy) with short TTL (~30s)\n- For rates without wallet connected, we still need the calldata to simulate — check if Sun.io route data is sufficient to encode the call without a sender address (may need a dummy sender for simulation)\n- `triggerconstantcontract` does NOT consume energy or cost anything — it's a read-only simulation\n\n## Affected Files\n\n- `packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts` — main fee estimation logic, replace static energy with dynamic\n- `packages/swapper/src/swappers/SunioSwapper/endpoints.ts` — contains `getUnsignedTronTransaction()` with the ABI encoding logic to reference/extract\n- `packages/swapper/src/swappers/SunioSwapper/utils/constants.ts` — Sun.io router contract address and ABI\n- `packages/unchained-client/src/tron/api.ts` — existing `estimateTRC20TransferFee()` pattern to follow for `triggerconstantcontract` usage\n\n## Acceptance Criteria\n\n- [ ] Fee estimation uses `triggerconstantcontract` to simulate the actual swap and get real energy usage\n- [ ] Estimated fee for a simple TRX→token single-hop swap is within 20% of actual on-chain cost\n- [ ] Estimated fee for a complex multi-hop TRC20→token swap is within 20% of actual on-chain cost\n- [ ] Falls back gracefully to current static estimates if simulation fails (429, timeout, etc.)\n- [ ] No additional latency — simulation runs in parallel with other RPC calls\n- [ ] Works for both rates (no wallet) and quotes (with wallet) — use dummy sender if needed for rates\n- [ ] Passes `yarn lint --fix` and `yarn type-check`","status":"open","priority":2,"issue_type":"feature","owner":"premiumjibles@gmail.com","created_at":"2026-02-27T15:49:58.395221393+07:00","created_by":"Jibles","updated_at":"2026-02-27T15:50:29.971573304+07:00"} +{"id":"shapeshift-185","title":"Use triggerconstantcontract for dynamic Sun.io swap fee estimation","description":"## Context\n\nSun.io swap energy consumption varies dramatically by route complexity:\n- TRX→Token: 77k–336k energy (median 200k)\n- TRC20→Token: 166k–426k energy (median 303k)\n\nOur current static estimates target the median, which means simple single-hop swaps show ~2x overestimated fees while complex multi-hop routes can be underestimated. A user doing a simple TRX→USDT swap sees ~12 TRX estimated but pays ~5.5 TRX.\n\n## Research Findings\n\n### Data from 172 real Sun.io router transactions\n\n**TRX-to-Token (47 txs):**\n| Percentile | Total Energy |\n|---|---|\n| P10 | 118,575 |\n| P25 | 149,194 |\n| P50 | 199,676 |\n| P75 | 238,224 |\n| P90 | 266,769 |\n\n**TRC20-to-Token (125 txs):**\n| Percentile | Total Energy |\n|---|---|\n| P10 | 195,781 |\n| P25 | 277,344 |\n| P50 | 302,542 |\n| P75 | 320,933 |\n| P90 | 359,650 |\n\n### Key technical findings\n- TronGrid does NOT support `estimateenergy` API (returns \"this node does not support estimate energy\")\n- TronGrid DOES support `triggerconstantcontract` which simulates a contract call and returns `energy_used` + `energy_penalty`\n- This is the same approach TronLink and other TRON wallets use for fee estimation\n- The existing codebase already uses `triggerconstantcontract` in `TronApi.estimateTRC20TransferFee()` — follow that pattern\n- Sun.io router contract: `TCFNp179Lg46D16zKoumd4Poa2WFFdtqYj`\n- Contract params: `consume_user_resource_percent=60`, `origin_energy_limit=1,200,000`, `energy_factor=0`\n- Current energy price: 100 sun/unit, bandwidth price: 1000 sun/unit\n\n## Approach\n\nCall `triggerconstantcontract` on the Sun.io router with the actual swap calldata to get a per-route energy estimate. The swap route and encoded calldata are already available from the Sun.io API response (`bestRoute`). The transaction encoding logic already exists in `getUnsignedTronTransaction()` — extract or reuse the ABI encoding to build the call for simulation.\n\n### Flow:\n1. Get Sun.io quote/route (already done before fee estimation)\n2. Encode the swap call using the route data (same ABI encoding as execution)\n3. Call `triggerconstantcontract` with the encoded data to simulate execution\n4. Read `energy_used` from response, add `energy_penalty` if present\n5. Apply `consume_user_resource_percent` (60%) to get user's share\n6. Apply small safety margin (1.1x) for estimation variance\n7. Fall back to current static estimates if simulation fails\n\n### Important considerations:\n- This adds 1 RPC call per quote/rate — must be added to the existing `Promise.all` to avoid extra latency\n- Cache the result per route hash (same route = same energy) with short TTL (~30s)\n- For rates without wallet connected, we still need the calldata to simulate — check if Sun.io route data is sufficient to encode the call without a sender address (may need a dummy sender for simulation)\n- `triggerconstantcontract` does NOT consume energy or cost anything — it's a read-only simulation\n\n## Affected Files\n\n- `packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts` — main fee estimation logic, replace static energy with dynamic\n- `packages/swapper/src/swappers/SunioSwapper/endpoints.ts` — contains `getUnsignedTronTransaction()` with the ABI encoding logic to reference/extract\n- `packages/swapper/src/swappers/SunioSwapper/utils/constants.ts` — Sun.io router contract address and ABI\n- `packages/unchained-client/src/tron/api.ts` — existing `estimateTRC20TransferFee()` pattern to follow for `triggerconstantcontract` usage\n\n## Acceptance Criteria\n\n- [ ] Fee estimation uses `triggerconstantcontract` to simulate the actual swap and get real energy usage\n- [ ] Estimated fee for a simple TRX→token single-hop swap is within 20% of actual on-chain cost\n- [ ] Estimated fee for a complex multi-hop TRC20→token swap is within 20% of actual on-chain cost\n- [ ] Falls back gracefully to current static estimates if simulation fails (429, timeout, etc.)\n- [ ] No additional latency — simulation runs in parallel with other RPC calls\n- [ ] Works for both rates (no wallet) and quotes (with wallet) — use dummy sender if needed for rates\n- [ ] Passes `yarn lint --fix` and `yarn type-check`","status":"closed","priority":2,"issue_type":"feature","owner":"premiumjibles@gmail.com","created_at":"2026-02-27T15:49:58.395221393+07:00","created_by":"Jibles","updated_at":"2026-03-01T20:11:13.315653109+07:00","closed_at":"2026-03-01T20:11:13.315653109+07:00","close_reason":"Implemented dynamic fee estimation using triggerConstantContract with real sender address. Falls back to static estimates for rates."} {"id":"shapeshift-20c","title":"Improve stemMatch validator with per-language morphology","description":"## Problem\n\nThe translation validator's `stemMatch` function in `scripts/script-utils.js` uses a **generic 70% prefix-chop algorithm** for all inflected languages. This is the single biggest source of false flags in the translation pipeline, causing:\n\n1. **False rejections** that force translators into grammatically awkward constructions to satisfy the validator\n2. **Inflated \"manual review\" counts** — UK had 19 strings flagged as manual review that were actually correct Ukrainian\n3. **Degraded translation quality** — RU agent was forced to use infinitive \"торговать\" mixed with imperatives just to pass validation\n\n### Benchmark Evidence (150 keys × 9 locales)\n\n**Glossary term match rates (skill translations):**\n- \"dust\" (never-translate): 100% — binary check works perfectly\n- \"wallet\": 72% — moderate inflection\n- \"staking\": 67% — moderate inflection \n- \"seed phrase\": 67%\n- \"swap\": 64%\n- \"claim\": **47%** — heavy inflection across cases/conjugations\n- \"deposit\": **40%** — heavy inflection, worst performer\n\nThese low rates are **not translation errors** — they're validator false positives. The translations used correct inflected forms (genitive, dative, imperative) that the naive prefix matcher can't recognize.\n\n### Root Cause\n\nTwo code paths in `stemMatch` (script-utils.js:11-24):\n\n1. **CJK (ja, zh)**: Exact substring match — works fine\n2. **Everything else**: Generic 70% prefix of each word — treats German, Russian, and Turkish identically despite fundamentally different morphology\n\n| Language | Morphology Type | What Changes | 70% Prefix Works? |\n|----------|----------------|--------------|-------------------|\n| de (Germanic) | Compounds + mild case | Articles change, stems stable | Yes, mostly fine |\n| es/fr/pt (Romance) | Verb conjugation + gender | Suffixes change | Borderline |\n| ru/uk (Slavic) | 6 cases + verb aspect | Stem + suffix both change | No — too many false flags |\n| tr (Agglutinative) | Suffix stacking + vowel harmony | Suffixes modify vowels | No — root preserved but suffixes stack deep |\n\n### Affected Files\n\n- `.claude/skills/translate/scripts/script-utils.js` — `stemMatch()` function (lines 11-24), `INFLECTED_LOCALES` set\n- `.claude/skills/translate/scripts/validate.js` — Check 5 (glossary compliance, lines 110-126) calls `stemMatch`\n- `src/assets/translations/glossary.json` — approved term definitions (currently single-form strings per locale)\n\n### Requirements\n\n1. Replace the generic stemMatch with **per-language matching strategies** that account for each language family's morphological patterns\n2. Support **multi-form glossary entries** (arrays alongside strings) for terms with irregular inflections, backward-compatible with existing string entries\n3. Per-language stem ratios: Germanic ~75%, Romance ~65%, Slavic ~55%, Agglutinative ~60%\n4. Per-language **suffix stripping**: strip known grammatical suffixes before matching to find the root (e.g., Russian case endings -а/-у/-е/-ом/-ой, Turkish agglutinative suffixes -ı/-in/-da/-lar, Romance conjugation endings -ado/-ido/-ando/-ción)\n5. Three-tier matching: exact substring → stem prefix → suffix-stripped root. Pass on first hit.\n6. Consider downgrading glossary-approved-translation from \"flag\" to \"info\" severity for inflected locales, so valid inflections don't trigger the review/refine loop\n\n### Acceptance Criteria\n\n- stemMatch has per-language configs (stem ratio + suffix list) for all 7 inflected locales\n- Glossary entries accept `string | string[]` per locale\n- Existing validate.js tests (if any) still pass\n- Re-running the benchmark shows \"claim\" and \"deposit\" match rates above 80%\n- No grammatically awkward forced forms in RU/UK/TR output","status":"closed","priority":2,"issue_type":"task","owner":"premiumjibles@gmail.com","created_at":"2026-02-24T18:38:15.541277548+07:00","created_by":"Jibles","updated_at":"2026-02-24T19:13:48.295324618+07:00","closed_at":"2026-02-24T19:13:48.295324618+07:00","close_reason":"Implemented per-language morphology for stemMatch. Replaced INFLECTED_LOCALES with LOCALE_CONFIGS (per-language stemRatio + suffix lists for de/es/fr/pt/ru/tr/uk). Added stripSuffix helper and 3-tier matching (exact→stem→suffix-stripped). Added multi-form glossary support (string|string[]) for claim/deposit/trade/approve in ru/uk/tr. Downgraded glossary flags to info severity for inflected locales. Updated prepare-locale.js for canonical form extraction and compile-report.js for clean display."} {"id":"shapeshift-saq","title":"Fix Sun.io swap fee estimation and OUT_OF_ENERGY error handling","description":"## Problem\n\nSun.io TRX trades broadcast despite insufficient gas, fail on-chain with OUT_OF_ENERGY, and then spin as \"pending\" indefinitely in the notification center.\n\nLinear ticket: SS-5573 / GitHub: shapeshift/web#12039\n\nTwo distinct bugs:\n1. **Fee estimation is 45-90x too low** — the UI gas check passes when it shouldn't, so the trade broadcasts and fails on-chain\n2. **checkTradeStatus doesn't detect OUT_OF_ENERGY** — failed trades show as \"pending\" forever instead of showing an error\n\n## Research Findings\n\n### Root Cause 1: Hardcoded energy estimate assumes contract subsidy covers ~98% of energy\n\nThe code at \\`getQuoteOrRate.ts:159\\` hardcodes \\`energyUsed = 2000\\` with the comment \"Sun.io contract owner provides most energy (~117k), users only pay ~2k\". This is wrong.\n\n**Actual contract settings** (queried live from \\`/wallet/getcontract\\`):\n\n SmartExchangeRouter (TCFNp179Lg46D16zKoumd4Poa2WFFdtqYj):\n consume_user_resource_percent: 60 ← user pays 60%, NOT ~2%\n origin_energy_limit: 1,200,000 ← cap on contract's subsidy per call\n energy_factor: 0 ← Dynamic Energy Model not active\n\n**Actual energy usage from live successful transactions:**\n\n| Swap Type | Total Energy | Contract Paid (~40%) | User Paid (~60%) | User Fee (TRX) |\n|---------------|-------------|---------------------|-----------------|----------------|\n| TRC20 swap | 302,639 | 121,055 | 181,584 | 0-5+ TRX |\n| TRX→token | 149,194 | 59,677 | 89,517 | ~2.48 TRX |\n\nCurrent estimate: 2,000 energy = ~0.2 TRX. Actual user cost: 89,000-181,000 energy = 2.5-18 TRX.\n\n**The failed transaction from the bug report confirms this:**\n\n TX: 5660b12d88db18221865a1d480665b257d89177bde292f8b24ce4f6e73511949\n Result: OUT_OF_ENERGY\n Energy total: 10,116\n Origin energy (contract paid): 4,046 (40%)\n User energy: 6,070 (60%)\n Energy fee burned: 0.607 TRX\n Total fee lost: 1.534 TRX\n\n### Root Cause 2: checkTradeStatus only checks for REVERT, not OUT_OF_ENERGY\n\nAt \\`endpoints.ts:188-190\\`, the status check maps \\`REVERT\\` → \\`TxStatus.Failed\\` but \\`OUT_OF_ENERGY\\` falls through to \\`TxStatus.Pending\\`. Other parts of the codebase handle this correctly:\n- \\`src/lib/utils/tron.ts:46\\` — checks for OUT_OF_ENERGY → Failed\n- \\`useAllowanceApproval.tsx:146\\` — checks for REVERT || OUT_OF_ENERGY\n\n### Approaches Considered and Ruled Out\n\n**1. Use Sun.io API fee data** — RULED OUT\nThe Sun.io quote API (\\`/swap/router\\`) returns only routing/pricing data. No energy, gas, bandwidth, or network fee fields exist in the response. The \\`fee\\` field is the protocol swap fee (e.g. 3%), not a network fee. Confirmed by hitting the live API.\n\n**2. Use triggerConstantContract to simulate the swap** — RULED OUT\nWorks for simple \\`transfer(address,uint256)\\` calls (used by our existing \\`estimateTRC20TransferFee\\`), but Sun.io's \\`swapExactInput\\` reverts in simulation because the from address doesn't have token approvals/balances. Swap functions have preconditions that prevent dry-run simulation.\n\n**3. Use estimateEnergy API** — RULED OUT (for now)\nTronGrid (our TRON RPC) has this endpoint disabled (\\`\"this node does not support estimate energy\"\\`). Would require switching to a paid TRON RPC provider like TronQL. Not worth the infrastructure change for this fix.\n\n**4. Method 3 from TRON docs (max_factor)** — RULED OUT\nmax_factor is 3.4x on mainnet. Applied naively it would show 39-80 TRX fees for swaps that actually cost 2.5-18 TRX. This over-penalizes Sun.io in the UI, making it look uncompetitive.\n\n### Chosen Approach: Empirical estimates + live contract parameter query\n\nSince Sun.io's \\`energy_factor = 0\\` (Dynamic Energy Model doesn't apply — usage is 0.008% of the 5B threshold), the energy costs are stable and predictable. We can:\n\n1. Use realistic base energy values derived from live transaction data\n2. Query \\`consume_user_resource_percent\\` and \\`origin_energy_limit\\` from the contract to dynamically calculate the user's share\n3. This auto-adapts if Sun.io changes their contract settings without needing code changes\n\n**Energy formula:**\n adjustedEnergy = baseEnergy × (1 + energy_factor)\n contractShare = min(adjustedEnergy × (1 - consumeUserPercent/100), originEnergyLimit)\n userEnergy = adjustedEnergy - contractShare\n energyFee = userEnergy × energyPrice\n\n**Base energy values (from live txs, apply 1.3x safety margin):**\n- TRX→token swaps: ~150,000 total energy → use 195,000\n- TRC20→token swaps: ~300,000 total energy → use 390,000\n\n## Implementation Spec\n\n### Change 1: Fix fee estimation in getQuoteOrRate.ts\n\nFile: \\`packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts\\` (lines 117-199)\n\nReplace the hardcoded \\`energyUsed = 2000\\` with realistic estimates:\n\n- Query the Sun.io contract's \\`consume_user_resource_percent\\` and \\`origin_energy_limit\\` via \\`/wallet/getcontract\\` (cache this — it changes rarely)\n- Also query \\`energy_factor\\` via \\`/wallet/getcontractinfo\\` (cache alongside)\n- Use base energy of ~150,000 for TRX swaps, ~300,000 for TRC20 swaps (with 1.3x safety margin)\n- Calculate user's share: \\`totalEnergy × consumeUserPercent/100\\`\n- Multiply by live \\`energyPrice\\` (already fetched from chain params)\n- The bandwidth estimate of 1100 bytes is roughly correct based on actual tx sizes seen (~927-1116 bytes) — keep it\n\nConsider caching the contract params in a module-level variable with a TTL (e.g. 1 hour), since they change rarely and we don't want an extra RPC call per quote.\n\n### Change 2: Fix checkTradeStatus in endpoints.ts\n\nFile: \\`packages/swapper/src/swappers/SunioSwapper/endpoints.ts\\` (lines 185-190)\n\nAdd \\`OUT_OF_ENERGY\\` as a failure condition. Current code:\n\n contractRet === 'REVERT' ? TxStatus.Failed : TxStatus.Pending\n\nShould be:\n\n contractRet === 'REVERT' || contractRet === 'OUT_OF_ENERGY' ? TxStatus.Failed : TxStatus.Pending\n\nThis matches the pattern used in \\`src/lib/utils/tron.ts:46\\` and \\`useAllowanceApproval.tsx:146\\`.\n\nAlso consider handling other TRON failure codes: \\`OUT_OF_TIME\\`, \\`JVM_STACK_OVER_FLOW\\`, \\`UNKNOWN\\`, \\`TRANSFER_FAILED\\`, etc. A safe approach: treat any \\`contractRet\\` that isn't \\`SUCCESS\\` and isn't absent/null as Failed (with the exception of no contractRet yet = still pending).\n\n### Change 3 (optional): Also check for OUT_OF_ENERGY in other Tron swappers\n\nCheck if Relay and ButterSwap's checkTradeStatus implementations have the same gap. If they delegate to a shared utility, this may already be covered. If they have their own Tron status checking, apply the same fix.\n\n## Relevant Files\n\n**Primary (must change):**\n- \\`packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts\\` — fee estimation (lines 117-199)\n- \\`packages/swapper/src/swappers/SunioSwapper/endpoints.ts\\` — checkTradeStatus (lines 185-190)\n\n**Reference (correct patterns to follow):**\n- \\`src/lib/utils/tron.ts:46\\` — correct OUT_OF_ENERGY handling\n- \\`src/components/MultiHopTrade/components/TradeConfirm/hooks/useAllowanceApproval.tsx:146\\` — correct OUT_OF_ENERGY handling\n- \\`packages/unchained-client/src/tron/api.ts:248-277\\` — estimateTRC20TransferFee pattern (for reference on how energy estimation is done for simpler calls)\n- \\`packages/chain-adapters/src/tron/TronChainAdapter.ts:457-573\\` — getFeeData (reference for energy calculation with safety margins)\n\n**Context (read for understanding):**\n- \\`packages/swapper/src/swappers/SunioSwapper/types.ts\\` — SunioRoute type (no fee fields from API)\n- \\`packages/swapper/src/swappers/SunioSwapper/utils/constants.ts\\` — contract address, API URL\n- \\`packages/swapper/src/swappers/SunioSwapper/INTEGRATION.md\\` — integration docs\n- \\`src/state/apis/swapper/helpers/validateTradeQuote.ts:211-223\\` — the balance check that uses the fee estimate\n\n**Key external data:**\n- Sun.io SmartExchangeRouter contract: \\`TCFNp179Lg46D16zKoumd4Poa2WFFdtqYj\\`\n- consume_user_resource_percent: 60\n- origin_energy_limit: 1,200,000\n- energy_factor: 0\n- Current energyPrice: 100 sun/unit\n- Current bandwidthPrice: 1,000 sun/byte\n\n## Acceptance Criteria\n\n1. Trade does not broadcast if user has insufficient TRX to cover realistic gas estimate\n2. Fee displayed in UI is within ~1.5x of actual on-chain cost (not 45-90x too low)\n3. If a trade fails on-chain with OUT_OF_ENERGY, the UI shows it as failed (not stuck on \"pending\")\n4. Fee estimation adapts if Sun.io changes contract energy settings (consume_user_resource_percent, origin_energy_limit) without code changes\n5. Existing lint and type-check pass (\\`yarn lint --fix \u0026\u0026 yarn type-check\\`)","status":"closed","priority":1,"issue_type":"bug","owner":"premiumjibles@gmail.com","created_at":"2026-02-27T10:09:30.218673815+07:00","created_by":"Jibles","updated_at":"2026-02-27T10:20:04.19372967+07:00","closed_at":"2026-02-27T10:20:04.19372967+07:00","close_reason":"Implemented dynamic fee estimation using contract params + 1-hour cache, fixed checkTradeStatus in Sun.io and ButterSwap to catch all non-SUCCESS failure states. PR #12045."} {"id":"shapeshiftWeb-2f09","title":"Sanity check Ink + Scroll regen data","description":"Verify generatedAssetData.json has entries for both eip155:534352 (Scroll) and eip155:57073 (Ink). Verify relatedAssetIndex.json has inkAssetId in ETH related array. Verify no regressions. Run review-second-class-evm skill.","status":"closed","priority":1,"issue_type":"task","owner":"contact@0xgom.es","created_at":"2026-02-19T13:51:24.013329+01:00","created_by":"gomes-bot","updated_at":"2026-02-19T17:01:37.198079+01:00","closed_at":"2026-02-19T17:01:37.198079+01:00","close_reason":"Popular assets + market data verified working after cache clear. All ink fixes merged, PR #11960 opened.","dependencies":[{"issue_id":"shapeshiftWeb-2f09","depends_on_id":"shapeshiftWeb-cgtg","type":"blocks","created_at":"2026-02-19T13:51:48.716437+01:00","created_by":"gomes-bot"}]} diff --git a/.env b/.env index 2f73c970f40..3d8696551dd 100644 --- a/.env +++ b/.env @@ -182,6 +182,7 @@ VITE_MAYACHAIN_NODE_URL=https://api.mayachain.shapeshift.com/lcd VITE_SOLANA_NODE_URL=https://api.solana.shapeshift.com/api/v1/jsonrpc VITE_STARKNET_NODE_URL=https://rpc.starknet.lava.build VITE_TRON_NODE_URL=https://api.trongrid.io +VITE_TRON_ESTIMATE_ENERGY_URL=https://tron-mainnet.core.chainstack.com/49869cfc535bb27e2803c06fabe3e74a VITE_NEAR_NODE_URL=https://rpc.mainnet.near.org VITE_NEAR_NODE_URL_FALLBACK_1=https://near.lava.build VITE_NEAR_NODE_URL_FALLBACK_2=https://rpc.fastnear.com diff --git a/.env.development b/.env.development index feb3a8b9793..1e8b5592692 100644 --- a/.env.development +++ b/.env.development @@ -91,6 +91,7 @@ VITE_MAYACHAIN_NODE_URL=https://dev-api.mayachain.shapeshift.com/lcd VITE_SOLANA_NODE_URL=https://dev-api.solana.shapeshift.com/api/v1/jsonrpc VITE_STARKNET_NODE_URL=https://rpc.starknet.lava.build VITE_TRON_NODE_URL=https://api.trongrid.io +VITE_TRON_ESTIMATE_ENERGY_URL=https://tron-mainnet.core.chainstack.com/49869cfc535bb27e2803c06fabe3e74a # midgard VITE_THORCHAIN_MIDGARD_URL=https://dev-api.thorchain.shapeshift.com/midgard/v2 diff --git a/headers/csps/chains/tron.ts b/headers/csps/chains/tron.ts index 0a593eb3cdb..672477fbda0 100644 --- a/headers/csps/chains/tron.ts +++ b/headers/csps/chains/tron.ts @@ -6,5 +6,5 @@ const mode = process.env.MODE ?? process.env.NODE_ENV ?? 'development' const env = loadEnv(mode, process.cwd(), '') export const csp: Csp = { - 'connect-src': [env.VITE_TRON_NODE_URL], + 'connect-src': [env.VITE_TRON_NODE_URL, env.VITE_TRON_ESTIMATE_ENERGY_URL].filter(Boolean), } diff --git a/packages/public-api/src/config.ts b/packages/public-api/src/config.ts index a9fbdc19414..39696e69683 100644 --- a/packages/public-api/src/config.ts +++ b/packages/public-api/src/config.ts @@ -11,6 +11,7 @@ export const getServerConfig = (): SwapperConfig => ({ VITE_THORCHAIN_NODE_URL: process.env.THORCHAIN_NODE_URL || 'https://thornode.ninerealms.com', VITE_MAYACHAIN_NODE_URL: process.env.MAYACHAIN_NODE_URL || 'https://tendermint.mayachain.info', VITE_TRON_NODE_URL: process.env.TRON_NODE_URL || 'https://api.trongrid.io', + VITE_TRON_ESTIMATE_ENERGY_URL: process.env.TRON_ESTIMATE_ENERGY_URL || '', VITE_FEATURE_THORCHAINSWAP_LONGTAIL: process.env.FEATURE_THORCHAINSWAP_LONGTAIL === 'true', VITE_FEATURE_THORCHAINSWAP_L1_TO_LONGTAIL: process.env.FEATURE_THORCHAINSWAP_L1_TO_LONGTAIL === 'true', diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts b/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts index 2535061382c..d60554129ee 100644 --- a/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts +++ b/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts @@ -16,11 +16,7 @@ import type { import { SwapperName, TradeQuoteError } from '../../../types' import { getInputOutputRate, makeSwapErrorRight } from '../../../utils' import type { SunioRoute } from '../types' -import { - DEFAULT_SLIPPAGE_PERCENTAGE, - SUNIO_SMART_ROUTER_CONTRACT, - SUNIO_TRON_NATIVE_ADDRESS, -} from './constants' +import { DEFAULT_SLIPPAGE_PERCENTAGE, SUNIO_SMART_ROUTER_CONTRACT } from './constants' import { convertAddressesToEvmFormat } from './convertAddressesToEvmFormat' import { fetchSunioQuote } from './fetchFromSunio' import { isSupportedChainId } from './helpers/helpers' @@ -29,21 +25,17 @@ import { sunioServiceFactory } from './sunioService' const ENERGY_PRICE = 100 const USER_ENERGY_SHARE = 0.6 const BASE_ENERGY_PER_HOP = 65_000 -const TOKEN_ENERGY_SHARE = 0.5 -const SAFETY_MARGIN = 1.2 -const ENERGY_FACTOR_CACHE_TTL_MS = 6 * 60 * 60 * 1000 +const MAX_PENALTY_MULTIPLIER = 4.4 -const energyFactorCache = new Map() - -const simulateSwapEnergy = async ( +const estimateSwapEnergy = async ( route: SunioRoute, sellAmountCryptoBaseUnit: string, senderAddress: string, isSellingNativeTrx: boolean, - rpcUrl: string, + estimateEnergyUrl: string, ): Promise => { try { - const tronWeb = new TronWeb({ fullHost: rpcUrl }) + const tronWeb = new TronWeb({ fullHost: estimateEnergyUrl }) const path = route.tokens const poolVersion = route.poolVersions @@ -76,7 +68,7 @@ const simulateSwapEnergy = async ( const functionSelector = 'swapExactInput(address[],string[],uint256[],uint24[],(uint256,uint256,address,uint256))' - const result = await tronWeb.transactionBuilder.triggerConstantContract( + const result = await tronWeb.transactionBuilder.estimateEnergy( SUNIO_SMART_ROUTER_CONTRACT, functionSelector, { callValue: isSellingNativeTrx ? Number(sellAmountCryptoBaseUnit) : 0 }, @@ -84,43 +76,14 @@ const simulateSwapEnergy = async ( senderAddress, ) - if (!result?.energy_used) return undefined + if (!result?.energy_required) return undefined - return result.energy_used + (result.energy_penalty ?? 0) + return result.energy_required } catch { return undefined } } -const getTokenEnergyFactor = async ( - rawContractAddress: string, - rpcUrl: string, -): Promise => { - const contractAddress = rawContractAddress.toLowerCase() - const now = Date.now() - const cached = energyFactorCache.get(contractAddress) - if (cached && now < cached.expiry) return cached.value - - try { - const response = await fetch(`${rpcUrl}/wallet/getcontractinfo`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ value: rawContractAddress, visible: true }), - }) - const data = await response.json() - const energyFactor: number = data?.contract_state?.energy_factor ?? 0 - - energyFactorCache.set(contractAddress, { - value: energyFactor, - expiry: now + ENERGY_FACTOR_CACHE_TTL_MS, - }) - - return energyFactor - } catch { - return 0 - } -} - export async function getQuoteOrRate( input: GetTronTradeQuoteInput | CommonTradeQuoteInput, deps: SwapperDeps, @@ -222,16 +185,18 @@ export async function getQuoteOrRate( const isSellingNativeTrx = !contractAddress const hopCount = bestRoute.tokens.length - 1 const rpcUrl = deps.config.VITE_TRON_NODE_URL - - const simulationPromise = sendAddress - ? simulateSwapEnergy( - bestRoute, - sellAmountIncludingProtocolFeesCryptoBaseUnit, - sendAddress, - isSellingNativeTrx, - rpcUrl, - ) - : Promise.resolve(undefined) + const estimateEnergyUrl = deps.config.VITE_TRON_ESTIMATE_ENERGY_URL + + const estimateEnergyPromise = + sendAddress && estimateEnergyUrl + ? estimateSwapEnergy( + bestRoute, + sellAmountIncludingProtocolFeesCryptoBaseUnit, + sendAddress, + isSellingNativeTrx, + estimateEnergyUrl, + ) + : Promise.resolve(undefined) const accountActivationPromise = receiveAddress ? fetch(`${rpcUrl}/wallet/getaccount`, { @@ -244,24 +209,14 @@ export async function getQuoteOrRate( .catch(() => 0) : Promise.resolve(0) - const [simulatedEnergy, accountActivationFee] = await Promise.all([ - simulationPromise, + const [estimatedEnergy, accountActivationFee] = await Promise.all([ + estimateEnergyPromise, accountActivationPromise, ]) - let totalEnergy: number - if (simulatedEnergy) { - totalEnergy = simulatedEnergy - } else { - const sellTokenAddress = contractAddress ?? SUNIO_TRON_NATIVE_ADDRESS - const energyFactor = isSellingNativeTrx - ? 0 - : await getTokenEnergyFactor(sellTokenAddress, rpcUrl) - const penaltyMultiplier = 1 + (TOKEN_ENERGY_SHARE * energyFactor) / 10000 - totalEnergy = Math.ceil(BASE_ENERGY_PER_HOP * hopCount * penaltyMultiplier) - } - - const userEnergy = Math.ceil(totalEnergy * SAFETY_MARGIN) * USER_ENERGY_SHARE + const totalEnergy = + estimatedEnergy ?? Math.ceil(BASE_ENERGY_PER_HOP * hopCount * MAX_PENALTY_MULTIPLIER) + const userEnergy = totalEnergy * USER_ENERGY_SHARE const energyFee = userEnergy * ENERGY_PRICE const bandwidthFee = 1_100_000 diff --git a/packages/swapper/src/types.ts b/packages/swapper/src/types.ts index 582d4e1456a..95b565b14e4 100644 --- a/packages/swapper/src/types.ts +++ b/packages/swapper/src/types.ts @@ -57,6 +57,7 @@ export type SwapperConfig = { VITE_THORCHAIN_NODE_URL: string VITE_MAYACHAIN_NODE_URL: string VITE_TRON_NODE_URL: string + VITE_TRON_ESTIMATE_ENERGY_URL: string VITE_FEATURE_THORCHAINSWAP_LONGTAIL: boolean VITE_FEATURE_THORCHAINSWAP_L1_TO_LONGTAIL: boolean VITE_THORCHAIN_MIDGARD_URL: string diff --git a/src/config.ts b/src/config.ts index 9c705672a1c..62bf0ef4b67 100644 --- a/src/config.ts +++ b/src/config.ts @@ -88,6 +88,7 @@ const validators = { VITE_SOLANA_NODE_URL: url(), VITE_STARKNET_NODE_URL: url(), VITE_TRON_NODE_URL: url(), + VITE_TRON_ESTIMATE_ENERGY_URL: url({ default: '' }), VITE_SUI_NODE_URL: url(), VITE_TON_NODE_URL: url(), VITE_NEAR_NODE_URL: url(),