Skip to content

Commit

Permalink
feat(keylessBackup): add delete happy path (#4969)
Browse files Browse the repository at this point in the history
### Description

Adding in the happy path for deleting your cloud backup, assuming
nothing goes wrong.
https://linear.app/valora/issue/ACT-766/delete-backup-button-behavior-happy-path


[Screen_recording_20240227_114012.webm](https://github.com/valora-inc/wallet/assets/8432644/f2d3c8d7-e345-4477-8c24-948c9041c519)


### Test plan

Unit tests updated. large manual testing regimine coming soon

### Related issues


https://linear.app/valora/issue/ACT-766/delete-backup-button-behavior-happy-path

### Network scalability

n/a

---------

Co-authored-by: Tom McGuire <Mcgtom10@gmail.com>
  • Loading branch information
jh2oman and MuckT committed Feb 28, 2024
1 parent deca7b0 commit 7c749f7
Show file tree
Hide file tree
Showing 24 changed files with 460 additions and 146 deletions.
3 changes: 2 additions & 1 deletion locales/base/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -2130,5 +2130,6 @@
"title": "Congratulations 🎉",
"description": "{{rewardName}} has successfully been added to your {{appName}} wallet."
}
}
},
"pleaseWait": "Please wait"
}
21 changes: 21 additions & 0 deletions src/account/Settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
} from 'src/app/actions'
import { ErrorMessages } from 'src/app/ErrorMessages'
import { PRIVACY_LINK, TOS_LINK } from 'src/brandingConfig'
import { deleteKeylessBackupStarted } from 'src/keylessBackup/slice'
import { KeylessBackupDeleteStatus } from 'src/keylessBackup/types'
import { ensurePincode, navigate } from 'src/navigator/NavigationService'
import { Screens } from 'src/navigator/Screens'
import { removeStoredPin, setPincodeWithBiometry } from 'src/pincode/authentication'
Expand Down Expand Up @@ -328,6 +330,25 @@ describe('Account', () => {
expect(ValoraAnalytics.track).toHaveBeenLastCalledWith(
SettingsEvents.settings_delete_keyless_backup
)
expect(store.getActions()).toContainEqual(deleteKeylessBackupStarted())
})

it('shows keyless backup in progress when flag is enabled and backup is in progress', () => {
jest.mocked(getFeatureGate).mockReturnValue(true)
const store = createMockStore({
account: { cloudBackupCompleted: true },
keylessBackup: { deleteBackupStatus: KeylessBackupDeleteStatus.InProgress },
})
const { getByTestId, getByText } = render(
<Provider store={store}>
<Settings {...getMockStackScreenProps(Screens.Settings)} />
</Provider>
)
expect(getByTestId('KeylessBackup')).toBeTruthy()
expect(getByText('pleaseWait')).toBeTruthy()
fireEvent.press(getByTestId('KeylessBackup'))
expect(navigate).not.toHaveBeenCalled()
expect(ValoraAnalytics.track).not.toHaveBeenCalled()
})

it('can revoke the phone number successfully', async () => {
Expand Down
84 changes: 70 additions & 14 deletions src/account/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ import {
} from 'src/components/SettingsItem'
import { PRIVACY_LINK, TOS_LINK } from 'src/config'
import { currentLanguageSelector } from 'src/i18n/selectors'
import ForwardChevron from 'src/icons/ForwardChevron'
import LoadingSpinner from 'src/icons/LoadingSpinner'
import { deleteKeylessBackupStatusSelector } from 'src/keylessBackup/selectors'
import { deleteKeylessBackupStarted } from 'src/keylessBackup/slice'
import { KeylessBackupDeleteStatus } from 'src/keylessBackup/types'
import { getLocalCurrencyCode } from 'src/localCurrency/selectors'
import DrawerTopBar from 'src/navigator/DrawerTopBar'
import { ensurePincode, navigate } from 'src/navigator/NavigationService'
Expand Down Expand Up @@ -103,7 +108,7 @@ export const Account = ({ navigation, route }: Props) => {
const hapticFeedbackEnabled = useSelector(hapticFeedbackEnabledSelector)
const currentLanguage = useSelector(currentLanguageSelector)
const cloudBackupCompleted = useSelector(cloudBackupCompletedSelector)

const deleteKeylessBackupStatus = useSelector(deleteKeylessBackupStatusSelector)
const walletConnectEnabled = v2
const connectedApplications = sessions.length

Expand Down Expand Up @@ -316,7 +321,7 @@ export const Account = ({ navigation, route }: Props) => {

const onPressDeleteKeylessBackup = () => {
ValoraAnalytics.track(SettingsEvents.settings_delete_keyless_backup)
// TODO(ACT-766): invoke API to delete and update status
dispatch(deleteKeylessBackupStarted())
}

const wipeReduxStore = () => {
Expand Down Expand Up @@ -363,6 +368,62 @@ export const Account = ({ navigation, route }: Props) => {

const showKeylessBackup = getFeatureGate(StatsigFeatureGates.SHOW_CLOUD_ACCOUNT_BACKUP_SETUP)

const getKeylessBackupItem = () => {
if (!showKeylessBackup) {
return null
}
if (deleteKeylessBackupStatus === KeylessBackupDeleteStatus.InProgress) {
return (
<SettingsItemCta
title={t('keylessBackupSettingsTitle')}
onPress={() => {
// do nothing
}}
testID="KeylessBackup"
cta={
<>
<LoadingSpinner width={22} />
<Text testID={`KeylessBackup/cta`} style={styles.value}>
{t('pleaseWait')}
</Text>
</>
}
/>
)
} else if (cloudBackupCompleted) {
return (
<SettingsItemCta
title={t('keylessBackupSettingsTitle')}
onPress={onPressDeleteKeylessBackup}
testID="KeylessBackup"
cta={
<>
<Text testID={`KeylessBackup/cta`} style={styles.value}>
{t('delete')}
</Text>
</>
}
/>
)
} else {
return (
<SettingsItemCta
title={t('keylessBackupSettingsTitle')}
onPress={onPressSetUpKeylessBackup}
testID="KeylessBackup"
cta={
<>
<Text testID={`KeylessBackup/cta`} style={styles.value}>
{t('setup')}
</Text>
<ForwardChevron />
</>
}
/>
)
}
}

return (
<SafeAreaView style={styles.container}>
<DrawerTopBar />
Expand Down Expand Up @@ -407,18 +468,7 @@ export const Account = ({ navigation, route }: Props) => {
onPress={goToRecoveryPhrase}
testID="RecoveryPhrase"
/>
{showKeylessBackup && (
<SettingsItemCta
title={t('keylessBackupSettingsTitle')}
onPress={
cloudBackupCompleted ? onPressDeleteKeylessBackup : onPressSetUpKeylessBackup
}
testID="KeylessBackup"
ctaText={cloudBackupCompleted ? t('delete') : t('setup')}
ctaColor={cloudBackupCompleted ? colors.error : colors.primary}
showChevron={!cloudBackupCompleted}
/>
)}
{getKeylessBackupItem()}
<SettingsItemTextValue
title={t('changePin')}
onPress={goToChangePin}
Expand Down Expand Up @@ -568,6 +618,12 @@ const styles = StyleSheet.create({
bottomSheetButton: {
marginTop: Spacing.Regular16,
},
value: {
...fontStyles.regular,
color: colors.gray4,
marginRight: Spacing.Smallest8,
marginLeft: 4,
},
})

export default Account
8 changes: 7 additions & 1 deletion src/account/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { isE164NumberStrict } from '@celo/phone-utils'
import { Actions, ActionTypes } from 'src/account/actions'
import { Actions as AppActions, ActionTypes as AppActionTypes } from 'src/app/actions'
import { DEV_SETTINGS_ACTIVE_INITIALLY } from 'src/config'
import { keylessBackupCompleted } from 'src/keylessBackup/slice'
import { deleteKeylessBackupCompleted, keylessBackupCompleted } from 'src/keylessBackup/slice'
import { getRehydratePayload, REHYDRATE, RehydrateAction } from 'src/redux/persist-helper'
import Logger from 'src/utils/Logger'
import { Actions as Web3Actions, ActionTypes as Web3ActionTypes } from 'src/web3/actions'
Expand Down Expand Up @@ -112,6 +112,7 @@ export const reducer = (
| Web3ActionTypes
| AppActionTypes
| typeof keylessBackupCompleted
| typeof deleteKeylessBackupCompleted
): State => {
switch (action.type) {
case REHYDRATE: {
Expand Down Expand Up @@ -287,6 +288,11 @@ export const reducer = (
...state,
cloudBackupCompleted: true,
}
case deleteKeylessBackupCompleted.type:
return {
...state,
cloudBackupCompleted: false,
}
default:
return state
}
Expand Down
19 changes: 5 additions & 14 deletions src/components/SettingsItem.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { fireEvent, render } from '@testing-library/react-native'
import * as React from 'react'
import 'react-native'
import { View } from 'react-native'
import {
SettingsItemCta,
SettingsItemInput,
Expand Down Expand Up @@ -88,30 +89,20 @@ describe('SettingsItemInput', () => {
})

describe('SettingsItemCta', () => {
const mockComponent = <View testID="cta-test"></View>
it('renders correctly', () => {
const { getByTestId, getByText, queryByTestId } = render(
<SettingsItemCta testID={testID} title={title} ctaText="cta" />
)

expect(getByText(title)).toBeTruthy()
expect(getByTestId(`${testID}/cta`)).toHaveTextContent('cta')
expect(queryByTestId('ForwardChevron')).toBeNull()
})

it('renders correctly with forward chevron', () => {
const { getByTestId, getByText } = render(
<SettingsItemCta testID={testID} title={title} ctaText="cta" showChevron={true} />
<SettingsItemCta testID={testID} title={title} cta={mockComponent} />
)

expect(getByText(title)).toBeTruthy()
expect(getByTestId(`${testID}/cta`)).toHaveTextContent('cta')
expect(getByTestId('ForwardChevron')).toBeTruthy()
expect(getByTestId(`cta-test`)).toBeTruthy()
})

it('reacts on press', () => {
const onPress = jest.fn()
const { getByTestId } = render(
<SettingsItemCta testID={testID} title={title} ctaText="cta" onPress={onPress} />
<SettingsItemCta testID={testID} title={title} cta={mockComponent} onPress={onPress} />
)

fireEvent.press(getByTestId(testID))
Expand Down
23 changes: 3 additions & 20 deletions src/components/SettingsItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,32 +159,15 @@ export function SettingsItemInput({
}

type SettingsItemCtaProps = {
ctaText: string
ctaColor?: colors
showChevron?: boolean
cta: JSX.Element
} & BaseProps

export function SettingsItemCta({
testID,
title,
showChevron,
onPress,
ctaText,
ctaColor,
}: SettingsItemCtaProps) {
export function SettingsItemCta({ testID, title, cta, onPress }: SettingsItemCtaProps) {
return (
<Wrapper testID={testID} onPress={onPress}>
<View style={styles.container}>
<Title value={title} />
<View style={styles.right}>
<Text
testID={testID ? `${testID}/cta` : `${title}/cta`}
style={[styles.value, { color: ctaColor }]}
>
{ctaText}
</Text>
{showChevron && <ForwardChevron color={ctaColor} />}
</View>
<View style={styles.right}>{cta}</View>
</View>
</Wrapper>
)
Expand Down
18 changes: 10 additions & 8 deletions src/keylessBackup/encryption.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import * as secp from '@noble/secp256k1'
import { fromBytes, fromHex } from 'viem'
import {
decryptPassphrase,
deriveKeyFromKeyShares,
encryptPassphrase,
getSecp256K1KeyPair,
getWalletAddressFromPrivateKey,
} from './encryption'
import * as secp from '@noble/secp256k1'

describe("Encryption utilities using Node's crypto and futoin-hkdf", () => {
let keyshare1: Buffer
Expand Down Expand Up @@ -53,15 +54,13 @@ describe("Encryption utilities using Node's crypto and futoin-hkdf", () => {
describe('getSecp256K1KeyPair', () => {
it('should produce a valid secp256k1 key pair', async () => {
const { privateKey, publicKey } = getSecp256K1KeyPair(keyshare1, keyshare2)
expect(privateKey).toBeInstanceOf(Uint8Array)
expect(privateKey.byteLength).toEqual(32)
expect(publicKey).toBeInstanceOf(Uint8Array)
expect(publicKey.byteLength).toEqual(33)
expect(fromHex(privateKey, 'bytes').byteLength).toEqual(32)
expect(fromHex(publicKey, 'bytes').byteLength).toEqual(33)

// able to sign with private key and verify with public key
const messageHash = await secp.utils.sha256(new TextEncoder().encode('hello world'))
const signature = await secp.sign(messageHash, privateKey)
expect(secp.verify(signature, messageHash, publicKey)).toBe(true)
const signature = await secp.sign(messageHash, fromHex(privateKey, 'bytes'))
expect(secp.verify(signature, messageHash, fromHex(publicKey, 'bytes'))).toBe(true)
})

it('gives same result when called twice with same inputs', async () => {
Expand Down Expand Up @@ -119,7 +118,10 @@ describe("Encryption utilities using Node's crypto and futoin-hkdf", () => {
it('gives lowercase wallet address associated with private key', () => {
expect(
getWalletAddressFromPrivateKey(
Buffer.from('0da7744e59ab530ebaa3ca5c6e67170fd18276fb1e093ba2eaa48f1d5756ffcb', 'hex')
fromBytes(
Buffer.from('0da7744e59ab530ebaa3ca5c6e67170fd18276fb1e093ba2eaa48f1d5756ffcb', 'hex'),
'hex'
)
)
).toBe('0xbdde6c4f63a50b23c8bd8409fe4d9cfb33c619de')
})
Expand Down
10 changes: 5 additions & 5 deletions src/keylessBackup/encryption.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as secp from '@noble/secp256k1'
import crypto from 'crypto'
import hkdf from 'futoin-hkdf'
import { fromBytes } from 'viem'
import { Hex, fromBytes } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'

/**
Expand Down Expand Up @@ -51,15 +51,15 @@ export function deriveKeyFromKeyShares(
export function getSecp256K1KeyPair(
keyshare1: Buffer,
keyshare2: Buffer
): { privateKey: Uint8Array; publicKey: Uint8Array } {
): { privateKey: Hex; publicKey: Hex } {
const derivedKey = deriveKeyFromKeyShares(keyshare1, keyshare2, 48) // 40 is the minimum for hashToPrivateKey
const privateKey = secp.utils.hashToPrivateKey(derivedKey)
const publicKey = secp.getPublicKey(privateKey, true)
return { privateKey, publicKey }
return { privateKey: fromBytes(privateKey, 'hex'), publicKey: fromBytes(publicKey, 'hex') }
}

export function getWalletAddressFromPrivateKey(privateKey: Uint8Array) {
return privateKeyToAccount(fromBytes(privateKey, 'hex')).address.toLowerCase()
export function getWalletAddressFromPrivateKey(privateKey: Hex) {
return privateKeyToAccount(privateKey).address.toLowerCase()
}

/**
Expand Down

0 comments on commit 7c749f7

Please sign in to comment.