From 3584b483dada8e9aca8ae1cc18926a76ead5c5f4 Mon Sep 17 00:00:00 2001 From: binarybaron Date: Thu, 2 Jul 2026 10:37:08 +0200 Subject: [PATCH 1/7] feat(gui): wallet setup wizard, named wallets, and wallet switcher Applied from stash 85824882 (feat/bob-concurrent-swaps) onto master. - Seed selection dialog is now a step-based wizard driven by a single state machine (mode flows in a FLOWS table, reducer with guarded transitions): create a wallet with user-chosen name/directory and a mandatory seed backup step, restore from seed with word autocomplete, or open a wallet file (drag & drop + recent wallets). - New wallets are stored under a user-chosen name instead of a unix timestamp; creation refuses to overwrite an existing wallet ( and .keys checked without extension truncation). - Wallet switcher on the Monero wallet page: records a PendingWalletAction marker consumed on next startup, then relaunches. - New Tauri commands: get_seed_words (vendored English wordlist, verified identical to monero/src/mnemonics/english.h), get_recent_wallets, set_pending_wallet. --- CHANGELOG.md | 2 + .../modal/seed-selection/BackupSeedStep.tsx | 116 ++ .../modal/seed-selection/NameLocationStep.tsx | 55 + .../modal/seed-selection/OpenWalletStep.tsx | 154 ++ .../modal/seed-selection/SeedPhraseInput.tsx | 143 ++ .../seed-selection/SeedSelectionDialog.tsx | 736 ++++---- .../pages/monero/MoneroWalletPage.tsx | 12 + .../monero/components/WalletSwitcher.tsx | 191 ++ .../pages/monero/components/index.ts | 1 + src-gui/src/renderer/rpc.ts | 25 + src-gui/src/store/hooks.ts | 10 + src-tauri/src/commands.rs | 27 +- swap/src/cli/api.rs | 130 +- swap/src/cli/api/request.rs | 79 +- swap/src/cli/api/seed_words.rs | 1631 +++++++++++++++++ swap/src/cli/api/tauri_bindings.rs | 47 +- 16 files changed, 2986 insertions(+), 373 deletions(-) create mode 100644 src-gui/src/renderer/components/modal/seed-selection/BackupSeedStep.tsx create mode 100644 src-gui/src/renderer/components/modal/seed-selection/NameLocationStep.tsx create mode 100644 src-gui/src/renderer/components/modal/seed-selection/OpenWalletStep.tsx create mode 100644 src-gui/src/renderer/components/modal/seed-selection/SeedPhraseInput.tsx create mode 100644 src-gui/src/renderer/components/pages/monero/components/WalletSwitcher.tsx create mode 100644 swap/src/cli/api/seed_words.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 256fddcc17..2ef42ca73b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ 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, restoring from a seed offers word autocomplete, and opening a wallet file supports drag & drop. Newly created wallets are named by the user instead of a Unix timestamp. +- GUI: Added a wallet switcher to the Monero wallet page for switching between recently used wallets, opening another wallet file, or creating a new wallet (the app restarts into the selected wallet). - 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/renderer/components/modal/seed-selection/BackupSeedStep.tsx b/src-gui/src/renderer/components/modal/seed-selection/BackupSeedStep.tsx new file mode 100644 index 0000000000..9035be5033 --- /dev/null +++ b/src-gui/src/renderer/components/modal/seed-selection/BackupSeedStep.tsx @@ -0,0 +1,116 @@ +import { + Alert, + Box, + Checkbox, + FormControlLabel, + Typography, +} from "@mui/material"; +import { useEffect, useState } from "react"; +import { + GetMoneroSeedResponse, + GetRestoreHeightResponse, +} from "models/tauriModel"; +import { getMoneroSeedAndRestoreHeight } from "renderer/rpc"; +import { useIsMoneroWalletAvailable } from "store/hooks"; +import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox"; +import { PrivateKeyScamAlert } from "renderer/components/other/PrivateKeyWarning"; +import CircularProgressWithSubtitle from "renderer/components/pages/swap/swap/components/CircularProgressWithSubtitle"; + +const SEED_FETCH_RETRY_INTERVAL_MS = 1000; +const SEED_FETCH_MAX_ATTEMPTS = 60; + +/// Shown after a fresh wallet has been created so the user records their seed +/// before continuing. The seed only becomes readable once the newly created +/// Monero wallet has finished opening, so we wait for it before fetching. +export default function BackupSeedStep({ + onConfirmedChange, +}: { + onConfirmedChange: (confirmed: boolean) => void; +}) { + const moneroWalletAvailable = useIsMoneroWalletAvailable(); + const [seed, setSeed] = useState< + [GetMoneroSeedResponse, GetRestoreHeightResponse] | null + >(null); + const [confirmed, setConfirmed] = useState(false); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (!moneroWalletAvailable || seed !== null) { + return; + } + + let cancelled = false; + let timeout: ReturnType | undefined; + let attempt = 0; + const fetchSeed = () => { + getMoneroSeedAndRestoreHeight() + .then((result) => { + if (!cancelled) setSeed(result); + }) + .catch((e) => { + if (cancelled) return; + attempt += 1; + if (attempt >= SEED_FETCH_MAX_ATTEMPTS) { + console.error("Failed to read wallet seed for backup", e); + setFailed(true); + return; + } + timeout = setTimeout(fetchSeed, SEED_FETCH_RETRY_INTERVAL_MS); + }); + }; + fetchSeed(); + + return () => { + cancelled = true; + if (timeout !== undefined) clearTimeout(timeout); + }; + }, [moneroWalletAvailable, seed]); + + if (failed) { + return ( + + Could not read the wallet seed to back up. You can view it later from + the wallet menu. + + ); + } + + if (seed === null) { + return ( + + ); + } + + 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. + + + + { + setConfirmed(e.target.checked); + 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..4571b12bbd --- /dev/null +++ b/src-gui/src/renderer/components/modal/seed-selection/NameLocationStep.tsx @@ -0,0 +1,55 @@ +import { Box, Button, TextField, Typography } from "@mui/material"; +import { open } from "@tauri-apps/plugin-dialog"; +import SearchIcon from "@mui/icons-material/Search"; + +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.trim().length === 0} + helperText={name.trim().length === 0 ? "Enter a wallet name" : ""} + /> + + + + + + ); +} 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..ac7fbd9267 --- /dev/null +++ b/src-gui/src/renderer/components/modal/seed-selection/OpenWalletStep.tsx @@ -0,0 +1,154 @@ +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]; + if (path) setWalletPath(path); + } 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); + }; + + 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/SeedPhraseInput.tsx b/src-gui/src/renderer/components/modal/seed-selection/SeedPhraseInput.tsx new file mode 100644 index 0000000000..412384f461 --- /dev/null +++ b/src-gui/src/renderer/components/modal/seed-selection/SeedPhraseInput.tsx @@ -0,0 +1,143 @@ +import { + Box, + ClickAwayListener, + MenuItem, + MenuList, + Paper, + Popper, + TextField, +} from "@mui/material"; +import { useEffect, useState } from "react"; +import { getSeedWords } from "renderer/rpc"; + +const MAX_SUGGESTIONS = 6; + +// Cached across mounts so the wordlist is only fetched once per session. +let cachedWords: string[] | null = null; + +function currentWordOf(value: string): string { + // The word being typed is the trailing token; a trailing space completes it. + return value.split(/\s+/).pop() ?? ""; +} + +function suggestionsFor(value: string, words: string[]): string[] { + const currentWord = currentWordOf(value); + if (currentWord.length === 0) return []; + + return words + .filter((word) => word.startsWith(currentWord) && word !== currentWord) + .slice(0, MAX_SUGGESTIONS); +} + +export default function SeedPhraseInput({ + value, + onChange, + error, + helperText, +}: { + value: string; + onChange: (value: string) => void; + error: boolean; + helperText: string; +}) { + const [words, setWords] = useState(cachedWords ?? []); + const [highlighted, setHighlighted] = useState(0); + const [dismissed, setDismissed] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + + useEffect(() => { + if (cachedWords !== null) return; + + getSeedWords() + .then((fetched) => { + cachedWords = fetched; + setWords(fetched); + }) + .catch(() => setWords([])); + }, []); + + const suggestions = suggestionsFor(value, words); + const open = suggestions.length > 0 && !dismissed; + + const completeWith = (word: string) => { + const trimmedEnd = value.replace(/\S*$/, ""); + onChange(`${trimmedEnd}${word} `); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!open) return; + + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + setHighlighted((h) => (h + 1) % suggestions.length); + break; + case "ArrowUp": + event.preventDefault(); + setHighlighted( + (h) => (h - 1 + suggestions.length) % suggestions.length, + ); + break; + case "Enter": + case "Tab": + event.preventDefault(); + completeWith(suggestions[highlighted]); + break; + case "Escape": + event.preventDefault(); + setDismissed(true); + break; + } + }; + + return ( + + { + setDismissed(false); + setHighlighted(0); + onChange(e.target.value); + }} + onKeyDown={handleKeyDown} + placeholder="Enter your Monero 25 words seed phrase..." + error={error} + helperText={helperText} + /> + theme.zIndex.modal + 1, + }} + > + setDismissed(true)}> + + + {suggestions.map((word, index) => ( + { + // Keep focus in the textarea so typing continues. + e.preventDefault(); + completeWith(word); + }} + > + {word} + + ))} + + + + + + ); +} 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..fc7f2eb5d3 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,163 @@ 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 { useState, useEffect, useReducer, useRef } from "react"; import { usePendingSeedSelectionApproval } 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 SeedPhraseInput from "./SeedPhraseInput"; + +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: "randomPassword", label: "Set password", action: "next" }, + { id: "nameLocation", label: "Name & location", 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); +} + +// The wizard is a state machine. `editing` navigates the FLOWS table of the +// selected mode. `backingUp` is entered once a new wallet has been created: +// the approval is already resolved, but the dialog stays up until the user has +// recorded the seed. `finished` unmounts the dialog. Only `editing` carries a +// mode/step, so out-of-range steps or "backup while restoring" cannot be +// represented. +type WizardState = + | { phase: "editing"; mode: WalletMode; step: StepId } + | { phase: "backingUp" } + | { phase: "finished" }; + +type WizardEvent = + | { type: "reset"; mode: WalletMode } + | { type: "selectMode"; mode: WalletMode } + | { type: "next" } + | { type: "back" } + | { type: "navigateBackTo"; step: StepId } + | { type: "walletCreationStarted" } + | { type: "walletCreationFailed" } + | { type: "finish" }; + +// 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 { + switch (event.type) { + case "reset": + return { phase: "editing", mode: event.mode, step: "chooseMode" }; + case "walletCreationStarted": + return state.phase === "editing" ? { phase: "backingUp" } : state; + case "walletCreationFailed": + // Creation only starts from the RandomSeed name & location step, so + // return there for the user to correct the input and retry. + return state.phase === "backingUp" + ? { phase: "editing", mode: "RandomSeed", step: "nameLocation" } + : state; + case "finish": + return state.phase === "backingUp" ? { phase: "finished" } : state; + } + + // The remaining events navigate within the editing phase. + if (state.phase !== "editing") return state; + const flow = FLOWS[state.mode]; + const index = stepIndex(state.mode, state.step); + + switch (event.type) { + 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) { @@ -69,9 +184,11 @@ function parseBlockHeightInput(blockheightInput: string): number | false { export default function SeedSelectionDialog() { const pendingApprovals = usePendingSeedSelectionApproval(); - const [selectedOption, setSelectedOption] = useState< - SeedChoice["type"] | undefined - >("RandomSeed"); + const [wizard, dispatch] = useReducer(wizardReducer, { + phase: "editing", + mode: "RandomSeed", + step: "chooseMode", + } as WizardState); const [customSeed, setCustomSeed] = useState(""); const [blockheightInput, setBlockheightInput] = useState(""); const [asyncSeedValidation, setAsyncSeedValidation] = @@ -79,18 +196,44 @@ export default function SeedSelectionDialog() { const [password, setPassword] = useState(""); const [isPasswordValid, setIsPasswordValid] = useState(true); const [walletPath, setWalletPath] = useState(""); + const [name, setName] = useState(""); + const [directory, setDirectory] = useState(""); + const [backupConfirmed, setBackupConfirmed] = useState(false); const approval = pendingApprovals[0]; - - // Extract recent wallets from the approval request content - const recentWallets = + const content = approval?.request?.type === "SeedSelection" - ? approval.request.content.recent_wallets - : []; + ? approval.request.content + : undefined; + const recentWallets = content?.recent_wallets ?? []; + + // Reset the wizard whenever a new seed-selection approval arrives (e.g. after + // the user cancels a password prompt and is asked to choose again). + const lastRequestIdRef = useRef(null); + useEffect(() => { + const requestId = approval?.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", + }); + setBackupConfirmed(false); + setName(""); + setDirectory(content?.default_wallet_directory ?? ""); + if (recentWallets.length > 0) { + setWalletPath(recentWallets[0]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on a new approval id + }, [approval?.request_id]); // Only run async validation when in "FromSeed" mode with content const needsSeedValidation = - selectedOption === "FromSeed" && customSeed.trim(); + wizard.phase === "editing" && + wizard.mode === "FromSeed" && + customSeed.trim(); useEffect(() => { if (!needsSeedValidation) return; @@ -100,111 +243,92 @@ export default function SeedSelectionDialog() { .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); - } - }; - 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 = (mode: WalletMode): SeedChoice => { + switch (mode) { case "RandomSeed": - seedChoice = { type: "RandomSeed", content: { password } }; - break; - + return { type: "RandomSeed", content: { password, name, directory } }; case "FromSeed": { const parsedBlockHeight = parseBlockHeightInput(blockheightInput); - if (parsedBlockHeight === false) { throw new Error("Invalid blockheight"); } - - seedChoice = { + return { type: "FromSeed", content: { seed: customSeed, password, restore_height: parsedBlockHeight, + name, + directory, }, }; - break; } - - default: - seedChoice = { - type: "FromWalletPath", - content: { wallet_path: walletPath }, - }; - break; + case "FromWalletPath": + return { type: "FromWalletPath", content: { wallet_path: walletPath } }; } + }; + const resolve = async (seedChoice: SeedChoice) => { + if (!approval) + throw new Error("No approval request found for seed selection"); await resolveApproval(approval.request_id, seedChoice); }; - if (!approval) { - return null; - } + // Creating transitions to the backup phase *before* resolving, so the dialog + // stays mounted once the approval clears; on failure we roll back. + const createWallet = async () => { + dispatch({ type: "walletCreationStarted" }); + try { + await resolve(buildSeedChoice("RandomSeed")); + } catch (e) { + dispatch({ type: "walletCreationFailed" }); + throw e; + } + }; + + if (wizard.phase === "finished") return null; + if (wizard.phase === "editing" && !approval) return null; + + // The backup phase is only reachable from the RandomSeed flow, whose last + // step records the seed. + const mode = wizard.phase === "editing" ? wizard.mode : "RandomSeed"; + const step = wizard.phase === "editing" ? wizard.step : "backupSeed"; + const flow = FLOWS[mode]; + const index = stepIndex(mode, step); + + // Whether the current step's input is complete enough to act on. + const stepValid: Record = { + chooseMode: true, + randomPassword: isPasswordValid, + seedPhrase: + customSeed.trim().length > 0 && !!isSeedValid && !isBlockheightInvalid, + storage: isPasswordValid, + nameLocation: name.trim().length > 0 && directory.trim().length > 0, + openFile: walletPath.length > 0, + backupSeed: backupConfirmed, + }; - // 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: () => dispatch({ type: "finish" }), + createWallet, + restoreWallet: () => resolve(buildSeedChoice("FromSeed")), + openWallet: () => resolve(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 && wizard.phase === "editing"; + 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)} - placeholder="Enter your Monero 25 words seed phrase..." + onChange={setCustomSeed} error={!isSeedValid && customSeed.length > 0} helperText={ isSeedValid @@ -359,13 +455,9 @@ export default function SeedSelectionDialog() { : "" } /> - setBlockheightInput(e.target.value)} @@ -379,126 +471,122 @@ 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" && ( + + )} + + {step === "openFile" && ( + )} + + {step === "chooseMode" && ( + resolve({ type: "Legacy" })} + contextRequirement={false} + color="inherit" + > + No wallet (Legacy) + + )} + {index > 0 && wizard.phase === "editing" && ( + + )} + - 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/pages/monero/MoneroWalletPage.tsx b/src-gui/src/renderer/components/pages/monero/MoneroWalletPage.tsx index 9b1939332b..5d5f23b740 100644 --- a/src-gui/src/renderer/components/pages/monero/MoneroWalletPage.tsx +++ b/src-gui/src/renderer/components/pages/monero/MoneroWalletPage.tsx @@ -6,6 +6,7 @@ import { WalletOverview, TransactionHistory, WalletActionButtons, + WalletSwitcher, } from "./components"; import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox"; import WalletPageLoadingState from "./components/WalletPageLoadingState"; @@ -35,8 +36,19 @@ export default function MoneroWalletPage() { flexDirection: "column", gap: 2, pb: 2, + position: "relative", }} > + + + (null); + const [recentWallets, setRecentWallets] = useState([]); + // Action the user picked, awaiting confirmation before the app relaunches. + const [pending, setPending] = useState(null); + + const refreshRecentWallets = () => { + getRecentWallets() + .then(setRecentWallets) + .catch((e) => console.error("Failed to load recent wallets", e)); + }; + + useEffect(refreshRecentWallets, []); + + const currentWallet = recentWallets[0]; + const otherWallets = recentWallets.slice(1); + + const openMenu = (event: React.MouseEvent) => { + refreshRecentWallets(); + setAnchorEl(event.currentTarget); + }; + + const confirmPending = async () => { + if (pending === null) return; + await setPendingWallet( + pending.kind === "switch" + ? { type: "Open", content: { wallet_path: pending.path } } + : { type: "ShowChooser" }, + ); + await relaunch(); + }; + + const chooseOther = async () => { + const selected = await open({ multiple: false, directory: false }); + if (selected) setPending({ kind: "switch", path: selected }); + }; + + return ( + <> + } + deleteIcon={} + onDelete={openMenu} + onClick={openMenu} + label={currentWallet ? walletFileName(currentWallet) : "Wallet"} + variant="button" + clickable + sx={{ + // Bookmark tab: flush to the top edge, square on top, rounded + // below, with a small downward notch tail. + position: "relative", + height: 36, + bgcolor: "background.paper", + boxShadow: 3, + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + borderBottomLeftRadius: 12, + borderBottomRightRadius: 12, + "&:hover": { + bgcolor: "background.paper", + boxShadow: 4, + }, + "&::after": { + content: '""', + position: "absolute", + bottom: 0, + left: "50%", + transform: "translate(-50%, 60%)", + borderLeft: "6px solid transparent", + borderRight: "6px solid transparent", + borderTop: "6px solid", + borderTopColor: "background.paper", + }, + }} + /> + setAnchorEl(null)} + > + {otherWallets.map((path) => ( + { + setAnchorEl(null); + setPending({ kind: "switch", path }); + }} + > + + + + {walletFileName(path)} + + ))} + {otherWallets.length > 0 && } + { + setAnchorEl(null); + chooseOther(); + }} + > + + + + Other wallet… + + { + setAnchorEl(null); + setPending({ kind: "create" }); + }} + > + + + + Create new wallet… + + + setPending(null)} + maxWidth="xs" + fullWidth + > + + {pending?.kind === "create" ? "Create new wallet" : "Switch wallet"} + + + + {pending?.kind === "create" + ? "The app will restart so you can set up a new wallet." + : `The app will restart to open "${ + pending ? walletFileName(pending.path) : "" + }".`}{" "} + Any running operations will be interrupted. + + + + + + {pending?.kind === "create" ? "Restart" : "Restart & switch"} + + + + + ); +} diff --git a/src-gui/src/renderer/components/pages/monero/components/index.ts b/src-gui/src/renderer/components/pages/monero/components/index.ts index c557d008e9..badf37ed10 100644 --- a/src-gui/src/renderer/components/pages/monero/components/index.ts +++ b/src-gui/src/renderer/components/pages/monero/components/index.ts @@ -1,5 +1,6 @@ export { default as WalletOverview } from "./WalletOverview"; export { default as TransactionHistory } from "./TransactionHistory"; export { default as WalletActionButtons } from "./WalletActionButtons"; +export { default as WalletSwitcher } from "./WalletSwitcher"; export { default as SendTransactionContent } from "./SendTransactionContent"; export { default as SendApprovalContent } from "./SendApprovalContent"; diff --git a/src-gui/src/renderer/rpc.ts b/src-gui/src/renderer/rpc.ts index e15edb4972..35604932a7 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -52,6 +52,11 @@ import { GetRestoreHeightResponse, MoneroNodeConfig, GetMoneroSeedResponse, + GetSeedWordsResponse, + GetRecentWalletsResponse, + PendingWalletAction, + SetPendingWalletArgs, + SetPendingWalletResponse, ContextStatus, GetSwapTimelockArgs, GetSwapTimelockResponse, @@ -605,6 +610,26 @@ export async function getMoneroSeedAndRestoreHeight(): Promise< return Promise.all([getMoneroSeed(), getRestoreHeight()]); } +export async function getSeedWords(): Promise { + const response = await invokeNoArgs("get_seed_words"); + return response.words; +} + +export async function getRecentWallets(): Promise { + const response = + await invokeNoArgs("get_recent_wallets"); + return response.wallets; +} + +export async function setPendingWallet( + action: PendingWalletAction, +): Promise { + await invoke( + "set_pending_wallet", + { action }, + ); +} + // Wallet management functions that handle Redux dispatching export async function initializeMoneroWallet() { try { diff --git a/src-gui/src/store/hooks.ts b/src-gui/src/store/hooks.ts index 910bad3a2d..a2962a07a5 100644 --- a/src-gui/src/store/hooks.ts +++ b/src-gui/src/store/hooks.ts @@ -17,6 +17,7 @@ import { PendingPasswordApprovalRequest, isPendingPasswordApprovalEvent, isContextFullyInitialized, + isContextWithMoneroWallet, } from "models/tauriModelExt"; import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; import type { AppDispatch, RootState } from "renderer/store/storeRenderer"; @@ -39,6 +40,7 @@ import { selectSwapInfoWithTimelock, selectSwapInfosRaw, } from "./selectors"; +import { ContextStatusType } from "./features/rpcSlice"; export const useAppDispatch = () => useDispatch(); export const useAppSelector: TypedUseSelectorHook = useSelector; @@ -120,6 +122,14 @@ export function useIsContextAvailable() { return useAppSelector((state) => isContextFullyInitialized(state.rpc.status)); } +export function useIsMoneroWalletAvailable() { + return useAppSelector((state) => + state.rpc.status?.type === ContextStatusType.Status + ? isContextWithMoneroWallet(state.rpc.status.status) + : false, + ); +} + /// We do not use a sanity check here, as opposed to the other useSwapInfo hooks, /// because we are explicitly asking for a specific swap export function useSwapInfo( diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4bdd9c32fc..c3b5cc8876 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -8,16 +8,16 @@ 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, - SetMoneroWalletPasswordArgs, SetRestoreHeightArgs, SuspendCurrentSwapArgs, - WithdrawBtcArgs, + DeleteAllLogsArgs, ExportBitcoinWalletArgs, GetBitcoinAddressArgs, GetCurrentSwapArgs, + GetDataDirArgs, GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, + GetMoneroBalanceArgs, GetMoneroHistoryArgs, GetMoneroMainAddressArgs, + GetMoneroSeedArgs, GetMoneroSubaddressesArgs, GetMoneroSyncProgressArgs, + GetPendingApprovalsResponse, GetRecentWalletsArgs, GetRestoreHeightArgs, + GetSeedWordsArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, GetSwapTimelockArgs, + MoneroRecoveryArgs, RedactArgs, RefreshP2PArgs, RejectApprovalArgs, + RejectApprovalResponse, ResolveApprovalArgs, ResumeSwapArgs, SendMoneroArgs, + SetMoneroSubaddressLabelArgs, SetMoneroWalletPasswordArgs, SetPendingWalletArgs, + SetRestoreHeightArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs, }, tauri_bindings::{ContextStatus, TauriSettings}, }, @@ -68,6 +68,9 @@ macro_rules! generate_command_handlers { get_monero_sync_progress, get_monero_seed, check_seed, + get_seed_words, + get_recent_wallets, + set_pending_wallet, get_pending_approvals, set_monero_restore_height, reject_approval_request, @@ -388,7 +391,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 @@ -424,4 +426,7 @@ tauri_command!(get_monero_subaddresses, GetMoneroSubaddressesArgs); tauri_command!(create_monero_subaddress, CreateMoneroSubaddressArgs); tauri_command!(set_monero_subaddress_label, SetMoneroSubaddressLabelArgs); tauri_command!(get_monero_seed, GetMoneroSeedArgs, no_args); +tauri_command!(get_seed_words, GetSeedWordsArgs, no_args); +tauri_command!(get_recent_wallets, GetRecentWalletsArgs, no_args); +tauri_command!(set_pending_wallet, SetPendingWalletArgs); tauri_command!(refresh_p2p, RefreshP2PArgs, no_args); diff --git a/swap/src/cli/api.rs b/swap/src/cli/api.rs index 8f7d56c5c7..b98b9e62e9 100644 --- a/swap/src/cli/api.rs +++ b/swap/src/cli/api.rs @@ -1,7 +1,8 @@ pub mod request; +pub mod seed_words; pub mod tauri_bindings; -use crate::cli::api::tauri_bindings::{ContextStatus, SeedChoice}; +use crate::cli::api::tauri_bindings::{ContextStatus, PendingWalletAction, SeedChoice}; 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::resolve_startup_seed_choice( + tauri_handle, + &wallet_database, + eigenwallet_data_dir, + ) + .await?, + ), None => None, }; @@ -959,22 +965,85 @@ mod wallet { Ok(wallet) } + /// Default directory new wallet files are stored in. + pub fn default_wallet_directory(eigenwallet_data_dir: &Path) -> PathBuf { + eigenwallet_data_dir.join("wallets") + } + /// 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?; 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) + .request_seed_selection_with_recent_wallets( + recent_wallets, + default_wallet_directory(eigenwallet_data_dir) + .display() + .to_string(), + ) .await?; Ok(seed_choice) } + /// Marker file recording the wallet to open after an app relaunch, used by + /// the wallet switcher. + fn pending_wallet_marker_path(eigenwallet_data_dir: &Path) -> PathBuf { + eigenwallet_data_dir.join("pending_wallet") + } + + pub fn write_pending_wallet( + eigenwallet_data_dir: &Path, + action: &PendingWalletAction, + ) -> Result<()> { + let marker = pending_wallet_marker_path(eigenwallet_data_dir); + swap_fs::ensure_directory_exists(&marker) + .context("Failed to create data directory for pending wallet marker")?; + let encoded = serde_json::to_string(action).context("Failed to encode pending wallet")?; + std::fs::write(&marker, encoded).context("Failed to write pending wallet marker")?; + Ok(()) + } + + /// Reads and consumes the pending-wallet marker, if any. + fn take_pending_wallet(eigenwallet_data_dir: &Path) -> Result> { + let marker = pending_wallet_marker_path(eigenwallet_data_dir); + + let encoded = match std::fs::read_to_string(&marker) { + Ok(encoded) => encoded, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e).context("Failed to read pending wallet marker"), + }; + std::fs::remove_file(&marker).context("Failed to remove pending wallet marker")?; + + let action = + serde_json::from_str(&encoded).context("Failed to decode pending wallet marker")?; + Ok(Some(action)) + } + + /// On startup, honor a wallet the user pre-selected before relaunching the + /// app. A consumed marker either opens a specific wallet or forces the + /// chooser; with no marker the user is prompted to choose. + pub(super) async fn resolve_startup_seed_choice( + tauri_handle: TauriHandle, + database: &monero_sys::Database, + eigenwallet_data_dir: &Path, + ) -> Result { + match take_pending_wallet(eigenwallet_data_dir)? { + Some(PendingWalletAction::Open { wallet_path }) => { + Ok(SeedChoice::FromWalletPath { wallet_path }) + } + Some(PendingWalletAction::ShowChooser) | None => { + request_seed_choice(tauri_handle, database, eigenwallet_data_dir).await + } + } + } + /// Opens or creates a Monero wallet after asking the user via the Tauri UI. /// /// The user can: @@ -986,15 +1055,13 @@ mod wallet { /// fails to open/create. pub(super) async fn open_monero_wallet( tauri_handle: Option, - eigenwallet_data_dir: &PathBuf, + 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> { - 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 @@ -1008,14 +1075,32 @@ mod wallet { (), ); - fn new_wallet_path(eigenwallet_wallets_dir: &PathBuf) -> Result { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); + // 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 wallet_path = - eigenwallet_wallets_dir.join(format!("wallet_{}", timestamp)); + 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:?}"); + } + + 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")?; @@ -1024,9 +1109,12 @@ mod wallet { } let wallet = match seed_choice { - SeedChoice::RandomSeed { password } => { - // Create wallet with Unix timestamp as name - let wallet_path = new_wallet_path(&eigenwallet_wallets_dir) + SeedChoice::RandomSeed { + password, + name, + directory, + } => { + let wallet_path = new_wallet_path(&directory, &name) .context("Failed to determine path for new wallet")?; monero::Wallet::open_or_create_with_password( @@ -1047,9 +1135,10 @@ mod wallet { seed: mnemonic, restore_height, password, + name, + directory, } => { - // Create wallet from provided seed - let wallet_path = new_wallet_path(&eigenwallet_wallets_dir) + let wallet_path = new_wallet_path(&directory, &name) .context("Failed to determine path for new wallet")?; monero::Wallet::open_or_create_from_seed_with_password( @@ -1150,6 +1239,7 @@ mod wallet { seed_choice = request_seed_choice( tauri_handle.clone().unwrap(), database, + eigenwallet_data_dir, ) .await?; continue; diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index 91007c66a4..30e5768c76 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -1,8 +1,8 @@ use super::tauri_bindings::TauriHandle; use crate::cli::api::Context; use crate::cli::api::tauri_bindings::{ - ApprovalRequestType, MoneroNodeConfig, SelectMakerDetails, SendMoneroDetails, TauriEmitter, - TauriSwapProgressEvent, + ApprovalRequestType, MoneroNodeConfig, PendingWalletAction, SelectMakerDetails, + SendMoneroDetails, TauriEmitter, TauriSwapProgressEvent, }; use crate::cli::list_sellers::QuoteWithAddress; use crate::common::{get_logs, redact}; @@ -1845,6 +1845,81 @@ impl CheckSeedArgs { } } +// GetSeedWords: the Monero English mnemonic wordlist, used by the GUI for +// seed-word autocomplete. +#[typeshare] +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct GetSeedWordsArgs; + +#[typeshare] +#[derive(Serialize, Deserialize, Debug)] +pub struct GetSeedWordsResponse { + pub words: Vec, +} + +impl Request for GetSeedWordsArgs { + type Response = GetSeedWordsResponse; + + async fn request(self, _: Arc) -> Result { + Ok(GetSeedWordsResponse { + words: crate::cli::api::seed_words::ENGLISH_SEED_WORDS + .iter() + .map(|word| word.to_string()) + .collect(), + }) + } +} + +// GetRecentWallets: paths of recently opened wallets, shown in the wallet +// switcher dropdown. +#[typeshare] +#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct GetRecentWalletsArgs; + +#[typeshare] +#[derive(Serialize, Deserialize, Debug)] +pub struct GetRecentWalletsResponse { + pub wallets: Vec, +} + +impl Request for GetRecentWalletsArgs { + type Response = GetRecentWalletsResponse; + + async fn request(self, ctx: Arc) -> Result { + let wallet_manager = ctx.try_get_monero_manager().await?; + let wallets = wallet_manager.get_recent_wallets().await?; + + Ok(GetRecentWalletsResponse { wallets }) + } +} + +// SetPendingWallet: records the action to take after the app relaunches, so the +// user can switch wallets. The marker is consumed on the next startup. +#[typeshare] +#[derive(Debug, Serialize, Deserialize)] +pub struct SetPendingWalletArgs { + pub action: PendingWalletAction, +} + +#[typeshare] +#[derive(Serialize, Deserialize, Debug)] +pub struct SetPendingWalletResponse { + pub success: bool, +} + +impl Request for SetPendingWalletArgs { + type Response = SetPendingWalletResponse; + + async fn request(self, ctx: Arc) -> Result { + let config = ctx.try_get_config().await?; + let eigenwallet_data_dir = swap_fs::system_data_dir_eigenwallet(config.is_testnet)?; + + super::wallet::write_pending_wallet(&eigenwallet_data_dir, &self.action)?; + + Ok(SetPendingWalletResponse { success: true }) + } +} + // New request type for Monero sync progress #[typeshare] #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] diff --git a/swap/src/cli/api/seed_words.rs b/swap/src/cli/api/seed_words.rs new file mode 100644 index 0000000000..c2412197a3 --- /dev/null +++ b/swap/src/cli/api/seed_words.rs @@ -0,0 +1,1631 @@ +// Vendored from monero-seed (monero-oxide/monero-wallet-util) seed/src/words/en.rs. +// The crate keeps its wordlist private, so we copy the English list to serve it +// to the GUI for seed-word autocomplete. +pub static ENGLISH_SEED_WORDS: &[&str] = &[ + "abbey", + "abducts", + "ability", + "ablaze", + "abnormal", + "abort", + "abrasive", + "absorb", + "abyss", + "academy", + "aces", + "aching", + "acidic", + "acoustic", + "acquire", + "across", + "actress", + "acumen", + "adapt", + "addicted", + "adept", + "adhesive", + "adjust", + "adopt", + "adrenalin", + "adult", + "adventure", + "aerial", + "afar", + "affair", + "afield", + "afloat", + "afoot", + "afraid", + "after", + "against", + "agenda", + "aggravate", + "agile", + "aglow", + "agnostic", + "agony", + "agreed", + "ahead", + "aided", + "ailments", + "aimless", + "airport", + "aisle", + "ajar", + "akin", + "alarms", + "album", + "alchemy", + "alerts", + "algebra", + "alkaline", + "alley", + "almost", + "aloof", + "alpine", + "already", + "also", + "altitude", + "alumni", + "always", + "amaze", + "ambush", + "amended", + "amidst", + "ammo", + "amnesty", + "among", + "amply", + "amused", + "anchor", + "android", + "anecdote", + "angled", + "ankle", + "annoyed", + "answers", + "antics", + "anvil", + "anxiety", + "anybody", + "apart", + "apex", + "aphid", + "aplomb", + "apology", + "apply", + "apricot", + "aptitude", + "aquarium", + "arbitrary", + "archer", + "ardent", + "arena", + "argue", + "arises", + "army", + "around", + "arrow", + "arsenic", + "artistic", + "ascend", + "ashtray", + "aside", + "asked", + "asleep", + "aspire", + "assorted", + "asylum", + "athlete", + "atlas", + "atom", + "atrium", + "attire", + "auburn", + "auctions", + "audio", + "august", + "aunt", + "austere", + "autumn", + "avatar", + "avidly", + "avoid", + "awakened", + "awesome", + "awful", + "awkward", + "awning", + "awoken", + "axes", + "axis", + "axle", + "aztec", + "azure", + "baby", + "bacon", + "badge", + "baffles", + "bagpipe", + "bailed", + "bakery", + "balding", + "bamboo", + "banjo", + "baptism", + "basin", + "batch", + "bawled", + "bays", + "because", + "beer", + "befit", + "begun", + "behind", + "being", + "below", + "bemused", + "benches", + "berries", + "bested", + "betting", + "bevel", + "beware", + "beyond", + "bias", + "bicycle", + "bids", + "bifocals", + "biggest", + "bikini", + "bimonthly", + "binocular", + "biology", + "biplane", + "birth", + "biscuit", + "bite", + "biweekly", + "blender", + "blip", + "bluntly", + "boat", + "bobsled", + "bodies", + "bogeys", + "boil", + "boldly", + "bomb", + "border", + "boss", + "both", + "bounced", + "bovine", + "bowling", + "boxes", + "boyfriend", + "broken", + "brunt", + "bubble", + "buckets", + "budget", + "buffet", + "bugs", + "building", + "bulb", + "bumper", + "bunch", + "business", + "butter", + "buying", + "buzzer", + "bygones", + "byline", + "bypass", + "cabin", + "cactus", + "cadets", + "cafe", + "cage", + "cajun", + "cake", + "calamity", + "camp", + "candy", + "casket", + "catch", + "cause", + "cavernous", + "cease", + "cedar", + "ceiling", + "cell", + "cement", + "cent", + "certain", + "chlorine", + "chrome", + "cider", + "cigar", + "cinema", + "circle", + "cistern", + "citadel", + "civilian", + "claim", + "click", + "clue", + "coal", + "cobra", + "cocoa", + "code", + "coexist", + "coffee", + "cogs", + "cohesive", + "coils", + "colony", + "comb", + "cool", + "copy", + "corrode", + "costume", + "cottage", + "cousin", + "cowl", + "criminal", + "cube", + "cucumber", + "cuddled", + "cuffs", + "cuisine", + "cunning", + "cupcake", + "custom", + "cycling", + "cylinder", + "cynical", + "dabbing", + "dads", + "daft", + "dagger", + "daily", + "damp", + "dangerous", + "dapper", + "darted", + "dash", + "dating", + "dauntless", + "dawn", + "daytime", + "dazed", + "debut", + "decay", + "dedicated", + "deepest", + "deftly", + "degrees", + "dehydrate", + "deity", + "dejected", + "delayed", + "demonstrate", + "dented", + "deodorant", + "depth", + "desk", + "devoid", + "dewdrop", + "dexterity", + "dialect", + "dice", + "diet", + "different", + "digit", + "dilute", + "dime", + "dinner", + "diode", + "diplomat", + "directed", + "distance", + "ditch", + "divers", + "dizzy", + "doctor", + "dodge", + "does", + "dogs", + "doing", + "dolphin", + "domestic", + "donuts", + "doorway", + "dormant", + "dosage", + "dotted", + "double", + "dove", + "down", + "dozen", + "dreams", + "drinks", + "drowning", + "drunk", + "drying", + "dual", + "dubbed", + "duckling", + "dude", + "duets", + "duke", + "dullness", + "dummy", + "dunes", + "duplex", + "duration", + "dusted", + "duties", + "dwarf", + "dwelt", + "dwindling", + "dying", + "dynamite", + "dyslexic", + "each", + "eagle", + "earth", + "easy", + "eating", + "eavesdrop", + "eccentric", + "echo", + "eclipse", + "economics", + "ecstatic", + "eden", + "edgy", + "edited", + "educated", + "eels", + "efficient", + "eggs", + "egotistic", + "eight", + "either", + "eject", + "elapse", + "elbow", + "eldest", + "eleven", + "elite", + "elope", + "else", + "eluded", + "emails", + "ember", + "emerge", + "emit", + "emotion", + "empty", + "emulate", + "energy", + "enforce", + "enhanced", + "enigma", + "enjoy", + "enlist", + "enmity", + "enough", + "enraged", + "ensign", + "entrance", + "envy", + "epoxy", + "equip", + "erase", + "erected", + "erosion", + "error", + "eskimos", + "espionage", + "essential", + "estate", + "etched", + "eternal", + "ethics", + "etiquette", + "evaluate", + "evenings", + "evicted", + "evolved", + "examine", + "excess", + "exhale", + "exit", + "exotic", + "exquisite", + "extra", + "exult", + "fabrics", + "factual", + "fading", + "fainted", + "faked", + "fall", + "family", + "fancy", + "farming", + "fatal", + "faulty", + "fawns", + "faxed", + "fazed", + "feast", + "february", + "federal", + "feel", + "feline", + "females", + "fences", + "ferry", + "festival", + "fetches", + "fever", + "fewest", + "fiat", + "fibula", + "fictional", + "fidget", + "fierce", + "fifteen", + "fight", + "films", + "firm", + "fishing", + "fitting", + "five", + "fixate", + "fizzle", + "fleet", + "flippant", + "flying", + "foamy", + "focus", + "foes", + "foggy", + "foiled", + "folding", + "fonts", + "foolish", + "fossil", + "fountain", + "fowls", + "foxes", + "foyer", + "framed", + "friendly", + "frown", + "fruit", + "frying", + "fudge", + "fuel", + "fugitive", + "fully", + "fuming", + "fungal", + "furnished", + "fuselage", + "future", + "fuzzy", + "gables", + "gadget", + "gags", + "gained", + "galaxy", + "gambit", + "gang", + "gasp", + "gather", + "gauze", + "gave", + "gawk", + "gaze", + "gearbox", + "gecko", + "geek", + "gels", + "gemstone", + "general", + "geometry", + "germs", + "gesture", + "getting", + "geyser", + "ghetto", + "ghost", + "giant", + "giddy", + "gifts", + "gigantic", + "gills", + "gimmick", + "ginger", + "girth", + "giving", + "glass", + "gleeful", + "glide", + "gnaw", + "gnome", + "goat", + "goblet", + "godfather", + "goes", + "goggles", + "going", + "goldfish", + "gone", + "goodbye", + "gopher", + "gorilla", + "gossip", + "gotten", + "gourmet", + "governing", + "gown", + "greater", + "grunt", + "guarded", + "guest", + "guide", + "gulp", + "gumball", + "guru", + "gusts", + "gutter", + "guys", + "gymnast", + "gypsy", + "gyrate", + "habitat", + "hacksaw", + "haggled", + "hairy", + "hamburger", + "happens", + "hashing", + "hatchet", + "haunted", + "having", + "hawk", + "haystack", + "hazard", + "hectare", + "hedgehog", + "heels", + "hefty", + "height", + "hemlock", + "hence", + "heron", + "hesitate", + "hexagon", + "hickory", + "hiding", + "highway", + "hijack", + "hiker", + "hills", + "himself", + "hinder", + "hippo", + "hire", + "history", + "hitched", + "hive", + "hoax", + "hobby", + "hockey", + "hoisting", + "hold", + "honked", + "hookup", + "hope", + "hornet", + "hospital", + "hotel", + "hounded", + "hover", + "howls", + "hubcaps", + "huddle", + "huge", + "hull", + "humid", + "hunter", + "hurried", + "husband", + "huts", + "hybrid", + "hydrogen", + "hyper", + "iceberg", + "icing", + "icon", + "identity", + "idiom", + "idled", + "idols", + "igloo", + "ignore", + "iguana", + "illness", + "imagine", + "imbalance", + "imitate", + "impel", + "inactive", + "inbound", + "incur", + "industrial", + "inexact", + "inflamed", + "ingested", + "initiate", + "injury", + "inkling", + "inline", + "inmate", + "innocent", + "inorganic", + "input", + "inquest", + "inroads", + "insult", + "intended", + "inundate", + "invoke", + "inwardly", + "ionic", + "irate", + "iris", + "irony", + "irritate", + "island", + "isolated", + "issued", + "italics", + "itches", + "items", + "itinerary", + "itself", + "ivory", + "jabbed", + "jackets", + "jaded", + "jagged", + "jailed", + "jamming", + "january", + "jargon", + "jaunt", + "javelin", + "jaws", + "jazz", + "jeans", + "jeers", + "jellyfish", + "jeopardy", + "jerseys", + "jester", + "jetting", + "jewels", + "jigsaw", + "jingle", + "jittery", + "jive", + "jobs", + "jockey", + "jogger", + "joining", + "joking", + "jolted", + "jostle", + "journal", + "joyous", + "jubilee", + "judge", + "juggled", + "juicy", + "jukebox", + "july", + "jump", + "junk", + "jury", + "justice", + "juvenile", + "kangaroo", + "karate", + "keep", + "kennel", + "kept", + "kernels", + "kettle", + "keyboard", + "kickoff", + "kidneys", + "king", + "kiosk", + "kisses", + "kitchens", + "kiwi", + "knapsack", + "knee", + "knife", + "knowledge", + "knuckle", + "koala", + "laboratory", + "ladder", + "lagoon", + "lair", + "lakes", + "lamb", + "language", + "laptop", + "large", + "last", + "later", + "launching", + "lava", + "lawsuit", + "layout", + "lazy", + "lectures", + "ledge", + "leech", + "left", + "legion", + "leisure", + "lemon", + "lending", + "leopard", + "lesson", + "lettuce", + "lexicon", + "liar", + "library", + "licks", + "lids", + "lied", + "lifestyle", + "light", + "likewise", + "lilac", + "limits", + "linen", + "lion", + "lipstick", + "liquid", + "listen", + "lively", + "loaded", + "lobster", + "locker", + "lodge", + "lofty", + "logic", + "loincloth", + "long", + "looking", + "lopped", + "lordship", + "losing", + "lottery", + "loudly", + "love", + "lower", + "loyal", + "lucky", + "luggage", + "lukewarm", + "lullaby", + "lumber", + "lunar", + "lurk", + "lush", + "luxury", + "lymph", + "lynx", + "lyrics", + "macro", + "madness", + "magically", + "mailed", + "major", + "makeup", + "malady", + "mammal", + "maps", + "masterful", + "match", + "maul", + "maverick", + "maximum", + "mayor", + "maze", + "meant", + "mechanic", + "medicate", + "meeting", + "megabyte", + "melting", + "memoir", + "menu", + "merger", + "mesh", + "metro", + "mews", + "mice", + "midst", + "mighty", + "mime", + "mirror", + "misery", + "mittens", + "mixture", + "moat", + "mobile", + "mocked", + "mohawk", + "moisture", + "molten", + "moment", + "money", + "moon", + "mops", + "morsel", + "mostly", + "motherly", + "mouth", + "movement", + "mowing", + "much", + "muddy", + "muffin", + "mugged", + "mullet", + "mumble", + "mundane", + "muppet", + "mural", + "musical", + "muzzle", + "myriad", + "mystery", + "myth", + "nabbing", + "nagged", + "nail", + "names", + "nanny", + "napkin", + "narrate", + "nasty", + "natural", + "nautical", + "navy", + "nearby", + "necklace", + "needed", + "negative", + "neither", + "neon", + "nephew", + "nerves", + "nestle", + "network", + "neutral", + "never", + "newt", + "nexus", + "nibs", + "niche", + "niece", + "nifty", + "nightly", + "nimbly", + "nineteen", + "nirvana", + "nitrogen", + "nobody", + "nocturnal", + "nodes", + "noises", + "nomad", + "noodles", + "northern", + "nostril", + "noted", + "nouns", + "novelty", + "nowhere", + "nozzle", + "nuance", + "nucleus", + "nudged", + "nugget", + "nuisance", + "null", + "number", + "nuns", + "nurse", + "nutshell", + "nylon", + "oaks", + "oars", + "oasis", + "oatmeal", + "obedient", + "object", + "obliged", + "obnoxious", + "observant", + "obtains", + "obvious", + "occur", + "ocean", + "october", + "odds", + "odometer", + "offend", + "often", + "oilfield", + "ointment", + "okay", + "older", + "olive", + "olympics", + "omega", + "omission", + "omnibus", + "onboard", + "oncoming", + "oneself", + "ongoing", + "onion", + "online", + "onslaught", + "onto", + "onward", + "oozed", + "opacity", + "opened", + "opposite", + "optical", + "opus", + "orange", + "orbit", + "orchid", + "orders", + "organs", + "origin", + "ornament", + "orphans", + "oscar", + "ostrich", + "otherwise", + "otter", + "ouch", + "ought", + "ounce", + "ourselves", + "oust", + "outbreak", + "oval", + "oven", + "owed", + "owls", + "owner", + "oxidant", + "oxygen", + "oyster", + "ozone", + "pact", + "paddles", + "pager", + "pairing", + "palace", + "pamphlet", + "pancakes", + "paper", + "paradise", + "pastry", + "patio", + "pause", + "pavements", + "pawnshop", + "payment", + "peaches", + "pebbles", + "peculiar", + "pedantic", + "peeled", + "pegs", + "pelican", + "pencil", + "people", + "pepper", + "perfect", + "pests", + "petals", + "phase", + "pheasants", + "phone", + "phrases", + "physics", + "piano", + "picked", + "pierce", + "pigment", + "piloted", + "pimple", + "pinched", + "pioneer", + "pipeline", + "pirate", + "pistons", + "pitched", + "pivot", + "pixels", + "pizza", + "playful", + "pledge", + "pliers", + "plotting", + "plus", + "plywood", + "poaching", + "pockets", + "podcast", + "poetry", + "point", + "poker", + "polar", + "ponies", + "pool", + "popular", + "portents", + "possible", + "potato", + "pouch", + "poverty", + "powder", + "pram", + "present", + "pride", + "problems", + "pruned", + "prying", + "psychic", + "public", + "puck", + "puddle", + "puffin", + "pulp", + "pumpkins", + "punch", + "puppy", + "purged", + "push", + "putty", + "puzzled", + "pylons", + "pyramid", + "python", + "queen", + "quick", + "quote", + "rabbits", + "racetrack", + "radar", + "rafts", + "rage", + "railway", + "raking", + "rally", + "ramped", + "randomly", + "rapid", + "rarest", + "rash", + "rated", + "ravine", + "rays", + "razor", + "react", + "rebel", + "recipe", + "reduce", + "reef", + "refer", + "regular", + "reheat", + "reinvest", + "rejoices", + "rekindle", + "relic", + "remedy", + "renting", + "reorder", + "repent", + "request", + "reruns", + "rest", + "return", + "reunion", + "revamp", + "rewind", + "rhino", + "rhythm", + "ribbon", + "richly", + "ridges", + "rift", + "rigid", + "rims", + "ringing", + "riots", + "ripped", + "rising", + "ritual", + "river", + "roared", + "robot", + "rockets", + "rodent", + "rogue", + "roles", + "romance", + "roomy", + "roped", + "roster", + "rotate", + "rounded", + "rover", + "rowboat", + "royal", + "ruby", + "rudely", + "ruffled", + "rugged", + "ruined", + "ruling", + "rumble", + "runway", + "rural", + "rustled", + "ruthless", + "sabotage", + "sack", + "sadness", + "safety", + "saga", + "sailor", + "sake", + "salads", + "sample", + "sanity", + "sapling", + "sarcasm", + "sash", + "satin", + "saucepan", + "saved", + "sawmill", + "saxophone", + "sayings", + "scamper", + "scenic", + "school", + "science", + "scoop", + "scrub", + "scuba", + "seasons", + "second", + "sedan", + "seeded", + "segments", + "seismic", + "selfish", + "semifinal", + "sensible", + "september", + "sequence", + "serving", + "session", + "setup", + "seventh", + "sewage", + "shackles", + "shelter", + "shipped", + "shocking", + "shrugged", + "shuffled", + "shyness", + "siblings", + "sickness", + "sidekick", + "sieve", + "sifting", + "sighting", + "silk", + "simplest", + "sincerely", + "sipped", + "siren", + "situated", + "sixteen", + "sizes", + "skater", + "skew", + "skirting", + "skulls", + "skydive", + "slackens", + "sleepless", + "slid", + "slower", + "slug", + "smash", + "smelting", + "smidgen", + "smog", + "smuggled", + "snake", + "sneeze", + "sniff", + "snout", + "snug", + "soapy", + "sober", + "soccer", + "soda", + "software", + "soggy", + "soil", + "solved", + "somewhere", + "sonic", + "soothe", + "soprano", + "sorry", + "southern", + "sovereign", + "sowed", + "soya", + "space", + "speedy", + "sphere", + "spiders", + "splendid", + "spout", + "sprig", + "spud", + "spying", + "square", + "stacking", + "stellar", + "stick", + "stockpile", + "strained", + "stunning", + "stylishly", + "subtly", + "succeed", + "suddenly", + "suede", + "suffice", + "sugar", + "suitcase", + "sulking", + "summon", + "sunken", + "superior", + "surfer", + "sushi", + "suture", + "swagger", + "swept", + "swiftly", + "sword", + "swung", + "syllabus", + "symptoms", + "syndrome", + "syringe", + "system", + "taboo", + "tacit", + "tadpoles", + "tagged", + "tail", + "taken", + "talent", + "tamper", + "tanks", + "tapestry", + "tarnished", + "tasked", + "tattoo", + "taunts", + "tavern", + "tawny", + "taxi", + "teardrop", + "technical", + "tedious", + "teeming", + "tell", + "template", + "tender", + "tepid", + "tequila", + "terminal", + "testing", + "tether", + "textbook", + "thaw", + "theatrics", + "thirsty", + "thorn", + "threaten", + "thumbs", + "thwart", + "ticket", + "tidy", + "tiers", + "tiger", + "tilt", + "timber", + "tinted", + "tipsy", + "tirade", + "tissue", + "titans", + "toaster", + "tobacco", + "today", + "toenail", + "toffee", + "together", + "toilet", + "token", + "tolerant", + "tomorrow", + "tonic", + "toolbox", + "topic", + "torch", + "tossed", + "total", + "touchy", + "towel", + "toxic", + "toyed", + "trash", + "trendy", + "tribal", + "trolling", + "truth", + "trying", + "tsunami", + "tubes", + "tucks", + "tudor", + "tuesday", + "tufts", + "tugs", + "tuition", + "tulips", + "tumbling", + "tunnel", + "turnip", + "tusks", + "tutor", + "tuxedo", + "twang", + "tweezers", + "twice", + "twofold", + "tycoon", + "typist", + "tyrant", + "ugly", + "ulcers", + "ultimate", + "umbrella", + "umpire", + "unafraid", + "unbending", + "uncle", + "under", + "uneven", + "unfit", + "ungainly", + "unhappy", + "union", + "unjustly", + "unknown", + "unlikely", + "unmask", + "unnoticed", + "unopened", + "unplugs", + "unquoted", + "unrest", + "unsafe", + "until", + "unusual", + "unveil", + "unwind", + "unzip", + "upbeat", + "upcoming", + "update", + "upgrade", + "uphill", + "upkeep", + "upload", + "upon", + "upper", + "upright", + "upstairs", + "uptight", + "upwards", + "urban", + "urchins", + "urgent", + "usage", + "useful", + "usher", + "using", + "usual", + "utensils", + "utility", + "utmost", + "utopia", + "uttered", + "vacation", + "vague", + "vain", + "value", + "vampire", + "vane", + "vapidly", + "vary", + "vastness", + "vats", + "vaults", + "vector", + "veered", + "vegan", + "vehicle", + "vein", + "velvet", + "venomous", + "verification", + "vessel", + "veteran", + "vexed", + "vials", + "vibrate", + "victim", + "video", + "viewpoint", + "vigilant", + "viking", + "village", + "vinegar", + "violin", + "vipers", + "virtual", + "visited", + "vitals", + "vivid", + "vixen", + "vocal", + "vogue", + "voice", + "volcano", + "vortex", + "voted", + "voucher", + "vowels", + "voyage", + "vulture", + "wade", + "waffle", + "wagtail", + "waist", + "waking", + "wallets", + "wanted", + "warped", + "washing", + "water", + "waveform", + "waxing", + "wayside", + "weavers", + "website", + "wedge", + "weekday", + "weird", + "welders", + "went", + "wept", + "were", + "western", + "wetsuit", + "whale", + "when", + "whipped", + "whole", + "wickets", + "width", + "wield", + "wife", + "wiggle", + "wildly", + "winter", + "wipeout", + "wiring", + "wise", + "withdrawn", + "wives", + "wizard", + "wobbly", + "woes", + "woken", + "wolf", + "womanly", + "wonders", + "woozy", + "worry", + "wounded", + "woven", + "wrap", + "wrist", + "wrong", + "yacht", + "yahoo", + "yanks", + "yard", + "yawning", + "yearbook", + "yellow", + "yesterday", + "yeti", + "yields", + "yodel", + "yoga", + "younger", + "yoyo", + "zapped", + "zeal", + "zebra", + "zero", + "zesty", + "zigzags", + "zinger", + "zippers", + "zodiac", + "zombie", + "zones", + "zoom", +]; diff --git a/swap/src/cli/api/tauri_bindings.rs b/swap/src/cli/api/tauri_bindings.rs index 5be8f8adb4..d165c88b23 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, +} + +/// Wallet to open after the app relaunches, recorded so the user can switch +/// wallets. `ShowChooser` forces the setup chooser instead of opening a wallet. +#[typeshare] +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "type", content = "content")] +pub enum PendingWalletAction { + Open { wallet_path: String }, + ShowChooser, } #[typeshare] @@ -576,11 +596,10 @@ pub trait TauriEmitter { timeout_secs: u64, ) -> Result; - async fn request_seed_selection(&self) -> Result; - async fn request_seed_selection_with_recent_wallets( &self, recent_wallets: Vec, + default_wallet_directory: String, ) -> Result; async fn request_password(&self, wallet_path: String) -> Result; @@ -696,16 +715,15 @@ impl TauriEmitter for TauriHandle { .unwrap_or(false)) } - async fn request_seed_selection(&self) -> Result { - self.request_seed_selection_with_recent_wallets(vec![]) - .await - } - async fn request_seed_selection_with_recent_wallets( &self, recent_wallets: Vec, + default_wallet_directory: String, ) -> Result { - let details = SeedSelectionDetails { recent_wallets }; + let details = SeedSelectionDetails { + recent_wallets, + default_wallet_directory, + }; self.request_approval(ApprovalRequestType::SeedSelection(details), None) .await } @@ -779,21 +797,18 @@ impl TauriEmitter for Option { } } - async fn request_seed_selection(&self) -> Result { - match self { - Some(tauri) => tauri.request_seed_selection().await, - None => bail!("No Tauri handle available"), - } - } - async fn request_seed_selection_with_recent_wallets( &self, recent_wallets: Vec, + default_wallet_directory: String, ) -> Result { match self { Some(tauri) => { tauri - .request_seed_selection_with_recent_wallets(recent_wallets) + .request_seed_selection_with_recent_wallets( + recent_wallets, + default_wallet_directory, + ) .await } None => bail!("No Tauri handle available"), From 486eacea0e0fcd67121a160d362ac253949e05cc Mon Sep 17 00:00:00 2001 From: binarybaron Date: Thu, 2 Jul 2026 10:54:59 +0200 Subject: [PATCH 2/7] refactor(gui,swap): review fixes for wallet wizard and switcher Review findings from three parallel reviewers, applied: Backend: - Wallet create/open failures (e.g. duplicate wallet name) no longer abort context initialization: the open loop treats every per-attempt failure as one outcome and re-prompts the seed chooser, matching the existing password-rejection recovery. - Pending-wallet marker: atomic write (tmp + rename), corrupt markers are discarded with a warning instead of failing startup, and an Open marker pointing at a deleted wallet falls back to the chooser. - new_wallet_path hoisted to module level; also rejects empty names and non-absolute directories at the IPC trust boundary. - TauriEmitter::request_seed_selection_with_recent_wallets renamed to request_seed_selection, taking SeedSelectionDetails directly. - SetPendingWalletArgs derives the data dir via eigenwallet_data::new, same source as the startup reader. Wizard (state machine completed): - Form fields moved into the editing variant; backingUp carries the editing state for rollback and owns the backup confirmation; reset rebuilds everything so no password/seed leaks across requests. - walletCreationStarted guarded to RandomSeed@nameLocation. - Async seed validation tagged with the seed it belongs to (no stale responses), primary/legacy buttons surface errors via snackbar. - BackupSeedStep: checkbox controlled by parent; a failed seed fetch unlocks Finish instead of hard-locking the modal; 5 retries not 60. - NameLocationStep: no error styling on pristine name field. Switcher: - Failed relaunch neutralizes the marker so a later manual launch cannot silently switch wallets. - Picking a .keys file resolves to the wallet file itself. - Confirm dialog keeps its copy while fading out; explicit warning when a funds-locked swap is running; chip label capped at 220px. - NewPasswordInput setPassword prop loosened to (password: string) => void (it never used functional updates). --- .../modal/seed-selection/BackupSeedStep.tsx | 16 +- .../modal/seed-selection/NameLocationStep.tsx | 8 +- .../modal/seed-selection/OpenWalletStep.tsx | 2 +- .../seed-selection/SeedSelectionDialog.tsx | 268 +++++++++++------- .../components/other/NewPasswordInput.tsx | 2 +- .../monero/components/WalletSwitcher.tsx | 46 ++- swap/src/cli/api.rs | 214 ++++++++------ swap/src/cli/api/request.rs | 4 +- swap/src/cli/api/tauri_bindings.rs | 31 +- 9 files changed, 361 insertions(+), 230 deletions(-) diff --git a/src-gui/src/renderer/components/modal/seed-selection/BackupSeedStep.tsx b/src-gui/src/renderer/components/modal/seed-selection/BackupSeedStep.tsx index 9035be5033..9605d0d065 100644 --- a/src-gui/src/renderer/components/modal/seed-selection/BackupSeedStep.tsx +++ b/src-gui/src/renderer/components/modal/seed-selection/BackupSeedStep.tsx @@ -17,21 +17,22 @@ import { PrivateKeyScamAlert } from "renderer/components/other/PrivateKeyWarning import CircularProgressWithSubtitle from "renderer/components/pages/swap/swap/components/CircularProgressWithSubtitle"; const SEED_FETCH_RETRY_INTERVAL_MS = 1000; -const SEED_FETCH_MAX_ATTEMPTS = 60; +const SEED_FETCH_MAX_ATTEMPTS = 5; /// Shown after a fresh wallet has been created so the user records their seed /// before continuing. The seed only becomes readable once the newly created /// Monero wallet has finished opening, so we wait for it before fetching. export default function BackupSeedStep({ + confirmed, onConfirmedChange, }: { + confirmed: boolean; onConfirmedChange: (confirmed: boolean) => void; }) { const moneroWalletAvailable = useIsMoneroWalletAvailable(); const [seed, setSeed] = useState< [GetMoneroSeedResponse, GetRestoreHeightResponse] | null >(null); - const [confirmed, setConfirmed] = useState(false); const [failed, setFailed] = useState(false); useEffect(() => { @@ -53,6 +54,9 @@ export default function BackupSeedStep({ if (attempt >= SEED_FETCH_MAX_ATTEMPTS) { console.error("Failed to read wallet seed for backup", e); setFailed(true); + // The wallet itself was created; never trap the user in the + // dialog over a display failure. + onConfirmedChange(true); return; } timeout = setTimeout(fetchSeed, SEED_FETCH_RETRY_INTERVAL_MS); @@ -62,8 +66,9 @@ export default function BackupSeedStep({ return () => { cancelled = true; - if (timeout !== undefined) clearTimeout(timeout); + clearTimeout(timeout); }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- refetch only when the wallet becomes readable }, [moneroWalletAvailable, seed]); if (failed) { @@ -103,10 +108,7 @@ export default function BackupSeedStep({ control={ { - setConfirmed(e.target.checked); - onConfirmedChange(e.target.checked); - }} + onChange={(e) => 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 index 4571b12bbd..6d2fe7474a 100644 --- a/src-gui/src/renderer/components/modal/seed-selection/NameLocationStep.tsx +++ b/src-gui/src/renderer/components/modal/seed-selection/NameLocationStep.tsx @@ -30,8 +30,12 @@ export default function NameLocationStep({ value={name} onChange={(e) => setName(e.target.value)} placeholder="my-wallet" - error={name.trim().length === 0} - helperText={name.trim().length === 0 ? "Enter a wallet name" : ""} + error={name.length > 0 && name.trim().length === 0} + helperText={ + name.length > 0 && name.trim().length === 0 + ? "Enter a wallet name" + : "" + } /> {recentWallets.map((path, index) => ( - + 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: "", +}; + +interface EditingState { + phase: "editing"; + mode: WalletMode; + step: StepId; + fields: FormFields; +} + // The wizard is a state machine. `editing` navigates the FLOWS table of the -// selected mode. `backingUp` is entered once a new wallet has been created: +// selected mode and owns all form input, so a reset cannot leak fields from a +// previous request. `backingUp` is entered once a new wallet has been created: // the approval is already resolved, but the dialog stays up until the user has -// recorded the seed. `finished` unmounts the dialog. Only `editing` carries a -// mode/step, so out-of-range steps or "backup while restoring" cannot be -// represented. +// recorded the seed; it carries the editing state to resume on failure. +// `finished` unmounts the dialog. type WizardState = - | { phase: "editing"; mode: WalletMode; step: StepId } - | { phase: "backingUp" } + | EditingState + | { phase: "backingUp"; confirmed: boolean; resume: EditingState } | { phase: "finished" }; type WizardEvent = - | { type: "reset"; mode: WalletMode } + | { 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 } | { type: "walletCreationStarted" } | { type: "walletCreationFailed" } + | { type: "confirmBackup"; confirmed: boolean } | { type: "finish" }; +const INITIAL_WIZARD: WizardState = { + phase: "editing", + 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 { switch (event.type) { case "reset": - return { phase: "editing", mode: event.mode, step: "chooseMode" }; + return { + phase: "editing", + mode: event.mode, + step: "chooseMode", + fields: { ...EMPTY_FIELDS, ...event.fields }, + }; case "walletCreationStarted": - return state.phase === "editing" ? { phase: "backingUp" } : state; + // Creation is only offered on the RandomSeed name & location step. + return state.phase === "editing" && + state.mode === "RandomSeed" && + state.step === "nameLocation" + ? { phase: "backingUp", confirmed: false, resume: state } + : state; case "walletCreationFailed": - // Creation only starts from the RandomSeed name & location step, so - // return there for the user to correct the input and retry. + return state.phase === "backingUp" ? state.resume : state; + case "confirmBackup": return state.phase === "backingUp" - ? { phase: "editing", mode: "RandomSeed", step: "nameLocation" } + ? { ...state, confirmed: event.confirmed } : state; case "finish": return state.phase === "backingUp" ? { phase: "finished" } : state; } - // The remaining events navigate within the editing phase. + // The remaining events edit or navigate within the editing phase. if (state.phase !== "editing") return state; const flow = FLOWS[state.mode]; const index = stepIndex(state.mode, state.step); switch (event.type) { + 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; @@ -184,21 +234,14 @@ function parseBlockHeightInput(blockheightInput: string): number | false { export default function SeedSelectionDialog() { const pendingApprovals = usePendingSeedSelectionApproval(); - const [wizard, dispatch] = useReducer(wizardReducer, { - phase: "editing", - mode: "RandomSeed", - step: "chooseMode", - } as WizardState); - const [customSeed, setCustomSeed] = useState(""); - const [blockheightInput, setBlockheightInput] = useState(""); - const [asyncSeedValidation, setAsyncSeedValidation] = - useState(false); - const [password, setPassword] = useState(""); + const [wizard, dispatch] = useReducer(wizardReducer, INITIAL_WIZARD); const [isPasswordValid, setIsPasswordValid] = useState(true); - const [walletPath, setWalletPath] = useState(""); - const [name, setName] = useState(""); - const [directory, setDirectory] = useState(""); - const [backupConfirmed, setBackupConfirmed] = 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 approval = pendingApprovals[0]; const content = @@ -208,7 +251,8 @@ export default function SeedSelectionDialog() { const recentWallets = content?.recent_wallets ?? []; // Reset the wizard whenever a new seed-selection approval arrives (e.g. after - // the user cancels a password prompt and is asked to choose again). + // the user cancels a password prompt or a wallet fails to create and the + // backend asks again). const lastRequestIdRef = useRef(null); useEffect(() => { const requestId = approval?.request_id; @@ -219,58 +263,104 @@ export default function SeedSelectionDialog() { dispatch({ type: "reset", mode: recentWallets.length > 0 ? "FromWalletPath" : "RandomSeed", + fields: { + directory: content?.default_wallet_directory ?? "", + walletPath: recentWallets[0] ?? "", + }, }); - setBackupConfirmed(false); - setName(""); - setDirectory(content?.default_wallet_directory ?? ""); - if (recentWallets.length > 0) { - setWalletPath(recentWallets[0]); - } + setSeedValidation(null); // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on a new approval id }, [approval?.request_id]); - // Only run async validation when in "FromSeed" mode with content + // While backing up, the fields of the editing state that created the wallet + // are still needed for a rollback on failure. + const editing = + wizard.phase === "editing" + ? wizard + : wizard.phase === "backingUp" + ? wizard.resume + : null; + + const trimmedSeed = editing?.fields.customSeed.trim() ?? ""; const needsSeedValidation = - wizard.phase === "editing" && - wizard.mode === "FromSeed" && - customSeed.trim(); + editing?.mode === "FromSeed" && trimmedSeed.length > 0; useEffect(() => { if (!needsSeedValidation) return; - checkSeed(customSeed.trim()) - .then(setAsyncSeedValidation) - .catch(() => setAsyncSeedValidation(false)); - }, [customSeed, needsSeedValidation]); + checkSeed(trimmedSeed) + .then((valid) => setSeedValidation({ forSeed: trimmedSeed, valid })) + .catch(() => setSeedValidation({ forSeed: trimmedSeed, valid: false })); + }, [trimmedSeed, needsSeedValidation]); - const isSeedValid = needsSeedValidation && asyncSeedValidation; - const hasBlockheightInput = blockheightInput.length > 0; - const isBlockheightValid = parseBlockHeightInput(blockheightInput) !== false; - const isBlockheightInvalid = - hasBlockheightInput && isBlockheightValid === false; + if (wizard.phase === "finished" || editing === null) return null; + if (wizard.phase === "editing" && !approval) return null; + + const { mode, fields } = editing; + const step: StepId = + wizard.phase === "backingUp" ? "backupSeed" : wizard.step; + const flow = FLOWS[mode]; + const index = stepIndex(mode, step); + + 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; - const buildSeedChoice = (mode: WalletMode): SeedChoice => { - switch (mode) { + // 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: wizard.phase === "backingUp" && wizard.confirmed, + }; + + const buildSeedChoice = (chosenMode: WalletMode): SeedChoice => { + switch (chosenMode) { case "RandomSeed": - return { type: "RandomSeed", content: { password, name, directory } }; + 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"); } return { type: "FromSeed", content: { - seed: customSeed, - password, + seed: fields.customSeed, + password: fields.password, restore_height: parsedBlockHeight, - name, - directory, + name: fields.name, + directory: fields.directory, }, }; } case "FromWalletPath": - return { type: "FromWalletPath", content: { wallet_path: walletPath } }; + return { + type: "FromWalletPath", + content: { wallet_path: fields.walletPath }, + }; } }; @@ -283,37 +373,16 @@ export default function SeedSelectionDialog() { // Creating transitions to the backup phase *before* resolving, so the dialog // stays mounted once the approval clears; on failure we roll back. const createWallet = async () => { + const seedChoice = buildSeedChoice("RandomSeed"); dispatch({ type: "walletCreationStarted" }); try { - await resolve(buildSeedChoice("RandomSeed")); + await resolve(seedChoice); } catch (e) { dispatch({ type: "walletCreationFailed" }); throw e; } }; - if (wizard.phase === "finished") return null; - if (wizard.phase === "editing" && !approval) return null; - - // The backup phase is only reachable from the RandomSeed flow, whose last - // step records the seed. - const mode = wizard.phase === "editing" ? wizard.mode : "RandomSeed"; - const step = wizard.phase === "editing" ? wizard.step : "backupSeed"; - const flow = FLOWS[mode]; - const index = stepIndex(mode, step); - - // Whether the current step's input is complete enough to act on. - const stepValid: Record = { - chooseMode: true, - randomPassword: isPasswordValid, - seedPhrase: - customSeed.trim().length > 0 && !!isSeedValid && !isBlockheightInvalid, - storage: isPasswordValid, - nameLocation: name.trim().length > 0 && directory.trim().length > 0, - openFile: walletPath.length > 0, - backupSeed: backupConfirmed, - }; - const primaryHandlers: Record void | Promise> = { next: () => dispatch({ type: "next" }), @@ -420,8 +489,8 @@ export default function SeedSelectionDialog() { {step === "randomPassword" && ( @@ -444,13 +513,13 @@ export default function SeedSelectionDialog() { created speeds up syncing. 0} + value={fields.customSeed} + onChange={setField("customSeed")} + error={!isSeedValid && fields.customSeed.length > 0} helperText={ isSeedValid ? "Seed is valid" - : customSeed.length > 0 + : fields.customSeed.length > 0 ? "Seed is invalid" : "" } @@ -459,8 +528,8 @@ export default function SeedSelectionDialog() { type="text" inputProps={{ inputmode: "numeric", pattern: "[0-9]*" }} label="Restore blockheight (optional)" - value={blockheightInput} - onChange={(e) => 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={ @@ -481,8 +550,8 @@ export default function SeedSelectionDialog() { Leave it empty to store the wallet unencrypted. @@ -491,21 +560,26 @@ export default function SeedSelectionDialog() { {step === "nameLocation" && ( )} {step === "backupSeed" && ( - + + dispatch({ type: "confirmBackup", confirmed }) + } + /> )} {step === "openFile" && ( )} @@ -517,6 +591,7 @@ export default function SeedSelectionDialog() { variant="text" onInvoke={() => resolve({ type: "Legacy" })} contextRequirement={false} + displayErrorSnackbar color="inherit" > No wallet (Legacy) @@ -536,6 +611,7 @@ export default function SeedSelectionDialog() { variant="contained" disabled={!stepValid[step]} contextRequirement={false} + displayErrorSnackbar onInvoke={async () => { await primaryHandlers[flow[index].action](); }} 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/renderer/components/pages/monero/components/WalletSwitcher.tsx b/src-gui/src/renderer/components/pages/monero/components/WalletSwitcher.tsx index 45f6411115..c7d05bd777 100644 --- a/src-gui/src/renderer/components/pages/monero/components/WalletSwitcher.tsx +++ b/src-gui/src/renderer/components/pages/monero/components/WalletSwitcher.tsx @@ -22,6 +22,7 @@ import { useEffect, useState } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import { relaunch } from "@tauri-apps/plugin-process"; import { getRecentWallets, setPendingWallet } from "renderer/rpc"; +import { useIsSwapRunningAndHasFundsLocked } from "store/hooks"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; function walletFileName(path: string): string { @@ -29,7 +30,8 @@ function walletFileName(path: string): string { } // Switching opens a specific wallet; creating restarts into the wallet setup -// chooser (an empty pending marker tells startup to prompt instead of opening). +// chooser (a ShowChooser marker also neutralizes any stale Open marker left by +// a previously failed switch). type PendingAction = { kind: "switch"; path: string } | { kind: "create" }; // Shows the current wallet and lets the user open a different one. The most @@ -40,7 +42,11 @@ export default function WalletSwitcher() { const [anchorEl, setAnchorEl] = useState(null); const [recentWallets, setRecentWallets] = useState([]); // Action the user picked, awaiting confirmation before the app relaunches. + // Kept while the dialog fades out so the copy doesn't flicker; `confirmOpen` + // alone controls visibility. const [pending, setPending] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const swapRunning = useIsSwapRunningAndHasFundsLocked(); const refreshRecentWallets = () => { getRecentWallets() @@ -58,6 +64,11 @@ export default function WalletSwitcher() { setAnchorEl(event.currentTarget); }; + const requestConfirmation = (action: PendingAction) => { + setPending(action); + setConfirmOpen(true); + }; + const confirmPending = async () => { if (pending === null) return; await setPendingWallet( @@ -65,12 +76,25 @@ export default function WalletSwitcher() { ? { type: "Open", content: { wallet_path: pending.path } } : { type: "ShowChooser" }, ); - await relaunch(); + try { + await relaunch(); + } catch (e) { + // Never leave an Open marker behind when the relaunch failed: a later + // manual launch would silently open a wallet the user no longer expects. + await setPendingWallet({ type: "ShowChooser" }).catch(() => {}); + throw e; + } }; const chooseOther = async () => { const selected = await open({ multiple: false, directory: false }); - if (selected) setPending({ kind: "switch", path: selected }); + if (!selected) return; + // Users commonly pick the `.keys` file; the wallet is the file + // without that extension. + requestConfirmation({ + kind: "switch", + path: selected.replace(/\.keys$/, ""), + }); }; return ( @@ -88,6 +112,7 @@ export default function WalletSwitcher() { // below, with a small downward notch tail. position: "relative", height: 36, + maxWidth: 220, bgcolor: "background.paper", boxShadow: 3, borderTopLeftRadius: 0, @@ -121,7 +146,7 @@ export default function WalletSwitcher() { key={path} onClick={() => { setAnchorEl(null); - setPending({ kind: "switch", path }); + requestConfirmation({ kind: "switch", path }); }} > @@ -145,7 +170,7 @@ export default function WalletSwitcher() { { setAnchorEl(null); - setPending({ kind: "create" }); + requestConfirmation({ kind: "create" }); }} > @@ -155,8 +180,9 @@ export default function WalletSwitcher() { setPending(null)} + open={confirmOpen} + onClose={() => setConfirmOpen(false)} + TransitionProps={{ onExited: () => setPending(null) }} maxWidth="xs" fullWidth > @@ -170,11 +196,13 @@ export default function WalletSwitcher() { : `The app will restart to open "${ pending ? walletFileName(pending.path) : "" }".`}{" "} - Any running operations will be interrupted. + {swapRunning + ? "A swap with locked funds is currently running. It will be interrupted, and its Monero will arrive in the wallet it was started with — not the one you are switching to." + : "Any running operations will be interrupted."} - 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) + } + /// Requests the user to select a seed choice from a list of recent wallets pub(super) async fn request_seed_choice( tauri_handle: TauriHandle, @@ -977,19 +1014,15 @@ mod wallet { eigenwallet_data_dir: &Path, ) -> 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, - default_wallet_directory(eigenwallet_data_dir) + 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?; - - Ok(seed_choice) + }) + .await } /// Marker file recording the wallet to open after an app relaunch, used by @@ -1006,11 +1039,18 @@ mod wallet { swap_fs::ensure_directory_exists(&marker) .context("Failed to create data directory for pending wallet marker")?; let encoded = serde_json::to_string(action).context("Failed to encode pending wallet")?; - std::fs::write(&marker, encoded).context("Failed to write pending wallet marker")?; + + // Write-then-rename so a crash cannot leave a torn marker behind. + let tmp = marker.with_extension("tmp"); + std::fs::write(&tmp, encoded).context("Failed to write pending wallet marker")?; + std::fs::rename(&tmp, &marker) + .context("Failed to move pending wallet marker into place")?; Ok(()) } - /// Reads and consumes the pending-wallet marker, if any. + /// Reads and consumes the pending-wallet marker, if any. A marker that + /// cannot be decoded is discarded: it is purely advisory, and failing + /// startup over it would leave the app unusable. fn take_pending_wallet(eigenwallet_data_dir: &Path) -> Result> { let marker = pending_wallet_marker_path(eigenwallet_data_dir); @@ -1021,9 +1061,11 @@ mod wallet { }; std::fs::remove_file(&marker).context("Failed to remove pending wallet marker")?; - let action = - serde_json::from_str(&encoded).context("Failed to decode pending wallet marker")?; - Ok(Some(action)) + Ok(serde_json::from_str(&encoded) + .inspect_err( + |error| tracing::warn!(%error, "Discarding corrupt pending wallet marker"), + ) + .ok()) } /// On startup, honor a wallet the user pre-selected before relaunching the @@ -1035,9 +1077,16 @@ mod wallet { eigenwallet_data_dir: &Path, ) -> Result { match take_pending_wallet(eigenwallet_data_dir)? { - Some(PendingWalletAction::Open { wallet_path }) => { + Some(PendingWalletAction::Open { wallet_path }) + if Path::new(&wallet_path).exists() => + { + tracing::info!(%wallet_path, "Opening wallet pre-selected before relaunch"); Ok(SeedChoice::FromWalletPath { wallet_path }) } + Some(PendingWalletAction::Open { wallet_path }) => { + tracing::warn!(%wallet_path, "Pending wallet no longer exists, showing chooser"); + request_seed_choice(tauri_handle, database, eigenwallet_data_dir).await + } Some(PendingWalletAction::ShowChooser) | None => { request_seed_choice(tauri_handle, database, eigenwallet_data_dir).await } @@ -1051,8 +1100,8 @@ mod wallet { /// - 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. + /// 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, @@ -1075,61 +1124,31 @@ mod wallet { (), ); - // 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:?}"); - } - - 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) - } - - let wallet = match seed_choice { + let opened = match seed_choice { SeedChoice::RandomSeed { password, name, directory, } => { - let wallet_path = new_wallet_path(&directory, &name) - .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, - ) + async { + let wallet_path = new_wallet_path(&directory, &name) + .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") + } .await - .context("Failed to create wallet from random seed")? } SeedChoice::FromSeed { seed: mnemonic, @@ -1138,24 +1157,27 @@ mod wallet { name, directory, } => { - let wallet_path = new_wallet_path(&directory, &name) - .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(), - ) + async { + let wallet_path = new_wallet_path(&directory, &name) + .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") + } .await - .context("Failed to create wallet from provided seed")? } SeedChoice::FromWalletPath { ref wallet_path } => { let wallet_path = wallet_path.clone(); @@ -1255,7 +1277,7 @@ mod wallet { true, ) .await - .context("Failed to open wallet from provided path")? + .context("Failed to open wallet from provided path") } SeedChoice::Legacy => { @@ -1273,6 +1295,26 @@ mod wallet { } }; + // A failed create/open (e.g. the wallet name is already + // taken) must not abort startup: put the user back into + // the chooser instead. + let wallet = match opened { + Ok(wallet) => wallet, + Err(error) => { + tracing::error!( + ?error, + "Failed to open or create wallet, asking user to choose again" + ); + seed_choice = request_seed_choice( + tauri_handle.clone().unwrap(), + database, + eigenwallet_data_dir, + ) + .await?; + continue; + } + }; + // Extract seed from the wallet tracing::info!( "Extracting seed from wallet directory: {}", diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index 30e5768c76..9e0d5b648a 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -1912,7 +1912,9 @@ impl Request for SetPendingWalletArgs { async fn request(self, ctx: Arc) -> Result { let config = ctx.try_get_config().await?; - let eigenwallet_data_dir = swap_fs::system_data_dir_eigenwallet(config.is_testnet)?; + // Same derivation as ContextBuilder::build, so the marker is written + // where the next startup reads it. + let eigenwallet_data_dir = super::eigenwallet_data::new(config.is_testnet)?; super::wallet::write_pending_wallet(&eigenwallet_data_dir, &self.action)?; diff --git a/swap/src/cli/api/tauri_bindings.rs b/swap/src/cli/api/tauri_bindings.rs index d165c88b23..8b2e4c1bdf 100644 --- a/swap/src/cli/api/tauri_bindings.rs +++ b/swap/src/cli/api/tauri_bindings.rs @@ -596,11 +596,7 @@ pub trait TauriEmitter { timeout_secs: u64, ) -> Result; - async fn request_seed_selection_with_recent_wallets( - &self, - recent_wallets: Vec, - default_wallet_directory: String, - ) -> Result; + async fn request_seed_selection(&self, details: SeedSelectionDetails) -> Result; async fn request_password(&self, wallet_path: String) -> Result; @@ -715,15 +711,7 @@ impl TauriEmitter for TauriHandle { .unwrap_or(false)) } - async fn request_seed_selection_with_recent_wallets( - &self, - recent_wallets: Vec, - default_wallet_directory: String, - ) -> Result { - let details = SeedSelectionDetails { - recent_wallets, - default_wallet_directory, - }; + async fn request_seed_selection(&self, details: SeedSelectionDetails) -> Result { self.request_approval(ApprovalRequestType::SeedSelection(details), None) .await } @@ -797,20 +785,9 @@ impl TauriEmitter for Option { } } - async fn request_seed_selection_with_recent_wallets( - &self, - recent_wallets: Vec, - default_wallet_directory: String, - ) -> Result { + async fn request_seed_selection(&self, details: SeedSelectionDetails) -> Result { match self { - Some(tauri) => { - tauri - .request_seed_selection_with_recent_wallets( - recent_wallets, - default_wallet_directory, - ) - .await - } + Some(tauri) => tauri.request_seed_selection(details).await, None => bail!("No Tauri handle available"), } } From 271d058c999fea07661357f5b7d632e50ad20bfe Mon Sep 17 00:00:00 2001 From: binarybaron Date: Thu, 2 Jul 2026 11:02:59 +0200 Subject: [PATCH 3/7] refactor: trim wallet switcher and pending-wallet restart logic Scope the PR down to the setup wizard. Removed: - WalletSwitcher component and its MoneroWalletPage overlay - get_recent_wallets / set_pending_wallet Tauri commands and their request types - PendingWalletAction and the pending_wallet marker read/write plus resolve_startup_seed_choice (startup asks the chooser directly again) Kept from the switcher review where it still applies: - OpenWalletStep resolves a picked/dropped .keys file to the wallet file itself - NameLocationStep Browse now uses PromiseInvokeButton (async folder picker with error snackbar) instead of a bare Button --- CHANGELOG.md | 1 - .../modal/seed-selection/NameLocationStep.tsx | 11 +- .../modal/seed-selection/OpenWalletStep.tsx | 6 +- .../pages/monero/MoneroWalletPage.tsx | 12 - .../monero/components/WalletSwitcher.tsx | 219 ------------------ .../pages/monero/components/index.ts | 1 - src-gui/src/renderer/rpc.ts | 19 -- src-tauri/src/commands.rs | 16 +- swap/src/cli/api.rs | 74 +----- swap/src/cli/api/request.rs | 56 +---- swap/src/cli/api/tauri_bindings.rs | 10 - 11 files changed, 21 insertions(+), 404 deletions(-) delete mode 100644 src-gui/src/renderer/components/pages/monero/components/WalletSwitcher.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ef42ca73b..ec5c494fd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,6 @@ 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, restoring from a seed offers word autocomplete, and opening a wallet file supports drag & drop. Newly created wallets are named by the user instead of a Unix timestamp. -- GUI: Added a wallet switcher to the Monero wallet page for switching between recently used wallets, opening another wallet file, or creating a new wallet (the app restarts into the selected wallet). - 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/renderer/components/modal/seed-selection/NameLocationStep.tsx b/src-gui/src/renderer/components/modal/seed-selection/NameLocationStep.tsx index 6d2fe7474a..815388d4fe 100644 --- a/src-gui/src/renderer/components/modal/seed-selection/NameLocationStep.tsx +++ b/src-gui/src/renderer/components/modal/seed-selection/NameLocationStep.tsx @@ -1,6 +1,7 @@ -import { Box, Button, TextField, Typography } from "@mui/material"; +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, @@ -45,14 +46,16 @@ export default function NameLocationStep({ placeholder="Select a folder..." InputProps={{ readOnly: true }} /> - + ); diff --git a/src-gui/src/renderer/components/modal/seed-selection/OpenWalletStep.tsx b/src-gui/src/renderer/components/modal/seed-selection/OpenWalletStep.tsx index de563b2367..34f749da98 100644 --- a/src-gui/src/renderer/components/modal/seed-selection/OpenWalletStep.tsx +++ b/src-gui/src/renderer/components/modal/seed-selection/OpenWalletStep.tsx @@ -34,7 +34,9 @@ export default function OpenWalletStep({ if (event.payload.type === "drop") { setIsDragging(false); const path = event.payload.paths[0]; - if (path) setWalletPath(path); + // 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 { @@ -54,7 +56,7 @@ export default function OpenWalletStep({ const selectWalletFile = async () => { const selected = await open({ multiple: false, directory: false }); - if (selected) setWalletPath(selected); + if (selected) setWalletPath(selected.replace(/\.keys$/, "")); }; return ( diff --git a/src-gui/src/renderer/components/pages/monero/MoneroWalletPage.tsx b/src-gui/src/renderer/components/pages/monero/MoneroWalletPage.tsx index 5d5f23b740..9b1939332b 100644 --- a/src-gui/src/renderer/components/pages/monero/MoneroWalletPage.tsx +++ b/src-gui/src/renderer/components/pages/monero/MoneroWalletPage.tsx @@ -6,7 +6,6 @@ import { WalletOverview, TransactionHistory, WalletActionButtons, - WalletSwitcher, } from "./components"; import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox"; import WalletPageLoadingState from "./components/WalletPageLoadingState"; @@ -36,19 +35,8 @@ export default function MoneroWalletPage() { flexDirection: "column", gap: 2, pb: 2, - position: "relative", }} > - - - (null); - const [recentWallets, setRecentWallets] = useState([]); - // Action the user picked, awaiting confirmation before the app relaunches. - // Kept while the dialog fades out so the copy doesn't flicker; `confirmOpen` - // alone controls visibility. - const [pending, setPending] = useState(null); - const [confirmOpen, setConfirmOpen] = useState(false); - const swapRunning = useIsSwapRunningAndHasFundsLocked(); - - const refreshRecentWallets = () => { - getRecentWallets() - .then(setRecentWallets) - .catch((e) => console.error("Failed to load recent wallets", e)); - }; - - useEffect(refreshRecentWallets, []); - - const currentWallet = recentWallets[0]; - const otherWallets = recentWallets.slice(1); - - const openMenu = (event: React.MouseEvent) => { - refreshRecentWallets(); - setAnchorEl(event.currentTarget); - }; - - const requestConfirmation = (action: PendingAction) => { - setPending(action); - setConfirmOpen(true); - }; - - const confirmPending = async () => { - if (pending === null) return; - await setPendingWallet( - pending.kind === "switch" - ? { type: "Open", content: { wallet_path: pending.path } } - : { type: "ShowChooser" }, - ); - try { - await relaunch(); - } catch (e) { - // Never leave an Open marker behind when the relaunch failed: a later - // manual launch would silently open a wallet the user no longer expects. - await setPendingWallet({ type: "ShowChooser" }).catch(() => {}); - throw e; - } - }; - - const chooseOther = async () => { - const selected = await open({ multiple: false, directory: false }); - if (!selected) return; - // Users commonly pick the `.keys` file; the wallet is the file - // without that extension. - requestConfirmation({ - kind: "switch", - path: selected.replace(/\.keys$/, ""), - }); - }; - - return ( - <> - } - deleteIcon={} - onDelete={openMenu} - onClick={openMenu} - label={currentWallet ? walletFileName(currentWallet) : "Wallet"} - variant="button" - clickable - sx={{ - // Bookmark tab: flush to the top edge, square on top, rounded - // below, with a small downward notch tail. - position: "relative", - height: 36, - maxWidth: 220, - bgcolor: "background.paper", - boxShadow: 3, - borderTopLeftRadius: 0, - borderTopRightRadius: 0, - borderBottomLeftRadius: 12, - borderBottomRightRadius: 12, - "&:hover": { - bgcolor: "background.paper", - boxShadow: 4, - }, - "&::after": { - content: '""', - position: "absolute", - bottom: 0, - left: "50%", - transform: "translate(-50%, 60%)", - borderLeft: "6px solid transparent", - borderRight: "6px solid transparent", - borderTop: "6px solid", - borderTopColor: "background.paper", - }, - }} - /> - setAnchorEl(null)} - > - {otherWallets.map((path) => ( - { - setAnchorEl(null); - requestConfirmation({ kind: "switch", path }); - }} - > - - - - {walletFileName(path)} - - ))} - {otherWallets.length > 0 && } - { - setAnchorEl(null); - chooseOther(); - }} - > - - - - Other wallet… - - { - setAnchorEl(null); - requestConfirmation({ kind: "create" }); - }} - > - - - - Create new wallet… - - - setConfirmOpen(false)} - TransitionProps={{ onExited: () => setPending(null) }} - maxWidth="xs" - fullWidth - > - - {pending?.kind === "create" ? "Create new wallet" : "Switch wallet"} - - - - {pending?.kind === "create" - ? "The app will restart so you can set up a new wallet." - : `The app will restart to open "${ - pending ? walletFileName(pending.path) : "" - }".`}{" "} - {swapRunning - ? "A swap with locked funds is currently running. It will be interrupted, and its Monero will arrive in the wallet it was started with — not the one you are switching to." - : "Any running operations will be interrupted."} - - - - - - {pending?.kind === "create" ? "Restart" : "Restart & switch"} - - - - - ); -} diff --git a/src-gui/src/renderer/components/pages/monero/components/index.ts b/src-gui/src/renderer/components/pages/monero/components/index.ts index badf37ed10..c557d008e9 100644 --- a/src-gui/src/renderer/components/pages/monero/components/index.ts +++ b/src-gui/src/renderer/components/pages/monero/components/index.ts @@ -1,6 +1,5 @@ export { default as WalletOverview } from "./WalletOverview"; export { default as TransactionHistory } from "./TransactionHistory"; export { default as WalletActionButtons } from "./WalletActionButtons"; -export { default as WalletSwitcher } from "./WalletSwitcher"; export { default as SendTransactionContent } from "./SendTransactionContent"; export { default as SendApprovalContent } from "./SendApprovalContent"; diff --git a/src-gui/src/renderer/rpc.ts b/src-gui/src/renderer/rpc.ts index 35604932a7..4d4fb75e06 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -53,10 +53,6 @@ import { MoneroNodeConfig, GetMoneroSeedResponse, GetSeedWordsResponse, - GetRecentWalletsResponse, - PendingWalletAction, - SetPendingWalletArgs, - SetPendingWalletResponse, ContextStatus, GetSwapTimelockArgs, GetSwapTimelockResponse, @@ -615,21 +611,6 @@ export async function getSeedWords(): Promise { return response.words; } -export async function getRecentWallets(): Promise { - const response = - await invokeNoArgs("get_recent_wallets"); - return response.wallets; -} - -export async function setPendingWallet( - action: PendingWalletAction, -): Promise { - await invoke( - "set_pending_wallet", - { action }, - ); -} - // Wallet management functions that handle Redux dispatching export async function initializeMoneroWallet() { try { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c3b5cc8876..5d5bcf3a0e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -12,12 +12,12 @@ use swap::cli::{ GetDataDirArgs, GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, GetMoneroBalanceArgs, GetMoneroHistoryArgs, GetMoneroMainAddressArgs, GetMoneroSeedArgs, GetMoneroSubaddressesArgs, GetMoneroSyncProgressArgs, - GetPendingApprovalsResponse, GetRecentWalletsArgs, GetRestoreHeightArgs, - GetSeedWordsArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, GetSwapTimelockArgs, - MoneroRecoveryArgs, RedactArgs, RefreshP2PArgs, RejectApprovalArgs, - RejectApprovalResponse, ResolveApprovalArgs, ResumeSwapArgs, SendMoneroArgs, - SetMoneroSubaddressLabelArgs, SetMoneroWalletPasswordArgs, SetPendingWalletArgs, - SetRestoreHeightArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs, + GetPendingApprovalsResponse, GetRestoreHeightArgs, GetSeedWordsArgs, GetSwapInfoArgs, + GetSwapInfosAllArgs, GetSwapTimelockArgs, MoneroRecoveryArgs, RedactArgs, + RefreshP2PArgs, RejectApprovalArgs, RejectApprovalResponse, ResolveApprovalArgs, + ResumeSwapArgs, SendMoneroArgs, SetMoneroSubaddressLabelArgs, + SetMoneroWalletPasswordArgs, SetRestoreHeightArgs, SuspendCurrentSwapArgs, + WithdrawBtcArgs, }, tauri_bindings::{ContextStatus, TauriSettings}, }, @@ -69,8 +69,6 @@ macro_rules! generate_command_handlers { get_monero_seed, check_seed, get_seed_words, - get_recent_wallets, - set_pending_wallet, get_pending_approvals, set_monero_restore_height, reject_approval_request, @@ -427,6 +425,4 @@ tauri_command!(create_monero_subaddress, CreateMoneroSubaddressArgs); tauri_command!(set_monero_subaddress_label, SetMoneroSubaddressLabelArgs); tauri_command!(get_monero_seed, GetMoneroSeedArgs, no_args); tauri_command!(get_seed_words, GetSeedWordsArgs, no_args); -tauri_command!(get_recent_wallets, GetRecentWalletsArgs, no_args); -tauri_command!(set_pending_wallet, SetPendingWalletArgs); tauri_command!(refresh_p2p, RefreshP2PArgs, no_args); diff --git a/swap/src/cli/api.rs b/swap/src/cli/api.rs index f95bd73543..33e5667bcb 100644 --- a/swap/src/cli/api.rs +++ b/swap/src/cli/api.rs @@ -2,9 +2,7 @@ pub mod request; pub mod seed_words; pub mod tauri_bindings; -use crate::cli::api::tauri_bindings::{ - ContextStatus, PendingWalletAction, SeedChoice, SeedSelectionDetails, -}; +use crate::cli::api::tauri_bindings::{ContextStatus, SeedChoice, SeedSelectionDetails}; use crate::cli::command::{Bitcoin, Monero}; use crate::common::tor::{bootstrap_tor_client, create_tor_client}; use crate::common::tracing_util::Format; @@ -574,7 +572,7 @@ mod builder { let seed_choice = match tauri_handle { Some(tauri_handle) => Some( - wallet::resolve_startup_seed_choice( + wallet::request_seed_choice( tauri_handle, &wallet_database, eigenwallet_data_dir, @@ -1025,74 +1023,6 @@ mod wallet { .await } - /// Marker file recording the wallet to open after an app relaunch, used by - /// the wallet switcher. - fn pending_wallet_marker_path(eigenwallet_data_dir: &Path) -> PathBuf { - eigenwallet_data_dir.join("pending_wallet") - } - - pub fn write_pending_wallet( - eigenwallet_data_dir: &Path, - action: &PendingWalletAction, - ) -> Result<()> { - let marker = pending_wallet_marker_path(eigenwallet_data_dir); - swap_fs::ensure_directory_exists(&marker) - .context("Failed to create data directory for pending wallet marker")?; - let encoded = serde_json::to_string(action).context("Failed to encode pending wallet")?; - - // Write-then-rename so a crash cannot leave a torn marker behind. - let tmp = marker.with_extension("tmp"); - std::fs::write(&tmp, encoded).context("Failed to write pending wallet marker")?; - std::fs::rename(&tmp, &marker) - .context("Failed to move pending wallet marker into place")?; - Ok(()) - } - - /// Reads and consumes the pending-wallet marker, if any. A marker that - /// cannot be decoded is discarded: it is purely advisory, and failing - /// startup over it would leave the app unusable. - fn take_pending_wallet(eigenwallet_data_dir: &Path) -> Result> { - let marker = pending_wallet_marker_path(eigenwallet_data_dir); - - let encoded = match std::fs::read_to_string(&marker) { - Ok(encoded) => encoded, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(e) => return Err(e).context("Failed to read pending wallet marker"), - }; - std::fs::remove_file(&marker).context("Failed to remove pending wallet marker")?; - - Ok(serde_json::from_str(&encoded) - .inspect_err( - |error| tracing::warn!(%error, "Discarding corrupt pending wallet marker"), - ) - .ok()) - } - - /// On startup, honor a wallet the user pre-selected before relaunching the - /// app. A consumed marker either opens a specific wallet or forces the - /// chooser; with no marker the user is prompted to choose. - pub(super) async fn resolve_startup_seed_choice( - tauri_handle: TauriHandle, - database: &monero_sys::Database, - eigenwallet_data_dir: &Path, - ) -> Result { - match take_pending_wallet(eigenwallet_data_dir)? { - Some(PendingWalletAction::Open { wallet_path }) - if Path::new(&wallet_path).exists() => - { - tracing::info!(%wallet_path, "Opening wallet pre-selected before relaunch"); - Ok(SeedChoice::FromWalletPath { wallet_path }) - } - Some(PendingWalletAction::Open { wallet_path }) => { - tracing::warn!(%wallet_path, "Pending wallet no longer exists, showing chooser"); - request_seed_choice(tauri_handle, database, eigenwallet_data_dir).await - } - Some(PendingWalletAction::ShowChooser) | None => { - request_seed_choice(tauri_handle, database, eigenwallet_data_dir).await - } - } - } - /// Opens or creates a Monero wallet after asking the user via the Tauri UI. /// /// The user can: diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index 9e0d5b648a..831796d18e 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -1,8 +1,8 @@ use super::tauri_bindings::TauriHandle; use crate::cli::api::Context; use crate::cli::api::tauri_bindings::{ - ApprovalRequestType, MoneroNodeConfig, PendingWalletAction, SelectMakerDetails, - SendMoneroDetails, TauriEmitter, TauriSwapProgressEvent, + ApprovalRequestType, MoneroNodeConfig, SelectMakerDetails, SendMoneroDetails, TauriEmitter, + TauriSwapProgressEvent, }; use crate::cli::list_sellers::QuoteWithAddress; use crate::common::{get_logs, redact}; @@ -1870,58 +1870,6 @@ impl Request for GetSeedWordsArgs { } } -// GetRecentWallets: paths of recently opened wallets, shown in the wallet -// switcher dropdown. -#[typeshare] -#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct GetRecentWalletsArgs; - -#[typeshare] -#[derive(Serialize, Deserialize, Debug)] -pub struct GetRecentWalletsResponse { - pub wallets: Vec, -} - -impl Request for GetRecentWalletsArgs { - type Response = GetRecentWalletsResponse; - - async fn request(self, ctx: Arc) -> Result { - let wallet_manager = ctx.try_get_monero_manager().await?; - let wallets = wallet_manager.get_recent_wallets().await?; - - Ok(GetRecentWalletsResponse { wallets }) - } -} - -// SetPendingWallet: records the action to take after the app relaunches, so the -// user can switch wallets. The marker is consumed on the next startup. -#[typeshare] -#[derive(Debug, Serialize, Deserialize)] -pub struct SetPendingWalletArgs { - pub action: PendingWalletAction, -} - -#[typeshare] -#[derive(Serialize, Deserialize, Debug)] -pub struct SetPendingWalletResponse { - pub success: bool, -} - -impl Request for SetPendingWalletArgs { - type Response = SetPendingWalletResponse; - - async fn request(self, ctx: Arc) -> Result { - let config = ctx.try_get_config().await?; - // Same derivation as ContextBuilder::build, so the marker is written - // where the next startup reads it. - let eigenwallet_data_dir = super::eigenwallet_data::new(config.is_testnet)?; - - super::wallet::write_pending_wallet(&eigenwallet_data_dir, &self.action)?; - - Ok(SetPendingWalletResponse { success: true }) - } -} - // New request type for Monero sync progress #[typeshare] #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] diff --git a/swap/src/cli/api/tauri_bindings.rs b/swap/src/cli/api/tauri_bindings.rs index 8b2e4c1bdf..01cbdebac8 100644 --- a/swap/src/cli/api/tauri_bindings.rs +++ b/swap/src/cli/api/tauri_bindings.rs @@ -170,16 +170,6 @@ pub struct SeedSelectionDetails { pub default_wallet_directory: String, } -/// Wallet to open after the app relaunches, recorded so the user can switch -/// wallets. `ShowChooser` forces the setup chooser instead of opening a wallet. -#[typeshare] -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(tag = "type", content = "content")] -pub enum PendingWalletAction { - Open { wallet_path: String }, - ShowChooser, -} - #[typeshare] #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ApprovalRequest { From 99882c577eeca7a129ea51be018431c69be188d5 Mon Sep 17 00:00:00 2001 From: binarybaron Date: Thu, 2 Jul 2026 11:10:37 +0200 Subject: [PATCH 4/7] refactor: remove seed phrase autocompletion Trims the wizard further: the restore step is a plain multiline TextField again (as on master). Removes SeedPhraseInput, the get_seed_words command and request types, and the vendored 1626-word English wordlist (swap/src/cli/api/seed_words.rs). Seed validity is still checked via the existing check_seed command. --- CHANGELOG.md | 2 +- .../modal/seed-selection/SeedPhraseInput.tsx | 143 -- .../seed-selection/SeedSelectionDialog.tsx | 11 +- src-gui/src/renderer/rpc.ts | 6 - src-tauri/src/commands.rs | 4 +- swap/src/cli/api.rs | 1 - swap/src/cli/api/request.rs | 25 - swap/src/cli/api/seed_words.rs | 1631 ----------------- 8 files changed, 10 insertions(+), 1813 deletions(-) delete mode 100644 src-gui/src/renderer/components/modal/seed-selection/SeedPhraseInput.tsx delete mode 100644 swap/src/cli/api/seed_words.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ec5c494fd8..063a79b5c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +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, restoring from a seed offers word autocomplete, and opening a wallet file supports drag & drop. Newly created wallets are named by the user instead of a Unix timestamp. +- 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/renderer/components/modal/seed-selection/SeedPhraseInput.tsx b/src-gui/src/renderer/components/modal/seed-selection/SeedPhraseInput.tsx deleted file mode 100644 index 412384f461..0000000000 --- a/src-gui/src/renderer/components/modal/seed-selection/SeedPhraseInput.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { - Box, - ClickAwayListener, - MenuItem, - MenuList, - Paper, - Popper, - TextField, -} from "@mui/material"; -import { useEffect, useState } from "react"; -import { getSeedWords } from "renderer/rpc"; - -const MAX_SUGGESTIONS = 6; - -// Cached across mounts so the wordlist is only fetched once per session. -let cachedWords: string[] | null = null; - -function currentWordOf(value: string): string { - // The word being typed is the trailing token; a trailing space completes it. - return value.split(/\s+/).pop() ?? ""; -} - -function suggestionsFor(value: string, words: string[]): string[] { - const currentWord = currentWordOf(value); - if (currentWord.length === 0) return []; - - return words - .filter((word) => word.startsWith(currentWord) && word !== currentWord) - .slice(0, MAX_SUGGESTIONS); -} - -export default function SeedPhraseInput({ - value, - onChange, - error, - helperText, -}: { - value: string; - onChange: (value: string) => void; - error: boolean; - helperText: string; -}) { - const [words, setWords] = useState(cachedWords ?? []); - const [highlighted, setHighlighted] = useState(0); - const [dismissed, setDismissed] = useState(false); - const [anchorEl, setAnchorEl] = useState(null); - - useEffect(() => { - if (cachedWords !== null) return; - - getSeedWords() - .then((fetched) => { - cachedWords = fetched; - setWords(fetched); - }) - .catch(() => setWords([])); - }, []); - - const suggestions = suggestionsFor(value, words); - const open = suggestions.length > 0 && !dismissed; - - const completeWith = (word: string) => { - const trimmedEnd = value.replace(/\S*$/, ""); - onChange(`${trimmedEnd}${word} `); - }; - - const handleKeyDown = (event: React.KeyboardEvent) => { - if (!open) return; - - switch (event.key) { - case "ArrowDown": - event.preventDefault(); - setHighlighted((h) => (h + 1) % suggestions.length); - break; - case "ArrowUp": - event.preventDefault(); - setHighlighted( - (h) => (h - 1 + suggestions.length) % suggestions.length, - ); - break; - case "Enter": - case "Tab": - event.preventDefault(); - completeWith(suggestions[highlighted]); - break; - case "Escape": - event.preventDefault(); - setDismissed(true); - break; - } - }; - - return ( - - { - setDismissed(false); - setHighlighted(0); - onChange(e.target.value); - }} - onKeyDown={handleKeyDown} - placeholder="Enter your Monero 25 words seed phrase..." - error={error} - helperText={helperText} - /> - theme.zIndex.modal + 1, - }} - > - setDismissed(true)}> - - - {suggestions.map((word, index) => ( - { - // Keep focus in the textarea so typing continues. - e.preventDefault(); - completeWith(word); - }} - > - {word} - - ))} - - - - - - ); -} 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 80e08a803b..268e9a8755 100644 --- a/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx +++ b/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx @@ -23,7 +23,6 @@ import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import BackupSeedStep from "./BackupSeedStep"; import OpenWalletStep from "./OpenWalletStep"; import NameLocationStep from "./NameLocationStep"; -import SeedPhraseInput from "./SeedPhraseInput"; type WalletMode = "RandomSeed" | "FromSeed" | "FromWalletPath"; @@ -512,9 +511,15 @@ export default function SeedSelectionDialog() { restore. Setting the restore height to around when the wallet was created speeds up syncing. - setField("customSeed")(e.target.value)} + placeholder="Enter your Monero 25 words seed phrase..." error={!isSeedValid && fields.customSeed.length > 0} helperText={ isSeedValid diff --git a/src-gui/src/renderer/rpc.ts b/src-gui/src/renderer/rpc.ts index 4d4fb75e06..e15edb4972 100644 --- a/src-gui/src/renderer/rpc.ts +++ b/src-gui/src/renderer/rpc.ts @@ -52,7 +52,6 @@ import { GetRestoreHeightResponse, MoneroNodeConfig, GetMoneroSeedResponse, - GetSeedWordsResponse, ContextStatus, GetSwapTimelockArgs, GetSwapTimelockResponse, @@ -606,11 +605,6 @@ export async function getMoneroSeedAndRestoreHeight(): Promise< return Promise.all([getMoneroSeed(), getRestoreHeight()]); } -export async function getSeedWords(): Promise { - const response = await invokeNoArgs("get_seed_words"); - return response.words; -} - // Wallet management functions that handle Redux dispatching export async function initializeMoneroWallet() { try { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5d5bcf3a0e..af5f79e835 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -12,7 +12,7 @@ use swap::cli::{ GetDataDirArgs, GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, GetMoneroBalanceArgs, GetMoneroHistoryArgs, GetMoneroMainAddressArgs, GetMoneroSeedArgs, GetMoneroSubaddressesArgs, GetMoneroSyncProgressArgs, - GetPendingApprovalsResponse, GetRestoreHeightArgs, GetSeedWordsArgs, GetSwapInfoArgs, + GetPendingApprovalsResponse, GetRestoreHeightArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, GetSwapTimelockArgs, MoneroRecoveryArgs, RedactArgs, RefreshP2PArgs, RejectApprovalArgs, RejectApprovalResponse, ResolveApprovalArgs, ResumeSwapArgs, SendMoneroArgs, SetMoneroSubaddressLabelArgs, @@ -68,7 +68,6 @@ macro_rules! generate_command_handlers { get_monero_sync_progress, get_monero_seed, check_seed, - get_seed_words, get_pending_approvals, set_monero_restore_height, reject_approval_request, @@ -424,5 +423,4 @@ tauri_command!(get_monero_subaddresses, GetMoneroSubaddressesArgs); tauri_command!(create_monero_subaddress, CreateMoneroSubaddressArgs); tauri_command!(set_monero_subaddress_label, SetMoneroSubaddressLabelArgs); tauri_command!(get_monero_seed, GetMoneroSeedArgs, no_args); -tauri_command!(get_seed_words, GetSeedWordsArgs, no_args); tauri_command!(refresh_p2p, RefreshP2PArgs, no_args); diff --git a/swap/src/cli/api.rs b/swap/src/cli/api.rs index 33e5667bcb..dbd2cb7a16 100644 --- a/swap/src/cli/api.rs +++ b/swap/src/cli/api.rs @@ -1,5 +1,4 @@ pub mod request; -pub mod seed_words; pub mod tauri_bindings; use crate::cli::api::tauri_bindings::{ContextStatus, SeedChoice, SeedSelectionDetails}; diff --git a/swap/src/cli/api/request.rs b/swap/src/cli/api/request.rs index 831796d18e..91007c66a4 100644 --- a/swap/src/cli/api/request.rs +++ b/swap/src/cli/api/request.rs @@ -1845,31 +1845,6 @@ impl CheckSeedArgs { } } -// GetSeedWords: the Monero English mnemonic wordlist, used by the GUI for -// seed-word autocomplete. -#[typeshare] -#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct GetSeedWordsArgs; - -#[typeshare] -#[derive(Serialize, Deserialize, Debug)] -pub struct GetSeedWordsResponse { - pub words: Vec, -} - -impl Request for GetSeedWordsArgs { - type Response = GetSeedWordsResponse; - - async fn request(self, _: Arc) -> Result { - Ok(GetSeedWordsResponse { - words: crate::cli::api::seed_words::ENGLISH_SEED_WORDS - .iter() - .map(|word| word.to_string()) - .collect(), - }) - } -} - // New request type for Monero sync progress #[typeshare] #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] diff --git a/swap/src/cli/api/seed_words.rs b/swap/src/cli/api/seed_words.rs deleted file mode 100644 index c2412197a3..0000000000 --- a/swap/src/cli/api/seed_words.rs +++ /dev/null @@ -1,1631 +0,0 @@ -// Vendored from monero-seed (monero-oxide/monero-wallet-util) seed/src/words/en.rs. -// The crate keeps its wordlist private, so we copy the English list to serve it -// to the GUI for seed-word autocomplete. -pub static ENGLISH_SEED_WORDS: &[&str] = &[ - "abbey", - "abducts", - "ability", - "ablaze", - "abnormal", - "abort", - "abrasive", - "absorb", - "abyss", - "academy", - "aces", - "aching", - "acidic", - "acoustic", - "acquire", - "across", - "actress", - "acumen", - "adapt", - "addicted", - "adept", - "adhesive", - "adjust", - "adopt", - "adrenalin", - "adult", - "adventure", - "aerial", - "afar", - "affair", - "afield", - "afloat", - "afoot", - "afraid", - "after", - "against", - "agenda", - "aggravate", - "agile", - "aglow", - "agnostic", - "agony", - "agreed", - "ahead", - "aided", - "ailments", - "aimless", - "airport", - "aisle", - "ajar", - "akin", - "alarms", - "album", - "alchemy", - "alerts", - "algebra", - "alkaline", - "alley", - "almost", - "aloof", - "alpine", - "already", - "also", - "altitude", - "alumni", - "always", - "amaze", - "ambush", - "amended", - "amidst", - "ammo", - "amnesty", - "among", - "amply", - "amused", - "anchor", - "android", - "anecdote", - "angled", - "ankle", - "annoyed", - "answers", - "antics", - "anvil", - "anxiety", - "anybody", - "apart", - "apex", - "aphid", - "aplomb", - "apology", - "apply", - "apricot", - "aptitude", - "aquarium", - "arbitrary", - "archer", - "ardent", - "arena", - "argue", - "arises", - "army", - "around", - "arrow", - "arsenic", - "artistic", - "ascend", - "ashtray", - "aside", - "asked", - "asleep", - "aspire", - "assorted", - "asylum", - "athlete", - "atlas", - "atom", - "atrium", - "attire", - "auburn", - "auctions", - "audio", - "august", - "aunt", - "austere", - "autumn", - "avatar", - "avidly", - "avoid", - "awakened", - "awesome", - "awful", - "awkward", - "awning", - "awoken", - "axes", - "axis", - "axle", - "aztec", - "azure", - "baby", - "bacon", - "badge", - "baffles", - "bagpipe", - "bailed", - "bakery", - "balding", - "bamboo", - "banjo", - "baptism", - "basin", - "batch", - "bawled", - "bays", - "because", - "beer", - "befit", - "begun", - "behind", - "being", - "below", - "bemused", - "benches", - "berries", - "bested", - "betting", - "bevel", - "beware", - "beyond", - "bias", - "bicycle", - "bids", - "bifocals", - "biggest", - "bikini", - "bimonthly", - "binocular", - "biology", - "biplane", - "birth", - "biscuit", - "bite", - "biweekly", - "blender", - "blip", - "bluntly", - "boat", - "bobsled", - "bodies", - "bogeys", - "boil", - "boldly", - "bomb", - "border", - "boss", - "both", - "bounced", - "bovine", - "bowling", - "boxes", - "boyfriend", - "broken", - "brunt", - "bubble", - "buckets", - "budget", - "buffet", - "bugs", - "building", - "bulb", - "bumper", - "bunch", - "business", - "butter", - "buying", - "buzzer", - "bygones", - "byline", - "bypass", - "cabin", - "cactus", - "cadets", - "cafe", - "cage", - "cajun", - "cake", - "calamity", - "camp", - "candy", - "casket", - "catch", - "cause", - "cavernous", - "cease", - "cedar", - "ceiling", - "cell", - "cement", - "cent", - "certain", - "chlorine", - "chrome", - "cider", - "cigar", - "cinema", - "circle", - "cistern", - "citadel", - "civilian", - "claim", - "click", - "clue", - "coal", - "cobra", - "cocoa", - "code", - "coexist", - "coffee", - "cogs", - "cohesive", - "coils", - "colony", - "comb", - "cool", - "copy", - "corrode", - "costume", - "cottage", - "cousin", - "cowl", - "criminal", - "cube", - "cucumber", - "cuddled", - "cuffs", - "cuisine", - "cunning", - "cupcake", - "custom", - "cycling", - "cylinder", - "cynical", - "dabbing", - "dads", - "daft", - "dagger", - "daily", - "damp", - "dangerous", - "dapper", - "darted", - "dash", - "dating", - "dauntless", - "dawn", - "daytime", - "dazed", - "debut", - "decay", - "dedicated", - "deepest", - "deftly", - "degrees", - "dehydrate", - "deity", - "dejected", - "delayed", - "demonstrate", - "dented", - "deodorant", - "depth", - "desk", - "devoid", - "dewdrop", - "dexterity", - "dialect", - "dice", - "diet", - "different", - "digit", - "dilute", - "dime", - "dinner", - "diode", - "diplomat", - "directed", - "distance", - "ditch", - "divers", - "dizzy", - "doctor", - "dodge", - "does", - "dogs", - "doing", - "dolphin", - "domestic", - "donuts", - "doorway", - "dormant", - "dosage", - "dotted", - "double", - "dove", - "down", - "dozen", - "dreams", - "drinks", - "drowning", - "drunk", - "drying", - "dual", - "dubbed", - "duckling", - "dude", - "duets", - "duke", - "dullness", - "dummy", - "dunes", - "duplex", - "duration", - "dusted", - "duties", - "dwarf", - "dwelt", - "dwindling", - "dying", - "dynamite", - "dyslexic", - "each", - "eagle", - "earth", - "easy", - "eating", - "eavesdrop", - "eccentric", - "echo", - "eclipse", - "economics", - "ecstatic", - "eden", - "edgy", - "edited", - "educated", - "eels", - "efficient", - "eggs", - "egotistic", - "eight", - "either", - "eject", - "elapse", - "elbow", - "eldest", - "eleven", - "elite", - "elope", - "else", - "eluded", - "emails", - "ember", - "emerge", - "emit", - "emotion", - "empty", - "emulate", - "energy", - "enforce", - "enhanced", - "enigma", - "enjoy", - "enlist", - "enmity", - "enough", - "enraged", - "ensign", - "entrance", - "envy", - "epoxy", - "equip", - "erase", - "erected", - "erosion", - "error", - "eskimos", - "espionage", - "essential", - "estate", - "etched", - "eternal", - "ethics", - "etiquette", - "evaluate", - "evenings", - "evicted", - "evolved", - "examine", - "excess", - "exhale", - "exit", - "exotic", - "exquisite", - "extra", - "exult", - "fabrics", - "factual", - "fading", - "fainted", - "faked", - "fall", - "family", - "fancy", - "farming", - "fatal", - "faulty", - "fawns", - "faxed", - "fazed", - "feast", - "february", - "federal", - "feel", - "feline", - "females", - "fences", - "ferry", - "festival", - "fetches", - "fever", - "fewest", - "fiat", - "fibula", - "fictional", - "fidget", - "fierce", - "fifteen", - "fight", - "films", - "firm", - "fishing", - "fitting", - "five", - "fixate", - "fizzle", - "fleet", - "flippant", - "flying", - "foamy", - "focus", - "foes", - "foggy", - "foiled", - "folding", - "fonts", - "foolish", - "fossil", - "fountain", - "fowls", - "foxes", - "foyer", - "framed", - "friendly", - "frown", - "fruit", - "frying", - "fudge", - "fuel", - "fugitive", - "fully", - "fuming", - "fungal", - "furnished", - "fuselage", - "future", - "fuzzy", - "gables", - "gadget", - "gags", - "gained", - "galaxy", - "gambit", - "gang", - "gasp", - "gather", - "gauze", - "gave", - "gawk", - "gaze", - "gearbox", - "gecko", - "geek", - "gels", - "gemstone", - "general", - "geometry", - "germs", - "gesture", - "getting", - "geyser", - "ghetto", - "ghost", - "giant", - "giddy", - "gifts", - "gigantic", - "gills", - "gimmick", - "ginger", - "girth", - "giving", - "glass", - "gleeful", - "glide", - "gnaw", - "gnome", - "goat", - "goblet", - "godfather", - "goes", - "goggles", - "going", - "goldfish", - "gone", - "goodbye", - "gopher", - "gorilla", - "gossip", - "gotten", - "gourmet", - "governing", - "gown", - "greater", - "grunt", - "guarded", - "guest", - "guide", - "gulp", - "gumball", - "guru", - "gusts", - "gutter", - "guys", - "gymnast", - "gypsy", - "gyrate", - "habitat", - "hacksaw", - "haggled", - "hairy", - "hamburger", - "happens", - "hashing", - "hatchet", - "haunted", - "having", - "hawk", - "haystack", - "hazard", - "hectare", - "hedgehog", - "heels", - "hefty", - "height", - "hemlock", - "hence", - "heron", - "hesitate", - "hexagon", - "hickory", - "hiding", - "highway", - "hijack", - "hiker", - "hills", - "himself", - "hinder", - "hippo", - "hire", - "history", - "hitched", - "hive", - "hoax", - "hobby", - "hockey", - "hoisting", - "hold", - "honked", - "hookup", - "hope", - "hornet", - "hospital", - "hotel", - "hounded", - "hover", - "howls", - "hubcaps", - "huddle", - "huge", - "hull", - "humid", - "hunter", - "hurried", - "husband", - "huts", - "hybrid", - "hydrogen", - "hyper", - "iceberg", - "icing", - "icon", - "identity", - "idiom", - "idled", - "idols", - "igloo", - "ignore", - "iguana", - "illness", - "imagine", - "imbalance", - "imitate", - "impel", - "inactive", - "inbound", - "incur", - "industrial", - "inexact", - "inflamed", - "ingested", - "initiate", - "injury", - "inkling", - "inline", - "inmate", - "innocent", - "inorganic", - "input", - "inquest", - "inroads", - "insult", - "intended", - "inundate", - "invoke", - "inwardly", - "ionic", - "irate", - "iris", - "irony", - "irritate", - "island", - "isolated", - "issued", - "italics", - "itches", - "items", - "itinerary", - "itself", - "ivory", - "jabbed", - "jackets", - "jaded", - "jagged", - "jailed", - "jamming", - "january", - "jargon", - "jaunt", - "javelin", - "jaws", - "jazz", - "jeans", - "jeers", - "jellyfish", - "jeopardy", - "jerseys", - "jester", - "jetting", - "jewels", - "jigsaw", - "jingle", - "jittery", - "jive", - "jobs", - "jockey", - "jogger", - "joining", - "joking", - "jolted", - "jostle", - "journal", - "joyous", - "jubilee", - "judge", - "juggled", - "juicy", - "jukebox", - "july", - "jump", - "junk", - "jury", - "justice", - "juvenile", - "kangaroo", - "karate", - "keep", - "kennel", - "kept", - "kernels", - "kettle", - "keyboard", - "kickoff", - "kidneys", - "king", - "kiosk", - "kisses", - "kitchens", - "kiwi", - "knapsack", - "knee", - "knife", - "knowledge", - "knuckle", - "koala", - "laboratory", - "ladder", - "lagoon", - "lair", - "lakes", - "lamb", - "language", - "laptop", - "large", - "last", - "later", - "launching", - "lava", - "lawsuit", - "layout", - "lazy", - "lectures", - "ledge", - "leech", - "left", - "legion", - "leisure", - "lemon", - "lending", - "leopard", - "lesson", - "lettuce", - "lexicon", - "liar", - "library", - "licks", - "lids", - "lied", - "lifestyle", - "light", - "likewise", - "lilac", - "limits", - "linen", - "lion", - "lipstick", - "liquid", - "listen", - "lively", - "loaded", - "lobster", - "locker", - "lodge", - "lofty", - "logic", - "loincloth", - "long", - "looking", - "lopped", - "lordship", - "losing", - "lottery", - "loudly", - "love", - "lower", - "loyal", - "lucky", - "luggage", - "lukewarm", - "lullaby", - "lumber", - "lunar", - "lurk", - "lush", - "luxury", - "lymph", - "lynx", - "lyrics", - "macro", - "madness", - "magically", - "mailed", - "major", - "makeup", - "malady", - "mammal", - "maps", - "masterful", - "match", - "maul", - "maverick", - "maximum", - "mayor", - "maze", - "meant", - "mechanic", - "medicate", - "meeting", - "megabyte", - "melting", - "memoir", - "menu", - "merger", - "mesh", - "metro", - "mews", - "mice", - "midst", - "mighty", - "mime", - "mirror", - "misery", - "mittens", - "mixture", - "moat", - "mobile", - "mocked", - "mohawk", - "moisture", - "molten", - "moment", - "money", - "moon", - "mops", - "morsel", - "mostly", - "motherly", - "mouth", - "movement", - "mowing", - "much", - "muddy", - "muffin", - "mugged", - "mullet", - "mumble", - "mundane", - "muppet", - "mural", - "musical", - "muzzle", - "myriad", - "mystery", - "myth", - "nabbing", - "nagged", - "nail", - "names", - "nanny", - "napkin", - "narrate", - "nasty", - "natural", - "nautical", - "navy", - "nearby", - "necklace", - "needed", - "negative", - "neither", - "neon", - "nephew", - "nerves", - "nestle", - "network", - "neutral", - "never", - "newt", - "nexus", - "nibs", - "niche", - "niece", - "nifty", - "nightly", - "nimbly", - "nineteen", - "nirvana", - "nitrogen", - "nobody", - "nocturnal", - "nodes", - "noises", - "nomad", - "noodles", - "northern", - "nostril", - "noted", - "nouns", - "novelty", - "nowhere", - "nozzle", - "nuance", - "nucleus", - "nudged", - "nugget", - "nuisance", - "null", - "number", - "nuns", - "nurse", - "nutshell", - "nylon", - "oaks", - "oars", - "oasis", - "oatmeal", - "obedient", - "object", - "obliged", - "obnoxious", - "observant", - "obtains", - "obvious", - "occur", - "ocean", - "october", - "odds", - "odometer", - "offend", - "often", - "oilfield", - "ointment", - "okay", - "older", - "olive", - "olympics", - "omega", - "omission", - "omnibus", - "onboard", - "oncoming", - "oneself", - "ongoing", - "onion", - "online", - "onslaught", - "onto", - "onward", - "oozed", - "opacity", - "opened", - "opposite", - "optical", - "opus", - "orange", - "orbit", - "orchid", - "orders", - "organs", - "origin", - "ornament", - "orphans", - "oscar", - "ostrich", - "otherwise", - "otter", - "ouch", - "ought", - "ounce", - "ourselves", - "oust", - "outbreak", - "oval", - "oven", - "owed", - "owls", - "owner", - "oxidant", - "oxygen", - "oyster", - "ozone", - "pact", - "paddles", - "pager", - "pairing", - "palace", - "pamphlet", - "pancakes", - "paper", - "paradise", - "pastry", - "patio", - "pause", - "pavements", - "pawnshop", - "payment", - "peaches", - "pebbles", - "peculiar", - "pedantic", - "peeled", - "pegs", - "pelican", - "pencil", - "people", - "pepper", - "perfect", - "pests", - "petals", - "phase", - "pheasants", - "phone", - "phrases", - "physics", - "piano", - "picked", - "pierce", - "pigment", - "piloted", - "pimple", - "pinched", - "pioneer", - "pipeline", - "pirate", - "pistons", - "pitched", - "pivot", - "pixels", - "pizza", - "playful", - "pledge", - "pliers", - "plotting", - "plus", - "plywood", - "poaching", - "pockets", - "podcast", - "poetry", - "point", - "poker", - "polar", - "ponies", - "pool", - "popular", - "portents", - "possible", - "potato", - "pouch", - "poverty", - "powder", - "pram", - "present", - "pride", - "problems", - "pruned", - "prying", - "psychic", - "public", - "puck", - "puddle", - "puffin", - "pulp", - "pumpkins", - "punch", - "puppy", - "purged", - "push", - "putty", - "puzzled", - "pylons", - "pyramid", - "python", - "queen", - "quick", - "quote", - "rabbits", - "racetrack", - "radar", - "rafts", - "rage", - "railway", - "raking", - "rally", - "ramped", - "randomly", - "rapid", - "rarest", - "rash", - "rated", - "ravine", - "rays", - "razor", - "react", - "rebel", - "recipe", - "reduce", - "reef", - "refer", - "regular", - "reheat", - "reinvest", - "rejoices", - "rekindle", - "relic", - "remedy", - "renting", - "reorder", - "repent", - "request", - "reruns", - "rest", - "return", - "reunion", - "revamp", - "rewind", - "rhino", - "rhythm", - "ribbon", - "richly", - "ridges", - "rift", - "rigid", - "rims", - "ringing", - "riots", - "ripped", - "rising", - "ritual", - "river", - "roared", - "robot", - "rockets", - "rodent", - "rogue", - "roles", - "romance", - "roomy", - "roped", - "roster", - "rotate", - "rounded", - "rover", - "rowboat", - "royal", - "ruby", - "rudely", - "ruffled", - "rugged", - "ruined", - "ruling", - "rumble", - "runway", - "rural", - "rustled", - "ruthless", - "sabotage", - "sack", - "sadness", - "safety", - "saga", - "sailor", - "sake", - "salads", - "sample", - "sanity", - "sapling", - "sarcasm", - "sash", - "satin", - "saucepan", - "saved", - "sawmill", - "saxophone", - "sayings", - "scamper", - "scenic", - "school", - "science", - "scoop", - "scrub", - "scuba", - "seasons", - "second", - "sedan", - "seeded", - "segments", - "seismic", - "selfish", - "semifinal", - "sensible", - "september", - "sequence", - "serving", - "session", - "setup", - "seventh", - "sewage", - "shackles", - "shelter", - "shipped", - "shocking", - "shrugged", - "shuffled", - "shyness", - "siblings", - "sickness", - "sidekick", - "sieve", - "sifting", - "sighting", - "silk", - "simplest", - "sincerely", - "sipped", - "siren", - "situated", - "sixteen", - "sizes", - "skater", - "skew", - "skirting", - "skulls", - "skydive", - "slackens", - "sleepless", - "slid", - "slower", - "slug", - "smash", - "smelting", - "smidgen", - "smog", - "smuggled", - "snake", - "sneeze", - "sniff", - "snout", - "snug", - "soapy", - "sober", - "soccer", - "soda", - "software", - "soggy", - "soil", - "solved", - "somewhere", - "sonic", - "soothe", - "soprano", - "sorry", - "southern", - "sovereign", - "sowed", - "soya", - "space", - "speedy", - "sphere", - "spiders", - "splendid", - "spout", - "sprig", - "spud", - "spying", - "square", - "stacking", - "stellar", - "stick", - "stockpile", - "strained", - "stunning", - "stylishly", - "subtly", - "succeed", - "suddenly", - "suede", - "suffice", - "sugar", - "suitcase", - "sulking", - "summon", - "sunken", - "superior", - "surfer", - "sushi", - "suture", - "swagger", - "swept", - "swiftly", - "sword", - "swung", - "syllabus", - "symptoms", - "syndrome", - "syringe", - "system", - "taboo", - "tacit", - "tadpoles", - "tagged", - "tail", - "taken", - "talent", - "tamper", - "tanks", - "tapestry", - "tarnished", - "tasked", - "tattoo", - "taunts", - "tavern", - "tawny", - "taxi", - "teardrop", - "technical", - "tedious", - "teeming", - "tell", - "template", - "tender", - "tepid", - "tequila", - "terminal", - "testing", - "tether", - "textbook", - "thaw", - "theatrics", - "thirsty", - "thorn", - "threaten", - "thumbs", - "thwart", - "ticket", - "tidy", - "tiers", - "tiger", - "tilt", - "timber", - "tinted", - "tipsy", - "tirade", - "tissue", - "titans", - "toaster", - "tobacco", - "today", - "toenail", - "toffee", - "together", - "toilet", - "token", - "tolerant", - "tomorrow", - "tonic", - "toolbox", - "topic", - "torch", - "tossed", - "total", - "touchy", - "towel", - "toxic", - "toyed", - "trash", - "trendy", - "tribal", - "trolling", - "truth", - "trying", - "tsunami", - "tubes", - "tucks", - "tudor", - "tuesday", - "tufts", - "tugs", - "tuition", - "tulips", - "tumbling", - "tunnel", - "turnip", - "tusks", - "tutor", - "tuxedo", - "twang", - "tweezers", - "twice", - "twofold", - "tycoon", - "typist", - "tyrant", - "ugly", - "ulcers", - "ultimate", - "umbrella", - "umpire", - "unafraid", - "unbending", - "uncle", - "under", - "uneven", - "unfit", - "ungainly", - "unhappy", - "union", - "unjustly", - "unknown", - "unlikely", - "unmask", - "unnoticed", - "unopened", - "unplugs", - "unquoted", - "unrest", - "unsafe", - "until", - "unusual", - "unveil", - "unwind", - "unzip", - "upbeat", - "upcoming", - "update", - "upgrade", - "uphill", - "upkeep", - "upload", - "upon", - "upper", - "upright", - "upstairs", - "uptight", - "upwards", - "urban", - "urchins", - "urgent", - "usage", - "useful", - "usher", - "using", - "usual", - "utensils", - "utility", - "utmost", - "utopia", - "uttered", - "vacation", - "vague", - "vain", - "value", - "vampire", - "vane", - "vapidly", - "vary", - "vastness", - "vats", - "vaults", - "vector", - "veered", - "vegan", - "vehicle", - "vein", - "velvet", - "venomous", - "verification", - "vessel", - "veteran", - "vexed", - "vials", - "vibrate", - "victim", - "video", - "viewpoint", - "vigilant", - "viking", - "village", - "vinegar", - "violin", - "vipers", - "virtual", - "visited", - "vitals", - "vivid", - "vixen", - "vocal", - "vogue", - "voice", - "volcano", - "vortex", - "voted", - "voucher", - "vowels", - "voyage", - "vulture", - "wade", - "waffle", - "wagtail", - "waist", - "waking", - "wallets", - "wanted", - "warped", - "washing", - "water", - "waveform", - "waxing", - "wayside", - "weavers", - "website", - "wedge", - "weekday", - "weird", - "welders", - "went", - "wept", - "were", - "western", - "wetsuit", - "whale", - "when", - "whipped", - "whole", - "wickets", - "width", - "wield", - "wife", - "wiggle", - "wildly", - "winter", - "wipeout", - "wiring", - "wise", - "withdrawn", - "wives", - "wizard", - "wobbly", - "woes", - "woken", - "wolf", - "womanly", - "wonders", - "woozy", - "worry", - "wounded", - "woven", - "wrap", - "wrist", - "wrong", - "yacht", - "yahoo", - "yanks", - "yard", - "yawning", - "yearbook", - "yellow", - "yesterday", - "yeti", - "yields", - "yodel", - "yoga", - "younger", - "yoyo", - "zapped", - "zeal", - "zebra", - "zero", - "zesty", - "zigzags", - "zinger", - "zippers", - "zodiac", - "zombie", - "zones", - "zoom", -]; From 3100afd6b8efa3af51d6b6d8dbe54ae6a217797e Mon Sep 17 00:00:00 2001 From: binarybaron Date: Thu, 2 Jul 2026 11:32:11 +0200 Subject: [PATCH 5/7] refactor(swap): drive wallet setup with a Rust state machine The init flow now lives in swap/src/cli/api/wallet_setup.rs as an explicit state machine: ChooseSeed -> OpenWallet -> BackupSeed -> Finish Every state that needs user input blocks on a Tauri approval; failures and password rejections fall back to ChooseSeed. New SeedBackup approval (ApprovalRequestType::SeedBackup) carries the freshly created wallet's seed + restore height from Rust, so the GUI no longer polls moneroWalletAvailable and re-fetches the seed with retries. Frontend consequences: - BackupSeedStep is purely presentational (props in, checkbox out); the retry loop, failure state, and availability gating are gone. - The dialog renders whichever backend request is pending (SeedBackup -> backup step, SeedSelection -> wizard) plus a local spinner while the wallet is being created; its reducer shrinks to pure wizard navigation (mode/step/fields) - backingUp/finished phases, rollback resume state, and creation events are deleted. - useIsMoneroWalletAvailable removed (last consumer gone). Same UI look throughout: dialog shell, breadcrumbs, steps unchanged. The legacy-wallet helpers and their tests move along into wallet_setup.rs; mod wallet in api.rs keeps only init_bitcoin_wallet. --- src-gui/src/models/tauriModelExt.ts | 15 + .../modal/seed-selection/BackupSeedStep.tsx | 90 +--- .../seed-selection/SeedSelectionDialog.tsx | 188 ++++---- src-gui/src/store/hooks.ts | 17 +- swap/src/cli/api.rs | 396 +--------------- swap/src/cli/api/tauri_bindings.rs | 28 ++ swap/src/cli/api/wallet_setup.rs | 428 ++++++++++++++++++ 7 files changed, 587 insertions(+), 575 deletions(-) create mode 100644 swap/src/cli/api/wallet_setup.rs 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 index 9605d0d065..da4b74832c 100644 --- a/src-gui/src/renderer/components/modal/seed-selection/BackupSeedStep.tsx +++ b/src-gui/src/renderer/components/modal/seed-selection/BackupSeedStep.tsx @@ -1,91 +1,21 @@ -import { - Alert, - Box, - Checkbox, - FormControlLabel, - Typography, -} from "@mui/material"; -import { useEffect, useState } from "react"; -import { - GetMoneroSeedResponse, - GetRestoreHeightResponse, -} from "models/tauriModel"; -import { getMoneroSeedAndRestoreHeight } from "renderer/rpc"; -import { useIsMoneroWalletAvailable } from "store/hooks"; +import { Box, Checkbox, FormControlLabel, Typography } from "@mui/material"; import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox"; import { PrivateKeyScamAlert } from "renderer/components/other/PrivateKeyWarning"; -import CircularProgressWithSubtitle from "renderer/components/pages/swap/swap/components/CircularProgressWithSubtitle"; -const SEED_FETCH_RETRY_INTERVAL_MS = 1000; -const SEED_FETCH_MAX_ATTEMPTS = 5; - -/// Shown after a fresh wallet has been created so the user records their seed -/// before continuing. The seed only becomes readable once the newly created -/// Monero wallet has finished opening, so we wait for it before fetching. +/// 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; }) { - const moneroWalletAvailable = useIsMoneroWalletAvailable(); - const [seed, setSeed] = useState< - [GetMoneroSeedResponse, GetRestoreHeightResponse] | null - >(null); - const [failed, setFailed] = useState(false); - - useEffect(() => { - if (!moneroWalletAvailable || seed !== null) { - return; - } - - let cancelled = false; - let timeout: ReturnType | undefined; - let attempt = 0; - const fetchSeed = () => { - getMoneroSeedAndRestoreHeight() - .then((result) => { - if (!cancelled) setSeed(result); - }) - .catch((e) => { - if (cancelled) return; - attempt += 1; - if (attempt >= SEED_FETCH_MAX_ATTEMPTS) { - console.error("Failed to read wallet seed for backup", e); - setFailed(true); - // The wallet itself was created; never trap the user in the - // dialog over a display failure. - onConfirmedChange(true); - return; - } - timeout = setTimeout(fetchSeed, SEED_FETCH_RETRY_INTERVAL_MS); - }); - }; - fetchSeed(); - - return () => { - cancelled = true; - clearTimeout(timeout); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- refetch only when the wallet becomes readable - }, [moneroWalletAvailable, seed]); - - if (failed) { - return ( - - Could not read the wallet seed to back up. You can view it later from - the wallet menu. - - ); - } - - if (seed === null) { - return ( - - ); - } - return ( @@ -94,13 +24,13 @@ export default function BackupSeedStep({ recover this wallet if you lose access to this device. 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 268e9a8755..822e74b475 100644 --- a/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx +++ b/src-gui/src/renderer/components/modal/seed-selection/SeedSelectionDialog.tsx @@ -13,7 +13,10 @@ import { } from "@mui/material"; import NewPasswordInput from "renderer/components/other/NewPasswordInput"; import { useState, useEffect, useReducer, useRef } from "react"; -import { usePendingSeedSelectionApproval } from "store/hooks"; +import { + usePendingSeedSelectionApproval, + usePendingSeedBackupApproval, +} from "store/hooks"; import { resolveApproval, checkSeed } from "renderer/rpc"; import { SeedChoice } from "models/tauriModel"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; @@ -23,7 +26,15 @@ import FolderOpenIcon from "@mui/icons-material/FolderOpen"; 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 = @@ -102,38 +113,24 @@ const EMPTY_FIELDS: FormFields = { directory: "", }; -interface EditingState { - phase: "editing"; +// 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; } -// The wizard is a state machine. `editing` navigates the FLOWS table of the -// selected mode and owns all form input, so a reset cannot leak fields from a -// previous request. `backingUp` is entered once a new wallet has been created: -// the approval is already resolved, but the dialog stays up until the user has -// recorded the seed; it carries the editing state to resume on failure. -// `finished` unmounts the dialog. -type WizardState = - | EditingState - | { phase: "backingUp"; confirmed: boolean; resume: EditingState } - | { phase: "finished" }; - 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 } - | { type: "walletCreationStarted" } - | { type: "walletCreationFailed" } - | { type: "confirmBackup"; confirmed: boolean } - | { type: "finish" }; + | { type: "navigateBackTo"; step: StepId }; const INITIAL_WIZARD: WizardState = { - phase: "editing", mode: "RandomSeed", step: "chooseMode", fields: EMPTY_FIELDS, @@ -142,37 +139,16 @@ const INITIAL_WIZARD: WizardState = { // 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 { - phase: "editing", mode: event.mode, step: "chooseMode", fields: { ...EMPTY_FIELDS, ...event.fields }, }; - case "walletCreationStarted": - // Creation is only offered on the RandomSeed name & location step. - return state.phase === "editing" && - state.mode === "RandomSeed" && - state.step === "nameLocation" - ? { phase: "backingUp", confirmed: false, resume: state } - : state; - case "walletCreationFailed": - return state.phase === "backingUp" ? state.resume : state; - case "confirmBackup": - return state.phase === "backingUp" - ? { ...state, confirmed: event.confirmed } - : state; - case "finish": - return state.phase === "backingUp" ? { phase: "finished" } : state; - } - - // The remaining events edit or navigate within the editing phase. - if (state.phase !== "editing") return state; - const flow = FLOWS[state.mode]; - const index = stepIndex(state.mode, state.step); - - switch (event.type) { case "setField": return { ...state, @@ -232,9 +208,14 @@ function parseBlockHeightInput(blockheightInput: string): number | false { } export default function SeedSelectionDialog() { - const pendingApprovals = usePendingSeedSelectionApproval(); + const selectionApproval = usePendingSeedSelectionApproval()[0]; + const backupApproval = usePendingSeedBackupApproval()[0]; const [wizard, dispatch] = useReducer(wizardReducer, INITIAL_WIZARD); const [isPasswordValid, setIsPasswordValid] = useState(true); + 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<{ @@ -242,19 +223,22 @@ export default function SeedSelectionDialog() { valid: boolean; } | null>(null); - const approval = pendingApprovals[0]; const content = - approval?.request?.type === "SeedSelection" - ? approval.request.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). + // 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 = approval?.request_id; + const requestId = selectionApproval?.request_id; if (!requestId || requestId === lastRequestIdRef.current) return; lastRequestIdRef.current = requestId; @@ -268,21 +252,21 @@ export default function SeedSelectionDialog() { }, }); setSeedValidation(null); + setWaitingForWallet(false); // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on a new approval id - }, [approval?.request_id]); - - // While backing up, the fields of the editing state that created the wallet - // are still needed for a rollback on failure. - const editing = - wizard.phase === "editing" - ? wizard - : wizard.phase === "backingUp" - ? wizard.resume - : null; + }, [selectionApproval?.request_id]); - const trimmedSeed = editing?.fields.customSeed.trim() ?? ""; + // A backup request means the wallet was created: ask for a fresh + // confirmation. + useEffect(() => { + if (!backupApproval) return; + setWaitingForWallet(false); + setBackupConfirmed(false); + }, [backupApproval?.request_id, backupApproval]); + + const trimmedSeed = wizard.fields.customSeed.trim(); const needsSeedValidation = - editing?.mode === "FromSeed" && trimmedSeed.length > 0; + wizard.mode === "FromSeed" && trimmedSeed.length > 0; useEffect(() => { if (!needsSeedValidation) return; @@ -292,14 +276,23 @@ export default function SeedSelectionDialog() { .catch(() => setSeedValidation({ forSeed: trimmedSeed, valid: false })); }, [trimmedSeed, needsSeedValidation]); - if (wizard.phase === "finished" || editing === null) return null; - if (wizard.phase === "editing" && !approval) return null; + // 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; - const { mode, fields } = editing; - const step: StepId = - wizard.phase === "backingUp" ? "backupSeed" : wizard.step; + // 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 }); @@ -323,7 +316,7 @@ export default function SeedSelectionDialog() { nameLocation: fields.name.trim().length > 0 && fields.directory.trim().length > 0, openFile: fields.walletPath.length > 0, - backupSeed: wizard.phase === "backingUp" && wizard.confirmed, + backupSeed: view === "backup" && backupConfirmed, }; const buildSeedChoice = (chosenMode: WalletMode): SeedChoice => { @@ -363,32 +356,38 @@ export default function SeedSelectionDialog() { } }; - const resolve = async (seedChoice: SeedChoice) => { - if (!approval) + const resolveSelection = async (seedChoice: SeedChoice) => { + if (!selectionApproval) throw new Error("No approval request found for seed selection"); - await resolveApproval(approval.request_id, seedChoice); + await resolveApproval(selectionApproval.request_id, seedChoice); }; - // Creating transitions to the backup phase *before* resolving, so the dialog - // stays mounted once the approval clears; on failure we roll back. + // 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"); - dispatch({ type: "walletCreationStarted" }); + setWaitingForWallet(true); try { - await resolve(seedChoice); + await resolveSelection(seedChoice); } catch (e) { - dispatch({ type: "walletCreationFailed" }); + setWaitingForWallet(false); throw e; } }; + const finishBackup = async () => { + if (!backupApproval) + throw new Error("No approval request found for seed backup"); + await resolveApproval(backupApproval.request_id, true); + }; + const primaryHandlers: Record void | Promise> = { next: () => dispatch({ type: "next" }), - finish: () => dispatch({ type: "finish" }), + finish: finishBackup, createWallet, - restoreWallet: () => resolve(buildSeedChoice("FromSeed")), - openWallet: () => resolve(buildSeedChoice("FromWalletPath")), + restoreWallet: () => resolveSelection(buildSeedChoice("FromSeed")), + openWallet: () => resolveSelection(buildSeedChoice("FromWalletPath")), }; return ( @@ -423,7 +422,7 @@ export default function SeedSelectionDialog() { 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 && wizard.phase === "editing"; + const navigable = i < index && view === "wizard"; const upcoming = i > index; return navigable ? ( @@ -572,14 +571,17 @@ export default function SeedSelectionDialog() { /> )} - {step === "backupSeed" && ( - - dispatch({ type: "confirmBackup", confirmed }) - } - /> - )} + {step === "backupSeed" && + (backupContent ? ( + + ) : ( + + ))} {step === "openFile" && ( resolve({ type: "Legacy" })} + onInvoke={() => resolveSelection({ type: "Legacy" })} contextRequirement={false} displayErrorSnackbar color="inherit" @@ -602,7 +604,7 @@ export default function SeedSelectionDialog() { No wallet (Legacy) )} - {index > 0 && wizard.phase === "editing" && ( + {index > 0 && view === "wizard" && (