diff --git a/src/components/transactions/Swap/actions/approval/useSwapTokenApproval.ts b/src/components/transactions/Swap/actions/approval/useSwapTokenApproval.ts index 517635cbd2..40788c3e4f 100644 --- a/src/components/transactions/Swap/actions/approval/useSwapTokenApproval.ts +++ b/src/components/transactions/Swap/actions/approval/useSwapTokenApproval.ts @@ -109,7 +109,11 @@ export const useSwapTokenApproval = ({ const { sendTx, signTxData } = useWeb3Context(); const [loadingPermitData, setLoadingPermitData] = useState(true); - const { isSmartContractWallet, isLoading: isLoadingWalletType } = useGetConnectedWalletType(); + const { + isSmartContractWallet, + isEip7702Wallet, + isLoading: isLoadingWalletType, + } = useGetConnectedWalletType(); const [ user, @@ -263,8 +267,16 @@ export const useSwapTokenApproval = ({ }; }, [chainId, token]); + // EIP-7702 delegates (Alchemy MA v2, etc.) intercept signTypedData with replay-safe + // wrapping (ERC-7739), so ERC-2612 permits and credit-delegation sigs don't ecrecover + // to the EOA on-chain. Force these wallets through direct approve / approveDelegation + // transactions instead. const tryPermit = - allowPermit && permitSupported === true && !isSmartContractWallet && !isLoadingWalletType; + allowPermit && + permitSupported === true && + !isSmartContractWallet && + !isEip7702Wallet && + !isLoadingWalletType; const usePermit = tryPermit && walletApprovalMethodPreference === ApprovalMethod.PERMIT; const approval = async () => { diff --git a/src/components/transactions/Swap/helpers/shared/provider.helpers.ts b/src/components/transactions/Swap/helpers/shared/provider.helpers.ts index baaea7c726..5dd18d716f 100644 --- a/src/components/transactions/Swap/helpers/shared/provider.helpers.ts +++ b/src/components/transactions/Swap/helpers/shared/provider.helpers.ts @@ -53,6 +53,10 @@ export const isSwapSupportedByCowProtocol = ( * * Notes: * - CoW is preferred when supported; fallback to ParaSwap if supported on chain + * - EIP-7702 delegated EOAs always fall back to ParaSwap. Smart-account delegates + * (Alchemy MA v2, etc.) intercept signTypedData and return wrapped signatures + * that don't ecrecover to the EOA, breaking both EIP-712 order signing and the + * flash-loan adapter's EIP-1271 wrapper. */ export const getSwitchProvider = ({ chainId, @@ -60,14 +64,17 @@ export const getSwitchProvider = ({ assetTo, shouldUseFlashloan, swapType, + userIsEip7702Wallet, }: { chainId: number; assetFrom: string; assetTo: string; shouldUseFlashloan?: boolean; swapType: SwapType; + userIsEip7702Wallet?: boolean; }): SwapProvider | undefined => { if ( + !userIsEip7702Wallet && isSwapSupportedByCowProtocol(chainId, assetFrom, assetTo, swapType, shouldUseFlashloan ?? false) ) { return SwapProvider.COW_PROTOCOL; diff --git a/src/components/transactions/Swap/hooks/useSwapQuote.ts b/src/components/transactions/Swap/hooks/useSwapQuote.ts index aeba71f4f1..fbc4a19c40 100644 --- a/src/components/transactions/Swap/hooks/useSwapQuote.ts +++ b/src/components/transactions/Swap/hooks/useSwapQuote.ts @@ -126,6 +126,7 @@ export const useSwapQuote = ({ assetTo: state.destinationToken.addressToSwap, swapType: params.swapType, shouldUseFlashloan: state.useFlashloan, + userIsEip7702Wallet: state.userIsEip7702Wallet, }); }, [ state.mainTxState.success, @@ -135,6 +136,7 @@ export const useSwapQuote = ({ state.destinationToken.addressToSwap, params.swapType, state.useFlashloan, + state.userIsEip7702Wallet, ]); const requiresQuoteInverted = useMemo( diff --git a/src/components/transactions/Swap/hooks/useUserContext.ts b/src/components/transactions/Swap/hooks/useUserContext.ts index 25ba020976..299872eda2 100644 --- a/src/components/transactions/Swap/hooks/useUserContext.ts +++ b/src/components/transactions/Swap/hooks/useUserContext.ts @@ -1,31 +1,52 @@ import { Dispatch, useEffect } from 'react'; -import { isSafeWallet, isSmartContractWallet } from 'src/helpers/provider'; -import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; +import { isEip7702Wallet, isSafeWallet, isSmartContractWallet } from 'src/helpers/provider'; import { getEthersProvider } from 'src/libs/web3-data-provider/adapters/EthersAdapter'; import { useRootStore } from 'src/store/root'; import { wagmiConfig } from 'src/ui-config/wagmiConfig'; import { SwapState } from '../types'; -export const useUserContext = ({ setState }: { setState: Dispatch> }) => { +// Detect on the swap's target chain, not the wallet's currently connected chain. +// EIP-7702 delegation is per-chain (the authorization tuple includes chainId), so +// a user can be 7702 on the swap chain while connected to a different one. +// Checking the wrong chain would misclassify and route through CoW. +export const useUserContext = ({ + chainId, + setState, +}: { + chainId: number; + setState: Dispatch>; +}) => { const user = useRootStore((store) => store.account); - const { chainId: connectedChainId } = useWeb3Context(); useEffect(() => { - try { - if (user && connectedChainId) { - setState({ user }); - getEthersProvider(wagmiConfig, { chainId: connectedChainId }).then((provider) => { - Promise.all([isSmartContractWallet(user, provider), isSafeWallet(user, provider)]).then( - ([isSmartContract, isSafe]) => { - setState({ userIsSmartContractWallet: isSmartContract }); - setState({ userIsSafeWallet: isSafe }); - } - ); + if (!user || !chainId) return; + + let cancelled = false; + setState({ user }); + + getEthersProvider(wagmiConfig, { chainId }) + .then((provider) => + Promise.all([ + isSmartContractWallet(user, provider), + isSafeWallet(user, provider), + isEip7702Wallet(user, provider), + ]) + ) + .then(([isSmartContract, isSafe, isEip7702]) => { + if (cancelled) return; + setState({ + userIsSmartContractWallet: isSmartContract, + userIsSafeWallet: isSafe, + userIsEip7702Wallet: isEip7702, }); - } - } catch (error) { - console.error(error); - } - }, [user, connectedChainId]); + }) + .catch((error) => { + console.error(error); + }); + + return () => { + cancelled = true; + }; + }, [user, chainId]); }; diff --git a/src/components/transactions/Swap/modals/request/BaseSwapModalContent.tsx b/src/components/transactions/Swap/modals/request/BaseSwapModalContent.tsx index cc91497815..f6a6e386a6 100644 --- a/src/components/transactions/Swap/modals/request/BaseSwapModalContent.tsx +++ b/src/components/transactions/Swap/modals/request/BaseSwapModalContent.tsx @@ -108,7 +108,7 @@ export const BaseSwapModalContent = ({ setState({ mainTxState }); }, [mainTxState]); const trackingHandlers = useHandleAnalytics({ state }); - useUserContext({ setState }); + useUserContext({ chainId: state.chainId, setState }); useMaxNativeAmount({ params, state, setState }); useSlippageSelector({ params, state, setState }); useFlowSelector({ params, state, setState }); diff --git a/src/components/transactions/Swap/types/state.types.ts b/src/components/transactions/Swap/types/state.types.ts index 0a1a853ca7..5e3e66a0a8 100644 --- a/src/components/transactions/Swap/types/state.types.ts +++ b/src/components/transactions/Swap/types/state.types.ts @@ -118,6 +118,8 @@ export type TokensSwapState = { userIsSmartContractWallet: boolean; /** True if the user is a Safe wallet. */ userIsSafeWallet: boolean; + /** True if the user is an EIP-7702 delegated EOA (CoW signature flow incompatible). */ + userIsEip7702Wallet: boolean; /** Token list for the source picker. */ sourceTokens: SwappableToken[]; /** Token list for the destination picker. */ @@ -261,6 +263,7 @@ export const swapDefaultState: SwapState = { forcedMaxValue: '', userIsSmartContractWallet: false, userIsSafeWallet: false, + userIsEip7702Wallet: false, sourceTokens: [], destinationTokens: [], isMaxSelected: false, diff --git a/src/helpers/provider.ts b/src/helpers/provider.ts index 4ae9492d54..d5980e9034 100644 --- a/src/helpers/provider.ts +++ b/src/helpers/provider.ts @@ -10,6 +10,19 @@ export const isSmartContractWallet = async (user: string, provider: JsonRpcProvi return code !== '0x'; }; +export const isEip7702Wallet = async ( + user: string, + provider: JsonRpcProvider +): Promise => { + try { + const code = await provider.getCode(user); + return isEip7702EOA(code, user); + } catch (error) { + console.error('Error detecting EIP-7702 delegation:', error); + return false; + } +}; + // https://eips.ethereum.org/EIPS/eip-7702#abstract function isEip7702EOA(code: string, account: string): boolean { return code.startsWith('0xef0100') || code.toLowerCase() === account.toLowerCase(); diff --git a/src/hooks/useGetConnectedWalletType.ts b/src/hooks/useGetConnectedWalletType.ts index 213659caad..b89bc34a9b 100644 --- a/src/hooks/useGetConnectedWalletType.ts +++ b/src/hooks/useGetConnectedWalletType.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { + isEip7702Wallet as getIsEip7702Wallet, isSafeWallet as getIsSafeWallet, isSmartContractWallet as getIsSmartContractWallet, } from 'src/helpers/provider'; @@ -13,6 +14,7 @@ export const useGetConnectedWalletType = () => { const user = useRootStore((store) => store.account); const [isSmartContractWallet, setUserIsSmartContractWallet] = useState(false); const [isSafeWallet, setUserIsSafeWallet] = useState(false); + const [isEip7702Wallet, setUserIsEip7702Wallet] = useState(false); const [isLoading, setIsLoading] = useState(true); useEffect(() => { @@ -21,25 +23,34 @@ export const useGetConnectedWalletType = () => { return; } + let cancelled = false; setIsLoading(true); getEthersProvider(wagmiConfig, { chainId }) .then((provider) => { return Promise.all([ getIsSmartContractWallet(user, provider), getIsSafeWallet(user, provider), + getIsEip7702Wallet(user, provider), ]); }) - .then(([isSmartContract, isSafe]) => { + .then(([isSmartContract, isSafe, isEip7702]) => { + if (cancelled) return; setUserIsSmartContractWallet(isSmartContract); setUserIsSafeWallet(isSafe); + setUserIsEip7702Wallet(isEip7702); }) .catch((error) => { console.error('Error fetching wallet type:', error); }) .finally(() => { + if (cancelled) return; setIsLoading(false); }); + + return () => { + cancelled = true; + }; }, [chainId, user]); - return { isSmartContractWallet, isSafeWallet, isLoading }; + return { isSmartContractWallet, isSafeWallet, isEip7702Wallet, isLoading }; };