Skip to content

Commit

Permalink
feat: prevent actions that require private key in demo mode (#6469)
Browse files Browse the repository at this point in the history
### Description

Unfortunately not all flows that access the private key go through the
same functions, so this PR adds a new blocking bottom sheet that is a
screen and navigates to it from:
1. `ensurePincode` - this is used from the flows that launch CAB / reset
pin / view recovery phrase
2. `sendPreparedTransaction` - this is used from all the transaction
flows, except for walletconnect
3. walletconnect saga, where we display the action request.

There is often UI that awaits the outcome of the transaction send flows
(spinners, navigation etc.) so I am returning the same result as if a
user dismissed the pincode screen during the flow.

### Test plan

Tested all flows to ensure the bottom sheet is correctly triggered, and
no extra errors are displayed.


https://github.com/user-attachments/assets/f376d1a7-a62c-43fa-b8a3-c598f81711d8



https://github.com/user-attachments/assets/eb817506-1cc1-4a8f-91c0-f9f3560e1455



https://github.com/user-attachments/assets/7bcdf0ee-00d9-465c-89c7-41f613669440



https://github.com/user-attachments/assets/5fbedfcd-7e2d-4b00-b61f-cec38788eb75



https://github.com/user-attachments/assets/55d26718-fa74-4406-a02f-257fdbc293c9


### Related issues

- Fixes RET-1304

### Backwards compatibility

Y

### Network scalability

If a new NetworkId and/or Network are added in the future, the changes
in this PR will:

- [ ] Continue to work without code changes, OR trigger a compilation
error (guaranteeing we find it when a new network is added)
  • Loading branch information
kathaypacific authored Feb 4, 2025
1 parent 1b52dd5 commit 60ecc4e
Show file tree
Hide file tree
Showing 14 changed files with 171 additions and 14 deletions.
5 changes: 5 additions & 0 deletions locales/base/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -2869,6 +2869,11 @@
"info": "You are currently viewing the app in demo mode",
"cta": "Exit Demo"
},
"restrictedAccess": {
"title": "In Demo Mode",
"info": "You can't complete this action because you are viewing the app in demo mode",
"cta": "Exit Demo"
},
"inAppIndicatorLabel": "Demo Mode"
}
}
60 changes: 60 additions & 0 deletions src/navigator/DemoModeAuthBlock.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { fireEvent, render } from '@testing-library/react-native'
import React from 'react'
import { Provider } from 'react-redux'
import DemoModeAuthBlock from 'src/navigator/DemoModeAuthBlock'
import { navigateBack, navigateClearingStack } from 'src/navigator/NavigationService'
import { demoModeToggled } from 'src/web3/actions'
import { createMockStore } from 'test/utils'
import { mockAccount } from 'test/values'

jest.mock('src/statsig')

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

it('renders correctly and executes the expected actions on button press', () => {
const store = createMockStore({
web3: {
account: mockAccount,
},
})
const { getByText } = render(
<Provider store={store}>
<DemoModeAuthBlock />
</Provider>
)

expect(getByText('demoMode.restrictedAccess.title')).toBeTruthy()
expect(getByText('demoMode.restrictedAccess.info')).toBeTruthy()

fireEvent.press(getByText('demoMode.restrictedAccess.cta'))

expect(store.getActions()).toEqual([demoModeToggled(false)])
expect(navigateBack).toHaveBeenCalledTimes(1)
expect(navigateClearingStack).not.toHaveBeenCalled()

fireEvent.press(getByText('dismiss'))
expect(navigateBack).toHaveBeenCalledTimes(2)
})

it('navigates to onboarding when exiting demo mode', () => {
const store = createMockStore({
web3: {
account: null, // no wallet set up
},
})
const { getByText } = render(
<Provider store={store}>
<DemoModeAuthBlock />
</Provider>
)

fireEvent.press(getByText('demoMode.restrictedAccess.cta'))

expect(store.getActions()).toEqual([demoModeToggled(false)])
expect(navigateBack).toHaveBeenCalled()
expect(navigateClearingStack).toHaveBeenCalled()
})
})
61 changes: 61 additions & 0 deletions src/navigator/DemoModeAuthBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text } from 'react-native'
import BottomSheetScrollView from 'src/components/BottomSheetScrollView'
import Button, { BtnSizes, BtnTypes } from 'src/components/Button'
import { navigateBack, navigateClearingStack } from 'src/navigator/NavigationService'
import { Screens } from 'src/navigator/Screens'
import { useDispatch, useSelector } from 'src/redux/hooks'
import { typeScale } from 'src/styles/fonts'
import { Spacing } from 'src/styles/styles'
import { demoModeToggled } from 'src/web3/actions'
import { rawWalletAddressSelector } from 'src/web3/selectors'

export default function DemoModeAuthBlock() {
const { t } = useTranslation()
const dispatch = useDispatch()
const originalWalletAddress = useSelector(rawWalletAddressSelector)

const handleExitDemoMode = () => {
dispatch(demoModeToggled(false))
navigateBack() // dismiss the bottom sheet

if (!originalWalletAddress) {
navigateClearingStack(Screens.Welcome)
}
}

return (
<BottomSheetScrollView>
<Text style={styles.title}>{t('demoMode.restrictedAccess.title')}</Text>
<Text style={styles.description}>{t('demoMode.restrictedAccess.info')}</Text>
<Button
style={styles.demoModeButton}
onPress={handleExitDemoMode}
text={t('demoMode.restrictedAccess.cta')}
type={BtnTypes.SECONDARY}
size={BtnSizes.FULL}
/>
<Button
style={styles.demoModeButton}
onPress={navigateBack}
text={t('dismiss')}
size={BtnSizes.FULL}
/>
</BottomSheetScrollView>
)
}

const styles = StyleSheet.create({
title: {
...typeScale.titleSmall,
marginBottom: Spacing.Regular16,
},
description: {
...typeScale.bodySmall,
marginBottom: Spacing.Small12,
},
demoModeButton: {
marginTop: Spacing.Small12,
},
})
9 changes: 8 additions & 1 deletion src/navigator/NavigationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { createRef, MutableRefObject } from 'react'
import { Platform } from 'react-native'
import { PincodeType } from 'src/account/reducer'
import { pincodeTypeSelector } from 'src/account/selectors'
import { AuthenticationEvents, NavigationEvents, OnboardingEvents } from 'src/analytics/Events'
import AppAnalytics from 'src/analytics/AppAnalytics'
import { AuthenticationEvents, NavigationEvents, OnboardingEvents } from 'src/analytics/Events'
import { Screens } from 'src/navigator/Screens'
import { StackParamList } from 'src/navigator/types'
import {
Expand All @@ -23,6 +23,7 @@ import { isUserCancelledError } from 'src/storage/keychain'
import { ensureError } from 'src/utils/ensureError'
import Logger from 'src/utils/Logger'
import { sleep } from 'src/utils/sleep'
import { demoModeEnabledSelector } from 'src/web3/selectors'

const TAG = 'NavigationService'

Expand Down Expand Up @@ -134,6 +135,12 @@ export const navigateClearingStack: SafeNavigate = (...args) => {
}

export async function ensurePincode(): Promise<boolean> {
const demoModeEnabled = demoModeEnabledSelector(store.getState())
if (demoModeEnabled) {
navigate(Screens.DemoModeAuthBlock)
return false
}

AppAnalytics.track(AuthenticationEvents.get_pincode_start)
const pincodeType = pincodeTypeSelector(store.getState())

Expand Down
2 changes: 2 additions & 0 deletions src/navigator/Navigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import WalletSecurityPrimer from 'src/keylessBackup/WalletSecurityPrimer'
import { KeylessBackupFlow, KeylessBackupOrigin } from 'src/keylessBackup/types'
import Language from 'src/language/Language'
import SelectLocalCurrency from 'src/localCurrency/SelectLocalCurrency'
import DemoModeAuthBlock from 'src/navigator/DemoModeAuthBlock'
import {
emptyHeader,
headerWithBackButton,
Expand Down Expand Up @@ -711,6 +712,7 @@ function nativeBottomSheets(BottomSheet: typeof RootStack) {
name={Screens.FiatExchangeCurrencyBottomSheet}
component={FiatExchangeCurrencyBottomSheet}
/>
<BottomSheet.Screen name={Screens.DemoModeAuthBlock} component={DemoModeAuthBlock} />
</>
)
}
Expand Down
9 changes: 7 additions & 2 deletions src/navigator/NavigatorWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { DynamicConfigs } from 'src/statsig/constants'
import { StatsigDynamicConfigs } from 'src/statsig/types'
import appTheme from 'src/styles/appTheme'
import Colors from 'src/styles/colors'
import variables from 'src/styles/variables'
import Logger from 'src/utils/Logger'
import { userInSanctionedCountrySelector } from 'src/utils/countryFeatures'
import { isVersionBelowMinimum } from 'src/utils/versionCheck'
Expand Down Expand Up @@ -185,7 +186,12 @@ export const NavigatorWrapper = () => {
angle={90}
style={styles.linearGradientBackground}
>
<View style={[styles.container, { borderWidth: demoModeEnabled ? 3 : 0 }]}>
<View
style={[
styles.container,
{ margin: demoModeEnabled ? variables.demoModeBorderWidth : 0 },
]}
>
<Navigator />
<HooksPreviewModeBanner />
{(appLocked || updateRequired) && (
Expand All @@ -208,7 +214,6 @@ const styles = StyleSheet.create({
flexDirection: 'column',
alignItems: 'stretch',
justifyContent: 'flex-start',
borderColor: 'transparent',
},
linearGradientBackground: {
flex: 1,
Expand Down
1 change: 1 addition & 0 deletions src/navigator/Screens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export enum Screens {
DappShortcutTransactionRequest = 'DappShortcutTransactionRequest',
DappsScreen = 'DappsScreen',
DebugImages = 'DebugImages',
DemoModeAuthBlock = 'DemoModeAuthBlock',
EarnInfoScreen = 'EarnInfoScreen',
EarnEnterAmount = 'EarnEnterAmount',
EarnConfirmationScreen = 'EarnConfirmationScreen',
Expand Down
1 change: 1 addition & 0 deletions src/navigator/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export type StackParamList = {
}
[Screens.DappsScreen]: undefined
[Screens.DebugImages]: undefined
[Screens.DemoModeAuthBlock]: undefined
[Screens.EarnInfoScreen]: undefined
[Screens.EarnEnterAmount]: {
pool: EarnPosition
Expand Down
5 changes: 4 additions & 1 deletion src/send/EnterAmountOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import Animated, {
withTiming,
} from 'react-native-reanimated'
import Touchable from 'src/components/Touchable'
import { useSelector } from 'src/redux/hooks'
import Colors from 'src/styles/colors'
import { typeScale } from 'src/styles/fonts'
import { Spacing } from 'src/styles/styles'
import variables from 'src/styles/variables'
import { demoModeEnabledSelector } from 'src/web3/selectors'

export default function EnterAmountOptions({
onPressAmount,
Expand All @@ -25,6 +27,7 @@ export default function EnterAmountOptions({
const { t } = useTranslation()
const translateY = useSharedValue(0)
const [isVisible, setIsVisible] = useState(false)
const demoModeEnabled = useSelector(demoModeEnabledSelector)

const amountOptions = useMemo(() => {
return [
Expand Down Expand Up @@ -90,7 +93,7 @@ export default function EnterAmountOptions({
styles.container,
{
position: 'absolute',
bottom: 0,
bottom: demoModeEnabled ? -variables.demoModeBorderWidth : 0,
width: variables.width,
},
animatedStyle,
Expand Down
1 change: 1 addition & 0 deletions src/styles/variables.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export default {
footerHeight: 60,
fontSizeBase: 15,
iconHitslop,
demoModeBorderWidth: 3,
}
10 changes: 1 addition & 9 deletions src/swap/saga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,6 @@ import { safely } from 'src/utils/safely'
import { publicClient } from 'src/viem'
import { getPreparedTransactions } from 'src/viem/preparedTransactionSerialization'
import { sendPreparedTransactions } from 'src/viem/saga'
import { getViemWallet } from 'src/web3/contracts'
import networkConfig from 'src/web3/networkConfig'
import { getNetworkFromNetworkId } from 'src/web3/utils'
import { call, put, select, takeEvery } from 'typed-redux-saga'
import { decodeFunctionData, erc20Abi } from 'viem'
Expand Down Expand Up @@ -167,12 +165,6 @@ export function* swapSubmitSaga(action: PayloadAction<SwapInfo>) {
throw new Error('Unknown token network')
}

const wallet = yield* call(getViemWallet, networkConfig.viemChain[network])
if (!wallet.account) {
// this should never happen
throw new Error('no account found in the wallet')
}

for (const tx of preparedTransactions) {
trackedTxs.push({
tx,
Expand All @@ -182,7 +174,7 @@ export function* swapSubmitSaga(action: PayloadAction<SwapInfo>) {
}

// Execute transaction(s)
Logger.debug(TAG, `Starting to swap execute for address: ${wallet.account.address}`)
Logger.debug(TAG, `Starting to execute swap for swapId ${swapId}`)

const beforeSwapExecutionTimestamp = Date.now()
quoteToTransactionElapsedTimeInMs = beforeSwapExecutionTimestamp - quoteReceivedAt
Expand Down
10 changes: 10 additions & 0 deletions src/viem/saga.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { navigate } from 'src/navigator/NavigationService'
import { Screens } from 'src/navigator/Screens'
import { CANCELLED_PIN_INPUT } from 'src/pincode/authentication'
import { tokensByIdSelector } from 'src/tokens/selectors'
import { BaseStandbyTransaction, addStandbyTransaction } from 'src/transactions/slice'
import { NetworkId } from 'src/transactions/types'
Expand All @@ -10,6 +13,7 @@ import {
import { getViemWallet } from 'src/web3/contracts'
import networkConfig from 'src/web3/networkConfig'
import { getConnectedUnlockedAccount } from 'src/web3/saga'
import { demoModeEnabledSelector } from 'src/web3/selectors'
import { getNetworkFromNetworkId } from 'src/web3/utils'
import { call, put, select } from 'typed-redux-saga'
import { Hash } from 'viem'
Expand Down Expand Up @@ -41,6 +45,12 @@ export function* sendPreparedTransactions(
) => BaseStandbyTransaction | null)[],
isGasSubsidized: boolean = false
) {
const demoModeEnabled = yield* select(demoModeEnabledSelector)
if (demoModeEnabled) {
navigate(Screens.DemoModeAuthBlock)
throw CANCELLED_PIN_INPUT
}

if (serializablePreparedTransactions.length !== createBaseStandbyTransactions.length) {
throw new Error('Mismatch in number of prepared transactions and standby transaction creators')
}
Expand Down
9 changes: 8 additions & 1 deletion src/walletConnect/saga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ import networkConfig, {
walletConnectChainIdToNetworkId,
} from 'src/web3/networkConfig'
import { getWalletAddress } from 'src/web3/saga'
import { walletAddressSelector } from 'src/web3/selectors'
import { demoModeEnabledSelector, walletAddressSelector } from 'src/web3/selectors'
import {
call,
delay,
Expand Down Expand Up @@ -429,6 +429,13 @@ function* showActionRequest(request: Web3WalletTypes.EventArguments['session_req
throw new Error('missing client')
}

const demoModeEnabled = yield* select(demoModeEnabledSelector)
if (demoModeEnabled) {
navigate(Screens.DemoModeAuthBlock)
yield* put(denyRequest(request, getSdkError('USER_REJECTED')))
return
}

const method = request.params.request.method
if (!isSupportedAction(method)) {
// Directly deny unsupported requests
Expand Down
2 changes: 2 additions & 0 deletions test/RootStateSchema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3691,6 +3691,7 @@
"DappShortcutsRewards",
"DappsScreen",
"DebugImages",
"DemoModeAuthBlock",
"EarnConfirmationScreen",
"EarnEnterAmount",
"EarnHome",
Expand Down Expand Up @@ -6010,6 +6011,7 @@
"DappShortcutsRewards",
"DappsScreen",
"DebugImages",
"DemoModeAuthBlock",
"EarnConfirmationScreen",
"EarnEnterAmount",
"EarnHome",
Expand Down

0 comments on commit 60ecc4e

Please # to comment.