From 0b6466a3255c21d009be03215a97452453ae72a0 Mon Sep 17 00:00:00 2001 From: Kheops <26880866+0xKheops@users.noreply.github.com> Date: Thu, 31 Aug 2023 03:51:33 +0900 Subject: [PATCH 01/13] feat: form container normal casing for errors --- .changeset/rotten-suits-try.md | 5 +++++ packages/talisman-ui/src/components/FormFieldContainer.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/rotten-suits-try.md diff --git a/.changeset/rotten-suits-try.md b/.changeset/rotten-suits-try.md new file mode 100644 index 0000000000..ae2b6b87d8 --- /dev/null +++ b/.changeset/rotten-suits-try.md @@ -0,0 +1,5 @@ +--- +"talisman-ui": patch +--- + +fix: change container errors to normal casing diff --git a/packages/talisman-ui/src/components/FormFieldContainer.tsx b/packages/talisman-ui/src/components/FormFieldContainer.tsx index b1be3218c3..a6373b8c73 100644 --- a/packages/talisman-ui/src/components/FormFieldContainer.tsx +++ b/packages/talisman-ui/src/components/FormFieldContainer.tsx @@ -21,7 +21,7 @@ export const FormFieldContainer: FC = ({
{label}
{children}
{!noErrorRow && ( -
+
{error}
)} From df5034717a574bb3a900ddf83d8509803952de6f Mon Sep 17 00:00:00 2001 From: Kheops <26880866+0xKheops@users.noreply.github.com> Date: Thu, 31 Aug 2023 03:56:06 +0900 Subject: [PATCH 02/13] feat: import account with custom derivation path --- apps/extension/src/core/handlers/index.ts | 1 + .../AccountAddSecretMnemonicForm.tsx | 195 ++++++++++-------- .../DerivationModeDropdown.tsx | 64 ++++++ .../AccountCreate/AccountAddSecret/context.ts | 8 +- .../domains/Account/AccountTypeSelector.tsx | 2 +- 5 files changed, 186 insertions(+), 84 deletions(-) create mode 100644 apps/extension/src/ui/domains/Account/AccountCreate/AccountAddSecret/DerivationModeDropdown.tsx diff --git a/apps/extension/src/core/handlers/index.ts b/apps/extension/src/core/handlers/index.ts index 679ca7b8b5..ec1de6ec68 100644 --- a/apps/extension/src/core/handlers/index.ts +++ b/apps/extension/src/core/handlers/index.ts @@ -15,6 +15,7 @@ const tabs = new Tabs(tabStores) // dev mode logs shouldn't log content for these messages const OBFUSCATE_LOG_MESSAGES: MessageTypes[] = [ "pri(mnemonic.unlock)", + "pri(mnemonic.address)", "pri(app.authenticate)", "pri(app.checkPassword)", "pri(app.changePassword)", diff --git a/apps/extension/src/ui/domains/Account/AccountCreate/AccountAddSecret/AccountAddSecretMnemonicForm.tsx b/apps/extension/src/ui/domains/Account/AccountCreate/AccountAddSecret/AccountAddSecretMnemonicForm.tsx index c3fabdf9d4..f3d68827c5 100644 --- a/apps/extension/src/ui/domains/Account/AccountCreate/AccountAddSecret/AccountAddSecretMnemonicForm.tsx +++ b/apps/extension/src/ui/domains/Account/AccountCreate/AccountAddSecret/AccountAddSecretMnemonicForm.tsx @@ -1,10 +1,11 @@ import { AccountAddressType } from "@core/domains/accounts/types" import { getEthDerivationPath } from "@core/domains/ethereum/helpers" import { yupResolver } from "@hookform/resolvers/yup" +import { mnemonicValidate } from "@polkadot/util-crypto" import { HeaderBlock } from "@talisman/components/HeaderBlock" import { notify, notifyUpdate } from "@talisman/components/Notifications" import { Spacer } from "@talisman/components/Spacer" -import { classNames } from "@talismn/util" +import { classNames, encodeAnyAddress } from "@talismn/util" import { api } from "@ui/api" import { AccountIcon } from "@ui/domains/Account/AccountIcon" import { AccountTypeSelector } from "@ui/domains/Account/AccountTypeSelector" @@ -16,23 +17,19 @@ import { useTranslation } from "react-i18next" import { useNavigate } from "react-router-dom" import { Button, - Checkbox, FormFieldContainer, FormFieldInputText, FormFieldTextarea, + Tooltip, + TooltipContent, + TooltipTrigger, } from "talisman-ui" import * as yup from "yup" -import { useAccountAddSecret } from "./context" +import { AccountAddDerivationMode, useAccountAddSecret } from "./context" +import { DerivationModeDropdown } from "./DerivationModeDropdown" -type FormData = { - name: string - type: AccountAddressType - mnemonic: string - multi: boolean -} - -const cleanupMnemonic = (input = "") => +export const cleanupMnemonic = (input = "") => input .trim() .toLowerCase() @@ -40,7 +37,7 @@ const cleanupMnemonic = (input = "") => .filter(Boolean) //remove empty strings .join(" ") -const isValidEthPrivateKey = (privateKey?: string) => { +export const isValidEthPrivateKey = (privateKey?: string) => { if (!privateKey) return false try { @@ -51,42 +48,27 @@ const isValidEthPrivateKey = (privateKey?: string) => { } } -// for polkadot, do not force //0 derivation path to preserve backwards compatibility (since beta we import mnemonics as-is) -// but for ethereum, use metamask's derivation path -const ETHEREUM_DERIVATION_PATH = getEthDerivationPath() - -const getAccountUri = async (secret: string, type: AccountAddressType) => { - if (!secret || !type) throw new Error("Missing arguments") +export const getSuri = (secret: string, type: AccountAddressType, derivationPath?: string) => { + if (!secret || !type) return null // metamask exports private key without the 0x in front of it // pjs keyring & crypto api will throw if it's missing if (type === "ethereum" && isValidEthPrivateKey(secret)) return secret.startsWith("0x") ? secret : `0x${secret}` - if (await testValidMnemonic(secret)) - return type === "ethereum" ? `${secret}${ETHEREUM_DERIVATION_PATH}` : secret - throw new Error("Invalid recovery phrase") -} + if (!mnemonicValidate(secret)) return null -const testNoDuplicate = async ( - allAccountsAddresses: string[], - type: AccountAddressType, - mnemonic?: string -) => { - if (!mnemonic) return false - try { - const uri = await getAccountUri(mnemonic, type) - const address = await api.addressFromMnemonic(uri, type) - return !allAccountsAddresses.includes(address) - } catch (err) { - return false - } + return derivationPath && !derivationPath.startsWith("/") + ? `${secret}/${derivationPath}` + : `${secret}${derivationPath}` } -const testValidMnemonic = async (val?: string) => { - // Don't bother calling the api if the mnemonic isn't the right length to reduce Sentry noise - if (!val || ![12, 24].includes(val.split(" ").length)) return false - return await api.accountValidateMnemonic(val) +type FormData = { + name: string + type: AccountAddressType + mnemonic: string + mode: AccountAddDerivationMode + derivationPath: string } export const AccountAddSecretMnemonicForm = () => { @@ -104,7 +86,8 @@ export const AccountAddSecretMnemonicForm = () => { .object({ name: yup.string().trim().required(""), type: yup.string().required("").oneOf(["ethereum", "sr25519"]), - multi: yup.boolean(), + mode: yup.string().required("").oneOf(["first", "custom", "multi"]), + derivationPath: yup.string().trim(), mnemonic: yup .string() .trim() @@ -117,33 +100,42 @@ export const AccountAddSecretMnemonicForm = () => { .test( "is-valid-mnemonic-ethereum", t("Invalid secret"), - (val) => isValidEthPrivateKey(val) || testValidMnemonic(val) - ) - .when("multi", { - is: false, - then: yup - .string() - .test("not-duplicate-ethereum", t("Account already exists"), async (val) => - testNoDuplicate(accountAddresses, "ethereum", val) - ), - }), + (val) => isValidEthPrivateKey(val) || mnemonicValidate(val ?? "") + ), otherwise: yup .string() .test("is-valid-mnemonic-sr25519", t("Invalid secret"), (val) => - testValidMnemonic(val) - ) - .when("multi", { - is: false, - then: yup - .string() - .test("not-duplicate-sr25519", t("Account already exists"), async (val) => - testNoDuplicate(accountAddresses, "sr25519", val) - ), - }), + mnemonicValidate(val ?? "") + ), }), }) - .required(), - [t, accountAddresses] + .required() + .test("account-exists", t("Account exists"), async (val, ctx) => { + const { mnemonic, type, derivationPath, mode } = val as FormData + if (!val || mode === "multi") return true + + const suri = getSuri(mnemonic, type, derivationPath) + if (!suri) return true + + let address: string + try { + address = encodeAnyAddress(await api.addressFromMnemonic(suri, type)) + } catch (err) { + return ctx.createError({ + path: "derivationPath", + message: t("Invalid derivation path"), + }) + } + + if (accountAddresses.some((a) => encodeAnyAddress(a) === address)) + return ctx.createError({ + path: mode === "custom" ? "derivationPath" : "mnemonic", + message: t("Account already exists"), + }) + + return true + }), + [accountAddresses, t] ) const { @@ -159,14 +151,14 @@ export const AccountAddSecretMnemonicForm = () => { resolver: yupResolver(schema), }) - const { type, mnemonic } = watch() + const { type, mnemonic, mode, derivationPath } = watch() const isPrivateKey = useMemo( () => type === "ethereum" && isValidEthPrivateKey(mnemonic), [mnemonic, type] ) useEffect(() => { - if (isPrivateKey) setValue("multi", false, { shouldValidate: true }) + if (isPrivateKey) setValue("mode", "first", { shouldValidate: true }) }, [isPrivateKey, setValue]) const words = useMemo( @@ -179,21 +171,25 @@ export const AccountAddSecretMnemonicForm = () => { useEffect(() => { const refreshTargetAddress = async () => { try { - const uri = await getAccountUri(cleanupMnemonic(mnemonic), type) - setTargetAddress(await api.addressFromMnemonic(uri, type)) + const suri = getSuri(cleanupMnemonic(mnemonic), type, derivationPath) + if (!suri) return setTargetAddress(undefined) + setTargetAddress(await api.addressFromMnemonic(suri, type)) } catch (err) { setTargetAddress(undefined) } } refreshTargetAddress() - }, [isValid, mnemonic, type]) + }, [derivationPath, isValid, mnemonic, type]) const submit = useCallback( - async ({ type, name, mnemonic, multi }: FormData) => { - updateData({ type, name, mnemonic, multi }) - if (multi) navigate("multiple") + async ({ type, name, mnemonic, mode, derivationPath }: FormData) => { + updateData({ type, name, mnemonic, mode, derivationPath }) + if (mode === "multi") navigate("multiple") else { + const suri = getSuri(mnemonic, type, derivationPath) + if (!suri) return + const notificationId = notify( { type: "processing", @@ -203,8 +199,7 @@ export const AccountAddSecretMnemonicForm = () => { { autoClose: false } ) try { - const uri = await getAccountUri(mnemonic, type) - onSuccess(await api.accountCreateFromSeed(name, uri, type)) + onSuccess(await api.accountCreateFromSeed(name, suri, type)) notifyUpdate(notificationId, { type: "success", title: t("Account imported"), @@ -225,12 +220,33 @@ export const AccountAddSecretMnemonicForm = () => { const handleTypeChange = useCallback( (type: AccountAddressType) => { setValue("type", type, { shouldValidate: true }) + if (mode === "first") + setValue("derivationPath", type === "ethereum" ? getEthDerivationPath() : "", { + shouldValidate: true, + }) // revalidate to get rid of "invalid mnemonic" with a private key, when switching to ethereum trigger() }, - [setValue, trigger] + [mode, setValue, trigger] + ) + + const handleModeChange = useCallback( + (mode: AccountAddDerivationMode) => { + setValue("mode", mode, { shouldValidate: true }) + if (mode === "first") + setValue("derivationPath", type === "ethereum" ? getEthDerivationPath() : "", { + shouldValidate: true, + }) + }, + [setValue, type] ) + useEffect(() => { + setValue("derivationPath", type === "ethereum" ? getEthDerivationPath() : "", { + shouldValidate: true, + }) + }, [setValue, type]) + return (
{ autoFocus data-lpignore after={ - targetAddress ? : null + targetAddress ? ( + + + + + {targetAddress} + + ) : null } /> @@ -266,17 +289,25 @@ export const AccountAddSecretMnemonicForm = () => { data-lpignore spellCheck={false} /> -
-
{t("Word count: {{words}}", { words })}
-
{errors.mnemonic?.message}
+
+
{t("Word count: {{words}}", { words })}
+
{errors.mnemonic?.message}
- + - {t("Import multiple accounts from this recovery phrase")} - + +
From ff211ed9834e8a61e607019896dfba943feb809f Mon Sep 17 00:00:00 2001 From: Kheops <26880866+0xKheops@users.noreply.github.com> Date: Fri, 1 Sep 2023 13:05:34 +0900 Subject: [PATCH 03/13] feat: derivation path in new account screen --- .../src/@talisman/components/Accordion.tsx | 13 +- .../@talisman/util/isValidDerivationPath.ts | 27 +++ .../src/core/domains/accounts/handler.ts | 26 ++- .../src/core/domains/accounts/types.ts | 3 + apps/extension/src/ui/api/api.ts | 2 + apps/extension/src/ui/api/types.ts | 1 + .../AccountAdd/AccountAddDerivedPage.tsx | 2 +- .../AddAccount/AccountAddDerivedPage.tsx | 2 +- .../AccountAddDerivedForm.tsx | 170 ++++++++++-------- .../AccountAddMnemonicDropdown.tsx | 87 +++++++++ 10 files changed, 246 insertions(+), 87 deletions(-) create mode 100644 apps/extension/src/@talisman/util/isValidDerivationPath.ts rename apps/extension/src/ui/domains/Account/AccountCreate/{ => AccountAddDerived}/AccountAddDerivedForm.tsx (54%) create mode 100644 apps/extension/src/ui/domains/Account/AccountCreate/AccountAddDerived/AccountAddMnemonicDropdown.tsx diff --git a/apps/extension/src/@talisman/components/Accordion.tsx b/apps/extension/src/@talisman/components/Accordion.tsx index e4a4fa1b01..1ca4243734 100644 --- a/apps/extension/src/@talisman/components/Accordion.tsx +++ b/apps/extension/src/@talisman/components/Accordion.tsx @@ -24,11 +24,12 @@ export const AccordionIcon: FC<{ isOpen: boolean; className?: string }> = ({
) -export const Accordion: FC<{ isOpen: boolean; children?: ReactNode; alwaysRender?: boolean }> = ({ - isOpen, - children, - alwaysRender, -}) => { +export const Accordion: FC<{ + isOpen: boolean + children?: ReactNode + alwaysRender?: boolean + className?: string +}> = ({ isOpen, children, alwaysRender, className }) => { const [contentHeight, setContentHeight] = useState() const refContainer = useRef(null) @@ -84,7 +85,7 @@ export const Accordion: FC<{ isOpen: boolean; children?: ReactNode; alwaysRender return ( { + try { + // standalone/disposable keyring, this is not the one that stores user's keys + const keyring = new Keyring({ type }) + + const suri = + !!derivationPath && !derivationPath.startsWith("/") + ? `${TEST_MNEMONIC}/${derivationPath}` + : `${TEST_MNEMONIC}${derivationPath}` + + // simply test if keyring can derive an address from the suri + const { address } = keyring.addFromUri(suri) + return !!address + } catch (err) { + return false + } +} diff --git a/apps/extension/src/core/domains/accounts/handler.ts b/apps/extension/src/core/domains/accounts/handler.ts index ba8ab534ba..eaf41f30d6 100644 --- a/apps/extension/src/core/domains/accounts/handler.ts +++ b/apps/extension/src/core/domains/accounts/handler.ts @@ -36,6 +36,7 @@ import keyring from "@polkadot/ui-keyring" import { assert } from "@polkadot/util" import { ethereumEncode, isEthereumAddress, mnemonicValidate } from "@polkadot/util-crypto" import { addressFromMnemonic } from "@talisman/util/addressFromMnemonic" +import { isValidDerivationPath } from "@talisman/util/isValidDerivationPath" import { decodeAnyAddress, encodeAnyAddress, sleep } from "@talismn/util" import { combineLatest } from "rxjs" @@ -71,11 +72,28 @@ export default class AccountsHandler extends ExtensionHandler { mnemonic = options.mnemonic } - const { val: derivationPath, err } = getNextDerivationPathForMnemonic(mnemonic, type) - if (err) throw new Error(derivationPath) + let derivationPath: string + if (typeof options.derivationPath === "string") { + derivationPath = options.derivationPath + } else { + const { val, err } = getNextDerivationPathForMnemonic(mnemonic, type) + if (err) throw new Error(val) + else derivationPath = val + } + + const suri = + derivationPath && !derivationPath.startsWith("/") + ? `${mnemonic}/${derivationPath}` + : `${mnemonic}${derivationPath}` + + const resultingAddress = encodeAnyAddress(addressFromMnemonic(suri, type)) + assert( + allAccounts.every((acc) => encodeAnyAddress(acc.address) !== resultingAddress), + "Account already exists" + ) const { pair } = keyring.addUri( - `${mnemonic}${derivationPath}`, + suri, password, { name, @@ -556,6 +574,8 @@ export default class AccountsHandler extends ExtensionHandler { return this.accountsCatalogRunActions(request as RequestAccountsCatalogAction[]) case "pri(accounts.validateMnemonic)": return this.accountValidateMnemonic(request as string) + case "pri(accounts.validateDerivationPath)": + return isValidDerivationPath(request as string) case "pri(accounts.setVerifierCertMnemonic)": return this.setVerifierCertMnemonic(request as RequestSetVerifierCertificateMnemonic) default: diff --git a/apps/extension/src/core/domains/accounts/types.ts b/apps/extension/src/core/domains/accounts/types.ts index 50338da4ee..03d845728c 100644 --- a/apps/extension/src/core/domains/accounts/types.ts +++ b/apps/extension/src/core/domains/accounts/types.ts @@ -181,10 +181,12 @@ export interface RequestAccountRename { export type RequestAccountCreateOptions = | { mnemonicId: string + derivationPath?: string } | { mnemonic: string confirmed: boolean + derivationPath?: string } export type RequestAccountCreate = { @@ -222,5 +224,6 @@ export interface AccountsMessages { "pri(accounts.catalog.subscribe)": [null, boolean, Trees] "pri(accounts.catalog.runActions)": [RequestAccountsCatalogAction[], boolean] "pri(accounts.validateMnemonic)": [string, boolean] + "pri(accounts.validateDerivationPath)": [string, boolean] "pri(accounts.setVerifierCertMnemonic)": [RequestSetVerifierCertificateMnemonic, boolean] } diff --git a/apps/extension/src/ui/api/api.ts b/apps/extension/src/ui/api/api.ts index bec602ac86..487c3564b4 100644 --- a/apps/extension/src/ui/api/api.ts +++ b/apps/extension/src/ui/api/api.ts @@ -136,6 +136,8 @@ export const api: MessageTypes = { messageService.sendMessage("pri(accounts.external.setIsPortfolio)", { address, isPortfolio }), accountValidateMnemonic: (mnemonic) => messageService.sendMessage("pri(accounts.validateMnemonic)", mnemonic), + accountValidateDerivationPath: (derivationPath) => + messageService.sendMessage("pri(accounts.validateDerivationPath)", derivationPath), setVerifierCertMnemonic: (verifierCertType, mnemonic, mnemonicId) => messageService.sendMessage("pri(accounts.setVerifierCertMnemonic)", { type: verifierCertType, diff --git a/apps/extension/src/ui/api/types.ts b/apps/extension/src/ui/api/types.ts index b479b80ecd..a8e08a8658 100644 --- a/apps/extension/src/ui/api/types.ts +++ b/apps/extension/src/ui/api/types.ts @@ -145,6 +145,7 @@ export default interface MessageTypes { accountExportPrivateKey: (address: string, password: string) => Promise accountRename: (address: string, name: string) => Promise accountValidateMnemonic: (mnemonic: string) => Promise + accountValidateDerivationPath: (derivationPath: string) => Promise setVerifierCertMnemonic: ( type: VerifierCertificateType, mnemonic?: string, diff --git a/apps/extension/src/ui/apps/dashboard/routes/AccountAdd/AccountAddDerivedPage.tsx b/apps/extension/src/ui/apps/dashboard/routes/AccountAdd/AccountAddDerivedPage.tsx index 0a35436ac1..311c23707d 100644 --- a/apps/extension/src/ui/apps/dashboard/routes/AccountAdd/AccountAddDerivedPage.tsx +++ b/apps/extension/src/ui/apps/dashboard/routes/AccountAdd/AccountAddDerivedPage.tsx @@ -1,7 +1,7 @@ import { AccountAddressType } from "@core/domains/accounts/types" import { HeaderBlock } from "@talisman/components/HeaderBlock" import { Spacer } from "@talisman/components/Spacer" -import { AccountAddDerivedForm } from "@ui/domains/Account/AccountCreate/AccountAddDerivedForm" +import { AccountAddDerivedForm } from "@ui/domains/Account/AccountCreate/AccountAddDerived/AccountAddDerivedForm" import { useSelectAccountAndNavigate } from "@ui/hooks/useSelectAccountAndNavigate" import { useTranslation } from "react-i18next" import { useSearchParams } from "react-router-dom" diff --git a/apps/extension/src/ui/apps/onboard/routes/AddAccount/AccountAddDerivedPage.tsx b/apps/extension/src/ui/apps/onboard/routes/AddAccount/AccountAddDerivedPage.tsx index 3ff67858e2..bc93d7f410 100644 --- a/apps/extension/src/ui/apps/onboard/routes/AddAccount/AccountAddDerivedPage.tsx +++ b/apps/extension/src/ui/apps/onboard/routes/AddAccount/AccountAddDerivedPage.tsx @@ -1,7 +1,7 @@ import { OnboardDialog } from "@ui/apps/onboard/components/OnboardDialog" import { useOnboard } from "@ui/apps/onboard/context" import { Layout } from "@ui/apps/onboard/layout" -import { AccountAddDerivedForm } from "@ui/domains/Account/AccountCreate/AccountAddDerivedForm" +import { AccountAddDerivedForm } from "@ui/domains/Account/AccountCreate/AccountAddDerived/AccountAddDerivedForm" import { useTranslation } from "react-i18next" export const AccountAddDerivedPage = () => { diff --git a/apps/extension/src/ui/domains/Account/AccountCreate/AccountAddDerivedForm.tsx b/apps/extension/src/ui/domains/Account/AccountCreate/AccountAddDerived/AccountAddDerivedForm.tsx similarity index 54% rename from apps/extension/src/ui/domains/Account/AccountCreate/AccountAddDerivedForm.tsx rename to apps/extension/src/ui/domains/Account/AccountCreate/AccountAddDerived/AccountAddDerivedForm.tsx index 721f1c30ed..4fde1826ef 100644 --- a/apps/extension/src/ui/domains/Account/AccountCreate/AccountAddDerivedForm.tsx +++ b/apps/extension/src/ui/domains/Account/AccountCreate/AccountAddDerived/AccountAddDerivedForm.tsx @@ -1,13 +1,10 @@ -import { - AccountAddressType, - AccountJsonAny, - RequestAccountCreateOptions, -} from "@core/domains/accounts/types" +import { AccountAddressType, RequestAccountCreateOptions } from "@core/domains/accounts/types" import { log } from "@core/log" import { yupResolver } from "@hookform/resolvers/yup" +import { Accordion, AccordionIcon } from "@talisman/components/Accordion" import { notify, notifyUpdate } from "@talisman/components/Notifications" import { Spacer } from "@talisman/components/Spacer" -import { ArrowRightIcon, PlusIcon, SeedIcon } from "@talisman/theme/icons" +import { ArrowRightIcon } from "@talisman/theme/icons" import { classNames } from "@talismn/util" import { sleep } from "@talismn/util" import { api } from "@ui/api" @@ -19,39 +16,57 @@ import { } from "@ui/domains/Mnemonic/MnemonicCreateModal" import useAccounts from "@ui/hooks/useAccounts" import { useMnemonics } from "@ui/hooks/useMnemonics" -import { FC, useCallback, useEffect, useMemo, useState } from "react" +import { FC, PropsWithChildren, useCallback, useEffect, useMemo } from "react" import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" import { useSearchParams } from "react-router-dom" -import { Button, Dropdown, FormFieldContainer, FormFieldInputText } from "talisman-ui" +import { Button, Checkbox, FormFieldContainer, FormFieldInputText, useOpenClose } from "talisman-ui" import * as yup from "yup" -type MnemonicOption = { - value: string - label: string - accounts?: AccountJsonAny[] -} - -const GENERATE_MNEMONIC_OPTION = { - value: "new", - label: "Generate new recovery phrase", - accountsCount: undefined, -} +import { AccountAddMnemonicDropdown } from "./AccountAddMnemonicDropdown" type FormData = { name: string type: AccountAddressType + mnemonicId: string | null + customDerivationPath: boolean + derivationPath: string } type AccountAddDerivedFormProps = { onSuccess: (address: string) => void } +const AdvancedSettings: FC = ({ children }) => { + const { t } = useTranslation("admin") + const { toggle, isOpen } = useOpenClose() + + return ( +
+
+ +
+ {/* enlarge the area or it would hide focus ring on the textbox */} + + {children} + +
+ ) +} + const AccountAddDerivedFormInner: FC = ({ onSuccess }) => { const { t } = useTranslation("admin") // get type paramter from url const [params] = useSearchParams() const urlParamType = (params.get("type") ?? undefined) as AccountAddressType | undefined + const mnemonics = useMnemonics() const allAccounts = useAccounts() const accountNames = useMemo(() => allAccounts.map((a) => a.name), [allAccounts]) @@ -61,9 +76,23 @@ const AccountAddDerivedFormInner: FC = ({ onSuccess .object({ name: yup.string().required("").notOneOf(accountNames, t("Name already in use")), type: yup.string().required("").oneOf(["ethereum", "sr25519"]), + derivationPath: yup.string(), }) - .required(), + .required() + .test("validateDerivationPath", t("Invalid derivation path"), async (val, ctx) => { + const { customDerivationPath, derivationPath } = val as FormData + if (!customDerivationPath) return true + + if (!(await api.accountValidateDerivationPath(derivationPath))) + return ctx.createError({ + path: "derivationPath", + message: t("Invalid derivation path"), + }) + // TODO : check if resulting address already exists + + return true + }), [accountNames, t] ) @@ -77,46 +106,31 @@ const AccountAddDerivedFormInner: FC = ({ onSuccess } = useForm({ mode: "onChange", resolver: yupResolver(schema), - defaultValues: { type: urlParamType }, + defaultValues: { type: urlParamType, mnemonicId: mnemonics[0]?.id ?? null }, }) - const mnemonics = useMnemonics() - const mnemonicOptions: MnemonicOption[] = useMemo(() => { - const accountsByMnemonic = allAccounts.reduce((result, acc) => { - if (!acc.derivedMnemonicId) return result - if (!result[acc.derivedMnemonicId]) result[acc.derivedMnemonicId] = [] - result[acc.derivedMnemonicId].push(acc) - return result - }, {} as Record) - return [ - ...mnemonics - .map((m) => ({ - label: m.name, - value: m.id, - accounts: accountsByMnemonic[m.id] || [], - })) - .sort((a, b) => a.label.localeCompare(b.label)), - GENERATE_MNEMONIC_OPTION, - ] - }, [allAccounts, mnemonics]) - const [selectedMnemonic, setSelectedMnemonic] = useState( - () => mnemonicOptions[0] - ) - const { generateMnemonic } = useMnemonicCreateModal() const submit = useCallback( - async ({ name, type }: FormData) => { - if (!selectedMnemonic) return - + async ({ name, type, mnemonicId, customDerivationPath, derivationPath }: FormData) => { let options: RequestAccountCreateOptions - if (selectedMnemonic.value === "new") { + // note on derivation path : + // undefined : backend will use next available derivation path + // string : forces backend to use provided value, empty string being a valid derivation path + + if (mnemonicId === null) { const mnemonicOptions = await generateMnemonic() if (mnemonicOptions === null) return // cancelled - options = mnemonicOptions + options = { + ...mnemonicOptions, + derivationPath: customDerivationPath ? derivationPath : undefined, + } } else { - options = { mnemonicId: selectedMnemonic.value } + options = { + mnemonicId, // undefined and empty strings should not be treated the same + derivationPath: customDerivationPath ? derivationPath : undefined, + } } const notificationId = notify( @@ -148,7 +162,7 @@ const AccountAddDerivedFormInner: FC = ({ onSuccess }) } }, - [generateMnemonic, onSuccess, selectedMnemonic, t] + [generateMnemonic, onSuccess, t] ) const handleTypeChange = useCallback( @@ -159,7 +173,14 @@ const AccountAddDerivedFormInner: FC = ({ onSuccess [setFocus, setValue] ) - const type = watch("type") + const handleMnemonicChange = useCallback( + (mnemonicId: string | null) => { + setValue("mnemonicId", mnemonicId, { shouldValidate: true }) + }, + [setValue] + ) + + const { type, mnemonicId, customDerivationPath } = watch() useEffect(() => { // if we have a type in the url, set it @@ -171,30 +192,8 @@ const AccountAddDerivedFormInner: FC = ({ onSuccess
- {mnemonicOptions.length > 1 && ( - ( -
-
- {o.value === "new" ? : } -
-
{o.label}
- {o.value !== "new" && ( -
- {t("used by {{count}} accounts", { count: o.accounts?.length ?? 0 })} -
- )} -
- )} - value={selectedMnemonic} - onChange={setSelectedMnemonic} - buttonClassName="py-6 bg-field" - optionClassName="py-4 bg-field" - /> + {!!mnemonics.length && ( + )} = ({ onSuccess /> + + + {t("Custom derivation path (advanced)")} + + + + + +
- {/* enlarge the area or it would hide focus ring on the textbox */} - + {/* enlarge the area or it would hide focus ring on the inputs */} + {children}
@@ -183,13 +225,20 @@ const AccountAddDerivedFormInner: FC = ({ onSuccess }) => { [setValue] ) - const { type, mnemonicId, customDerivationPath } = watch() + const { type, mnemonicId, customDerivationPath, derivationPath } = watch() + const { data: nextDerivationPath } = useNextAvailableDerivationPath(mnemonicId, type) + const { data: address } = useLookupAddress( + mnemonicId, + type, + customDerivationPath ? derivationPath : nextDerivationPath + ) useEffect(() => { - // when customDerivationPath is checked, mark derivationPath as touched to trigger validation - if (customDerivationPath) - setValue("derivationPath", "", { shouldValidate: true, shouldTouch: true }) - }, [customDerivationPath, setValue]) + // prefill custom derivation path with next available one + if (nextDerivationPath === undefined || nextDerivationPath === null) return + if (!customDerivationPath) + setValue("derivationPath", nextDerivationPath, { shouldValidate: true }) + }, [customDerivationPath, nextDerivationPath, setValue]) useEffect(() => { // if we have a type in the url, set it @@ -211,6 +260,16 @@ const AccountAddDerivedFormInner: FC = ({ onSuccess }) => { spellCheck={false} autoComplete="off" data-lpignore + after={ + address ? ( + + + + + {address} + + ) : null + } /> @@ -234,7 +293,7 @@ const AccountAddDerivedFormInner: FC = ({ onSuccess }) => { spellCheck={false} disabled={!customDerivationPath} autoComplete="off" - className="font-mono disabled:cursor-not-allowed disabled:select-none disabled:opacity-50" + className="font-mono disabled:cursor-not-allowed disabled:select-none" data-lpignore /> From 61d8988b9f23b696b6682c2c08f48b41c4f220b9 Mon Sep 17 00:00:00 2001 From: Kheops <26880866+0xKheops@users.noreply.github.com> Date: Tue, 5 Sep 2023 17:31:05 +0900 Subject: [PATCH 12/13] fix: derivation path validation --- apps/extension/src/@talisman/util/isValidDerivationPath.ts | 6 +++++- .../AccountAdd/AccountAddDerived/AccountAddDerivedForm.tsx | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/extension/src/@talisman/util/isValidDerivationPath.ts b/apps/extension/src/@talisman/util/isValidDerivationPath.ts index 956d0c5f71..554ceac639 100644 --- a/apps/extension/src/@talisman/util/isValidDerivationPath.ts +++ b/apps/extension/src/@talisman/util/isValidDerivationPath.ts @@ -12,5 +12,9 @@ const TEST_MNEMONIC = "test test test test test test test test test test test ju */ export const isValidDerivationPath = (derivationPath: string, type: KeypairType = "sr25519") => { if (typeof derivationPath !== "string") return false - return !!addressFromSuri(formatSuri(TEST_MNEMONIC, derivationPath), type) + try { + return !!addressFromSuri(formatSuri(TEST_MNEMONIC, derivationPath), type) + } catch (err) { + return false + } } diff --git a/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddDerived/AccountAddDerivedForm.tsx b/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddDerived/AccountAddDerivedForm.tsx index 0c3204bf60..fa5dd28d5e 100644 --- a/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddDerived/AccountAddDerivedForm.tsx +++ b/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddDerived/AccountAddDerivedForm.tsx @@ -133,7 +133,7 @@ const AccountAddDerivedFormInner: FC = ({ onSuccess }) => { if (allAccounts.some((a) => a.address === address)) return ctx.createError({ path: "derivationPath", - message: t("Address already exists"), + message: t("Account already exists"), }) } return true From 44be1c692c1f0409106287e370c765e0f8d3df43 Mon Sep 17 00:00:00 2001 From: Kheops <26880866+0xKheops@users.noreply.github.com> Date: Tue, 5 Sep 2023 18:22:02 +0900 Subject: [PATCH 13/13] fix derivation path validation --- apps/extension/src/@talisman/util/isValidDerivationPath.ts | 5 +++-- apps/extension/src/core/domains/accounts/handler.ts | 4 ++-- apps/extension/src/core/domains/accounts/types.ts | 1 + apps/extension/src/ui/api/api.ts | 4 ++-- apps/extension/src/ui/api/types.ts | 2 +- .../AccountAdd/AccountAddDerived/AccountAddDerivedForm.tsx | 5 +++-- 6 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/extension/src/@talisman/util/isValidDerivationPath.ts b/apps/extension/src/@talisman/util/isValidDerivationPath.ts index 554ceac639..5e4b92623a 100644 --- a/apps/extension/src/@talisman/util/isValidDerivationPath.ts +++ b/apps/extension/src/@talisman/util/isValidDerivationPath.ts @@ -10,10 +10,11 @@ const TEST_MNEMONIC = "test test test test test test test test test test test ju * Don't call this from front-end as it loads a heavy wasm blob * */ -export const isValidDerivationPath = (derivationPath: string, type: KeypairType = "sr25519") => { +export const isValidDerivationPath = (derivationPath: string, type: KeypairType) => { if (typeof derivationPath !== "string") return false try { - return !!addressFromSuri(formatSuri(TEST_MNEMONIC, derivationPath), type) + const suri = formatSuri(TEST_MNEMONIC, derivationPath) + return !!addressFromSuri(suri, type) } catch (err) { return false } diff --git a/apps/extension/src/core/domains/accounts/handler.ts b/apps/extension/src/core/domains/accounts/handler.ts index eec7085259..4cdc6fb9ac 100644 --- a/apps/extension/src/core/domains/accounts/handler.ts +++ b/apps/extension/src/core/domains/accounts/handler.ts @@ -550,8 +550,8 @@ export default class AccountsHandler extends ExtensionHandler { } } - private validateDerivationPath({ derivationPath }: RequestValidateDerivationPath): boolean { - return isValidDerivationPath(derivationPath) + private validateDerivationPath({ derivationPath, type }: RequestValidateDerivationPath): boolean { + return isValidDerivationPath(derivationPath, type) } private async getNextDerivationPath({ diff --git a/apps/extension/src/core/domains/accounts/types.ts b/apps/extension/src/core/domains/accounts/types.ts index 3094d517c3..bf9fbd1a0f 100644 --- a/apps/extension/src/core/domains/accounts/types.ts +++ b/apps/extension/src/core/domains/accounts/types.ts @@ -205,6 +205,7 @@ export type RequestSetVerifierCertificateMnemonic = { // wrap in a dedicated type because empty strings are changed to null by the message service export type RequestValidateDerivationPath = { derivationPath: string + type: AccountAddressType } export type RequestAddressLookupBySuri = { diff --git a/apps/extension/src/ui/api/api.ts b/apps/extension/src/ui/api/api.ts index d0c4abf030..2c3b56c541 100644 --- a/apps/extension/src/ui/api/api.ts +++ b/apps/extension/src/ui/api/api.ts @@ -134,8 +134,8 @@ export const api: MessageTypes = { messageService.sendMessage("pri(accounts.external.setIsPortfolio)", { address, isPortfolio }), accountValidateMnemonic: (mnemonic) => messageService.sendMessage("pri(accounts.validateMnemonic)", mnemonic), - validateDerivationPath: (derivationPath) => - messageService.sendMessage("pri(accounts.validateDerivationPath)", { derivationPath }), + validateDerivationPath: (derivationPath, type) => + messageService.sendMessage("pri(accounts.validateDerivationPath)", { derivationPath, type }), addressLookup: (lookup) => messageService.sendMessage("pri(accounts.address.lookup)", lookup), setVerifierCertMnemonic: (verifierCertType, mnemonic, mnemonicId) => messageService.sendMessage("pri(accounts.setVerifierCertMnemonic)", { diff --git a/apps/extension/src/ui/api/types.ts b/apps/extension/src/ui/api/types.ts index 64f37feccf..67daaf85af 100644 --- a/apps/extension/src/ui/api/types.ts +++ b/apps/extension/src/ui/api/types.ts @@ -145,7 +145,7 @@ export default interface MessageTypes { accountExportPrivateKey: (address: string, password: string) => Promise accountRename: (address: string, name: string) => Promise accountValidateMnemonic: (mnemonic: string) => Promise - validateDerivationPath: (derivationPath: string) => Promise + validateDerivationPath: (derivationPath: string, type: AccountAddressType) => Promise addressLookup: (lookup: RequestAddressLookup) => Promise getNextDerivationPath: (mnemonicId: string, type: AccountAddressType) => Promise setVerifierCertMnemonic: ( diff --git a/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddDerived/AccountAddDerivedForm.tsx b/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddDerived/AccountAddDerivedForm.tsx index fa5dd28d5e..076563d988 100644 --- a/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddDerived/AccountAddDerivedForm.tsx +++ b/apps/extension/src/ui/domains/Account/AccountAdd/AccountAddDerived/AccountAddDerivedForm.tsx @@ -65,9 +65,10 @@ const useLookupAddress = ( ) => { return useQuery({ queryKey: ["useLookupAddress", mnemonicId, derivationPath], - queryFn: () => { + queryFn: async () => { // empty string is valid if (!mnemonicId || !type || typeof derivationPath !== "string") return null + if (!(await api.validateDerivationPath(derivationPath, type))) return null return api.addressLookup({ mnemonicId, type, derivationPath }) }, enabled: !!mnemonicId && type && typeof derivationPath === "string", @@ -122,7 +123,7 @@ const AccountAddDerivedFormInner: FC = ({ onSuccess }) => { const { customDerivationPath, derivationPath, mnemonicId, type } = val as FormData if (!customDerivationPath) return true - if (derivationPath && !(await api.validateDerivationPath(derivationPath))) + if (!(await api.validateDerivationPath(derivationPath, type))) return ctx.createError({ path: "derivationPath", message: t("Invalid derivation path"),