Skip to content
Draft
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
4 changes: 4 additions & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,8 @@ NODE_ENV=development
VITE_FEATURE_MIXPANEL=true

VITE_FEATURE_TX_HISTORY_BYE_BYE=true

VITE_SWAPS_SERVER_URL=/swaps-api
VITE_USER_SERVER_URL=/user-api
VITE_NOTIFICATION_SERVER_URL=/notifications-api
VITE_FEATURE_SWAPPER_FIAT_RAMPS=true
3 changes: 2 additions & 1 deletion .yarnrc.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
nodeLinker: node-modules

npmAuthToken: "${NPM_AUTH-fallback}"

npmRegistryServer: "https://registry.npmjs.org"

plugins:
Expand All @@ -15,4 +17,3 @@ unsafeHttpWhitelist:
- 127.0.0.1

yarnPath: .yarn/releases/yarn-3.5.0.cjs
npmAuthToken: ${NPM_AUTH-fallback}
5 changes: 5 additions & 0 deletions headers/csps/shapeshiftServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { Csp } from '../types'

export const csp: Csp = {
'connect-src': ['http://localhost:3002', 'http://localhost:3001'],
}
5 changes: 3 additions & 2 deletions scripts/generateAssetData/color-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -3013,7 +3013,6 @@
"eip155:1/erc20:0x79eb84b5e30ef2481c8f00fd0aa7aad6ac0aa54d": "#F4B654",
"eip155:1/erc20:0x79eddec7f9648cf9953db39507efa9f772c83c6e": "#DB1A04",
"eip155:1/erc20:0x79f05c263055ba20ee0e814acd117c20caa10e0c": "#1C46BA",
"eip155:1/erc20:0x79fd640000f8563a866322483524a4b48f1ed702": "#C0E4E4",
"eip155:1/erc20:0x79fe521199697c9cf94d16d5d49277c933ccc38b": "#C52CDE",
"eip155:1/erc20:0x7a097173f1c9e78d84800381c1da649b688cd823": "#130C2C",
"eip155:1/erc20:0x7a2bc711e19ba6aff6ce8246c546e8c4b4944dfd": "#64C034",
Expand Down Expand Up @@ -7831,6 +7830,7 @@
"eip155:42161/erc20:0x39bce681d72720f80424914800a78c63fdfaf645": "#079277",
"eip155:42161/erc20:0x3a1429d50e0cbbc45c997af600541fe1cc3d2923": "#040404",
"eip155:42161/erc20:0x3a18dcc9745edcd1ef33ecb93b0b6eba5671e7ca": "#A13334",
"eip155:42161/erc20:0x3a26d8f73057a2487f9f752d1a71cd2c56fa412c": "#E0AFC5",
"eip155:42161/erc20:0x3a8b787f78d775aecfeea15706d4221b40f345ab": "#E0E0E0",
"eip155:42161/erc20:0x3a98e79cdc7d8b2716a8696e25af028e429f11da": "#0454FB",
"eip155:42161/erc20:0x3ad63b3c0ea6d7a093ff98fde040baddc389ecdc": "#D5ECE6",
Expand Down Expand Up @@ -14140,6 +14140,7 @@
"eip155:8453/erc20:0xa1ebb0922a7f43df50b34ad9bf2f602f88aab869": "#F4F6F4",
"eip155:8453/erc20:0xa202b2b7b4d2fe56bf81492ffdda657fe512de07": "#0A42BA",
"eip155:8453/erc20:0xa23dd9379f2e12d9ce76ec738b9f5e520d1d861c": "#D31B1A",
"eip155:8453/erc20:0xa260d72df8ff2696f3a8d0be46b7bc4d743be764": "#E9C90E",
"eip155:8453/erc20:0xa26a3903cbba4e6c2c757d7601fbc569efc479ef": "#079277",
"eip155:8453/erc20:0xa26a4611b8313bbb25ccb1a9e227ecc536a2f8f7": "#723CD3",
"eip155:8453/erc20:0xa2cac0023a4797b4729db94783405189a4203afc": "#E0AFC5",
Expand Down Expand Up @@ -14924,4 +14925,4 @@
"eip155:8453/erc20:0xff702347d81725ed8bbe341392af511e29cfed98": "#262626",
"eip155:8453/erc20:0xff9c8aad2629d1be9833fd162a87ab7ce1d68fdc": "#F1F5F2",
"eip155:8453/slip44:60": "#5C6BC0"
}
}
3 changes: 3 additions & 0 deletions src/context/AppProvider/AppContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import React, { useEffect, useMemo } from 'react'
import { useTranslate } from 'react-polyglot'

import { useDiscoverAccounts } from './hooks/useDiscoverAccounts'
import { useManageUser } from './hooks/useManageUser'
import { usePortfolioFetch } from './hooks/usePortfolioFetch'
import { useSnapStatusHandler } from './hooks/useSnapStatusHandler'

Expand Down Expand Up @@ -74,6 +75,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
// Handle Ledger device connection state and wallet disconnection
useLedgerConnectionState()

useManageUser()

useEffect(() => {
const handleLedgerOpenApp = ({ chainId, reject }: LedgerOpenAppEventArgs) => {
const onCancel = () => {
Expand Down
143 changes: 143 additions & 0 deletions src/context/AppProvider/hooks/useManageUser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { useQuery } from '@tanstack/react-query'
import axios from 'axios'
import { useCallback } from 'react'

import { getExpoToken } from '@/context/WalletProvider/MobileWallet/mobileMessageHandlers'
import { isMobile } from '@/lib/globals'
import { selectWalletEnabledAccountIds } from '@/state/slices/common-selectors'
import { useAppSelector } from '@/state/store'

interface UserResponse {
id: string
email: string
userAccounts: {
id: string
accountId: string
}[]
devices: {
id: string
deviceToken: string
deviceType: 'MOBILE' | 'WEB'
isActive: boolean
}[]
}

interface UserInitResponse {
user: UserResponse
websocketChannel?: string
expoToken?: string
}

const getUserOrCreate = async (accountIds: string[]): Promise<UserInitResponse> => {
const serverUrl = import.meta.env.VITE_USER_SERVER_URL

if (!serverUrl) {
throw new Error('USER_SERVER_URL not configured')
}

try {
const response = await axios.post(`${serverUrl}/users/get-or-create`, {
accountIds,
})

const user = response.data

const websocketChannel = `user:${user.id}`

return {
user,
websocketChannel,
}
} catch (error) {
console.error('Failed to get or create user:', error)
throw error
}
}

const registerDevice = async (
userId: string,
deviceToken: string,
deviceType: 'MOBILE' | 'WEB',
): Promise<void> => {
const serverUrl = import.meta.env.VITE_USER_SERVER_URL

const response = await axios.post(`${serverUrl}/users/${userId}/devices`, {
userId,
deviceToken,
deviceType,
})

return response.data
}

export const useManageUser = () => {
const walletEnabledAccountIds = useAppSelector(selectWalletEnabledAccountIds)

const { data: mobileExpoToken } = useQuery({
queryKey: ['getExpoToken'],
queryFn: () => getExpoToken(),
enabled: isMobile,
gcTime: Infinity,
staleTime: Infinity,
})

const {
data: userData,
isLoading,
error,
} = useQuery({
queryKey: ['getOrCreateUser', ...walletEnabledAccountIds],
queryFn: () => getUserOrCreate(walletEnabledAccountIds),
enabled: walletEnabledAccountIds.length > 0,
staleTime: Infinity,
gcTime: Infinity,
})

const initializeUserWithDevice = useCallback(
async (expoToken: string | null | undefined) => {
if (!userData?.user) return

try {
let deviceToken: string
let deviceType: 'MOBILE' | 'WEB'

if (isMobile && expoToken) {
deviceToken = expoToken
deviceType = 'MOBILE'
} else {
deviceToken = userData.websocketChannel || `user:${userData.user.id}`
deviceType = 'WEB'
}

await registerDevice(userData.user.id, deviceToken, deviceType)

return {
userId: userData.user.id,
expoToken: isMobile ? expoToken : undefined,
websocketChannel: userData.websocketChannel,
}
} catch (error) {
console.error('Failed to initialize user with device:', error)
throw error
}
},
[userData],
)

const { data: deviceData } = useQuery({
queryKey: ['registerDevice', userData?.user?.id, mobileExpoToken],
queryFn: () => initializeUserWithDevice(mobileExpoToken),
enabled: !!userData?.user?.id,
staleTime: Infinity,
gcTime: Infinity,
})

return {
user: userData?.user,
expoToken: deviceData?.expoToken,
websocketChannel: deviceData?.websocketChannel,
isLoading,
error,
initializeUserWithDevice,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type Command =
| 'listWallets'
| 'getWalletCount'
| 'reloadWebview'
| 'getExpoToken'
| 'requestStoreReview'
| 'getAppVersion'

Expand Down Expand Up @@ -44,6 +45,9 @@ type Message =
| {
cmd: 'reloadWebview'
}
| {
cmd: 'getExpoToken'
}
| {
cmd: 'vibrate'
level: HapticLevel
Expand Down Expand Up @@ -187,6 +191,11 @@ export const reloadWebview = (): Promise<boolean> => {
return postMessage<boolean>({ cmd: 'reloadWebview' })
}

/**
export const getExpoToken = (): Promise<string | null> => {
return postMessage<string | null>({ cmd: 'getExpoToken' })
}

/**
* Trigger device haptic feedback via the mobile app.
* No-ops when not running inside a React Native WebView.
Expand Down
121 changes: 121 additions & 0 deletions src/hooks/useNotificationWebsocket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { useQuery } from '@tanstack/react-query'
import type { Socket } from 'socket.io-client'
import { io } from 'socket.io-client'

import { useManageUser } from '@/context/AppProvider/hooks/useManageUser'

interface WebSocketConfig {
serverUrl: string
userId: string
}

const createWebSocketConnection = (config: WebSocketConfig): Promise<Socket> => {
return new Promise((resolve, reject) => {
const socket = io(config.serverUrl, {
transports: ['websocket'],
autoConnect: true,
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
})

socket.on('connect', () => {
console.log('WebSocket connected for user:', config.userId)

socket.emit('authenticate', { userId: config.userId }, (response: any) => {
if (response?.success) {
console.log('Successfully registered to WebSocket')
resolve(socket)
} else {
const error = new Error(response?.error || 'Failed to register to WebSocket')
console.error('WebSocket registration failed:', error)
reject(error)
}
})
})

socket.on('connect_error', error => {
console.error('WebSocket connection error:', error)
reject(error)
})

const timeout = setTimeout(() => {
reject(new Error('WebSocket connection timeout'))
}, 10000)

socket.on('connect', () => {
clearTimeout(timeout)
})

socket.on('connect_error', () => {
clearTimeout(timeout)
})
})
}

const serverUrl = import.meta.env.VITE_SWAPS_SERVER_URL

export const useNotificationWebsocket = () => {
const { user } = useManageUser()

const {
data: socket,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['websocket', user?.id],
queryFn: () => {
if (!user?.id || !serverUrl) {
throw new Error('User ID or server URL not available')
}
return createWebSocketConnection({
serverUrl,
userId: user.id,
})
},
enabled: !!user?.id && !!serverUrl,
staleTime: Infinity,
gcTime: Infinity,
retry: 3,
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
})

const addEventListener = (event: string, handler: (...args: any[]) => void) => {
if (socket) {
socket.on(event, handler)
return () => socket.off(event, handler)
}
return () => {}
}

const removeEventListener = (event: string, handler: (...args: any[]) => void) => {
if (socket) {
socket.off(event, handler)
}
}

const emit = (event: string, data: any) => {
if (socket?.connected) {
socket.emit(event, data)
} else {
console.warn('Cannot emit event: WebSocket not connected')
}
}

return {
socket,
isLoading,
error,
refetch,
isConnected: socket?.connected ?? false,
addEventListener,
removeEventListener,
emit,
onNotification: (handler: (data: any) => void) => addEventListener('notification', handler),
onSwapUpdate: (handler: (data: any) => void) => addEventListener('swap_update', handler),
onConnect: (handler: () => void) => addEventListener('connect', handler),
onDisconnect: (handler: (reason: string) => void) => addEventListener('disconnect', handler),
onError: (handler: (error: Error) => void) => addEventListener('error', handler),
}
}
Loading