Skip to content

Commit

Permalink
Review updates
Browse files Browse the repository at this point in the history
  • Loading branch information
grod220 committed Nov 8, 2024
1 parent 653f225 commit 2e90c28
Show file tree
Hide file tree
Showing 9 changed files with 222 additions and 104 deletions.
67 changes: 67 additions & 0 deletions apps/extension/src/hooks/latest-block-height.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
76 changes: 38 additions & 38 deletions apps/extension/src/hooks/latest-block-height.ts
Original file line number Diff line number Diff line change
@@ -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<number> => {
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.
Expand Down
3 changes: 2 additions & 1 deletion apps/extension/src/routes/page/onboarding/confirm-backup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion apps/extension/src/routes/page/onboarding/import.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
35 changes: 35 additions & 0 deletions apps/extension/src/routes/page/onboarding/password/hooks.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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 };
};
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<string>();
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<LocationState> | 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<typeof usePageNav>,
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();
Expand Down
8 changes: 8 additions & 0 deletions apps/extension/src/routes/page/onboarding/password/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export enum SEED_PHRASE_ORIGIN {
IMPORTED = 'IMPORTED',
NEWLY_GENERATED = 'NEWLY_GENERATED',
}

export interface LocationState {
origin?: SEED_PHRASE_ORIGIN;
}
65 changes: 65 additions & 0 deletions apps/extension/src/routes/page/onboarding/password/utils.ts
Original file line number Diff line number Diff line change
@@ -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<LocationState> | 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<typeof usePageNav>,
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()),
);
};
2 changes: 1 addition & 1 deletion apps/extension/src/routes/page/onboarding/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down

0 comments on commit 2e90c28

Please sign in to comment.