diff --git a/CHANGELOG.md b/CHANGELOG.md index 256fddcc17..063a79b5c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- GUI: The wallet setup dialog is now a multi-step wizard. Creating a wallet lets you pick a name and location and requires backing up the seed phrase before continuing, and opening a wallet file supports drag & drop. Newly created wallets are named by the user instead of a Unix timestamp. - ASB: The Hermes protocol is now enabled by default (`hermes_enabled` defaults to `true`), and the default `hermes_min_swap_amount` was lowered from `0.01` to `0.001` BTC (~50 USD at a reference price of 50,000 USD/BTC). ## [4.11.4] - 2026-06-30 diff --git a/src-gui/src/models/tauriModelExt.ts b/src-gui/src/models/tauriModelExt.ts index 968709fc39..91adf2834e 100644 --- a/src-gui/src/models/tauriModelExt.ts +++ b/src-gui/src/models/tauriModelExt.ts @@ -341,6 +341,11 @@ export type PendingSeedSelectionApprovalRequest = ApprovalRequest & { content: Extract; }; +export type PendingSeedBackupApprovalRequest = ApprovalRequest & { + request: Extract; + content: Extract; +}; + export function isPendingLockBitcoinApprovalEvent( event: ApprovalRequest, ): event is PendingLockBitcoinApprovalRequest { @@ -361,6 +366,16 @@ export function isPendingSeedSelectionApprovalEvent( ); } +export function isPendingSeedBackupApprovalEvent( + event: ApprovalRequest, +): event is PendingSeedBackupApprovalRequest { + // Check if the request is a SeedBackup request and is pending + return ( + event.request.type === "SeedBackup" && + event.request_status.state === "Pending" + ); +} + export function isPendingBackgroundProcess( process: TauriBackgroundProgress, ): process is TauriBackgroundProgress { diff --git a/src-gui/src/renderer/components/modal/seed-selection/BackupSeedStep.tsx b/src-gui/src/renderer/components/modal/seed-selection/BackupSeedStep.tsx new file mode 100644 index 0000000000..da4b74832c --- /dev/null +++ b/src-gui/src/renderer/components/modal/seed-selection/BackupSeedStep.tsx @@ -0,0 +1,48 @@ +import { Box, Checkbox, FormControlLabel, Typography } from "@mui/material"; +import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox"; +import { PrivateKeyScamAlert } from "renderer/components/other/PrivateKeyWarning"; + +/// Shown while the backend blocks startup on its SeedBackup approval: the +/// freshly created wallet's seed is displayed once so the user records it +/// before continuing. +export default function BackupSeedStep({ + seed, + restoreHeight, + confirmed, + onConfirmedChange, +}: { + seed: string; + restoreHeight: number; + confirmed: boolean; + onConfirmedChange: (confirmed: boolean) => void; +}) { + return ( + + + + Write down your seed phrase and restore height. They are the only way to + recover this wallet if you lose access to this device. + + + + onConfirmedChange(e.target.checked)} + /> + } + label="I have written down my seed phrase and restore height" + /> + + ); +} diff --git a/src-gui/src/renderer/components/modal/seed-selection/NameLocationStep.tsx b/src-gui/src/renderer/components/modal/seed-selection/NameLocationStep.tsx new file mode 100644 index 0000000000..815388d4fe --- /dev/null +++ b/src-gui/src/renderer/components/modal/seed-selection/NameLocationStep.tsx @@ -0,0 +1,62 @@ +import { Box, TextField, Typography } from "@mui/material"; +import { open } from "@tauri-apps/plugin-dialog"; +import SearchIcon from "@mui/icons-material/Search"; +import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; + +export default function NameLocationStep({ + name, + setName, + directory, + setDirectory, +}: { + name: string; + setName: (name: string) => void; + directory: string; + setDirectory: (directory: string) => void; +}) { + const selectDirectory = async () => { + const selected = await open({ multiple: false, directory: true }); + if (selected) setDirectory(selected); + }; + + return ( + + + Choose a name for the wallet file and where to store it on this device. + + setName(e.target.value)} + placeholder="my-wallet" + error={name.length > 0 && name.trim().length === 0} + helperText={ + name.length > 0 && name.trim().length === 0 + ? "Enter a wallet name" + : "" + } + /> + + + } + > + Browse + + + + ); +} diff --git a/src-gui/src/renderer/components/modal/seed-selection/OpenWalletStep.tsx b/src-gui/src/renderer/components/modal/seed-selection/OpenWalletStep.tsx new file mode 100644 index 0000000000..34f749da98 --- /dev/null +++ b/src-gui/src/renderer/components/modal/seed-selection/OpenWalletStep.tsx @@ -0,0 +1,156 @@ +import { + Box, + Divider, + List, + ListItem, + ListItemButton, + ListItemText, + Typography, +} from "@mui/material"; +import { useEffect, useState } from "react"; +import { open } from "@tauri-apps/plugin-dialog"; +import { getCurrentWebview } from "@tauri-apps/api/webview"; +import FolderOpenIcon from "@mui/icons-material/FolderOpen"; + +export default function OpenWalletStep({ + walletPath, + setWalletPath, + recentWallets, +}: { + walletPath: string; + setWalletPath: (path: string) => void; + recentWallets: string[]; +}) { + const [isDragging, setIsDragging] = useState(false); + + // Tauri delivers dropped file paths through the webview drag-drop event + // rather than the DOM, so we subscribe to it while this step is mounted. + useEffect(() => { + let active = true; + let unlisten: (() => void) | undefined; + + getCurrentWebview() + .onDragDropEvent((event) => { + if (event.payload.type === "drop") { + setIsDragging(false); + const path = event.payload.paths[0]; + // Users commonly pick the `.keys` file; the wallet is the + // file without that extension. + if (path) setWalletPath(path.replace(/\.keys$/, "")); + } else if (event.payload.type === "leave") { + setIsDragging(false); + } else { + setIsDragging(true); + } + }) + .then((fn) => { + if (active) unlisten = fn; + else fn(); + }); + + return () => { + active = false; + unlisten?.(); + }; + }, [setWalletPath]); + + const selectWalletFile = async () => { + const selected = await open({ multiple: false, directory: false }); + if (selected) setWalletPath(selected.replace(/\.keys$/, "")); + }; + + return ( + + + + + Drag a wallet file here, or click to browse + + + + {recentWallets.length > 0 && ( + + + {recentWallets.map((path, index) => ( + + + setWalletPath(path)} + > + + + + {index < recentWallets.length - 1 && } + + ))} + + + )} + + ); +} diff --git a/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx b/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx index 83bad90cab..d15b41f0cd 100644 --- a/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx +++ b/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx @@ -3,48 +3,188 @@ import { DialogActions, DialogContent, DialogTitle, - FormControl, - FormControlLabel, - Radio, - RadioGroup, TextField, Typography, Button, Box, - List, - ListItem, - ListItemButton, - ListItemText, - Divider, Card, CardContent, + Breadcrumbs, } from "@mui/material"; import NewPasswordInput from "renderer/components/other/NewPasswordInput"; -import { useState, useEffect, useRef } from "react"; -import { usePendingSeedSelectionApproval } from "store/hooks"; +import { useState, useEffect, useReducer, useRef } from "react"; +import { + usePendingSeedSelectionApproval, + usePendingSeedBackupApproval, +} from "store/hooks"; import { resolveApproval, checkSeed } from "renderer/rpc"; import { SeedChoice } from "models/tauriModel"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; -import { open } from "@tauri-apps/plugin-dialog"; import AddIcon from "@mui/icons-material/Add"; import RefreshIcon from "@mui/icons-material/Refresh"; import FolderOpenIcon from "@mui/icons-material/FolderOpen"; -import SearchIcon from "@mui/icons-material/Search"; +import BackupSeedStep from "./BackupSeedStep"; +import OpenWalletStep from "./OpenWalletStep"; +import NameLocationStep from "./NameLocationStep"; +import CircularProgressWithSubtitle from "renderer/components/pages/swap/swap/components/CircularProgressWithSubtitle"; + +// The wallet-setup flow itself is a state machine in Rust +// (swap/src/cli/api/wallet_setup.rs): the backend walks +// ChooseSeed -> OpenWallet -> BackupSeed -> Finish and blocks on an approval +// whenever it needs user input. This dialog only renders whichever request is +// pending — a SeedSelection approval shows the wizard below, a SeedBackup +// approval shows the backup step — plus a local spinner while the backend is +// busy creating the wallet between the two. +type WalletMode = "RandomSeed" | "FromSeed" | "FromWalletPath"; + +type StepId = + | "chooseMode" + | "randomPassword" + | "nameLocation" + | "backupSeed" + | "seedPhrase" + | "storage" + | "openFile"; + +/// What the primary (bottom-right) button does on a step. +type PrimaryActionKind = + | "next" + | "createWallet" + | "restoreWallet" + | "openWallet" + | "finish"; + +interface Step { + id: StepId; + label: string; + action: PrimaryActionKind; +} + +// Single source of truth for each mode's wizard steps. Labels, content, the +// primary action, and navigation are all derived from this table, so a flow +// only ever changes here. +const FLOWS: Record = { + RandomSeed: [ + { id: "chooseMode", label: "Choose wallet", action: "next" }, + { id: "nameLocation", label: "Name & location", action: "next" }, + { id: "randomPassword", label: "Set password", action: "createWallet" }, + { id: "backupSeed", label: "Back up seed", action: "finish" }, + ], + FromSeed: [ + { id: "chooseMode", label: "Choose wallet", action: "next" }, + { id: "seedPhrase", label: "Enter seed phrase", action: "next" }, + { id: "storage", label: "Wallet storage", action: "next" }, + { id: "nameLocation", label: "Name & location", action: "restoreWallet" }, + ], + FromWalletPath: [ + { id: "chooseMode", label: "Choose wallet", action: "next" }, + { id: "openFile", label: "Open wallet file", action: "openWallet" }, + ], +}; + +const PRIMARY_LABELS: Record = { + next: "Continue", + createWallet: "Create wallet", + restoreWallet: "Restore wallet", + openWallet: "Open wallet", + finish: "Finish", +}; + +function stepIndex(mode: WalletMode, step: StepId): number { + return FLOWS[mode].findIndex((s) => s.id === step); +} + +/// Everything the user types into the wizard. +interface FormFields { + password: string; + customSeed: string; + blockheightInput: string; + walletPath: string; + name: string; + directory: string; +} + +const EMPTY_FIELDS: FormFields = { + password: "", + customSeed: "", + blockheightInput: "", + walletPath: "", + name: "", + directory: "", +}; + +// Wizard navigation state for filling out a SeedSelection approval. This is +// purely presentational: mode/step navigate the FLOWS table, fields hold the +// user's input, and a reset rebuilds everything for a new request. +interface WizardState { + mode: WalletMode; + step: StepId; + fields: FormFields; +} + +type WizardEvent = + | { type: "reset"; mode: WalletMode; fields: Partial } + | { type: "selectMode"; mode: WalletMode } + | { type: "setField"; field: keyof FormFields; value: string } + | { type: "next" } + | { type: "back" } + | { type: "navigateBackTo"; step: StepId }; + +const INITIAL_WIZARD: WizardState = { + mode: "RandomSeed", + step: "chooseMode", + fields: EMPTY_FIELDS, +}; + +// Every transition is guarded: an event that does not apply to the current +// state is a no-op instead of producing an invalid state. +function wizardReducer(state: WizardState, event: WizardEvent): WizardState { + const flow = FLOWS[state.mode]; + const index = stepIndex(state.mode, state.step); + + switch (event.type) { + case "reset": + return { + mode: event.mode, + step: "chooseMode", + fields: { ...EMPTY_FIELDS, ...event.fields }, + }; + case "setField": + return { + ...state, + fields: { ...state.fields, [event.field]: event.value }, + }; + case "selectMode": + // First click selects a mode, clicking the selected mode again advances. + if (state.step !== "chooseMode") return state; + return state.mode === event.mode + ? { ...state, step: flow[1].id } + : { ...state, mode: event.mode }; + case "next": + return index < flow.length - 1 + ? { ...state, step: flow[index + 1].id } + : state; + case "back": + return index > 0 ? { ...state, step: flow[index - 1].id } : state; + case "navigateBackTo": { + const target = stepIndex(state.mode, event.step); + return target !== -1 && target < index + ? { ...state, step: event.step } + : state; + } + } +} /** * Parses a block height input string and returns a number if valid, 0 for empty string, or false if invalid. * - * @param blockheightInput - The input string representing a block height. - * @returns A non-negative integer if the input is a valid positive number, - * 0 if the input is an empty string, - * false if the input is invalid (non-numeric, negative, or NaN). - * - * @example - * parseBlockHeightInput(""); // 0 - * parseBlockHeightInput("123"); // 123 - * parseBlockHeightInput("abc"); // false - * parseBlockHeightInput("-1"); // false - * parseBlockHeightInput("0"); // 0 + * Handles edge cases: + * - Empty string: returns 0 (valid, means "scan from beginning") + * - Whitespace-only: returns false (invalid) + * - Non-numeric characters: returns false (invalid) + * - Negative numbers: returns false (invalid) + * - Zero: returns 0 (valid) + * - Positive integers: returns the number (valid) */ function parseBlockHeightInput(blockheightInput: string): number | false { if (blockheightInput.length === 0) { @@ -68,143 +208,200 @@ function parseBlockHeightInput(blockheightInput: string): number | false { } export default function SeedSelectionDialog() { - const pendingApprovals = usePendingSeedSelectionApproval(); - const [selectedOption, setSelectedOption] = useState< - SeedChoice["type"] | undefined - >("RandomSeed"); - const [customSeed, setCustomSeed] = useState(""); - const [blockheightInput, setBlockheightInput] = useState(""); - const [asyncSeedValidation, setAsyncSeedValidation] = - useState(false); - const [password, setPassword] = useState(""); + const selectionApproval = usePendingSeedSelectionApproval()[0]; + const backupApproval = usePendingSeedBackupApproval()[0]; + const [wizard, dispatch] = useReducer(wizardReducer, INITIAL_WIZARD); const [isPasswordValid, setIsPasswordValid] = useState(true); - const [walletPath, setWalletPath] = useState(""); - - const approval = pendingApprovals[0]; + const [backupConfirmed, setBackupConfirmed] = useState(false); + // Backend is creating the wallet: the SeedSelection approval is resolved + // but the SeedBackup approval has not arrived yet. + const [waitingForWallet, setWaitingForWallet] = useState(false); + // Result of the async seed check, tagged with the seed it belongs to so a + // stale response can never validate a newer input. + const [seedValidation, setSeedValidation] = useState<{ + forSeed: string; + valid: boolean; + } | null>(null); + + const content = + selectionApproval?.request?.type === "SeedSelection" + ? selectionApproval.request.content + : undefined; + const recentWallets = content?.recent_wallets ?? []; + const backupContent = + backupApproval?.request?.type === "SeedBackup" + ? backupApproval.request.content + : undefined; + + // Reset the wizard whenever a new seed-selection approval arrives (e.g. + // after the user cancels a password prompt or a wallet fails to create and + // the backend asks again). + const lastRequestIdRef = useRef(null); + useEffect(() => { + const requestId = selectionApproval?.request_id; + if (!requestId || requestId === lastRequestIdRef.current) return; + lastRequestIdRef.current = requestId; + + // Default to opening a recent wallet when one exists, otherwise create. + dispatch({ + type: "reset", + mode: recentWallets.length > 0 ? "FromWalletPath" : "RandomSeed", + fields: { + directory: content?.default_wallet_directory ?? "", + walletPath: recentWallets[0] ?? "", + }, + }); + setSeedValidation(null); + setWaitingForWallet(false); + // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on a new approval id + }, [selectionApproval?.request_id]); + + // A new backup request means a wallet was created: ask for a fresh + // confirmation. Keyed on the stable request id — the approval object's + // identity churns on every store update and must not re-run this reset. + const lastBackupRequestIdRef = useRef(null); + useEffect(() => { + const requestId = backupApproval?.request_id; + if (!requestId || requestId === lastBackupRequestIdRef.current) return; + lastBackupRequestIdRef.current = requestId; - // Extract recent wallets from the approval request content - const recentWallets = - approval?.request?.type === "SeedSelection" - ? approval.request.content.recent_wallets - : []; + setWaitingForWallet(false); + setBackupConfirmed(false); + // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on a new backup request id + }, [backupApproval?.request_id]); - // Only run async validation when in "FromSeed" mode with content + const trimmedSeed = wizard.fields.customSeed.trim(); const needsSeedValidation = - selectedOption === "FromSeed" && customSeed.trim(); + wizard.mode === "FromSeed" && trimmedSeed.length > 0; useEffect(() => { if (!needsSeedValidation) return; - checkSeed(customSeed.trim()) - .then(setAsyncSeedValidation) - .catch(() => setAsyncSeedValidation(false)); - }, [customSeed, needsSeedValidation]); - - // isSeedValid is derived: only true if we need validation AND async check passed - const isSeedValid = needsSeedValidation && asyncSeedValidation; - - // Auto-select the first recent wallet if available (one-time initialization) - const hasInitializedRef = useRef(false); - useEffect(() => { - if (recentWallets.length > 0 && !hasInitializedRef.current) { - hasInitializedRef.current = true; - setSelectedOption("FromWalletPath"); - setWalletPath(recentWallets[0]); - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- only init once when wallets available - }, [recentWallets.length]); - - const selectWalletFile = async () => { - const selected = await open({ - multiple: false, - directory: false, - }); - - if (selected) { - setWalletPath(selected); - } + checkSeed(trimmedSeed) + .then((valid) => setSeedValidation({ forSeed: trimmedSeed, valid })) + .catch(() => setSeedValidation({ forSeed: trimmedSeed, valid: false })); + }, [trimmedSeed, needsSeedValidation]); + + // Which screen is shown is decided by the backend's pending request. + const view = backupApproval + ? ("backup" as const) + : selectionApproval + ? ("wizard" as const) + : waitingForWallet + ? ("waiting" as const) + : null; + + if (view === null) return null; + + // The backup and waiting screens render the last step of the create flow. + const mode = view === "wizard" ? wizard.mode : "RandomSeed"; + const step: StepId = view === "wizard" ? wizard.step : "backupSeed"; + const flow = FLOWS[mode]; + const index = stepIndex(mode, step); + const { fields } = wizard; + + const setField = (field: keyof FormFields) => (value: string) => + dispatch({ type: "setField", field, value }); + + const isSeedValid = + needsSeedValidation && + seedValidation !== null && + seedValidation.forSeed === trimmedSeed && + seedValidation.valid; + const hasBlockheightInput = fields.blockheightInput.length > 0; + const isBlockheightValid = + parseBlockHeightInput(fields.blockheightInput) !== false; + const isBlockheightInvalid = hasBlockheightInput && !isBlockheightValid; + + // Whether the current step's input is complete enough to act on. + const stepValid: Record = { + chooseMode: true, + randomPassword: isPasswordValid, + seedPhrase: trimmedSeed.length > 0 && isSeedValid && !isBlockheightInvalid, + storage: isPasswordValid, + nameLocation: + fields.name.trim().length > 0 && fields.directory.trim().length > 0, + openFile: fields.walletPath.length > 0, + backupSeed: view === "backup" && backupConfirmed, }; - const hasBlockheightInput = blockheightInput.length > 0; - const isBlockheightValid = parseBlockHeightInput(blockheightInput) !== false; - const isBlockheightInvalid = - hasBlockheightInput && isBlockheightValid === false; - - const Legacy = async () => { - if (!approval) - throw new Error("No approval request found for seed selection"); - - await resolveApproval(approval.request_id, { - type: "Legacy", - }); - }; - - const accept = async () => { - if (!approval) - throw new Error("No approval request found for seed selection"); - - let seedChoice: SeedChoice; - - switch (selectedOption) { + const buildSeedChoice = (chosenMode: WalletMode): SeedChoice => { + switch (chosenMode) { case "RandomSeed": - seedChoice = { type: "RandomSeed", content: { password } }; - break; - + return { + type: "RandomSeed", + content: { + password: fields.password, + name: fields.name, + directory: fields.directory, + }, + }; case "FromSeed": { - const parsedBlockHeight = parseBlockHeightInput(blockheightInput); - + const parsedBlockHeight = parseBlockHeightInput( + fields.blockheightInput, + ); if (parsedBlockHeight === false) { throw new Error("Invalid blockheight"); } - - seedChoice = { + return { type: "FromSeed", content: { - seed: customSeed, - password, + seed: fields.customSeed, + password: fields.password, restore_height: parsedBlockHeight, + name: fields.name, + directory: fields.directory, }, }; - break; } - - default: - seedChoice = { + case "FromWalletPath": + return { type: "FromWalletPath", - content: { wallet_path: walletPath }, + content: { wallet_path: fields.walletPath }, }; - break; } + }; + + const resolveSelection = async (seedChoice: SeedChoice) => { + if (!selectionApproval) + throw new Error("No approval request found for seed selection"); + await resolveApproval(selectionApproval.request_id, seedChoice); + }; - await resolveApproval(approval.request_id, seedChoice); + // The backend creates the wallet after the choice is resolved and then + // requests the seed backup; show a spinner in between. + const createWallet = async () => { + const seedChoice = buildSeedChoice("RandomSeed"); + setWaitingForWallet(true); + try { + await resolveSelection(seedChoice); + } catch (e) { + setWaitingForWallet(false); + throw e; + } }; - if (!approval) { - return null; - } + const finishBackup = async () => { + if (!backupApproval) + throw new Error("No approval request found for seed backup"); + await resolveApproval(backupApproval.request_id, true); + }; - // Disable the button if the user is restoring from a seed and the seed is invalid - // or if selecting wallet path and no path is selected, - // or if blockheight is provided but invalid, - // or if setting a password and they don't match - const isDisabled = - selectedOption === "FromSeed" - ? customSeed.trim().length === 0 || - !isSeedValid || - isBlockheightInvalid || - !isPasswordValid - : selectedOption === "FromWalletPath" - ? !walletPath - : selectedOption === "RandomSeed" - ? !isPasswordValid - : false; + const primaryHandlers: Record void | Promise> = + { + next: () => dispatch({ type: "next" }), + finish: finishBackup, + createWallet, + restoreWallet: () => resolveSelection(buildSeedChoice("FromSeed")), + openWallet: () => resolveSelection(buildSeedChoice("FromWalletPath")), + }; return ( - - - {/* Open existing wallet option */} - setSelectedOption("FromWalletPath")} - > - - - - Open wallet file - - - - - {/* Create new wallet option */} - setSelectedOption("RandomSeed")} - > - - + + + {flow.map((s, i) => { + const crumbLabel = i === 0 ? "Set up your wallet" : s.label; + // Past crumbs link back; upcoming crumbs preview muted; navigation + // is disabled once a wallet has been created. + const navigable = i < index && view === "wizard"; + const upcoming = i > index; + + return navigable ? ( dispatch({ type: "navigateBackTo", step: s.id })} + sx={{ + cursor: "pointer", + "&:hover": { textDecoration: "underline" }, + }} > - Create new wallet + {crumbLabel} - - - - {/* Restore from seed option */} - setSelectedOption("FromSeed")} - > - - + ) : ( - Restore from seed + {crumbLabel} - - - + ); + })} + + + + {step === "chooseMode" && ( + + + } + label="Open wallet file" + onClick={() => + dispatch({ type: "selectMode", mode: "FromWalletPath" }) + } + /> + } + label="Create new wallet" + onClick={() => + dispatch({ type: "selectMode", mode: "RandomSeed" }) + } + /> + + } + label="Restore from seed" + onClick={() => dispatch({ type: "selectMode", mode: "FromSeed" })} + /> + + )} - {selectedOption === "RandomSeed" && ( + {step === "randomPassword" && ( - - A new wallet with a random seed phrase will be generated. - - - You will have the option to back it up later. + A new wallet with a random seed phrase will be generated. You will + record the seed in the final step. )} - {selectedOption === "FromSeed" && ( + {step === "seedPhrase" && ( + + Enter the 25-word Monero seed phrase of the wallet you want to + restore. Setting the restore height to around when the wallet was + created speeds up syncing. + setCustomSeed(e.target.value)} + value={fields.customSeed} + onChange={(e) => setField("customSeed")(e.target.value)} placeholder="Enter your Monero 25 words seed phrase..." - error={!isSeedValid && customSeed.length > 0} + error={!isSeedValid && fields.customSeed.length > 0} helperText={ isSeedValid ? "Seed is valid" - : customSeed.length > 0 + : fields.customSeed.length > 0 ? "Seed is invalid" : "" } /> - setBlockheightInput(e.target.value)} + value={fields.blockheightInput} + onChange={(e) => setField("blockheightInput")(e.target.value)} placeholder="Enter restore blockheight, leave empty to scan from the blockchain start" error={isBlockheightInvalid} helperText={ @@ -379,126 +550,132 @@ export default function SeedSelectionDialog() { : "" } /> + + )} + {step === "storage" && ( + + + Set a password to encrypt the wallet file stored on this device. + Leave it empty to store the wallet unencrypted. + )} - {selectedOption === "FromWalletPath" && ( - - - - - - {recentWallets.length > 0 && ( - - - - {recentWallets.map((path, index) => ( - - - setWalletPath(path)} - > - - - - {index < recentWallets.length - 1 && } - - ))} - - - - )} - + {step === "nameLocation" && ( + + )} + + {step === "backupSeed" && + (backupContent ? ( + + ) : ( + + ))} + + {step === "openFile" && ( + )} + + {step === "chooseMode" && ( + resolveSelection({ type: "Legacy" })} + contextRequirement={false} + displayErrorSnackbar + color="inherit" + > + No wallet (Legacy) + + )} + {index > 0 && view === "wizard" && ( + + )} + - No wallet (Legacy) - - { + await primaryHandlers[flow[index].action](); + }} > - Continue + {PRIMARY_LABELS[flow[index].action]} ); } + +function ModeCard({ + selected, + icon, + label, + onClick, +}: { + selected: boolean; + icon: React.ReactNode; + label: string; + onClick: () => void; +}) { + return ( + + + {icon} + + {label} + + + + ); +} diff --git a/src-gui/src/renderer/components/other/NewPasswordInput.tsx b/src-gui/src/renderer/components/other/NewPasswordInput.tsx index 4ffa754672..56b81894d0 100644 --- a/src-gui/src/renderer/components/other/NewPasswordInput.tsx +++ b/src-gui/src/renderer/components/other/NewPasswordInput.tsx @@ -10,7 +10,7 @@ export default function NewPasswordInput({ autoFocus = true, }: { password: string; - setPassword: Dispatch>; + setPassword: (password: string) => void; isPasswordValid: boolean; setIsPasswordValid: Dispatch>; autoFocus?: boolean; diff --git a/src-gui/src/store/hooks.ts b/src-gui/src/store/hooks.ts index 910bad3a2d..b880787a68 100644 --- a/src-gui/src/store/hooks.ts +++ b/src-gui/src/store/hooks.ts @@ -6,6 +6,8 @@ import { isPendingBackgroundProcess, isPendingLockBitcoinApprovalEvent, isPendingSeedSelectionApprovalEvent, + isPendingSeedBackupApprovalEvent, + PendingSeedBackupApprovalRequest, PendingApprovalRequest, PendingLockBitcoinApprovalRequest, PendingSelectMakerApprovalRequest, @@ -232,6 +234,11 @@ export function usePendingSeedSelectionApproval(): PendingSeedSelectionApprovalR return approvals.filter((c) => isPendingSeedSelectionApprovalEvent(c)); } +export function usePendingSeedBackupApproval(): PendingSeedBackupApprovalRequest[] { + const approvals = usePendingApprovals(); + return approvals.filter((c) => isPendingSeedBackupApprovalEvent(c)); +} + export function usePendingPasswordApproval(): PendingPasswordApprovalRequest[] { const approvals = usePendingApprovals(); return approvals.filter((c) => isPendingPasswordApprovalEvent(c)); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4bdd9c32fc..af5f79e835 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -8,14 +8,14 @@ use swap::cli::{ BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, ChangeMoneroNodeArgs, CheckElectrumNodeArgs, CheckElectrumNodeResponse, CheckMoneroNodeArgs, CheckMoneroNodeResponse, CheckSeedArgs, CheckSeedResponse, CreateMoneroSubaddressArgs, - DeleteAllLogsArgs, ExportBitcoinWalletArgs, - GetBitcoinAddressArgs, GetCurrentSwapArgs, GetDataDirArgs, GetHistoryArgs, GetLogsArgs, - GetMoneroAddressesArgs, GetMoneroBalanceArgs, GetMoneroHistoryArgs, - GetMoneroMainAddressArgs, GetMoneroSeedArgs, GetMoneroSubaddressesArgs, - GetMoneroSyncProgressArgs, GetPendingApprovalsResponse, GetRestoreHeightArgs, - GetSwapInfoArgs, GetSwapInfosAllArgs, GetSwapTimelockArgs, MoneroRecoveryArgs, - RedactArgs, RefreshP2PArgs, RejectApprovalArgs, RejectApprovalResponse, - ResolveApprovalArgs, ResumeSwapArgs, SendMoneroArgs, SetMoneroSubaddressLabelArgs, + DeleteAllLogsArgs, ExportBitcoinWalletArgs, GetBitcoinAddressArgs, GetCurrentSwapArgs, + GetDataDirArgs, GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, + GetMoneroBalanceArgs, GetMoneroHistoryArgs, GetMoneroMainAddressArgs, + GetMoneroSeedArgs, GetMoneroSubaddressesArgs, GetMoneroSyncProgressArgs, + GetPendingApprovalsResponse, GetRestoreHeightArgs, GetSwapInfoArgs, + GetSwapInfosAllArgs, GetSwapTimelockArgs, MoneroRecoveryArgs, RedactArgs, + RefreshP2PArgs, RejectApprovalArgs, RejectApprovalResponse, ResolveApprovalArgs, + ResumeSwapArgs, SendMoneroArgs, SetMoneroSubaddressLabelArgs, SetMoneroWalletPasswordArgs, SetRestoreHeightArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs, }, @@ -388,7 +388,6 @@ pub async fn save_txt_files( Ok(()) } - // Here we define the Tauri commands that will be available to the frontend // The commands are defined using the `tauri_command!` macro. // Implementations are handled by the Request trait diff --git a/swap/src/cli/api.rs b/swap/src/cli/api.rs index 8f7d56c5c7..37f2f70407 100644 --- a/swap/src/cli/api.rs +++ b/swap/src/cli/api.rs @@ -1,7 +1,8 @@ pub mod request; pub mod tauri_bindings; +mod wallet_setup; -use crate::cli::api::tauri_bindings::{ContextStatus, SeedChoice}; +use crate::cli::api::tauri_bindings::ContextStatus; use crate::cli::command::{Bitcoin, Monero}; use crate::common::tor::{bootstrap_tor_client, create_tor_client}; use crate::common::tracing_util::Format; @@ -570,9 +571,14 @@ mod builder { .context("Failed to initialize wallet database")?; let seed_choice = match tauri_handle { - Some(tauri_handle) => { - Some(wallet::request_seed_choice(tauri_handle, &wallet_database).await?) - } + Some(tauri_handle) => Some( + wallet_setup::request_seed_choice( + tauri_handle, + &wallet_database, + eigenwallet_data_dir, + ) + .await?, + ), None => None, }; @@ -682,7 +688,7 @@ mod builder { let daemon = monero_sys::Daemon::try_from(monero_node_address)?; // Open or create Monero wallet - let (wallet, seed) = wallet::open_monero_wallet( + let (wallet, seed) = wallet_setup::open_monero_wallet( self.tauri_handle.clone(), eigenwallet_data_dir, base_data_dir, @@ -896,10 +902,6 @@ pub use builder::ContextBuilder; mod wallet { use super::*; - // Legacy mode uses this Monero monitoring wallet and the seed.pem-derived - // Bitcoin wallet in the same CLI data directory. - const LEGACY_MONITORING_WALLET_NAME: &str = "swap-tool-blockchain-monitoring-wallet"; - pub(super) async fn init_bitcoin_wallet( electrum_rpc_urls: Vec, seed: &Seed, @@ -931,335 +933,6 @@ mod wallet { Ok(wallet) } - - fn legacy_wallet_path(data_dir: &Path) -> PathBuf { - data_dir.join(LEGACY_MONITORING_WALLET_NAME) - } - - fn is_legacy_wallet_path(wallet_path: &str, legacy_data_dir: &Path) -> bool { - Path::new(wallet_path) == legacy_wallet_path(legacy_data_dir) - } - - pub(super) async fn request_and_open_monero_wallet_legacy( - data_dir: &PathBuf, - env_config: EnvConfig, - daemon: &monero_sys::Daemon, - ) -> Result { - let wallet_path = legacy_wallet_path(data_dir); - - let wallet = monero::Wallet::open_or_create( - wallet_path.display().to_string(), - daemon.clone(), - env_config.monero_network, - true, - ) - .await - .context("Failed to create wallet")?; - - Ok(wallet) - } - - /// Requests the user to select a seed choice from a list of recent wallets - pub(super) async fn request_seed_choice( - tauri_handle: TauriHandle, - database: &monero_sys::Database, - ) -> Result { - let recent_wallets = database.get_recent_wallets(5).await?; - let recent_wallets: Vec = - recent_wallets.into_iter().map(|w| w.wallet_path).collect(); - - let seed_choice = tauri_handle - .request_seed_selection_with_recent_wallets(recent_wallets) - .await?; - - Ok(seed_choice) - } - - /// Opens or creates a Monero wallet after asking the user via the Tauri UI. - /// - /// The user can: - /// - Create a new wallet with a random seed. - /// - Recover a wallet from a given seed phrase. - /// - Open an existing wallet file (with password verification). - /// - /// Errors if the user aborts, provides an incorrect password, or the wallet - /// fails to open/create. - pub(super) async fn open_monero_wallet( - tauri_handle: Option, - eigenwallet_data_dir: &PathBuf, - legacy_data_dir: &PathBuf, - env_config: EnvConfig, - daemon: &monero_sys::Daemon, - seed_choice: Option, - database: &monero_sys::Database, - ) -> Result<(monero_sys::WalletHandle, Seed), Error> { - let eigenwallet_wallets_dir = eigenwallet_data_dir.join("wallets"); - - let wallet = match seed_choice { - Some(mut seed_choice) => { - // This loop continually requests the user to select a wallet file - // It then requests the user to provide a password. - // It repeats until the user provides a valid password or rejects the password request - // When the user rejects the password request, we prompt him to select a wallet again - loop { - let _monero_progress_handle = tauri_handle - .new_background_process_with_initial_progress( - TauriBackgroundProgress::OpeningMoneroWallet, - (), - ); - - fn new_wallet_path(eigenwallet_wallets_dir: &PathBuf) -> Result { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - let wallet_path = - eigenwallet_wallets_dir.join(format!("wallet_{}", timestamp)); - - swap_fs::ensure_directory_exists(&wallet_path) - .context("Failed to create wallet directory")?; - - Ok(wallet_path) - } - - let wallet = match seed_choice { - SeedChoice::RandomSeed { password } => { - // Create wallet with Unix timestamp as name - let wallet_path = new_wallet_path(&eigenwallet_wallets_dir) - .context("Failed to determine path for new wallet")?; - - monero::Wallet::open_or_create_with_password( - wallet_path.display().to_string(), - if password.is_empty() { - None - } else { - Some(password) - }, - daemon.clone(), - env_config.monero_network, - true, - ) - .await - .context("Failed to create wallet from random seed")? - } - SeedChoice::FromSeed { - seed: mnemonic, - restore_height, - password, - } => { - // Create wallet from provided seed - let wallet_path = new_wallet_path(&eigenwallet_wallets_dir) - .context("Failed to determine path for new wallet")?; - - monero::Wallet::open_or_create_from_seed_with_password( - wallet_path.display().to_string(), - mnemonic, - if password.is_empty() { - None - } else { - Some(password) - }, - env_config.monero_network, - restore_height.into(), - true, - daemon.clone(), - ) - .await - .context("Failed to create wallet from provided seed")? - } - SeedChoice::FromWalletPath { ref wallet_path } => { - let wallet_path = wallet_path.clone(); - - if is_legacy_wallet_path(&wallet_path, legacy_data_dir) { - let wallet = request_and_open_monero_wallet_legacy( - legacy_data_dir, - env_config, - daemon, - ) - .await?; - let seed = Seed::from_file_or_generate(legacy_data_dir) - .await - .context("Failed to read legacy seed from file")?; - - break (wallet, seed); - } - - // Helper function to verify password - let verify_password = |password: String| -> Result { - monero_sys::WalletHandle::verify_wallet_password( - wallet_path.clone(), - password, - ) - .map_err(|e| { - anyhow::anyhow!("Failed to verify wallet password: {}", e) - }) - }; - - // Request and verify password before opening wallet - let wallet_password: Option = { - const WALLET_EMPTY_PASSWORD: &str = ""; - - // First try empty password - if verify_password(WALLET_EMPTY_PASSWORD.to_string())? { - Some(WALLET_EMPTY_PASSWORD.to_string()) - } else { - // If empty password fails, ask user for password - loop { - // Request password from user - let password = tauri_handle - .request_password(wallet_path.clone()) - .await - .inspect_err(|e| { - tracing::error!( - "Failed to get password from user: {}", - e - ); - }) - .ok(); - - // If the user rejects the password request (presses cancel) - // We prompt him to select a wallet again - let password = match password { - Some(password) => password, - None => break None, - }; - - // Verify the password using the helper function - match verify_password(password.clone()) { - Ok(true) => { - break Some(password); - } - Ok(false) => { - // Continue loop to request password again - continue; - } - Err(e) => { - return Err(e); - } - } - } - } - }; - - let password = match wallet_password { - Some(password) => password, - // None means the user rejected the password request - // We prompt him to select a wallet again - None => { - seed_choice = request_seed_choice( - tauri_handle.clone().unwrap(), - database, - ) - .await?; - continue; - } - }; - - // Open existing wallet with verified password - monero::Wallet::open_or_create_with_password( - wallet_path.clone(), - password, - daemon.clone(), - env_config.monero_network, - true, - ) - .await - .context("Failed to open wallet from provided path")? - } - - SeedChoice::Legacy => { - let wallet = request_and_open_monero_wallet_legacy( - legacy_data_dir, - env_config, - daemon, - ) - .await?; - let seed = Seed::from_file_or_generate(legacy_data_dir) - .await - .context("Failed to read legacy seed from file")?; - - break (wallet, seed); - } - }; - - // Extract seed from the wallet - tracing::info!( - "Extracting seed from wallet directory: {}", - legacy_data_dir.display() - ); - let seed = Seed::from_monero_wallet(&wallet) - .await - .context("Failed to extract seed from wallet")?; - - break (wallet, seed); - } - } - - // If we don't have a seed choice, we use the legacy wallet - // This is used for the CLI to monitor the blockchain - None => { - let wallet = - request_and_open_monero_wallet_legacy(legacy_data_dir, env_config, daemon) - .await?; - let seed = Seed::from_file_or_generate(legacy_data_dir) - .await - .context("Failed to read legacy seed from file")?; - - (wallet, seed) - } - }; - - Ok(wallet) - } - - #[cfg(test)] - mod tests { - use super::*; - use bitcoin_wallet::BitcoinWalletSeed; - - #[test] - fn detects_legacy_monitoring_wallet_path() { - let legacy_data_dir = PathBuf::from("/tmp/eigenwallet-mainnet"); - let wallet_path = legacy_wallet_path(&legacy_data_dir); - - assert!(is_legacy_wallet_path( - wallet_path.to_str().unwrap(), - &legacy_data_dir, - )); - } - - #[test] - fn does_not_treat_other_wallet_files_as_legacy() { - let legacy_data_dir = PathBuf::from("/tmp/eigenwallet-mainnet"); - let wallet_path = "/tmp/eigenwallet/wallets/wallet_123"; - - assert!(!is_legacy_wallet_path(wallet_path, &legacy_data_dir)); - } - - #[tokio::test] - async fn legacy_seed_file_keeps_bitcoin_key_stable() { - let temp_dir = tempfile::tempdir().unwrap(); - - let legacy_seed = Seed::from_file_or_generate(temp_dir.path()).await.unwrap(); - let legacy_key = legacy_seed - .derive_extended_private_key(bitcoin::Network::Bitcoin) - .unwrap(); - - let reread_legacy_seed = Seed::from_file_or_generate(temp_dir.path()).await.unwrap(); - let reread_legacy_key = reread_legacy_seed - .derive_extended_private_key(bitcoin::Network::Bitcoin) - .unwrap(); - - let non_legacy_seed = Seed::from([0; crate::seed::SEED_LENGTH]); - let non_legacy_key = non_legacy_seed - .derive_extended_private_key(bitcoin::Network::Bitcoin) - .unwrap(); - - assert_eq!(legacy_key, reread_legacy_key); - assert_ne!(legacy_key, non_legacy_key); - } - } } pub mod data { diff --git a/swap/src/cli/api/tauri_bindings.rs b/swap/src/cli/api/tauri_bindings.rs index 5be8f8adb4..6e6a4af876 100644 --- a/swap/src/cli/api/tauri_bindings.rs +++ b/swap/src/cli/api/tauri_bindings.rs @@ -141,11 +141,19 @@ pub struct PasswordRequestDetails { pub enum SeedChoice { RandomSeed { password: String, + /// File name for the new wallet. + name: String, + /// Directory the new wallet file is stored in. + directory: String, }, FromSeed { seed: String, restore_height: u32, password: String, + /// File name for the restored wallet. + name: String, + /// Directory the restored wallet file is stored in. + directory: String, }, FromWalletPath { wallet_path: String, @@ -158,6 +166,18 @@ pub enum SeedChoice { pub struct SeedSelectionDetails { /// List of recently used wallet paths pub recent_wallets: Vec, + /// Default directory new wallet files are stored in. + pub default_wallet_directory: String, +} + +/// Seed phrase of a freshly created wallet, shown once so the user can back +/// it up before startup continues. +#[typeshare] +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SeedBackupDetails { + pub seed: String, + #[typeshare(serialized_as = "number")] + pub restore_height: u64, } #[typeshare] @@ -187,6 +207,9 @@ pub enum ApprovalRequestType { /// Request password for wallet file. /// User must provide password to unlock the selected wallet. PasswordRequest(PasswordRequestDetails), + /// Request the user to back up the seed of a freshly created wallet. + /// Resolved once the user confirms having recorded it. + SeedBackup(SeedBackupDetails), } #[typeshare] @@ -558,6 +581,7 @@ impl Display for ApprovalRequest { ApprovalRequestType::SeedSelection(_) => write!(f, "SeedSelection()"), ApprovalRequestType::SendMonero(_) => write!(f, "SendMonero()"), ApprovalRequestType::PasswordRequest(_) => write!(f, "PasswordRequest()"), + ApprovalRequestType::SeedBackup(_) => write!(f, "SeedBackup()"), } } } @@ -576,12 +600,9 @@ pub trait TauriEmitter { timeout_secs: u64, ) -> Result; - async fn request_seed_selection(&self) -> Result; + async fn request_seed_selection(&self, details: SeedSelectionDetails) -> Result; - async fn request_seed_selection_with_recent_wallets( - &self, - recent_wallets: Vec, - ) -> Result; + async fn request_seed_backup(&self, details: SeedBackupDetails) -> Result; async fn request_password(&self, wallet_path: String) -> Result; @@ -696,17 +717,13 @@ impl TauriEmitter for TauriHandle { .unwrap_or(false)) } - async fn request_seed_selection(&self) -> Result { - self.request_seed_selection_with_recent_wallets(vec![]) + async fn request_seed_selection(&self, details: SeedSelectionDetails) -> Result { + self.request_approval(ApprovalRequestType::SeedSelection(details), None) .await } - async fn request_seed_selection_with_recent_wallets( - &self, - recent_wallets: Vec, - ) -> Result { - let details = SeedSelectionDetails { recent_wallets }; - self.request_approval(ApprovalRequestType::SeedSelection(details), None) + async fn request_seed_backup(&self, details: SeedBackupDetails) -> Result { + self.request_approval(ApprovalRequestType::SeedBackup(details), None) .await } @@ -779,23 +796,16 @@ impl TauriEmitter for Option { } } - async fn request_seed_selection(&self) -> Result { + async fn request_seed_selection(&self, details: SeedSelectionDetails) -> Result { match self { - Some(tauri) => tauri.request_seed_selection().await, + Some(tauri) => tauri.request_seed_selection(details).await, None => bail!("No Tauri handle available"), } } - async fn request_seed_selection_with_recent_wallets( - &self, - recent_wallets: Vec, - ) -> Result { + async fn request_seed_backup(&self, details: SeedBackupDetails) -> Result { match self { - Some(tauri) => { - tauri - .request_seed_selection_with_recent_wallets(recent_wallets) - .await - } + Some(tauri) => tauri.request_seed_backup(details).await, None => bail!("No Tauri handle available"), } } diff --git a/swap/src/cli/api/wallet_setup.rs b/swap/src/cli/api/wallet_setup.rs new file mode 100644 index 0000000000..dfdf4ac432 --- /dev/null +++ b/swap/src/cli/api/wallet_setup.rs @@ -0,0 +1,428 @@ +//! The eigenwallet startup flow: opening or creating the Monero wallet the +//! app runs on, driven by an explicit state machine ([`SetupFlow`]). +//! +//! Every state that needs user input blocks on a Tauri approval; the GUI is a +//! renderer of whichever request is currently pending. + +use super::*; +use crate::cli::api::tauri_bindings::{SeedBackupDetails, SeedChoice, SeedSelectionDetails}; + +// Legacy mode uses this Monero monitoring wallet and the seed.pem-derived +// Bitcoin wallet in the same CLI data directory. +const LEGACY_MONITORING_WALLET_NAME: &str = "swap-tool-blockchain-monitoring-wallet"; + +/// The wallet-setup flow as an explicit state machine. Transitions: +/// +/// ```text +/// ChooseSeed ──► OpenWallet ──► BackupSeed ──► Finish +/// ▲ │ (create) (fresh wallet) +/// └──────────────┘ (failure or password rejection) +/// ``` +enum SetupFlow { + /// Ask the user how to open a wallet. + ChooseSeed, + /// Attempt to open or create the wallet for the user's choice. + OpenWallet(SeedChoice), + /// A freshly generated wallet: show the seed until the user confirms + /// having backed it up. + BackupSeed(monero_sys::WalletHandle), + /// The wallet is open; extract the swap seed and finish. + Finish(monero_sys::WalletHandle), +} + +/// Opens or creates a Monero wallet by driving [`SetupFlow`]. +/// +/// The user can: +/// - Create a new wallet with a random seed (the seed must be backed up +/// before startup continues). +/// - Recover a wallet from a given seed phrase. +/// - Open an existing wallet file (with password verification). +/// +/// A wallet that fails to open or create puts the user back into the +/// chooser; hard errors (e.g. the approval UI going away) propagate. +pub(super) async fn open_monero_wallet( + tauri_handle: Option, + eigenwallet_data_dir: &Path, + legacy_data_dir: &PathBuf, + env_config: EnvConfig, + daemon: &monero_sys::Daemon, + seed_choice: Option, + database: &monero_sys::Database, +) -> Result<(monero_sys::WalletHandle, Seed), Error> { + // Without a seed choice there is no UI driving the flow: the CLI uses + // the legacy wallet to monitor the blockchain. + let Some(seed_choice) = seed_choice else { + let wallet = + request_and_open_monero_wallet_legacy(legacy_data_dir, env_config, daemon).await?; + let seed = Seed::from_file_or_generate(legacy_data_dir) + .await + .context("Failed to read legacy seed from file")?; + + return Ok((wallet, seed)); + }; + + let mut flow = SetupFlow::OpenWallet(seed_choice); + + loop { + flow = match flow { + SetupFlow::ChooseSeed => SetupFlow::OpenWallet( + request_seed_choice( + tauri_handle.clone().unwrap(), + database, + eigenwallet_data_dir, + ) + .await?, + ), + SetupFlow::OpenWallet(choice) => { + let _monero_progress_handle = tauri_handle + .new_background_process_with_initial_progress( + TauriBackgroundProgress::OpeningMoneroWallet, + (), + ); + + let opened: Result = match choice { + SeedChoice::RandomSeed { + password, + name, + directory, + } => { + async { + let wallet_path = new_wallet_path(&directory, &name) + .context("Failed to determine path for new wallet")?; + + let wallet = monero::Wallet::open_or_create_with_password( + wallet_path.display().to_string(), + if password.is_empty() { + None + } else { + Some(password) + }, + daemon.clone(), + env_config.monero_network, + true, + ) + .await + .context("Failed to create wallet from random seed")?; + + Ok(SetupFlow::BackupSeed(wallet)) + } + .await + } + SeedChoice::FromSeed { + seed: mnemonic, + restore_height, + password, + name, + directory, + } => { + async { + let wallet_path = new_wallet_path(&directory, &name) + .context("Failed to determine path for new wallet")?; + + let wallet = monero::Wallet::open_or_create_from_seed_with_password( + wallet_path.display().to_string(), + mnemonic, + if password.is_empty() { + None + } else { + Some(password) + }, + env_config.monero_network, + restore_height.into(), + true, + daemon.clone(), + ) + .await + .context("Failed to create wallet from provided seed")?; + + Ok(SetupFlow::Finish(wallet)) + } + .await + } + SeedChoice::FromWalletPath { wallet_path } => { + if is_legacy_wallet_path(&wallet_path, legacy_data_dir) { + let wallet = request_and_open_monero_wallet_legacy( + legacy_data_dir, + env_config, + daemon, + ) + .await?; + let seed = Seed::from_file_or_generate(legacy_data_dir) + .await + .context("Failed to read legacy seed from file")?; + + return Ok((wallet, seed)); + } + + // Helper function to verify password + let verify_password = |password: String| -> Result { + monero_sys::WalletHandle::verify_wallet_password( + wallet_path.clone(), + password, + ) + .map_err(|e| anyhow::anyhow!("Failed to verify wallet password: {}", e)) + }; + + // Request and verify password before opening wallet + let wallet_password: Option = { + const WALLET_EMPTY_PASSWORD: &str = ""; + + // First try empty password + if verify_password(WALLET_EMPTY_PASSWORD.to_string())? { + Some(WALLET_EMPTY_PASSWORD.to_string()) + } else { + // If empty password fails, ask user for password + loop { + // Request password from user + let password = tauri_handle + .request_password(wallet_path.clone()) + .await + .inspect_err(|e| { + tracing::error!( + "Failed to get password from user: {}", + e + ); + }) + .ok(); + + // If the user rejects the password request (presses cancel) + // We prompt him to select a wallet again + let password = match password { + Some(password) => password, + None => break None, + }; + + // Verify the password using the helper function + match verify_password(password.clone()) { + Ok(true) => { + break Some(password); + } + Ok(false) => { + // Continue loop to request password again + continue; + } + Err(e) => { + return Err(e); + } + } + } + } + }; + + match wallet_password { + // The user rejected the password request: back to + // the chooser. + None => Ok(SetupFlow::ChooseSeed), + // Open existing wallet with verified password + Some(password) => monero::Wallet::open_or_create_with_password( + wallet_path.clone(), + password, + daemon.clone(), + env_config.monero_network, + true, + ) + .await + .context("Failed to open wallet from provided path") + .map(SetupFlow::Finish), + } + } + SeedChoice::Legacy => { + let wallet = request_and_open_monero_wallet_legacy( + legacy_data_dir, + env_config, + daemon, + ) + .await?; + let seed = Seed::from_file_or_generate(legacy_data_dir) + .await + .context("Failed to read legacy seed from file")?; + + return Ok((wallet, seed)); + } + }; + + // A failed create/open (e.g. the wallet name is already taken) + // must not abort startup: put the user back into the chooser + // instead. + match opened { + Ok(next) => next, + Err(error) => { + tracing::error!( + ?error, + "Failed to open or create wallet, asking user to choose again" + ); + SetupFlow::ChooseSeed + } + } + } + SetupFlow::BackupSeed(wallet) => { + // Block until the user confirms having recorded the seed. If + // the backup approval cannot complete, keep going: the wallet + // already exists and the seed stays viewable from the wallet + // page. + let backup = async { + let details = SeedBackupDetails { + seed: wallet.seed().await?, + restore_height: wallet.get_restore_height().await?, + }; + tauri_handle.request_seed_backup(details).await + } + .await; + + match backup { + Ok(true) => {} + Ok(false) => { + tracing::warn!("Seed backup rejected by user, continuing startup") + } + Err(error) => { + tracing::warn!( + ?error, + "Seed backup could not be confirmed, continuing startup" + ) + } + } + + SetupFlow::Finish(wallet) + } + SetupFlow::Finish(wallet) => { + let seed = Seed::from_monero_wallet(&wallet) + .await + .context("Failed to extract seed from wallet")?; + + return Ok((wallet, seed)); + } + }; + } +} + +/// Requests the user to select a seed choice from a list of recent wallets +pub(super) async fn request_seed_choice( + tauri_handle: TauriHandle, + database: &monero_sys::Database, + eigenwallet_data_dir: &Path, +) -> Result { + let recent_wallets = database.get_recent_wallets(5).await?; + + tauri_handle + .request_seed_selection(SeedSelectionDetails { + recent_wallets: recent_wallets.into_iter().map(|w| w.wallet_path).collect(), + default_wallet_directory: default_wallet_directory(eigenwallet_data_dir) + .display() + .to_string(), + }) + .await +} + +/// Default directory new wallet files are stored in. +fn default_wallet_directory(eigenwallet_data_dir: &Path) -> PathBuf { + eigenwallet_data_dir.join("wallets") +} + +/// Builds the path for a freshly created wallet. The name must be a single +/// path component so it cannot escape the chosen directory, and the wallet +/// must not already exist (creating must never silently open an existing +/// wallet). +fn new_wallet_path(directory: &str, name: &str) -> Result { + if name.trim().is_empty() { + anyhow::bail!("Wallet name must not be empty"); + } + + let is_single_component = Path::new(name) + .components() + .eq(std::iter::once(std::path::Component::Normal(name.as_ref()))); + if !is_single_component { + anyhow::bail!("Wallet name must be a single file name, got {name:?}"); + } + + if directory.trim().is_empty() || !Path::new(directory).is_absolute() { + anyhow::bail!("Wallet directory must be an absolute path, got {directory:?}"); + } + + let wallet_path = PathBuf::from(directory).join(name); + // Monero stores a wallet as `` plus `.keys`; `with_extension` + // would truncate a name containing dots. + let keys_path = PathBuf::from(directory).join(format!("{name}.keys")); + + if wallet_path.exists() || keys_path.exists() { + anyhow::bail!("A wallet named {name:?} already exists in {directory:?}"); + } + + swap_fs::ensure_directory_exists(&wallet_path).context("Failed to create wallet directory")?; + + Ok(wallet_path) +} + +fn legacy_wallet_path(data_dir: &Path) -> PathBuf { + data_dir.join(LEGACY_MONITORING_WALLET_NAME) +} + +fn is_legacy_wallet_path(wallet_path: &str, legacy_data_dir: &Path) -> bool { + Path::new(wallet_path) == legacy_wallet_path(legacy_data_dir) +} + +pub(super) async fn request_and_open_monero_wallet_legacy( + data_dir: &PathBuf, + env_config: EnvConfig, + daemon: &monero_sys::Daemon, +) -> Result { + let wallet_path = legacy_wallet_path(data_dir); + + let wallet = monero::Wallet::open_or_create( + wallet_path.display().to_string(), + daemon.clone(), + env_config.monero_network, + true, + ) + .await + .context("Failed to create wallet")?; + + Ok(wallet) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin_wallet::BitcoinWalletSeed; + + #[test] + fn detects_legacy_monitoring_wallet_path() { + let legacy_data_dir = PathBuf::from("/tmp/eigenwallet-mainnet"); + let wallet_path = legacy_wallet_path(&legacy_data_dir); + + assert!(is_legacy_wallet_path( + wallet_path.to_str().unwrap(), + &legacy_data_dir, + )); + } + + #[test] + fn does_not_treat_other_wallet_files_as_legacy() { + let legacy_data_dir = PathBuf::from("/tmp/eigenwallet-mainnet"); + let wallet_path = "/tmp/eigenwallet/wallets/wallet_123"; + + assert!(!is_legacy_wallet_path(wallet_path, &legacy_data_dir)); + } + + #[tokio::test] + async fn legacy_seed_file_keeps_bitcoin_key_stable() { + let temp_dir = tempfile::tempdir().unwrap(); + + let legacy_seed = Seed::from_file_or_generate(temp_dir.path()).await.unwrap(); + let legacy_key = legacy_seed + .derive_extended_private_key(bitcoin::Network::Bitcoin) + .unwrap(); + + let reread_legacy_seed = Seed::from_file_or_generate(temp_dir.path()).await.unwrap(); + let reread_legacy_key = reread_legacy_seed + .derive_extended_private_key(bitcoin::Network::Bitcoin) + .unwrap(); + + let non_legacy_seed = Seed::from([0; crate::seed::SEED_LENGTH]); + let non_legacy_key = non_legacy_seed + .derive_extended_private_key(bitcoin::Network::Bitcoin) + .unwrap(); + + assert_eq!(legacy_key, reread_legacy_key); + assert_ne!(legacy_key, non_legacy_key); + } +}