diff --git a/apps/extension/package.json b/apps/extension/package.json index 2d075695d1..dbc110ac47 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -32,6 +32,7 @@ "exponential-backoff": "^3.1.1", "framer-motion": "^11.0.22", "immer": "^10.0.4", + "lodash": "^4.17.21", "lucide-react": "^0.363.0", "node-fetch": "^3.3.2", "react": "^18.2.0", @@ -45,6 +46,7 @@ "@penumbra-zone/polyfills": "workspace:*", "@radix-ui/react-icons": "^1.3.0", "@types/firefox-webext-browser": "^120.0.3", + "@types/lodash": "^4.17.0", "@types/react": "^18.2.72", "@types/react-dom": "^18.2.22", "autoprefixer": "^10.4.19", diff --git a/apps/extension/src/routes/page/onboarding/routes.tsx b/apps/extension/src/routes/page/onboarding/routes.tsx index f42ffc7f62..c68496c6d7 100644 --- a/apps/extension/src/routes/page/onboarding/routes.tsx +++ b/apps/extension/src/routes/page/onboarding/routes.tsx @@ -6,7 +6,7 @@ import { ImportSeedPhrase } from './import'; import { OnboardingSuccess } from './success'; import { SetPassword } from './set-password'; import { pageIndexLoader } from '..'; -import { SetRpcEndpoint } from './set-rpc-endpoint'; +import { SetGrpcEndpoint } from './set-grpc-endpoint'; export const onboardingRoutes = [ { @@ -30,8 +30,8 @@ export const onboardingRoutes = [ element: , }, { - path: PagePath.SET_RPC_ENDPOINT, - element: , + path: PagePath.SET_GRPC_ENDPOINT, + element: , }, { path: PagePath.ONBOARDING_SUCCESS, diff --git a/apps/extension/src/routes/page/onboarding/set-grpc-endpoint.tsx b/apps/extension/src/routes/page/onboarding/set-grpc-endpoint.tsx new file mode 100644 index 0000000000..71d78daa2a --- /dev/null +++ b/apps/extension/src/routes/page/onboarding/set-grpc-endpoint.tsx @@ -0,0 +1,34 @@ +import { Card, CardDescription, CardHeader, CardTitle } from '@penumbra-zone/ui/components/ui/card'; +import { FadeTransition } from '@penumbra-zone/ui/components/ui/fade-transition'; +import { usePageNav } from '../../../utils/navigate'; +import { PagePath } from '../paths'; +import { ServicesMessage } from '@penumbra-zone/types/src/services'; +import { GrpcEndpointForm } from '../../../shared/components/grpc-endpoint-form'; + +export const SetGrpcEndpoint = () => { + const navigate = usePageNav(); + + const onSuccess = (): void => { + void chrome.runtime.sendMessage(ServicesMessage.OnboardComplete); + navigate(PagePath.ONBOARDING_SUCCESS); + }; + + 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/onboarding/set-password.tsx b/apps/extension/src/routes/page/onboarding/set-password.tsx index c755c17515..6f82a6f281 100644 --- a/apps/extension/src/routes/page/onboarding/set-password.tsx +++ b/apps/extension/src/routes/page/onboarding/set-password.tsx @@ -25,7 +25,7 @@ export const SetPassword = () => { void (async () => { await onboardingSave(password); - navigate(PagePath.SET_RPC_ENDPOINT); + navigate(PagePath.SET_GRPC_ENDPOINT); })(); }; diff --git a/apps/extension/src/routes/page/onboarding/set-rpc-endpoint.tsx b/apps/extension/src/routes/page/onboarding/set-rpc-endpoint.tsx deleted file mode 100644 index f3d1950180..0000000000 --- a/apps/extension/src/routes/page/onboarding/set-rpc-endpoint.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { Card, CardDescription, CardHeader, CardTitle } from '@penumbra-zone/ui/components/ui/card'; -import { FadeTransition } from '@penumbra-zone/ui/components/ui/fade-transition'; -import { RPC_ENDPOINTS } from '@penumbra-zone/constants/src/rpc-endpoints'; -import { FormEvent, useMemo, useRef, useState } from 'react'; -import { SelectList } from '@penumbra-zone/ui/components/ui/select-list'; -import { Button } from '@penumbra-zone/ui/components/ui/button'; -import { useStore } from '../../../state'; -import { usePageNav } from '../../../utils/navigate'; -import { PagePath } from '../paths'; -import { ServicesMessage } from '@penumbra-zone/types/src/services'; -import { Network } from 'lucide-react'; - -const randomSort = () => (Math.random() >= 0.5 ? 1 : -1); - -export const SetRpcEndpoint = () => { - const navigate = usePageNav(); - const randomlySortedEndpoints = useMemo(() => [...RPC_ENDPOINTS].sort(randomSort), []); - const [grpcEndpoint, setGrpcEndpoint] = useState(randomlySortedEndpoints[0]!.url); - const setGrpcEndpointInZustand = useStore(state => state.network.setGRPCEndpoint); - const customRpcEndpointInput = useRef(null); - const isCustomRpcEndpoint = !RPC_ENDPOINTS.some(({ url }) => url === grpcEndpoint); - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - await setGrpcEndpointInZustand(grpcEndpoint); - void chrome.runtime.sendMessage(ServicesMessage.OnboardComplete); - navigate(PagePath.ONBOARDING_SUCCESS); - }; - - 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. - - - -
void handleSubmit(e)}> - - {randomlySortedEndpoints.map(rpcEndpoint => ( - - ) - } - /> - ))} - - setGrpcEndpoint(e.target.value)} - className='w-full bg-transparent' - /> - } - onSelect={() => { - if (!isCustomRpcEndpoint) setGrpcEndpoint(''); - customRpcEndpointInput.current?.focus(); - }} - isSelected={isCustomRpcEndpoint} - image={} - /> - - - -
- - - Add to this list - -
-
- ); -}; diff --git a/apps/extension/src/routes/page/paths.ts b/apps/extension/src/routes/page/paths.ts index b0cf73bf03..49b54b7788 100644 --- a/apps/extension/src/routes/page/paths.ts +++ b/apps/extension/src/routes/page/paths.ts @@ -6,7 +6,7 @@ export enum PagePath { IMPORT_SEED_PHRASE = '/welcome/import', ONBOARDING_SUCCESS = '/welcome/success', SET_PASSWORD = '/welcome/set-password', - SET_RPC_ENDPOINT = '/welcome/set-rpc-endpoint', + SET_GRPC_ENDPOINT = '/welcome/set-grpc-endpoint', RESTORE_PASSWORD = '/restore-password', RESTORE_PASSWORD_INDEX = '/restore-password/', RESTORE_PASSWORD_SET_PASSWORD = '/restore-password/set-password', diff --git a/apps/extension/src/routes/popup/padding-wrapper.tsx b/apps/extension/src/routes/popup/padding-wrapper.tsx new file mode 100644 index 0000000000..b4a2fea158 --- /dev/null +++ b/apps/extension/src/routes/popup/padding-wrapper.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react'; + +/** + * Wrap this around any top-level components that should have horizontal and + * bottom padding in the extension popup. + */ +export const PaddingWrapper = ({ children }: { children: ReactNode }) => { + return
{children}
; +}; diff --git a/apps/extension/src/routes/popup/settings/settings-rpc.tsx b/apps/extension/src/routes/popup/settings/settings-rpc.tsx index 5d6ddb7f6a..fea7aaf227 100644 --- a/apps/extension/src/routes/popup/settings/settings-rpc.tsx +++ b/apps/extension/src/routes/popup/settings/settings-rpc.tsx @@ -1,27 +1,15 @@ -import { QueryService } from '@buf/penumbra-zone_penumbra.connectrpc_es/penumbra/core/app/v1/app_connect'; -import { createGrpcWebTransport } from '@connectrpc/connect-web'; -import { createPromiseClient } from '@connectrpc/connect'; -import { FormEvent, useState } from 'react'; -import { Button } from '@penumbra-zone/ui/components/ui/button'; +import { useState } from 'react'; import { FadeTransition } from '@penumbra-zone/ui/components/ui/fade-transition'; -import { Input } from '@penumbra-zone/ui/components/ui/input'; -import { useChainIdQuery } from '../../../hooks/chain-id'; import { ShareGradientIcon } from '../../../icons/share-gradient'; import { SettingsHeader } from '../../../shared/components/settings-header'; -import { useStore } from '../../../state'; -import { networkSelector } from '../../../state/network'; import '@penumbra-zone/polyfills/src/Promise.withResolvers'; -import { TrashIcon } from '@radix-ui/react-icons'; -import { ServicesMessage } from '@penumbra-zone/types/src/services'; +import { GrpcEndpointForm } from '../../../shared/components/grpc-endpoint-form'; +import { PaddingWrapper } from '../padding-wrapper'; export const SettingsRPC = () => { - const { chainId: currentChainId } = useChainIdQuery(); - const [newChainId, setNewChainId] = useState(); - const { grpcEndpoint, setGRPCEndpoint } = useStore(networkSelector); - - const [rpcInput, setRpcInput] = useState(grpcEndpoint ?? ''); - const [rpcError, setRpcError] = useState(); const [countdownTime, setCountdownTime] = useState(); + const submitButtonLabel = + Number(countdownTime) > 0 ? `Saved! Restarting in ${countdownTime}...` : 'Save'; const countdown = (seconds: number) => { const { promise, resolve } = Promise.withResolvers(); @@ -33,32 +21,11 @@ export const SettingsRPC = () => { return promise; }; - const onSubmit = (evt: FormEvent) => { - evt.preventDefault(); - void (async () => { - try { - const trialClient = createPromiseClient( - QueryService, - createGrpcWebTransport({ baseUrl: rpcInput }), - ); - const { appParameters } = await trialClient.appParameters({}); - if (!appParameters?.chainId) throw new Error('Endpoint did not provide a valid chainId'); - - setRpcError(undefined); - setNewChainId(appParameters.chainId); - await setGRPCEndpoint(rpcInput); - // If the chain id has changed, our cache is invalid - if (appParameters.chainId !== currentChainId) - void chrome.runtime.sendMessage(ServicesMessage.ClearCache); - // Visually confirm success for a few seconds - await countdown(5); - // Reload the extension to ensure all scopes holding the old config are killed - chrome.runtime.reload(); - } catch (e: unknown) { - console.warn('Could not use new RPC endpoint', e); - setRpcError(String(e) || 'Unknown RPC failure'); - } - })(); + const onSuccess = async () => { + // Visually confirm success for a few seconds, then reload the extension to + // ensure all scopes holding the old config are killed + await countdown(5); + chrome.runtime.reload(); }; return ( @@ -68,61 +35,10 @@ export const SettingsRPC = () => {
-
-
-
-
-
RPC URL
- {rpcError ?
{rpcError}
: null} -
-
-
- {rpcInput !== grpcEndpoint ? ( - - ) : null} -
- { - setRpcError(undefined); - setRpcInput(evt.target.value); - }} - className='text-muted-foreground' - /> -
-
-
-

Chain id

-
- {newChainId ?? currentChainId} -
-
-
- {countdownTime !== undefined ? ( - - ) : ( - - )} -
+ + + + ); diff --git a/apps/extension/src/shared/components/grpc-endpoint-form/chain-id-or-error.tsx b/apps/extension/src/shared/components/grpc-endpoint-form/chain-id-or-error.tsx new file mode 100644 index 0000000000..4c9bf61d77 --- /dev/null +++ b/apps/extension/src/shared/components/grpc-endpoint-form/chain-id-or-error.tsx @@ -0,0 +1,25 @@ +import { cn } from '@penumbra-zone/ui/lib/utils'; + +export const ChainIdOrError = ({ + error, + chainId, + chainIdChanged, +}: { + error?: string; + chainId?: string; + chainIdChanged: boolean; +}) => { + if (!error && !chainId) return null; + + return ( +
+ {error ? error : chainId ? `Chain ID: ${chainId}` : null} +
+ ); +}; diff --git a/apps/extension/src/shared/components/grpc-endpoint-form/confirm-changed-chain-id-dialog.tsx b/apps/extension/src/shared/components/grpc-endpoint-form/confirm-changed-chain-id-dialog.tsx new file mode 100644 index 0000000000..3d3e7a9c20 --- /dev/null +++ b/apps/extension/src/shared/components/grpc-endpoint-form/confirm-changed-chain-id-dialog.tsx @@ -0,0 +1,46 @@ +import { PromiseWithResolvers } from '@penumbra-zone/polyfills/src/Promise.withResolvers'; +import { Button } from '@penumbra-zone/ui/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, +} from '@penumbra-zone/ui/components/ui/dialog'; + +export const ConfirmChangedChainIdDialog = ({ + chainId, + originalChainId, + promiseWithResolvers, +}: { + chainId?: string; + originalChainId?: string; + promiseWithResolvers?: PromiseWithResolvers; +}) => { + return ( + + + Chain ID changed + +
+

+ The originally selected gRPC endpoint was serving chain ID{' '} + {originalChainId}. But the gRPC endpoint + you've selected now serves chain ID{' '} + {chainId}. Was this intentional? +

+ +
+ + + +
+
+
+
+
+ ); +}; diff --git a/apps/extension/src/shared/components/grpc-endpoint-form/index.tsx b/apps/extension/src/shared/components/grpc-endpoint-form/index.tsx new file mode 100644 index 0000000000..9c8d0344a1 --- /dev/null +++ b/apps/extension/src/shared/components/grpc-endpoint-form/index.tsx @@ -0,0 +1,105 @@ +import { FormEvent, useRef } from 'react'; +import { SelectList } from '@penumbra-zone/ui/components/ui/select-list'; +import { Button } from '@penumbra-zone/ui/components/ui/button'; +import { Network } from 'lucide-react'; +import { useGrpcEndpointForm } from './use-grpc-endpoint-form'; +import { ConfirmChangedChainIdDialog } from './confirm-changed-chain-id-dialog'; +import { ChainIdOrError } from './chain-id-or-error'; + +/** + * Renders all the parts of the gRPC endpoint form that are shared between the + * onboarding flow and the RPC settings page. + */ +export const GrpcEndpointForm = ({ + submitButtonLabel, + onSuccess, +}: { + submitButtonLabel: string; + onSuccess: () => void | Promise; +}) => { + const { + chainId, + chainIdChanged, + confirmChangedChainIdPromise, + originalChainId, + grpcEndpoints, + grpcEndpointInput, + setGrpcEndpointInput, + onSubmit, + rpcError, + isSubmitButtonEnabled, + isCustomGrpcEndpoint, + } = useGrpcEndpointForm(); + const customGrpcEndpointInput = useRef(null); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (isSubmitButtonEnabled) void onSubmit(onSuccess); + }; + + return ( + <> +
+
+ + {grpcEndpoints.map(option => ( + + ) + } + /> + ))} + + setGrpcEndpointInput(e.target.value)} + className='w-full bg-transparent' + /> + } + onSelect={() => { + if (!isCustomGrpcEndpoint) setGrpcEndpointInput(''); + customGrpcEndpointInput.current?.focus(); + }} + isSelected={isCustomGrpcEndpoint} + image={} + /> + + + + Add to this list + + + +
+ + +
+ + + + ); +}; 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 new file mode 100644 index 0000000000..595017169c --- /dev/null +++ b/apps/extension/src/shared/components/grpc-endpoint-form/use-grpc-endpoint-form.ts @@ -0,0 +1,153 @@ +import { Code, ConnectError, createPromiseClient } from '@connectrpc/connect'; +import { QueryService } from '@buf/penumbra-zone_penumbra.connectrpc_es/penumbra/core/app/v1/app_connect'; +import { createGrpcWebTransport } from '@connectrpc/connect-web'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { AllSlices } from '../../../state'; +import { useStoreShallow } from '../../../utils/use-store-shallow'; +import { ServicesMessage } from '@penumbra-zone/types/src/services'; +import { GRPC_ENDPOINTS } from '@penumbra-zone/constants/src/grpc-endpoints'; +import debounce from 'lodash/debounce'; +import { PromiseWithResolvers } from '@penumbra-zone/polyfills/src/Promise.withResolvers'; + +const randomSort = () => (Math.random() >= 0.5 ? 1 : -1); + +const useSaveGrpcEndpointSelector = (state: AllSlices) => ({ + grpcEndpoint: state.network.grpcEndpoint, + setGrpcEndpoint: state.network.setGRPCEndpoint, +}); + +const isValidUrl = (url: string) => { + try { + new URL(url); + return true; + } catch { + return false; + } +}; + +export const useGrpcEndpointForm = () => { + const [originalChainId, setOriginalChainId] = useState(); + const [chainId, setChainId] = useState(); + const grpcEndpoints = useMemo(() => [...GRPC_ENDPOINTS].sort(randomSort), []); + const { grpcEndpoint, setGrpcEndpoint } = useStoreShallow(useSaveGrpcEndpointSelector); + const [grpcEndpointInput, setGrpcEndpointInput] = useState( + grpcEndpoint ?? grpcEndpoints[0]?.url ?? '', + ); + const [rpcError, setRpcError] = useState(); + const [isSubmitButtonEnabled, setIsSubmitButtonEnabled] = useState(false); + const [confirmChangedChainIdPromise, setConfirmChangedChainIdPromise] = useState< + PromiseWithResolvers | undefined + >(); + + const isCustomGrpcEndpoint = !GRPC_ENDPOINTS.some(({ url }) => url === grpcEndpointInput); + + const setGrpcEndpointInputOnLoadFromState = useCallback(() => { + if (grpcEndpoint) setGrpcEndpointInput(grpcEndpoint); + }, [grpcEndpoint]); + + useEffect(setGrpcEndpointInputOnLoadFromState, [setGrpcEndpointInputOnLoadFromState]); + + const handleChangeGrpcEndpointInput = useMemo(() => { + return debounce(async (grpcEndpointInput: string) => { + setIsSubmitButtonEnabled(false); + setRpcError(undefined); + + if (!isValidUrl(grpcEndpointInput)) return; + + try { + const trialClient = createPromiseClient( + QueryService, + createGrpcWebTransport({ baseUrl: grpcEndpointInput }), + ); + const { appParameters } = await trialClient.appParameters({}); + if (!appParameters?.chainId) throw new ConnectError('', Code.NotFound); + + setIsSubmitButtonEnabled(true); + setChainId(appParameters.chainId); + + // Only set the original chain ID the first time, so that we can compare + // it on submit. + setOriginalChainId(originalChainId => + originalChainId ? originalChainId : appParameters.chainId, + ); + } catch (e) { + if (e instanceof ConnectError && e.code === Code.NotFound) { + setRpcError( + 'Could not get a chain ID from this endpoint. Please double-check your endpoint URL and try again.', + ); + } else if (e instanceof ConnectError && e.code === Code.Unknown) { + setRpcError( + 'Could not connect to endpoint. Please double-check your endpoint URL and try again.', + ); + } else { + setRpcError('Could not connect to endpoint: ' + String(e)); + } + } + }, 400); + }, []); + + useEffect( + () => void handleChangeGrpcEndpointInput(grpcEndpointInput), + [handleChangeGrpcEndpointInput, grpcEndpointInput], + ); + + const chainIdChanged = !!originalChainId && !!chainId && originalChainId !== chainId; + + const onSubmit = async ( + /** Callback to run when the RPC endpoint successfully saves */ + onSuccess: () => void | Promise, + ) => { + setIsSubmitButtonEnabled(false); + + // If the chain id has changed, our cache is invalid + if (chainIdChanged) { + const promiseWithResolvers = Promise.withResolvers(); + setConfirmChangedChainIdPromise(promiseWithResolvers); + + try { + await promiseWithResolvers.promise; + } catch { + setIsSubmitButtonEnabled(true); + return; + } finally { + setConfirmChangedChainIdPromise(undefined); + } + + await setGrpcEndpoint(grpcEndpointInput); + void chrome.runtime.sendMessage(ServicesMessage.ClearCache); + } else { + await setGrpcEndpoint(grpcEndpointInput); + } + + await onSuccess(); + setIsSubmitButtonEnabled(true); + setOriginalChainId(chainId); + }; + + return { + chainId, + originalChainId, + /** + * gRPC endpoints report which chain they represent via the `chainId` + * property returned by their `appParameters` RPC method. All endpoints for + * a given chain will have the same chain ID. If the chain ID changes when a + * user selects a different gRPC endpoint, that means that the new gRPC + * endpoint represents an entirely different chain than the user was + * previously using. This is significant, and should be surfaced to the + * user. + */ + chainIdChanged, + confirmChangedChainIdPromise, + /** + * The gRPC endpoint entered into the text field, which may or may not be + * the same as the one saved in local storage. + */ + grpcEndpointInput, + setGrpcEndpointInput, + grpcEndpoints, + rpcError, + onSubmit, + isSubmitButtonEnabled, + isCustomGrpcEndpoint, + }; +}; diff --git a/packages/constants/src/rpc-endpoints.ts b/packages/constants/src/grpc-endpoints.ts similarity index 52% rename from packages/constants/src/rpc-endpoints.ts rename to packages/constants/src/grpc-endpoints.ts index 6ca01e5541..d6923db2ee 100644 --- a/packages/constants/src/rpc-endpoints.ts +++ b/packages/constants/src/grpc-endpoints.ts @@ -1,20 +1,15 @@ import { STAKING_TOKEN_METADATA } from './assets'; -interface RpcEndpoint { +interface GrpcEndpoint { name: string; url: string; imageUrl?: string; } -export const RPC_ENDPOINTS: RpcEndpoint[] = [ +export const GRPC_ENDPOINTS: GrpcEndpoint[] = [ { name: 'Penumbra Labs Testnet RPC', url: 'https://grpc.testnet.penumbra.zone', imageUrl: STAKING_TOKEN_METADATA.images[0]?.svg, }, - { - name: 'Penumbra Labs Testnet Preview RPC', - url: 'https://grpc.testnet-preview.penumbra.zone', - imageUrl: STAKING_TOKEN_METADATA.images[0]?.svg, - }, ]; diff --git a/packages/ui/components/ui/dialog.tsx b/packages/ui/components/ui/dialog.tsx index 41ae0c6b8f..2a0f68730e 100644 --- a/packages/ui/components/ui/dialog.tsx +++ b/packages/ui/components/ui/dialog.tsx @@ -7,6 +7,10 @@ import { cn } from '../../lib/utils'; import { cva, VariantProps } from 'class-variance-authority'; /** + * You can use a `` in two ways. + * + * 1) Letting it manage its own internal open vs. closed state. + * * @example * ```tsx * @@ -25,6 +29,23 @@ import { cva, VariantProps } from 'class-variance-authority'; * * + * + * + * Header here, which includes a built-in close button. + * + *

Content here

+ * + *
Clicking anything inside here will close the dialog.
+ *
+ *
+ *