From 6c49c71516be4dcc0a9ee5d2b63ceb70ec23fb04 Mon Sep 17 00:00:00 2001 From: Martin Grabina Date: Tue, 12 May 2026 10:53:09 -0300 Subject: [PATCH 1/4] fix(swap): route EIP-7702 wallets to ParaSwap EIP-7702 delegated EOAs running smart-account modules (Alchemy MA v2, Robinhood Wallet, etc.) intercept signTypedData and return wrapped signatures that don't ecrecover to the underlying EOA. This breaks both the simple EIP-712 order path and the flash-loan adapter's EIP-1271 wrapper, surfacing as InvalidEip1271Signature from the CoW orderbook on adapter flows (collateral swap, debt swap, repay-with-collateral, withdraw-and-swap). Detect 7702 delegation in the swap user context (code prefix 0xef0100) and force ParaSwap, which executes on-chain and doesn't depend on off-chain signature verification. --- .../Swap/helpers/shared/provider.helpers.ts | 7 +++++++ .../transactions/Swap/hooks/useSwapQuote.ts | 2 ++ .../transactions/Swap/hooks/useUserContext.ts | 17 ++++++++++------- .../transactions/Swap/types/state.types.ts | 3 +++ src/helpers/provider.ts | 13 +++++++++++++ 5 files changed, 35 insertions(+), 7 deletions(-) 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..4a19977c47 100644 --- a/src/components/transactions/Swap/hooks/useUserContext.ts +++ b/src/components/transactions/Swap/hooks/useUserContext.ts @@ -1,5 +1,5 @@ import { Dispatch, useEffect } from 'react'; -import { isSafeWallet, isSmartContractWallet } from 'src/helpers/provider'; +import { isEip7702Wallet, isSafeWallet, isSmartContractWallet } from 'src/helpers/provider'; import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; import { getEthersProvider } from 'src/libs/web3-data-provider/adapters/EthersAdapter'; import { useRootStore } from 'src/store/root'; @@ -16,12 +16,15 @@ export const useUserContext = ({ setState }: { setState: Dispatch { - Promise.all([isSmartContractWallet(user, provider), isSafeWallet(user, provider)]).then( - ([isSmartContract, isSafe]) => { - setState({ userIsSmartContractWallet: isSmartContract }); - setState({ userIsSafeWallet: isSafe }); - } - ); + Promise.all([ + isSmartContractWallet(user, provider), + isSafeWallet(user, provider), + isEip7702Wallet(user, provider), + ]).then(([isSmartContract, isSafe, isEip7702]) => { + setState({ userIsSmartContractWallet: isSmartContract }); + setState({ userIsSafeWallet: isSafe }); + setState({ userIsEip7702Wallet: isEip7702 }); + }); }); } } catch (error) { 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(); From 5cf08e10a56d1b3dae2021a6b99ac81dd9d0ca7d Mon Sep 17 00:00:00 2001 From: Martin Grabina Date: Tue, 12 May 2026 11:03:25 -0300 Subject: [PATCH 2/4] fix(swap): guard against stale wallet detection results If user or connectedChainId changes while the wallet-detection Promise.all is in flight, the previous context's results can clobber the new one, leaving userIsEip7702Wallet (and the smart-wallet flags) wrong until the next refresh. Now drives provider selection, so the staleness can route a non-7702 user to ParaSwap incorrectly. Add a cancelled flag tied to the effect's cleanup, and batch the three state writes into one. --- .../transactions/Swap/hooks/useUserContext.ts | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/src/components/transactions/Swap/hooks/useUserContext.ts b/src/components/transactions/Swap/hooks/useUserContext.ts index 4a19977c47..3ba52cefa6 100644 --- a/src/components/transactions/Swap/hooks/useUserContext.ts +++ b/src/components/transactions/Swap/hooks/useUserContext.ts @@ -12,23 +12,33 @@ export const useUserContext = ({ setState }: { setState: Dispatch { - try { - if (user && connectedChainId) { - setState({ user }); - getEthersProvider(wagmiConfig, { chainId: connectedChainId }).then((provider) => { - Promise.all([ - isSmartContractWallet(user, provider), - isSafeWallet(user, provider), - isEip7702Wallet(user, provider), - ]).then(([isSmartContract, isSafe, isEip7702]) => { - setState({ userIsSmartContractWallet: isSmartContract }); - setState({ userIsSafeWallet: isSafe }); - setState({ userIsEip7702Wallet: isEip7702 }); - }); + if (!user || !connectedChainId) return; + + let cancelled = false; + setState({ user }); + + getEthersProvider(wagmiConfig, { chainId: connectedChainId }) + .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); - } + }) + .catch((error) => { + console.error(error); + }); + + return () => { + cancelled = true; + }; }, [user, connectedChainId]); }; From 9e98a9f9b919268a24d05d62a603f18b2fb03e67 Mon Sep 17 00:00:00 2001 From: Martin Grabina Date: Tue, 12 May 2026 12:07:07 -0300 Subject: [PATCH 3/4] fix(swap): skip permit and credit delegation sigs for EIP-7702 wallets Routing 7702 wallets to ParaSwap only avoids the CoW failure; the ParaSwap adapter path still asks the wallet for ERC-2612 permit and credit-delegation signatures, both of which break the same way (wrapped signTypedData, ecrecover yields wrong address, on-chain permit() / delegationWithSig() revert). Add isEip7702Wallet to useGetConnectedWalletType (with the same cancellation guard pattern) and gate tryPermit on !isEip7702Wallet so these wallets fall through to direct approve() / approveDelegation() transactions, which are plain ETH txs signed with the EOA key and round- trip fine regardless of the delegate. --- .../actions/approval/useSwapTokenApproval.ts | 16 ++++++++++++++-- src/hooks/useGetConnectedWalletType.ts | 15 +++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) 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/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 }; }; From 4b7512449a060ea48d48b59e3ec1ffcdfbadfe24 Mon Sep 17 00:00:00 2001 From: Martin Grabina Date: Tue, 12 May 2026 13:13:12 -0300 Subject: [PATCH 4/4] fix(swap): detect 7702 on swap chain, not connected chain EIP-7702 delegation is per-chain (the authorization tuple includes chainId). useUserContext was reading connectedChainId from useWeb3Context to decide whether the user is a 7702 EOA, but provider selection in useSwapQuote later applies that flag to state.chainId via getSwitchProvider. Cross-chain UX (browsing markets on chain A while connected to chain B) could misclassify the wallet and route to CoW on the swap chain, hitting the exact signature failure this gating prevents. Take chainId as a prop and use state.chainId at the call site, dropping the useWeb3Context dependency. --- .../transactions/Swap/hooks/useUserContext.ts | 20 +++++++++++++------ .../modals/request/BaseSwapModalContent.tsx | 2 +- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/components/transactions/Swap/hooks/useUserContext.ts b/src/components/transactions/Swap/hooks/useUserContext.ts index 3ba52cefa6..299872eda2 100644 --- a/src/components/transactions/Swap/hooks/useUserContext.ts +++ b/src/components/transactions/Swap/hooks/useUserContext.ts @@ -1,23 +1,31 @@ import { Dispatch, useEffect } from 'react'; import { isEip7702Wallet, isSafeWallet, isSmartContractWallet } from 'src/helpers/provider'; -import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; 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(() => { - if (!user || !connectedChainId) return; + if (!user || !chainId) return; let cancelled = false; setState({ user }); - getEthersProvider(wagmiConfig, { chainId: connectedChainId }) + getEthersProvider(wagmiConfig, { chainId }) .then((provider) => Promise.all([ isSmartContractWallet(user, provider), @@ -40,5 +48,5 @@ export const useUserContext = ({ setState }: { setState: Dispatch { cancelled = true; }; - }, [user, connectedChainId]); + }, [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 });