@openocean.finance/widget
Version:
Openocean Widget for cross-chain bridging and swapping. It will drive your multi-chain strategy and attract new users from everywhere.
416 lines (399 loc) • 15.1 kB
text/typescript
import { useAccount } from '@openocean.finance/wallet-management'
import type { Route } from '@openocean.finance/widget-sdk'
import { OpenOceanErrorCode } from '@openocean.finance/widget-sdk'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { parseUnits, formatUnits } from 'viem'
import { useWidgetConfig } from '../providers/WidgetProvider/WidgetProvider.js'
import { DebridgeService } from '../services/DebridgeService.js'
import { OpenOceanService } from '../services/OpenOceanService.js'
import { useFieldValues } from '../stores/form/useFieldValues.js'
import { useSetExecutableRoute } from '../stores/routes/useSetExecutableRoute.js'
import { useSettings } from '../stores/settings/useSettings.js'
import { defaultSlippage } from '../stores/settings/useSettingsStore.js'
import { WidgetEvent } from '../types/events.js'
import { getChainTypeFromAddress } from '../utils/chainType.js'
import { useChain } from './useChain.js'
import { useDebouncedWatch } from './useDebouncedWatch.js'
import { useGasPrice } from './useGasPrice.js'
import { useGasRefuel } from './useGasRefuel.js'
import { useIsBatchingSupported } from './useIsBatchingSupported.js'
import { useSwapOnly } from './useSwapOnly.js'
import { useToken } from './useToken.js'
import { useWidgetEvents } from './useWidgetEvents.js'
import { useServerErrorStore } from '../stores/useServerErrorStore.js'
const refetchTime = 60_000
interface RoutesProps {
observableRoute?: Route
}
export const useRoutes = ({ observableRoute }: RoutesProps = {}) => {
const {
subvariant,
sdkConfig,
contractTool,
bridges,
exchanges,
fee,
feeConfig,
useRelayerRoutes,
referrer
} = useWidgetConfig()
const setExecutableRoute = useSetExecutableRoute()
const queryClient = useQueryClient()
const emitter = useWidgetEvents()
const swapOnly = useSwapOnly()
const {
disabledBridges,
disabledExchanges,
enabledBridges,
enabledExchanges,
enabledAutoRefuel,
routePriority,
slippage,
} = useSettings([
'disabledBridges',
'disabledExchanges',
'enabledBridges',
'enabledExchanges',
'enabledAutoRefuel',
'routePriority',
'slippage',
])
const [fromTokenAmount] = useDebouncedWatch(500, 'fromAmount')
const [
fromChainId,
fromTokenAddress,
_toAddress,
toTokenAmount,
toChainId,
toTokenAddress,
contractCalls,
] = useFieldValues(
'fromChain',
'fromToken',
'toAddress',
'toAmount',
'toChain',
'toToken',
'contractCalls'
)
const { token: fromToken } = useToken(fromChainId, fromTokenAddress)
const { token: toToken } = useToken(toChainId, toTokenAddress)
const { chain: fromChain } = useChain(fromChainId)
const { chain: toChain } = useChain(toChainId)
const { enabled: enabledRefuel, fromAmount: gasRecommendationFromAmount } =
useGasRefuel()
const { gasPrice } = useGasPrice(fromChainId?.toString() || '')
const { account } = useAccount({ chainType: fromChain?.chainType })
const { isBatchingSupported, isBatchingSupportedLoading } =
useIsBatchingSupported(fromChain, account.address)
const hasAmount = Number(fromTokenAmount) > 0 || Number(toTokenAmount) > 0
const contractCallQuoteEnabled: boolean =
subvariant === 'custom' ? Boolean(contractCalls && account.address) : true
const toAddress = fromChainId === toChainId || (fromChain?.chainType === 'EVM' && toChain?.chainType === 'EVM') ? account.address : _toAddress
// When we bridge between ecosystems we need to be sure toAddress is set and has the same chainType as toChain
// If toAddress is set, it must have the same chainType as toChain
const hasToAddressAndChainTypeSatisfied: boolean =
!!toChain &&
!!toAddress &&
getChainTypeFromAddress(toAddress) === toChain.chainType
// We need to check for toAddress only if it is set
const isToAddressSatisfied = toAddress
? hasToAddressAndChainTypeSatisfied
: true
const isEnabled =
Boolean(Number(fromChain?.id)) &&
Boolean(Number(toChain?.id)) &&
Boolean(fromToken?.address) &&
Boolean(toToken?.address) &&
!Number.isNaN(slippage) &&
hasAmount &&
isToAddressSatisfied &&
contractCallQuoteEnabled &&
!isBatchingSupportedLoading
const queryKey = [
'routes',
account.address,
fromChain?.id as number,
fromToken?.address as string,
fromTokenAmount,
toAddress,
toChain?.id as number,
toToken?.address as string,
toTokenAmount,
contractCalls,
slippage,
swapOnly,
disabledBridges,
disabledExchanges,
enabledBridges,
enabledExchanges,
routePriority,
subvariant,
sdkConfig?.routeOptions?.allowSwitchChain,
enabledRefuel && enabledAutoRefuel,
gasRecommendationFromAmount,
feeConfig?.fee || fee,
!!isBatchingSupported,
observableRoute?.id,
] as const
const { data, isLoading, isFetching, isFetched, dataUpdatedAt, refetch } =
useQuery({
queryKey,
queryFn: async ({
queryKey: [
_,
fromAddress,
fromChainId,
fromTokenAddress,
fromTokenAmount,
toAddress,
toChainId,
toTokenAddress,
toTokenAmount,
contractCalls,
slippage = defaultSlippage,
swapOnly,
disabledBridges,
disabledExchanges,
allowedBridges,
allowedExchanges,
routePriority,
subvariant,
allowSwitchChain,
enabledRefuel,
gasRecommendationFromAmount,
fee,
isBatchingSupported,
_observableRouteId,
],
signal,
}) => {
try {
useServerErrorStore.getState().setError(null)
const fromAmount = parseUnits(fromTokenAmount, fromToken!.decimals)
const formattedSlippage = slippage
? (Number.parseFloat(slippage) / 100).toString()
: '0.01' // Default slippage 1%
let quoteResult: any // Initialize quoteResult
// Check if it's a cross-chain swap
if (fromChainId !== toChainId) {
// Use DebridgeService for cross-chain quotes
if (fromToken && toToken) {
// Construct Asset objects for DebridgeService
const fromMsg = {
address: fromTokenAddress,
symbol: fromToken.symbol,
decimals: fromToken.decimals,
name: fromToken.name,
icon: fromToken.logoURI,
chainId: fromChainId,
}
const toMsg = {
address: toTokenAddress,
symbol: toToken.symbol,
decimals: toToken.decimals,
name: toToken.name,
icon: toToken.logoURI,
chainId: toChainId,
}
quoteResult = await DebridgeService.swapUThenCross({
fromMsg: fromMsg,
toMsg: toMsg,
inAmount: fromAmount.toString(),
slippage_tolerance: formattedSlippage, // Debridge might use a different format/unit
account: account?.address || '',
receiver: toAddress, // Assuming receiver is the same as account for now
})
// Add a flag or modify structure to indicate it's a Debridge route
if (quoteResult) {
quoteResult.isDebridgeRoute = true
}
} else {
console.warn(
'Cannot get Debridge quote: Missing account address, fromToken, or toToken.'
)
quoteResult = null // Or handle error appropriately
}
} else {
// Use OpenOceanService for same-chain swaps
if (account.address) {
quoteResult = await OpenOceanService.getSwapQuote({
chain: fromChainId.toString(),
inTokenSymbol: fromToken?.symbol || '',
inTokenAddress: fromTokenAddress,
outTokenSymbol: toToken?.symbol || '',
outTokenAddress: toTokenAddress,
amount: fromAmount.toString(),
account: account.address,
slippage: formattedSlippage, // OpenOcean expects slippage like '100' for 1%
gasPrice: gasPrice || '10', // Consider chain-specific defaults
enabledDexIds: fromChainId === 1151111081099710 ? '6' : '', // Example for Solana specific dex
referrer: referrer?.address || '',
referrerFee: referrer?.fee || '',
})
} else {
// Use getQuote if account is not connected (view mode)
quoteResult = await OpenOceanService.getQuote({
chain: fromChainId.toString(),
inTokenSymbol: fromToken?.symbol || '',
inTokenAddress: fromTokenAddress,
outTokenSymbol: toToken?.symbol || '',
outTokenAddress: toTokenAddress,
amount: fromAmount.toString(),
slippage: formattedSlippage, // OpenOcean expects slippage like '100' for 1%
gasPrice: gasPrice || '10', // Consider chain-specific defaults
enabledDexIds: fromChainId === 1151111081099710 ? '6' : '', // Example for Solana specific dex
})
}
// Ensure the structure is consistent or add a flag
if (quoteResult) {
quoteResult.isDebridgeRoute = false
}
}
const data = quoteResult && quoteResult.data || {}
// minOutAmount calculation is now handled within DebridgeService or OpenOceanService
const isDebridge = data.isDebridgeRoute === true
let toAmountMin = '0';
if (data?.minOutAmount) {
toAmountMin = data.minOutAmount;
} else if (isDebridge) {
toAmountMin = '0';
} else {
const amount = Number(data?.outAmount || 0);
const slippageValue = Number.parseFloat(slippage);
const minAmount = (amount * (100 - slippageValue)) / 100;
toAmountMin = minAmount.toFixed(20).replace(/\.?0+$/, '');
// 如果还是科学计数法,再强制转字符串
if (toAmountMin.includes('e') || toAmountMin.includes('E')) {
toAmountMin = minAmount.toLocaleString('fullwide', { useGrouping: false, maximumSignificantDigits: 21 });
toAmountMin = toAmountMin.replace(/\.?0+$/, '');
}
}
const route: Route = {
id: data?.orderId || Date.now().toString(),
fromChainId: fromChainId,
fromAmountUSD: data?.fromTokenUSD || '0',
fromAmount: fromAmount.toString(),
fromToken: fromToken!,
fromAddress: fromAddress || '',
toChainId: toChainId,
toAmountUSD: data?.toTokenUSD || '0',
toAmount: data?.outAmount || '0',
toAmountMin,
toToken: toToken!,
toAddress: toAddress || '',
insurance: {
state: 'NOT_INSURABLE',
feeAmountUsd: '0',
},
steps: [
{
id: '1',
type: 'openocean',
tool: isDebridge ? 'debridge' : 'openocean',
transactionRequest: {
chainId: data?.chainId || fromChainId,
from: data?.from,
data: data?.transaction || data?.data || '0x',
to: data?.to,
value: data?.value || '0x0',
gasPrice: data?.gasPrice,
type: isDebridge ? '0x0' : data?.dexId || '0x0',
},
toolDetails: {
key: isDebridge ? 'debridge' : 'openocean',
name: isDebridge ? 'Debridge' : 'OpenOcean',
logoURI: isDebridge
? 'https://s3.openocean.finance/static/debridge.svg'
: 'https://assets.coingecko.com/coins/images/17014/small/ooe_log.png',
},
action: {
fromChainId: fromChainId,
fromAmount: fromAmount.toString(),
fromToken: fromToken!,
toChainId: toChainId,
toToken: toToken!,
slippage: Number(formattedSlippage),
fromAddress: fromAddress || '',
toAddress: toAddress || '',
},
estimate: {
fromAmount: fromAmount.toString(),
toAmount: data?.outAmount || '0',
toAmountMin,
approvalAddress:
data?.approveContract ||
(isDebridge
? data?.to
: '0x6352a56caadC4F1E25CD6c75970Fa768A3304e64'),
executionDuration: data?.executionDuration || Math.floor(Math.random() * 20) + 40,
tool: isDebridge ? 'debridge' : 'openocean',
feeCosts: data?.feeCosts || [
{
name: 'Gas Fee',
description: 'Estimated gas fee',
token: fromToken!,
amount: (data?.estimatedGas || '0').toString(),
amountUSD: '0',
percentage: '0',
included: true,
},
],
},
includedSteps: [],
},
],
...(data as any),
}
const routes = [route]
emitter.emit(WidgetEvent.AvailableRoutes, routes)
return routes
} catch (error) {
console.error('Failed to fetch OpenOcean routes:', error)
return []
}
},
enabled: isEnabled,
staleTime: refetchTime,
refetchInterval(query) {
return Math.min(
Math.abs(refetchTime - (Date.now() - query.state.dataUpdatedAt)),
refetchTime
)
},
retry(failureCount, error: any) {
if (process.env.NODE_ENV === 'development') {
console.warn('Route query failed:', { failureCount, error })
}
if (failureCount >= 3) {
return false
}
if (error?.code === OpenOceanErrorCode.NotFound) {
return false
}
return true
},
})
const setReviewableRoute = (route: Route) => {
const queryDataKey = queryKey.toSpliced(queryKey.length - 1, 1, route.id)
queryClient.setQueryData(
queryDataKey,
{ routes: [route] },
{ updatedAt: dataUpdatedAt }
)
setExecutableRoute(route)
}
return {
routes: data,
isLoading,
isFetching,
isFetched,
dataUpdatedAt,
refetchTime,
refetch,
fromChain,
toChain,
queryKey,
setReviewableRoute,
}
}