Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add RPC selection to onboarding flow #868

Merged
merged 20 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"exponential-backoff": "^3.1.1",
"framer-motion": "^11.0.22",
"immer": "^10.0.4",
"lucide-react": "^0.363.0",
"node-fetch": "^3.3.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
Expand Down
63 changes: 34 additions & 29 deletions apps/extension/src/impls.ts → apps/extension/src/get-rpc-impls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,36 +24,41 @@ import { viewImpl } from '@penumbra-zone/router/src/grpc/view-protocol-server';
import { localExtStorage } from '@penumbra-zone/storage/src/chrome/local';
import { ServiceType } from '@bufbuild/protobuf';

const grpcEndpoint = await localExtStorage.get('grpcEndpoint');
export const getRpcImpls = async () => {
jessepinho marked this conversation as resolved.
Show resolved Hide resolved
const grpcEndpoint = await localExtStorage.get('grpcEndpoint');
if (!grpcEndpoint) throw new Error('gRPC endpoint not set yet');

type RpcImplTuple<T extends ServiceType> = [T, Partial<ServiceImpl<T>>];
type RpcImplTuple<T extends ServiceType> = [T, Partial<ServiceImpl<T>>];

const penumbraProxies: RpcImplTuple<ServiceType>[] = [
AppService,
CompactBlockService,
DexService,
DexSimulationService,
GovernanceService,
IbcProxy,
ShieldedPoolService,
TendermintProxyService,
].map(
serviceType =>
[
serviceType,
createProxyImpl(
const penumbraProxies: RpcImplTuple<ServiceType>[] = [
AppService,
CompactBlockService,
DexService,
DexSimulationService,
GovernanceService,
IbcProxy,
ShieldedPoolService,
TendermintProxyService,
].map(
serviceType =>
[
serviceType,
createPromiseClient(serviceType, createGrpcWebTransport({ baseUrl: grpcEndpoint })),
),
] as const,
);
createProxyImpl(
serviceType,
createPromiseClient(serviceType, createGrpcWebTransport({ baseUrl: grpcEndpoint })),
),
] as const,
);

export const rpcImpls: RpcImplTuple<ServiceType>[] = [
// rpc local implementations
[CustodyService, rethrowImplErrors(CustodyService, custodyImpl)],
[SctService, rethrowImplErrors(SctService, sctImpl)],
[StakingService, rethrowImplErrors(StakingService, stakingImpl)],
[ViewService, rethrowImplErrors(ViewService, viewImpl)],
// rpc remote proxies
...penumbraProxies,
] as const;
const rpcImpls: RpcImplTuple<ServiceType>[] = [
// rpc local implementations
[CustodyService, rethrowImplErrors(CustodyService, custodyImpl)],
[SctService, rethrowImplErrors(SctService, sctImpl)],
[StakingService, rethrowImplErrors(StakingService, stakingImpl)],
[ViewService, rethrowImplErrors(ViewService, viewImpl)],
// rpc remote proxies
...penumbraProxies,
] as const;

return rpcImpls;
};
2 changes: 0 additions & 2 deletions apps/extension/src/hooks/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { passwordSelector } from '../state/password';
import { generateSelector } from '../state/seed-phrase/generate';
import { importSelector } from '../state/seed-phrase/import';
import { walletsSelector } from '../state/wallets';
import { ServicesMessage } from '@penumbra-zone/types/src/services';

// Saves hashed password, uses that hash to encrypt the seed phrase
// and then saves that to session + local storage
Expand All @@ -19,6 +18,5 @@ export const useOnboardingSave = () => {
await setPassword(plaintextPassword);

await addWallet({ label: 'Wallet #1', seedPhrase });
void chrome.runtime.sendMessage(ServicesMessage.OnboardComplete);
};
};
13 changes: 9 additions & 4 deletions apps/extension/src/routes/page/onboarding/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ImportSeedPhrase } from './import';
import { OnboardingSuccess } from './success';
import { SetPassword } from './set-password';
import { pageIndexLoader } from '..';
import { SetRpcEndpoint } from './set-rpc-endpoint';

export const onboardingRoutes = [
{
Expand All @@ -24,13 +25,17 @@ export const onboardingRoutes = [
path: PagePath.IMPORT_SEED_PHRASE,
element: <ImportSeedPhrase />,
},
{
path: PagePath.SET_PASSWORD,
element: <SetPassword />,
},
{
path: PagePath.SET_RPC_ENDPOINT,
element: <SetRpcEndpoint />,
},
{
path: PagePath.ONBOARDING_SUCCESS,
element: <OnboardingSuccess />,
loader: pageIndexLoader,
},
{
path: PagePath.SET_PASSWORD,
element: <SetPassword />,
},
];
6 changes: 3 additions & 3 deletions apps/extension/src/routes/page/onboarding/set-password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,16 @@ import { PasswordInput } from '../../../shared/components/password-input';

export const SetPassword = () => {
const navigate = usePageNav();
const finalOnboardingSave = useOnboardingSave();
const onboardingSave = useOnboardingSave();
const [password, setPassword] = useState('');
const [confirmation, setConfirmation] = useState('');

const handleSubmit = (event: FormEvent | MouseEvent) => {
event.preventDefault();

void (async () => {
await finalOnboardingSave(password);
navigate(PagePath.ONBOARDING_SUCCESS);
await onboardingSave(password);
navigate(PagePath.SET_RPC_ENDPOINT);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that I didn't make this change in the RestorePassword component. It doesn't seem to be part of the onboarding flow, and I actually am unclear on if that component is actually used anywhere?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feel free to delete it. It's unused for like 8 months.

})();
};

Expand Down
94 changes: 94 additions & 0 deletions apps/extension/src/routes/page/onboarding/set-rpc-endpoint.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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 '../../../shared/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<HTMLInputElement | null>(null);
jessepinho marked this conversation as resolved.
Show resolved Hide resolved
Comment on lines +17 to +20
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about moving all this logic into zustand with an RPC slice? My hunch is perhaps we'd want to re-use this within our settings tab in the extension and moving the logic out likely makes this easier.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It also reduces the redundancies with setGrpcEndpointInZustand & setGrpcEndpoint

const isCustomRpcEndpoint = !RPC_ENDPOINTS.some(({ url }) => url === grpcEndpoint);

const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
await setGrpcEndpointInZustand(grpcEndpoint);
void chrome.runtime.sendMessage(ServicesMessage.OnboardComplete);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@turbocrime please confirm that this is correct (moving OnboardComplete) to this step, since this is the new last step of onboarding.

navigate(PagePath.ONBOARDING_SUCCESS);
};

return (
<FadeTransition>
<Card className='w-[400px]' gradient>
<CardHeader>
<CardTitle>Select your preferred RPC endpoint</CardTitle>
<CardDescription>
If you&apos;re unsure which one to choose, leave this option set to the default.
</CardDescription>
jessepinho marked this conversation as resolved.
Show resolved Hide resolved
</CardHeader>

<form className='mt-6 flex flex-col gap-4' onSubmit={e => void handleSubmit(e)}>
<SelectList>
{randomlySortedEndpoints.map(rpcEndpoint => (
<SelectList.Option
key={rpcEndpoint.url}
label={rpcEndpoint.name}
secondary={rpcEndpoint.url}
onSelect={setGrpcEndpoint}
value={rpcEndpoint.url}
isSelected={rpcEndpoint.url === grpcEndpoint}
image={
!!rpcEndpoint.imageUrl && (
<img src={rpcEndpoint.imageUrl} className='size-full object-contain' />
)
}
/>
))}

<SelectList.Option
label='Custom RPC'
secondary={
<input
type='url'
ref={customRpcEndpointInput}
value={isCustomRpcEndpoint && !!grpcEndpoint ? grpcEndpoint : ''}
onChange={e => setGrpcEndpoint(e.target.value)}
className='w-full bg-transparent'
/>
}
onSelect={() => {
if (!isCustomRpcEndpoint) setGrpcEndpoint('');
Comment on lines +71 to +72
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do some validation like the way we do in settings right now? If it cannot connect to the grpc url, we do not allow it to be set.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going to take the liberty of doing this (and addressing your comment above about moving logic into Zustand) in a separate PR, since it looks like that'll require some (necessary) refactoring that I was anyway going to do when adding a similar RPC selection form to the extension's settings page.

customRpcEndpointInput.current?.focus();
}}
isSelected={isCustomRpcEndpoint}
image={<Network className='size-full' />}
/>
</SelectList>

<Button variant='gradient' className='mt-2' type='submit'>
Next
</Button>
</form>

<a
href='https://github.com/penumbra-zone/web/blob/main/apps/extension/src/shared/rpc-endpoints.ts'
target='_blank'
rel='noreferrer'
className='mt-6 block text-right text-xs text-muted-foreground'
>
Add to this list
</a>
</Card>
</FadeTransition>
);
};
1 change: 1 addition & 0 deletions apps/extension/src/routes/page/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +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',
RESTORE_PASSWORD = '/restore-password',
RESTORE_PASSWORD_INDEX = '/restore-password/',
RESTORE_PASSWORD_SET_PASSWORD = '/restore-password/set-password',
Expand Down
29 changes: 28 additions & 1 deletion apps/extension/src/service-worker.ts
jessepinho marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { stakingClientCtx } from '@penumbra-zone/router/src/ctx/staking-client';
import { approveTransaction } from './approve-transaction';

// all rpc implementations, local and proxy
import { rpcImpls } from './impls';
import { getRpcImpls } from './get-rpc-impls';
import { backOff } from 'exponential-backoff';
import {
FullViewingKey,
Expand All @@ -56,6 +56,32 @@ const startServices = async () => {
return services;
};

/**
* When a user first onboards with the extension, they won't have chosen a gRPC
* endpoint yet. So we'll wait until they've chosen one to start trying to make
* requests against it.
*/
const waitUntilGrpcEndpointExists = async () => {
const grpcEndpointPromise = Promise.withResolvers();
const grpcEndpoint = await localExtStorage.get('grpcEndpoint');

if (grpcEndpoint) {
grpcEndpointPromise.resolve();
} else {
const listener = (changes: Record<string, { newValue?: unknown }>) => {
if (changes['grpcEndpoint']?.newValue) {
grpcEndpointPromise.resolve();
localExtStorage.removeListener(listener);
}
};
localExtStorage.addListener(listener);
}

return grpcEndpointPromise.promise;
};

await waitUntilGrpcEndpointExists();

const services = await backOff(startServices, {
retry: (e, attemptNumber) => {
if (process.env['NODE_ENV'] === 'development')
Expand All @@ -64,6 +90,7 @@ const services = await backOff(startServices, {
},
});

const rpcImpls = await getRpcImpls();
let custodyClient: PromiseClient<typeof CustodyService> | undefined;
let stakingClient: PromiseClient<typeof StakingService> | undefined;
const handler = connectChannelAdapter({
Expand Down
20 changes: 20 additions & 0 deletions apps/extension/src/shared/rpc-endpoints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { STAKING_TOKEN_METADATA } from '@penumbra-zone/constants/src/assets';

interface RpcEndpoint {
name: string;
url: string;
imageUrl?: string;
}

export const RPC_ENDPOINTS: RpcEndpoint[] = [
{
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,
},
];
jessepinho marked this conversation as resolved.
Show resolved Hide resolved
27 changes: 27 additions & 0 deletions apps/extension/src/utils/use-store-shallow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useShallow } from 'zustand/react/shallow';
jessepinho marked this conversation as resolved.
Show resolved Hide resolved
import { AllSlices, useStore } from '../state';

/**
* Like `useStore()`, but checks for shallow equality to prevent unnecessary
* re-renders if none of the properties returned by `selector` have changed.
*
* Calling `useStoreShallow(selector)` is the same as calling
* `useStore(useShallow(selector))`. But it's so common to use those two
* together that this function combines both for ease of use.
*
* @example
* ```tsx
* import { useStoreShallow } from '../utils/use-store-shallow';
*
* const myComponentSelector = (state: AllSlices) => ({
* prop1: state.mySlice.prop1,
* prop2: state.mySlice.prop2,
* });
*
* const MyComponent = () => {
* const state = useStoreShallow(myComponentSelector);
* };
* ```
*/
export const useStoreShallow = <U>(selector: (state: AllSlices) => U) =>
useStore(useShallow<AllSlices, U>(selector));
1 change: 1 addition & 0 deletions packages/services/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export class Services implements ServicesInterface {
const grpcEndpoint = await localExtStorage.get('grpcEndpoint');
const wallet0 = (await localExtStorage.get('wallets'))[0];
if (!wallet0) throw Error('No wallets found');
if (!grpcEndpoint) throw Error('No gRPC endpoint found');
const { id: walletId, fullViewingKey } = Wallet.fromJson(wallet0);
return {
...initConfig,
Expand Down
14 changes: 14 additions & 0 deletions packages/storage/src/chrome/base.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { EmptyObject, isEmptyObj } from '@penumbra-zone/types/src/utility';

type Listener = (changes: Record<string, { oldValue?: unknown; newValue?: unknown }>) => void;

export interface IStorage {
get(key: string): Promise<Record<string, unknown>>;
set(items: Record<string, unknown>): Promise<void>;
remove(key: string): Promise<void>;
onChanged: {
addListener(listener: Listener): void;
removeListener(listener: Listener): void;
};
}
jessepinho marked this conversation as resolved.
Show resolved Hide resolved

export interface StorageItem<T> {
Expand Down Expand Up @@ -49,6 +55,14 @@ export class ExtensionStorage<T> {
await this.storage.remove(String(key));
}

addListener(listener: Listener) {
this.storage.onChanged.addListener(listener);
}

removeListener(listener: Listener) {
this.storage.onChanged.removeListener(listener);
}

private async migrateIfNeeded<K extends keyof T>(key: K, item: StorageItem<T[K]>): Promise<T[K]> {
if (item.version !== this.version) {
const migrationFn = this.migrations[key]?.[item.version];
Expand Down
2 changes: 0 additions & 2 deletions packages/storage/src/chrome/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,10 @@ import { LocalStorageState, LocalStorageVersion } from './types';
// declaration in `apps/extension/prax.d.ts` because we are in an independent
// package. we should probably move the localExtStorage declaration into the
// extension app.
declare const DEFAULT_GRPC_URL: string;
declare const MINIFRONT_URL: string;

export const localDefaults: LocalStorageState = {
wallets: [],
grpcEndpoint: DEFAULT_GRPC_URL,
jessepinho marked this conversation as resolved.
Show resolved Hide resolved
fullSyncHeight: 0,
knownSites: [{ origin: MINIFRONT_URL, choice: UserChoice.Approved, date: Date.now() }],
};
Expand Down
Loading
Loading