mirror of
https://github.com/aljazceru/mutiny-web.git
synced 2026-01-31 12:04:23 +01:00
restore
This commit is contained in:
64
e2e/restore.spec.ts
Normal file
64
e2e/restore.spec.ts
Normal 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");
|
||||
});
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
264
src/routes/settings/Restore.tsx
Normal file
264
src/routes/settings/Restore.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
2050
src/utils/words.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user