UNPKG

@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
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, } }