Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
})
Expand Down
1 change: 1 addition & 0 deletions apps/cowswap-frontend/src/modules/injectedWidget/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof callWidgetHook>

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)
})
})
Original file line number Diff line number Diff line change
@@ -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<void> {
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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,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'
Expand Down Expand Up @@ -192,6 +192,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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({
Expand Down Expand Up @@ -100,18 +107,38 @@ 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<unknown> {
return tradeFlow(
params,
{ priceImpact: undefined } as never,
{} as never,
analytics as never,
jest.fn().mockResolvedValue(true),
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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -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)) {
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,5 @@ export interface TradeFlowContext {
}

export class PriceImpactDeclineError extends Error {}

export { WidgetHookDeclineError } from 'modules/injectedWidget'
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion apps/cowswap-frontend/src/modules/permit/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,14 @@ export type GeneratePermitHook = (params: GeneratePermitHookParams) => Promise<P

export type GeneratePermitHookParams = Pick<PermitHookParams, 'inputToken' | 'permitInfo' | 'account' | 'amount'> & {
customSpender?: string
preSignCallback?: () => void
preSignCallback?: () => void | Promise<void>
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { HandlePermitParams } from '../types'
*/
export async function handlePermit(params: HandlePermitParams): Promise<AppDataInfo> {
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
Expand All @@ -37,6 +38,12 @@ export async function handlePermit(params: HandlePermitParams): Promise<AppDataI
account,
permitInfo,
amount,
customSpender,
// Firing the ON_BEFORE_APPROVAL widget hook (and requesting the signature) is centralized in
// `generatePermitHook`; passing the full currency opts this user-facing trade flow into it.
sellCurrency: inputToken,
preSignCallback,
postSignCallback,
})

if (!permitData) {
Expand Down
Loading
Loading