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 95bc25ddfa5..907c10dde73 100644 --- a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.ts +++ b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useGeneratePermitInAdvanceToTrade.ts @@ -21,6 +21,8 @@ export function useGeneratePermitInAdvanceToTrade(amountToApprove: CurrencyAmoun return useCallback(async () => { if (!account || !permitInfo) return false + const amountRaw = BigInt(amountToApprove.quotient.toString()) + const preSignCallback = (): void => updateApproveProgressModalState({ currency: amountToApprove.currency, @@ -29,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: BigInt(amountToApprove.quotient.toString()), + amount: amountRaw, + sellCurrency: amountToApprove.currency, preSignCallback, postSignCallback: resetApproveProgressModalState, }) 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/hooks/useHandleOrderPlacement.ts b/apps/cowswap-frontend/src/modules/limitOrders/hooks/useHandleOrderPlacement.ts index 6cfb05a3027..e5f0e1dfd4e 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/hooks/useHandleOrderPlacement.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/hooks/useHandleOrderPlacement.ts @@ -20,7 +20,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' @@ -196,6 +196,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.test.ts b/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.test.ts index 3e675e0e9af..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 @@ -2,7 +2,7 @@ import { CurrencyAmount, Token } from '@cowprotocol/currency' import { handlePermit } from 'modules/permit' -import { TradeFlowContext } from '../types' +import { TradeFlowContext, WidgetHookDeclineError } from '../types' import { tradeFlow } from './index' @@ -11,6 +11,13 @@ jest.mock('modules/permit', () => ({ callDataContainsPermitSigner: jest.fn().mockReturnValue(false), })) +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: { postLimitOrder: jest.fn().mockResolvedValue({ @@ -100,9 +107,9 @@ describe('limit orders tradeFlow - permit amount', () => { mockHandlePermit.mockResolvedValue({ fullAppData: '{}', doc: {} } as never) }) - 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,28 @@ 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('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(mockHandlePermit).toHaveBeenCalledWith(expect.objectContaining({ preSignCallback: expect.any(Function) })) + }) + + it('rethrows WidgetHookDeclineError (without swap-error analytics) when handlePermit is declined', async () => { + mockHandlePermit.mockRejectedValue(new WidgetHookDeclineError()) + + await expect(runTradeFlow(buildParams())).rejects.toBeInstanceOf(WidgetHookDeclineError) + + 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 c3b5be04e87..763daf02320 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/services/tradeFlow/index.ts @@ -4,7 +4,6 @@ import { sendTransaction } from 'wagmi/actions' 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 { tradingSdk } from 'tradingSdk/tradingSdk' @@ -14,7 +13,7 @@ import { partialOrderUpdate } from 'legacy/state/orders/utils' import { mapUnsignedOrderToOrder, wrapErrorInOperatorError } from 'legacy/utils/trade' 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' @@ -73,8 +72,6 @@ export async function tradeFlow( try { logTradeFlow('LIMIT ORDER FLOW', 'STEP 2: handle permit') - if (isSupportedPermitInfo(permitInfo)) await beforePermit() - postOrderParams.appData = await handlePermit({ permitInfo, inputToken: sellToken, @@ -83,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)) { @@ -196,6 +196,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) diff --git a/apps/cowswap-frontend/src/modules/limitOrders/services/types.ts b/apps/cowswap-frontend/src/modules/limitOrders/services/types.ts index 02eb15aafa0..ee13f59e848 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 TradeFlowContext { } export class PriceImpactDeclineError 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)) { @@ -269,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)