Skip to content
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

feat(mobile): add integration with gateway backend for notifications feature #4878

Open
wants to merge 22 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
50af7ca
feat: add dedicated hook for delegation
Jonathansoufer Feb 4, 2025
67d6a07
chore: move constants to global
Jonathansoufer Feb 4, 2025
d75da8e
fix: typo
Jonathansoufer Feb 4, 2025
07e4b56
feat: create delegate hook
Jonathansoufer Feb 5, 2025
178d4d0
refactor: sending signature
Jonathansoufer Feb 5, 2025
34dc5e7
chore: add siwe lib and utils
Jonathansoufer Feb 7, 2025
ef8b296
fix: add cockie auth on RTK
Jonathansoufer Feb 7, 2025
df34acc
chore: add debug logger
Jonathansoufer Feb 7, 2025
497cce7
feat: add siwe hook
Jonathansoufer Feb 7, 2025
81d76fa
feat: add useDelegate hook
Jonathansoufer Feb 7, 2025
3e83d6f
Merge branch 'dev' into feat/notifications-gtw-integration
Jonathansoufer Feb 7, 2025
6166897
chore: add support to random numbers generation
Jonathansoufer Feb 13, 2025
dcece0e
chore: adds sign w ethereum hook
Jonathansoufer Feb 13, 2025
9259893
chore: adds specific function to return signer
Jonathansoufer Feb 13, 2025
bdb6c84
chore: removes console.log
Jonathansoufer Feb 13, 2025
6a7264e
chore: adds hook to hold interaction w/ gateway
Jonathansoufer Feb 13, 2025
8158986
chore: adds redux structure for delegators
Jonathansoufer Feb 13, 2025
fd1ca3f
chore: adds timeout wrapper
Jonathansoufer Feb 13, 2025
f5d09ab
chore: adds userId
Jonathansoufer Feb 13, 2025
7bf5d31
refactor: useDelegator hook
Jonathansoufer Feb 13, 2025
8109e9b
refactor: fixes key generation and granted rights
Jonathansoufer Feb 18, 2025
8364bb4
Merge branch 'dev' into feat/notifications-gtw-integration
Jonathansoufer Feb 18, 2025
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
12 changes: 12 additions & 0 deletions apps/mobile/babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,17 @@ module.exports = function (api) {
api.cache(true)
return {
presets: ['babel-preset-expo'],
plugins: [
[
'module-resolver',
{
alias: {
crypto: 'react-native-quick-crypto',
stream: 'stream-browserify',
buffer: '@craftzdog/react-native-buffer',
},
},
],
],
compojoom marked this conversation as resolved.
Show resolved Hide resolved
}
}
7 changes: 4 additions & 3 deletions apps/mobile/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { registerRootComponent } from 'expo';
import './shim'
import { registerRootComponent } from 'expo'

import App from './App';
import App from './App'

// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
registerRootComponent(App)
13 changes: 8 additions & 5 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
}
},
"scripts": {
"start": "expo start",
"start": "expo start --dev-client",
compojoom marked this conversation as resolved.
Show resolved Hide resolved
"start:android": "expo run:android",
"start:ios": "expo run:ios",
"storybook:metro": "STORYBOOK_ENABLED='true' expo start",
Expand Down Expand Up @@ -45,8 +45,8 @@
"@notifee/react-native": "^9.1.8",
"@react-native-clipboard/clipboard": "^1.15.0",
"@react-native-community/blur": "^4.4.1",
"@react-native-firebase/app": "^21.7.1",
"@react-native-firebase/messaging": "^21.7.1",
"@react-native-firebase/app": "^21.8.0",
"@react-native-firebase/messaging": "^21.8.0",
"@react-native-menu/menu": "^1.1.6",
"@react-native/babel-preset": "^0.76.2",
"@react-navigation/material-top-tabs": "^7.1.0",
Expand Down Expand Up @@ -85,17 +85,18 @@
"moti": "^0.29.0",
"react": "18.3.1",
"react-dom": "^18.3.1",
"react-native": "0.76.3",
"react-native": "0.76.7",
"react-native-collapsible-tab-view": "^8.0.0",
"react-native-device-crypto": "^0.1.7",
"react-native-device-info": "^14.0.1",
"react-native-draggable-flatlist": "^4.0.1",
"react-native-gesture-handler": "~2.20.2",
"react-native-get-random-values": "~1.11.0",
compojoom marked this conversation as resolved.
Show resolved Hide resolved
"react-native-keychain": "^9.2.2",
"react-native-mmkv": "^3.1.0",
"react-native-pager-view": "^6.5.1",
"react-native-progress": "^5.0.1",
"react-native-quick-crypto": "^0.7.11",
"react-native-quick-crypto": "^0.7.12",
"react-native-reanimated": "~3.16.7",
"react-native-safe-area-context": "~5.1.0",
"react-native-screens": "~4.5.0",
Expand All @@ -104,6 +105,7 @@
"react-redux": "^9.1.2",
"redux": "^5.0.1",
"redux-persist": "^6.0.0",
"siwe": "^3.0.0",
"tamagui": "^1.117.1",
"timezone-mock": "^1.3.6",
"tsconfig-paths-webpack-plugin": "^4.2.0"
Expand Down Expand Up @@ -136,6 +138,7 @@
"@types/lodash": "^4.17.13",
"@types/node": "^22.13.1",
"@types/react": "~18.3.12",
"@types/react-native-get-random-values": "^1",
compojoom marked this conversation as resolved.
Show resolved Hide resolved
"babel-loader": "^8.4.1",
"eslint": "^9.20.1",
"eslint-config-prettier": "^9.1.0",
Expand Down
26 changes: 26 additions & 0 deletions apps/mobile/shim.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { install } from 'react-native-quick-crypto'
install()

import { ethers } from 'ethers'

import crypto from 'react-native-quick-crypto'

ethers.randomBytes.register((length) => {
return new Uint8Array(crypto.randomBytes(length))
})

ethers.computeHmac.register((algo, key, data) => {
return crypto.createHmac(algo, key).update(data).digest()
})

ethers.pbkdf2.register((passwd, salt, iter, keylen, algo) => {
return crypto.pbkdf2Sync(passwd, salt, iter, keylen, algo)
})

ethers.sha256.register((data) => {
return crypto.createHash('sha256').update(data).digest()
})

ethers.sha512.register((data) => {
return crypto.createHash('sha512').update(data).digest()
})
5 changes: 2 additions & 3 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import '../../shim'
Copy link
Contributor

Choose a reason for hiding this comment

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

we already imported the shim in the index.js. Do we need it here as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed. This was part of the tests we did while resolving the ethers/crypto issue.


import { Stack } from 'expo-router'
import 'react-native-reanimated'
import { SafeThemeProvider } from '@/src/theme/provider/safeTheme'
Expand All @@ -13,14 +15,11 @@ import { NotificationsProvider } from '@/src/context/NotificationsContext'
import { SafeToastProvider } from '@/src/theme/provider/toastProvider'
import { configureReanimatedLogger, ReanimatedLogLevel } from 'react-native-reanimated'
import { OnboardingHeader } from '@/src/features/Onboarding/components/OnboardingHeader'
import { install } from 'react-native-quick-crypto'
import { getDefaultScreenOptions } from '@/src/navigation/hooks/utils'
import { NavigationGuardHOC } from '@/src/navigation/NavigationGuardHOC'
import { StatusBar } from 'expo-status-bar'
import { TestCtrls } from '@/src/tests/e2e-maestro/components/TestCtrls'

install()

configureReanimatedLogger({
level: ReanimatedLogLevel.warn,
strict: false,
Expand Down
14 changes: 12 additions & 2 deletions apps/mobile/src/app/notifications-opt-in.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import React from 'react'
import React, { useCallback } from 'react'
import { useColorScheme } from 'react-native'
import { OptIn } from '@/src/components/OptIn'
import useNotifications from '@/src/hooks/useNotifications'
import { router, useFocusEffect } from 'expo-router'
import { useDelegateKey } from '../hooks/useDelegateKey'
import { useAuthGetNonceV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/auth'

function NotificationsOptIn() {
const { enableNotifications, isAppNotificationEnabled } = useNotifications()
const { data } = useAuthGetNonceV1Query()
const { createDelegate } = useDelegateKey()

const colorScheme = useColorScheme()

const toggleNotificationsOn = useCallback(async () => {
enableNotifications()
await createDelegate(data)
}, [data])

useFocusEffect(() => {
if (isAppNotificationEnabled) {
router.replace('/(tabs)')
Expand All @@ -27,7 +37,7 @@ function NotificationsOptIn() {
image={image}
isVisible
ctaButton={{
onPress: enableNotifications,
onPress: toggleNotificationsOn,
label: 'Enable notifications',
}}
secondaryButton={{
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const POLLING_INTERVAL = 15_000
export const GATEWAY_URL_PRODUCTION =
process.env.NEXT_PUBLIC_GATEWAY_URL_PRODUCTION || 'https://safe-client.safe.global'
export const GATEWAY_URL_STAGING = process.env.NEXT_PUBLIC_GATEWAY_URL_STAGING || 'https://safe-client.staging.5afe.dev'
export const GATEWAY_URL = isProduction ? GATEWAY_URL_PRODUCTION : GATEWAY_URL_STAGING
export const GATEWAY_URL = 'https://safe-client.staging.5afe.dev'
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess that we copy pasted here. The env vars should not be called NEXT_..., but EXPO or something. Can we use an env here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed. That was a copy/paste forgotten.


/**
* The version of the onboarding flow.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const Navbar = () => {
const router = useRouter()
const activeSafe = useDefinedActiveSafe()
const isAppNotificationEnabled = useAppSelector(selectAppNotificationStatus)

const handleNotificationAccess = () => {
if (!isAppNotificationEnabled) {
router.navigate('/notifications-opt-in')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ import React, { useCallback } from 'react'

import { useAppSelector, useAppDispatch } from '@/src/store/hooks'
import { selectAppNotificationStatus, toggleAppNotifications } from '@/src/store/notificationsSlice'
import { useDelegateKey } from '@/src/hooks/useDelegateKey'
import { NotificationView } from '@/src/features/Notifications/components/NotificationView'


export const NotificationsContainer = () => {
const dispatch = useAppDispatch()
const { deleteDelegate } = useDelegateKey()
const isAppNotificationEnabled = useAppSelector(selectAppNotificationStatus)

const handleToggleAppNotifications = useCallback(() => {
const handleToggleAppNotifications = useCallback(async () => {
dispatch(toggleAppNotifications(!isAppNotificationEnabled))
if (!isAppNotificationEnabled) {
await deleteDelegate()
Copy link
Contributor

Choose a reason for hiding this comment

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

what happens if for whatever reason this fails? Is the user still going to receive push notifications?

}
}, [isAppNotificationEnabled])

return <NotificationView onChange={handleToggleAppNotifications} value={isAppNotificationEnabled} />
Expand Down
127 changes: 127 additions & 0 deletions apps/mobile/src/hooks/useDelegateKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { useState, useCallback } from 'react'
import { Wallet } from 'ethers'

import { AuthNonce } from '@safe-global/store/gateway/AUTO_GENERATED/auth'
import { AddressInfo } from '@safe-global/store/gateway/AUTO_GENERATED/safes'

import Logger from '@/src/utils/logger'
import { Address } from '@/src/types/address'
import { selectActiveSafe } from '@/src/store/activeSafeSlice'
import { useAppDispatch, useAppSelector } from '@/src/store/hooks'
import { useSign } from './useSign'
import { useGTW } from './useGTW'

import { selectFCMToken } from '../store/notificationsSlice'
// import { selectSigners } from '../store/signersSlice'
import { addDelegatedAddress } from '../store/delegatedSlice'
import { useSiwe } from './useSiwe'
import { getSigner } from '../utils/notifications'

const ERROR_MSG = 'useDelegateKey: Something went wrong'

export enum DELEGATED_ACCOUNT_TYPE {
REGULAR = 'REGULAR',
OWNER = 'OWNER',
}

export function useDelegateKey(safeOwner?: AddressInfo) {
// Local states
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<unknown>(null)
const [delegatedAccountType, setDelegatedAccountType] = useState<DELEGATED_ACCOUNT_TYPE>()

// Custom hooks
const { getPrivateKey, storePrivateKey } = useSign()
const { createDelegatedKeyOnBackEnd, deleteDelegatedKeyOnBackEnd } = useGTW()
const { createSiweMessage } = useSiwe()
// Redux
const dispatch = useAppDispatch()
const activeSafe = useAppSelector(selectActiveSafe)

// const appSigners = useAppSelector(selectSigners)
const fcmToken = useAppSelector(selectFCMToken)

// Step 0 - Get the nonce to be included in the message to be sent to the backend
const createDelegate = useCallback(async (data: AuthNonce | undefined) => {
setLoading(true)
setError(null)

const nonce = data?.nonce
if (!activeSafe || !fcmToken || !nonce) {
throw Logger.info(ERROR_MSG)
}

try {
// Step 1 - Try to get the safe owner's private key from keychain
const safeOwnerPK = safeOwner && (await getPrivateKey(safeOwner.value))

const delegatedAccType = safeOwnerPK ? DELEGATED_ACCOUNT_TYPE.OWNER : DELEGATED_ACCOUNT_TYPE.REGULAR
setDelegatedAccountType(delegatedAccType)

// Step 2 - Create a new random (delegated) private key
const randomDelegatedAccount = Wallet.createRandom()

if (!randomDelegatedAccount) {
throw Logger.error(ERROR_MSG, error)
}

// Step 2.1 - Store the delegated account in the redux store
dispatch(
addDelegatedAddress({ delegatedAddress: randomDelegatedAccount.address as Address, safes: [activeSafe] }),
)

// Step 2.2 - Store it in the keychain
storePrivateKey(randomDelegatedAccount.address, randomDelegatedAccount.privateKey)

// Step 2.3 - Define the signer account
const signerAccount = getSigner(safeOwnerPK, randomDelegatedAccount)

// Step 3 - Create a message following the SIWE standard
const siweMessage = createSiweMessage({
address: signerAccount.address,
chainId: Number(activeSafe.chainId),
nonce,
statement: 'SafeWallet wants you to sign in with your Ethereum account',
})

// Step 4 - Triggers the backend to create the delegate
await createDelegatedKeyOnBackEnd({
safeAddress: activeSafe.address,
signer: signerAccount,
message: siweMessage,
chainId: activeSafe.chainId,
fcmToken,
delegatedAccount: randomDelegatedAccount,
delegatedAccountType: delegatedAccType,
})
} catch (err) {
Logger.error('useDelegateKey: Something went wrong', err)
setError(err)
return
} finally {
setLoading(false)
}
}, [])

const deleteDelegate = useCallback(async () => {
setLoading(true)
setError(null)
try {
await deleteDelegatedKeyOnBackEnd(activeSafe)
} catch (err) {
Logger.error('useDelegateKey: Something went wrong', err)
setError(err)
return
} finally {
setLoading(false)
}
}, [])

return {
loading,
error,
createDelegate,
deleteDelegate,
delegatedAccountType,
}
}
Loading
Loading