Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/deep-beans-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@relayprotocol/relay-kit-ui': patch
---

Add transaction count check for explicit deposits
23 changes: 6 additions & 17 deletions packages/ui/src/components/widgets/SwapWidgetRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -567,29 +567,18 @@ const SwapWidgetRenderer: FC<SwapWidgetRendererProps> = ({
const loadingProtocolVersion =
fromChain?.id && originChainSupportsProtocolv2 && isLoadingFromTokenPrice

// Get native balance only when not swapping from native token
const isFromNative = fromToken?.address === fromChain?.currency?.address
const { value: nativeBalance } = useCurrencyBalance({
chain: fromChain,
address: address,
currency: fromChain?.currency?.address
? (fromChain.currency.address as string)
: undefined,
enabled: fromToken !== undefined && !isFromNative,
wallet
})

const effectiveNativeBalance = isFromNative ? fromBalance : nativeBalance
const hasZeroNativeBalance = effectiveNativeBalance === 0n

const eoaExplicitDeposit = useEOADetection(
const explicitDeposit = useEOADetection(
wallet,
quoteProtocol,
fromToken?.chainId,
fromChain?.vmType
fromChain?.vmType,
fromChain,
address,
fromBalance,
isFromNative
)

const explicitDeposit = hasZeroNativeBalance ? true : eoaExplicitDeposit
const normalizedSponsoredTokens = useMemo(() => {
const chainVms = relayClient?.chains.reduce(
(chains, chain) => {
Expand Down
4 changes: 3 additions & 1 deletion packages/ui/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useInternalRelayChains } from './useInternalRelayChains.js'
import useGasTopUpRequired from './useGasTopUpRequired.js'
import useHyperliquidUsdcBalance from './useHyperliquidUsdcBalance.js'
import useEOADetection from './useEOADetection.js'
import useTransactionCount from './useTransactionCount.js'

export {
useMounted,
Expand All @@ -47,5 +48,6 @@ export {
useInternalRelayChains,
useGasTopUpRequired,
useHyperliquidUsdcBalance,
useEOADetection
useEOADetection,
useTransactionCount
}
128 changes: 113 additions & 15 deletions packages/ui/src/hooks/useEOADetection.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import { useMemo, useEffect, useState, useRef } from 'react'
import type { AdaptedWallet } from '@relayprotocol/relay-sdk'
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 protocol version is 'preferV2' and wallet supports EOA detection
*/
const useEOADetection = (
wallet?: AdaptedWallet,
protocolVersion?: string,
chainId?: number,
chainVmType?: string
chainVmType?: string,
fromChain?: RelayChain,
userAddress?: string,
fromBalance?: bigint,
isFromNative?: boolean
): boolean | undefined => {
const [detectionState, setDetectionState] = useState<{
value: boolean | undefined
Expand All @@ -24,23 +31,90 @@ const useEOADetection = (
walletId.current += 1
}

const conditionKey = `${wallet?.vmType}:${chainVmType}:${!!wallet?.isEOA}:${protocolVersion}:${chainId}:${walletId.current}`
const shouldRunSafetyChecks = Boolean(
protocolVersion === 'preferV2' &&
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}:${protocolVersion}:${chainId}:${walletId.current}:${hasZeroNativeBalance}:${hasLowTransactionCount}`

const shouldDetect = useMemo(() => {
return (
protocolVersion === 'preferV2' &&
chainId !== undefined &&
(!wallet || wallet?.vmType === 'evm') &&
chainVmType === 'evm'
chainVmType === 'evm' &&
!hasZeroNativeBalance &&
!hasLowTransactionCount
)
}, [wallet?.vmType, protocolVersion, chainId, chainVmType])
}, [
wallet?.vmType,
protocolVersion,
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])
}, [
conditionKey,
shouldDetect,
detectionState,
hasZeroNativeBalance,
hasLowTransactionCount,
isLoadingSafetyChecks
])

useEffect(() => {
setDetectionState({ value: undefined, conditionKey })
Expand All @@ -60,19 +134,43 @@ const useEOADetection = (
return
}

const eoaResult = await wallet.isEOA(chainId!)
const { isEOA, isEIP7702Delegated } = eoaResult
const explicitDepositValue = !isEOA || isEIP7702Delegated
const abortController = new AbortController()
const timeoutId = setTimeout(() => {
abortController.abort()
}, 1000)

setDetectionState((current) =>
current.conditionKey === conditionKey
? { value: explicitDepositValue, conditionKey }
: current
)
try {
const eoaResult = await Promise.race([
wallet.isEOA(chainId!),
new Promise<never>((_, reject) => {
abortController.signal.addEventListener('abort', () => {
reject(new Error('EOA_DETECTION_TIMEOUT'))
})
})
])

clearTimeout(timeoutId)
const { isEOA, isEIP7702Delegated } = eoaResult
const explicitDepositValue = !isEOA || isEIP7702Delegated

setDetectionState((current) =>
current.conditionKey === conditionKey
? { value: explicitDepositValue, conditionKey }
: current
)
} catch (eoaError: any) {
clearTimeout(timeoutId)

setDetectionState((current) =>
current.conditionKey === conditionKey
? { value: true, conditionKey }
: current
)
}
} catch (error) {
setDetectionState((current) =>
current.conditionKey === conditionKey
? { value: undefined, conditionKey }
? { value: true, conditionKey }
: current
)
}
Expand Down
53 changes: 53 additions & 0 deletions packages/ui/src/hooks/useTransactionCount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { type Address, isAddress } from 'viem'
import { useQuery } from '@tanstack/react-query'
import { usePublicClient } from 'wagmi'

type UseTransactionCountProps = {
address?: Address | string
chainId?: number
enabled?: boolean
}

type UseTransactionCountData = {
data?: number
isLoading: boolean
isError: boolean
error: Error | null
}

const useTransactionCount = ({
address,
chainId,
enabled = true
}: UseTransactionCountProps): UseTransactionCountData => {
const publicClient = usePublicClient({ chainId })
const isValidAddress = address && isAddress(address)

const { data, isLoading, isError, error } = useQuery({
queryKey: ['transactionCount', chainId, address],
queryFn: async () => {
if (!publicClient || !address) {
throw new Error('Missing publicClient or address')
}

return await publicClient.getTransactionCount({
address: address as Address
})
},
enabled: Boolean(
enabled && publicClient && isValidAddress && chainId !== undefined
),
refetchOnWindowFocus: false,
staleTime: 60 * 60 * 1000,
gcTime: 60 * 60 * 1000
})

return {
data,
isLoading,
isError,
error: error as Error | null
}
}

export default useTransactionCount