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
14 changes: 11 additions & 3 deletions apps/cowswap-frontend/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,21 @@ jest.mock('@cowprotocol/analytics', () => {
'tokenSymbol',
'chainId',
])
const mockCowAnalytics = {
sendEvent: jest.fn(),
}

return {
__esModule: true,
AnalyticsCategory: {},
Category: {},
createCowTracker: (category: string, options: { enabled?: boolean } = {}) => {
return (event: Record<string, unknown>) => {
if (options.enabled === false) return

mockCowAnalytics.sendEvent({ category, ...event })
}
},
CowAnalytics: class MockCowAnalytics {
sendEvent = jest.fn()
},
Expand All @@ -58,9 +68,7 @@ jest.mock('@cowprotocol/analytics', () => {
return JSON.stringify(ga4Event)
},
useAnalyticsReporter: jest.fn(),
useCowAnalytics: jest.fn().mockImplementation(() => ({
sendEvent: jest.fn(),
})),
useCowAnalytics: jest.fn().mockImplementation(() => mockCowAnalytics),
waitForAnalytics: jest.fn().mockResolvedValue(undefined),
WebVitalsAnalytics: class MockWebVitalsAnalytics {
constructor(_cowAnalytics?: unknown) {}
Expand Down
1 change: 1 addition & 0 deletions apps/cowswap-frontend/src/common/analytics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export enum CowSwapAnalyticsCategory {
SURPLUS_MODAL = 'Surplus Modal',
COWSWAP = 'CoWSwap',
LIMIT_ORDER_SETTINGS = 'Limit Order Settings',
CAPTCHA = 'Captcha',

// UI Categories
WALLET = 'Wallet',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
} from './affiliateAnalytics.utils'

import { useAffiliateStateViewAnalytics } from '../hooks/useAffiliateStateViewAnalytics'
import { logAffiliate } from '../utils/logger'

import type { TraderWalletStatus as TraderWalletStatusType } from '../hooks/useAffiliateTraderWallet'

Expand All @@ -38,23 +37,18 @@ jest.mock('@cowprotocol/analytics', () => {
}
})

jest.mock('../utils/logger', () => ({
logAffiliate: jest.fn(),
}))

jest.mock('../hooks/useAffiliateTraderWallet', () => ({
TraderWalletStatus,
}))

const useCowAnalyticsMock = useCowAnalytics as jest.MockedFunction<typeof useCowAnalytics>
const logAffiliateMock = logAffiliate as jest.MockedFunction<typeof logAffiliate>

describe('trackAffiliateEvent', () => {
beforeEach(() => {
jest.clearAllMocks()
})

it('sends affiliate analytics payloads without undefined fields', () => {
it('sends affiliate analytics payloads', () => {
const sendEvent = jest.fn()
const analytics = { sendEvent } as unknown as CowAnalytics

Expand All @@ -73,28 +67,7 @@ describe('trackAffiliateEvent', () => {
action: 'affiliate_trader_page_state_viewed',
chainId: 1,
walletStatus: TraderWalletStatus.LINKED,
})
expect(Object.keys(payload)).toEqual(['category', 'action', 'chainId', 'walletStatus'])
expect(Object.prototype.hasOwnProperty.call(payload, 'optionalField')).toBe(false)
})

it('swallows analytics transport failures', () => {
const sendEvent = jest.fn(() => {
throw new Error('analytics failed')
})
const analytics = { sendEvent } as unknown as CowAnalytics

expect(() =>
trackAffiliateEvent({
analytics,
action: 'affiliate_trader_page_state_viewed',
walletStatus: TraderWalletStatus.LINKED,
}),
).not.toThrow()

expect(logAffiliateMock).toHaveBeenCalledWith('Failed to send analytics event', {
action: 'affiliate_trader_page_state_viewed',
error: expect.any(Error),
optionalField: undefined,
})
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {

import { TraderWalletStatus } from '../hooks/useAffiliateTraderWallet'
import { AffiliatePartnerCodeCreateError } from '../lib/affiliatePartnerCodeCreateError'
import { logAffiliate } from '../utils/logger'

interface TrackAffiliateEventParams {
analytics: CowAnalytics
Expand All @@ -30,18 +29,12 @@ interface AffiliatePartnerPageStateParams {
}

export function trackAffiliateEvent({ analytics, action, chainId, ...customParams }: TrackAffiliateEventParams): void {
try {
analytics.sendEvent(
compactRecord({
category: CowSwapAnalyticsCategory.AFFILIATE,
action,
chainId,
...customParams,
}) as GtmEvent<CowSwapAnalyticsCategory.AFFILIATE>,
)
} catch (error) {
logAffiliate('Failed to send analytics event', { action, error })
}
analytics.sendEvent({
category: CowSwapAnalyticsCategory.AFFILIATE,
action,
chainId,
...customParams,
} as GtmEvent<CowSwapAnalyticsCategory.AFFILIATE>)
}
Comment on lines 31 to 38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

as GtmEvent cast bypasses chainId type mismatch.

TrackAffiliateEventParams.chainId is typed SupportedChainId | number, but EventOptions.chainId expects SupportedChainId. The cast silently widens invalid numeric values through to the analytics payload. Either narrow the parameter type or validate at the boundary.

🛡️ Narrow chainId type instead of casting
 interface TrackAffiliateEventParams {
   analytics: CowAnalytics
   action: AffiliateAnalyticsAction
-  chainId?: SupportedChainId | number
+  chainId?: SupportedChainId
   [key: string]: unknown
 }

If raw number support is intentional, validate before sending:

 export function trackAffiliateEvent({ analytics, action, chainId, ...customParams }: TrackAffiliateEventParams): void {
+  if (chainId !== undefined && !Object.values(SupportedChainId).includes(chainId as SupportedChainId)) {
+    return
+  }
   analytics.sendEvent({
     category: CowSwapAnalyticsCategory.AFFILIATE,
     action,
     chainId,
     ...customParams,
   } as GtmEvent<CowSwapAnalyticsCategory.AFFILIATE>)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/cowswap-frontend/src/modules/affiliate/analytics/affiliateAnalytics.utils.ts`
around lines 31 - 38, The `trackAffiliateEvent` helper is masking a `chainId`
type mismatch with an `as GtmEvent` cast, allowing invalid numeric values to
reach `analytics.sendEvent`. Fix this in `trackAffiliateEvent` by removing the
cast-based bypass and either narrowing `TrackAffiliateEventParams.chainId` to
`SupportedChainId` or validating/coercing `chainId` before building the event
payload so the `GtmEvent` type is satisfied without unsafe assertions.


export function getAffiliatePartnerPageState({
Expand Down Expand Up @@ -123,7 +116,3 @@ export function normalizeAffiliatePartnerCodeCreateFailureReason(
return AffiliatePartnerCodeCreateFailureReason.UNEXPECTED_ERROR
}
}

function compactRecord(value: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(value).filter(([, entryValue]) => entryValue !== undefined))
}
Original file line number Diff line number Diff line change
Expand Up @@ -159,31 +159,4 @@ describe('useAffiliatePartnerCodeCreate', () => {
result: 'success',
})
})

it('continues code creation if the start event throws', async () => {
const walletClient = createWalletClient()

sendEvent.mockImplementationOnce(() => {
throw new Error('analytics failed')
})
createCodeMock.mockResolvedValue({ code: 'COW-123' } as Awaited<ReturnType<typeof bffAffiliateApi.createCode>>)

const { result } = renderHook(() =>
useAffiliatePartnerCodeCreate({
account: '0x1111111111111111111111111111111111111111',
walletClient,
code: 'COW-123',
setError,
}),
)

await act(async () => {
await result.current.onCreate()
})

expect(walletClient.signTypedData).toHaveBeenCalledTimes(1)
expect(createCodeMock).toHaveBeenCalledTimes(1)
expect(mutatePartnerInfo).toHaveBeenCalledTimes(1)
expect(setError.mock.calls).toEqual([[undefined]])
})
})
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import { useAtom, useAtomValue } from 'jotai'
import { ReactNode, useEffect, useRef, useState } from 'react'

import { createCowTracker } from '@cowprotocol/analytics'
import { useTheme } from '@cowprotocol/common-hooks'
import { getJwtTtl } from '@cowprotocol/common-utils'

import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
import { setBearerToken } from 'cowSdk'
import { captchaJwtAtom } from 'entities/captcha/state/captchaJwtAtom'

import { CowSwapAnalyticsCategory } from 'common/analytics/types'
import { featureFlagsAtom } from 'common/state/featureFlagsState'

import { exchangeTurnstileToken } from '../api/captchaApi'
import { TURNSTILE_SITE_KEY } from '../config/captcha.const'
import { TURNSTILE_DEMO_INTERACTIVE_SITE_KEY, TURNSTILE_SITE_KEY } from '../config/captcha.const'
import { useCaptchaDebugControls } from '../hooks/useCaptchaDebugControls'
import { logCaptcha } from '../logger'

/* eslint-disable max-lines-per-function */
export function CaptchaWidget(): ReactNode {
const [captchaJwt, setCaptchaJwt] = useAtom(captchaJwtAtom)
const { isCaptchaEnabled } = useAtomValue(featureFlagsAtom)
Expand All @@ -23,6 +26,10 @@ export function CaptchaWidget(): ReactNode {
const [siteKey, setSiteKey] = useState(TURNSTILE_SITE_KEY)
const theme = useTheme()

const trackCaptcha = createCowTracker(CowSwapAnalyticsCategory.CAPTCHA, {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createCowTracker returns a new function every time, so it should be memoized

enabled: siteKey !== TURNSTILE_DEMO_INTERACTIVE_SITE_KEY,
})

useEffect(() => {
if (!isCaptchaEnabled) {
logCaptcha.debug('Disabled by feature flag')
Expand Down Expand Up @@ -82,13 +89,16 @@ export function CaptchaWidget(): ReactNode {
}}
onWidgetLoad={(widgetId) => {
logCaptcha.debug('Challenge starting', { widgetId })
trackCaptcha({ action: 'captcha_challenge_started' })
captchaRef.current?.execute()
}}
onBeforeInteractive={() => {
logCaptcha.debug('Challenge requires interaction')
trackCaptcha({ action: 'captcha_interaction_required' })
}}
onAfterInteractive={() => {
logCaptcha.debug('Challenge interaction completed')
trackCaptcha({ action: 'captcha_interaction_completed' })
}}
onSuccess={async (token: string) => {
const requestId = exchangeRequestIdRef.current + 1
Expand All @@ -107,30 +117,37 @@ export function CaptchaWidget(): ReactNode {
}

logCaptcha.info('JWT received', { requestId })
trackCaptcha({ action: 'captcha_challenge_solved' })
setCaptchaJwt(jwt)
} catch (error) {
if (exchangeRequestIdRef.current !== requestId) {
return
}

logCaptcha.error('JWT exchange failed', { requestId, error })
trackCaptcha({ action: 'captcha_challenge_failed', reason: 'jwtExchangeFailed' })
setCaptchaJwt(null)
}
}}
onExpire={() => {
exchangeRequestIdRef.current += 1

logCaptcha.warn('Challenge expired')
trackCaptcha({ action: 'captcha_challenge_failed', reason: 'turnstileExpired' })
setCaptchaJwt(null)
logCaptcha.debug('Challenge re-starting')
captchaRef.current?.reset()
}}
onError={(errorCode) => {
exchangeRequestIdRef.current += 1

logCaptcha.error('Challenge errored', { errorCode, hostname: window.location.hostname })
trackCaptcha({ action: 'captcha_challenge_failed', reason: 'turnstileError' })
setCaptchaJwt(null)
}}
onUnsupported={() => {
logCaptcha.warn('Challenge unsupported by browser')
trackCaptcha({ action: 'captcha_challenge_failed', reason: 'browserUnsupported' })
}}
/>
)
Expand Down
7 changes: 4 additions & 3 deletions apps/cowswap-frontend/vercel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ const csp = buildCsp([
['upgrade-insecure-requests', []],
])

const crossOriginOpenerPolicy = process.env.VERCEL_ENV === 'production' ? 'same-origin-allow-popups' : 'unsafe-none'

// ---------------------------------------------------------------------------
// Vercel config
// ---------------------------------------------------------------------------
Expand All @@ -87,9 +89,8 @@ export const config: VercelConfig = {
routes.header('/(.*)', [
// Controls which resources the browser is allowed to load; prevents XSS and data injection attacks.
{ key: 'Content-Security-Policy', value: csp },
// Isolates the browsing context so cross-origin pages cannot access window.opener,
// while still allowing this page to open popups (needed for wallet connections).
{ key: 'Cross-Origin-Opener-Policy', value: 'same-origin-allow-popups' },
// Preview deploys use unsafe-none so Tag Assistant can keep its cross-origin debug connection.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be a bit dangerous in case if something depends on Cross-Origin-Opener-Policy and we don't notice it locally because it's unsafe-none

{ key: 'Cross-Origin-Opener-Policy', value: crossOriginOpenerPolicy },
// Allows any origin to load this page as an iframe, required for the embeddable widget.
{ key: 'Cross-Origin-Resource-Policy', value: 'cross-origin' },
// Prevents browsers from MIME-sniffing a response away from the declared Content-Type.
Expand Down
23 changes: 22 additions & 1 deletion libs/analytics/src/CowAnalytics.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,42 @@
import { SupportedChainId } from '@cowprotocol/cow-sdk'

export type AnalyticsEvent = string | EventOptions | (EventOptions & Record<string, unknown>)

export interface CowAnalytics {
setUserAccount(account: string | undefined, walletName?: string): void
sendPageView(path?: string, params?: string[], title?: string): void
sendEvent(event: string | EventOptions, params?: unknown): void
sendEvent(event: AnalyticsEvent, params?: unknown): void
sendTiming(timingCategory: string, timingVar: string, timingValue: number, timingLabel: string): void
sendError(error: Error, errorInfo?: string): void
outboundLink(params: OutboundLinkParams): void
setContext(key: AnalyticsContext, value?: string): void
}

export type EventOptions = {
/**
* Event name/action, also used as the GTM `event` name.
*/
action: string
/**
* High-level event bucket used for reporting.
*/
category: string
/**
* Optional free-form text label, kept for existing GTM/GA event_label flows.
* Prefer named custom params for new metadata when the meaning is specific.
*/
label?: string
/**
* Optional GA-style numeric metric. Use custom params for text metadata like failure reasons.
*/
value?: number
/**
* Whether the event should not affect interaction/bounce metrics.
*/
nonInteraction?: boolean
/**
* Optional chain id attached to chain-specific events.
*/
chainId?: SupportedChainId
}

Expand Down
20 changes: 20 additions & 0 deletions libs/analytics/src/createCowTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { getCowAnalytics } from './utils'

import type { EventOptions } from './CowAnalytics'

type CowTrackerEvent = Omit<EventOptions, 'category'> & Record<string, unknown>
type CowTracker = (event: CowTrackerEvent) => void

type CowTrackerOptions = {
enabled?: boolean
}

export function createCowTracker(category: string, options: CowTrackerOptions = {}): CowTracker {
const { enabled = true } = options

return (event) => {
if (!enabled) return

getCowAnalytics()?.sendEvent({ category, ...event })
}
}
Loading
Loading