From cb8cffb0f680dbe3943a2acbd16cca34c02198eb Mon Sep 17 00:00:00 2001 From: Martin Grabina Date: Fri, 15 May 2026 11:20:59 -0300 Subject: [PATCH] feat(swap): support EIP-7702 wrapping delegates (ERC-7739 / MA v2) for CoW orders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-way wallet branching in the CoW swap flow: - contract -> setPreSignature on-chain (unchanged) - delegated-eoa-wrapping -> EIP-1271 off-chain via customEIP1271Signature - eoa / delegated-eoa-plain -> EIP-712 off-chain (unchanged) New `classifyAccount` helper (src/helpers/eip7702.ts) vendored from the cow-sdk proposal in cowprotocol/cow-sdk#878; drop when upstream merges. Inspects on-chain code for the canonical 23-byte 0xef0100 marker and looks the delegate up in a `WRAPPING_DELEGATES` allowlist. The allowlist is empty today, so the new wrapping branch is unreachable until populated. New `sendOrderForWrappingDelegate` in cow/orders.helpers.ts uses `tradingSdk.postLimitOrder` with `additionalParams.signingScheme: SigningScheme.EIP1271` and a `customEIP1271Signature` callback that forwards the wallet's raw signTypedData bytes verbatim — no `(order, sig)` ABI tuple wrap. CoW resolves verification via `isValidSignature` on the EOA, which EIP-7702 dispatches to the delegate. Production behavior is unchanged the day this lands. The new path activates only once known wrapping delegates are added to the allowlist (Alchemy MA v2, ZeroDev Kernel v3, Biconomy Nexus, etc.). Reviewed with codex; no blocking compatibility issues. --- .../actions/SwapActions/SwapActionsViaCoW.tsx | 46 ++++++++- .../Swap/helpers/cow/orders.helpers.ts | 98 +++++++++++++++++++ src/helpers/eip7702.ts | 53 ++++++++++ 3 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 src/helpers/eip7702.ts diff --git a/src/components/transactions/Swap/actions/SwapActions/SwapActionsViaCoW.tsx b/src/components/transactions/Swap/actions/SwapActions/SwapActionsViaCoW.tsx index b15cff96c7..be940929e6 100644 --- a/src/components/transactions/Swap/actions/SwapActions/SwapActionsViaCoW.tsx +++ b/src/components/transactions/Swap/actions/SwapActions/SwapActionsViaCoW.tsx @@ -9,7 +9,7 @@ import { BigNumber } from 'ethers'; import stringify from 'json-stringify-deterministic'; import { Dispatch, useMemo } from 'react'; import { TxActionsWrapper } from 'src/components/transactions/TxActionsWrapper'; -import { isSmartContractWallet } from 'src/helpers/provider'; +import { classifyAccount } from 'src/helpers/eip7702'; import { useModalContext } from 'src/hooks/useModal'; import { useSwapOrdersTracking } from 'src/hooks/useSwapOrdersTracking'; import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; @@ -28,6 +28,7 @@ import { isNativeToken, populateEthFlowTx, sendOrder, + sendOrderForWrappingDelegate, uploadAppData, } from '../../helpers/cow'; import { useSwapGasEstimation } from '../../hooks/useSwapGasEstimation'; @@ -279,7 +280,12 @@ export const SwapActionsViaCoW = ({ } else { let orderId; try { - if (await isSmartContractWallet(user, provider)) { + // Three-way wallet branching: + // contract -> setPreSignature on-chain + // delegated-eoa-wrapping -> EIP-1271 off-chain, bytes forwarded raw + // eoa / delegated-eoa-plain -> EIP-712 off-chain + const walletKind = await classifyAccount(user, provider); + if (walletKind === 'contract') { const preSignTransaction = await getPreSignTransaction({ provider, validTo, @@ -333,6 +339,42 @@ export const SwapActionsViaCoW = ({ success: true, txHash: preSignTransaction.orderId, }); + } else if (walletKind === 'delegated-eoa-wrapping') { + orderId = await sendOrderForWrappingDelegate({ + validTo, + tokenSrc: state.sourceToken.addressToSwap, + tokenSrcDecimals: state.sourceToken.decimals, + tokenDest: state.destinationToken.addressToSwap, + tokenDestDecimals: state.destinationToken.decimals, + quote: state.swapRate?.order, + sellAmount: sellAmountAccountingCosts.toString(), + buyAmount: buyAmountAccountingCosts.toString(), + slippageBps, + smartSlippage, + orderType: state.orderType, + swapType: params.swapType, + market: currentMarket, + kind: + state.orderType === OrderType.MARKET + ? OrderKind.SELL + : state.side === 'buy' + ? OrderKind.BUY + : OrderKind.SELL, + chainId: state.chainId, + user, + provider, + inputSymbol: state.sourceToken.symbol, + outputSymbol: state.destinationToken.symbol, + appCode, + orderBookQuote: state.swapRate?.orderBookQuote, + signatureParams, + estimateGasLimit, + }); + setMainTxState({ + loading: false, + success: true, + txHash: orderId, + }); } else { orderId = await sendOrder({ validTo, diff --git a/src/components/transactions/Swap/helpers/cow/orders.helpers.ts b/src/components/transactions/Swap/helpers/cow/orders.helpers.ts index 361a791ec2..f014ab59a5 100644 --- a/src/components/transactions/Swap/helpers/cow/orders.helpers.ts +++ b/src/components/transactions/Swap/helpers/cow/orders.helpers.ts @@ -6,6 +6,7 @@ import { OrderClass, OrderKind, OrderParameters, + OrderSigningUtils, OrderStatus, QuoteAndPost, SellTokenSource, @@ -239,6 +240,103 @@ export const sendOrder = async ({ .then((orderResult) => orderResult.orderId); }; +/** + * Send a CoW order for an EIP-7702 delegated EOA whose delegate wraps + * signatures at sign time (ERC-7739 / ERC-7579 MA v2). The wallet's + * signTypedData returns bytes that verify via the owner's isValidSignature, + * which 7702 dispatches to the delegate. Submitted with + * `signingScheme: EIP1271` and `from = user EOA`; CoW resolves verification + * on-chain via the delegate. + */ +export const sendOrderForWrappingDelegate = async ({ + provider, + chainId, + user, + slippageBps, + inputSymbol, + outputSymbol, + smartSlippage, + appCode, + orderType, + sellAmount, + buyAmount, + tokenSrc, + tokenDest, + tokenSrcDecimals, + tokenDestDecimals, + kind, + signatureParams, + estimateGasLimit, + validTo, + swapType, + market, +}: CowProtocolActionParams) => { + const signer = provider?.getSigner(); + + if (!isChainIdSupportedByCoWProtocol(chainId)) { + throw new Error('Chain not supported.'); + } + if (!signer) { + throw new Error('No signer found in provider'); + } + + const permitHook = + signatureParams && estimateGasLimit + ? await getPermitHook({ tokenAddress: tokenSrc, signatureParams, estimateGasLimit, chainId }) + : undefined; + + const hooks = permitHook ? { pre: [permitHook] } : undefined; + + const appData = COW_APP_DATA( + inputSymbol, + outputSymbol, + slippageBps, + smartSlippage, + orderType, + appCode, + swapType, + market, + hooks + ); + + const tradingSdk = await getCowTradingSdkByChainIdAndAppCode(chainId, appCode); + + return tradingSdk + .postLimitOrder( + { + sellAmount, + buyAmount, + kind: kind == OrderKind.SELL ? OrderKind.SELL : OrderKind.BUY, + sellToken: tokenSrc, + buyToken: tokenDest, + sellTokenDecimals: tokenSrcDecimals, + buyTokenDecimals: tokenDestDecimals, + validTo, + owner: user as `0x${string}`, + env: COW_ENV, + }, + { + appData, + additionalParams: { + applyCostsSlippageAndFees: false, + signingScheme: SigningScheme.EIP1271, + // The wallet's signTypedData wraps internally per its delegate's + // spec (7739 / MA v2 / both). We forward bytes verbatim — no + // (order, sig) ABI tuple wrap. + customEIP1271Signature: async (orderToSign, walletSigner) => { + const result = await OrderSigningUtils.signOrder( + orderToSign, + chainId as SupportedChainId, + walletSigner + ); + return result.signature; + }, + }, + } + ) + .then((orderResult) => orderResult.orderId); +}; + export const getOrderStatus = async (orderId: string, chainId: number) => { const orderBookApi = new OrderBookApi({ chainId: chainId, env: COW_ENV }); const status = await orderBookApi.getOrderCompetitionStatus(orderId, { diff --git a/src/helpers/eip7702.ts b/src/helpers/eip7702.ts new file mode 100644 index 0000000000..77e9a5c947 --- /dev/null +++ b/src/helpers/eip7702.ts @@ -0,0 +1,53 @@ +import { JsonRpcProvider } from '@ethersproject/providers'; + +/** + * On-chain classification of an account, from CoW's signing-scheme + * perspective. + * + * Vendored from the cow-sdk proposal in + * https://github.com/cowprotocol/cow-sdk/pull/878. When that PR merges, drop + * this module and import `classifyAccount` from `@cowprotocol/sdk-common`. + */ +export type WalletKind = 'eoa' | 'delegated-eoa-plain' | 'delegated-eoa-wrapping' | 'contract'; + +const EIP7702_DELEGATION_PREFIX = '0xef0100'; +const EIP7702_DELEGATION_HEX_LENGTH = 2 + 23 * 2; // "0x" + 23 bytes + +/** + * Delegate addresses (lowercase, no `0x`) known to force signature wrapping + * at `signTypedData_v4` time (ERC-7739 / ERC-7579 MA v2 / both). Empty today; + * populate as wrapping delegates are confirmed in production. + */ +const WRAPPING_DELEGATES = new Set(); + +function isEip7702DelegationCode(code: string): boolean { + if (!code) return false; + const lower = code.toLowerCase(); + return ( + lower.length === EIP7702_DELEGATION_HEX_LENGTH && lower.startsWith(EIP7702_DELEGATION_PREFIX) + ); +} + +function extractEip7702Delegate(code: string): string | null { + if (!isEip7702DelegationCode(code)) return null; + return code.slice(2 + 6).toLowerCase(); +} + +/** + * Classify an account by inspecting its on-chain code. Drives CoW + * signing-scheme selection at the call-site of `sendOrder` / + * `getPreSignTransaction` / `sendOrderForWrappingDelegate`. + */ +export async function classifyAccount( + user: string, + provider: JsonRpcProvider +): Promise { + const code = await provider.getCode(user); + if (!code || code === '0x') return 'eoa'; + + const delegate = extractEip7702Delegate(code); + if (delegate !== null) { + return WRAPPING_DELEGATES.has(delegate) ? 'delegated-eoa-wrapping' : 'delegated-eoa-plain'; + } + return 'contract'; +}