Something went wrong. Try again.
Send ETH to anyone in the Atmosphere. Your keys never leave MetaMask. peachlet.app
bluesky atproto ethereum metamask
Something went wrong. Try again.
14 kB · 430 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431import { useController, useForm } from "react-hook-form";import { zodResolver } from "@hookform/resolvers/zod";import { walletDefaults, walletForm } from "../../wallet-validation";import { RecoveryBackup } from "../RecoveryBackup";import { useEffect, useState, useSyncExternalStore } from "react";import { createRecoveryPhrase, normalizeSecret, type WalletSecret,} from "@peachlet/wallet-core";import { backupLocalWallet, localAddress, localWalletState, lockLocalWallet, saveLocalWallet, subscribeWallet, unlockLocalWallet,} from "../../local-wallet";import { enableWalletPasskey, hasWalletPasskey, supportsWalletPasskeys, unlockWithPasskey,} from "../../wallet-passkey";import { Button, Heading, Hint, Input, Label, Panel } from "../ui";
export function LocalWallet({ allowWhileBusy = false, initialAction, onCreated, onCancel, onDismiss,}: { allowWhileBusy?: boolean; initialAction?: "create" | "import"; onCreated?: () => void; onCancel?: () => void; onDismiss?: () => void;}) { const state = useSyncExternalStore(subscribeWallet, localWalletState); const [mode, setMode] = useState<"home" | "create" | "import" | "backup">( initialAction || "home", ); const [initialPhrase] = useState(() => initialAction === "create" ? createRecoveryPhrase() : "", ); const form = useForm({ resolver: zodResolver(walletForm(state === "absent")), defaultValues: { ...walletDefaults, phrase: initialPhrase }, }); const kindField = useController({ control: form.control, name: "kind", }).field; const phraseField = useController({ control: form.control, name: "phrase", }).field; const passwordField = useController({ control: form.control, name: "password", }).field; const repeatField = useController({ control: form.control, name: "repeat", }).field; const { kind, phrase, password, repeat } = form.watch(); const setKind = (value: WalletSecret["kind"]) => kindField.onChange(value); const setPhrase = (value: string) => phraseField.onChange(value); const setPassword = (value: string) => passwordField.onChange(value); const [confirmed, setConfirmed] = useState(false); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const [passkeys, setPasskeys] = useState(false); const clear = () => { form.reset(walletDefaults); setConfirmed(false); setMode("home"); }; useEffect(() => { void supportsWalletPasskeys() .then(setPasskeys) .catch(() => {}); }, []); useEffect(() => { const hide = () => { if (document.hidden) { clear(); onCancel?.(); } }; document.addEventListener("visibilitychange", hide); return () => document.removeEventListener("visibilitychange", hide); }, []); const act = async (action: () => Promise<void>) => { setBusy(true); setError(""); try { await action(); } catch (e) { setError(e instanceof Error ? e.message : "Please try again."); } finally { setBusy(false); } }; const validated = (action: () => Promise<void>) => { if (!busy) return form.handleSubmit(() => act(action))(); }; const save = () => validated(async () => { if (password !== repeat) throw new Error("Your passwords do not match."); if (mode === "create" && !confirmed) throw new Error( "Choose the three missing recovery words to confirm your backup.", ); await saveLocalWallet( normalizeSecret(phrase, mode === "create" ? "mnemonic" : kind), password, ); clear(); onCreated?.(); }); return ( <Panel> <Heading> {mode === "create" ? confirmed ? "Protect your wallet" : "Back up your wallet" : mode === "import" ? "Restore your wallet" : "Wallet security"} </Heading> <Hint className="mt-2"> Your wallet, on this device. No extension needed. Only you control your keys. </Hint> {state === "absent" && mode === "home" && ( <div className="mt-4 flex flex-wrap gap-3"> <Button keepEnabled={allowWhileBusy} onClick={() => { setPhrase(createRecoveryPhrase()); setMode("create"); }} > Create a wallet </Button> <Button keepEnabled={allowWhileBusy} variant="subtle" onClick={() => setMode("import")} > I already have a wallet </Button> </div> )} {state === "absent" && (mode === "create" || mode === "import") && ( <form noValidate className="mt-4 flex flex-col gap-3" onSubmit={(e) => { e.preventDefault(); void save(); }} > {mode === "create" ? ( !confirmed && ( <RecoveryBackup key={phrase} phrase={phrase} onConfirmed={setConfirmed} onCancel={() => { clear(); onCancel?.(); }} allowWhileBusy={allowWhileBusy} /> ) ) : ( <> <Label htmlFor="wallet-import-kind">Restore using</Label> <select id="wallet-import-kind" value={kind} onChange={(e) => setKind(e.target.value as WalletSecret["kind"]) } > <option value="mnemonic">Recovery phrase</option> <option value="privateKey">Private key</option> </select> <Label htmlFor="wallet-secret"> {kind === "mnemonic" ? "Recovery words, in order" : "Private key"} </Label> <Input id="wallet-secret" type="password" secretLabel={ kind === "mnemonic" ? "recovery words" : "private key" } {...phraseField} aria-invalid={!!form.formState.errors.phrase} autoComplete="off" spellCheck={false} autoCapitalize="none" /> <Hint> Restores the first Ethereum account. Imported recovery phrases with an extra BIP-39 passphrase are not supported. </Hint> </> )} {(mode === "import" || confirmed) && ( <> <Label htmlFor="wallet-new-password"> Protect this wallet with a password </Label> <Input id="wallet-new-password" type="password" autoComplete="new-password" {...passwordField} aria-invalid={!!form.formState.errors.password} /> <Hint> At least 12 characters. This password unlocks this browser only. </Hint> <Label htmlFor="wallet-repeat">Repeat password</Label> <Input id="wallet-repeat" type="password" autoComplete="new-password" {...repeatField} aria-invalid={!!form.formState.errors.repeat} /> <div className="mt-3 flex items-center justify-end gap-3"> <Button keepEnabled={allowWhileBusy} variant="subtle" className="!bg-transparent" disabled={busy} onClick={() => { clear(); onCancel?.(); }} > Cancel </Button> <Button keepEnabled={allowWhileBusy} type="submit" disabled={busy} > Save my wallet </Button> </div> {mode === "create" && ( <Button variant="subtle" className="!bg-transparent" disabled={busy} onClick={() => setConfirmed(false)} > Back to recovery phrase </Button> )} </> )} </form> )} {state === "locked" && ( <form noValidate className="mt-4 flex flex-col gap-3" onSubmit={(e) => { e.preventDefault(); void validated(async () => { await unlockLocalWallet(password); clear(); }); }} > <Label htmlFor="wallet-password">Wallet password</Label> <Input id="wallet-password" type="password" autoComplete="current-password" {...passwordField} aria-invalid={!!form.formState.errors.password} /> <div className="mt-3 flex items-center justify-end gap-3"> {onDismiss && ( <Button keepEnabled variant="subtle" onClick={onDismiss}> Cancel </Button> )} <Button keepEnabled={allowWhileBusy} type="submit" disabled={busy}> Unlock wallet </Button> </div> {hasWalletPasskey() && ( <Button keepEnabled={allowWhileBusy} variant="subtle" disabled={busy} onClick={() => act(unlockWithPasskey)} > Unlock with your device </Button> )} </form> )} {state === "unlocked" && ( <div className="mt-4 flex flex-col gap-3"> <p className="break-all font-mono text-sm">{localAddress()}</p> <Hint> Locks when you leave this tab or after 15 minutes without a wallet action. </Hint> <div className="flex flex-wrap gap-3"> <Button keepEnabled={allowWhileBusy} variant="subtle" onClick={() => { clear(); lockLocalWallet(); }} > Lock wallet </Button> <Button keepEnabled={allowWhileBusy} variant="subtle" onClick={() => setMode("backup")} > Backup & security </Button> </div> {mode === "backup" && ( <form noValidate className="flex flex-col gap-3" onSubmit={(event) => { event.preventDefault(); if (!busy) void validated(async () => { const value = await backupLocalWallet(password); setPhrase(value.value); setPassword(""); }); }} > <Label htmlFor="wallet-security-password"> Confirm your password </Label> <Input id="wallet-security-password" type="password" {...passwordField} aria-invalid={!!form.formState.errors.password} autoComplete="current-password" /> <Button keepEnabled={allowWhileBusy} type="submit" disabled={busy} > Show my recovery information </Button> {passkeys && ( <Hint> Choose a passkey provider that supports wallet encryption, such as Google Password Manager or iCloud Passwords. Touch ID confirms your identity; support also depends on where the passkey is saved. </Hint> )} {passkeys && ( <Button keepEnabled={allowWhileBusy} disabled={busy} variant="subtle" onClick={() => validated(async () => { await enableWalletPasskey(password); clear(); }) } > Enable device unlock </Button> )} {phrase && ( <p className="break-all rounded-md bg-sunken p-4 font-sans"> {phrase} </p> )} <Hint> Device unlock depends on your browser and authenticator. Keep your password and recovery backup. </Hint> <Button keepEnabled={allowWhileBusy} variant="subtle" onClick={clear} > Done </Button> </form> )} </div> )} {Object.entries(form.formState.errors).map(([name, issue]) => ( <p key={name} role="alert" className="mt-2 text-sm text-bad"> {issue.message} </p> ))} {busy && <Hint>Securing your wallet…</Hint>} {error && ( <p role="alert" className="mt-3 text-sm text-bad"> {error} </p> )} </Panel> );}