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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased (develop)

- added: Track when a user opens the app from a marketing push notification, reporting the campaign to analytics and navigating to an optional deep link.
- added: Remote enable/disable of gift card providers via the info server's giftCardInfo config, supporting whole-provider disabling for Phaze and Bitrefill and per-brand disabling for Phaze.
- changed: Reorganize the wallet list menu so Asset Settings is reached through Wallet Settings, and rename the Monero "Backend" card to "Server Settings".
- fixed: Prevent imported Monero wallets from using the Edge LWS backend. Choosing to import now prompts the user to continue with a full node or configure a custom LWS server, matching the wallet settings rule.
Expand Down
87 changes: 87 additions & 0 deletions src/__tests__/PushMessageParser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, it } from '@jest/globals'
import type { FirebaseMessagingTypes } from '@react-native-firebase/messaging'

import { parsePushMessage } from '../util/PushMessageParser'

// The parser only uses `showDevError` from AirshipInstance; stub it so the test
// avoids pulling in the native Airship module.
jest.mock('../components/services/AirshipInstance', () => ({
showDevError: () => undefined
}))

/**
* Builds a minimal RemoteMessage; the parser only reads `data` and
* `notification`.
*/
function makeMessage(
data: Record<string, string> | undefined,
notification?: { title?: string; body?: string }
): FirebaseMessagingTypes.RemoteMessage {
return {
data,
notification
} as unknown as FirebaseMessagingTypes.RemoteMessage
}

describe('parsePushMessage', () => {
it('captures the campaignId from a marketing payload', () => {
expect(
parsePushMessage(makeMessage({ type: 'marketing', campaignId: 'abc123' }))
).toEqual({
type: 'marketing',
campaignId: 'abc123',
link: undefined
})
})

it('parses an optional deep-link url into a nested navigation link', () => {
expect(
parsePushMessage(
makeMessage({
type: 'marketing',
campaignId: 'abc123',
url: 'edge://swap'
})
)
).toEqual({
type: 'marketing',
campaignId: 'abc123',
link: { type: 'swap' }
})
})

it('degrades to track-only when the url is unparseable', () => {
expect(
parsePushMessage(
makeMessage({
type: 'marketing',
campaignId: 'abc123',
url: 'not a url'
})
)
).toEqual({
type: 'marketing',
campaignId: 'abc123',
link: undefined
})
})

it('ignores a non-marketing payload', () => {
expect(parsePushMessage(makeMessage({ foo: 'bar' }))).toBeUndefined()
})

it('still parses a price-change payload', () => {
expect(
parsePushMessage(
makeMessage(
{ type: 'price-change', pluginId: 'bitcoin' },
{ body: 'BTC is up' }
)
)
).toEqual({
type: 'price-change',
pluginId: 'bitcoin',
body: 'BTC is up'
})
})
})
17 changes: 17 additions & 0 deletions src/actions/DeepLinkingActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,23 @@ async function handleLink(
break
}

case 'marketing': {
// The user opened the app from a marketing push. Report it so the
// campaign's open rate can be tracked. The send UI lives in the internal
// tools project; the campaignId rides in the push notification payload.
dispatch(
logEvent('Marketing_Notification_Opened', {
campaignId: link.campaignId
})
)
// Optional navigation: delegate to the shared handler, mirroring the
// affiliate link. Unsupported targets fall through its existing guards.
if (link.link != null) {
await handleLink(navigation, dispatch, state, link.link)
}
break
}

case 'other': {
const matchingWalletIdsAndUris: Array<{
walletId: string
Expand Down
7 changes: 7 additions & 0 deletions src/types/DeepLinkTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ export interface PriceChangeLink {
body: string // Human-readable message
}

export interface MarketingLink {
type: 'marketing'
campaignId: string // Correlates notification opens to a marketing campaign
link?: DeepLink // Optional navigation target parsed from the payload URL
}

export interface RampLink {
type: 'ramp'
direction: FiatDirection
Expand Down Expand Up @@ -165,6 +171,7 @@ export type DeepLink =
| EdgeLoginLink
| FiatPluginLink
| FiatProviderLink
| MarketingLink
| ModalLink
| NoopLink
| PasswordRecoveryLink
Expand Down
33 changes: 32 additions & 1 deletion src/util/PushMessageParser.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { FirebaseMessagingTypes } from '@react-native-firebase/messaging'
import { asMaybe, asObject, asString, asValue } from 'cleaners'
import { asMaybe, asObject, asOptional, asString, asValue } from 'cleaners'

import { showDevError } from '../components/services/AirshipInstance'
import type { DeepLink } from '../types/DeepLinkTypes'
import { parseDeepLink } from './DeepLinkParser'

/**
* Extracts a deep link from a push message, if present.
Expand All @@ -17,9 +19,38 @@ export function parsePushMessage(
body: asString(message.notification?.body)
}
}

const marketing = asMaybe(asMarketingPayloadData)(message.data)
if (marketing != null) {
let link: DeepLink | undefined
if (marketing.url != null) {
try {
const parsed = parseDeepLink(marketing.url)
// Only navigate for a recognized Edge deep link. `parseDeepLink` is
// lenient and returns an `other` link for anything it does not know, so
// an unrecognized or malformed URL degrades to track-only.
if (parsed.type !== 'other') link = parsed

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Agentic Security Review
Severity: MEDIUM

The marketing push path accepts any recognized deep link type from message.data.url and forwards it into the shared deep-link dispatcher. This means a marketing payload is not constrained to benign campaign destinations and can invoke broader in-app actions.

Impact: A compromised or misconfigured campaign sender could cause notification opens to trigger higher-risk flows (for example auth/session or internal navigation handlers) instead of only safe marketing navigation.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit b4eafc8. Configure here.

} catch (error: unknown) {
// parseDeepLink can still throw on some malformed input; never let that
// drop the push, or we would also lose the open tracking.
showDevError(error)
}
}
return {
type: 'marketing',
campaignId: marketing.campaignId,
link
}
}
}

const asPriceChangePayloadData = asObject({
type: asValue('price-change'),
pluginId: asString
})

const asMarketingPayloadData = asObject({
type: asValue('marketing'),
campaignId: asString,
url: asOptional(asString)
})
2 changes: 2 additions & 0 deletions src/util/tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type TrackingEventName =
| 'Fio_Handle_Bundled_Tx'
| 'Load_Install_Reason_Match'
| 'Load_Install_Reason_Fail'
| 'Marketing_Notification_Opened'
| 'Sell_Quote'
| 'Sell_Quote_Next'
| 'Sell_Success'
Expand Down Expand Up @@ -152,6 +153,7 @@ export interface TrackingValues extends LoginTrackingValues {
surveyCategory2?: string // User's answer to a survey (first tier response)
surveyResponse2?: string // User's answer to a survey
appleAdsKeywordId?: string // Apple Search Ads attribution keyword ID
campaignId?: string // Marketing push campaign identifier (notification opens)

// Conversion values
conversionValues?:
Expand Down