Skip to content

Commit

Permalink
fix: invalid mnemonic on paste
Browse files Browse the repository at this point in the history
  • Loading branch information
dianasavvatina committed Feb 25, 2025
1 parent ebd3722 commit 399181b
Show file tree
Hide file tree
Showing 6 changed files with 115 additions and 33 deletions.
1 change: 1 addition & 0 deletions apps/web/src/assets/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export { default as MoonIcon } from "./moon.svg";
export { default as MultisigIcon } from "./multisig.svg";
export { default as OutgoingArrowIcon } from "./outgoing-arrow.svg";
export { default as OutlineQuestionCircleIcon } from "./outline-question-circle.svg";
export { default as PasteIcon } from "./paste.svg";
export { default as PercentIcon } from "./percent.svg";
export { default as PencilIcon } from "./pencil.svg";
export { default as PlusIcon } from "./plus.svg";
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/assets/icons/paste.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const ImportWallet = () => {
<ModalContent>
<ModalHeader>
<ModalCloseButton />
<Center flexDirection="column" gap="16px">
<Center flexDirection="row" gap="16px">
<Icon as={LoginIcon} width="24px" height="24px" color={color("400")} />
<Heading size="xl">Import wallet</Heading>
</Center>
Expand All @@ -43,7 +43,7 @@ export const ImportWallet = () => {
options={hasOnboarded ? AFTER_ONBOARDING_OPTIONS : BEFORE_ONBOARDING_OPTIONS}
/>

<TabPanels padding="30px 0 0 0">
<TabPanels padding="20px 0 0 0">
<TabPanel>
<SeedPhraseTab />
</TabPanel>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { mockToast } from "@umami/state";
import { mnemonic1 } from "@umami/test-utils";
import { mnemonic1, mnemonic12 } from "@umami/test-utils";

import { SeedPhraseTab } from "./SeedPhraseTab";
import { act, render, screen, userEvent } from "../../../testUtils";
import { act, render, screen, userEvent, waitFor } from "../../../testUtils";

jest.setTimeout(10000);

Expand Down Expand Up @@ -69,22 +69,44 @@ describe("<SeedPhraseTab />", () => {
});
});

it("fills in the form automatically", async () => {
it("changes the number of words on paste", async () => {
const user = userEvent.setup();

render(<SeedPhraseTab />);

expect(screen.getAllByTestId("mnemonic-input")).toHaveLength(24);

expect(screen.getByText("24 word seed phrase")).toBeInTheDocument();
expect(screen.queryByText("12 word seed phrase")).not.toBeInTheDocument();

const textbox = screen.getAllByTestId("mnemonic-input")[0];
await act(() => user.click(textbox));
await act(() => user.paste(`${mnemonic12} `));

await waitFor(() => {
expect(screen.getByText("12 word seed phrase")).toBeInTheDocument();
});
expect(screen.queryByText("24 word seed phrase")).not.toBeInTheDocument();

expect(screen.getAllByTestId("mnemonic-input")).toHaveLength(12);
});

it("trims and fills in the form by paste button", async () => {
const user = userEvent.setup({});
render(<SeedPhraseTab />);

const textbox = screen.getAllByTestId("mnemonic-input")[0];
await act(() => user.click(textbox));

await act(() => user.paste(mnemonic1));
await act(() => user.paste(`${mnemonic1} `));

screen.getAllByTestId("mnemonic-input").forEach((textbox, i) => {
expect(textbox).toHaveValue(mnemonic1.split(" ")[i]);
});
});
});

describe("clear all", () => {
describe("clear", () => {
it("clears all the words", async () => {
const user = userEvent.setup();

Expand All @@ -100,11 +122,19 @@ describe("<SeedPhraseTab />", () => {
expect(textbox1).toHaveValue("something1");
expect(textbox2).toHaveValue("something2");

await act(() => user.click(screen.getByRole("button", { name: "Clear all" })));
await act(() => user.click(screen.getByText("24 word seed phrase")));
const changeTo12Button = await screen.findByRole("button", { name: "12" });
await act(() => user.click(changeTo12Button));

await act(() => user.click(screen.getByRole("button", { name: "Clear" })));

expect(textbox1).toHaveValue(undefined);
expect(textbox2).toHaveValue(undefined);

await act(() => user.click(screen.getByText("12 word seed phrase")));
const changeTo24Button = await screen.findByRole("button", { name: "24" });
await act(() => user.click(changeTo24Button));

expect(screen.getAllByTestId("mnemonic-input")).toHaveLength(24);
});

Expand All @@ -118,7 +148,7 @@ describe("<SeedPhraseTab />", () => {
await act(() => user.click(changeTo12Button));
expect(screen.getAllByTestId("mnemonic-input")).toHaveLength(12);

await act(() => user.click(screen.getByRole("button", { name: "Clear all" })));
await act(() => user.click(screen.getByRole("button", { name: "Clear" })));

expect(screen.getAllByTestId("mnemonic-input")).toHaveLength(12);
});
Expand Down
82 changes: 58 additions & 24 deletions apps/web/src/components/Onboarding/ImportWallet/SeedPhraseTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,33 +22,37 @@ import { range } from "lodash";
import { useState } from "react";
import { FormProvider, useFieldArray } from "react-hook-form";

import { CloseIcon, EyeIcon, EyeOffIcon } from "../../../assets/icons";
import { CloseIcon, EyeIcon, EyeOffIcon, PasteIcon } from "../../../assets/icons";
import { useColor } from "../../../styles/useColor";
import { trackOnboardingEvent } from "../../../utils/analytics";
import { MnemonicWord } from "../../MnemonicWord";
import { RadioButtons } from "../../RadioButtons";
import { SetupPassword } from "../SetupPassword";

const MNEMONIC_SIZE_OPTIONS = [12, 15, 18, 21, 24];
const MNEMONIC_SIZE_MAX = 24;

type FormValues = {
mnemonicSize: number;
mnemonic: { val: string }[];
};

const getEmptyValue = (mnemonicSize: number) => range(mnemonicSize).map(() => ({ val: "" }));

export const SeedPhraseTab = () => {
const color = useColor();
const { handleAsyncAction, isLoading } = useAsyncActionHandler();
const form = useMultiForm<FormValues>({
mode: "onBlur",
defaultValues: {
mnemonicSize: 24,
mnemonic: range(24).map(() => ({ val: "" })),
mnemonicSize: MNEMONIC_SIZE_MAX,
mnemonic: getEmptyValue(MNEMONIC_SIZE_MAX),
},
});
const { openWith } = useDynamicModalContext();
const [showBlur, setShowBlur] = useState(true);
const { isVisible, toggleMnemonic } = useToggleMnemonic();
const [expandedIndex, setExpandedIndex] = useState<number | number[]>(-1);

const {
handleSubmit,
Expand All @@ -60,7 +64,7 @@ export const SeedPhraseTab = () => {
const { fields, remove, append, update } = useFieldArray({
control,
name: "mnemonic",
rules: { required: true, minLength: 12, maxLength: 24 },
rules: { required: true, minLength: 12, maxLength: MNEMONIC_SIZE_MAX },
});

const mnemonicSize = form.watch("mnemonicSize");
Expand All @@ -71,17 +75,29 @@ export const SeedPhraseTab = () => {
} else {
remove(range(newSize, mnemonicSize));
}

// change the accordion for the seed phrase size
form.setValue("mnemonicSize", newSize);

// Close the accordion
setExpandedIndex(-1);
};

const pasteMnemonic = (mnemonic: string) =>
handleAsyncAction(async () => {
const words = mnemonic.split(" ");
const words = mnemonic.trim().split(" "); // trim here. otherwise the last word is ''

if (!MNEMONIC_SIZE_OPTIONS.includes(words.length)) {
throw new CustomError(
`the mnemonic must be ${MNEMONIC_SIZE_OPTIONS.join(", ")} words long`
);
}
words.slice(0, mnemonicSize).forEach((word, i) => update(i, { val: word }));

if (words.length !== mnemonicSize) {
changeMnemonicSize(words.length);
}

words.forEach((word, i) => update(i, { val: word.trim() }));
return Promise.resolve();
});

Expand All @@ -95,29 +111,36 @@ export const SeedPhraseTab = () => {
return openWith(<SetupPassword mode="mnemonic" />);
});

const clearAll = () =>
form.setValue(
"mnemonic",
range(mnemonicSize).map(() => ({ val: "" }))
);

const lastRowSize = useBreakpointValue({ md: fields.length % 4 }) || 0;
const clearAll = () => form.setValue("mnemonic", getEmptyValue(mnemonicSize));

const onPaste: InputProps["onPaste"] = event => {
event.preventDefault();
void pasteMnemonic(event.clipboardData.getData("text/plain"));
return;
};

const handlePaste = async () => {
try {
const text = await navigator.clipboard.readText();
if (text) {
void pasteMnemonic(text);
}
} catch (error) {
console.error("Failed to read clipboard:", error);
}
};

const indexProps = {
fontSize: { base: "12px", md: "14px" },
};

const lastRowSize = useBreakpointValue({ md: fields.length % 4 }) || 0;

return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<Flex flexDirection="column">
<Accordion allowToggle>
<Accordion allowToggle index={expandedIndex} onChange={setExpandedIndex}>
<AccordionItem>
<AccordionButton
justifyContent="center"
Expand Down Expand Up @@ -145,7 +168,7 @@ export const SeedPhraseTab = () => {

<Grid
position="relative"
gridRowGap="16px"
gridRowGap="8px"
gridColumnGap="8px"
gridTemplateColumns={{ base: "repeat(3, 1fr)", md: "repeat(4, 1fr)" }}
marginTop="36px"
Expand Down Expand Up @@ -174,7 +197,8 @@ export const SeedPhraseTab = () => {
</Text>
</Flex>
)}
{fields.slice(0, fields.length - lastRowSize).map((field, index) => (

{fields.slice(0, mnemonicSize - lastRowSize).map((field, index) => (
<MnemonicWord
key={field.id}
autocompleteProps={{
Expand Down Expand Up @@ -220,35 +244,45 @@ export const SeedPhraseTab = () => {
})}
</Center>

<Flex gap="8px">
<Flex flexWrap="nowrap" gap="8px" marginTop="12px">
<Button
gap="8px"
width="full"
marginTop="16px"
fontSize="14px"
data-testid="paste-button"
isDisabled={showBlur}
onClick={clearAll}
onClick={handlePaste}
variant="ghost"
>
<Icon as={CloseIcon} boxSize="18px" color={color("400")} />
Clear all
<Icon as={PasteIcon} boxSize="18px" color={color("400")} />
Paste
</Button>
<Button
gap="8px"
width="full"
marginTop="16px"
fontSize="14px"
isDisabled={showBlur}
onClick={toggleMnemonic}
variant="ghost"
>
<Icon as={isVisible ? EyeOffIcon : EyeIcon} boxSize="18px" color={color("400")} />
{isVisible ? "Hide phrase" : "Show phrase"}
{isVisible ? "Hide" : "Show"}
</Button>
<Button
gap="8px"
width="full"
fontSize="14px"
isDisabled={showBlur}
onClick={clearAll}
variant="ghost"
>
<Icon as={CloseIcon} boxSize="18px" color={color("400")} />
Clear
</Button>
</Flex>

<Button
marginTop="30px"
marginTop="12px"
isDisabled={!isValid}
isLoading={isLoading}
type="submit"
Expand Down
3 changes: 3 additions & 0 deletions packages/test-utils/src/mnemonic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ export const encryptedMnemonic1 = {

export const mnemonic2 =
"tone ahead staff legend common seek dove struggle ancient praise person injury poverty space enrich trick option defense ripple approve garlic favorite omit dose";

export const mnemonic12 =
"poverty space enrich trick option defense ripple approve garlic favorite omit dose";

1 comment on commit 399181b

@github-actions
Copy link

Choose a reason for hiding this comment

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

Title Lines Statements Branches Functions
apps/desktop Coverage: 83%
83.74% (1788/2135) 79.42% (849/1069) 78.27% (454/580)
apps/web Coverage: 83%
83.74% (1788/2135) 79.42% (849/1069) 78.27% (454/580)
packages/components Coverage: 97%
97.48% (194/199) 94.87% (74/78) 89.7% (61/68)
packages/core Coverage: 82%
82.64% (219/265) 72.51% (95/131) 81.66% (49/60)
packages/crypto Coverage: 100%
100% (43/43) 90.9% (10/11) 100% (7/7)
packages/data-polling Coverage: 96%
94.66% (142/150) 87.5% (21/24) 92.85% (39/42)
packages/multisig Coverage: 98%
98.47% (129/131) 85.71% (18/21) 100% (36/36)
packages/social-auth Coverage: 95%
95.45% (21/22) 91.66% (11/12) 100% (3/3)
packages/state Coverage: 83%
83.15% (869/1045) 80% (200/250) 77.01% (315/409)
packages/tezos Coverage: 89%
88.65% (125/141) 93.02% (40/43) 87.5% (35/40)
packages/tzkt Coverage: 89%
87.32% (62/71) 87.5% (14/16) 80.48% (33/41)

Please sign in to comment.