From 15671019225fb04d466ae82d020ea4496551b807 Mon Sep 17 00:00:00 2001 From: tenderdeve Date: Mon, 22 Jun 2026 19:45:32 +0530 Subject: [PATCH 1/7] fix(widget): fire ON_BEFORE_APPROVAL widget hook before permit signing For permittable tokens, CowSwap signs an EIP-2612 permit instead of performing an on-chain ERC-20 approval. The ON_BEFORE_APPROVAL widget hook, however, was only fired from the on-chain approval path (useApproveCurrency), so widget integrators that subscribed to onBeforeApproval never saw the event when the user was about to sign a permit. From the integrator's perspective the user authorized spending without their pre-approval handler ever running. Fire ON_BEFORE_APPROVAL in the swap and limit order trade flows right before requesting the permit signature, mirroring the payload shape used by useApproveCurrency (chainId, sellToken, sellAmount, walletAddress, spenderAddress). The hook is skipped when a cached permit is reused, matching the behaviour of the existing permit request UI step. If the integrator's hook returns false, the relevant flow aborts cleanly without dispatching a permit request: - swap flow returns false, just like a declined price impact; - limit order flow throws a new WidgetHookDeclineError which is swallowed by useHandleOrderPlacement and only dismisses the trade confirmation modal. Closes #7685 --- .../hooks/useHandleOrderPlacement.ts | 6 ++- .../limitOrders/services/tradeFlow/index.ts | 40 ++++++++++++++++--- .../src/modules/limitOrders/services/types.ts | 2 + .../tradeFlow/services/swapFlow/index.ts | 25 ++++++++++-- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/limitOrders/hooks/useHandleOrderPlacement.ts b/apps/cowswap-frontend/src/modules/limitOrders/hooks/useHandleOrderPlacement.ts index f69f471e5d9..67960ca4825 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/hooks/useHandleOrderPlacement.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/hooks/useHandleOrderPlacement.ts @@ -17,7 +17,7 @@ import { useUpdateLimitOrdersRawState } from 'modules/limitOrders/hooks/useLimit import { useSafeBundleFlowContext } from 'modules/limitOrders/hooks/useSafeBundleFlowContext' import { safeBundleFlow } from 'modules/limitOrders/services/safeBundleFlow' import { tradeFlow } from 'modules/limitOrders/services/tradeFlow' -import { PriceImpactDeclineError, TradeFlowContext } from 'modules/limitOrders/services/types' +import { PriceImpactDeclineError, TradeFlowContext, WidgetHookDeclineError } from 'modules/limitOrders/services/types' import { LimitOrdersSettingsState } from 'modules/limitOrders/state/limitOrdersSettingsAtom' import { partiallyFillableOverrideAtom } from 'modules/limitOrders/state/partiallyFillableOverride' import { calculateLimitOrdersDeadline } from 'modules/limitOrders/utils/calculateLimitOrdersDeadline' @@ -202,6 +202,10 @@ export function useHandleOrderPlacement( }) .catch((error) => { if (error instanceof PriceImpactDeclineError) return + if (error instanceof WidgetHookDeclineError) { + tradeConfirmActions.onDismiss() + return + } if (error instanceof OperatorError) { tradeConfirmActions.onError(error.message || error.description) diff --git a/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.ts b/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.ts index 4d09e81e00f..ff10d663f33 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.ts @@ -1,8 +1,16 @@ -import { captureError, ERROR_TYPES, normalizeError, reportPermitWithDefaultSigner } from '@cowprotocol/common-utils' -import { SigningScheme } from '@cowprotocol/cow-sdk' +import { + captureError, + COW_PROTOCOL_VAULT_RELAYER_ADDRESS, + currencyAmountToTokenAmount, + ERROR_TYPES, + normalizeError, + reportPermitWithDefaultSigner, +} from '@cowprotocol/common-utils' +import { SigningScheme, SupportedChainId } from '@cowprotocol/cow-sdk' import { Percent } from '@cowprotocol/currency' import { isSupportedPermitInfo } from '@cowprotocol/permit-utils' import { Command, UiOrderType } from '@cowprotocol/types' +import { WidgetHookEvents } from '@cowprotocol/widget-lib' import { tradingSdk } from 'tradingSdk/tradingSdk' import { sendTransaction } from 'wagmi/actions' @@ -11,8 +19,9 @@ import { PriceImpact } from 'legacy/hooks/usePriceImpact' import { partialOrderUpdate } from 'legacy/state/orders/utils' import { mapUnsignedOrderToOrder, wrapErrorInOperatorError } from 'legacy/utils/trade' +import { callWidgetHook } from 'modules/injectedWidget' import { LOW_RATE_THRESHOLD_PERCENT } from 'modules/limitOrders/const/trade' -import { PriceImpactDeclineError, TradeFlowContext } from 'modules/limitOrders/services/types' +import { PriceImpactDeclineError, TradeFlowContext, WidgetHookDeclineError } from 'modules/limitOrders/services/types' import { LimitOrdersSettingsState } from 'modules/limitOrders/state/limitOrdersSettingsAtom' import { calculateLimitOrdersDeadline } from 'modules/limitOrders/utils/calculateLimitOrdersDeadline' import { emitPostedOrderEvent } from 'modules/orders' @@ -27,7 +36,7 @@ import { getSwapErrorMessage } from 'common/utils/getSwapErrorMessage' import type { Hex } from 'viem' // TODO: Break down this large function into smaller functions -// eslint-disable-next-line max-lines-per-function +// eslint-disable-next-line max-lines-per-function, complexity export async function tradeFlow( params: TradeFlowContext, priceImpact: PriceImpact, @@ -73,7 +82,28 @@ export async function tradeFlow( try { logTradeFlow('LIMIT ORDER FLOW', 'STEP 2: handle permit') - if (isSupportedPermitInfo(permitInfo)) await beforePermit() + if (isSupportedPermitInfo(permitInfo)) { + const cachedPermit = await params.getCachedPermit(sellToken.address) + + if (!cachedPermit) { + const sellTokenAmount = currencyAmountToTokenAmount(inputAmount) + const isWidgetHookPassed = await callWidgetHook(WidgetHookEvents.ON_BEFORE_APPROVAL, { + chainId: sellTokenAmount.currency.chainId, + sellToken: { + ...sellTokenAmount.currency, + name: sellTokenAmount.currency.name || '', + symbol: sellTokenAmount.currency.symbol || '', + }, + sellAmount: (permitAmountToSign ?? 0n).toString(), + walletAddress: account, + spenderAddress: COW_PROTOCOL_VAULT_RELAYER_ADDRESS[chainId as SupportedChainId], + }) + + if (!isWidgetHookPassed) throw new WidgetHookDeclineError() + } + + await beforePermit() + } postOrderParams.appData = await handlePermit({ permitInfo, diff --git a/apps/cowswap-frontend/src/modules/limitOrders/services/types.ts b/apps/cowswap-frontend/src/modules/limitOrders/services/types.ts index 766a792f226..f28c7d1d808 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/services/types.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/services/types.ts @@ -36,3 +36,5 @@ export interface SafeBundleFlowContext extends TradeFlowContext { } export class PriceImpactDeclineError extends Error {} + +export class WidgetHookDeclineError extends Error {} diff --git a/apps/cowswap-frontend/src/modules/tradeFlow/services/swapFlow/index.ts b/apps/cowswap-frontend/src/modules/tradeFlow/services/swapFlow/index.ts index 4cbf2d3c015..4184d54f631 100644 --- a/apps/cowswap-frontend/src/modules/tradeFlow/services/swapFlow/index.ts +++ b/apps/cowswap-frontend/src/modules/tradeFlow/services/swapFlow/index.ts @@ -1,16 +1,19 @@ import { captureError, + COW_PROTOCOL_VAULT_RELAYER_ADDRESS, + currencyAmountToTokenAmount, delay, ERROR_TYPES, getCurrencyAddress, normalizeError, reportPermitWithDefaultSigner, } from '@cowprotocol/common-utils' -import { SigningScheme, SigningStepManager } from '@cowprotocol/cow-sdk' +import { SigningScheme, SigningStepManager, SupportedChainId } from '@cowprotocol/cow-sdk' import { Percent } from '@cowprotocol/currency' import { isSupportedPermitInfo } from '@cowprotocol/permit-utils' import { CoWShedEip1271SignatureInvalid } from '@cowprotocol/sdk-cow-shed' import { UiOrderType } from '@cowprotocol/types' +import { WidgetHookEvents } from '@cowprotocol/widget-lib' import { SigningSteps } from 'entities/trade' import ms from 'ms.macro' @@ -21,6 +24,7 @@ import { PriceImpact } from 'legacy/hooks/usePriceImpact' import { partialOrderUpdate } from 'legacy/state/orders/utils' import { mapUnsignedOrderToOrder, wrapErrorInOperatorError } from 'legacy/utils/trade' +import { callWidgetHook } from 'modules/injectedWidget' import { emitPostedOrderEvent } from 'modules/orders' import { callDataContainsPermitSigner, handlePermit } from 'modules/permit' import { addPendingOrderStep } from 'modules/trade/utils/addPendingOrderStep' @@ -86,13 +90,28 @@ export async function swapFlow( try { logTradeFlow('SWAP FLOW', 'STEP 2: handle permit') + const { appData, account, isSafeWallet, recipientAddressOrName, kind } = orderParams + if (shouldSignPermit) { + const sellTokenAmount = currencyAmountToTokenAmount(inputAmount) + const isWidgetHookPassed = await callWidgetHook(WidgetHookEvents.ON_BEFORE_APPROVAL, { + chainId: sellTokenAmount.currency.chainId, + sellToken: { + ...sellTokenAmount.currency, + name: sellTokenAmount.currency.name || '', + symbol: sellTokenAmount.currency.symbol || '', + }, + sellAmount: (permitAmountToSign ?? 0n).toString(), + walletAddress: account, + spenderAddress: COW_PROTOCOL_VAULT_RELAYER_ADDRESS[chainId as SupportedChainId], + }) + + if (!isWidgetHookPassed) return false + setSigningStep(isBridgingOrder ? '1/3' : '1/2', SigningSteps.PermitSigning) tradeConfirmActions.requestPermitSignature(tradeAmounts) } - const { appData, account, isSafeWallet, recipientAddressOrName, inputAmount, outputAmount, kind } = orderParams - orderParams.appData = await handlePermit({ appData, typedHooks, From f9d60508525c188ddbc43f41d560fdfe7adc283a Mon Sep 17 00:00:00 2001 From: tenderdeve Date: Tue, 23 Jun 2026 12:29:09 +0530 Subject: [PATCH 2/7] fix(widget): skip swap-error analytics for WidgetHookDeclineError Treat the widget-hook decline as an expected abort path rather than a generic swap error: re-throw before the catch block routes it through captureError and analytics, so declined ON_BEFORE_APPROVAL hooks no longer pollute error telemetry. Addresses @coderabbitai's feedback on #7697. --- .../src/modules/limitOrders/services/tradeFlow/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.ts b/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.ts index ff10d663f33..30b909d76ff 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.ts @@ -226,6 +226,11 @@ export async function tradeFlow( } catch (err: unknown) { const error = normalizeError(err) + // Expected abort path: skip generic swap-error analytics so widget-hook declines don't pollute telemetry. + if (error instanceof WidgetHookDeclineError) { + throw error + } + logTradeFlow('LIMIT ORDER FLOW', 'STEP 9: ERROR: ', error) const swapErrorMessage = getSwapErrorMessage(error) From 97b1ab54c1a22f48a495662c419cdf3af7f896f9 Mon Sep 17 00:00:00 2001 From: tenderdeve Date: Tue, 23 Jun 2026 16:19:44 +0530 Subject: [PATCH 3/7] fix(widget): fire ON_BEFORE_APPROVAL hook before the trade approve tx The previous fix only wired ON_BEFORE_APPROVAL into the permit-signing paths (limit orders + swap permit flow). On the Swap page with a non-permittable token, the approval still goes through `useTradeApproveCallback` -> `approveCallback`, which never fired the hook. Result: the widget's onBeforeApproval listener never ran on the Swap page in that scenario. Call the hook in `useTradeApproveCallback` right before the ERC20 `approve` transaction. If the host widget declines, reset the approve progress modal and bail out without sending the tx, matching the behaviour of the other hook call sites. Addresses @elena-zh's review on #7697. --- .../useTradeApproveCallback.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useTradeApproveCallback.ts b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useTradeApproveCallback.ts index cd6fca881b0..cc81c5b3b26 100644 --- a/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useTradeApproveCallback.ts +++ b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useTradeApproveCallback.ts @@ -3,10 +3,13 @@ import { useCallback } from 'react' import { useTradeSpenderAddress } from '@cowprotocol/balances-and-allowances' import { Currency, CurrencyAmount } from '@cowprotocol/currency' import { useIsSafeViaWc, useIsSafeWallet, useWalletInfo } from '@cowprotocol/wallet' +import { WidgetHookEvents } from '@cowprotocol/widget-lib' import { useSetOptimisticAllowance } from 'entities/optimisticAllowance/useSetOptimisticAllowance' import { usePublicClient } from 'wagmi' +import { callWidgetHook } from 'modules/injectedWidget' + import { processApprovalTransaction } from './approveUtils' import { useApprovalAnalytics } from './useApprovalAnalytics' import { useHandleApprovalError } from './useHandleApprovalError' @@ -64,6 +67,7 @@ interface ProcessTransactionConfirmationParams { }) => void } +// eslint-disable-next-line max-lines-per-function export function useTradeApproveCallback(currency: Currency | undefined): TradeApproveCallback { const symbol = currency?.symbol @@ -82,6 +86,7 @@ export function useTradeApproveCallback(currency: Currency | undefined): TradeAp const handleApprovalError = useHandleApprovalError(symbol) return useCallback( + // eslint-disable-next-line complexity async (amount, { useModals = true, waitForTxConfirmation } = DEFAULT_APPROVE_PARAMS) => { if (useModals) { const amountToApprove = currency ? CurrencyAmount.fromRawAmount(currency, amount.toString()) : undefined @@ -91,6 +96,27 @@ export function useTradeApproveCallback(currency: Currency | undefined): TradeAp approvalAnalytics('Send', symbol) try { + if (currency && account && spender) { + const isWidgetHookPassed = await callWidgetHook(WidgetHookEvents.ON_BEFORE_APPROVAL, { + chainId: currency.chainId, + sellToken: { + chainId: currency.chainId, + address: currency.isToken ? currency.address : '', + decimals: currency.decimals, + name: currency.name || '', + symbol: currency.symbol || '', + }, + sellAmount: amount.toString(), + walletAddress: account, + spenderAddress: spender, + }) + + if (!isWidgetHookPassed) { + resetApproveProgressModalState() + return undefined + } + } + const response = await approveCallback(amount) if (!response) { From 6653bb118709def2c7b45794352237cdd50e63e0 Mon Sep 17 00:00:00 2001 From: tenderdeve Date: Tue, 23 Jun 2026 17:48:32 +0530 Subject: [PATCH 4/7] fix(widget): fire ON_BEFORE_APPROVAL hook in advance permit signing On the Swap page, when the user clicks Approve for a permittable token, the trade form pre-signs an EIP-2612 permit via `useGeneratePermitInAdvanceToTrade`. That path skipped the `ON_BEFORE_APPROVAL` widget hook, so the host widget's `onBeforeApproval` listener never ran on Swap with permittable tokens (it worked on Limit Orders because the permit is signed inside the limit-order `tradeFlow`, which already calls the hook). Call the hook in `useGeneratePermitInAdvanceToTrade` right before `generatePermit`. If the host widget declines, bail out without signing, matching the behaviour of the other hook call sites. Also drop the speculative hook call I added to `useTradeApproveCallback` in 97b1ab54c: `useApproveCurrency` (the only path that consumes `useTradeApproveCallback` for the Swap form) already fires the hook, so the extra call would double-prompt. Addresses @elena-zh's follow-up review on #7697. --- .../useTradeApproveCallback.ts | 26 ----------------- .../useGeneratePermitInAdvanceToTrade.ts | 28 +++++++++++++++++-- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useTradeApproveCallback.ts b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useTradeApproveCallback.ts index cc81c5b3b26..cd6fca881b0 100644 --- a/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useTradeApproveCallback.ts +++ b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useTradeApproveCallback.ts @@ -3,13 +3,10 @@ import { useCallback } from 'react' import { useTradeSpenderAddress } from '@cowprotocol/balances-and-allowances' import { Currency, CurrencyAmount } from '@cowprotocol/currency' import { useIsSafeViaWc, useIsSafeWallet, useWalletInfo } from '@cowprotocol/wallet' -import { WidgetHookEvents } from '@cowprotocol/widget-lib' import { useSetOptimisticAllowance } from 'entities/optimisticAllowance/useSetOptimisticAllowance' import { usePublicClient } from 'wagmi' -import { callWidgetHook } from 'modules/injectedWidget' - import { processApprovalTransaction } from './approveUtils' import { useApprovalAnalytics } from './useApprovalAnalytics' import { useHandleApprovalError } from './useHandleApprovalError' @@ -67,7 +64,6 @@ interface ProcessTransactionConfirmationParams { }) => void } -// eslint-disable-next-line max-lines-per-function export function useTradeApproveCallback(currency: Currency | undefined): TradeApproveCallback { const symbol = currency?.symbol @@ -86,7 +82,6 @@ export function useTradeApproveCallback(currency: Currency | undefined): TradeAp const handleApprovalError = useHandleApprovalError(symbol) return useCallback( - // eslint-disable-next-line complexity async (amount, { useModals = true, waitForTxConfirmation } = DEFAULT_APPROVE_PARAMS) => { if (useModals) { const amountToApprove = currency ? CurrencyAmount.fromRawAmount(currency, amount.toString()) : undefined @@ -96,27 +91,6 @@ export function useTradeApproveCallback(currency: Currency | undefined): TradeAp approvalAnalytics('Send', symbol) try { - if (currency && account && spender) { - const isWidgetHookPassed = await callWidgetHook(WidgetHookEvents.ON_BEFORE_APPROVAL, { - chainId: currency.chainId, - sellToken: { - chainId: currency.chainId, - address: currency.isToken ? currency.address : '', - decimals: currency.decimals, - name: currency.name || '', - symbol: currency.symbol || '', - }, - sellAmount: amount.toString(), - walletAddress: account, - spenderAddress: spender, - }) - - if (!isWidgetHookPassed) { - resetApproveProgressModalState() - return undefined - } - } - const response = await approveCallback(amount) if (!response) { diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.ts b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.ts index 95bc25ddfa5..161c614e46a 100644 --- a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.ts +++ b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.ts @@ -1,9 +1,12 @@ import { useCallback } from 'react' -import { getWrappedToken, isRejectRequestProviderError } from '@cowprotocol/common-utils' +import { useTradeSpenderAddress } from '@cowprotocol/balances-and-allowances' +import { currencyAmountToTokenAmount, getWrappedToken, isRejectRequestProviderError } from '@cowprotocol/common-utils' import { Currency, CurrencyAmount } from '@cowprotocol/currency' import { useWalletInfo } from '@cowprotocol/wallet' +import { WidgetHookEvents } from '@cowprotocol/widget-lib' +import { callWidgetHook } from 'modules/injectedWidget' import { useGeneratePermitHook, usePermitInfo } from 'modules/permit' import { TradeType } from 'modules/trade' @@ -14,6 +17,7 @@ export function useGeneratePermitInAdvanceToTrade(amountToApprove: CurrencyAmoun const updateApproveProgressModalState = useUpdateApproveProgressModalState() const resetApproveProgressModalState = useResetApproveProgressModalState() const { account } = useWalletInfo() + const tradeSpenderAddress = useTradeSpenderAddress() const token = getWrappedToken(amountToApprove.currency) const permitInfo = usePermitInfo(token, TradeType.SWAP) @@ -21,6 +25,25 @@ export function useGeneratePermitInAdvanceToTrade(amountToApprove: CurrencyAmoun return useCallback(async () => { if (!account || !permitInfo) return false + const amountRaw = BigInt(amountToApprove.quotient.toString()) + + if (tradeSpenderAddress) { + const tokenAmount = currencyAmountToTokenAmount(amountToApprove) + const isWidgetHookPassed = await callWidgetHook(WidgetHookEvents.ON_BEFORE_APPROVAL, { + chainId: tokenAmount.currency.chainId, + sellToken: { + ...tokenAmount.currency, + name: tokenAmount.currency.name || '', + symbol: tokenAmount.currency.symbol || '', + }, + sellAmount: amountRaw.toString(), + walletAddress: account, + spenderAddress: tradeSpenderAddress, + }) + + if (!isWidgetHookPassed) return false + } + const preSignCallback = (): void => updateApproveProgressModalState({ currency: amountToApprove.currency, @@ -33,7 +56,7 @@ export function useGeneratePermitInAdvanceToTrade(amountToApprove: CurrencyAmoun inputToken: { name: token.name || '', address: token.address as `0x${string}` }, account, permitInfo, - amount: BigInt(amountToApprove.quotient.toString()), + amount: amountRaw, preSignCallback, postSignCallback: resetApproveProgressModalState, }) @@ -54,6 +77,7 @@ export function useGeneratePermitInAdvanceToTrade(amountToApprove: CurrencyAmoun resetApproveProgressModalState, token.address, token.name, + tradeSpenderAddress, updateApproveProgressModalState, ]) } From 7d49d28c42e44bfa94ba06fb059f5423ebc4eb59 Mon Sep 17 00:00:00 2001 From: tenderdeve Date: Tue, 23 Jun 2026 18:10:11 +0530 Subject: [PATCH 5/7] fix(widget): gate advance permit signing on tradeSpenderAddress If \`tradeSpenderAddress\` was unavailable, the previous version skipped the \`ON_BEFORE_APPROVAL\` widget hook but still called \`generatePermit\`, letting a permit be signed without the pre-approval gate. Treat a missing spender as a hard precondition (same as missing \`account\` / \`permitInfo\`) so the hook is always evaluated before any permit signature. Addresses @coderabbitai's review on #7697. --- .../useGeneratePermitInAdvanceToTrade.ts | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.ts b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.ts index 161c614e46a..54a0416874e 100644 --- a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.ts +++ b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.ts @@ -23,26 +23,24 @@ export function useGeneratePermitInAdvanceToTrade(amountToApprove: CurrencyAmoun const permitInfo = usePermitInfo(token, TradeType.SWAP) return useCallback(async () => { - if (!account || !permitInfo) return false + if (!account || !permitInfo || !tradeSpenderAddress) return false const amountRaw = BigInt(amountToApprove.quotient.toString()) - if (tradeSpenderAddress) { - const tokenAmount = currencyAmountToTokenAmount(amountToApprove) - const isWidgetHookPassed = await callWidgetHook(WidgetHookEvents.ON_BEFORE_APPROVAL, { - chainId: tokenAmount.currency.chainId, - sellToken: { - ...tokenAmount.currency, - name: tokenAmount.currency.name || '', - symbol: tokenAmount.currency.symbol || '', - }, - sellAmount: amountRaw.toString(), - walletAddress: account, - spenderAddress: tradeSpenderAddress, - }) + const tokenAmount = currencyAmountToTokenAmount(amountToApprove) + const isWidgetHookPassed = await callWidgetHook(WidgetHookEvents.ON_BEFORE_APPROVAL, { + chainId: tokenAmount.currency.chainId, + sellToken: { + ...tokenAmount.currency, + name: tokenAmount.currency.name || '', + symbol: tokenAmount.currency.symbol || '', + }, + sellAmount: amountRaw.toString(), + walletAddress: account, + spenderAddress: tradeSpenderAddress, + }) - if (!isWidgetHookPassed) return false - } + if (!isWidgetHookPassed) return false const preSignCallback = (): void => updateApproveProgressModalState({ From 2db25db0b4ebb53e8d1fda0f47eddea01c268e1c Mon Sep 17 00:00:00 2001 From: tenderdeve Date: Mon, 20 Jul 2026 18:07:23 +0530 Subject: [PATCH 6/7] fix(widget): skip ON_BEFORE_APPROVAL when a cached permit is reused The hook was fired before the permit cache was consulted, so a trade that reuses a cached permit (no signature needed) still asked the integrator to approve and aborted on decline. Now every flow checks the cache first and only fires the hook on a genuine cache miss: - advance permit: look up the cached permit before calling the hook - limit + swap flows: key the cache lookup by the same amount permit generation uses (permitAmountToSign, falling back to maxUint256) Adds regression tests: cached permit skips the hook; an uncached decline prevents permit signing. --- .../useGeneratePermitInAdvanceToTrade.ts | 39 +++++++++------ .../services/tradeFlow/index.test.ts | 48 +++++++++++++++++-- .../limitOrders/services/tradeFlow/index.ts | 6 ++- .../tradeFlow/services/swapFlow/index.ts | 5 +- 4 files changed, 78 insertions(+), 20 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.ts b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.ts index 54a0416874e..38090406420 100644 --- a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.ts +++ b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.ts @@ -7,13 +7,14 @@ import { useWalletInfo } from '@cowprotocol/wallet' import { WidgetHookEvents } from '@cowprotocol/widget-lib' import { callWidgetHook } from 'modules/injectedWidget' -import { useGeneratePermitHook, usePermitInfo } from 'modules/permit' +import { useGeneratePermitHook, useGetCachedPermit, usePermitInfo } from 'modules/permit' import { TradeType } from 'modules/trade' import { useResetApproveProgressModalState, useUpdateApproveProgressModalState } from '../' export function useGeneratePermitInAdvanceToTrade(amountToApprove: CurrencyAmount): () => Promise { const generatePermit = useGeneratePermitHook() + const getCachedPermit = useGetCachedPermit() const updateApproveProgressModalState = useUpdateApproveProgressModalState() const resetApproveProgressModalState = useResetApproveProgressModalState() const { account } = useWalletInfo() @@ -27,20 +28,29 @@ export function useGeneratePermitInAdvanceToTrade(amountToApprove: CurrencyAmoun const amountRaw = BigInt(amountToApprove.quotient.toString()) - const tokenAmount = currencyAmountToTokenAmount(amountToApprove) - const isWidgetHookPassed = await callWidgetHook(WidgetHookEvents.ON_BEFORE_APPROVAL, { - chainId: tokenAmount.currency.chainId, - sellToken: { - ...tokenAmount.currency, - name: tokenAmount.currency.name || '', - symbol: tokenAmount.currency.symbol || '', - }, - sellAmount: amountRaw.toString(), - walletAddress: account, - spenderAddress: tradeSpenderAddress, - }) + // Only ask the host widget to approve when a permit signature is actually needed. A permit + // already cached under this token/amount/spender is reused by `generatePermit` without any + // signature, so firing ON_BEFORE_APPROVAL for it would let the integrator wrongly abort a trade + // that needs no approval. `generatePermit` caches with the default spender (vault relayer), so + // the lookup uses the same default here. + const cachedPermit = await getCachedPermit(token.address, amountRaw) - if (!isWidgetHookPassed) return false + if (!cachedPermit) { + const tokenAmount = currencyAmountToTokenAmount(amountToApprove) + const isWidgetHookPassed = await callWidgetHook(WidgetHookEvents.ON_BEFORE_APPROVAL, { + chainId: tokenAmount.currency.chainId, + sellToken: { + ...tokenAmount.currency, + name: tokenAmount.currency.name || '', + symbol: tokenAmount.currency.symbol || '', + }, + sellAmount: amountRaw.toString(), + walletAddress: account, + spenderAddress: tradeSpenderAddress, + }) + + if (!isWidgetHookPassed) return false + } const preSignCallback = (): void => updateApproveProgressModalState({ @@ -71,6 +81,7 @@ export function useGeneratePermitInAdvanceToTrade(amountToApprove: CurrencyAmoun account, amountToApprove, generatePermit, + getCachedPermit, permitInfo, resetApproveProgressModalState, token.address, diff --git a/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.test.ts b/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.test.ts index 3e675e0e9af..e94d4647469 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.test.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.test.ts @@ -1,8 +1,9 @@ import { CurrencyAmount, Token } from '@cowprotocol/currency' +import { callWidgetHook } from 'modules/injectedWidget' import { handlePermit } from 'modules/permit' -import { TradeFlowContext } from '../types' +import { TradeFlowContext, WidgetHookDeclineError } from '../types' import { tradeFlow } from './index' @@ -11,6 +12,10 @@ jest.mock('modules/permit', () => ({ callDataContainsPermitSigner: jest.fn().mockReturnValue(false), })) +jest.mock('modules/injectedWidget', () => ({ + callWidgetHook: jest.fn().mockResolvedValue(true), +})) + jest.mock('tradingSdk/tradingSdk', () => ({ tradingSdk: { postLimitOrder: jest.fn().mockResolvedValue({ @@ -43,6 +48,7 @@ jest.mock('modules/limitOrders/utils/calculateLimitOrdersDeadline', () => ({ })) const mockHandlePermit = handlePermit as jest.MockedFunction +const mockCallWidgetHook = callWidgetHook as jest.MockedFunction describe('limit orders tradeFlow - permit amount', () => { const sellToken = new Token(1, '0x1111111111111111111111111111111111111111', 18, 'SELL', 'Sell Token') @@ -98,11 +104,12 @@ describe('limit orders tradeFlow - permit amount', () => { beforeEach(() => { jest.clearAllMocks() mockHandlePermit.mockResolvedValue({ fullAppData: '{}', doc: {} } as never) + mockCallWidgetHook.mockResolvedValue(true) }) - it('signs the permit with the bounded amount from permitAmountToSign', async () => { - await tradeFlow( - buildParams(), + function runTradeFlow(params: TradeFlowContext): Promise { + return tradeFlow( + params, { priceImpact: undefined } as never, {} as never, analytics as never, @@ -110,8 +117,41 @@ describe('limit orders tradeFlow - permit amount', () => { jest.fn().mockResolvedValue(undefined), jest.fn(), ) + } + + it('signs the permit with the bounded amount from permitAmountToSign', async () => { + await runTradeFlow(buildParams()) expect(mockHandlePermit).toHaveBeenCalledTimes(1) expect(mockHandlePermit).toHaveBeenCalledWith(expect.objectContaining({ amount: permitAmountToSign })) }) + + it('looks up the cached permit with the bounded permit amount', async () => { + const params = buildParams() + await runTradeFlow(params) + + expect(params.getCachedPermit).toHaveBeenCalledWith(sellToken.address, permitAmountToSign) + }) + + it('skips the ON_BEFORE_APPROVAL hook when a cached permit is reused', async () => { + const params = buildParams() + ;(params.getCachedPermit as jest.Mock).mockResolvedValue({ fullAppData: '{}' }) + + await runTradeFlow(params) + + expect(mockCallWidgetHook).not.toHaveBeenCalled() + // Permit handling still runs (it reuses the cached permit internally) + expect(mockHandlePermit).toHaveBeenCalledTimes(1) + }) + + it('aborts before signing when an uncached permit is declined by the host widget', async () => { + const params = buildParams() + ;(params.getCachedPermit as jest.Mock).mockResolvedValue(undefined) + mockCallWidgetHook.mockResolvedValue(false) + + await expect(runTradeFlow(params)).rejects.toBeInstanceOf(WidgetHookDeclineError) + + expect(mockCallWidgetHook).toHaveBeenCalledTimes(1) + expect(mockHandlePermit).not.toHaveBeenCalled() + }) }) diff --git a/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.ts b/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.ts index 30b909d76ff..e19b309afc0 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.ts @@ -13,6 +13,7 @@ import { Command, UiOrderType } from '@cowprotocol/types' import { WidgetHookEvents } from '@cowprotocol/widget-lib' import { tradingSdk } from 'tradingSdk/tradingSdk' +import { maxUint256 } from 'viem' import { sendTransaction } from 'wagmi/actions' import { PriceImpact } from 'legacy/hooks/usePriceImpact' @@ -83,7 +84,10 @@ export async function tradeFlow( try { logTradeFlow('LIMIT ORDER FLOW', 'STEP 2: handle permit') if (isSupportedPermitInfo(permitInfo)) { - const cachedPermit = await params.getCachedPermit(sellToken.address) + // Match the amount the permit is (or would be) cached under (see `generatePermitHook`, which + // falls back to `maxUint256`), otherwise an amount-keyed cached permit is missed and the + // ON_BEFORE_APPROVAL hook fires even though no signature is needed. + const cachedPermit = await params.getCachedPermit(sellToken.address, permitAmountToSign ?? maxUint256) if (!cachedPermit) { const sellTokenAmount = currencyAmountToTokenAmount(inputAmount) diff --git a/apps/cowswap-frontend/src/modules/tradeFlow/services/swapFlow/index.ts b/apps/cowswap-frontend/src/modules/tradeFlow/services/swapFlow/index.ts index 4184d54f631..95589e09e80 100644 --- a/apps/cowswap-frontend/src/modules/tradeFlow/services/swapFlow/index.ts +++ b/apps/cowswap-frontend/src/modules/tradeFlow/services/swapFlow/index.ts @@ -18,6 +18,7 @@ import { WidgetHookEvents } from '@cowprotocol/widget-lib' import { SigningSteps } from 'entities/trade' import ms from 'ms.macro' import { tradingSdk } from 'tradingSdk/tradingSdk' +import { maxUint256 } from 'viem' import { sendTransaction } from 'wagmi/actions' import { PriceImpact } from 'legacy/hooks/usePriceImpact' @@ -83,7 +84,9 @@ export async function swapFlow( } = input const { chainId } = context const inputCurrency = inputAmount.currency - const cachedPermit = await getCachedPermit(getCurrencyAddress(inputCurrency), permitAmountToSign) + // Match the amount the permit is (or would be) cached under (`generatePermitHook` falls back to + // `maxUint256`) so a cached permit is reused and ON_BEFORE_APPROVAL is not fired needlessly. + const cachedPermit = await getCachedPermit(getCurrencyAddress(inputCurrency), permitAmountToSign ?? maxUint256) const shouldSignPermit = isSupportedPermitInfo(permitInfo) && !cachedPermit const isBridgingOrder = inputAmount.currency.chainId !== outputAmount.currency.chainId From 09825363890cfac9c6574b7031b9cffa62a87c1a Mon Sep 17 00:00:00 2001 From: tenderdeve Date: Wed, 22 Jul 2026 16:36:47 +0530 Subject: [PATCH 7/7] refactor(widget): centralize ON_BEFORE_APPROVAL in permit hook The approval veto was duplicated across the swap, limit and pre-generate flows. Fire it once inside generatePermitHook on a genuine cache miss and let callers opt in via sellCurrency, so the three call sites drop their inline cache-lookup + callWidgetHook blocks. --- .../useGeneratePermitInAdvanceToTrade.test.ts | 1 + .../useGeneratePermitInAdvanceToTrade.ts | 41 +++---------- .../src/modules/injectedWidget/index.ts | 1 + .../services/fireOnBeforeApprovalHook.test.ts | 59 +++++++++++++++++++ .../services/fireOnBeforeApprovalHook.ts | 44 ++++++++++++++ .../services/tradeFlow/index.test.ts | 43 +++++--------- .../limitOrders/services/tradeFlow/index.ts | 46 ++------------- .../src/modules/limitOrders/services/types.ts | 2 +- .../permit/hooks/useGeneratePermitHook.ts | 16 ++++- .../src/modules/permit/types.ts | 8 ++- .../src/modules/permit/utils/handlePermit.ts | 7 +++ .../tradeFlow/services/swapFlow/index.ts | 39 +++++------- 12 files changed, 177 insertions(+), 130 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/injectedWidget/services/fireOnBeforeApprovalHook.test.ts create mode 100644 apps/cowswap-frontend/src/modules/injectedWidget/services/fireOnBeforeApprovalHook.ts diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.test.ts b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.test.ts index 93b0694342e..aa74bcb7946 100644 --- a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.test.ts +++ b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.test.ts @@ -141,6 +141,7 @@ describe('useGeneratePermitInAdvanceToTrade', () => { account: mockAccount, permitInfo: mockPermitInfo, amount: BigInt(mockAmountToApprove.quotient.toString()), + sellCurrency: mockAmountToApprove.currency, preSignCallback: expect.any(Function), postSignCallback: expect.any(Function), }) diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.ts b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.ts index 38090406420..907c10dde73 100644 --- a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.ts +++ b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.ts @@ -1,57 +1,28 @@ import { useCallback } from 'react' -import { useTradeSpenderAddress } from '@cowprotocol/balances-and-allowances' -import { currencyAmountToTokenAmount, getWrappedToken, isRejectRequestProviderError } from '@cowprotocol/common-utils' +import { getWrappedToken, isRejectRequestProviderError } from '@cowprotocol/common-utils' import { Currency, CurrencyAmount } from '@cowprotocol/currency' import { useWalletInfo } from '@cowprotocol/wallet' -import { WidgetHookEvents } from '@cowprotocol/widget-lib' -import { callWidgetHook } from 'modules/injectedWidget' -import { useGeneratePermitHook, useGetCachedPermit, usePermitInfo } from 'modules/permit' +import { useGeneratePermitHook, usePermitInfo } from 'modules/permit' import { TradeType } from 'modules/trade' import { useResetApproveProgressModalState, useUpdateApproveProgressModalState } from '../' export function useGeneratePermitInAdvanceToTrade(amountToApprove: CurrencyAmount): () => Promise { const generatePermit = useGeneratePermitHook() - const getCachedPermit = useGetCachedPermit() const updateApproveProgressModalState = useUpdateApproveProgressModalState() const resetApproveProgressModalState = useResetApproveProgressModalState() const { account } = useWalletInfo() - const tradeSpenderAddress = useTradeSpenderAddress() const token = getWrappedToken(amountToApprove.currency) const permitInfo = usePermitInfo(token, TradeType.SWAP) return useCallback(async () => { - if (!account || !permitInfo || !tradeSpenderAddress) return false + if (!account || !permitInfo) return false const amountRaw = BigInt(amountToApprove.quotient.toString()) - // Only ask the host widget to approve when a permit signature is actually needed. A permit - // already cached under this token/amount/spender is reused by `generatePermit` without any - // signature, so firing ON_BEFORE_APPROVAL for it would let the integrator wrongly abort a trade - // that needs no approval. `generatePermit` caches with the default spender (vault relayer), so - // the lookup uses the same default here. - const cachedPermit = await getCachedPermit(token.address, amountRaw) - - if (!cachedPermit) { - const tokenAmount = currencyAmountToTokenAmount(amountToApprove) - const isWidgetHookPassed = await callWidgetHook(WidgetHookEvents.ON_BEFORE_APPROVAL, { - chainId: tokenAmount.currency.chainId, - sellToken: { - ...tokenAmount.currency, - name: tokenAmount.currency.name || '', - symbol: tokenAmount.currency.symbol || '', - }, - sellAmount: amountRaw.toString(), - walletAddress: account, - spenderAddress: tradeSpenderAddress, - }) - - if (!isWidgetHookPassed) return false - } - const preSignCallback = (): void => updateApproveProgressModalState({ currency: amountToApprove.currency, @@ -60,11 +31,15 @@ export function useGeneratePermitInAdvanceToTrade(amountToApprove: CurrencyAmoun }) try { + // The ON_BEFORE_APPROVAL widget veto fires inside `generatePermit` on a genuine cache miss + // (passing `sellCurrency` opts this trade approval into it) and throws WidgetHookDeclineError + // on decline, which is caught below and reported as "not approved". const permitData = await generatePermit({ inputToken: { name: token.name || '', address: token.address as `0x${string}` }, account, permitInfo, amount: amountRaw, + sellCurrency: amountToApprove.currency, preSignCallback, postSignCallback: resetApproveProgressModalState, }) @@ -81,12 +56,10 @@ export function useGeneratePermitInAdvanceToTrade(amountToApprove: CurrencyAmoun account, amountToApprove, generatePermit, - getCachedPermit, permitInfo, resetApproveProgressModalState, token.address, token.name, - tradeSpenderAddress, updateApproveProgressModalState, ]) } diff --git a/apps/cowswap-frontend/src/modules/injectedWidget/index.ts b/apps/cowswap-frontend/src/modules/injectedWidget/index.ts index 3838fb7c76b..4e01e872ce7 100644 --- a/apps/cowswap-frontend/src/modules/injectedWidget/index.ts +++ b/apps/cowswap-frontend/src/modules/injectedWidget/index.ts @@ -7,6 +7,7 @@ export { useInjectedWidgetPalette } from './hooks/useInjectedWidgetPalette' export { WidgetMarkdownContent } from './pure/WidgetMarkdownContent' export { callWidgetHook } from './services/callWidgetHook' +export { fireOnBeforeApprovalHook, WidgetHookDeclineError } from './services/fireOnBeforeApprovalHook' export { buildOrderWidgetHookPayload, buildOrdersWidgetHookPayload, diff --git a/apps/cowswap-frontend/src/modules/injectedWidget/services/fireOnBeforeApprovalHook.test.ts b/apps/cowswap-frontend/src/modules/injectedWidget/services/fireOnBeforeApprovalHook.test.ts new file mode 100644 index 00000000000..d67f8a745ec --- /dev/null +++ b/apps/cowswap-frontend/src/modules/injectedWidget/services/fireOnBeforeApprovalHook.test.ts @@ -0,0 +1,59 @@ +import { Token } from '@cowprotocol/currency' +import { WidgetHookEvents } from '@cowprotocol/widget-lib' + +import { callWidgetHook } from './callWidgetHook' +import { fireOnBeforeApprovalHook, WidgetHookDeclineError } from './fireOnBeforeApprovalHook' + +jest.mock('./callWidgetHook', () => ({ callWidgetHook: jest.fn() })) + +const mockCallWidgetHook = callWidgetHook as jest.MockedFunction + +const sellCurrency = new Token(1, '0x1111111111111111111111111111111111111111', 18, 'SELL', 'Sell Token') + +const params = { + sellCurrency, + sellAmount: 1000000000000000000n, + walletAddress: '0xaccount', + spenderAddress: '0xspender', +} + +describe('fireOnBeforeApprovalHook', () => { + beforeEach(() => jest.clearAllMocks()) + + it('fires ON_BEFORE_APPROVAL with the sell token payload', async () => { + mockCallWidgetHook.mockResolvedValue(true) + + await fireOnBeforeApprovalHook(params) + + expect(mockCallWidgetHook).toHaveBeenCalledWith(WidgetHookEvents.ON_BEFORE_APPROVAL, { + chainId: 1, + sellToken: { + chainId: 1, + address: sellCurrency.address, + decimals: 18, + name: 'Sell Token', + symbol: 'SELL', + }, + sellAmount: '1000000000000000000', + walletAddress: '0xaccount', + spenderAddress: '0xspender', + }) + }) + + it('defaults sellAmount to "0" when no amount is given', async () => { + mockCallWidgetHook.mockResolvedValue(true) + + await fireOnBeforeApprovalHook({ ...params, sellAmount: undefined }) + + expect(mockCallWidgetHook).toHaveBeenCalledWith( + WidgetHookEvents.ON_BEFORE_APPROVAL, + expect.objectContaining({ sellAmount: '0' }), + ) + }) + + it('throws WidgetHookDeclineError when the host widget declines', async () => { + mockCallWidgetHook.mockResolvedValue(false) + + await expect(fireOnBeforeApprovalHook(params)).rejects.toBeInstanceOf(WidgetHookDeclineError) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/injectedWidget/services/fireOnBeforeApprovalHook.ts b/apps/cowswap-frontend/src/modules/injectedWidget/services/fireOnBeforeApprovalHook.ts new file mode 100644 index 00000000000..55c1b92f83b --- /dev/null +++ b/apps/cowswap-frontend/src/modules/injectedWidget/services/fireOnBeforeApprovalHook.ts @@ -0,0 +1,44 @@ +import { getCurrencyAddress } from '@cowprotocol/common-utils' +import { Currency } from '@cowprotocol/currency' +import { WidgetHookEvents } from '@cowprotocol/widget-lib' + +import { callWidgetHook } from './callWidgetHook' + +interface FireOnBeforeApprovalHookParams { + sellCurrency: Currency + sellAmount: bigint | undefined + walletAddress: string + spenderAddress: string +} + +/** Thrown when the host widget vetoes an approval via the ON_BEFORE_APPROVAL hook. */ +export class WidgetHookDeclineError extends Error {} + +/** + * Ask the host widget to approve (ON_BEFORE_APPROVAL) right before a permit signature is requested. + * + * Throws {@link WidgetHookDeclineError} when the widget declines, so the caller can abort the flow. + * Resolves as a no-op when not running as an injected widget or when hooks are disabled. + */ +export async function fireOnBeforeApprovalHook({ + sellCurrency, + sellAmount, + walletAddress, + spenderAddress, +}: FireOnBeforeApprovalHookParams): Promise { + const isWidgetHookPassed = await callWidgetHook(WidgetHookEvents.ON_BEFORE_APPROVAL, { + chainId: sellCurrency.chainId, + sellToken: { + chainId: sellCurrency.chainId, + address: getCurrencyAddress(sellCurrency), + decimals: sellCurrency.decimals, + name: sellCurrency.name || '', + symbol: sellCurrency.symbol || '', + }, + sellAmount: (sellAmount ?? 0n).toString(), + walletAddress, + spenderAddress, + }) + + if (!isWidgetHookPassed) throw new WidgetHookDeclineError() +} diff --git a/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.test.ts b/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.test.ts index e94d4647469..7e807344a82 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.test.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.test.ts @@ -1,6 +1,5 @@ import { CurrencyAmount, Token } from '@cowprotocol/currency' -import { callWidgetHook } from 'modules/injectedWidget' import { handlePermit } from 'modules/permit' import { TradeFlowContext, WidgetHookDeclineError } from '../types' @@ -12,9 +11,12 @@ jest.mock('modules/permit', () => ({ callDataContainsPermitSigner: jest.fn().mockReturnValue(false), })) -jest.mock('modules/injectedWidget', () => ({ - callWidgetHook: jest.fn().mockResolvedValue(true), -})) +jest.mock('modules/injectedWidget', () => { + // The ON_BEFORE_APPROVAL veto now fires inside `handlePermit` (mocked here); the flow only needs + // to recognise its decline error, so provide a real class for `instanceof` checks. + class WidgetHookDeclineError extends Error {} + return { callWidgetHook: jest.fn().mockResolvedValue(true), WidgetHookDeclineError } +}) jest.mock('tradingSdk/tradingSdk', () => ({ tradingSdk: { @@ -48,7 +50,6 @@ jest.mock('modules/limitOrders/utils/calculateLimitOrdersDeadline', () => ({ })) const mockHandlePermit = handlePermit as jest.MockedFunction -const mockCallWidgetHook = callWidgetHook as jest.MockedFunction describe('limit orders tradeFlow - permit amount', () => { const sellToken = new Token(1, '0x1111111111111111111111111111111111111111', 18, 'SELL', 'Sell Token') @@ -104,7 +105,6 @@ describe('limit orders tradeFlow - permit amount', () => { beforeEach(() => { jest.clearAllMocks() mockHandlePermit.mockResolvedValue({ fullAppData: '{}', doc: {} } as never) - mockCallWidgetHook.mockResolvedValue(true) }) function runTradeFlow(params: TradeFlowContext): Promise { @@ -126,32 +126,19 @@ describe('limit orders tradeFlow - permit amount', () => { expect(mockHandlePermit).toHaveBeenCalledWith(expect.objectContaining({ amount: permitAmountToSign })) }) - it('looks up the cached permit with the bounded permit amount', async () => { - const params = buildParams() - await runTradeFlow(params) - - expect(params.getCachedPermit).toHaveBeenCalledWith(sellToken.address, permitAmountToSign) - }) - - it('skips the ON_BEFORE_APPROVAL hook when a cached permit is reused', async () => { - const params = buildParams() - ;(params.getCachedPermit as jest.Mock).mockResolvedValue({ fullAppData: '{}' }) - - await runTradeFlow(params) + it('delegates the ON_BEFORE_APPROVAL veto to handlePermit via a preSignCallback', async () => { + // The cache lookup + widget veto + "requesting signature" UI all live inside handlePermit now; + // the flow just hands it the beforePermit callback to flag the signing step. + await runTradeFlow(buildParams()) - expect(mockCallWidgetHook).not.toHaveBeenCalled() - // Permit handling still runs (it reuses the cached permit internally) - expect(mockHandlePermit).toHaveBeenCalledTimes(1) + expect(mockHandlePermit).toHaveBeenCalledWith(expect.objectContaining({ preSignCallback: expect.any(Function) })) }) - it('aborts before signing when an uncached permit is declined by the host widget', async () => { - const params = buildParams() - ;(params.getCachedPermit as jest.Mock).mockResolvedValue(undefined) - mockCallWidgetHook.mockResolvedValue(false) + it('rethrows WidgetHookDeclineError (without swap-error analytics) when handlePermit is declined', async () => { + mockHandlePermit.mockRejectedValue(new WidgetHookDeclineError()) - await expect(runTradeFlow(params)).rejects.toBeInstanceOf(WidgetHookDeclineError) + await expect(runTradeFlow(buildParams())).rejects.toBeInstanceOf(WidgetHookDeclineError) - expect(mockCallWidgetHook).toHaveBeenCalledTimes(1) - expect(mockHandlePermit).not.toHaveBeenCalled() + expect(analytics.error).not.toHaveBeenCalled() }) }) diff --git a/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.ts b/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.ts index bdfd7198861..763daf02320 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.ts @@ -1,20 +1,10 @@ -import { maxUint256 } from 'viem' import type { Hex } from 'viem' import { sendTransaction } from 'wagmi/actions' -import { - captureError, - COW_PROTOCOL_VAULT_RELAYER_ADDRESS, - currencyAmountToTokenAmount, - ERROR_TYPES, - normalizeError, - reportPermitWithDefaultSigner, -} from '@cowprotocol/common-utils' -import { SigningScheme, SupportedChainId } from '@cowprotocol/cow-sdk' +import { captureError, ERROR_TYPES, normalizeError, reportPermitWithDefaultSigner } from '@cowprotocol/common-utils' +import { SigningScheme } from '@cowprotocol/cow-sdk' import { Percent } from '@cowprotocol/currency' -import { isSupportedPermitInfo } from '@cowprotocol/permit-utils' import { Command, UiOrderType } from '@cowprotocol/types' -import { WidgetHookEvents } from '@cowprotocol/widget-lib' import { tradingSdk } from 'tradingSdk/tradingSdk' @@ -22,7 +12,6 @@ import { PriceImpact } from 'legacy/hooks/usePriceImpact' import { partialOrderUpdate } from 'legacy/state/orders/utils' import { mapUnsignedOrderToOrder, wrapErrorInOperatorError } from 'legacy/utils/trade' -import { callWidgetHook } from 'modules/injectedWidget' import { LOW_RATE_THRESHOLD_PERCENT } from 'modules/limitOrders/const/trade' import { PriceImpactDeclineError, TradeFlowContext, WidgetHookDeclineError } from 'modules/limitOrders/services/types' import { LimitOrdersSettingsState } from 'modules/limitOrders/state/limitOrdersSettingsAtom' @@ -37,7 +26,7 @@ import { TradeFlowAnalytics } from 'modules/trade/utils/tradeFlowAnalytics' import { getSwapErrorMessage } from 'common/utils/getSwapErrorMessage' // TODO: Break down this large function into smaller functions -// eslint-disable-next-line max-lines-per-function, complexity +// eslint-disable-next-line max-lines-per-function export async function tradeFlow( params: TradeFlowContext, priceImpact: PriceImpact, @@ -83,32 +72,6 @@ export async function tradeFlow( try { logTradeFlow('LIMIT ORDER FLOW', 'STEP 2: handle permit') - if (isSupportedPermitInfo(permitInfo)) { - // Match the amount the permit is (or would be) cached under (see `generatePermitHook`, which - // falls back to `maxUint256`), otherwise an amount-keyed cached permit is missed and the - // ON_BEFORE_APPROVAL hook fires even though no signature is needed. - const cachedPermit = await params.getCachedPermit(sellToken.address, permitAmountToSign ?? maxUint256) - - if (!cachedPermit) { - const sellTokenAmount = currencyAmountToTokenAmount(inputAmount) - const isWidgetHookPassed = await callWidgetHook(WidgetHookEvents.ON_BEFORE_APPROVAL, { - chainId: sellTokenAmount.currency.chainId, - sellToken: { - ...sellTokenAmount.currency, - name: sellTokenAmount.currency.name || '', - symbol: sellTokenAmount.currency.symbol || '', - }, - sellAmount: (permitAmountToSign ?? 0n).toString(), - walletAddress: account, - spenderAddress: COW_PROTOCOL_VAULT_RELAYER_ADDRESS[chainId as SupportedChainId], - }) - - if (!isWidgetHookPassed) throw new WidgetHookDeclineError() - } - - await beforePermit() - } - postOrderParams.appData = await handlePermit({ permitInfo, inputToken: sellToken, @@ -117,6 +80,9 @@ export async function tradeFlow( typedHooks, amount: permitAmountToSign, generatePermitHook, + // Cache lookup, the ON_BEFORE_APPROVAL veto and the "requesting permit signature" UI all fire + // inside `generatePermitHook` on a genuine cache miss now; `beforePermit` flags the step. + preSignCallback: beforePermit, }) if (callDataContainsPermitSigner(postOrderParams.appData.fullAppData)) { diff --git a/apps/cowswap-frontend/src/modules/limitOrders/services/types.ts b/apps/cowswap-frontend/src/modules/limitOrders/services/types.ts index 7c0701c70a1..ee13f59e848 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/services/types.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/services/types.ts @@ -37,4 +37,4 @@ export interface TradeFlowContext { export class PriceImpactDeclineError extends Error {} -export class WidgetHookDeclineError extends Error {} +export { WidgetHookDeclineError } from 'modules/injectedWidget' diff --git a/apps/cowswap-frontend/src/modules/permit/hooks/useGeneratePermitHook.ts b/apps/cowswap-frontend/src/modules/permit/hooks/useGeneratePermitHook.ts index df5e2093489..7f1f295670b 100644 --- a/apps/cowswap-frontend/src/modules/permit/hooks/useGeneratePermitHook.ts +++ b/apps/cowswap-frontend/src/modules/permit/hooks/useGeneratePermitHook.ts @@ -14,6 +14,8 @@ import { } from '@cowprotocol/permit-utils' import { useWalletInfo } from '@cowprotocol/wallet' +import { fireOnBeforeApprovalHook } from 'modules/injectedWidget' + import { useGetCachedPermit } from './useGetCachedPermit' import { storePermitCacheAtom } from '../state/permitCacheAtom' @@ -95,7 +97,19 @@ async function runPermitRequest( const cachedPermit = await getCachedPermit(params.inputToken.address, amount, spender) if (cachedPermit) return cachedPermit - params.preSignCallback?.() + // Cache miss: a real permit signature is about to be requested. When a sell currency is provided + // (i.e. this is a user-facing trade approval), give the host widget a chance to veto it first. + // Throws WidgetHookDeclineError on decline so the calling flow aborts. + if (params.sellCurrency && params.account) { + await fireOnBeforeApprovalHook({ + sellCurrency: params.sellCurrency, + sellAmount: params.amount, + walletAddress: params.account, + spenderAddress: spender, + }) + } + + await params.preSignCallback?.() try { const hookData = await generatePermitHook({ account: params.account, diff --git a/apps/cowswap-frontend/src/modules/permit/types.ts b/apps/cowswap-frontend/src/modules/permit/types.ts index f0f15548d23..bfaa1bdaaa1 100644 --- a/apps/cowswap-frontend/src/modules/permit/types.ts +++ b/apps/cowswap-frontend/src/modules/permit/types.ts @@ -19,8 +19,14 @@ export type GeneratePermitHook = (params: GeneratePermitHookParams) => Promise

& { customSpender?: string - preSignCallback?: () => void + preSignCallback?: () => void | Promise postSignCallback?: () => void + /** + * Full sell currency. When provided, a cache-miss fires the `ON_BEFORE_APPROVAL` widget hook + * before requesting the permit signature. Omit for speculative/pre-generation callers that must + * never prompt the host widget. + */ + sellCurrency?: Currency } export type GetPermitCacheParams = PermitCacheKeyParams diff --git a/apps/cowswap-frontend/src/modules/permit/utils/handlePermit.ts b/apps/cowswap-frontend/src/modules/permit/utils/handlePermit.ts index 49e28d3cb50..8c00a71af7f 100644 --- a/apps/cowswap-frontend/src/modules/permit/utils/handlePermit.ts +++ b/apps/cowswap-frontend/src/modules/permit/utils/handlePermit.ts @@ -25,6 +25,7 @@ import { HandlePermitParams } from '../types' */ export async function handlePermit(params: HandlePermitParams): Promise { const { amount, permitInfo, inputToken, account, appData, typedHooks, generatePermitHook } = params + const { customSpender, preSignCallback, postSignCallback } = params if (isSupportedPermitInfo(permitInfo) && !getIsNativeToken(inputToken)) { // permitInfo will only be set if there's NOT enough allowance @@ -37,6 +38,12 @@ export async function handlePermit(params: HandlePermitParams): Promise { + setSigningStep(isBridgingOrder ? '1/3' : '1/2', SigningSteps.PermitSigning) + tradeConfirmActions.requestPermitSignature(tradeAmounts) + } + : undefined, }) if (callDataContainsPermitSigner(orderParams.appData.fullAppData)) { @@ -291,6 +276,10 @@ export async function swapFlow( return true } catch (err: unknown) { + // Expected abort path: the host widget vetoed the approval. Bail out quietly without swap-error + // telemetry, matching the previous inline `return false`. + if (err instanceof WidgetHookDeclineError) return false + const error = normalizeError(err) logTradeFlow('SWAP FLOW', 'STEP 8: ERROR: ', error)