encrypt / decrypt

This commit is contained in:
Paul Miller
2023-07-03 14:11:02 -05:00
parent c33e542932
commit d8467ca1bb
12 changed files with 387 additions and 54 deletions

View File

@@ -6,7 +6,7 @@ import { A } from "solid-start";
import { OnboardWarning } from "~/components/OnboardWarning"; import { OnboardWarning } from "~/components/OnboardWarning";
import { CombinedActivity } from "./Activity"; import { CombinedActivity } from "./Activity";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { Match, Show, Switch, createMemo } from "solid-js"; import { Match, Show, Switch } from "solid-js";
import { ExternalLink } from "./layout/ExternalLink"; import { ExternalLink } from "./layout/ExternalLink";
import { BetaWarningModal } from "~/components/BetaWarningModal"; import { BetaWarningModal } from "~/components/BetaWarningModal";
import settings from "~/assets/icons/settings.svg"; import settings from "~/assets/icons/settings.svg";
@@ -14,6 +14,7 @@ import pixelLogo from "~/assets/mutiny-pixel-logo.png";
import plusLogo from "~/assets/mutiny-plus-logo.png"; import plusLogo from "~/assets/mutiny-plus-logo.png";
import { PendingNwc } from "./PendingNwc"; import { PendingNwc } from "./PendingNwc";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
import { DecryptDialog } from "./DecryptDialog";
export default function App() { export default function App() {
const [state, _actions] = useMegaStore(); const [state, _actions] = useMegaStore();
@@ -96,6 +97,7 @@ export default function App() {
</span> </span>
</p> </p>
</DefaultMain> </DefaultMain>
<DecryptDialog />
<BetaWarningModal /> <BetaWarningModal />
<NavBar activeTab="home" /> <NavBar activeTab="home" />
</SafeArea> </SafeArea>

View File

@@ -0,0 +1,74 @@
import { Show, createSignal } from "solid-js";
import { Button, SimpleDialog } from "~/components/layout";
import { TextField } from "~/components/layout/TextField";
import { InfoBox } from "~/components/InfoBox";
import { useMegaStore } from "~/state/megaStore";
import eify from "~/utils/eify";
import { A } from "solid-start";
export function DecryptDialog() {
const [state, actions] = useMegaStore();
const [password, setPassword] = createSignal("");
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal("");
async function decrypt(e: Event) {
e.preventDefault();
setLoading(true);
try {
await actions.setupMutinyWallet(undefined, password());
// If we get this far and the state stills wants a password that means the password was wrong
if (state.needs_password) {
throw new Error("wrong");
}
} catch (e) {
const err = eify(e);
console.error(e);
if (err.message === "wrong") {
setError("Invalid password");
} else {
throw e;
}
} finally {
setLoading(false);
}
}
function noop() {
// noop
}
return (
<SimpleDialog
title="Enter your password"
// Only show the dialog if we need a password and there's no setup error
open={state.needs_password && !state.setup_error}
>
<form onSubmit={decrypt}>
<div class="flex flex-col gap-4">
<TextField
name="password"
type="password"
ref={noop}
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
error={""}
onBlur={noop}
onChange={noop}
/>
<Show when={error()}>
<InfoBox accent="red">{error()}</InfoBox>
</Show>
<Button intent="blue" loading={loading()} onClick={decrypt}>
Decrypt Wallet
</Button>
</div>
</form>
<A class="self-end text-m-grey-400" href="/settings/restore">
Forgot Password?
</A>
</SimpleDialog>
);
}

View File

@@ -1,19 +1,64 @@
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { Button, InnerCard, NiceP, VStack } from "~/components/layout"; import {
import { createSignal } from "solid-js"; Button,
InnerCard,
NiceP,
SimpleDialog,
VStack
} from "~/components/layout";
import { Show, createSignal } from "solid-js";
import eify from "~/utils/eify"; import eify from "~/utils/eify";
import { showToast } from "./Toaster"; import { showToast } from "./Toaster";
import { downloadTextFile } from "~/utils/download"; import { downloadTextFile } from "~/utils/download";
import { createFileUploader } from "@solid-primitives/upload"; import { createFileUploader } from "@solid-primitives/upload";
import { ConfirmDialog } from "./Dialog"; import { ConfirmDialog } from "./Dialog";
import initMutinyWallet, { MutinyWallet } from "@mutinywallet/mutiny-wasm"; import initMutinyWallet, { MutinyWallet } from "@mutinywallet/mutiny-wasm";
import { InfoBox } from "./InfoBox";
import { TextField } from "./layout/TextField";
export function ImportExport(props: { emergency?: boolean }) { export function ImportExport(props: { emergency?: boolean }) {
const [state, _] = useMegaStore(); const [state, _] = useMegaStore();
const [error, setError] = createSignal<Error>();
const [exportDecrypt, setExportDecrypt] = createSignal(false);
const [password, setPassword] = createSignal("");
async function handleSave() { async function handleSave() {
try {
setError(undefined);
const json = await MutinyWallet.export_json(); const json = await MutinyWallet.export_json();
downloadTextFile(json || "", "mutiny-state.json"); downloadTextFile(json || "", "mutiny-state.json");
} catch (e) {
console.error(e);
const err = eify(e);
if (err.message === "Incorrect password entered.") {
setExportDecrypt(true);
} else {
setError(err);
}
}
}
async function savePassword(e: Event) {
e.preventDefault();
try {
setError(undefined);
if (!password()) {
throw new Error("Password is required");
}
const json = await MutinyWallet.export_json(password());
downloadTextFile(json || "", "mutiny-state.json");
} catch (e) {
console.error(e);
setError(eify(e));
} finally {
setExportDecrypt(false);
setPassword("");
}
}
function noop() {
// noop
} }
const { files, selectFiles } = createFileUploader(); const { files, selectFiles } = createFileUploader();
@@ -23,6 +68,7 @@ export function ImportExport(props: { emergency?: boolean }) {
const fileReader = new FileReader(); const fileReader = new FileReader();
try { try {
setError(undefined);
const file: File = files()[0].file; const file: File = files()[0].file;
const text = await new Promise<string | null>((resolve, reject) => { const text = await new Promise<string | null>((resolve, reject) => {
@@ -45,6 +91,7 @@ export function ImportExport(props: { emergency?: boolean }) {
await state.mutiny_wallet.stop(); await state.mutiny_wallet.stop();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setError(eify(e));
} }
} else { } else {
// If there's no mutiny wallet loaded we need to initialize WASM // If there's no mutiny wallet loaded we need to initialize WASM
@@ -96,6 +143,9 @@ export function ImportExport(props: { emergency?: boolean }) {
to make sure you don't create conflicts. to make sure you don't create conflicts.
</NiceP> </NiceP>
<div /> <div />
<Show when={error()}>
<InfoBox accent="red">{error()?.message}</InfoBox>
</Show>
<VStack> <VStack>
<Button onClick={handleSave}>Save State As File</Button> <Button onClick={handleSave}>Save State As File</Button>
<Button onClick={uploadFile}>Import State From File</Button> <Button onClick={uploadFile}>Import State From File</Button>
@@ -109,6 +159,32 @@ export function ImportExport(props: { emergency?: boolean }) {
> >
Do you want to replace your state with {files()[0].name}? Do you want to replace your state with {files()[0].name}?
</ConfirmDialog> </ConfirmDialog>
{/* TODO: this is pretty redundant with the DecryptDialog, could make a shared component */}
<SimpleDialog
title="Enter your password to decrypt"
open={exportDecrypt()}
>
<form onSubmit={savePassword}>
<div class="flex flex-col gap-4">
<TextField
name="password"
type="password"
ref={noop}
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
error={""}
onBlur={noop}
onChange={noop}
/>
<Show when={error()}>
<InfoBox accent="red">{error()?.message}</InfoBox>
</Show>
<Button intent="blue" onClick={savePassword}>
Decrypt Wallet
</Button>
</div>
</form>
</SimpleDialog>
</> </>
); );
} }

View File

@@ -61,7 +61,9 @@ export function TextField(props: TextFieldProps) {
class="w-full p-2 rounded-lg bg-white/10 placeholder-neutral-400" class="w-full p-2 rounded-lg bg-white/10 placeholder-neutral-400"
/> />
</Show> </Show>
<KTextField.ErrorMessage>{props.error}</KTextField.ErrorMessage> <KTextField.ErrorMessage class="text-m-red">
{props.error}
</KTextField.ErrorMessage>
<Show when={props.caption}> <Show when={props.caption}>
<TinyText>{props.caption}</TinyText> <TinyText>{props.caption}</TinyText>
</Show> </Show>

View File

@@ -18,6 +18,7 @@ import { generateGradient } from "~/utils/gradientHash";
import close from "~/assets/icons/close.svg"; import close from "~/assets/icons/close.svg";
import { A } from "solid-start"; import { A } from "solid-start";
import down from "~/assets/icons/down.svg"; import down from "~/assets/icons/down.svg";
import { DecryptDialog } from "../DecryptDialog";
export { Button, ButtonLink, Linkify }; export { Button, ButtonLink, Linkify };
@@ -110,7 +111,6 @@ export const Collapser: ParentComponent<{
); );
}; };
export const SafeArea: ParentComponent = (props) => { export const SafeArea: ParentComponent = (props) => {
return ( return (
<div class="h-[100dvh] safe-left safe-right"> <div class="h-[100dvh] safe-left safe-right">
@@ -161,6 +161,7 @@ export const MutinyWalletGuard: ParentComponent = (props) => {
<Show when={state.mutiny_wallet && !state.wallet_loading}> <Show when={state.mutiny_wallet && !state.wallet_loading}>
{props.children} {props.children}
</Show> </Show>
<DecryptDialog />
</Suspense> </Suspense>
); );
}; };
@@ -318,7 +319,7 @@ export function ModalCloseButton() {
); );
} }
export const SIMPLE_OVERLAY = "fixed inset-0 z-50 bg-black/70 backdrop-blur-md"; export const SIMPLE_OVERLAY = "fixed inset-0 z-50 bg-black/20 backdrop-blur-md";
export const SIMPLE_DIALOG_POSITIONER = export const SIMPLE_DIALOG_POSITIONER =
"fixed inset-0 z-50 flex items-center justify-center"; "fixed inset-0 z-50 flex items-center justify-center";
export const SIMPLE_DIALOG_CONTENT = export const SIMPLE_DIALOG_CONTENT =
@@ -327,10 +328,13 @@ export const SIMPLE_DIALOG_CONTENT =
export const SimpleDialog: ParentComponent<{ export const SimpleDialog: ParentComponent<{
title: string; title: string;
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen?: (open: boolean) => void;
}> = (props) => { }> = (props) => {
return ( return (
<Dialog.Root open={props.open} onOpenChange={props.setOpen}> <Dialog.Root
open={props.open}
onOpenChange={props.setOpen && props.setOpen}
>
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay class={SIMPLE_OVERLAY} /> <Dialog.Overlay class={SIMPLE_OVERLAY} />
<div class={SIMPLE_DIALOG_POSITIONER}> <div class={SIMPLE_DIALOG_POSITIONER}>
@@ -339,9 +343,11 @@ export const SimpleDialog: ParentComponent<{
<Dialog.Title> <Dialog.Title>
<SmallHeader>{props.title}</SmallHeader> <SmallHeader>{props.title}</SmallHeader>
</Dialog.Title> </Dialog.Title>
<Show when={props.setOpen}>
<Dialog.CloseButton> <Dialog.CloseButton>
<ModalCloseButton /> <ModalCloseButton />
</Dialog.CloseButton> </Dialog.CloseButton>
</Show>
</div> </div>
<Dialog.Description class="flex flex-col gap-4"> <Dialog.Description class="flex flex-col gap-4">
{props.children} {props.children}

View File

@@ -106,7 +106,8 @@ export async function checkForWasm() {
} }
export async function setupMutinyWallet( export async function setupMutinyWallet(
settings?: MutinyWalletSettingStrings settings?: MutinyWalletSettingStrings,
password?: string
): Promise<MutinyWallet> { ): Promise<MutinyWallet> {
// Ultimate defense against getting multiple instances of the wallet running. // Ultimate defense against getting multiple instances of the wallet running.
// If we detect that the wallet has already been initialized in this session, we'll reload the page. // If we detect that the wallet has already been initialized in this session, we'll reload the page.
@@ -138,7 +139,7 @@ export async function setupMutinyWallet(
console.log("Using subscriptions address", subscriptions); console.log("Using subscriptions address", subscriptions);
const mutinyWallet = await new MutinyWallet( const mutinyWallet = await new MutinyWallet(
// Password // Password
"", password ? password : undefined,
// Mnemonic // Mnemonic
undefined, undefined,
proxy, proxy,

View File

@@ -55,10 +55,13 @@ export default function Backup() {
const [hasSeenBackup, setHasSeenBackup] = createSignal(false); const [hasSeenBackup, setHasSeenBackup] = createSignal(false);
const [hasCheckedAll, setHasCheckedAll] = createSignal(false); const [hasCheckedAll, setHasCheckedAll] = createSignal(false);
const [loading, setLoading] = createSignal(false);
function wroteDownTheWords() { function wroteDownTheWords() {
setLoading(true);
actions.setHasBackedUp(); actions.setHasBackedUp();
navigate("/"); navigate("/settings/encrypt");
setLoading(false);
} }
return ( return (
@@ -93,6 +96,7 @@ export default function Backup() {
disabled={!hasSeenBackup() || !hasCheckedAll()} disabled={!hasSeenBackup() || !hasCheckedAll()}
intent="blue" intent="blue"
onClick={wroteDownTheWords} onClick={wroteDownTheWords}
loading={loading()}
> >
I wrote down the words I wrote down the words
</Button> </Button>

View File

@@ -0,0 +1,140 @@
import {
Button,
DefaultMain,
LargeHeader,
NiceP,
MutinyWalletGuard,
SafeArea,
VStack,
ButtonLink
} from "~/components/layout";
import NavBar from "~/components/NavBar";
import { useMegaStore } from "~/state/megaStore";
import { Show, createSignal } from "solid-js";
import { BackLink } from "~/components/layout/BackLink";
import { createForm } from "@modular-forms/solid";
import { TextField } from "~/components/layout/TextField";
import { timeout } from "~/utils/timeout";
import eify from "~/utils/eify";
import { InfoBox } from "~/components/InfoBox";
type EncryptPasswordForm = {
existingPassword: string;
password: string;
confirmPassword: string;
};
export default function Encrypt() {
const [store, _actions] = useMegaStore();
const [error, setError] = createSignal<Error>();
const [loading, setLoading] = createSignal(false);
const [_encryptPasswordForm, { Form, Field }] =
createForm<EncryptPasswordForm>({
initialValues: {
existingPassword: "",
password: "",
confirmPassword: ""
},
validate: (values) => {
const errors: Record<string, string> = {};
if (values.password !== values.confirmPassword) {
errors.confirmPassword = "Passwords do not match";
}
return errors;
}
});
const handleFormSubmit = async (f: EncryptPasswordForm) => {
setLoading(true);
try {
await store.mutiny_wallet?.change_password(
f.existingPassword === "" ? undefined : f.existingPassword,
f.password === "" ? undefined : f.password
);
await timeout(1000);
window.location.href = "/";
} catch (e) {
console.error(e);
setError(eify(e));
setLoading(false);
}
};
return (
<MutinyWalletGuard>
<SafeArea>
<DefaultMain>
<BackLink href="/settings" title="Settings" />
<LargeHeader>
Encrypt your seed words (optional)
</LargeHeader>
<VStack>
<NiceP>
Mutiny is a "hot wallet" so it needs your seed word
to operate, but you can optionally encrypt those
words with a password.
</NiceP>
<NiceP>
That way, if someone gets access to your browser,
they still won't have access to your funds.
</NiceP>
<Form onSubmit={handleFormSubmit}>
<VStack>
<Field name="existingPassword">
{(field, props) => (
<TextField
{...props}
{...field}
type="password"
label="Existing Password (optional)"
placeholder="Existing password"
caption="Leave blank if you haven't set a password yet."
/>
)}
</Field>
<Field name="password">
{(field, props) => (
<TextField
{...props}
{...field}
type="password"
label="Password"
placeholder="Enter a password"
caption="This password will be used to encrypt your seed words. If you forget it, you will need to re-enter your seed words to access your funds. You did write down your seed words, right?"
/>
)}
</Field>
<Field name="confirmPassword">
{(field, props) => (
<TextField
{...props}
{...field}
type="password"
label="Confirm Password"
placeholder="Enter the same password"
/>
)}
</Field>
<Show when={error()}>
<InfoBox accent="red">
{error()?.message}
</InfoBox>
</Show>
<div />
<Button intent="blue" loading={loading()}>
Encrypt
</Button>
</VStack>
</Form>
<ButtonLink href="/settings" intent="green">
Skip
</ButtonLink>
</VStack>
</DefaultMain>
<NavBar activeTab="settings" />
</SafeArea>
</MutinyWalletGuard>
);
}

View File

@@ -2,7 +2,6 @@ import {
Button, Button,
DefaultMain, DefaultMain,
LargeHeader, LargeHeader,
MutinyWalletGuard,
NiceP, NiceP,
SafeArea, SafeArea,
VStack VStack
@@ -235,30 +234,28 @@ function TwelveWordsEntry() {
export default function RestorePage() { export default function RestorePage() {
return ( return (
<MutinyWalletGuard>
<SafeArea> <SafeArea>
<DefaultMain> <DefaultMain>
<BackLink title="Settings" href="/settings" /> <BackLink title="Settings" href="/settings" />
<LargeHeader>Restore</LargeHeader> <LargeHeader>Restore</LargeHeader>
<VStack> <VStack>
<NiceP> <NiceP>
You can restore an existing Mutiny Wallet from your You can restore an existing Mutiny Wallet from your 12
12 word seed phrase. This will replace your existing word seed phrase. This will replace your existing
wallet, so make sure you know what you're doing! wallet, so make sure you know what you're doing!
</NiceP> </NiceP>
<NiceP> <NiceP>
<strong class="font-bold text-m-red"> <strong class="font-bold text-m-red">
Beta warning: Beta warning:
</strong>{" "} </strong>{" "}
you can currently only restore on-chain funds. you can currently only restore on-chain funds. Lightning
Lightning backup restore is coming soon. backup restore is coming soon.
</NiceP> </NiceP>
<TwelveWordsEntry /> <TwelveWordsEntry />
</VStack> </VStack>
</DefaultMain> </DefaultMain>
<NavBar activeTab="settings" /> <NavBar activeTab="settings" />
</SafeArea> </SafeArea>
</MutinyWalletGuard>
); );
} }

View File

@@ -10,6 +10,7 @@ import NavBar from "~/components/NavBar";
import { A } from "solid-start"; import { A } from "solid-start";
import { For, Show } from "solid-js"; import { For, Show } from "solid-js";
import forward from "~/assets/icons/forward.svg"; import forward from "~/assets/icons/forward.svg";
import { useMegaStore } from "~/state/megaStore";
function SettingsLinkList(props: { function SettingsLinkList(props: {
header: string; header: string;
@@ -18,6 +19,7 @@ function SettingsLinkList(props: {
text: string; text: string;
caption?: string; caption?: string;
accent?: "red" | "green"; accent?: "red" | "green";
disabled?: boolean;
}[]; }[];
}) { }) {
return ( return (
@@ -26,7 +28,11 @@ function SettingsLinkList(props: {
{(link) => ( {(link) => (
<A <A
href={link.href} href={link.href}
class="no-underline flex w-full flex-col gap-1 py-2 hover:bg-m-grey-750 active:bg-m-grey-900 px-4 " class="no-underline flex w-full flex-col gap-1 py-2 hover:bg-m-grey-750 active:bg-m-grey-900 px-4"
classList={{
"opacity-50 cursor pointer-events-none grayscale":
link.disabled
}}
> >
<div class="flex justify-between"> <div class="flex justify-between">
<span <span
@@ -52,6 +58,8 @@ function SettingsLinkList(props: {
} }
export default function Settings() { export default function Settings() {
const [state, _actions] = useMegaStore();
return ( return (
<SafeArea> <SafeArea>
<DefaultMain> <DefaultMain>
@@ -84,6 +92,14 @@ export default function Settings() {
text: "Restore", text: "Restore",
accent: "red" accent: "red"
}, },
{
href: "/settings/encrypt",
text: "Change Password",
disabled: !state.has_backed_up,
caption: !state.has_backed_up
? "Backup first to unlock encryption"
: undefined
},
{ {
href: "/settings/servers", href: "/settings/servers",
text: "Servers", text: "Servers",

View File

@@ -47,10 +47,14 @@ export type MegaStore = [
existing_tab_detected: boolean; existing_tab_detected: boolean;
subscription_timestamp?: number; subscription_timestamp?: number;
readonly mutiny_plus: boolean; readonly mutiny_plus: boolean;
needs_password: boolean;
}, },
{ {
fetchUserStatus(): Promise<UserStatus>; fetchUserStatus(): Promise<UserStatus>;
setupMutinyWallet(settings?: MutinyWalletSettingStrings): Promise<void>; setupMutinyWallet(
settings?: MutinyWalletSettingStrings,
password?: string
): Promise<void>;
deleteMutinyWallet(): Promise<void>; deleteMutinyWallet(): Promise<void>;
setWaitlistId(waitlist_id: string): void; setWaitlistId(waitlist_id: string): void;
setScanResult(scan_result: ParsedParams | undefined): void; setScanResult(scan_result: ParsedParams | undefined): void;
@@ -95,7 +99,8 @@ export const Provider: ParentComponent = (props) => {
if (state.subscription_timestamp < Math.ceil(Date.now() / 1000)) if (state.subscription_timestamp < Math.ceil(Date.now() / 1000))
return false; return false;
else return true; else return true;
} },
needs_password: false
}); });
const actions = { const actions = {
@@ -161,17 +166,24 @@ export const Provider: ParentComponent = (props) => {
} }
}, },
async setupMutinyWallet( async setupMutinyWallet(
settings?: MutinyWalletSettingStrings settings?: MutinyWalletSettingStrings,
password?: string
): Promise<void> { ): Promise<void> {
try { try {
// If we're already in an error state there should be no reason to continue // If we're already in an error state there should be no reason to continue
if (state.setup_error) { if (state.setup_error) {
throw state.setup_error; throw state.setup_error;
} }
setState({ wallet_loading: true }); setState({ wallet_loading: true });
// This is where all the real setup happens const mutinyWallet = await setupMutinyWallet(
const mutinyWallet = await setupMutinyWallet(settings); settings,
password
);
// If we get this far then we don't need the password anymore
setState({ needs_password: false });
// Get balance optimistically // Get balance optimistically
const balance = await mutinyWallet.get_balance(); const balance = await mutinyWallet.get_balance();
@@ -210,8 +222,12 @@ export const Provider: ParentComponent = (props) => {
}); });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
if (eify(e).message === "Incorrect password entered.") {
setState({ needs_password: true });
} else {
setState({ setup_error: eify(e) }); setState({ setup_error: eify(e) });
} }
}
}, },
async deleteMutinyWallet(): Promise<void> { async deleteMutinyWallet(): Promise<void> {
try { try {
@@ -315,13 +331,10 @@ export const Provider: ParentComponent = (props) => {
}); });
} else { } else {
console.log("running setup node manager..."); console.log("running setup node manager...");
actions actions
.setupMutinyWallet() .setupMutinyWallet()
.then(() => console.log("node manager setup done")) .then(() => console.log("node manager setup done"));
.catch((e) => {
console.error(e);
setState({ setup_error: eify(e) });
});
// Setup an event listener to stop the mutiny wallet when the page unloads // Setup an event listener to stop the mutiny wallet when the page unloads
window.onunload = async (_e) => { window.onunload = async (_e) => {

View File

@@ -57,7 +57,9 @@ export default defineConfig({
"nostr-tools", "nostr-tools",
"class-variance-authority", "class-variance-authority",
"@kobalte/core", "@kobalte/core",
"@solid-primitives/upload" "@solid-primitives/upload",
"i18next",
"i18next-browser-languagedetector"
], ],
// This is necessary because otherwise `vite dev` can't find the wasm // This is necessary because otherwise `vite dev` can't find the wasm
exclude: ["@mutinywallet/mutiny-wasm", "@mutinywallet/waila-wasm"] exclude: ["@mutinywallet/mutiny-wasm", "@mutinywallet/waila-wasm"]