Skip to content

CP-9990: Implement Price Alerts for Watchlist Favorites #3067

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
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 @@ -69,10 +69,17 @@ export const handleDeeplink = ({
deeplink.callback?.()
navigateFromDeeplinkUrl('/claimStakeReward')
} else if (action === ACTIONS.WatchList) {
const coingeckoId = pathname.split('/')[1]
navigateFromDeeplinkUrl(
`/trackTokenDetail?tokenId=${coingeckoId}&marketType=${MarketType.SEARCH}`
)
const tokenId = pathname.split('/')[1]
if (tokenId) {
// Detect if this is an internalId (EIP-155 format) or coingeckoId
const isInternalId = tokenId.startsWith('eip155:')
const marketType = isInternalId
? MarketType.TRENDING
: MarketType.SEARCH
navigateFromDeeplinkUrl(
`/trackTokenDetail?tokenId=${tokenId}&marketType=${marketType}`
)
}
Comment on lines +73 to +82
Copy link
Collaborator

Choose a reason for hiding this comment

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

we are supporting solana pretty soon so we can't hard code this to eip155. there should be no need to support MarketType.SEARCH anymore since we are gonna use internal id only from now on.

} else if (action === ACTIONS.OfframpCompleted) {
dispatch(offrampSend({ searchParams }))
} else if (action === ACTIONS.OnrampCompleted) {
Expand Down
2 changes: 1 addition & 1 deletion packages/core-mobile/app/services/fcm/FCMService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ class FCMService {
}
} else if (fcmData.type === NotificationTypes.NEWS) {
return {
url: fcmData.url
url: fcmData.urlV2 || fcmData.url // Prioritize urlV2 over url for backward compatibility
Copy link
Collaborator

Choose a reason for hiding this comment

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

let's remove .url so backend can remove it at some point. from the chat, that seems to be the plan.

}
}
}
Expand Down
3 changes: 2 additions & 1 deletion packages/core-mobile/app/services/fcm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ export const NewsDataSchema = object({
event: nativeEnum(NewsEvents),
title: string(),
body: string(),
url: string()
url: string(),
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
url: string(),

urlV2: string().optional() // New field for notifications with internalId
})

export const NotificationPayloadSchema = object({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { TokenSubscriptionPayload } from 'services/notifications/priceAlert/types'
import Logger from 'utils/Logger'
import fetchWithAppCheck from 'utils/httpClient'
import Config from 'react-native-config'

export async function setPriceAlertSubscriptions(
payload: TokenSubscriptionPayload
): Promise<void> {
Logger.info(
'[setPriceAlertSubscriptions] Setting token subscriptions:',
payload.tokens.map(t => t.internalId)
)

try {
const response = await fetchWithAppCheck(
Config.NOTIFICATION_SENDER_API_URL +
'/v1/push/price-alerts/custom/subscribe',
JSON.stringify(payload)
)

if (response.ok) {
const result = await response.json()
Logger.info(
'[setPriceAlertSubscriptions] Successfully subscribed to token price alerts:',
result
)
} else {
throw new Error(`${response.status}:${response.statusText}`)
}
} catch (error) {
Logger.error(
`[setPriceAlertSubscriptions] Failed to set token subscriptions:`,
error
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface TokenSubscriptionItem {
internalId: string // EIP-155 format: eip155:43114-0x<contract_address>
}

export interface TokenSubscriptionPayload {
tokens: TokenSubscriptionItem[]
deviceArn: string
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { setPriceAlertSubscriptions } from 'services/notifications/priceAlert/setPriceAlertSubscriptions'
import FCMService from 'services/fcm/FCMService'
import { registerDeviceToNotificationSender } from 'services/notifications/registerDeviceToNotificationSender'

export async function unsubscribeForPriceAlert(): Promise<void> {
const fcmToken = await FCMService.getFCMToken()
const deviceArn = await registerDeviceToNotificationSender(fcmToken)
await setPriceAlertSubscriptions({ tokens: [], deviceArn })
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import type { Action } from 'redux'
import { ChannelId, NewsChannelId } from 'services/notifications/channels'
import { handleProcessNotificationData } from 'store/notifications/listeners/handleProcessNotificationData'
import { promptEnableNotifications } from 'store/notifications'
import { toggleWatchListFavorite } from 'store/watchlist'
import { setPriceAlertNotifications } from 'store/notifications/listeners/setPriceAlertNotifications'
import { unsubscribeForPriceAlert } from 'services/notifications/priceAlert/unsubscribeForPriceAlert'
import {
onFcmTokenChange,
processNotificationData,
Expand Down Expand Up @@ -189,6 +192,20 @@ export const addNotificationsListeners = (
)
})
})

startListening({
actionCreator: toggleWatchListFavorite,
effect: setPriceAlertNotifications
})

startListening({
matcher: isAnyOf(onNotificationsTurnedOffForNews),
effect: async () => {
await unsubscribeForPriceAlert().catch(reason => {
Logger.error(`[listeners.ts][unsubscribeForPriceAlert]${reason}`)
})
}
})
}

const onNotificationsTurnedOnForBalanceChange = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import Logger from 'utils/Logger'
import { AnyAction } from '@reduxjs/toolkit'
import { AppListenerEffectAPI } from 'store/types'
import { selectWatchlistFavoriteIds } from 'store/watchlist'
import FCMService from 'services/fcm/FCMService'
import { registerDeviceToNotificationSender } from 'services/notifications/registerDeviceToNotificationSender'
import NotificationsService from 'services/notifications/NotificationsService'
import { selectNotificationSubscription } from 'store/notifications'
import { ChannelId } from 'services/notifications/channels'
import { unsubscribeForPriceAlert } from 'services/notifications/priceAlert/unsubscribeForPriceAlert'
import { setPriceAlertSubscriptions } from 'services/notifications/priceAlert/setPriceAlertSubscriptions'

export const setPriceAlertNotifications = async (
_: AnyAction,
listenerApi: AppListenerEffectAPI
): Promise<void> => {
const state = listenerApi.getState()

const userHasEnabledPriceAlertNotifications = selectNotificationSubscription(
ChannelId.PRICE_ALERTS
)(state)

if (!userHasEnabledPriceAlertNotifications) {
return
}

const favoriteTokensIds = selectWatchlistFavoriteIds(state)
Logger.info(
'[services/notifications/tokenChange/store/listeners.ts] Setting token subscriptions for favorites:',
favoriteTokensIds
)

try {
// Get deviceArn following the same pattern as other notification services
const fcmToken = await FCMService.getFCMToken()
const deviceArn = await registerDeviceToNotificationSender(fcmToken)

//check if only PRICE_ALERTS notifications are denied
const blockedNotifications =
await NotificationsService.getBlockedNotifications()
if (blockedNotifications.has(ChannelId.PRICE_ALERTS)) {
await unsubscribeForPriceAlert()
return
}

const tokens = favoriteTokensIds.map(id => ({
internalId: id
}))
Comment on lines +46 to +48
Copy link
Collaborator

Choose a reason for hiding this comment

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

I was under the impression that the backend API requires internal IDs. This list looks like it contains CoinGecko IDs rather than the actual internal ones. is this correct?


// Only call the API if we have tokens to subscribe to
if (tokens.length > 0) {
await setPriceAlertSubscriptions({
tokens,
deviceArn
})
Logger.info(
`[TokenChange] Successfully subscribed to token price alerts for ${tokens.length} tokens`
)
} else {
Logger.info(
'[TokenChange] No valid internalIds found for favorite tokens, skipping subscription'
)
}
} catch (error) {
// Handle specific APNS/FCM token errors gracefully
Logger.error(`[setTokenSubscriptionsForFavorites]${error}`)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { unSubscribeForBalanceChange } from 'services/notifications/balanceChang
import { unSubscribeForNews } from 'services/notifications/news/unsubscribeForNews'
import messaging from '@react-native-firebase/messaging'
import Logger from 'utils/Logger'
import { unsubscribeForPriceAlert } from 'services/notifications/priceAlert/unsubscribeForPriceAlert'

export async function unsubscribeAllNotifications(): Promise<void> {
const fcmToken = await FCMService.getFCMToken()
Expand All @@ -13,7 +14,8 @@ export async function unsubscribeAllNotifications(): Promise<void> {
unSubscribeForNews({
deviceArn,
channelIds: []
})
}),
unsubscribeForPriceAlert()
Copy link
Collaborator

Choose a reason for hiding this comment

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

it's probably better to use promise all settled so that each unsubscribe is independent.

]).catch(error => {
//as fallback invalidate token so user doesn't get notifications
messaging().deleteToken()
Expand Down
4 changes: 2 additions & 2 deletions packages/core-mobile/app/store/watchlist/slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
BITCOIN_COINGECKO_ID,
ETHEREUM_COINGECKO_ID
} from 'consts/coingecko'
import { initialState } from './types'
import { CoingeckoId, initialState } from './types'

const DEFAULT_WATCHLIST_FAVORITES = [
ETHEREUM_COINGECKO_ID,
Expand Down Expand Up @@ -48,7 +48,7 @@ export const selectIsWatchlistFavorite =
(coingeckoId: string) => (state: RootState) =>
state.watchlist.favorites.includes(coingeckoId)

export const selectWatchlistFavoriteIds = (state: RootState): string[] => {
export const selectWatchlistFavoriteIds = (state: RootState): CoingeckoId[] => {
Copy link
Collaborator

@atn4z7 atn4z7 Jul 21, 2025

Choose a reason for hiding this comment

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

everything should be driven by internal id now. can we migrate coingecko ids to internal ids for favorites?

return state.watchlist.favorites
}

Expand Down
4 changes: 2 additions & 2 deletions packages/core-mobile/app/store/watchlist/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export enum MarketType {
}

type InternalId = string
type CoingeckoId = string
export type CoingeckoId = string

export type MarketToken =
| {
Expand Down Expand Up @@ -76,5 +76,5 @@ export type TokensAndCharts = {
}

export type WatchListFavoriteState = {
favorites: string[]
favorites: CoingeckoId[]
}
Loading