@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.
454 lines (436 loc) • 16.8 kB
text/typescript
import { useAccount } from '@openocean.finance/wallet-management'
import type { Route } from '@openocean.finance/widget-sdk'
import { ChainType, OpenOceanErrorCode } from '@openocean.finance/widget-sdk'
import { useWallet } from '@solana/wallet-adapter-react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { parseUnits } from 'viem'
import { useConfig } from 'wagmi'
import { getWalletClient } from 'wagmi/actions'
import { getCrossChainQuote } from '../cross/crossChainQuote.js'
import { useWidgetConfig } from '../providers/WidgetProvider/WidgetProvider.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 { useServerErrorStore } from '../stores/useServerErrorStore.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'
const refetchTime = 60_000
interface RoutesProps {
observableRoute?: Route
}
export const useRoutes = ({ observableRoute }: RoutesProps = {}) => {
const wagmiConfig = useConfig()
const { wallet: solanaWallet } = useWallet()
const { subvariant, sdkConfig, fee, feeConfig, 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 bridging between ecosystems, we need to ensure 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 only need to check if toAddress 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 ? slippage : '1' // 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,
isNative: fromToken.isNative,
}
const toMsg = {
address: toTokenAddress,
symbol: toToken.symbol,
decimals: toToken.decimals,
name: toToken.name,
icon: toToken.logoURI,
chainId: toChainId,
isNative: toToken.isNative,
}
// Get appropriate wallet client based on chain type
let walletClient = undefined
if (fromChain?.chainType === ChainType.EVM) {
try {
walletClient = await getWalletClient(wagmiConfig)
} catch (error) {
console.warn(
'Failed to get wallet client for EVM chain:',
error
)
// Continue without walletClient for non-EVM chains
}
} else if (fromChain?.chainType === ChainType.SVM) {
// For Solana chains, use the Solana wallet adapter
walletClient = solanaWallet?.adapter
}
quoteResult = await getCrossChainQuote({
feeBps: 10,
fromMsg,
toMsg,
inAmount: fromAmount.toString(),
slippage_tolerance: formattedSlippage,
account: account?.address || '',
walletClient,
recipient: toAddress || '',
})
// 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) {
if (quoteResult.error) {
throw new Error(quoteResult.error)
}
if (quoteResult.data) {
quoteResult.isBridge = 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.isBridge = false
}
}
if (!quoteResult) {
return []
}
// biome-ignore lint/complexity/useOptionalChain: <explanation>
const data = (quoteResult && quoteResult.data) || {}
// minOutAmount calculation is now handled within DebridgeService or OpenOceanService
const isBridge = quoteResult.isBridge
let toAmountMin = '0'
if (data?.minOutAmount && Number(data.minOutAmount) > 0) {
toAmountMin = data.minOutAmount
} else if (isBridge) {
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 still in scientific notation, force convert to string
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: isBridge ? 'bridge' : 'swap',
tool: isBridge ? 'bridge' : '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: isBridge ? data?.quoteAdapterKey : data?.dexId || '0x0',
},
toolDetails: {
key: isBridge ? data?.quoteAdapterKey : 'openocean',
name: isBridge ? data?.quoteAdapterName : 'OpenOcean',
logoURI: isBridge
? '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 || data?.to || '0x0',
executionDuration:
data?.executionDuration ||
Math.floor(Math.random() * 20) + 40,
tool: isBridge ? 'bridge' : '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: [],
quoteData: isBridge ? data : null,
},
],
...(data as any),
}
const routes = [route]
emitter.emit(WidgetEvent.AvailableRoutes, routes)
return routes
} catch (error) {
console.error('Failed to fetch OpenOcean routes:', error)
useServerErrorStore.getState().setError((error as Error).message)
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 false
},
})
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,
}
}