Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- GUI: The wallet setup dialog is now a multi-step wizard. Creating a wallet lets you pick a name and location and requires backing up the seed phrase before continuing, and opening a wallet file supports drag & drop. Newly created wallets are named by the user instead of a Unix timestamp.
- ASB: The Hermes protocol is now enabled by default (`hermes_enabled` defaults to `true`), and the default `hermes_min_swap_amount` was lowered from `0.01` to `0.001` BTC (~50 USD at a reference price of 50,000 USD/BTC).

## [4.11.4] - 2026-06-30
Expand Down
15 changes: 15 additions & 0 deletions src-gui/src/models/tauriModelExt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,11 @@ export type PendingSeedSelectionApprovalRequest = ApprovalRequest & {
content: Extract<ApprovalRequest["request_status"], { state: "Pending" }>;
};

export type PendingSeedBackupApprovalRequest = ApprovalRequest & {
request: Extract<ApprovalRequest["request"], { type: "SeedBackup" }>;
content: Extract<ApprovalRequest["request_status"], { state: "Pending" }>;
};

export function isPendingLockBitcoinApprovalEvent(
event: ApprovalRequest,
): event is PendingLockBitcoinApprovalRequest {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Box, Checkbox, FormControlLabel, Typography } from "@mui/material";
import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox";
import { PrivateKeyScamAlert } from "renderer/components/other/PrivateKeyWarning";

/// Shown while the backend blocks startup on its SeedBackup approval: the
/// freshly created wallet's seed is displayed once so the user records it
/// before continuing.
export default function BackupSeedStep({
seed,
restoreHeight,
confirmed,
onConfirmedChange,
}: {
seed: string;
restoreHeight: number;
confirmed: boolean;
onConfirmedChange: (confirmed: boolean) => void;
}) {
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<PrivateKeyScamAlert />
<Typography variant="body2" color="text.secondary">
Write down your seed phrase and restore height. They are the only way to
recover this wallet if you lose access to this device.
</Typography>
<ActionableMonospaceTextBox
content={seed}
displayCopyIcon={true}
enableQrCode={false}
spoilerText="Press to reveal"
/>
<ActionableMonospaceTextBox
content={restoreHeight.toString()}
displayCopyIcon={true}
enableQrCode={false}
/>
<FormControlLabel
control={
<Checkbox
checked={confirmed}
onChange={(e) => onConfirmedChange(e.target.checked)}
/>
}
label="I have written down my seed phrase and restore height"
/>
</Box>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Box, TextField, Typography } from "@mui/material";
import { open } from "@tauri-apps/plugin-dialog";
import SearchIcon from "@mui/icons-material/Search";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";

export default function NameLocationStep({
name,
setName,
directory,
setDirectory,
}: {
name: string;
setName: (name: string) => void;
directory: string;
setDirectory: (directory: string) => void;
}) {
const selectDirectory = async () => {
const selected = await open({ multiple: false, directory: true });
if (selected) setDirectory(selected);
};

return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="body2" color="text.secondary">
Choose a name for the wallet file and where to store it on this device.
</Typography>
<TextField
fullWidth
autoFocus
label="Wallet name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="my-wallet"
error={name.length > 0 && name.trim().length === 0}
helperText={
name.length > 0 && name.trim().length === 0
? "Enter a wallet name"
: ""
}
/>
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<TextField
fullWidth
label="Save location"
value={directory}
placeholder="Select a folder..."
InputProps={{ readOnly: true }}
/>
<PromiseInvokeButton
variant="outlined"
onInvoke={selectDirectory}
contextRequirement={false}
displayErrorSnackbar
sx={{ minWidth: "120px", height: "56px" }}
startIcon={<SearchIcon />}
>
Browse
</PromiseInvokeButton>
</Box>
</Box>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import {
Box,
Divider,
List,
ListItem,
ListItemButton,
ListItemText,
Typography,
} from "@mui/material";
import { useEffect, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";

export default function OpenWalletStep({
walletPath,
setWalletPath,
recentWallets,
}: {
walletPath: string;
setWalletPath: (path: string) => void;
recentWallets: string[];
}) {
const [isDragging, setIsDragging] = useState(false);

// Tauri delivers dropped file paths through the webview drag-drop event
// rather than the DOM, so we subscribe to it while this step is mounted.
useEffect(() => {
let active = true;
let unlisten: (() => void) | undefined;

getCurrentWebview()
.onDragDropEvent((event) => {
if (event.payload.type === "drop") {
setIsDragging(false);
const path = event.payload.paths[0];
// Users commonly pick the `<name>.keys` file; the wallet is the
// file without that extension.
if (path) setWalletPath(path.replace(/\.keys$/, ""));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keys suffix strips wallet basename

Medium Severity

Drag-and-drop and the file picker always remove a trailing .keys from the chosen path. That is correct when the user selects the keys sidecar, but if the wallet’s on-disk name itself ends with .keys (allowed by the name step), selecting the main wallet file strips that suffix and sends the wrong wallet_path to open.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 99882c5. Configure here.

} else if (event.payload.type === "leave") {
setIsDragging(false);
} else {
setIsDragging(true);
}
})
.then((fn) => {
if (active) unlisten = fn;
else fn();
});

return () => {
active = false;
unlisten?.();
};
}, [setWalletPath]);

const selectWalletFile = async () => {
const selected = await open({ multiple: false, directory: false });
if (selected) setWalletPath(selected.replace(/\.keys$/, ""));
};

return (
<Box sx={{ gap: 2, display: "flex", flexDirection: "column" }}>
<Box
onClick={selectWalletFile}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 1,
py: 4,
px: 2,
cursor: "pointer",
borderRadius: 2,
border: "2px dashed",
borderColor: isDragging ? "primary.main" : "divider",
backgroundColor: isDragging ? "action.hover" : "transparent",
transition: "border-color 0.15s, background-color 0.15s",
"&:hover": { borderColor: "primary.main" },
}}
>
<FolderOpenIcon sx={{ fontSize: 40, color: "text.secondary" }} />
<Typography variant="body2" color="text.secondary">
Drag a wallet file here, or click to browse
</Typography>
</Box>

{recentWallets.length > 0 && (
<Box
sx={{
border: 1,
borderColor: "divider",
borderRadius: 1,
maxHeight: 200,
overflowY: "scroll",
"&::-webkit-scrollbar": {
display: "block !important",
width: "8px !important",
},
"&::-webkit-scrollbar-track": {
display: "block !important",
background: "rgba(255,255,255,.1) !important",
borderRadius: "4px",
},
"&::-webkit-scrollbar-thumb": {
display: "block !important",
background: "rgba(255,255,255,.6) !important",
borderRadius: "4px",
minHeight: "20px !important",
},
"&::-webkit-scrollbar-thumb:hover": {
background: "rgba(255,255,255,.8) !important",
},
"&::-webkit-scrollbar-corner": {
background: "transparent !important",
},
scrollbarWidth: "thin",
scrollbarColor: "rgba(255,255,255,.6) rgba(255,255,255,.1)",
}}
>
<List disablePadding>
{recentWallets.map((path, index) => (
<Box key={path}>
<ListItem disablePadding>
<ListItemButton
selected={walletPath === path}
onClick={() => setWalletPath(path)}
>
<ListItemText
primary={path.split(/[/\\]/).pop() || path}
secondary={path}
primaryTypographyProps={{
fontWeight: walletPath === path ? 600 : 400,
fontSize: "0.9rem",
}}
secondaryTypographyProps={{
fontSize: "0.75rem",
sx: {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
}}
/>
</ListItemButton>
</ListItem>
{index < recentWallets.length - 1 && <Divider />}
</Box>
))}
</List>
</Box>
)}
</Box>
);
}
Loading
Loading