Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .beads/pr-context.jsonl

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion headers/csps/chains/tron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
1 change: 1 addition & 0 deletions packages/public-api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,10 @@ export const checkTradeStatus = async (input: CheckTradeStatusInput): Promise<Tr

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

Expand Down
17 changes: 2 additions & 15 deletions packages/swapper/src/swappers/SunioSwapper/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -181,11 +169,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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
197 changes: 109 additions & 88 deletions packages/swapper/src/swappers/SunioSwapper/utils/getQuoteOrRate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,75 @@ import type {
} from '../../../types'
import { SwapperName, TradeQuoteError } from '../../../types'
import { getInputOutputRate, makeSwapErrorRight } from '../../../utils'
import type { SunioRoute } from '../types'
import { DEFAULT_SLIPPAGE_PERCENTAGE, SUNIO_SMART_ROUTER_CONTRACT } from './constants'
import { convertAddressesToEvmFormat } from './convertAddressesToEvmFormat'
import { fetchSunioQuote } from './fetchFromSunio'
import { isSupportedChainId } from './helpers/helpers'
import { sunioServiceFactory } from './sunioService'

const ENERGY_PRICE = 100
const USER_ENERGY_SHARE = 0.6
const BASE_ENERGY_PER_HOP = 65_000
const MAX_PENALTY_MULTIPLIER = 4.4

const estimateSwapEnergy = async (
route: SunioRoute,
sellAmountCryptoBaseUnit: string,
senderAddress: string,
isSellingNativeTrx: boolean,
estimateEnergyUrl: string,
): Promise<number | undefined> => {
try {
const tronWeb = new TronWeb({ fullHost: estimateEnergyUrl })

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.estimateEnergy(
SUNIO_SMART_ROUTER_CONTRACT,
functionSelector,
{ callValue: isSellingNativeTrx ? Number(sellAmountCryptoBaseUnit) : 0 },
parameters,
senderAddress,
)

if (!result?.energy_required) return undefined

return result.energy_required
} catch {
return undefined
}
}

export async function getQuoteOrRate(
input: GetTronTradeQuoteInput | CommonTradeQuoteInput,
deps: SwapperDeps,
Expand All @@ -39,14 +103,13 @@ export async function getQuoteOrRate(
sellAsset,
buyAsset,
sellAmountIncludingProtocolFeesCryptoBaseUnit,
sendAddress,
receiveAddress,
accountNumber,
affiliateBps,
slippageTolerancePercentageDecimal,
} = input

const { assertGetTronChainAdapter: _assertGetTronChainAdapter } = deps

if (!isSupportedChainId(sellAsset.chainId)) {
return Err(
makeSwapErrorRight({
Expand Down Expand Up @@ -104,7 +167,6 @@ export async function getQuoteOrRate(

const isQuote = input.quoteOrRate === 'quote'

// For quotes, receiveAddress is required
if (isQuote && !receiveAddress) {
return Err(
makeSwapErrorRight({
Expand All @@ -114,96 +176,55 @@ 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 contractAddress = contractAddressOrUndefined(sellAsset.assetId)
const isSellingNativeTrx = !contractAddress

const tronWeb = new TronWeb({ fullHost: deps.config.VITE_TRON_NODE_URL })

// Get chain parameters for pricing
const params = await tronWeb.trx.getChainParameters()
const bandwidthPrice = params.find(p => p.key === 'getTransactionFee')?.value ?? 1000
const energyPrice = params.find(p => p.key === 'getEnergyFee')?.value ?? 100

// Check if recipient needs activation (applies to all swaps)
let accountActivationFee = 0
try {
const recipientInfoResponse = await fetch(
`${deps.config.VITE_TRON_NODE_URL}/wallet/getaccount`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: receiveAddress, visible: true }),
},
)
const recipientInfo = await recipientInfoResponse.json()
const recipientExists = recipientInfo && Object.keys(recipientInfo).length > 1
if (!recipientExists) {
accountActivationFee = 1_000_000 // 1 TRX
}
} catch {
// Ignore activation check errors
}

// For native TRX swaps, Sun.io uses a contract call with value
// We need to estimate energy for the swap contract, not just bandwidth
if (isSellingNativeTrx) {
try {
// Sun.io contract owner provides most energy (~117k), users only pay ~2k
// Use fixed 2k energy estimate instead of querying (which returns total 120k)
const energyUsed = 2000 // User pays ~2k energy, contract covers the rest
const energyFee = energyUsed * energyPrice // No multiplier - contract provides energy

// Estimate bandwidth for contract call (much larger than simple transfer)
const bandwidthFee = 1100 * bandwidthPrice // ~1100 bytes for contract call (with safety buffer)

networkFeeCryptoBaseUnit = bn(energyFee)
.plus(bandwidthFee)
.plus(accountActivationFee)
.toFixed(0)
} catch (estimationError) {
// Fallback estimate: ~2k energy + ~1100 bytes bandwidth + activation fee
const fallbackEnergyFee = 2000 * energyPrice
const fallbackBandwidthFee = 1100 * bandwidthPrice
networkFeeCryptoBaseUnit = bn(fallbackEnergyFee)
.plus(fallbackBandwidthFee)
.plus(accountActivationFee)
.toFixed(0)
}
} else {
// For TRC-20 swaps through Sun.io router
// Same as TRX: contract owner provides most energy, user pays ~2k
// Sun.io provides ~217k energy, user pays ~2k
const energyFee = 2000 * energyPrice
const bandwidthFee = 1100 * bandwidthPrice

networkFeeCryptoBaseUnit = bn(energyFee)
.plus(bandwidthFee)
.plus(accountActivationFee)
.toFixed(0)
}
} catch (error) {
// For rates, fall back to '0' on estimation failure
// For quotes, let it error (required for accurate swap)
if (!isQuote) {
networkFeeCryptoBaseUnit = '0'
} else {
throw error
}
}
}

const buyAmountCryptoBaseUnit = BigAmount.fromPrecision({
value: bestRoute.amountOut,
precision: buyAsset.precision,
}).toBaseUnit()

// Calculate protocol fees only for quotes
const contractAddress = contractAddressOrUndefined(sellAsset.assetId)
const isSellingNativeTrx = !contractAddress
const hopCount = bestRoute.tokens.length - 1
const rpcUrl = deps.config.VITE_TRON_NODE_URL
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`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: receiveAddress, visible: true }),
})
.then(res => res.json())
.then(info => (info && Object.keys(info).length > 1 ? 0 : 1_000_000))
.catch(() => 0)
: Promise.resolve(0)

const [estimatedEnergy, accountActivationFee] = await Promise.all([
estimateEnergyPromise,
accountActivationPromise,
])

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

const networkFeeCryptoBaseUnit = bn(energyFee)
.plus(bandwidthFee)
.plus(accountActivationFee)
.toFixed(0)

const protocolFeeCryptoBaseUnit = isQuote
? bn(bestRoute.fee).times(sellAmountIncludingProtocolFeesCryptoBaseUnit).toFixed(0)
: '0'
Expand Down
1 change: 1 addition & 0 deletions packages/swapper/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down