diff --git a/apps/extension/src/hooks/full-sync-height.ts b/apps/extension/src/hooks/full-sync-height.ts index 7c609d70..65e14808 100644 --- a/apps/extension/src/hooks/full-sync-height.ts +++ b/apps/extension/src/hooks/full-sync-height.ts @@ -1,9 +1,8 @@ -import { useQuery } from '@tanstack/react-query'; import { PopupLoaderData } from '../routes/popup/home'; import { useStore } from '../state'; import { networkSelector } from '../state/network'; import { useLoaderData } from 'react-router-dom'; -import { fetchBlockHeight } from '../state/block-height'; +import { useLatestBlockHeight } from './latest-block-height'; const tryGetMax = (a?: number, b?: number): number | undefined => { // Height can be 0n which is falsy, so should compare to undefined state @@ -28,18 +27,7 @@ const useFullSyncHeight = (): number | undefined => { export const useSyncProgress = () => { const fullSyncHeight = useFullSyncHeight(); - const { grpcEndpoint } = useStore(networkSelector); - - const { data: queriedLatest, error } = useQuery({ - queryKey: ['latestBlockHeight'], - queryFn: async () => { - if (!grpcEndpoint) { - return; - } - return await fetchBlockHeight(grpcEndpoint); - }, - enabled: Boolean(grpcEndpoint), - }); + const { data: queriedLatest, error } = useLatestBlockHeight(); // If we have a queried sync height and it's ahead of our block-height query, // use the sync value instead diff --git a/apps/extension/src/hooks/latest-block-height.ts b/apps/extension/src/hooks/latest-block-height.ts new file mode 100644 index 00000000..6dd35982 --- /dev/null +++ b/apps/extension/src/hooks/latest-block-height.ts @@ -0,0 +1,74 @@ +import { useQuery } from '@tanstack/react-query'; +import { sample } from 'lodash'; +import { createPromiseClient } 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'; + +// 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. +const fetchBlockHeightWithFallback = async (endpoints: string[]): Promise => { + if (endpoints.length === 0) { + throw new Error('All RPC endpoints failed to fetch the block height.'); + } + + // Randomly select an RPC endpoint from the chain registry + const randomGrpcEndpoint = sample(endpoints); + if (!randomGrpcEndpoint) { + throw new Error('No RPC endpoints found.'); + } + + try { + return await fetchBlockHeight(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); + } +}; + +// Fetch the block height from a specific RPC endpoint with a timeout to prevent hanging requests. +export const fetchBlockHeight = async (grpcEndpoint: string): Promise => { + const tendermintClient = createPromiseClient( + TendermintProxyService, + createGrpcWebTransport({ baseUrl: grpcEndpoint, defaultTimeoutMs: 2000 }), + ); + + const result = await tendermintClient.getStatus({}); + if (!result.syncInfo) { + throw new Error('No syncInfo in getStatus result'); + } + 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); + + return useQuery({ + queryKey: ['latestBlockHeight'], + queryFn: async () => { + if (!grpcEndpoint) { + return; + } + return await fetchBlockHeight(grpcEndpoint); + }, + enabled: Boolean(grpcEndpoint), + }); +}; diff --git a/apps/extension/src/hooks/onboarding.ts b/apps/extension/src/hooks/onboarding.ts index c9e291d7..6f5e3fee 100644 --- a/apps/extension/src/hooks/onboarding.ts +++ b/apps/extension/src/hooks/onboarding.ts @@ -1,5 +1,4 @@ import { useStore } from '../state'; -// import { existingWalletBlockHeightSelector } from '../state/block-height'; import { passwordSelector } from '../state/password'; import { generateSelector } from '../state/seed-phrase/generate'; import { importSelector } from '../state/seed-phrase/import'; @@ -20,11 +19,3 @@ export const useAddWallet = () => { await addWallet({ label: 'Wallet #1', seedPhrase }); }; }; - -export const useOnboardingSaveOptional = () => { - const { setBlockHeight } = useStore(state => state.walletHeight); - - return async (walletBlockHeight: number) => { - await setBlockHeight(walletBlockHeight); - }; -}; diff --git a/apps/extension/src/routes/page/onboarding/generate.tsx b/apps/extension/src/routes/page/onboarding/generate.tsx index d6a99644..bcc5d060 100644 --- a/apps/extension/src/routes/page/onboarding/generate.tsx +++ b/apps/extension/src/routes/page/onboarding/generate.tsx @@ -1,6 +1,6 @@ import { ExclamationTriangleIcon, LockClosedIcon } from '@radix-ui/react-icons'; import { SeedPhraseLength } from '@penumbra-zone/crypto-web/mnemonic'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Button } from '@repo/ui/components/ui/button'; import { BackIcon } from '@repo/ui/components/ui/icons/back-icon'; import { Card, CardContent, CardHeader, CardTitle } from '@repo/ui/components/ui/card'; @@ -14,38 +14,29 @@ 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 { walletBlockHeightSelector } from '../../../state/block-height'; +import { useLatestBlockHeightWithFallback } from '../../../hooks/latest-block-height'; +import { localExtStorage } from '../../../storage/local'; export const GenerateSeedPhrase = () => { const navigate = usePageNav(); const { phrase, generateRandomSeedPhrase } = useStore(generateSelector); const [count, { startCountdown }] = useCountdown({ countStart: 3 }); const [reveal, setReveal] = useState(false); - const [error, setError] = useState(null); - const blockHeight = useStore(walletBlockHeightSelector); - const setBlockHeight = useStore(state => state.walletHeight.setBlockHeight); - // Track if the block height has been initialized to avoid multiple fetch attempts - const isInitialized = useRef(false); + const { data: latestBlockHeight, isLoading, error } = useLatestBlockHeightWithFallback(); + + const onSubmit = async () => { + await localExtStorage.set('walletCreationBlockHeight', latestBlockHeight); + navigate(PagePath.CONFIRM_BACKUP); + }; // On render, asynchronously generate a new seed phrase and initialize the wallet creation block height useEffect(() => { - void (async () => { - try { - if (!phrase.length) { - generateRandomSeedPhrase(SeedPhraseLength.TWELVE_WORDS); - } - startCountdown(); - - if (!isInitialized.current && blockHeight === 0) { - await setBlockHeight(0, true); - isInitialized.current = true; - } - } catch (error) { - setError('Failed to fetch block height. Please try again later'); - } - })(); - }, [generateRandomSeedPhrase, phrase.length, startCountdown, blockHeight, setBlockHeight]); + if (!phrase.length) { + generateRandomSeedPhrase(SeedPhraseLength.TWELVE_WORDS); + } + startCountdown(); + }, [generateRandomSeedPhrase, phrase.length, startCountdown]); return ( @@ -84,13 +75,9 @@ export const GenerateSeedPhrase = () => {

Wallet Birthday

- {error ? ( - {error} - ) : isInitialized.current ? ( - Number(blockHeight).toLocaleString() - ) : ( - 'Loading...' - )} + {Boolean(error) && {String(error)}} + {isLoading && 'Loading...'} + {latestBlockHeight && Number(latestBlockHeight).toLocaleString()}

@@ -126,7 +113,7 @@ export const GenerateSeedPhrase = () => {