From 2d0e9d187b8972abcd85e82df0bc4a6895dfc2ae Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Thu, 7 Nov 2024 19:48:39 -0800 Subject: [PATCH] Simplify onboarding (#231) * Remove steps from onboarding * add origin path * remove 12 toggle * clearing wallet height on chain id change * Add better checks for remove in storage * Review updates --- .github/ISSUE_TEMPLATE/deployment.md | 1 - .../src/hooks/latest-block-height.test.ts | 67 +++++++++++++++++ .../src/hooks/latest-block-height.ts | 37 ++++------ apps/extension/src/routes/page/index.tsx | 8 -- .../routes/page/onboarding/confirm-backup.tsx | 5 +- .../correct-wallet-birthday.test.ts | 46 ------------ .../page/onboarding/default-frontend.tsx | 33 --------- .../src/routes/page/onboarding/generate.tsx | 45 +---------- .../src/routes/page/onboarding/height.tsx | 62 ---------------- .../src/routes/page/onboarding/import.tsx | 5 +- .../routes/page/onboarding/password/hooks.ts | 35 +++++++++ .../{set-password.tsx => password/index.tsx} | 38 +++++----- .../routes/page/onboarding/password/types.ts | 8 ++ .../routes/page/onboarding/password/utils.ts | 65 ++++++++++++++++ .../src/routes/page/onboarding/routes.tsx | 24 +----- .../page/onboarding/set-grpc-endpoint.tsx | 70 ------------------ apps/extension/src/routes/page/paths.ts | 7 -- .../routes/page/restore-password/index.tsx | 10 --- .../restore-password/restore-password.tsx | 48 ------------ .../routes/page/restore-password/routes.tsx | 14 ---- .../page/restore-password/set-password.tsx | 74 ------------------- apps/extension/src/routes/page/router.tsx | 7 -- apps/extension/src/routes/popup/login.tsx | 2 +- .../use-grpc-endpoint-form.ts | 7 +- apps/extension/src/state/network.ts | 4 + .../src/storage/base-defaults.test.ts | 30 ++++++++ apps/extension/src/storage/base.ts | 15 +++- 27 files changed, 271 insertions(+), 496 deletions(-) create mode 100644 apps/extension/src/hooks/latest-block-height.test.ts delete mode 100644 apps/extension/src/routes/page/onboarding/correct-wallet-birthday.test.ts delete mode 100644 apps/extension/src/routes/page/onboarding/default-frontend.tsx delete mode 100644 apps/extension/src/routes/page/onboarding/height.tsx create mode 100644 apps/extension/src/routes/page/onboarding/password/hooks.ts rename apps/extension/src/routes/page/onboarding/{set-password.tsx => password/index.tsx} (67%) create mode 100644 apps/extension/src/routes/page/onboarding/password/types.ts create mode 100644 apps/extension/src/routes/page/onboarding/password/utils.ts delete mode 100644 apps/extension/src/routes/page/onboarding/set-grpc-endpoint.tsx delete mode 100644 apps/extension/src/routes/page/restore-password/index.tsx delete mode 100644 apps/extension/src/routes/page/restore-password/restore-password.tsx delete mode 100644 apps/extension/src/routes/page/restore-password/routes.tsx delete mode 100644 apps/extension/src/routes/page/restore-password/set-password.tsx diff --git a/.github/ISSUE_TEMPLATE/deployment.md b/.github/ISSUE_TEMPLATE/deployment.md index 6454252c..cd2333a7 100644 --- a/.github/ISSUE_TEMPLATE/deployment.md +++ b/.github/ISSUE_TEMPLATE/deployment.md @@ -53,7 +53,6 @@ Manual testing to confirm extension works with all flows. Can use mainnet rpc or - Lock screen - [ ] Entering correct password grants entry - [ ] Entering incorrect password denies entry and gives warning - - [ ] Forgot password flow allows to reset wallet state - [ ] Penumbra support link correctly links to discord - [ ] Previous wallet version upgrade. Ways to test: - In dev env, have previous version loaded (via `load unpacked`) and click `window -> extensions -> update` after re-building new code. diff --git a/apps/extension/src/hooks/latest-block-height.test.ts b/apps/extension/src/hooks/latest-block-height.test.ts new file mode 100644 index 00000000..c023ead1 --- /dev/null +++ b/apps/extension/src/hooks/latest-block-height.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { Code, ConnectError, createRouterTransport } from '@connectrpc/connect'; +import { TendermintProxyService } from '@penumbra-zone/protobuf/penumbra/util/tendermint_proxy/v1/tendermint_proxy_connect'; +import { fetchBlockHeightWithFallback } from './latest-block-height'; +import { GetStatusResponse } from '@penumbra-zone/protobuf/penumbra/util/tendermint_proxy/v1/tendermint_proxy_pb'; + +const endpoints = ['rpc1.example.com', 'rpc2.example.com', 'rpc3.example.com']; + +const getMock = (fn: () => GetStatusResponse) => { + return createRouterTransport(router => { + router.service(TendermintProxyService, { + getStatus() { + return fn(); + }, + }); + }); +}; + +describe('fetchBlockHeightWithFallback', () => { + it('should fetch block height successfully from the first endpoint', async () => { + const mockTransport = getMock( + () => new GetStatusResponse({ syncInfo: { latestBlockHeight: 800n } }), + ); + const result = await fetchBlockHeightWithFallback(endpoints, mockTransport); + expect(result.blockHeight).toEqual(800); + expect(endpoints.includes(result.rpc)).toBeTruthy(); + }); + + it('should fallback to the second endpoint if the first fails', async () => { + let called = false; + const mockTransport = getMock(() => { + if (!called) { + called = true; + throw new ConnectError('Error calling service', Code.Unknown); + } + return new GetStatusResponse({ syncInfo: { latestBlockHeight: 800n } }); + }); + const result = await fetchBlockHeightWithFallback(endpoints, mockTransport); + expect(result.blockHeight).toEqual(800); + expect(endpoints.includes(result.rpc)).toBeTruthy(); + expect(called).toBeTruthy(); + }); + + it('should fallback through all endpoints and throw an error if all fail', async () => { + let timesCalled = 0; + const mockTransport = getMock(() => { + timesCalled++; + throw new ConnectError('Error calling service', Code.Unknown); + }); + await expect(() => fetchBlockHeightWithFallback(endpoints, mockTransport)).rejects.toThrow( + new Error('All RPC endpoints failed to fetch the block height.'), + ); + expect(timesCalled).toEqual(3); + }); + + it('should throw an error immediately if the endpoints array is empty', async () => { + let timesCalled = 0; + const mockTransport = getMock(() => { + timesCalled++; + throw new ConnectError('Error calling service', Code.Unknown); + }); + await expect(() => fetchBlockHeightWithFallback([], mockTransport)).rejects.toThrow( + new Error('All RPC endpoints failed to fetch the block height.'), + ); + expect(timesCalled).toEqual(0); + }); +}); diff --git a/apps/extension/src/hooks/latest-block-height.ts b/apps/extension/src/hooks/latest-block-height.ts index a170c2da..04abc2fc 100644 --- a/apps/extension/src/hooks/latest-block-height.ts +++ b/apps/extension/src/hooks/latest-block-height.ts @@ -1,9 +1,8 @@ import { useQuery } from '@tanstack/react-query'; import { sample } from 'lodash'; -import { createPromiseClient } from '@connectrpc/connect'; +import { createPromiseClient, Transport } from '@connectrpc/connect'; import { createGrpcWebTransport } from '@connectrpc/connect-web'; import { TendermintProxyService } from '@penumbra-zone/protobuf'; -import { ChainRegistryClient } from '@penumbra-labs/registry'; import { useStore } from '../state'; import { networkSelector } from '../state/network'; @@ -11,7 +10,11 @@ import { networkSelector } from '../state/network'; // from the chain registry, using a recursive callback to try another endpoint if the current // one fails. Additionally, this implements a timeout mechanism at the request level to avoid // hanging from stalled requests. -const fetchBlockHeightWithFallback = async (endpoints: string[]): Promise => { + +export const fetchBlockHeightWithFallback = async ( + endpoints: string[], + transport?: Transport, // Deps injection mostly for unit tests +): Promise<{ blockHeight: number; rpc: string }> => { if (endpoints.length === 0) { throw new Error('All RPC endpoints failed to fetch the block height.'); } @@ -23,24 +26,23 @@ const fetchBlockHeightWithFallback = async (endpoints: string[]): Promise endpoint !== randomGrpcEndpoint); - return fetchBlockHeightWithFallback(remainingEndpoints); + return fetchBlockHeightWithFallback(remainingEndpoints, transport); } }; -// Fetch the block height from a specific RPC endpoint with a request-level timeout that superceeds +// Fetch the block height from a specific RPC endpoint with a request-level timeout that supersedes // the channel transport-level timeout to prevent hanging requests. export const fetchBlockHeightWithTimeout = async ( grpcEndpoint: string, - timeoutMs = 5000, + transport = createGrpcWebTransport({ baseUrl: grpcEndpoint }), + timeoutMs = 3000, ): Promise => { - const tendermintClient = createPromiseClient( - TendermintProxyService, - createGrpcWebTransport({ baseUrl: grpcEndpoint }), - ); + const tendermintClient = createPromiseClient(TendermintProxyService, transport); const result = await tendermintClient.getStatus({}, { signal: AbortSignal.timeout(timeoutMs) }); if (!result.syncInfo) { @@ -63,19 +65,6 @@ export const fetchBlockHeight = async (grpcEndpoint: string): Promise => return Number(result.syncInfo.latestBlockHeight); }; -export const useLatestBlockHeightWithFallback = () => { - return useQuery({ - queryKey: ['latestBlockHeightWithFallback'], - queryFn: async () => { - const chainRegistryClient = new ChainRegistryClient(); - const { rpcs } = chainRegistryClient.bundled.globals(); - const suggestedEndpoints = rpcs.map(i => i.url); - return await fetchBlockHeightWithFallback(suggestedEndpoints); - }, - retry: false, - }); -}; - export const useLatestBlockHeight = () => { const { grpcEndpoint } = useStore(networkSelector); diff --git a/apps/extension/src/routes/page/index.tsx b/apps/extension/src/routes/page/index.tsx index f07b3e4e..61fc71bf 100644 --- a/apps/extension/src/routes/page/index.tsx +++ b/apps/extension/src/routes/page/index.tsx @@ -11,18 +11,10 @@ import { getDefaultFrontend } from '../../state/default-frontend'; // Will redirect to onboarding if necessary. export const pageIndexLoader = async () => { const wallets = await localExtStorage.get('wallets'); - const grpcEndpoint = await localExtStorage.get('grpcEndpoint'); - const frontendUrl = await localExtStorage.get('frontendUrl'); if (!wallets.length) { return redirect(PagePath.WELCOME); } - if (!grpcEndpoint) { - return redirect(PagePath.SET_GRPC_ENDPOINT); - } - if (!frontendUrl) { - return redirect(PagePath.SET_DEFAULT_FRONTEND); - } return null; }; diff --git a/apps/extension/src/routes/page/onboarding/confirm-backup.tsx b/apps/extension/src/routes/page/onboarding/confirm-backup.tsx index ebb57112..64fb07fa 100644 --- a/apps/extension/src/routes/page/onboarding/confirm-backup.tsx +++ b/apps/extension/src/routes/page/onboarding/confirm-backup.tsx @@ -13,7 +13,8 @@ import { Input } from '@repo/ui/components/ui/input'; import { useStore } from '../../../state'; import { generateSelector } from '../../../state/seed-phrase/generate'; import { usePageNav } from '../../../utils/navigate'; -import { PagePath } from '../paths'; +import { navigateToPasswordPage } from './password/utils'; +import { SEED_PHRASE_ORIGIN } from './password/types'; export const ConfirmBackup = () => { const navigate = usePageNav(); @@ -38,7 +39,7 @@ export const ConfirmBackup = () => { diff --git a/apps/extension/src/routes/page/onboarding/correct-wallet-birthday.test.ts b/apps/extension/src/routes/page/onboarding/correct-wallet-birthday.test.ts deleted file mode 100644 index 8d2917da..00000000 --- a/apps/extension/src/routes/page/onboarding/correct-wallet-birthday.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { adjustWalletBirthday } from './set-grpc-endpoint'; -import { localExtStorage } from '../../../storage/local'; - -describe('correctBirthdayHeightIfNeeded', () => { - it('should update the wallet birthday if the users wallet birthday is greater than the chain height', async () => { - const mockSet = vi.fn(); - vi.spyOn(localExtStorage, 'set').mockImplementation(mockSet); - await adjustWalletBirthday(1000, 900n); - - expect(mockSet).toHaveBeenCalledWith('walletCreationBlockHeight', 900); - }); - - it('should not update the wallet birthday if the users wallet birthday is less than the chain height', async () => { - const mockSet = vi.fn(); - vi.spyOn(localExtStorage, 'set').mockImplementation(mockSet); - await adjustWalletBirthday(900, 1000n); - - expect(mockSet).not.toHaveBeenCalled(); - }); - - it('should not update the wallet birthday if the users wallet birthday is equal to the chain height', async () => { - const mockSet = vi.fn(); - vi.spyOn(localExtStorage, 'set').mockImplementation(mockSet); - await adjustWalletBirthday(900, 900n); - - expect(mockSet).not.toHaveBeenCalled(); - }); - - it('should not update the wallet birthday if the latestBlockHeight is undefined', async () => { - const mockSet = vi.fn(); - vi.spyOn(localExtStorage, 'set').mockImplementation(mockSet); - await adjustWalletBirthday(900, undefined); - - expect(mockSet).not.toHaveBeenCalled(); - }); - - it('should not update if the wallet birthday is zero or negative', async () => { - const mockSet = vi.spyOn(localExtStorage, 'set').mockImplementation(() => Promise.resolve()); - - await adjustWalletBirthday(0, 900n); - await adjustWalletBirthday(-100, 900n); - - expect(mockSet).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/extension/src/routes/page/onboarding/default-frontend.tsx b/apps/extension/src/routes/page/onboarding/default-frontend.tsx deleted file mode 100644 index bc256b2f..00000000 --- a/apps/extension/src/routes/page/onboarding/default-frontend.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Card, CardDescription, CardHeader, CardTitle } from '@repo/ui/components/ui/card'; -import { FadeTransition } from '@repo/ui/components/ui/fade-transition'; -import { usePageNav } from '../../../utils/navigate'; -import { PagePath } from '../paths'; -import { DefaultFrontendForm } from '../../../shared/components/default-frontend-form'; -import { FormEventHandler } from 'react'; - -export const SetDefaultFrontendPage = () => { - const navigate = usePageNav(); - - const onSubmit: FormEventHandler = (event): void => { - event.preventDefault(); - navigate(PagePath.SET_NUMERAIRES); - }; - - return ( - - - - Select your preferred frontend app - - - - Prax has a shortcut for your portfolio page. You can always change this later - - -
- - -
-
- ); -}; diff --git a/apps/extension/src/routes/page/onboarding/generate.tsx b/apps/extension/src/routes/page/onboarding/generate.tsx index 389a5902..62760a25 100644 --- a/apps/extension/src/routes/page/onboarding/generate.tsx +++ b/apps/extension/src/routes/page/onboarding/generate.tsx @@ -13,9 +13,6 @@ import { useStore } from '../../../state'; import { generateSelector } from '../../../state/seed-phrase/generate'; import { usePageNav } from '../../../utils/navigate'; import { PagePath } from '../paths'; -import { WordLengthToogles } from '../../../shared/containers/word-length-toogles'; -import { useLatestBlockHeightWithFallback } from '../../../hooks/latest-block-height'; -import { localExtStorage } from '../../../storage/local'; export const GenerateSeedPhrase = () => { const navigate = usePageNav(); @@ -23,14 +20,11 @@ export const GenerateSeedPhrase = () => { const [count, { startCountdown }] = useCountdown({ countStart: 3 }); const [reveal, setReveal] = useState(false); - const { data: latestBlockHeight, isLoading, error } = useLatestBlockHeightWithFallback(); - - const onSubmit = async () => { - await localExtStorage.set('walletCreationBlockHeight', latestBlockHeight); + const onSubmit = () => { navigate(PagePath.CONFIRM_BACKUP); }; - // On render, asynchronously generate a new seed phrase and initialize the wallet creation block height + // On render, asynchronously generate a new seed phrase useEffect(() => { if (!phrase.length) { generateRandomSeedPhrase(SeedPhraseLength.TWELVE_WORDS); @@ -47,7 +41,6 @@ export const GenerateSeedPhrase = () => {
-
{ disabled={!reveal} text={phrase.join(' ')} label={Copy to clipboard} - className='m-auto w-48' + className='m-auto' isSuccessCopyText />
- {reveal && ( -
-

Wallet Birthday

-

- - {Boolean(error) && {String(error)}} - {isLoading && 'Loading...'} - {latestBlockHeight && Number(latestBlockHeight)} - -

-

- This is the block height at the time your wallet was created. Please save the block - height along with your recovery passphrase. It's not required, but will help - you restore your wallet quicker on a fresh Prax install next time. -

- Copy to clipboard} - className='m-auto mt-4 w-48' - isSuccessCopyText - /> -
- )} -

@@ -117,12 +85,7 @@ export const GenerateSeedPhrase = () => {

{reveal ? ( - ) : ( diff --git a/apps/extension/src/routes/page/onboarding/height.tsx b/apps/extension/src/routes/page/onboarding/height.tsx deleted file mode 100644 index 69b37cb7..00000000 --- a/apps/extension/src/routes/page/onboarding/height.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { BackIcon } from '@repo/ui/components/ui/icons/back-icon'; -import { Button } from '@repo/ui/components/ui/button'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@repo/ui/components/ui/card'; -import { FadeTransition } from '@repo/ui/components/ui/fade-transition'; -import { usePageNav } from '../../../utils/navigate'; -import { PagePath } from '../paths'; -import { FormEvent, useState } from 'react'; -import { Input } from '@repo/ui/components/ui/input'; -import { localExtStorage } from '../../../storage/local'; - -export const ImportWalletCreationHeight = () => { - const navigate = usePageNav(); - const [blockHeight, setBlockHeight] = useState(); - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - - void (async () => { - await localExtStorage.set('walletCreationBlockHeight', blockHeight ? blockHeight : 0); - navigate(PagePath.SET_PASSWORD); - })(); - }; - - return ( - - navigate(-1)} /> - - - - Enter your wallet's birthday (Optional) - - - This is the block height at the time your wallet was created. Providing your - wallet's block creation height can help speed up the synchronization process, but - it's not required. If you don't have this information, you can safely skip - this step. - - - -
- setBlockHeight(Number(e.target.value))} - className='rounded-md border border-gray-700 p-3 text-[16px] font-normal leading-[24px]' - /> - -
-
-
-
- ); -}; diff --git a/apps/extension/src/routes/page/onboarding/import.tsx b/apps/extension/src/routes/page/onboarding/import.tsx index 29d7ebf5..4774fd8a 100644 --- a/apps/extension/src/routes/page/onboarding/import.tsx +++ b/apps/extension/src/routes/page/onboarding/import.tsx @@ -12,9 +12,10 @@ import { cn } from '@repo/ui/lib/utils'; import { useStore } from '../../../state'; import { importSelector } from '../../../state/seed-phrase/import'; import { usePageNav } from '../../../utils/navigate'; -import { PagePath } from '../paths'; import { ImportForm } from '../../../shared/containers/import-form'; import { FormEvent, MouseEvent } from 'react'; +import { navigateToPasswordPage } from './password/utils'; +import { SEED_PHRASE_ORIGIN } from './password/types'; export const ImportSeedPhrase = () => { const navigate = usePageNav(); @@ -22,7 +23,7 @@ export const ImportSeedPhrase = () => { const handleSubmit = (event: MouseEvent | FormEvent) => { event.preventDefault(); - navigate(PagePath.IMPORT_WALLET_CREATION_HEIGHT); + navigateToPasswordPage(navigate, SEED_PHRASE_ORIGIN.IMPORTED); }; return ( diff --git a/apps/extension/src/routes/page/onboarding/password/hooks.ts b/apps/extension/src/routes/page/onboarding/password/hooks.ts new file mode 100644 index 00000000..757cfa58 --- /dev/null +++ b/apps/extension/src/routes/page/onboarding/password/hooks.ts @@ -0,0 +1,35 @@ +import { useAddWallet } from '../../../../hooks/onboarding'; +import { usePageNav } from '../../../../utils/navigate'; +import { FormEvent, useCallback, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { getSeedPhraseOrigin, setOnboardingValuesInStorage } from './utils'; +import { PagePath } from '../../paths'; +import { localExtStorage } from '../../../../storage/local'; + +export const useFinalizeOnboarding = () => { + const addWallet = useAddWallet(); + const navigate = usePageNav(); + const [error, setError] = useState(); + const [loading, setLoading] = useState(false); + const location = useLocation(); + + const handleSubmit = useCallback(async (event: FormEvent, password: string) => { + event.preventDefault(); + try { + setLoading(true); + setError(undefined); + await addWallet(password); + const origin = getSeedPhraseOrigin(location); + await setOnboardingValuesInStorage(origin); + navigate(PagePath.ONBOARDING_SUCCESS); + } catch (e) { + setError(String(e)); + // If something fails, roll back the wallet addition so it forces onboarding if they leave and click popup again + await localExtStorage.remove('wallets'); + } finally { + setLoading(false); + } + }, []); + + return { handleSubmit, error, loading }; +}; diff --git a/apps/extension/src/routes/page/onboarding/set-password.tsx b/apps/extension/src/routes/page/onboarding/password/index.tsx similarity index 67% rename from apps/extension/src/routes/page/onboarding/set-password.tsx rename to apps/extension/src/routes/page/onboarding/password/index.tsx index 63562c91..296f9c00 100644 --- a/apps/extension/src/routes/page/onboarding/set-password.tsx +++ b/apps/extension/src/routes/page/onboarding/password/index.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useState } from 'react'; +import { useState } from 'react'; import { BackIcon } from '@repo/ui/components/ui/icons/back-icon'; import { Button } from '@repo/ui/components/ui/button'; import { @@ -9,25 +9,16 @@ import { CardTitle, } from '@repo/ui/components/ui/card'; import { FadeTransition } from '@repo/ui/components/ui/fade-transition'; -import { useAddWallet } from '../../../hooks/onboarding'; -import { usePageNav } from '../../../utils/navigate'; -import { PagePath } from '../paths'; -import { PasswordInput } from '../../../shared/components/password-input'; +import { LineWave } from 'react-loader-spinner'; +import { usePageNav } from '../../../../utils/navigate'; +import { PasswordInput } from '../../../../shared/components/password-input'; +import { useFinalizeOnboarding } from './hooks'; export const SetPassword = () => { const navigate = usePageNav(); - const addWallet = useAddWallet(); const [password, setPassword] = useState(''); const [confirmation, setConfirmation] = useState(''); - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - - void (async () => { - await addWallet(password); - navigate(PagePath.SET_GRPC_ENDPOINT); - })(); - }; + const { handleSubmit, error, loading } = useFinalizeOnboarding(); return ( @@ -41,7 +32,7 @@ export const SetPassword = () => { -
+ void handleSubmit(e, password)}> { + {error &&
{error}
}
diff --git a/apps/extension/src/routes/page/onboarding/password/types.ts b/apps/extension/src/routes/page/onboarding/password/types.ts new file mode 100644 index 00000000..d31fd2a5 --- /dev/null +++ b/apps/extension/src/routes/page/onboarding/password/types.ts @@ -0,0 +1,8 @@ +export enum SEED_PHRASE_ORIGIN { + IMPORTED = 'IMPORTED', + NEWLY_GENERATED = 'NEWLY_GENERATED', +} + +export interface LocationState { + origin?: SEED_PHRASE_ORIGIN; +} diff --git a/apps/extension/src/routes/page/onboarding/password/utils.ts b/apps/extension/src/routes/page/onboarding/password/utils.ts new file mode 100644 index 00000000..b49e5205 --- /dev/null +++ b/apps/extension/src/routes/page/onboarding/password/utils.ts @@ -0,0 +1,65 @@ +import { Location } from 'react-router-dom'; +import { LocationState, SEED_PHRASE_ORIGIN } from './types'; +import { PagePath } from '../../paths'; +import { usePageNav } from '../../../../utils/navigate'; +import { ChainRegistryClient } from '@penumbra-labs/registry'; +import { sample } from 'lodash'; +import { createPromiseClient } from '@connectrpc/connect'; +import { createGrpcWebTransport } from '@connectrpc/connect-web'; +import { localExtStorage } from '../../../../storage/local'; +import { AppService } from '@penumbra-zone/protobuf'; +import { fetchBlockHeightWithFallback } from '../../../../hooks/latest-block-height'; + +export const getSeedPhraseOrigin = (location: Location): SEED_PHRASE_ORIGIN => { + const state = location.state as Partial | undefined; + if ( + state && + typeof state.origin === 'string' && + Object.values(SEED_PHRASE_ORIGIN).includes(state.origin) + ) { + return state.origin; + } + // Default to IMPORTED if the origin is not valid as it won't generate a walletCreationHeight + return SEED_PHRASE_ORIGIN.IMPORTED; +}; + +export const navigateToPasswordPage = ( + nav: ReturnType, + origin: SEED_PHRASE_ORIGIN, +) => nav(PagePath.SET_PASSWORD, { state: { origin } }); + +// A request-level timeout that supersedes the channel transport-level timeout to prevent hanging requests. +const DEFAULT_TRANSPORT_OPTS = { timeoutMs: 5000 }; + +export const setOnboardingValuesInStorage = async (seedPhraseOrigin: SEED_PHRASE_ORIGIN) => { + const chainRegistryClient = new ChainRegistryClient(); + const { rpcs, frontends } = await chainRegistryClient.remote.globals(); + const randomFrontend = sample(frontends); + if (!randomFrontend) { + throw new Error('Registry missing frontends'); + } + + // Queries for blockHeight regardless of SEED_PHRASE_ORIGIN as a means of testing endpoint for liveness + const { blockHeight, rpc } = await fetchBlockHeightWithFallback(rpcs.map(r => r.url)); + + const { appParameters } = await createPromiseClient( + AppService, + createGrpcWebTransport({ baseUrl: rpc }), + ).appParameters({}, DEFAULT_TRANSPORT_OPTS); + if (!appParameters?.chainId) { + throw new Error('No chain id'); + } + + if (seedPhraseOrigin === SEED_PHRASE_ORIGIN.NEWLY_GENERATED) { + await localExtStorage.set('walletCreationBlockHeight', blockHeight); + } + + const { numeraires } = await chainRegistryClient.remote.get(appParameters.chainId); + + await localExtStorage.set('grpcEndpoint', rpc); + await localExtStorage.set('frontendUrl', randomFrontend.url); + await localExtStorage.set( + 'numeraires', + numeraires.map(n => n.toJsonString()), + ); +}; diff --git a/apps/extension/src/routes/page/onboarding/routes.tsx b/apps/extension/src/routes/page/onboarding/routes.tsx index f4bf9b2c..1336f9e8 100644 --- a/apps/extension/src/routes/page/onboarding/routes.tsx +++ b/apps/extension/src/routes/page/onboarding/routes.tsx @@ -4,12 +4,7 @@ import { GenerateSeedPhrase } from './generate'; import { ConfirmBackup } from './confirm-backup'; import { ImportSeedPhrase } from './import'; import { OnboardingSuccess } from './success'; -import { SetPassword } from './set-password'; -import { pageIndexLoader } from '..'; -import { SetGrpcEndpoint } from './set-grpc-endpoint'; -import { SetDefaultFrontendPage } from './default-frontend'; -import { SetNumerairesPage } from './set-numeraire'; -import { ImportWalletCreationHeight } from './height'; +import { SetPassword } from './password'; export const onboardingRoutes = [ { @@ -28,29 +23,12 @@ export const onboardingRoutes = [ path: PagePath.IMPORT_SEED_PHRASE, element: , }, - { - path: PagePath.IMPORT_WALLET_CREATION_HEIGHT, - element: , - }, { path: PagePath.SET_PASSWORD, element: , }, - { - path: PagePath.SET_GRPC_ENDPOINT, - element: , - }, - { - path: PagePath.SET_DEFAULT_FRONTEND, - element: , - }, - { - path: PagePath.SET_NUMERAIRES, - element: , - }, { path: PagePath.ONBOARDING_SUCCESS, element: , - loader: pageIndexLoader, }, ]; diff --git a/apps/extension/src/routes/page/onboarding/set-grpc-endpoint.tsx b/apps/extension/src/routes/page/onboarding/set-grpc-endpoint.tsx deleted file mode 100644 index 0d4490db..00000000 --- a/apps/extension/src/routes/page/onboarding/set-grpc-endpoint.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Card, CardDescription, CardHeader, CardTitle } from '@repo/ui/components/ui/card'; -import { FadeTransition } from '@repo/ui/components/ui/fade-transition'; -import { usePageNav } from '../../../utils/navigate'; -import { PagePath } from '../paths'; -import { GrpcEndpointForm } from '../../../shared/components/grpc-endpoint-form'; -import { createPromiseClient } from '@connectrpc/connect'; -import { createGrpcWebTransport } from '@connectrpc/connect-web'; -import { AppService, TendermintProxyService } from '@penumbra-zone/protobuf'; -import { localExtStorage } from '../../../storage/local'; - -// Because the new seed phrase generation step queries a mainnet rpc, -// when using a non-mainnet chain id, there is a chance that generated wallet birthday is wrong. -// This logic fixes this issue after they select their rpc. -export const correctBirthdayHeightIfNeeded = async (grpcEndpoint: string) => { - const transport = createGrpcWebTransport({ baseUrl: grpcEndpoint }); - const { appParameters } = await createPromiseClient(AppService, transport).appParameters({}); - - if (!appParameters?.chainId.includes('penumbra-1')) { - const setWalletBirthday = await localExtStorage.get('walletCreationBlockHeight'); - if (setWalletBirthday) { - const tendermintClient = createPromiseClient(TendermintProxyService, transport); - const { syncInfo } = await tendermintClient.getStatus({}); - - await adjustWalletBirthday(setWalletBirthday, syncInfo?.latestBlockHeight); - } - } -}; - -// If the user's birthday is longer than the chain height, that means their mainnet birthday -// is too long and needs to be shortened to the current block height of the non-mainnet chain -export const adjustWalletBirthday = async ( - setWalletBirthday: number, - latestBlockHeight: bigint | undefined, -) => { - if (latestBlockHeight && Number(latestBlockHeight) < setWalletBirthday) { - await localExtStorage.set('walletCreationBlockHeight', Number(latestBlockHeight)); - } -}; - -export const SetGrpcEndpoint = () => { - const navigate = usePageNav(); - - const onSuccess = (): void => { - navigate(PagePath.SET_DEFAULT_FRONTEND); - }; - - return ( - - - - Select your preferred RPC endpoint - - The requests you make may reveal your intentions about transactions you wish to make, so - select an RPC node that you trust. If you're unsure which one to choose, leave this - option set to the default. - - - -
- -
-
-
- ); -}; diff --git a/apps/extension/src/routes/page/paths.ts b/apps/extension/src/routes/page/paths.ts index d6bfe892..62751eae 100644 --- a/apps/extension/src/routes/page/paths.ts +++ b/apps/extension/src/routes/page/paths.ts @@ -4,13 +4,6 @@ export enum PagePath { GENERATE_SEED_PHRASE = '/welcome/generate', CONFIRM_BACKUP = '/welcome/confirm-backup', IMPORT_SEED_PHRASE = '/welcome/import', - IMPORT_WALLET_CREATION_HEIGHT = '/welcome/set-wallet-creation-height', ONBOARDING_SUCCESS = '/welcome/success', SET_PASSWORD = '/welcome/set-password', - SET_GRPC_ENDPOINT = '/welcome/set-grpc-endpoint', - SET_DEFAULT_FRONTEND = '/welcome/set-default-frontend', - SET_NUMERAIRES = '/welcome/set-numeraires', - RESTORE_PASSWORD = '/restore-password', - RESTORE_PASSWORD_INDEX = '/restore-password/', - RESTORE_PASSWORD_SET_PASSWORD = '/restore-password/set-password', } diff --git a/apps/extension/src/routes/page/restore-password/index.tsx b/apps/extension/src/routes/page/restore-password/index.tsx deleted file mode 100644 index a740580d..00000000 --- a/apps/extension/src/routes/page/restore-password/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Outlet } from 'react-router-dom'; -import { AnimatePresence } from 'framer-motion'; - -export const RestorePasswordIndex = () => { - return ( - - - - ); -}; diff --git a/apps/extension/src/routes/page/restore-password/restore-password.tsx b/apps/extension/src/routes/page/restore-password/restore-password.tsx deleted file mode 100644 index 6649745e..00000000 --- a/apps/extension/src/routes/page/restore-password/restore-password.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Button } from '@repo/ui/components/ui/button'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@repo/ui/components/ui/card'; -import { FadeTransition } from '@repo/ui/components/ui/fade-transition'; -import { cn } from '@repo/ui/lib/utils'; -import { useStore } from '../../../state'; -import { importSelector } from '../../../state/seed-phrase/import'; -import { usePageNav } from '../../../utils/navigate'; -import { PagePath } from '../paths'; -import { ImportForm } from '../../../shared/containers/import-form'; - -export const RestorePassword = () => { - const navigate = usePageNav(); - const { phrase, phraseIsValid } = useStore(importSelector); - - return ( - - - - Reset wallet with recovery phrase - - Feel free to paste it into the first box and the rest will fill - - - - - - - - - ); -}; diff --git a/apps/extension/src/routes/page/restore-password/routes.tsx b/apps/extension/src/routes/page/restore-password/routes.tsx deleted file mode 100644 index 166f6352..00000000 --- a/apps/extension/src/routes/page/restore-password/routes.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { PagePath } from '../paths'; -import { RestorePassword } from './restore-password'; -import { SetPassword } from './set-password'; - -export const restorePasswordRoutes = [ - { - path: PagePath.RESTORE_PASSWORD_INDEX, - element: , - }, - { - path: PagePath.RESTORE_PASSWORD_SET_PASSWORD, - element: , - }, -]; diff --git a/apps/extension/src/routes/page/restore-password/set-password.tsx b/apps/extension/src/routes/page/restore-password/set-password.tsx deleted file mode 100644 index 01d3ef3c..00000000 --- a/apps/extension/src/routes/page/restore-password/set-password.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { FormEvent, useState } from 'react'; -import { Button } from '@repo/ui/components/ui/button'; -import { BackIcon } from '@repo/ui/components/ui/icons/back-icon'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@repo/ui/components/ui/card'; -import { FadeTransition } from '@repo/ui/components/ui/fade-transition'; -import { useAddWallet } from '../../../hooks/onboarding'; -import { usePageNav } from '../../../utils/navigate'; -import { PagePath } from '../paths'; -import { PasswordInput } from '../../../shared/components/password-input'; - -export const SetPassword = () => { - const navigate = usePageNav(); - const addWallet = useAddWallet(); - const [password, setPassword] = useState(''); - const [confirmation, setConfirmation] = useState(''); - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - void (async function () { - await addWallet(password); - navigate(PagePath.ONBOARDING_SUCCESS); - })(); - }; - - return ( - - navigate(-1)} /> - - - Create a password - - We will use this password to encrypt your data and you'll need it to unlock your - wallet. - - - -
- setPassword(value)} - /> - setConfirmation(value)} - validations={[ - { - type: 'warn', - issue: "passwords don't match", - checkFn: (txt: string) => password !== txt, - }, - ]} - /> - - -
-
-
- ); -}; diff --git a/apps/extension/src/routes/page/router.tsx b/apps/extension/src/routes/page/router.tsx index dab4715a..7ded973b 100644 --- a/apps/extension/src/routes/page/router.tsx +++ b/apps/extension/src/routes/page/router.tsx @@ -3,8 +3,6 @@ import { PageIndex, pageIndexLoader } from '.'; import { Onboarding } from './onboarding'; import { onboardingRoutes } from './onboarding/routes'; import { PagePath } from './paths'; -import { RestorePasswordIndex } from './restore-password'; -import { restorePasswordRoutes } from './restore-password/routes'; export const pageRoutes: RouteObject[] = [ { @@ -20,11 +18,6 @@ export const pageRoutes: RouteObject[] = [ element: , children: onboardingRoutes, }, - { - path: PagePath.RESTORE_PASSWORD, - element: , - children: restorePasswordRoutes, - }, ], }, ]; diff --git a/apps/extension/src/routes/popup/login.tsx b/apps/extension/src/routes/popup/login.tsx index e068b9c0..9a7c1c6c 100644 --- a/apps/extension/src/routes/popup/login.tsx +++ b/apps/extension/src/routes/popup/login.tsx @@ -69,7 +69,7 @@ export const Login = () => { Penumbra Support diff --git a/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts b/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts index d961cf16..fbb7f85d 100644 --- a/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts +++ b/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts @@ -14,6 +14,7 @@ const useSaveGrpcEndpointSelector = (state: AllSlices) => ({ grpcEndpoint: state.network.grpcEndpoint, chainId: state.network.chainId, setGrpcEndpoint: state.network.setGRPCEndpoint, + clearWalletCreationHeight: state.network.clearWalletCreationHeight, setChainId: state.network.setChainId, }); @@ -31,9 +32,8 @@ export const useGrpcEndpointForm = (isOnboarding: boolean) => { const grpcEndpointsQuery = useRpcs(); // Get the rpc set in storage (if present) - const { grpcEndpoint, chainId, setGrpcEndpoint, setChainId } = useStoreShallow( - useSaveGrpcEndpointSelector, - ); + const { grpcEndpoint, chainId, setGrpcEndpoint, setChainId, clearWalletCreationHeight } = + useStoreShallow(useSaveGrpcEndpointSelector); const [originalChainId, setOriginalChainId] = useState(); const [grpcEndpointInput, setGrpcEndpointInput] = useState(''); @@ -138,6 +138,7 @@ export const useGrpcEndpointForm = (isOnboarding: boolean) => { setConfirmChangedChainIdPromise(undefined); } + await clearWalletCreationHeight(); // changing chain id means the wallet birthday is no longer valid await setGrpcEndpoint(grpcEndpointInput); void chrome.runtime.sendMessage(ServicesMessage.ClearCache); } else { diff --git a/apps/extension/src/state/network.ts b/apps/extension/src/state/network.ts index db6ebee9..ecce8c49 100644 --- a/apps/extension/src/state/network.ts +++ b/apps/extension/src/state/network.ts @@ -7,6 +7,7 @@ export interface NetworkSlice { fullSyncHeight?: number; chainId?: string; setGRPCEndpoint: (endpoint: string) => Promise; + clearWalletCreationHeight: () => Promise; setChainId: (chainId: string) => void; } @@ -24,6 +25,9 @@ export const createNetworkSlice = await local.set('grpcEndpoint', endpoint); }, + clearWalletCreationHeight: async () => { + await local.remove('walletCreationBlockHeight'); + }, setChainId: (chainId: string) => { set(state => { state.network.chainId = chainId; diff --git a/apps/extension/src/storage/base-defaults.test.ts b/apps/extension/src/storage/base-defaults.test.ts index e02fbb63..18656f38 100644 --- a/apps/extension/src/storage/base-defaults.test.ts +++ b/apps/extension/src/storage/base-defaults.test.ts @@ -61,4 +61,34 @@ describe('Base storage default instantiation', () => { expect(result1).toBe(0); expect(result3).toBe(123); }); + + test('remove sets value to default when default exists', async () => { + await extStorage.set('accounts', [{ label: 'Account 1' }]); + await extStorage.remove('accounts'); + const networkValue = await extStorage.get('accounts'); + expect(networkValue).toStrictEqual([]); + }); + + test('remove removes key when no default exists', async () => { + await extStorage.set('seedPhrase', 'test seed phrase'); + await extStorage.remove('seedPhrase'); + const seedPhraseValue = await extStorage.get('seedPhrase'); + expect(seedPhraseValue).toBe(undefined); + }); + + test('remove throws error when attempting to remove dbVersion', async () => { + await expect(extStorage.remove('dbVersion')).rejects.toThrow('Cannot remove dbVersion'); + }); + + test('remove maintains concurrency and locks', async () => { + const promise1 = extStorage.remove('network'); + const promise2 = extStorage.set('network', 'testnet'); + const promise3 = extStorage.get('network'); + + await promise1; + await promise2; + const networkValue = await promise3; + + expect(networkValue).toBe('testnet'); + }); }); diff --git a/apps/extension/src/storage/base.ts b/apps/extension/src/storage/base.ts index 163d9245..f3ae11c3 100644 --- a/apps/extension/src/storage/base.ts +++ b/apps/extension/src/storage/base.ts @@ -113,11 +113,22 @@ export class ExtensionStorage { } /** - * Removes key/value from db (waits on ongoing migration) + * Removes key/value from db (waits on ongoing migration). If there is a default, sets that. */ async remove(key: K): Promise { await this.withDbLock(async () => { - await this.storage.remove(String(key)); + // Prevent removing dbVersion + if (key === 'dbVersion') { + throw new Error('Cannot remove dbVersion'); + } + + const specificKey = key as K & Exclude; + const defaultValue = this.defaults[specificKey]; + if (defaultValue !== undefined) { + await this._set({ [specificKey]: defaultValue } as Record); + } else { + await this.storage.remove(String(specificKey)); + } }); }