From 2e90c2886a8f6afcbab3802ed29cf07e110a8d99 Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Thu, 7 Nov 2024 17:58:09 -0800 Subject: [PATCH] Review updates --- .../src/hooks/latest-block-height.test.ts | 67 ++++++++++++++++ .../src/hooks/latest-block-height.ts | 76 +++++++++---------- .../routes/page/onboarding/confirm-backup.tsx | 3 +- .../src/routes/page/onboarding/import.tsx | 3 +- .../routes/page/onboarding/password/hooks.ts | 35 +++++++++ .../{set-password.tsx => password/index.tsx} | 67 +--------------- .../routes/page/onboarding/password/types.ts | 8 ++ .../routes/page/onboarding/password/utils.ts | 65 ++++++++++++++++ .../src/routes/page/onboarding/routes.tsx | 2 +- 9 files changed, 222 insertions(+), 104 deletions(-) create mode 100644 apps/extension/src/hooks/latest-block-height.test.ts 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} (53%) create mode 100644 apps/extension/src/routes/page/onboarding/password/types.ts create mode 100644 apps/extension/src/routes/page/onboarding/password/utils.ts 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 e3debcec..04abc2fc 100644 --- a/apps/extension/src/hooks/latest-block-height.ts +++ b/apps/extension/src/hooks/latest-block-height.ts @@ -1,54 +1,54 @@ 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 { AppService, TendermintProxyService } from '@penumbra-zone/protobuf'; -import { ChainRegistryClient } from '@penumbra-labs/registry'; +import { TendermintProxyService } from '@penumbra-zone/protobuf'; import { useStore } from '../state'; import { networkSelector } from '../state/network'; -import { localExtStorage } from '../storage/local'; -import { SEED_PHRASE_ORIGIN } from '../routes/page/onboarding/set-password'; -const DEFAULT_TRANSPORT_OPTS = { timeoutMs: 5000 }; +// Utility function to fetch the block height by randomly querying one of the RPC endpoints +// 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. -export const setOnboardingValuesInStorage = async (seedPhraseOrigin: SEED_PHRASE_ORIGIN) => { - const chainRegistryClient = new ChainRegistryClient(); - const { rpcs, frontends } = await chainRegistryClient.remote.globals(); - const randomRpc = sample(rpcs); - const randomFrontend = sample(frontends); - if (!randomRpc || !randomFrontend) { - throw new Error('Registry missing RPCs or frontends'); +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.'); } - const { appParameters } = await createPromiseClient( - AppService, - createGrpcWebTransport({ baseUrl: randomRpc.url }), - ).appParameters({}, DEFAULT_TRANSPORT_OPTS); - if (!appParameters?.chainId) { - throw new Error('No chain id'); + // Randomly select an RPC endpoint from the chain registry + const randomGrpcEndpoint = sample(endpoints); + if (!randomGrpcEndpoint) { + throw new Error('No RPC endpoints found.'); } - const { numeraires } = await chainRegistryClient.remote.get(appParameters.chainId); - - if (seedPhraseOrigin === SEED_PHRASE_ORIGIN.NEWLY_GENERATED) { - const tendermintClient = createPromiseClient( - TendermintProxyService, - createGrpcWebTransport({ baseUrl: randomRpc.url }), - ); - const result = await tendermintClient.getStatus({}, DEFAULT_TRANSPORT_OPTS); - if (!result.syncInfo) { - throw new Error('No syncInfo in getStatus result'); - } - const walletBirthday = Number(result.syncInfo.latestBlockHeight); - await localExtStorage.set('walletCreationBlockHeight', walletBirthday); + try { + const blockHeight = await fetchBlockHeightWithTimeout(randomGrpcEndpoint, transport); + return { blockHeight, rpc: randomGrpcEndpoint }; + } catch (e) { + // Remove the current endpoint from the list and retry with remaining endpoints + const remainingEndpoints = endpoints.filter(endpoint => endpoint !== randomGrpcEndpoint); + return fetchBlockHeightWithFallback(remainingEndpoints, transport); } +}; - await localExtStorage.set('grpcEndpoint', randomRpc.url); - await localExtStorage.set('frontendUrl', randomFrontend.url); - await localExtStorage.set( - 'numeraires', - numeraires.map(n => n.toJsonString()), - ); +// 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, + transport = createGrpcWebTransport({ baseUrl: grpcEndpoint }), + timeoutMs = 3000, +): Promise => { + const tendermintClient = createPromiseClient(TendermintProxyService, transport); + + const result = await tendermintClient.getStatus({}, { signal: AbortSignal.timeout(timeoutMs) }); + if (!result.syncInfo) { + throw new Error('No syncInfo in getStatus result'); + } + return Number(result.syncInfo.latestBlockHeight); }; // Fetch the block height from a specific RPC endpoint. diff --git a/apps/extension/src/routes/page/onboarding/confirm-backup.tsx b/apps/extension/src/routes/page/onboarding/confirm-backup.tsx index dbae9fc3..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 { navigateToPasswordPage, SEED_PHRASE_ORIGIN } from './set-password'; +import { navigateToPasswordPage } from './password/utils'; +import { SEED_PHRASE_ORIGIN } from './password/types'; export const ConfirmBackup = () => { const navigate = usePageNav(); diff --git a/apps/extension/src/routes/page/onboarding/import.tsx b/apps/extension/src/routes/page/onboarding/import.tsx index 7ad9854c..4774fd8a 100644 --- a/apps/extension/src/routes/page/onboarding/import.tsx +++ b/apps/extension/src/routes/page/onboarding/import.tsx @@ -14,7 +14,8 @@ import { importSelector } from '../../../state/seed-phrase/import'; import { usePageNav } from '../../../utils/navigate'; import { ImportForm } from '../../../shared/containers/import-form'; import { FormEvent, MouseEvent } from 'react'; -import { navigateToPasswordPage, SEED_PHRASE_ORIGIN } from './set-password'; +import { navigateToPasswordPage } from './password/utils'; +import { SEED_PHRASE_ORIGIN } from './password/types'; export const ImportSeedPhrase = () => { const navigate = usePageNav(); 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 53% rename from apps/extension/src/routes/page/onboarding/set-password.tsx rename to apps/extension/src/routes/page/onboarding/password/index.tsx index e3e0e089..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, useCallback, 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,69 +9,10 @@ import { CardTitle, } from '@repo/ui/components/ui/card'; import { FadeTransition } from '@repo/ui/components/ui/fade-transition'; -import { usePageNav } from '../../../utils/navigate'; -import { PasswordInput } from '../../../shared/components/password-input'; import { LineWave } from 'react-loader-spinner'; -import { useAddWallet } from '../../../hooks/onboarding'; -import { setOnboardingValuesInStorage } from '../../../hooks/latest-block-height'; -import { PagePath } from '../paths'; -import { Location, useLocation } from 'react-router-dom'; -import { localExtStorage } from '../../../storage/local'; - -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 }; -}; - -export enum SEED_PHRASE_ORIGIN { - IMPORTED = 'IMPORTED', - NEWLY_GENERATED = 'NEWLY_GENERATED', -} - -interface LocationState { - origin?: SEED_PHRASE_ORIGIN; -} - -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 } }); +import { usePageNav } from '../../../../utils/navigate'; +import { PasswordInput } from '../../../../shared/components/password-input'; +import { useFinalizeOnboarding } from './hooks'; export const SetPassword = () => { const navigate = usePageNav(); 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 9f77f688..1336f9e8 100644 --- a/apps/extension/src/routes/page/onboarding/routes.tsx +++ b/apps/extension/src/routes/page/onboarding/routes.tsx @@ -4,7 +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 { SetPassword } from './password'; export const onboardingRoutes = [ {