Skip to content

Commit

Permalink
Merge pull request #102 from spacemeshos/feat-82-create-key-option
Browse files Browse the repository at this point in the history
Add "Create new key" option into Account creation modal
  • Loading branch information
brusherru authored Oct 14, 2024
2 parents ae2cb3d + ceb85a2 commit 00b2269
Show file tree
Hide file tree
Showing 7 changed files with 235 additions and 29 deletions.
130 changes: 112 additions & 18 deletions src/components/CreateAccountModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { Form, useForm } from 'react-hook-form';

import {
Expand All @@ -20,7 +20,10 @@ import { useCurrentHRP } from '../hooks/useNetworkSelectors';
import { useAccountsList } from '../hooks/useWalletSelectors';
import usePassword from '../store/usePassword';
import useWallet from '../store/useWallet';
import { findUnusedKey, getUsedPublicKeys } from '../utils/account';
import Bip32KeyDerivation from '../utils/bip32';
import {
CREATE_NEW_KEY_LITERAL,
GENESIS_VESTING_ACCOUNTS,
GENESIS_VESTING_END,
GENESIS_VESTING_START,
Expand Down Expand Up @@ -48,11 +51,11 @@ function CreateAccountModal({
isOpen,
onClose,
}: CreateAccountModalProps): JSX.Element {
const { createAccount, wallet } = useWallet();
const { createAccount, createKeyPair, wallet } = useWallet();
const { withPassword } = usePassword();
const hrp = useCurrentHRP();
const accounts = useAccountsList(hrp);
const keys = wallet?.keychain || [];
const keys = useMemo(() => wallet?.keychain || [], [wallet]);
const defaultValues = {
displayName: '',
Required: 1,
Expand All @@ -75,6 +78,36 @@ function CreateAccountModal({
const selectedTemplate = watch('templateAddress');
const selectedOwner = watch('Owner');
const totalAmount = watch('TotalAmount');
const selectedPublicKey = watch('PublicKey');
const selectedPublicKeys = watch('PublicKeys');

const usedPublicKeys = useMemo(
() => getUsedPublicKeys(accounts, keys),
[accounts, keys]
);
const unusedKey = useMemo(
() => findUnusedKey(keys, usedPublicKeys),
[keys, usedPublicKeys]
);
const unusedKeys = useMemo(
() => keys.filter((key) => !usedPublicKeys.has(key.publicKey)),
[keys, usedPublicKeys]
);

const isKeyUsed = (() => {
if (selectedTemplate === StdPublicKeys.SingleSig) {
return usedPublicKeys.has(selectedPublicKey);
}
if (
selectedTemplate === StdPublicKeys.MultiSig ||
selectedTemplate === StdPublicKeys.Vesting
) {
return (selectedPublicKeys || []).some((pk: string) =>
usedPublicKeys.has(pk)
);
}
return false;
})();

useEffect(() => {
if (selectedTemplate === StdPublicKeys.Vault) {
Expand All @@ -87,16 +120,18 @@ function CreateAccountModal({
}, [register, selectedTemplate, unregister]);

useEffect(() => {
const owner = selectedOwner || getValues('Owner');
if (Object.hasOwn(GENESIS_VESTING_ACCOUNTS, owner)) {
const amount =
GENESIS_VESTING_ACCOUNTS[
owner as keyof typeof GENESIS_VESTING_ACCOUNTS
];
setValue('TotalAmount', String(amount));
setValue('InitialUnlockAmount', String(amount / 4n));
setValue('VestingStart', GENESIS_VESTING_START);
setValue('VestingEnd', GENESIS_VESTING_END);
if (selectedTemplate === StdPublicKeys.Vault) {
const owner = selectedOwner || getValues('Owner');
if (Object.hasOwn(GENESIS_VESTING_ACCOUNTS, owner)) {
const amount =
GENESIS_VESTING_ACCOUNTS[
owner as keyof typeof GENESIS_VESTING_ACCOUNTS
];
setValue('TotalAmount', String(amount));
setValue('InitialUnlockAmount', String(amount / 4n));
setValue('VestingStart', GENESIS_VESTING_START);
setValue('VestingEnd', GENESIS_VESTING_END);
}
}
}, [getValues, selectedOwner, selectedTemplate, setValue]);

Expand All @@ -106,20 +141,66 @@ function CreateAccountModal({
}
}, [totalAmount, setValue]);

const multiKeyValues = useMemo(
() => [unusedKey?.publicKey || CREATE_NEW_KEY_LITERAL],
[unusedKey]
);

const close = () => {
reset(defaultValues);
onClose();
};

const createNewKeyPairIfNeeded = async (
values: FormValues,
password: string
): Promise<FormValues> => {
if (
values.templateAddress === StdPublicKeys.SingleSig &&
values.PublicKey === CREATE_NEW_KEY_LITERAL
) {
const path = Bip32KeyDerivation.createPath(wallet?.keychain?.length || 0);
const key = await createKeyPair(values.displayName, path, password);
return { ...values, PublicKey: key.publicKey };
}
if (
(values.templateAddress === StdPublicKeys.MultiSig ||
values.templateAddress === StdPublicKeys.Vesting) &&
values.PublicKeys.some((pk) => pk === CREATE_NEW_KEY_LITERAL)
) {
let keysCreated = 0;
const newKeys = await values.PublicKeys.reduce(async (acc, pk, idx) => {
const prev = await acc;
if (pk === CREATE_NEW_KEY_LITERAL) {
const path = Bip32KeyDerivation.createPath(
(wallet?.keychain?.length || 0) + keysCreated
);
keysCreated += 1;
const newKey = await createKeyPair(
`${values.displayName} #${idx}`,
path,
password
).then((k) => k.publicKey);
return [...prev, newKey];
}
return [...prev, pk];
}, Promise.resolve([] as string[]));
return { ...values, PublicKeys: newKeys };
}
return values;
};

const submit = handleSubmit(async (data) => {
const success = await withPassword(
(password) =>
createAccount(
async (password) => {
const formValues = await createNewKeyPairIfNeeded(data, password);
return createAccount(
data.displayName,
data.templateAddress,
extractSpawnArgs(data),
extractSpawnArgs(formValues),
password
),
);
},
'Create an Account',
// eslint-disable-next-line max-len
`Please enter the password to create the new account "${
Expand Down Expand Up @@ -222,6 +303,8 @@ function CreateAccountModal({
unregister={unregister}
errors={errors}
isSubmitted={isSubmitted}
hasCreateOption
autoSelectKeys={unusedKeys}
/>
</>
);
Expand Down Expand Up @@ -263,6 +346,9 @@ function CreateAccountModal({
unregister={unregister}
errors={errors}
isSubmitted={isSubmitted}
values={multiKeyValues}
hasCreateOption
autoSelectKeys={unusedKeys}
/>
</>
);
Expand All @@ -283,7 +369,8 @@ function CreateAccountModal({
errors={errors}
isSubmitted={isSubmitted}
isRequired
value={keys[0]?.publicKey}
value={unusedKey?.publicKey}
hasCreateOption
/>
</>
);
Expand Down Expand Up @@ -327,6 +414,13 @@ function CreateAccountModal({
<Box pt={2} pb={1} color="brand.lightGray">
{renderTemplateSpecificFields()}
</Box>
{isKeyUsed && (
<Text color="orange">
The selected key is already used in another account.
<br />
Are you sure you want to create another one?
</Text>
)}
</ModalBody>
<ModalFooter>
<Button onClick={submit} variant="whiteModal" w="full">
Expand Down
26 changes: 24 additions & 2 deletions src/components/FormKeySelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import { isHexString } from '../types/common';
import { SafeKey } from '../types/wallet';
import { getAbbreviatedHexString } from '../utils/abbr';
import { CREATE_NEW_KEY_LITERAL } from '../utils/constants';

type Props<
T extends FieldValues,
Expand All @@ -38,6 +39,7 @@ type Props<
isRequired?: boolean;
defaultForeign?: boolean;
value?: string | null;
hasCreateOption?: boolean;
}>;

enum KeyType {
Expand All @@ -55,8 +57,12 @@ function FormKeySelect<T extends FieldValues, FieldName extends Path<T>>({
isRequired = false,
children = '',
value = null,
hasCreateOption = false,
}: Props<T, FieldName>): JSX.Element {
const isLocalValue = !value || keys.some((key) => key.publicKey === value);
const isLocalValue =
!value ||
(hasCreateOption && value === CREATE_NEW_KEY_LITERAL) ||
keys.some((key) => key.publicKey === value);
const [keyType, setKeyType] = useState(
isLocalValue ? KeyType.Local : KeyType.Foreign
);
Expand All @@ -70,9 +76,19 @@ function FormKeySelect<T extends FieldValues, FieldName extends Path<T>>({
setKeyType(KeyType.Foreign);
}
}, [keys, value]);

useEffect(() => {
if (!hasCreateOption) return;
if (keyType === KeyType.Local && formValue === '') {
register(fieldName, {
value: CREATE_NEW_KEY_LITERAL as PathValue<T, FieldName>,
});
}
}, [fieldName, formValue, hasCreateOption, keyType, register]);

useEffect(
() => () => unregister(fieldName),
[unregister, fieldName, keyType, value]
[unregister, fieldName, keyType]
);

const error = get(errors, fieldName) as FieldError | undefined;
Expand All @@ -93,6 +109,12 @@ function FormKeySelect<T extends FieldValues, FieldName extends Path<T>>({
default:
return (
<Select {...register(fieldName, { value: formValue })}>
{hasCreateOption && (
<>
<option value={CREATE_NEW_KEY_LITERAL}>Create New Key</option>
<option disabled>--------------------</option>
</>
)}
{keys.map((key) => (
<option key={key.publicKey} value={key.publicKey}>
{key.displayName} ({getAbbreviatedHexString(key.publicKey)})
Expand Down
33 changes: 27 additions & 6 deletions src/components/FormMultiKeySelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import { Button, IconButton, Text } from '@chakra-ui/react';
import { IconPlus, IconTrash } from '@tabler/icons-react';

import { SafeKey } from '../types/wallet';
import { BUTTON_ICON_SIZE, MAX_MULTISIG_AMOUNT } from '../utils/constants';
import {
BUTTON_ICON_SIZE,
CREATE_NEW_KEY_LITERAL,
MAX_MULTISIG_AMOUNT,
} from '../utils/constants';

import FormKeySelect from './FormKeySelect';

Expand All @@ -28,8 +32,9 @@ type Props<T extends FieldValues, FieldName extends ArrayPath<T>> = {
errors: FieldErrors<T>;
isSubmitted?: boolean;
values?: string[] | null;
hasCreateOption?: boolean;
autoSelectKeys?: null | SafeKey[];
};

function FormMultiKeySelect<
T extends FieldValues,
FieldName extends ArrayPath<T>
Expand All @@ -42,6 +47,8 @@ function FormMultiKeySelect<
errors,
isSubmitted = false,
values = null,
hasCreateOption = false,
autoSelectKeys = null,
}: Props<T, FieldName>): JSX.Element {
const { fields, append, remove } = useFieldArray({
control,
Expand All @@ -50,13 +57,12 @@ function FormMultiKeySelect<
const addEmptyField = useCallback(
() =>
append(
(keys[fields.length]?.publicKey ||
`0x${String(fields.length).padStart(2, '0')}`) as FieldArray<
`0x${String(fields.length).padStart(2, '0')}` as FieldArray<
T,
FieldName
>
),
[append, fields.length, keys]
[append, fields.length]
);

useEffect(() => {
Expand All @@ -69,6 +75,20 @@ function FormMultiKeySelect<
return () => remove();
}, [values, append, remove, keys]);

const getSelectValue = useCallback(
(index: number) => {
const nextRecommendedKey = autoSelectKeys
? autoSelectKeys[index]?.publicKey
: keys[index]?.publicKey;
const defaultKey = hasCreateOption
? CREATE_NEW_KEY_LITERAL
: `0x${String(index).padStart(2, '0')}`;
const nextKey = nextRecommendedKey ?? defaultKey;
return values?.[index] || nextKey;
},
[autoSelectKeys, hasCreateOption, keys, values]
);

const rootError = errors[fieldName]?.message;
return (
<>
Expand All @@ -87,7 +107,8 @@ function FormMultiKeySelect<
errors={errors}
isSubmitted={isSubmitted}
isRequired
value={values?.[index] || keys[index]?.publicKey}
value={getSelectValue(index)}
hasCreateOption={hasCreateOption}
>
<IconButton
icon={<IconTrash size={BUTTON_ICON_SIZE} />}
Expand Down
12 changes: 9 additions & 3 deletions src/components/createAccountSchema.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,26 @@ import { StdPublicKeys } from '@spacemesh/sm-codec';

import { Bech32AddressSchema } from '../api/schemas/address';
import { PublicKeySchema } from '../api/schemas/common';
import { CREATE_NEW_KEY_LITERAL } from '../utils/constants';
import { AnySpawnArguments } from '../utils/templates';

const PublicKeyOrNewSchema = z.union([
PublicKeySchema,
z.literal(CREATE_NEW_KEY_LITERAL),
]);

const DisplayNameSchema = z.string().min(2);
const SingleSigSchema = z.object({
displayName: DisplayNameSchema,
templateAddress: z.literal(StdPublicKeys.SingleSig),
PublicKey: PublicKeySchema,
PublicKey: PublicKeyOrNewSchema,
});
const MultiSigSchema = z.object({
displayName: DisplayNameSchema,
templateAddress: z.literal(StdPublicKeys.MultiSig),
Required: z.number().min(0).max(10),
PublicKeys: z
.array(PublicKeySchema)
.array(PublicKeyOrNewSchema)
.min(1, 'MultiSig account requires at least two parties'),
});
const VaultSchema = z.object({
Expand All @@ -34,7 +40,7 @@ const VestingSchema = z.object({
templateAddress: z.literal(StdPublicKeys.Vesting),
Required: z.number().min(0).max(10),
PublicKeys: z
.array(PublicKeySchema)
.array(PublicKeyOrNewSchema)
.min(1, 'Vesting account requires at least two parties'),
});
export const FormSchema = z.discriminatedUnion('templateAddress', [
Expand Down
Loading

0 comments on commit 00b2269

Please sign in to comment.