diff --git a/.claude/skills/swapper-integration/common-gotchas.md b/.claude/skills/swapper-integration/common-gotchas.md index 946b04cb0ab..7d52f5ada07 100644 --- a/.claude/skills/swapper-integration/common-gotchas.md +++ b/.claude/skills/swapper-integration/common-gotchas.md @@ -469,4 +469,258 @@ After implementation, verify: --- +## 13. TRON-Specific: Human-Readable Amounts ⚠️ + +**Problem**: TRON aggregator APIs (like Sun.io) return amounts in human-readable format, not base units. + +**Real Example** (Sun.io): +```json +{ + "amountIn": "1.000000", // Human-readable (1 USDT) + "amountOut": "1.071122" // Human-readable (1.071122 USDC) +} +``` + +**Solution**: Must multiply by `10^precision` to convert to crypto base units: +```typescript +const buyAmountCryptoBaseUnit = bn(route.amountOut) + .times(bn(10).pow(buyAsset.precision)) + .toFixed(0) +``` + +**Affected Chains**: TRON swappers using aggregator APIs + +--- + +## 14. TRON-Specific: Smart Contract Transaction Building ⚠️⚠️ + +**Problem**: TRON swappers require calling smart contracts (not simple sends), which is different from deposit-to-address swappers. + +**Wrong Assumption**: Can use generic `getUnsignedTronTransaction` from tron-utils +**Reality**: Generic util only handles simple TRC-20 sends, not smart contract calls + +**Solution**: Build custom TRON transaction using TronWeb: +```typescript +import { TronWeb } from 'tronweb' + +const tronWeb = new TronWeb({ fullHost: rpcUrl }) + +const txData = await tronWeb.transactionBuilder.triggerSmartContract( + contractAddress, + functionSelector, + options, + parameters, + from +) + +const rawDataHex = typeof txData.transaction.raw_data_hex === 'string' + ? txData.transaction.raw_data_hex + : (txData.transaction.raw_data_hex as Buffer).toString('hex') +``` + +**Affected Files**: `endpoints.ts` (custom `getUnsignedTronTransaction`) + +--- + +## 15. TRON-Specific: Address Format (Not EVM!) ⚠️ + +**Problem**: TRON addresses use Base58 encoding (start with 'T'), not EVM hex with checksum. + +**Wrong**: Using `getAddress()` from viem for TRON addresses +**Right**: TRON addresses are already in correct format, no checksumming needed + +**Native TRX Address**: `T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb` + +**Solution**: +```typescript +export const assetIdToTronToken = (assetId: AssetId): string => { + if (isToken(assetId)) { + const { assetReference } = fromAssetId(assetId) + return assetReference // Already in Base58 format + } + return 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb' // Native TRX +} +``` + +**Affected Files**: `utils/helpers/helpers.ts` + +--- + +## 16. TRON-Specific: RPC URL Access ⚠️ + +**Problem**: SwapperConfig doesn't have VITE_UNCHAINED_TRON_HTTP_URL + +**Solution**: Access RPC URL from TRON chain adapter instance: +```typescript +const adapter = assertGetTronChainAdapter(chainId) +const rpcUrl = adapter.httpProvider.getRpcUrl() +``` + +**Note**: Use `httpProvider.getRpcUrl()` for type-safe access (matches pattern in src/lib/utils/tron.ts). + +**Affected Files**: `endpoints.ts` (getUnsignedTronTransaction), approval utilities + +--- + +## 17. TRON-Specific: Address Format for triggerSmartContract ⚠️⚠️ + +**Problem**: triggerSmartContract requires addresses in **hex format**, not Base58. + +**Error**: `invalid address (argument="address", value="TRwyik9Fb6HNjNhThJP3KJv4MAr1o7mCVv", code=INVALID_ARGUMENT)` + +**Root Cause**: Even though TRON addresses are Base58, `triggerSmartContract` internally validates them as EVM addresses and needs hex format. + +**Solution**: Convert all addresses to hex using `tronWeb.address.toHex()`: +```typescript +const parameters = [ + { + type: 'address[]', + value: routeParams.path.map(addr => tronWeb.address.toHex(addr)) + }, + { + type: 'tuple', + value: { + to: tronWeb.address.toHex(recipientBase58), + // ... other fields + } + } +] + +const txData = await tronWeb.transactionBuilder.triggerSmartContract( + contractAddress, + functionSelector, + options, + parameters, + tronWeb.address.toHex(fromAddress) // issuerAddress also needs hex +) +``` + +**Test with `node -e`**: +```bash +node -e " +const TronWeb = require('tronweb'); +const tw = new TronWeb({ fullHost: 'https://api.trongrid.io' }); +console.log(tw.address.toHex('TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t')); +" +``` + +**Affected Files**: `endpoints.ts` (getUnsignedTronTransaction) + +--- + +## 18. TRON-Specific: Immutable Array Parameters ⚠️ + +**Problem**: TronWeb mutates parameter arrays internally, causing errors if arrays are frozen/immutable. + +**Error**: `Cannot assign to read only property '0' of object '[object Array]'` + +**Root Cause**: TronWeb's `encodeArgs` function mutates the arrays when converting addresses. If arrays come from API responses or const declarations, they may be frozen. + +**Solution**: Clone arrays before passing to TronWeb: +```typescript +const parameters = [ + { type: 'address[]', value: [...addressArray].map(addr => tronWeb.address.toHex(addr)) }, + { type: 'string[]', value: [...poolVersions] }, + { type: 'uint256[]', value: [...versionLengths] }, +] +``` + +**Affected Files**: `endpoints.ts` (triggerSmartContract calls) + +--- + +--- + +## 19. TRON-Specific: TronWeb 6.x Tuple Address Bug ⚠️⚠️⚠️ + +**Problem**: TronWeb 6.x doesn't convert TRON Base58 addresses to EVM format when they appear inside tuple parameters, causing ethers.js AbiCoder to reject them. + +**Error**: `invalid address (argument="address", value="TRwyik9Fb6HNjNhThJP3KJv4MAr1o7mCVv", code=INVALID_ARGUMENT, version=6.13.5)` + +**Root Cause**: +- TronWeb 6.x uses ethers.js internally for ABI parameter encoding +- It auto-converts addresses for `address` and `address[]` types: `TRwyik9...` → `41xxx...` → `0xxx...` +- BUT it forgets to convert addresses inside tuples! +- ethers.js AbiCoder expects EVM format (`0x...`), rejects TRON Base58 (`T...`) + +**Real Example** (Sun.io swapper): +```typescript +// ✗ FAILS +{ + type: 'tuple(uint256,uint256,address,uint256)', + value: ['100000', '95000', 'TRwyik9Fb6HNjNhThJP3KJv4MAr1o7mCVv', 1234567890] +} +// Error: invalid address + +// ✓ WORKS +{ + type: 'tuple(uint256,uint256,address,uint256)', + value: ['100000', '95000', '0xaf46828a4d975381e62bdb9f272388d97daf14b6', 1234567890] +} +``` + +**Solution**: Manually convert TRON addresses in tuple values to EVM format: + +```typescript +/** + * Converts TRON Base58 addresses to EVM hex format (0x-prefixed). + * Required for TronWeb 6.x tuple parameters containing addresses. + */ +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) // TRwyik... → 41af46828a... + return hex.replace(/^41/, '0x') // 41af... → 0xaf... + } + + return value +} + +// Apply to tuple values before passing to triggerSmartContract +{ + type: 'tuple(uint256,uint256,address,uint256)', + value: convertAddressesToEvmFormat([ + amountIn, + amountOutMin, + recipientAddress, // Will be converted if TRON Base58 + deadline, + ]) +} +``` + +**Why EVM format for TRON?**: TronWeb uses ethers.js (Ethereum library) internally for ABI encoding. The conversion is only for parameter encoding - the actual TRON transaction still uses TRON addresses. + +**Test with `node -e`**: +```bash +node -e " +const { TronWeb } = require('tronweb'); +const addr = 'TRwyik9Fb6HNjNhThJP3KJv4MAr1o7mCVv'; +const hex = TronWeb.address.toHex(addr); +const evm = hex.replace(/^41/, '0x'); +console.log('TRON Base58:', addr); +console.log('TRON Hex:', hex); +console.log('EVM Format:', evm); +// Output: EVM Format: 0xaf46828a4d975381e62bdb9f272388d97daf14b6 +" +``` + +**Affected Scenarios**: +- Any TRON swapper using triggerSmartContract with tuple parameters +- Smart contract functions with struct parameters containing addresses +- Multi-parameter contract calls with addresses in complex types + +**NOT Affected**: +- Simple `address` type parameters ✅ TronWeb handles +- `address[]` array parameters ✅ TronWeb handles +- TRC20 token transfers ✅ Use simple address type + +**Affected Files**: Any file calling `triggerSmartContract` with tuple/struct parameters + +--- + **Remember**: Most bugs come from assumptions about API behavior. Always verify with actual API calls and responses! + +**PROTIP**: Use `node -e` to quickly test library behavior, address conversions, and API parsing before writing full code! diff --git a/.env b/.env index 414c7f5c83b..5ce374dc148 100644 --- a/.env +++ b/.env @@ -230,4 +230,5 @@ VITE_NOTIFICATIONS_SERVER_URL=https://shapeshiftnotifications-service-production VITE_FEATURE_TRON=false VITE_SUI_NODE_URL=https://fullnode.mainnet.sui.io:443 VITE_FEATURE_CETUS_SWAP=false +VITE_FEATURE_SUNIO_SWAP=false VITE_FEATURE_MONAD=false diff --git a/.env.development b/.env.development index a96ef3f4387..2e8da11e738 100644 --- a/.env.development +++ b/.env.development @@ -92,3 +92,4 @@ VITE_FEATURE_WC_DIRECT_CONNECTION=true VITE_FEATURE_TRON=true VITE_FEATURE_MONAD=true VITE_FEATURE_CETUS_SWAP=true +VITE_FEATURE_SUNIO_SWAP=true diff --git a/headers/csps/defi/swappers/Sunio.ts b/headers/csps/defi/swappers/Sunio.ts new file mode 100644 index 00000000000..f3cb485c030 --- /dev/null +++ b/headers/csps/defi/swappers/Sunio.ts @@ -0,0 +1,5 @@ +import type { Csp } from '../../../types' + +export const csp: Csp = { + 'connect-src': ['https://rot.endjgfsv.link', 'https://openapi.sun.io'], +} diff --git a/headers/csps/index.ts b/headers/csps/index.ts index 58950e8bea8..87a002f18d0 100644 --- a/headers/csps/index.ts +++ b/headers/csps/index.ts @@ -36,6 +36,7 @@ import { csp as cowSwap } from './defi/swappers/CowSwap' import { csp as nearIntents } from './defi/swappers/NearIntents' import { csp as oneInch } from './defi/swappers/OneInch' import { csp as portals } from './defi/swappers/Portals' +import { csp as sunio } from './defi/swappers/Sunio' import { csp as thor } from './defi/swappers/Thor' import { csp as discord } from './discord' import { csp as banxa } from './fiatRamps/banxa' @@ -121,6 +122,7 @@ export const csps = [ nearIntents, oneInch, portals, + sunio, thor, butterSwap, foxPage, diff --git a/packages/swapper/src/constants.ts b/packages/swapper/src/constants.ts index 8ff624abcbf..123ef709ff3 100644 --- a/packages/swapper/src/constants.ts +++ b/packages/swapper/src/constants.ts @@ -22,6 +22,8 @@ import { portalsApi } from './swappers/PortalsSwapper/endpoints' import { portalsSwapper } from './swappers/PortalsSwapper/PortalsSwapper' import { relaySwapper } from './swappers/RelaySwapper' import { relayApi } from './swappers/RelaySwapper/endpoints' +import { sunioApi } from './swappers/SunioSwapper/endpoints' +import { sunioSwapper } from './swappers/SunioSwapper/SunioSwapper' import { thorchainApi } from './swappers/ThorchainSwapper/endpoints' import { thorchainSwapper } from './swappers/ThorchainSwapper/ThorchainSwapper' import { zrxApi } from './swappers/ZrxSwapper/endpoints' @@ -92,6 +94,10 @@ export const swappers: Record = ...cetusSwapper, ...cetusApi, }, + [SwapperName.Sunio]: { + ...sunioSwapper, + ...sunioApi, + }, [SwapperName.Test]: undefined, } @@ -106,6 +112,7 @@ const DEFAULT_ARBITRUM_BRIDGE_SLIPPAGE_DECIMAL_PERCENTAGE = '0' // no slippage f const DEFAULT_CHAINFLIP_SLIPPAGE_DECIMAL_PERCENTAGE = '0.02' // 2% const DEFAULT_BUTTERSWAP_SLIPPAGE_DECIMAL_PERCENTAGE = '0.015' // 1.5% const DEFAULT_CETUS_SLIPPAGE_DECIMAL_PERCENTAGE = '0.005' // .5% +const DEFAULT_SUNIO_SLIPPAGE_DECIMAL_PERCENTAGE = '0.005' // .5% export const getDefaultSlippageDecimalPercentageForSwapper = ( swapperName: SwapperName | undefined, @@ -138,6 +145,8 @@ export const getDefaultSlippageDecimalPercentageForSwapper = ( return DEFAULT_NEAR_INTENTS_SLIPPAGE_DECIMAL_PERCENTAGE case SwapperName.Cetus: return DEFAULT_CETUS_SLIPPAGE_DECIMAL_PERCENTAGE + case SwapperName.Sunio: + return DEFAULT_SUNIO_SLIPPAGE_DECIMAL_PERCENTAGE default: return assertUnreachable(swapperName) } diff --git a/packages/swapper/src/index.ts b/packages/swapper/src/index.ts index cfe3a584d04..cca501345cd 100644 --- a/packages/swapper/src/index.ts +++ b/packages/swapper/src/index.ts @@ -6,6 +6,7 @@ export * from './swappers/ArbitrumBridgeSwapper' export * from './swappers/BebopSwapper' export * from './swappers/CetusSwapper' export * from './swappers/ChainflipSwapper' +export * from './swappers/SunioSwapper' export * from './swappers/CowSwapper' export * from './swappers/JupiterSwapper' export * from './swappers/PortalsSwapper' diff --git a/packages/swapper/src/swappers/SunioSwapper/INTEGRATION.md b/packages/swapper/src/swappers/SunioSwapper/INTEGRATION.md new file mode 100644 index 00000000000..84097020af1 --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/INTEGRATION.md @@ -0,0 +1,168 @@ +# Sun.io Integration + +## Overview +- **Website**: https://sun.io +- **API Docs**: https://docs.sun.io/developers/swap/smart-router +- **Supported Chains**: TRON only +- **Type**: TRON Direct Smart Contract Execution via HTTP Quote API + +## API Details + +### Quote Endpoint +- **Base URL**: `https://rot.endjgfsv.link/swap/router` +- **Method**: GET +- **Authentication**: None required (public API) +- **Rate Limiting**: No observed limits + +**NOTE**: The `rot.endjgfsv.link` domain appears unusual, but it's the official Sun.io backend API. +This was verified by inspecting XHR requests from sun.io's own frontend application. +The sun.io frontend makes requests to this endpoint with `origin: https://sun.io`. + +### Query Parameters + +- `fromToken` - TRC20 token contract address (e.g., TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t for USDT) +- `toToken` - TRC20 token contract address +- `amountIn` - Amount to swap in token base units +- `typeList` - Comma-separated DEX types: `SUNSWAP_V1,SUNSWAP_V2,SUNSWAP_V3,PSM,CURVE` + +### Example Request + +```bash +curl 'https://rot.endjgfsv.link/swap/router?fromToken=TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t&toToken=TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8&amountIn=1000000&typeList=SUNSWAP_V1,SUNSWAP_V2,SUNSWAP_V3,PSM,CURVE' +``` + +### Response Format + +```json +{ + "code": 0, + "message": "SUCCESS", + "data": [{ + "amountIn": "1.000000", + "amountOut": "1.071122", + "inUsd": "1.000023900000000000000000", + "outUsd": "1.070933370295836840000000", + "impact": "-0.002174", + "fee": "0.003000", + "tokens": ["TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8"], + "symbols": ["USDT", "USDC"], + "poolFees": ["0", "0"], + "poolVersions": ["v2"], + "stepAmountsOut": ["1.071122"] + }] +} +``` + +The API returns multiple routes sorted by best price (first route is best). + +## Implementation Details + +### Chain Support +Sun.io operates **exclusively on TRON blockchain** for TRC-20 token swaps. + +### Native Token Handling +- Native TRX: `T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb` +- Wrapped TRX (WTRX): `TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR` + +### Transaction Building +Unlike EVM swappers, Sun.io requires building TRON smart contract transactions: +1. API returns routing information (tokens, pool versions, fees) +2. Build `swapExactInput` call to smart router contract `TCFNp179Lg46D16zKoumd4Poa2WFFdtqYj` +3. Use TronWeb's `triggerSmartContract` to construct unsigned transaction +4. Sign and broadcast via TRON chain adapter + +### Smart Contract Function + +The swap executes via SunSwap's Smart Exchange Router: +```solidity +function swapExactInput( + address[] calldata path, + string[] calldata poolVersion, + uint256[] calldata versionLen, + uint24[] calldata fees, + SwapData calldata data +) external nonReentrant payable returns (uint256[] memory amountsOut) +``` + +Where `SwapData` is: +```solidity +struct SwapData { + uint256 amountIn; + uint256 amountOutMin; // With slippage applied + address recipient; + uint256 deadline; +} +``` + +### Route Parameters Mapping + +From API response to contract parameters: +- `tokens[]` → `path[]` +- `poolVersions[]` → `poolVersion[]` (e.g., ["v2", "v3"]) +- Calculate `versionLen[]` from path length and pool count +- `poolFees[]` → `fees[]` (converted to uint24) + +### Slippage Application + +Sun.io API returns `amountOut` without slippage. We apply slippage when building the transaction: +```typescript +amountOutMin = amountOut * (1 - slippageTolerancePercentageDecimal) +``` + +Default slippage: **0.5%** (0.005 decimal) + +### Fee Estimation + +Network fees use TRON chain adapter's `getFeeData()`: +- Returns `txFee` in SUN (smallest unit of TRX) +- Typical swap fee: ~14-30 TRX depending on route complexity + +## Gotchas + +### 1. Amounts are Human-Readable +The API returns amounts in **human-readable format** (e.g., "1.071122"), NOT base units. +Must multiply by `10^precision` to convert to crypto base units. + +### 2. Multi-Hop Routes +API can return multi-hop routes (e.g., USDC → WTRX → USDT). +The `tokens[]` array includes ALL tokens in the path, including intermediaries. + +### 3. TronWeb Transaction Building +Must use TronWeb library to build smart contract calls - this is specific to TRON and different from EVM chains. + +### 4. No Affiliate Fee Support +Sun.io API doesn't support affiliate fees - we pass `affiliateBps` but it's ignored. + +### 5. Address Format +TRON addresses start with 'T' and use Base58 encoding (not EIP-55 checksum like EVM). + +## Testing Notes + +**Test Pairs** (high liquidity on TRON): +- USDT (TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t) ↔ USDC (TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8) +- TRX (T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb) ↔ USDT + +**Verify**: +- Quote amounts match API response (after precision conversion) +- Slippage is applied correctly in `amountOutMin` +- Transaction can be signed by TRON wallet +- Network fees are reasonable (~14-30 TRX) + +## Known Issues + +1. **Status Checking**: Currently returns default status. Full TRON transaction status polling not implemented. +2. **Cross-Account**: Not supported (same as most single-chain swappers) + +## References +- [Sun.io Smart Router Docs](https://docs.sun.io/developers/swap/smart-router) +- [SunSwap Contracts](https://github.com/sun-protocol/smart-exchange-router) +- [TronWeb Documentation](https://tronweb.network/docu/docs/intro/) + +## API Discovery + +The `rot.endjgfsv.link` endpoint was discovered by: +1. Inspecting network traffic from sun.io web application +2. Observing XHR requests with `origin: https://sun.io` header +3. Testing and verifying responses match expected swap data + +This appears to be Sun.io's internal aggregator API used by their frontend. diff --git a/packages/swapper/src/swappers/SunioSwapper/SunioSwapper.ts b/packages/swapper/src/swappers/SunioSwapper/SunioSwapper.ts new file mode 100644 index 00000000000..a7f1dfdc8ee --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/SunioSwapper.ts @@ -0,0 +1,6 @@ +import type { Swapper } from '../../types' +import { executeTronTransaction } from '../../utils' + +export const sunioSwapper: Swapper = { + executeTronTransaction, +} diff --git a/packages/swapper/src/swappers/SunioSwapper/endpoints.ts b/packages/swapper/src/swappers/SunioSwapper/endpoints.ts new file mode 100644 index 00000000000..d88d8241aa4 --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/endpoints.ts @@ -0,0 +1,208 @@ +import { tronAssetId, tronChainId } from '@shapeshiftoss/caip' +import type { tron } from '@shapeshiftoss/chain-adapters' +import { toAddressNList } from '@shapeshiftoss/chain-adapters' +import { TxStatus } from '@shapeshiftoss/unchained-client' +import { TronWeb } from 'tronweb' + +import { getTronTransactionFees } from '../../tron-utils/getTronTransactionFees' +import type { + CommonTradeQuoteInput, + GetTradeRateInput, + GetTronTradeQuoteInput, + GetUnsignedTronTransactionArgs, + SwapperApi, + SwapperDeps, + TradeQuoteResult, + TradeRateResult, +} from '../../types' +import { + createDefaultStatusResponse, + getExecutableTradeStep, + isExecutableTradeQuote, +} from '../../utils' +import { getSunioTradeQuote } from './getSunioTradeQuote/getSunioTradeQuote' +import { getSunioTradeRate } from './getSunioTradeRate/getSunioTradeRate' +import { buildSwapRouteParameters } from './utils/buildSwapRouteParameters' +import { SUNIO_SMART_ROUTER_CONTRACT } from './utils/constants' + +const convertAddressesToEvmFormat = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(v => convertAddressesToEvmFormat(v)) + } + + if (typeof value === 'string' && value.startsWith('T') && TronWeb.isAddress(value)) { + const hex = TronWeb.address.toHex(value) + return hex.replace(/^41/, '0x') + } + + return value +} + +export const sunioApi: SwapperApi = { + getTradeQuote: async ( + input: GetTronTradeQuoteInput | CommonTradeQuoteInput, + deps: SwapperDeps, + ): Promise => { + const maybeTradeQuote = await getSunioTradeQuote(input, deps) + return maybeTradeQuote.map(quote => [quote]) + }, + + getTradeRate: async (input: GetTradeRateInput, deps: SwapperDeps): Promise => { + const maybeTradeRate = await getSunioTradeRate(input, deps) + return maybeTradeRate.map(rate => [rate]) + }, + + getUnsignedTronTransaction: async ( + args: GetUnsignedTronTransactionArgs, + ): Promise => { + const { + tradeQuote, + stepIndex, + from, + slippageTolerancePercentageDecimal, + assertGetTronChainAdapter, + } = args + + if (!isExecutableTradeQuote(tradeQuote)) { + throw new Error('Unable to execute a trade rate quote') + } + + const step = getExecutableTradeStep(tradeQuote, stepIndex) + + const adapter = assertGetTronChainAdapter(tronChainId) + + const sunioMetadata = step.sunioTransactionMetadata + if (!sunioMetadata) { + throw new Error('[Sun.io] Missing transaction metadata in quote') + } + + const rpcUrl = adapter.httpProvider.getRpcUrl() + + const tronWeb = new TronWeb({ + fullHost: rpcUrl, + }) + + const routeParams = buildSwapRouteParameters( + sunioMetadata.route, + step.sellAmountIncludingProtocolFeesCryptoBaseUnit, + step.buyAmountAfterFeesCryptoBaseUnit, + from, + slippageTolerancePercentageDecimal, + ) + + const parameters = [ + { type: 'address[]', value: routeParams.path }, + { type: 'string[]', value: routeParams.poolVersion }, + { type: 'uint256[]', value: routeParams.versionLen }, + { type: 'uint24[]', value: routeParams.fees }, + { + type: 'tuple(uint256,uint256,address,uint256)', + value: convertAddressesToEvmFormat([ + routeParams.swapData.amountIn, + routeParams.swapData.amountOutMin, + routeParams.swapData.recipient, + routeParams.swapData.deadline, + ]), + }, + ] + + const functionSelector = + 'swapExactInput(address[],string[],uint256[],uint24[],(uint256,uint256,address,uint256))' + + const isSellingNativeTrx = step.sellAsset.assetId === tronAssetId + const callValue = isSellingNativeTrx + ? Number(step.sellAmountIncludingProtocolFeesCryptoBaseUnit) + : 0 + + const options = { + feeLimit: 100_000_000, + callValue, + } + + const txData = await tronWeb.transactionBuilder.triggerSmartContract( + SUNIO_SMART_ROUTER_CONTRACT, + functionSelector, + options, + parameters, + from, + ) + + if (!txData.result || !txData.result.result) { + throw new Error('[Sun.io] Failed to build swap transaction') + } + + const transaction = txData.transaction + + const rawDataHex = + typeof transaction.raw_data_hex === 'string' + ? transaction.raw_data_hex + : Buffer.isBuffer(transaction.raw_data_hex) + ? (transaction.raw_data_hex as Buffer).toString('hex') + : Array.isArray(transaction.raw_data_hex) + ? Buffer.from(transaction.raw_data_hex as number[]).toString('hex') + : (() => { + throw new Error(`Unexpected raw_data_hex type: ${typeof transaction.raw_data_hex}`) + })() + + if (!/^[0-9a-fA-F]+$/.test(rawDataHex)) { + throw new Error(`Invalid raw_data_hex format: ${rawDataHex.slice(0, 100)}`) + } + + const accountNumber = step.accountNumber + if (accountNumber === undefined) { + throw new Error('[Sun.io] accountNumber is required for execution') + } + const bip44Params = adapter.getBip44Params({ accountNumber }) + + const addressNList = toAddressNList(bip44Params) + + return { + addressNList, + rawDataHex, + transaction, + } + }, + + getTronTransactionFees, + + checkTradeStatus: async ({ txHash, assertGetTronChainAdapter }) => { + try { + const adapter = assertGetTronChainAdapter(tronChainId) + const rpcUrl = adapter.httpProvider.getRpcUrl() + + const response = await fetch(`${rpcUrl}/wallet/gettransactionbyid`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: txHash, visible: true }), + }) + + if (!response.ok) { + return createDefaultStatusResponse(txHash) + } + + const tx = await response.json() + + if (!tx || !tx.txID) { + return createDefaultStatusResponse(txHash) + } + + const contractRet = tx.ret?.[0]?.contractRet + + const status = + contractRet === 'SUCCESS' + ? TxStatus.Confirmed + : contractRet === 'REVERT' + ? TxStatus.Failed + : TxStatus.Pending + + return { + status, + buyTxHash: txHash, + message: undefined, + } + } catch (error) { + console.error('[Sun.io] Error checking trade status:', error) + return createDefaultStatusResponse(txHash) + } + }, +} diff --git a/packages/swapper/src/swappers/SunioSwapper/getSunioTradeQuote/getSunioTradeQuote.ts b/packages/swapper/src/swappers/SunioSwapper/getSunioTradeQuote/getSunioTradeQuote.ts new file mode 100644 index 00000000000..d879100bb3a --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/getSunioTradeQuote/getSunioTradeQuote.ts @@ -0,0 +1,17 @@ +import type { Result } from '@sniptt/monads' + +import type { + CommonTradeQuoteInput, + GetTronTradeQuoteInput, + SwapErrorRight, + SwapperDeps, + TradeQuote, +} from '../../../types' +import { getQuoteOrRate } from '../utils/getQuoteOrRate' + +export const getSunioTradeQuote = ( + input: GetTronTradeQuoteInput | CommonTradeQuoteInput, + deps: SwapperDeps, +): Promise> => { + return getQuoteOrRate(input, deps) +} diff --git a/packages/swapper/src/swappers/SunioSwapper/getSunioTradeRate/getSunioTradeRate.ts b/packages/swapper/src/swappers/SunioSwapper/getSunioTradeRate/getSunioTradeRate.ts new file mode 100644 index 00000000000..f306fadf7ef --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/getSunioTradeRate/getSunioTradeRate.ts @@ -0,0 +1,11 @@ +import type { Result } from '@sniptt/monads' + +import type { GetTradeRateInput, SwapErrorRight, SwapperDeps, TradeRate } from '../../../types' +import { getQuoteOrRate } from '../utils/getQuoteOrRate' + +export const getSunioTradeRate = ( + input: GetTradeRateInput, + deps: SwapperDeps, +): Promise> => { + return getQuoteOrRate(input, deps) +} diff --git a/packages/swapper/src/swappers/SunioSwapper/index.ts b/packages/swapper/src/swappers/SunioSwapper/index.ts new file mode 100644 index 00000000000..bb66b6053e5 --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/index.ts @@ -0,0 +1,4 @@ +export { sunioApi } from './endpoints' +export { sunioSwapper } from './SunioSwapper' +export * from './types' +export * from './utils/constants' diff --git a/packages/swapper/src/swappers/SunioSwapper/types.ts b/packages/swapper/src/swappers/SunioSwapper/types.ts new file mode 100644 index 00000000000..6c911bf6af4 --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/types.ts @@ -0,0 +1,35 @@ +import type { tronChainId } from '@shapeshiftoss/caip' + +export type SunioSupportedChainId = typeof tronChainId + +export type SunioRoute = { + amountIn: string + amountOut: string + inUsd: string + outUsd: string + impact: string + fee: string + tokens: string[] + symbols: string[] + poolFees: string[] + poolVersions: string[] + stepAmountsOut: string[] +} + +export type SunioQuoteResponse = { + code: number + message: string + data: SunioRoute[] +} + +export const SUNIO_SUPPORTED_DEX_TYPES = [ + 'SUNSWAP_V1', + 'SUNSWAP_V2', + 'SUNSWAP_V3', + 'PSM', + 'CURVE', + 'CURVE_COMBINATION', + 'WTRX', +] as const + +export type SunioDexType = (typeof SUNIO_SUPPORTED_DEX_TYPES)[number] diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/abi.ts b/packages/swapper/src/swappers/SunioSwapper/utils/abi.ts new file mode 100644 index 00000000000..b22acc543f9 --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/utils/abi.ts @@ -0,0 +1,25 @@ +export const SUNSWAP_ROUTER_ABI = [ + { + name: 'swapExactInput', + type: 'function', + stateMutability: 'payable', + inputs: [ + { internalType: 'address[]', name: 'path', type: 'address[]' }, + { internalType: 'string[]', name: 'poolVersion', type: 'string[]' }, + { internalType: 'uint256[]', name: 'versionLen', type: 'uint256[]' }, + { internalType: 'uint24[]', name: 'fees', type: 'uint24[]' }, + { + internalType: 'struct SwapData', + name: 'data', + type: 'tuple', + components: [ + { internalType: 'uint256', name: 'amountIn', type: 'uint256' }, + { internalType: 'uint256', name: 'amountOutMin', type: 'uint256' }, + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'deadline', type: 'uint256' }, + ], + }, + ], + outputs: [{ internalType: 'uint256[]', name: 'amountsOut', type: 'uint256[]' }], + }, +] as const diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/buildSwapRouteParameters.ts b/packages/swapper/src/swappers/SunioSwapper/utils/buildSwapRouteParameters.ts new file mode 100644 index 00000000000..458a45fd18c --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/utils/buildSwapRouteParameters.ts @@ -0,0 +1,56 @@ +import { bn } from '@shapeshiftoss/utils' + +import type { SunioRoute } from '../types' + +export type SwapRouteParameters = { + path: string[] + poolVersion: string[] + versionLen: number[] + fees: number[] + swapData: { + amountIn: string + amountOutMin: string + recipient: string + deadline: number + } +} + +export const buildSwapRouteParameters = ( + route: SunioRoute, + sellAmountCryptoBaseUnit: string, + minBuyAmountCryptoBaseUnit: string, + recipient: string, + slippageTolerancePercentageDecimal: string, +): SwapRouteParameters => { + const path = route.tokens + + const poolVersion = route.poolVersions + + const versionLen = poolVersion.map((_, index) => { + if (index === poolVersion.length - 1) { + return path.length - index + } + return 2 + }) + + const fees = route.poolFees.map(fee => Number(fee)) + + const amountOutWithSlippage = bn(minBuyAmountCryptoBaseUnit) + .times(bn(1).minus(slippageTolerancePercentageDecimal)) + .toFixed(0) + + const swapData = { + amountIn: sellAmountCryptoBaseUnit, + amountOutMin: amountOutWithSlippage, + recipient, + deadline: Math.floor(Date.now() / 1000) + 60 * 20, + } + + return { + path, + poolVersion, + versionLen, + fees, + swapData, + } +} diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/buildSwapTransaction.ts b/packages/swapper/src/swappers/SunioSwapper/utils/buildSwapTransaction.ts new file mode 100644 index 00000000000..12e820dd0f8 --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/utils/buildSwapTransaction.ts @@ -0,0 +1,96 @@ +import { TronWeb } from 'tronweb' + +import type { SunioRoute } from '../types' +import { SUNIO_SMART_ROUTER_CONTRACT } from './constants' + +const convertAddressesToEvmFormat = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(v => convertAddressesToEvmFormat(v)) + } + + if (typeof value === 'string' && value.startsWith('T') && TronWeb.isAddress(value)) { + const hex = TronWeb.address.toHex(value) + return hex.replace(/^41/, '0x') + } + + return value +} + +export type BuildSwapTransactionArgs = { + route: SunioRoute + from: string + to: string + sellAmountCryptoBaseUnit: string + minBuyAmountCryptoBaseUnit: string + rpcUrl: string + deadline?: number +} + +export const buildSunioSwapTransaction = async (args: BuildSwapTransactionArgs): Promise => { + const { + route, + from, + to, + sellAmountCryptoBaseUnit, + minBuyAmountCryptoBaseUnit, + rpcUrl, + deadline, + } = args + + const tronWeb = new TronWeb({ + fullHost: rpcUrl, + }) + + const path = route.tokens + + const poolVersion = route.poolVersions + + const versionLen = Array(poolVersion.length).fill(2) + + const fees = route.poolFees.map(fee => Number(fee)) + + const swapData = { + amountIn: sellAmountCryptoBaseUnit, + amountOutMin: minBuyAmountCryptoBaseUnit, + recipient: to, + deadline: deadline ?? Math.floor(Date.now() / 1000) + 60 * 20, + } + + const parameters = [ + { type: 'address[]', value: path }, + { type: 'string[]', value: poolVersion }, + { type: 'uint256[]', value: versionLen }, + { type: 'uint24[]', value: fees }, + { + type: 'tuple(uint256,uint256,address,uint256)', + value: convertAddressesToEvmFormat([ + swapData.amountIn, + swapData.amountOutMin, + swapData.recipient, + swapData.deadline, + ]), + }, + ] + + const functionSelector = + 'swapExactInput(address[],string[],uint256[],uint24[],(uint256,uint256,address,uint256))' + + const options = { + feeLimit: 100_000_000, + callValue: 0, + } + + const txData = await tronWeb.transactionBuilder.triggerSmartContract( + SUNIO_SMART_ROUTER_CONTRACT, + functionSelector, + options, + parameters, + from, + ) + + if (!txData.result || !txData.result.result) { + throw new Error('[Sun.io] Failed to build swap transaction') + } + + return txData.transaction +} diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/constants.ts b/packages/swapper/src/swappers/SunioSwapper/utils/constants.ts new file mode 100644 index 00000000000..3407537abe1 --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/utils/constants.ts @@ -0,0 +1,20 @@ +import { tronChainId } from '@shapeshiftoss/caip' + +export const SUNIO_SUPPORTED_CHAIN_IDS = [tronChainId] as const + +// NOTE: This domain looks suspicious, but it's the official Sun.io backend API +// Verified by inspecting XHR requests from sun.io's own frontend application +// The sun.io frontend makes requests to this endpoint with origin: https://sun.io +export const SUNIO_API_BASE_URL = 'https://rot.endjgfsv.link' + +// Smart Exchange Router - aggregates liquidity across V1, V2, V3, PSM, and SunCurve +// This is the current mainnet router that sun.io's frontend uses +export const SUNIO_SMART_ROUTER_CONTRACT = 'TCFNp179Lg46D16zKoumd4Poa2WFFdtqYj' as const + +export const DEFAULT_SLIPPAGE_PERCENTAGE = '0.005' + +export const SUNIO_DEX_TYPES = + 'PSM,CURVE,CURVE_COMBINATION,WTRX,SUNSWAP_V1,SUNSWAP_V2,SUNSWAP_V3' as const + +// Native TRX token address (used when assetId is not a TRC-20 token) +export const SUNIO_TRON_NATIVE_ADDRESS = 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb' as const diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/fetchFromSunio.ts b/packages/swapper/src/swappers/SunioSwapper/utils/fetchFromSunio.ts new file mode 100644 index 00000000000..2916e46d7db --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/utils/fetchFromSunio.ts @@ -0,0 +1,68 @@ +import type { AssetId } from '@shapeshiftoss/caip' +import type { Result } from '@sniptt/monads' +import { Err, Ok } from '@sniptt/monads' + +import type { SwapErrorRight } from '../../../types' +import { TradeQuoteError } from '../../../types' +import { makeSwapErrorRight } from '../../../utils' +import type { SunioQuoteResponse } from '../types' +import { SUNIO_API_BASE_URL, SUNIO_DEX_TYPES } from './constants' +import { assetIdToTronToken } from './helpers/helpers' +import type { SunioService } from './sunioService' + +export type FetchSunioQuoteParams = { + sellAssetId: AssetId + buyAssetId: AssetId + sellAmountCryptoBaseUnit: string +} + +export const fetchSunioQuote = async ( + params: FetchSunioQuoteParams, + service: SunioService, +): Promise> => { + try { + const { sellAssetId, buyAssetId, sellAmountCryptoBaseUnit } = params + + const fromToken = assetIdToTronToken(sellAssetId) + const toToken = assetIdToTronToken(buyAssetId) + + const queryParams = { + fromToken, + toToken, + amountIn: sellAmountCryptoBaseUnit, + typeList: SUNIO_DEX_TYPES, + } + + const url = `${SUNIO_API_BASE_URL}/swap/router` + + const maybeResponse = await service.get(url, { + params: queryParams, + }) + + if (maybeResponse.isErr()) { + return Err(maybeResponse.unwrapErr()) + } + + const { data: response } = maybeResponse.unwrap() + + if (response.code !== 0 || !response.data || response.data.length === 0) { + return Err( + makeSwapErrorRight({ + message: `[Sun.io] ${response.message || 'No routes found'}`, + code: TradeQuoteError.NoRouteFound, + details: { response }, + }), + ) + } + + return Ok(response) + } catch (error) { + return Err( + makeSwapErrorRight({ + message: '[Sun.io] Failed to fetch quote', + code: TradeQuoteError.QueryFailed, + cause: error, + }), + ) + } +} diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts b/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts new file mode 100644 index 00000000000..42d9b53176b --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts @@ -0,0 +1,199 @@ +import { tronChainId } from '@shapeshiftoss/caip' +import { bn } from '@shapeshiftoss/utils' +import type { Result } from '@sniptt/monads' +import { Err, Ok } from '@sniptt/monads' + +import type { + CommonTradeQuoteInput, + GetTradeRateInput, + GetTronTradeQuoteInput, + SwapErrorRight, + SwapperDeps, + TradeQuote, + TradeRate, +} from '../../../types' +import { SwapperName, TradeQuoteError } from '../../../types' +import { getInputOutputRate, makeSwapErrorRight } from '../../../utils' +import { DEFAULT_SLIPPAGE_PERCENTAGE, SUNIO_SMART_ROUTER_CONTRACT } from './constants' +import { fetchSunioQuote } from './fetchFromSunio' +import { isSupportedChainId } from './helpers/helpers' +import { sunioServiceFactory } from './sunioService' + +export async function getQuoteOrRate( + input: GetTronTradeQuoteInput | CommonTradeQuoteInput, + deps: SwapperDeps, +): Promise> + +export async function getQuoteOrRate( + input: GetTradeRateInput, + deps: SwapperDeps, +): Promise> + +export async function getQuoteOrRate( + input: GetTradeRateInput | GetTronTradeQuoteInput | CommonTradeQuoteInput, + deps: SwapperDeps, +): Promise> { + try { + const { + sellAsset, + buyAsset, + sellAmountIncludingProtocolFeesCryptoBaseUnit, + receiveAddress, + accountNumber, + affiliateBps, + slippageTolerancePercentageDecimal, + } = input + + const { assertGetTronChainAdapter } = deps + + if (!isSupportedChainId(sellAsset.chainId)) { + return Err( + makeSwapErrorRight({ + message: `[${SwapperName.Sunio}] Unsupported chainId: ${sellAsset.chainId}`, + code: TradeQuoteError.UnsupportedChain, + details: { chainId: sellAsset.chainId }, + }), + ) + } + + if (sellAsset.chainId !== buyAsset.chainId) { + return Err( + makeSwapErrorRight({ + message: `[${SwapperName.Sunio}] Cross-chain not supported`, + code: TradeQuoteError.CrossChainNotSupported, + }), + ) + } + + if (sellAsset.chainId !== tronChainId) { + return Err( + makeSwapErrorRight({ + message: `[${SwapperName.Sunio}] Only TRON chain supported`, + code: TradeQuoteError.UnsupportedChain, + }), + ) + } + + const service = sunioServiceFactory() + const maybeQuoteResponse = await fetchSunioQuote( + { + sellAssetId: sellAsset.assetId, + buyAssetId: buyAsset.assetId, + sellAmountCryptoBaseUnit: sellAmountIncludingProtocolFeesCryptoBaseUnit, + }, + service, + ) + + if (maybeQuoteResponse.isErr()) { + return Err(maybeQuoteResponse.unwrapErr()) + } + + const quoteResponse = maybeQuoteResponse.unwrap() + + const bestRoute = quoteResponse.data[0] + + if (!bestRoute) { + return Err( + makeSwapErrorRight({ + message: '[Sun.io] No routes available', + code: TradeQuoteError.NoRouteFound, + }), + ) + } + + const isQuote = input.quoteOrRate === 'quote' + + // Fetch network fees only for quotes + let networkFeeCryptoBaseUnit: string | undefined = undefined + + if (isQuote) { + if (!receiveAddress) { + return Err( + makeSwapErrorRight({ + message: '[Sun.io] receiveAddress is required for quotes', + code: TradeQuoteError.InternalError, + }), + ) + } + + const adapter = assertGetTronChainAdapter(sellAsset.chainId) + const feeData = await adapter.getFeeData({ + to: receiveAddress, + value: '0', + sendMax: false, + }) + networkFeeCryptoBaseUnit = feeData.fast.txFee + } + + const buyAmountCryptoBaseUnit = bn(bestRoute.amountOut) + .times(bn(10).pow(buyAsset.precision)) + .toFixed(0) + + // Calculate protocol fees only for quotes + const protocolFeeCryptoBaseUnit = isQuote + ? bn(bestRoute.fee).times(sellAmountIncludingProtocolFeesCryptoBaseUnit).toFixed(0) + : '0' + + const buyAmountAfterFeesCryptoBaseUnit = buyAmountCryptoBaseUnit + + const rate = getInputOutputRate({ + sellAmountCryptoBaseUnit: sellAmountIncludingProtocolFeesCryptoBaseUnit, + buyAmountCryptoBaseUnit, + sellAsset, + buyAsset, + }) + + const trade = { + id: crypto.randomUUID(), + quoteOrRate: input.quoteOrRate, + rate, + slippageTolerancePercentageDecimal: + slippageTolerancePercentageDecimal ?? DEFAULT_SLIPPAGE_PERCENTAGE, + receiveAddress, + affiliateBps, + steps: [ + { + buyAmountBeforeFeesCryptoBaseUnit: buyAmountCryptoBaseUnit, + buyAmountAfterFeesCryptoBaseUnit, + sellAmountIncludingProtocolFeesCryptoBaseUnit, + feeData: { + networkFeeCryptoBaseUnit, + protocolFees: + protocolFeeCryptoBaseUnit !== '0' + ? { + [sellAsset.assetId]: { + amountCryptoBaseUnit: protocolFeeCryptoBaseUnit, + requiresBalance: false, + asset: sellAsset, + }, + } + : {}, + }, + rate, + source: SwapperName.Sunio, + buyAsset, + sellAsset, + accountNumber, + allowanceContract: SUNIO_SMART_ROUTER_CONTRACT, + estimatedExecutionTimeMs: undefined, + ...(isQuote && { + sunioTransactionMetadata: { + route: bestRoute, + }, + }), + }, + ], + swapperName: SwapperName.Sunio, + } + + return Ok(trade as typeof input.quoteOrRate extends 'quote' ? TradeQuote : TradeRate) + } catch (error) { + return Err( + makeSwapErrorRight({ + message: `[Sun.io] Failed to get trade ${input.quoteOrRate}`, + code: TradeQuoteError.UnknownError, + cause: error, + }), + ) + } +} diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/helpers/helpers.ts b/packages/swapper/src/swappers/SunioSwapper/utils/helpers/helpers.ts new file mode 100644 index 00000000000..15acdc5e632 --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/utils/helpers/helpers.ts @@ -0,0 +1,21 @@ +import type { AssetId } from '@shapeshiftoss/caip' +import { fromAssetId, tronChainId } from '@shapeshiftoss/caip' +import { isToken } from '@shapeshiftoss/utils' + +import { SUNIO_SUPPORTED_CHAIN_IDS, SUNIO_TRON_NATIVE_ADDRESS } from '../constants' + +export const isSupportedChainId = (chainId: string): boolean => { + return SUNIO_SUPPORTED_CHAIN_IDS.includes(chainId as any) +} + +export const assetIdToTronToken = (assetId: AssetId): string => { + if (isToken(assetId)) { + const { assetReference } = fromAssetId(assetId) + return assetReference + } + return SUNIO_TRON_NATIVE_ADDRESS +} + +export const isTronChainId = (chainId: string): chainId is typeof tronChainId => { + return chainId === tronChainId +} diff --git a/packages/swapper/src/swappers/SunioSwapper/utils/sunioService.ts b/packages/swapper/src/swappers/SunioSwapper/utils/sunioService.ts new file mode 100644 index 00000000000..63009fb974d --- /dev/null +++ b/packages/swapper/src/swappers/SunioSwapper/utils/sunioService.ts @@ -0,0 +1,20 @@ +import { createCache, makeSwapperAxiosServiceMonadic } from '../../../utils' + +const maxAge = 5 * 1000 + +const cachedUrls = ['/swap/router'] + +export const sunioServiceFactory = () => { + const axiosConfig = { + timeout: 10000, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + } + + const serviceBase = createCache(maxAge, cachedUrls, axiosConfig) + return makeSwapperAxiosServiceMonadic(serviceBase) +} + +export type SunioService = ReturnType diff --git a/packages/swapper/src/types.ts b/packages/swapper/src/types.ts index fac2302210d..e8372d3e8b9 100644 --- a/packages/swapper/src/types.ts +++ b/packages/swapper/src/types.ts @@ -86,6 +86,7 @@ export enum SwapperName { Bebop = 'Bebop', NearIntents = 'NEAR Intents', Cetus = 'Cetus', + Sunio = 'Sun.io', } export type SwapSource = SwapperName | `${SwapperName} • ${string}` @@ -370,6 +371,21 @@ export type TradeQuoteStep = { value: Hex gasLimit: string } + sunioTransactionMetadata?: { + route: { + amountIn: string + amountOut: string + inUsd: string + outUsd: string + impact: string + fee: string + tokens: string[] + symbols: string[] + poolFees: string[] + poolVersions: string[] + stepAmountsOut: string[] + } + } } export type TradeRateStep = Omit & { accountNumber: undefined } @@ -617,6 +633,7 @@ export type CheckTradeStatusInput = { UtxoSwapperDeps & CosmosSdkSwapperDeps & SolanaSwapperDeps & + TronSwapperDeps & SuiSwapperDeps export type TradeStatus = { diff --git a/src/components/MultiHopTrade/components/TradeConfirm/hooks/useAllowanceApproval.tsx b/src/components/MultiHopTrade/components/TradeConfirm/hooks/useAllowanceApproval.tsx index bc3a57173f3..0c36138e57e 100644 --- a/src/components/MultiHopTrade/components/TradeConfirm/hooks/useAllowanceApproval.tsx +++ b/src/components/MultiHopTrade/components/TradeConfirm/hooks/useAllowanceApproval.tsx @@ -1,4 +1,4 @@ -import { fromAccountId } from '@shapeshiftoss/caip' +import { fromAccountId, tronChainId } from '@shapeshiftoss/caip' import { assertGetViemClient } from '@shapeshiftoss/contracts' import { isGridPlus } from '@shapeshiftoss/hdwallet-gridplus' import { isTrezor } from '@shapeshiftoss/hdwallet-trezor' @@ -114,8 +114,62 @@ export const useAllowanceApproval = ( if (!tradeQuoteStep?.sellAsset || !sellAssetAccountId) return - const publicClient = assertGetViemClient(tradeQuoteStep.sellAsset.chainId) - await publicClient.waitForTransactionReceipt({ hash: txHash as Hash }) + // Handle TRON transaction confirmation + if (tradeQuoteStep.sellAsset.chainId === tronChainId) { + const adapter = await import('@/lib/utils').then(m => + m.assertGetTronChainAdapter(tronChainId), + ) + const rpcUrl = adapter.httpProvider.getRpcUrl() + + // Poll for transaction confirmation (TRON doesn't have waitForTransactionReceipt) + let confirmed = false + let attempts = 0 + const maxAttempts = 60 // 60 seconds max (TRON can be slow) + + while (!confirmed && attempts < maxAttempts) { + try { + // Try wallet first (recent txs), then walletsolidity (confirmed txs) + const endpoint = + attempts < 20 ? '/wallet/gettransactionbyid' : '/walletsolidity/gettransactionbyid' + const response = await fetch(`${rpcUrl}${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: txHash }), + }) + + if (response.ok) { + const tx = await response.json() + const contractRet = tx?.ret?.[0]?.contractRet + + if (contractRet === 'SUCCESS') { + confirmed = true + } else if (contractRet === 'REVERT' || contractRet === 'OUT_OF_ENERGY') { + throw new Error(`Transaction failed: ${contractRet}`) + } + // If no contractRet yet, continue polling + } + } catch (err) { + // Continue polling on errors unless it's a failure + if (err instanceof Error && err.message.includes('Transaction failed')) { + throw err + } + } + + if (!confirmed) { + await new Promise(resolve => setTimeout(resolve, 1000)) + attempts++ + } + } + + if (!confirmed) { + // Don't throw - approval might have succeeded even if we couldn't confirm + // Transaction polling timed out but the approval likely succeeded on-chain + } + } else { + // Handle EVM transaction confirmation + const publicClient = assertGetViemClient(tradeQuoteStep.sellAsset.chainId) + await publicClient.waitForTransactionReceipt({ hash: txHash as Hash }) + } dispatch( tradeQuoteSlice.actions.setAllowanceApprovalTxComplete({ diff --git a/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/SwapperIcon.tsx b/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/SwapperIcon.tsx index 327c73cf2f0..fe714512ba9 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/SwapperIcon.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/SwapperIcon.tsx @@ -14,6 +14,7 @@ import MayachainIcon from './maya_logo.png' import NearIntentsIcon from './near-intents-icon.png' import PortalsIcon from './portals-icon.png' import RelayIcon from './relay-icon.svg?url' +import SunioIcon from './sunio-icon.png' import THORChainIcon from './thorchain-icon.png' import { LazyLoadAvatar } from '@/components/LazyLoadAvatar' @@ -54,6 +55,8 @@ export const SwapperIcon = ({ return NearIntentsIcon case SwapperName.Cetus: return CetusIcon + case SwapperName.Sunio: + return SunioIcon case SwapperName.Test: return '' default: diff --git a/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/sunio-icon.png b/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/sunio-icon.png new file mode 100644 index 00000000000..923ecd9796c Binary files /dev/null and b/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/sunio-icon.png differ diff --git a/src/components/MultiHopTrade/hooks/useGetTradeQuotes/useGetTradeQuotes.tsx b/src/components/MultiHopTrade/hooks/useGetTradeQuotes/useGetTradeQuotes.tsx index b3b17e1c2c0..74ae5bc07a8 100644 --- a/src/components/MultiHopTrade/hooks/useGetTradeQuotes/useGetTradeQuotes.tsx +++ b/src/components/MultiHopTrade/hooks/useGetTradeQuotes/useGetTradeQuotes.tsx @@ -145,6 +145,7 @@ export const useGetTradeQuotes = () => { permit2?.state === TransactionExecutionState.AwaitingConfirmation) || (!permit2?.isRequired && hopExecutionMetadata?.state === HopExecutionState.AwaitingSwap) ) + return ( hopExecutionMetadata?.state === HopExecutionState.AwaitingSwap && hopExecutionMetadata?.swap?.state === TransactionExecutionState.AwaitingConfirmation diff --git a/src/config.ts b/src/config.ts index 4c1fd34f6e0..1f831b23dc1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -201,6 +201,7 @@ const validators = { VITE_BEBOP_API_KEY: str(), VITE_FEATURE_NEAR_INTENTS_SWAP: bool({ default: false }), VITE_FEATURE_CETUS_SWAP: bool({ default: true }), + VITE_FEATURE_SUNIO_SWAP: bool({ default: false }), VITE_NEAR_INTENTS_API_KEY: str(), VITE_FEATURE_TX_HISTORY_BYE_BYE: bool({ default: false }), VITE_FEATURE_RFOX_FOX_ECOSYSTEM_PAGE: bool({ default: false }), diff --git a/src/hooks/queries/useApprovalFees.ts b/src/hooks/queries/useApprovalFees.ts index 7e85d7bd26f..606a6573b76 100644 --- a/src/hooks/queries/useApprovalFees.ts +++ b/src/hooks/queries/useApprovalFees.ts @@ -1,11 +1,12 @@ import type { AssetId } from '@shapeshiftoss/caip' -import { fromAssetId } from '@shapeshiftoss/caip' +import { fromAssetId, tronChainId } from '@shapeshiftoss/caip' +import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { maxUint256 } from 'viem' import { useEvmFees } from './useEvmFees' -import { assertUnreachable } from '@/lib/utils' +import { assertGetTronChainAdapter, assertUnreachable } from '@/lib/utils' import { getApproveContractData } from '@/lib/utils/evm' export enum AllowanceType { @@ -42,6 +43,9 @@ export const useApprovalFees = ({ const approveContractData = useMemo(() => { if (!amountCryptoBaseUnit || !spender || !to || !chainId || !enabled) return + // Only generate contract data for EVM chains (TRON doesn't use this) + if (chainId === tronChainId) return undefined + return getApproveContractData({ approvalAmountCryptoBaseUnit: getApprovalAmountCryptoBaseUnit( amountCryptoBaseUnit, @@ -53,20 +57,48 @@ export const useApprovalFees = ({ }) }, [allowanceType, amountCryptoBaseUnit, chainId, enabled, spender, to]) + // For TRON, estimate approval fees directly + const tronFeesResult = useQuery({ + queryKey: ['tronApprovalFees', assetId, spender, from], + queryFn: async () => { + if (!assetId || !to || !from || !chainId) { + throw new Error('Missing required parameters for TRON fee estimation') + } + + const adapter = assertGetTronChainAdapter(chainId) + + // Estimate fees for approval transaction + const feeData = await adapter.getFeeData({ + to, + value: '0', + sendMax: false, + }) + + return { + networkFeeCryptoBaseUnit: feeData.fast.txFee, + } + }, + enabled: Boolean(enabled && chainId === tronChainId && assetId && to && from), + refetchInterval: isRefetchEnabled ? 15_000 : false, + }) + const evmFeesResult = useEvmFees({ to, from, value: '0', chainId, data: approveContractData, - enabled: Boolean(enabled), + enabled: Boolean(enabled && chainId !== tronChainId), refetchIntervalInBackground: isRefetchEnabled ? true : false, refetchInterval: isRefetchEnabled ? 15_000 : false, }) + // Return unified interface - TRON or EVM fees + const feesResult = chainId === tronChainId ? tronFeesResult : evmFeesResult + return { approveContractData, - evmFeesResult, + evmFeesResult: feesResult, } } diff --git a/src/lib/tradeExecution.ts b/src/lib/tradeExecution.ts index 710e4cd3991..f0062bc467d 100644 --- a/src/lib/tradeExecution.ts +++ b/src/lib/tradeExecution.ts @@ -87,6 +87,7 @@ export const fetchTradeStatus = async ({ assertGetUtxoChainAdapter, assertGetCosmosSdkChainAdapter, assertGetSolanaChainAdapter, + assertGetTronChainAdapter, assertGetSuiChainAdapter, fetchIsSmartContractAddressQuery, }) diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 6e855aee8f4..23515c9431f 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -288,3 +288,5 @@ export function assertIsKnownChainId(chainId: ChainId): asserts chainId is Known throw Error(`Unknown ChainId${chainId}`) } } + +export * from './tron' diff --git a/src/lib/utils/tron/approve.ts b/src/lib/utils/tron/approve.ts new file mode 100644 index 00000000000..23f57c03874 --- /dev/null +++ b/src/lib/utils/tron/approve.ts @@ -0,0 +1,110 @@ +import { fromAssetId } from '@shapeshiftoss/caip' +import { toAddressNList } from '@shapeshiftoss/chain-adapters' +import { supportsTron } from '@shapeshiftoss/hdwallet-core' +import { TronWeb } from 'tronweb' + +import { assertGetTronChainAdapter } from '..' +import type { ApproveTronInputWithWallet } from './types' + +export const approveTron = async ({ + assetId, + spender, + amountCryptoBaseUnit, + wallet, + accountNumber, + from, +}: ApproveTronInputWithWallet): Promise => { + const { assetReference: tokenAddress, chainId } = fromAssetId(assetId) + + const adapter = assertGetTronChainAdapter(chainId) + const rpcUrl = adapter.httpProvider.getRpcUrl() + + const tronWeb = new TronWeb({ fullHost: rpcUrl }) + + // Build approve transaction + const parameters = [ + { type: 'address', value: spender }, + { type: 'uint256', value: amountCryptoBaseUnit }, + ] + + const options = { + feeLimit: 100_000_000, // 100 TRX fee limit + callValue: 0, + } + + const txData = await tronWeb.transactionBuilder.triggerSmartContract( + tokenAddress, + 'approve(address,uint256)', + options, + parameters, + from, + ) + + if (!txData.result?.result) { + throw new Error('Failed to build TRON approval transaction') + } + + // Extract raw_data_hex + const transaction = txData.transaction + const rawDataHex = + typeof transaction.raw_data_hex === 'string' + ? transaction.raw_data_hex + : Buffer.isBuffer(transaction.raw_data_hex) + ? (transaction.raw_data_hex as Buffer).toString('hex') + : Array.isArray(transaction.raw_data_hex) + ? Buffer.from(transaction.raw_data_hex as number[]).toString('hex') + : (() => { + throw new Error(`Unexpected raw_data_hex type: ${typeof transaction.raw_data_hex}`) + })() + + if (!/^[0-9a-fA-F]+$/.test(rawDataHex)) { + throw new Error(`Invalid raw_data_hex format: ${rawDataHex.slice(0, 100)}`) + } + + // Build HDWallet-compatible transaction + const bip44Params = adapter.getBip44Params({ accountNumber }) + const addressNList = toAddressNList(bip44Params) + + const tronTx = { + addressNList, + rawDataHex, + transaction, + } + + // Sign with wallet + if (!supportsTron(wallet)) { + throw new Error('Wallet does not support TRON') + } + + const signedTx = await wallet.tronSignTx(tronTx) + + if (!signedTx?.serialized) { + throw new Error('Failed to sign TRON approval transaction') + } + + // Build the transaction object for broadcast + // signedTx.serialized is the raw hex, signature is separate + const broadcastTx = { + ...transaction, + signature: [signedTx.signature], + } + + const broadcastResponse = await fetch(`${rpcUrl}/wallet/broadcasttransaction`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(broadcastTx), + }) + + if (!broadcastResponse.ok) { + throw new Error(`Failed to broadcast TRON approval: ${broadcastResponse.statusText}`) + } + + const result = await broadcastResponse.json() + + const txid = result.txid ?? transaction.txID + if (!result.result && !txid) { + throw new Error(`TRON approval broadcast failed: ${JSON.stringify(result)}`) + } + + return txid +} diff --git a/src/lib/utils/tron/getAllowance.ts b/src/lib/utils/tron/getAllowance.ts new file mode 100644 index 00000000000..0b5698737c8 --- /dev/null +++ b/src/lib/utils/tron/getAllowance.ts @@ -0,0 +1,76 @@ +import type { AssetId, ChainId } from '@shapeshiftoss/caip' +import { fromAssetId } from '@shapeshiftoss/caip' +import { TronWeb } from 'tronweb' + +import { assertGetTronChainAdapter } from '..' + +type GetTrc20AllowanceArgs = { + address: string + spender: string + from: string + chainId: ChainId +} + +export const getTrc20Allowance = async ({ + address, + spender, + from, + chainId, +}: GetTrc20AllowanceArgs): Promise => { + const adapter = assertGetTronChainAdapter(chainId) + const rpcUrl = adapter.httpProvider.getRpcUrl() + + // Encode parameters for allowance(address,address) + // TronWeb.address.toHex returns 41-prefixed hex (e.g., 41af46...) + // We need to remove the 41 prefix to get the 20-byte address + const ownerHex = TronWeb.address.toHex(from).replace(/^41/, '') + const spenderHex = TronWeb.address.toHex(spender).replace(/^41/, '') + + // Pad to 32 bytes (64 hex chars) each + const parameter = ownerHex.padStart(64, '0') + spenderHex.padStart(64, '0') + + const response = await fetch(`${rpcUrl}/wallet/triggerconstantcontract`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + owner_address: from, + contract_address: address, + function_selector: 'allowance(address,address)', + parameter, + visible: true, + }), + }) + + if (!response.ok) { + throw new Error(`Failed to query TRC-20 allowance: ${response.statusText}`) + } + + const result = await response.json() + + if (!result.constant_result?.[0]) { + throw new Error('Invalid response from TRON node when querying allowance') + } + + // Decode uint256 from hex + const allowanceHex = result.constant_result[0] + const allowance = BigInt('0x' + allowanceHex) + + return allowance.toString() +} + +type GetAllowanceInput = { + assetId: AssetId + spender: string + from: string +} + +export const getAllowance = ({ assetId, spender, from }: GetAllowanceInput): Promise => { + const { assetReference, chainId } = fromAssetId(assetId) + + return getTrc20Allowance({ + address: assetReference, + spender, + from, + chainId, + }) +} diff --git a/src/lib/utils/tron/index.ts b/src/lib/utils/tron/index.ts new file mode 100644 index 00000000000..50c06585d1d --- /dev/null +++ b/src/lib/utils/tron/index.ts @@ -0,0 +1,7 @@ +export { approveTron } from './approve' +export { getTrc20Allowance, getAllowance } from './getAllowance' +export type { + ApproveTronInputWithWallet, + MaybeApproveTronInput, + MaybeApproveTronInputWithWallet, +} from './types' diff --git a/src/lib/utils/tron/types.ts b/src/lib/utils/tron/types.ts new file mode 100644 index 00000000000..c110f0baf37 --- /dev/null +++ b/src/lib/utils/tron/types.ts @@ -0,0 +1,17 @@ +import type { AssetId } from '@shapeshiftoss/caip' +import type { HDWallet } from '@shapeshiftoss/hdwallet-core' + +import type { MaybeUndefinedFields } from '@/lib/types' + +type ApproveTronInput = { + assetId: AssetId + spender: string + amountCryptoBaseUnit: string + accountNumber: number + from: string +} + +export type MaybeApproveTronInput = MaybeUndefinedFields + +export type ApproveTronInputWithWallet = ApproveTronInput & { wallet: HDWallet } +export type MaybeApproveTronInputWithWallet = MaybeUndefinedFields diff --git a/src/react-queries/queries/common.ts b/src/react-queries/queries/common.ts index 4ce2cfb76c8..a132f876753 100644 --- a/src/react-queries/queries/common.ts +++ b/src/react-queries/queries/common.ts @@ -1,6 +1,6 @@ import { createQueryKeys } from '@lukemorales/query-key-factory' import type { AssetId, ChainId } from '@shapeshiftoss/caip' -import { fromAssetId } from '@shapeshiftoss/caip' +import { fromAssetId, tronChainId } from '@shapeshiftoss/caip' import { evmChainIds } from '@shapeshiftoss/chain-adapters' import type { EvmChainId } from '@shapeshiftoss/types' import type { Result } from '@sniptt/monads' @@ -12,6 +12,7 @@ import type { PartialFields } from '@/lib/types' import { assertGetChainAdapter } from '@/lib/utils' import type { GetFeesWithWalletEip1559SupportArgs } from '@/lib/utils/evm' import { getErc20Allowance } from '@/lib/utils/evm' +import { getTrc20Allowance } from '@/lib/utils/tron/getAllowance' export const common = createQueryKeys('common', { allowanceCryptoBaseUnit: ( @@ -32,9 +33,6 @@ export const common = createQueryKeys('common', { if (spender === '0x0') { return Err(GetAllowanceErr.ZeroAddress) } - if (!evmChainIds.includes(chainId as EvmChainId)) { - return Err(GetAllowanceErr.NotEVMChain) - } // Asserts and makes the query error (i.e isError) if this errors - *not* a monadic error const adapter = assertGetChainAdapter(chainId) @@ -44,6 +42,23 @@ export const common = createQueryKeys('common', { return Err(GetAllowanceErr.IsFeeAsset) } + // Handle TRON chain + if (chainId === tronChainId) { + const allowanceOnChainCryptoBaseUnit = await getTrc20Allowance({ + address: assetReference, + spender, + from, + chainId, + }) + + return Ok(allowanceOnChainCryptoBaseUnit) + } + + // Handle EVM chains + if (!evmChainIds.includes(chainId as EvmChainId)) { + return Err(GetAllowanceErr.NotEVMChain) + } + const allowanceOnChainCryptoBaseUnit = await getErc20Allowance({ address: assetReference, spender, diff --git a/src/react-queries/queries/mutations.ts b/src/react-queries/queries/mutations.ts index c47535824aa..a22c09ce469 100644 --- a/src/react-queries/queries/mutations.ts +++ b/src/react-queries/queries/mutations.ts @@ -1,7 +1,10 @@ import { createMutationKeys } from '@lukemorales/query-key-factory' +import { fromAssetId, tronChainId } from '@shapeshiftoss/caip' import { approve } from '@/lib/utils/evm/approve' import type { MaybeApproveInputWithWallet } from '@/lib/utils/evm/types' +import { approveTron } from '@/lib/utils/tron/approve' +import type { MaybeApproveTronInputWithWallet } from '@/lib/utils/tron/types' export const mutations = createMutationKeys('mutations', { approve: ({ @@ -12,7 +15,7 @@ export const mutations = createMutationKeys('mutations', { wallet, from, pubKey, - }: MaybeApproveInputWithWallet) => ({ + }: (MaybeApproveInputWithWallet | MaybeApproveTronInputWithWallet) & { pubKey?: string }) => ({ mutationKey: ['approve', { assetId, accountNumber, amountCryptoBaseUnit, spender }], mutationFn: (_: void) => { if (!assetId) throw new Error('assetId is required') @@ -22,6 +25,21 @@ export const mutations = createMutationKeys('mutations', { if (accountNumber === undefined) throw new Error('accountNumber is required') if (!from) throw new Error('from is required') + const { chainId } = fromAssetId(assetId) + + // Handle TRON approvals + if (chainId === tronChainId) { + return approveTron({ + assetId, + accountNumber, + amountCryptoBaseUnit, + spender, + wallet, + from, + }) + } + + // Handle EVM approvals return approve({ assetId, accountNumber, diff --git a/src/state/helpers.ts b/src/state/helpers.ts index 46e21be1e83..8a9d620d4e8 100644 --- a/src/state/helpers.ts +++ b/src/state/helpers.ts @@ -20,6 +20,7 @@ export const isCrossAccountTradeSupported = (swapperName: SwapperName) => { case SwapperName.ArbitrumBridge: case SwapperName.Portals: case SwapperName.Cetus: + case SwapperName.Sunio: case SwapperName.Test: // Technically supported for Arbitrum Bridge, but we disable it for the sake of simplicity for now return false @@ -43,6 +44,7 @@ export const getEnabledSwappers = ( BebopSwap, NearIntentsSwap, CetusSwap, + SunioSwap, }: FeatureFlags, isCrossAccountTrade: boolean, isSolBuyAssetId: boolean, @@ -79,6 +81,8 @@ export const getEnabledSwappers = ( (!isCrossAccountTrade || isCrossAccountTradeSupported(SwapperName.NearIntents)), [SwapperName.Cetus]: CetusSwap && (!isCrossAccountTrade || isCrossAccountTradeSupported(SwapperName.Cetus)), + [SwapperName.Sunio]: + SunioSwap && (!isCrossAccountTrade || isCrossAccountTradeSupported(SwapperName.Sunio)), [SwapperName.Test]: false, } } diff --git a/src/state/slices/preferencesSlice/preferencesSlice.ts b/src/state/slices/preferencesSlice/preferencesSlice.ts index 781cef52fff..8560ee3694c 100644 --- a/src/state/slices/preferencesSlice/preferencesSlice.ts +++ b/src/state/slices/preferencesSlice/preferencesSlice.ts @@ -93,6 +93,7 @@ export type FeatureFlags = { BebopSwap: boolean NearIntentsSwap: boolean CetusSwap: boolean + SunioSwap: boolean LazyTxHistory: boolean RfoxFoxEcosystemPage: boolean LedgerReadOnly: boolean @@ -229,6 +230,7 @@ const initialState: Preferences = { BebopSwap: getConfig().VITE_FEATURE_BEBOP_SWAP, NearIntentsSwap: getConfig().VITE_FEATURE_NEAR_INTENTS_SWAP, CetusSwap: getConfig().VITE_FEATURE_CETUS_SWAP, + SunioSwap: getConfig().VITE_FEATURE_SUNIO_SWAP, LazyTxHistory: getConfig().VITE_FEATURE_TX_HISTORY_BYE_BYE, RfoxFoxEcosystemPage: getConfig().VITE_FEATURE_RFOX_FOX_ECOSYSTEM_PAGE, LedgerReadOnly: getConfig().VITE_FEATURE_LEDGER_READ_ONLY, diff --git a/src/test/mocks/store.ts b/src/test/mocks/store.ts index bc9ce7d0920..be2dcc7c87c 100644 --- a/src/test/mocks/store.ts +++ b/src/test/mocks/store.ts @@ -166,6 +166,7 @@ export const mockStore: ReduxState = { BebopSwap: false, NearIntentsSwap: false, CetusSwap: false, + SunioSwap: false, LazyTxHistory: false, RfoxFoxEcosystemPage: false, QuickBuy: false,