This commit is contained in:
Paul Miller
2023-06-26 18:03:33 -05:00
parent c83834fa24
commit 1373311271
7 changed files with 2388 additions and 5 deletions

64
e2e/restore.spec.ts Normal file
View File

@@ -0,0 +1,64 @@
import { test, expect } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.goto("http://localhost:3420/");
});
test("restore from seed", async ({ page }) => {
// should have 100k sats on-chain
const TEST_SEED_WORDS =
"rival hood review write spoon tide orange ill opera enrich clip acoustic";
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Mutiny Wallet/);
// Wait for an element matching the selector to appear in DOM.
await page.waitForSelector("text=0 SATS");
console.log("Page loaded.");
// Wait for a while just to make sure we can load everything
await page.waitForTimeout(1000);
// Navigate to settings
const settingsLink = await page.getByRole("link", { name: "Settings" });
settingsLink.click();
// Wait for settings to load
await page.waitForSelector("text=Settings");
// Click the "Restore" link
page.click("text=Restore");
// There should be some warning text: "This will replace your existing wallet"
await expect(page.locator("p")).toContainText([
"This will replace your existing wallet"
]);
let seedWords = TEST_SEED_WORDS.split(" ");
// Find the input field with the name "words.0"
for (let i = 0; i < 12; i++) {
const wordInput = await page.locator(`input[name='words.${i}']`);
// Type the seed words into the input field
await wordInput.type(seedWords[i]);
}
// There should be a button with the text "Restore" and it should not be disabled
const restoreButton = await page.locator("button", { hasText: "Restore" });
await expect(restoreButton).not.toBeDisabled();
restoreButton.click();
// A modal should pop up, click the "Confirm" button
const confirmButton = await page.locator("button", { hasText: "Confirm" });
confirmButton.click();
// Wait for the wallet to load
await page.waitForSelector("img[alt='lightning']");
// Eventually we should have a balance of 100k sats
await page.waitForSelector("text=100,000 SATS");
});

View File

@@ -1,5 +1,5 @@
<svg width="36" height="36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.025 4.275A3.5 3.5 0 0 1 7.5 3.25h5.25a2 2 0 1 1 0 4H8V31h20V7.25h-4.75a2 2 0 1 1 0-4h5.25a3.5 3.5 0 0 1 3.5 3.5V31.5a3.5 3.5 0 0 1-3.5 3.5h-21A3.5 3.5 0 0 1 4 31.5V6.75a3.5 3.5 0 0 1 1.025-2.475Z" fill="#000"/>
<path d="M12.75 3h10.5v4.5h-10.5V3Z" fill="#000"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.75 3a2 2 0 0 1 2-2h10.5a2 2 0 0 1 2 2v4.5a2 2 0 0 1-2 2h-10.5a2 2 0 0 1-2-2V3Zm4 2v.5h6.5V5h-6.5Z" fill="#000"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.025 4.275A3.5 3.5 0 0 1 7.5 3.25h5.25a2 2 0 1 1 0 4H8V31h20V7.25h-4.75a2 2 0 1 1 0-4h5.25a3.5 3.5 0 0 1 3.5 3.5V31.5a3.5 3.5 0 0 1-3.5 3.5h-21A3.5 3.5 0 0 1 4 31.5V6.75a3.5 3.5 0 0 1 1.025-2.475Z" fill="#fff"/>
<path d="M12.75 3h10.5v4.5h-10.5V3Z" fill="#fff"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.75 3a2 2 0 0 1 2-2h10.5a2 2 0 0 1 2 2v4.5a2 2 0 0 1-2 2h-10.5a2 2 0 0 1-2-2V3Zm4 2v.5h6.5V5h-6.5Z" fill="#fff"/>
</svg>

Before

Width:  |  Height:  |  Size: 569 B

After

Width:  |  Height:  |  Size: 569 B

View File

@@ -82,7 +82,7 @@ export default function NavBar(props: { activeTab: ActiveTab }) {
}
>
<A href="/settings">
<img src={settings} alt="settings" />
<img src={settings} alt="Settings" />
</A>
</li>
</ul>

View File

@@ -2,7 +2,7 @@ import { TextField as KTextField } from "@kobalte/core";
import { type JSX, Show, splitProps } from "solid-js";
import { TinyText } from ".";
type TextFieldProps = {
export type TextFieldProps = {
name: string;
type?: "text" | "email" | "tel" | "password" | "url" | "date";
label?: string;

View File

@@ -0,0 +1,264 @@
import {
Button,
DefaultMain,
LargeHeader,
MutinyWalletGuard,
NiceP,
SafeArea,
VStack
} from "~/components/layout";
import { BackLink } from "~/components/layout/BackLink";
import NavBar from "~/components/NavBar";
import { useMegaStore } from "~/state/megaStore";
import { For, Show, createSignal, splitProps } from "solid-js";
import pasteIcon from "~/assets/icons/paste.svg";
import {
SubmitHandler,
createForm,
custom,
required,
setValues,
validate
} from "@modular-forms/solid";
import { TextField as KTextField } from "@kobalte/core";
import { TextFieldProps } from "~/components/layout/TextField";
import { showToast } from "~/components/Toaster";
import eify from "~/utils/eify";
import { ConfirmDialog } from "~/components/Dialog";
import { MutinyWallet } from "@mutinywallet/mutiny-wasm";
import { WORDS_EN } from "~/utils/words";
import { InfoBox } from "~/components/InfoBox";
type SeedWordsForm = {
words: string[];
};
// create an array of 12 empty strings
const initialValues: SeedWordsForm = {
words: Array.from({ length: 12 }, () => "")
};
function validateWord(word?: string): boolean {
// return word?.trim() === "bacon";
return WORDS_EN.includes(word?.trim() ?? "");
}
export function SeedTextField(props: TextFieldProps) {
const [fieldProps] = splitProps(props, [
"placeholder",
"ref",
"onInput",
"onChange",
"onBlur"
]);
return (
<KTextField.Root
class="flex flex-col gap-2"
name={props.name}
value={props.value}
validationState={props.error ? "invalid" : "valid"}
required={props.required}
>
<KTextField.Input
{...fieldProps}
autoCapitalize="none"
autocorrect="off"
autocomplete="off"
type={props.type}
class="w-full p-2 rounded-lg border bg-m-grey-750 placeholder-neutral-400"
classList={{
"border-m-grey-750": !props.error && !props.value,
"border-m-red": !!props.error,
"border-m-green": !props.error && !!props.value
}}
/>
</KTextField.Root>
);
}
function TwelveWordsEntry() {
const [state, _actions] = useMegaStore();
const [error, setError] = createSignal<Error>();
const [mnemnoic, setMnemonic] = createSignal<string>();
const [confirmOpen, setConfirmOpen] = createSignal(false);
const [confirmLoading, setConfirmLoading] = createSignal(false);
const [seedWordsForm, { Form, Field, FieldArray }] =
createForm<SeedWordsForm>({
initialValues,
validateOn: "blur"
});
async function handlePaste() {
if (!navigator.clipboard.readText)
return showToast(new Error("Clipboard not supported"));
try {
const text = await navigator.clipboard.readText();
// split words on space or newline
const words = text.split(/[\s\n]+/);
if (words.length !== 12)
return showToast(new Error("Wrong number of words"));
setValues(seedWordsForm, "words", words);
validate(seedWordsForm);
} catch (e) {
console.error(e);
}
}
async function restore() {
try {
setConfirmLoading(true);
if (state.mutiny_wallet) {
console.log("Mutiny wallet loaded, stopping");
try {
await state.mutiny_wallet.stop();
} catch (e) {
console.error(e);
}
}
await MutinyWallet.restore_mnemonic(mnemnoic() || "", "");
setTimeout(() => {
window.location.href = "/";
}, 1000);
} catch (e) {
setError(eify(e));
} finally {
setConfirmLoading(false);
}
}
const onSubmit: SubmitHandler<SeedWordsForm> = async (values) => {
setError(undefined);
const valid = values.words?.every(validateWord);
if (!valid) {
setError(new Error("Invalid seed phrase"));
return;
}
const seed = values.words?.join(" ");
setMnemonic(seed);
setConfirmOpen(true);
};
return (
<>
<Form onSubmit={onSubmit} class="flex flex-col gap-4">
<div class="flex flex-col gap-4 bg-m-grey-800 p-4 rounded-xl overflow-hidden">
<Show when={error()}>
<InfoBox accent="red">{error()?.message}</InfoBox>
</Show>
<ul class="overflow-hidden columns-2 w-full list-none pt-2 pr-2">
<FieldArray name="words">
{(fieldArray) => (
<For each={fieldArray.items}>
{(_, index) => (
<div class="flex items-center gap-1 mb-2">
<pre class="w-[2rem] text-right">
{index() + 1}
{"."}
</pre>
<Field
name={`words.${index()}`}
validate={[
required(
"You need to enter all 12 words"
),
custom(
validateWord,
"Wrong word"
)
]}
>
{(field, props) => (
<SeedTextField
{...props}
value={field.value}
error={field.error}
/>
)}
</Field>
</div>
)}
</For>
)}
</FieldArray>
</ul>
<div class="flex w-full justify-center">
<button
onClick={handlePaste}
class="bg-white/10 hover:bg-white/20 py-2 px-4 rounded-lg"
type="button"
>
<div class="flex items-center gap-2">
<span>Dangerously Paste from Clipboard</span>
<img
src={pasteIcon}
alt="paste"
class="w-4 h-4"
/>
</div>
</button>
</div>
</div>
<Button
type="submit"
intent="red"
disabled={seedWordsForm.invalid || !seedWordsForm.dirty}
>
Restore
</Button>
</Form>
<ConfirmDialog
open={confirmOpen()}
onConfirm={restore}
onCancel={() => setConfirmOpen(false)}
loading={confirmLoading()}
>
<p>
Are you sure you want to restore to this wallet? Your
existing wallet will be deleted!
</p>
</ConfirmDialog>
</>
);
}
export default function RestorePage() {
return (
<MutinyWalletGuard>
<SafeArea>
<DefaultMain>
<BackLink title="Settings" href="/settings" />
<LargeHeader>Restore</LargeHeader>
<VStack>
<NiceP>
You can restore an existing Mutiny Wallet from your
12 word seed phrase. This will replace your existing
wallet, so make sure you know what you're doing!
</NiceP>
<NiceP>
<strong class="font-bold text-m-red">
Beta warning:
</strong>{" "}
you can currently only restore on-chain funds.
Lightning backup restore is coming soon.
</NiceP>
<TwelveWordsEntry />
</VStack>
</DefaultMain>
<NavBar activeTab="settings" />
</SafeArea>
</MutinyWalletGuard>
);
}

View File

@@ -70,6 +70,11 @@ export default function Settings() {
text: "Backup",
accent: "green"
},
{
href: "/settings/restore",
text: "Restore",
accent: "red"
},
{
href: "/settings/servers",
text: "Servers",

2050
src/utils/words.ts Normal file

File diff suppressed because it is too large Load Diff