diff --git a/.changeset/witty-clocks-fall.md b/.changeset/witty-clocks-fall.md new file mode 100644 index 000000000..2055340f9 --- /dev/null +++ b/.changeset/witty-clocks-fall.md @@ -0,0 +1,6 @@ +--- +'@relayprotocol/relay-sdk': minor +'@relayprotocol/relay-kit-ui': minor +--- + +Refactor EOA detection to improve ux diff --git a/packages/sdk/src/types/AdaptedWallet.ts b/packages/sdk/src/types/AdaptedWallet.ts index 9005d3b9f..b6ebac605 100644 --- a/packages/sdk/src/types/AdaptedWallet.ts +++ b/packages/sdk/src/types/AdaptedWallet.ts @@ -115,7 +115,9 @@ export type AdaptedWallet = { items: TransactionStepItem[] ) => Promise // detect if wallet is an EOA (externally owned account) - isEOA?: ( - chainId: number - ) => Promise<{ isEOA: boolean; isEIP7702Delegated: boolean }> + // Note: returns isEOA: false when explicit deposit should be used + isEOA?: (chainId: number) => Promise<{ + isEOA: boolean + isEIP7702Delegated: boolean + }> } diff --git a/packages/sdk/src/utils/index.ts b/packages/sdk/src/utils/index.ts index 83fbee355..1b78a458d 100644 --- a/packages/sdk/src/utils/index.ts +++ b/packages/sdk/src/utils/index.ts @@ -5,7 +5,7 @@ export { request, APIError, isAPIError } from './request.js' export { log, LogLevel } from './logger.js' export { axios } from './axios.js' export { default as prepareCallTransaction } from './prepareCallTransaction.js' -export { adaptViemWallet } from './viemWallet.js' +export { adaptViemWallet, isViemWalletClient } from './viemWallet.js' export { convertViemChainToRelayChain, type RelayAPIChain } from './chain.js' export { getCurrentStepData } from './getCurrentStepData.js' export { diff --git a/packages/sdk/src/utils/viemWallet.ts b/packages/sdk/src/utils/viemWallet.ts index adbe4d516..423f90e9d 100644 --- a/packages/sdk/src/utils/viemWallet.ts +++ b/packages/sdk/src/utils/viemWallet.ts @@ -11,6 +11,20 @@ import { http } from 'viem' +// Cache for expensive RPC calls (code, balance, tx count) +interface EOACacheEntry { + code?: { value: string | undefined; timestamp: number } + balance?: { value: bigint; timestamp: number } + txCount?: { value: number; timestamp: number } +} + +const CACHE_DURATION_MS = 2 * 60 * 1000 // 2 minutes +const eoaCache = new Map() + +function getCacheKey(address: string, chainId: number): string { + return `${address}-${chainId}` +} + export function isViemWalletClient( wallet: WalletClient | AdaptedWallet ): wallet is WalletClient { @@ -245,60 +259,219 @@ export const adaptViemWallet = (wallet: WalletClient): AdaptedWallet => { }, isEOA: async ( chainId: number - ): Promise<{ isEOA: boolean; isEIP7702Delegated: boolean }> => { + ): Promise<{ + isEOA: boolean + isEIP7702Delegated: boolean + }> => { if (!wallet.account) { - return { isEOA: false, isEIP7702Delegated: false } + return { + isEOA: false, + isEIP7702Delegated: false + } } try { - let hasSmartWalletCapabilities = false - try { - const capabilities = await wallet.getCapabilities({ - account: wallet.account, - chainId + const address = wallet.account.address + const cacheKey = getCacheKey(address, chainId) + const now = Date.now() + + // Get or create cache entry for this address+chain + let cacheEntry = eoaCache.get(cacheKey) + if (!cacheEntry) { + cacheEntry = {} + eoaCache.set(cacheKey, cacheEntry) + } + + // Always fetch capabilities fresh (wallet-specific, not cacheable) + const getSmartWalletCapabilities = async () => { + let _hasSmartWalletCapabilities = false + try { + const capabilities = await wallet.getCapabilities({ + account: wallet.account, + chainId + }) + + _hasSmartWalletCapabilities = Boolean( + capabilities?.atomicBatch?.supported || + capabilities?.paymasterService?.supported || + capabilities?.auxiliaryFunds?.supported || + capabilities?.sessionKeys?.supported + ) + } catch (capabilitiesError) {} + return _hasSmartWalletCapabilities + } + + // Get code with caching + const getCode = async () => { + // Check cache first + console.log('CHECKING CODE CACHE') + if ( + cacheEntry && + cacheEntry?.code && + now - cacheEntry?.code.timestamp < CACHE_DURATION_MS + ) { + console.log('CODE CACHE HIT') + const code = cacheEntry!.code.value + const hasCode = Boolean(code && code !== '0x') + const isEIP7702Delegated = Boolean( + code && code.toLowerCase().startsWith('0xef01') + ) + return { hasCode, isEIP7702Delegated } + } + + // Fetch from RPC + console.log('FETCHING CODE FROM RPC') + const client = getClient() + const chain = client.chains.find((chain) => chain.id === chainId) + const rpcUrl = chain?.httpRpcUrl + + if (!chain) { + throw new Error(`Chain ${chainId} not found in relay client`) + } + + const viemClient = createPublicClient({ + chain: chain?.viemChain, + transport: rpcUrl ? http(rpcUrl) : http() }) - hasSmartWalletCapabilities = Boolean( - capabilities?.atomicBatch?.supported || - capabilities?.paymasterService?.supported || - capabilities?.auxiliaryFunds?.supported || - capabilities?.sessionKeys?.supported - ) - } catch (capabilitiesError) {} + try { + const _code = await viemClient.getCode({ address }) - const client = getClient() - const chain = client.chains.find((chain) => chain.id === chainId) - const rpcUrl = chain?.httpRpcUrl + // Cache the result + cacheEntry!.code = { value: _code, timestamp: now } - if (!chain) { - throw new Error(`Chain ${chainId} not found in relay client`) + const hasCode = Boolean(_code && _code !== '0x') + const isEIP7702Delegated = Boolean( + _code && _code.toLowerCase().startsWith('0xef01') + ) + return { hasCode, isEIP7702Delegated } + } catch (getCodeError) { + throw getCodeError + } } - const viemClient = createPublicClient({ - chain: chain?.viemChain, - transport: rpcUrl ? http(rpcUrl) : http() - }) + // Get balance with caching + const getNativeBalance = async () => { + // Check cache first + console.log('CHECKING BALANCE CACHE') + if ( + cacheEntry && + cacheEntry?.balance && + now - cacheEntry?.balance.timestamp < CACHE_DURATION_MS + ) { + console.log('BALANCE CACHE HIT') + return cacheEntry?.balance.value + } - let code - try { - code = await viemClient.getCode({ - address: wallet.account.address + // Fetch from RPC + console.log('FETCHING BALANCE FROM RPC') + const client = getClient() + const chain = client.chains.find((chain) => chain.id === chainId) + const rpcUrl = chain?.httpRpcUrl + + if (!chain) { + return BigInt(0) + } + + const viemClient = createPublicClient({ + chain: chain?.viemChain, + transport: rpcUrl ? http(rpcUrl) : http() }) - } catch (getCodeError) { - throw getCodeError + + try { + console.log('FETCHING BALANCE FROM RPC') + const balance = await viemClient.getBalance({ address }) + console.log('BALANCE FETCHED FROM RPC') + // Cache the result + cacheEntry!.balance = { value: balance, timestamp: now } + return balance + } catch (error) { + return BigInt(0) + } } - const hasCode = Boolean(code && code !== '0x') - const isEIP7702Delegated = Boolean( - code && code.toLowerCase().startsWith('0xef01') - ) - const isSmartWallet = + // Get transaction count with caching + const getTransactionCount = async () => { + // Check cache first + console.log('CHECKING TRANSACTION COUNT CACHE') + if ( + cacheEntry && + cacheEntry?.txCount && + now - cacheEntry?.txCount.timestamp < CACHE_DURATION_MS + ) { + console.log('TRANSACTION COUNT CACHE HIT') + return cacheEntry?.txCount.value + } + + // Fetch from RPC + console.log('FETCHING TRANSACTION COUNT FROM RPC') + const client = getClient() + const chain = client.chains.find((chain) => chain.id === chainId) + const rpcUrl = chain?.httpRpcUrl + + if (!chain) { + return 0 + } + + const viemClient = createPublicClient({ + chain: chain?.viemChain, + transport: rpcUrl ? http(rpcUrl) : http() + }) + + try { + const txCount = await viemClient.getTransactionCount({ address }) + // Cache the result + cacheEntry!.txCount = { value: txCount, timestamp: now } + return txCount + } catch (error) { + return 0 + } + } + + const [ + hasSmartWalletCapabilitiesResult, + getCodeResult, + nativeBalanceResult, + transactionCountResult + ] = await Promise.allSettled([ + getSmartWalletCapabilities(), + getCode(), + getNativeBalance(), + getTransactionCount() + ]) + + const hasSmartWalletCapabilities = + hasSmartWalletCapabilitiesResult.status === 'fulfilled' + ? hasSmartWalletCapabilitiesResult.value + : false + const { hasCode, isEIP7702Delegated } = + getCodeResult.status === 'fulfilled' + ? getCodeResult.value + : { hasCode: false, isEIP7702Delegated: false } + const nativeBalance = + nativeBalanceResult.status === 'fulfilled' + ? nativeBalanceResult.value + : BigInt(0) + const transactionCount = + transactionCountResult.status === 'fulfilled' + ? transactionCountResult.value + : 0 + + let isSmartWallet = hasSmartWalletCapabilities || hasCode || isEIP7702Delegated - const isEOA = !isSmartWallet - return { isEOA, isEIP7702Delegated } + // If balance is zero or transaction count is <= 1, it's likely a smart wallet + if (nativeBalance === BigInt(0) || transactionCount <= 1) { + isSmartWallet = true + } + + return { isEOA: !isSmartWallet, isEIP7702Delegated } } catch (error) { - return { isEOA: false, isEIP7702Delegated: false } + // On error, default to explicit deposit (isEOA: false) for safety + return { + isEOA: false, + isEIP7702Delegated: false + } } } } diff --git a/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx b/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx index 2bfa51368..11244f4f8 100644 --- a/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx +++ b/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx @@ -10,7 +10,7 @@ import { useIsWalletCompatible, useFallbackState, useGasTopUpRequired, - useEOADetection, + useExplicitDeposit, useDisplayName, useLighterAccount } from '../../hooks/index.js' @@ -480,20 +480,14 @@ const SwapWidgetRenderer: FC = ({ const isFromNative = fromToken?.address === fromChain?.currency?.address - const explicitDeposit = useEOADetection( + const explicitDeposit = useExplicitDeposit( wallet, fromToken?.chainId, fromChain?.vmType, - fromChain, - address, - fromBalance, - isFromNative + address ) - const shouldSetQuoteParameters = - fromToken && - toToken && - (fromChain?.vmType !== 'evm' || explicitDeposit !== undefined) + const shouldSetQuoteParameters = fromToken && toToken const quoteParameters: Parameters['2'] = shouldSetQuoteParameters @@ -528,9 +522,10 @@ const SwapWidgetRenderer: FC = ({ } } : {}), - ...(explicitDeposit !== undefined && { - explicitDeposit: explicitDeposit - }) + ...(explicitDeposit !== undefined && + fromChain?.vmType === 'evm' && { + explicitDeposit: explicitDeposit + }) } : undefined diff --git a/packages/ui/src/components/widgets/TokenWidget/widget/TokenWidgetRenderer.tsx b/packages/ui/src/components/widgets/TokenWidget/widget/TokenWidgetRenderer.tsx index a66649643..38f1b34f1 100644 --- a/packages/ui/src/components/widgets/TokenWidget/widget/TokenWidgetRenderer.tsx +++ b/packages/ui/src/components/widgets/TokenWidget/widget/TokenWidgetRenderer.tsx @@ -9,9 +9,9 @@ import { usePreviousValueChange, useIsWalletCompatible, useFallbackState, - useEOADetection, useDisplayName, - useLighterAccount + useLighterAccount, + useExplicitDeposit } from '../../../../hooks/index.js' import type { Address, WalletClient } from 'viem' import { formatUnits, parseUnits } from 'viem' @@ -566,20 +566,14 @@ const TokenWidgetRenderer: FC = ({ const isFromNative = fromToken?.address === fromChain?.currency?.address - const explicitDeposit = useEOADetection( + const explicitDeposit = useExplicitDeposit( wallet, fromToken?.chainId, fromChain?.vmType, - fromChain, - address, - fromBalance, - isFromNative + address ) - const shouldSetQuoteParameters = - fromToken && - toToken && - (fromChain?.vmType !== 'evm' || explicitDeposit !== undefined) + const shouldSetQuoteParameters = fromToken && toToken const quoteParameters: Parameters['2'] = shouldSetQuoteParameters @@ -614,9 +608,10 @@ const TokenWidgetRenderer: FC = ({ } } : {}), - ...(explicitDeposit !== undefined && { - explicitDeposit: explicitDeposit - }) + ...(explicitDeposit !== undefined && + fromChain?.vmType === 'evm' && { + explicitDeposit: explicitDeposit + }) } : undefined diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index 943db28bd..40d343679 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -22,7 +22,7 @@ import useMoonPayTransaction from './useMoonPayTransaction.js' import { useInternalRelayChains } from './useInternalRelayChains.js' import useGasTopUpRequired from './useGasTopUpRequired.js' import useHyperliquidBalance from './useHyperliquidBalance.js' -import useEOADetection from './useEOADetection.js' +import useExplicitDeposit from './useExplicitDeposit.js' import useTransactionCount from './useTransactionCount.js' import useTronBalance from './useTronBalance.js' import useLighterAccount from './useLighterAccount.js' @@ -53,7 +53,7 @@ export { useInternalRelayChains, useGasTopUpRequired, useHyperliquidBalance, - useEOADetection, + useExplicitDeposit, useTransactionCount, useTronBalance, useLighterAccount, diff --git a/packages/ui/src/hooks/useEOADetection.ts b/packages/ui/src/hooks/useEOADetection.ts deleted file mode 100644 index f8184a820..000000000 --- a/packages/ui/src/hooks/useEOADetection.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { useMemo, useEffect, useState, useRef } from 'react' -import type { AdaptedWallet, RelayChain } from '@relayprotocol/relay-sdk' -import useCurrencyBalance from './useCurrencyBalance.js' -import useTransactionCount from './useTransactionCount.js' - -/** - * Hook to detect if a wallet is an EOA and return the appropriate explicitDeposit flag - * Includes checks for zero native balance and low transaction count - * Only runs detection when evm chain and wallet supports EOA detection - */ -const useEOADetection = ( - wallet?: AdaptedWallet, - chainId?: number, - chainVmType?: string, - fromChain?: RelayChain, - userAddress?: string, - fromBalance?: bigint, - isFromNative?: boolean -): boolean | undefined => { - const [detectionState, setDetectionState] = useState<{ - value: boolean | undefined - conditionKey: string - }>({ value: undefined, conditionKey: '' }) - - const walletRef = useRef(wallet) - const walletId = useRef(0) - - if (walletRef.current !== wallet) { - walletRef.current = wallet - walletId.current += 1 - } - - const shouldRunSafetyChecks = Boolean( - chainVmType === 'evm' && !isFromNative && userAddress && fromChain - ) - - // get native balance - const { value: nativeBalance, isLoading: isLoadingNativeBalance } = - useCurrencyBalance({ - chain: fromChain, - address: userAddress, - currency: fromChain?.currency?.address - ? (fromChain.currency.address as string) - : undefined, - enabled: shouldRunSafetyChecks, - wallet - }) - - // get transaction count - const { data: transactionCount, isLoading: isLoadingTransactionCount } = - useTransactionCount({ - address: userAddress, - chainId: chainId, - enabled: shouldRunSafetyChecks - }) - - const isLoadingSafetyChecks = Boolean( - shouldRunSafetyChecks && - (isLoadingNativeBalance || isLoadingTransactionCount) - ) - - // Calculate safety check conditions - const effectiveNativeBalance = isFromNative ? fromBalance : nativeBalance - const hasZeroNativeBalance = - shouldRunSafetyChecks && effectiveNativeBalance === 0n - const hasLowTransactionCount = - shouldRunSafetyChecks && - transactionCount !== undefined && - transactionCount <= 1 - - const conditionKey = `${wallet?.vmType}:${chainVmType}:${!!wallet?.isEOA}:${chainId}:${walletId.current}:${hasZeroNativeBalance}:${hasLowTransactionCount}` - - const shouldDetect = useMemo(() => { - return ( - chainId !== undefined && - (!wallet || wallet?.vmType === 'evm') && - chainVmType === 'evm' && - !hasZeroNativeBalance && - !hasLowTransactionCount - ) - }, [ - wallet?.vmType, - chainId, - chainVmType, - hasZeroNativeBalance, - hasLowTransactionCount - ]) - - // Synchronously return undefined when conditions change - const explicitDeposit = useMemo(() => { - if (isLoadingSafetyChecks) { - return undefined - } - - // force explicit deposit for zero native balance or low transaction count - if (hasZeroNativeBalance || hasLowTransactionCount) { - return true - } - - return detectionState.conditionKey !== conditionKey || !shouldDetect - ? undefined - : detectionState.value - }, [ - conditionKey, - shouldDetect, - detectionState, - hasZeroNativeBalance, - hasLowTransactionCount, - isLoadingSafetyChecks - ]) - - useEffect(() => { - setDetectionState({ value: undefined, conditionKey }) - - if (!shouldDetect) { - return - } - - const detectEOA = async () => { - const baseEventData = { - chain_id: chainId, - address: userAddress, - wallet_type: wallet?.vmType - } - - try { - if (!wallet || !wallet?.isEOA) { - setDetectionState((current) => - current.conditionKey === conditionKey - ? { value: false, conditionKey } - : current - ) - return - } - - const timeoutMs = 2500 - const timeoutError = new Error('EOA_DETECTION_TIMEOUT') - let timeoutId: ReturnType | undefined - - const startTime = performance.now() - - try { - const eoaResult = await Promise.race([ - wallet.isEOA(chainId!), - new Promise((_, reject) => { - timeoutId = setTimeout(() => { - reject(timeoutError) - }, timeoutMs) - }) - ]) - - if (timeoutId) { - clearTimeout(timeoutId) - } - const { isEOA, isEIP7702Delegated } = eoaResult - const explicitDepositValue = !isEOA || isEIP7702Delegated - - setDetectionState((current) => - current.conditionKey === conditionKey - ? { value: explicitDepositValue, conditionKey } - : current - ) - } catch (eoaError: any) { - if (timeoutId) { - clearTimeout(timeoutId) - } - const duration = performance.now() - startTime - const isTimeout = eoaError === timeoutError - - if (isTimeout) { - console.error('[EOA Detection] timeout', { - ...baseEventData, - error_type: 'timeout', - duration_ms: Math.round(duration) - }) - } else { - console.error('[EOA Detection] error', { - ...baseEventData, - error_type: 'error', - duration_ms: Math.round(duration), - error_message: eoaError?.message || 'Unknown error', - error_name: eoaError?.name - }) - } - - setDetectionState((current) => - current.conditionKey === conditionKey - ? { value: true, conditionKey } - : current - ) - } - } catch (error: any) { - console.error('[EOA Detection] error', { - ...baseEventData, - error_type: 'error', - error_message: error?.message || 'Unknown error', - error_name: error?.name - }) - - setDetectionState((current) => - current.conditionKey === conditionKey - ? { value: true, conditionKey } - : current - ) - } - } - - detectEOA() - }, [conditionKey, shouldDetect, wallet, chainId, userAddress]) - - if (!shouldDetect && chainVmType === 'evm') { - return explicitDeposit ?? true - } - - return explicitDeposit -} - -export default useEOADetection diff --git a/packages/ui/src/hooks/useExplicitDeposit.ts b/packages/ui/src/hooks/useExplicitDeposit.ts new file mode 100644 index 000000000..818b9e9fc --- /dev/null +++ b/packages/ui/src/hooks/useExplicitDeposit.ts @@ -0,0 +1,83 @@ +import { useEffect, useState, useRef, useCallback } from 'react' +import type { AdaptedWallet } from '@relayprotocol/relay-sdk' + +const DEBOUNCE_DELAY_MS = 150 // 150ms debounce + +/** + * Hook to detect if explicit deposit should be enabled for a wallet + * Uses balance and transaction count checks, plus EOA detection + * RPC calls (code, balance, tx count) are cached at the SDK level + * Wallet capabilities are always fetched fresh (wallet-provider specific) + */ +const useExplicitDeposit = ( + wallet?: AdaptedWallet, + chainId?: number, + chainVmType?: string, + userAddress?: string +): boolean | undefined => { + const [explicitDeposit, setExplicitDeposit] = useState( + undefined + ) + const debounceTimerRef = useRef | undefined>( + undefined + ) + + const checkExplicitDeposit = useCallback(async () => { + if (!wallet || !chainId || chainVmType !== 'evm') { + setExplicitDeposit(undefined) + return + } + + // Check if wallet has isEOA method (AdaptedWallet with EVM support) + if (!wallet.isEOA) { + // If wallet doesn't support isEOA, default to false (no explicit deposit) + setExplicitDeposit(false) + return + } + + try { + console.log('CHECKING EXPLICIT DEPOSIT') + const { isEOA } = await wallet.isEOA(chainId) + // isEOA: false means use explicit deposit + const shouldUseExplicitDeposit = !isEOA + setExplicitDeposit(shouldUseExplicitDeposit) + } catch (error) { + console.error('[Explicit Deposit] error', { + chain_id: chainId, + address: userAddress, + error_message: error instanceof Error ? error.message : 'Unknown error' + }) + // On error, default to true (explicit deposit enabled for safety) + setExplicitDeposit(true) + } + }, [wallet, chainId, chainVmType, userAddress]) + + useEffect(() => { + // Clear any pending debounce timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + + // Reset state when conditions change + setExplicitDeposit(undefined) + + if (!wallet || !chainId || chainVmType !== 'evm') { + return + } + + // Debounce the check to avoid rapid re-calculations + debounceTimerRef.current = setTimeout(() => { + checkExplicitDeposit() + }, DEBOUNCE_DELAY_MS) + + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + } + }, [wallet, chainId, chainVmType, checkExplicitDeposit]) + + return explicitDeposit +} + +export default useExplicitDeposit