feat(wallet): Export Monero seedphrase (#515)

* feat(wallet): Allow exporting Monero seed

* refactors, display restore height too
This commit is contained in:
Mohan 2025-08-11 11:51:19 +02:00 committed by GitHub
parent 660423f873
commit 0a75ea6a19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 248 additions and 63 deletions

View file

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- ASB + CONTROLLER: Add a `monero_seed` command to the controller shell. You can use it to export the seed and restore height of the internal Monero wallet. You can use those to import the wallet into a wallet software of your own choosing. - ASB + CONTROLLER: Add a `monero_seed` command to the controller shell. You can use it to export the seed and restore height of the internal Monero wallet. You can use those to import the wallet into a wallet software of your own choosing.
- GUI: You can now change the Monero Node without having to restart. - GUI: You can now change the Monero Node without having to restart.
- GUI: You can now export the seed phrase of the Monero wallet.
## [3.0.0-beta.8] - 2025-08-10 ## [3.0.0-beta.8] - 2025-08-10

View file

@ -17,6 +17,7 @@ type Props = {
displayCopyIcon?: boolean; displayCopyIcon?: boolean;
enableQrCode?: boolean; enableQrCode?: boolean;
light?: boolean; light?: boolean;
spoilerText?: string;
}; };
function QRCodeModal({ open, onClose, content }: ModalProps) { function QRCodeModal({ open, onClose, content }: ModalProps) {
@ -63,10 +64,12 @@ export default function ActionableMonospaceTextBox({
displayCopyIcon = true, displayCopyIcon = true,
enableQrCode = true, enableQrCode = true,
light = false, light = false,
spoilerText,
}: Props) { }: Props) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [qrCodeOpen, setQrCodeOpen] = useState(false); const [qrCodeOpen, setQrCodeOpen] = useState(false);
const [isQrCodeButtonHovered, setIsQrCodeButtonHovered] = useState(false); const [isQrCodeButtonHovered, setIsQrCodeButtonHovered] = useState(false);
const [isRevealed, setIsRevealed] = useState(!spoilerText);
const handleCopy = async () => { const handleCopy = async () => {
await writeText(content); await writeText(content);
@ -76,52 +79,84 @@ export default function ActionableMonospaceTextBox({
return ( return (
<> <>
<Tooltip <Box sx={{ position: "relative" }}>
title={ <Tooltip
isQrCodeButtonHovered title={
? "" isQrCodeButtonHovered
: copied ? ""
? "Copied to clipboard" : copied
: "Click to copy" ? "Copied to clipboard"
} : "Click to copy"
arrow }
> arrow
<Box
sx={{
display: "flex",
alignItems: "center",
cursor: "pointer",
}}
> >
<Box sx={{ flexGrow: 1 }} onClick={handleCopy}> <Box
<MonospaceTextBox light={light}> sx={{
{content} display: "flex",
{displayCopyIcon && ( alignItems: "center",
<IconButton cursor: "pointer",
onClick={handleCopy} filter: spoilerText && !isRevealed ? "blur(8px)" : "none",
size="small" transition: "filter 0.3s ease",
sx={{ marginLeft: 1 }} }}
> >
<FileCopyOutlined /> <Box sx={{ flexGrow: 1 }} onClick={handleCopy}>
</IconButton> <MonospaceTextBox light={light}>
)} {content}
{enableQrCode && ( {displayCopyIcon && (
<Tooltip title="Show QR Code" arrow>
<IconButton <IconButton
onClick={() => setQrCodeOpen(true)} onClick={handleCopy}
onMouseEnter={() => setIsQrCodeButtonHovered(true)}
onMouseLeave={() => setIsQrCodeButtonHovered(false)}
size="small" size="small"
sx={{ marginLeft: 1 }} sx={{ marginLeft: 1 }}
> >
<QrCodeIcon /> <FileCopyOutlined />
</IconButton> </IconButton>
</Tooltip> )}
)} {enableQrCode && (
</MonospaceTextBox> <Tooltip title="Show QR Code" arrow>
<IconButton
onClick={() => setQrCodeOpen(true)}
onMouseEnter={() => setIsQrCodeButtonHovered(true)}
onMouseLeave={() => setIsQrCodeButtonHovered(false)}
size="small"
sx={{ marginLeft: 1 }}
>
<QrCodeIcon />
</IconButton>
</Tooltip>
)}
</MonospaceTextBox>
</Box>
</Box> </Box>
</Box> </Tooltip>
</Tooltip>
{spoilerText && !isRevealed && (
<Box
onClick={() => setIsRevealed(true)}
sx={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
bgcolor: "rgba(0, 0, 0, 0.1)",
borderRadius: 1,
}}
>
<Box
sx={{
bgcolor: "background.paper",
p: 2,
borderRadius: 1,
boxShadow: 2,
}}
>
{spoilerText}
</Box>
</Box>
)}
</Box>
{enableQrCode && ( {enableQrCode && (
<QRCodeModal <QRCodeModal
open={qrCodeOpen} open={qrCodeOpen}

View file

@ -0,0 +1,51 @@
import { ListItemIcon, MenuItem, Typography } from "@mui/material";
import { Key as KeyIcon } from "@mui/icons-material";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import { getMoneroSeedAndRestoreHeight } from "renderer/rpc";
import {
GetMoneroSeedResponse,
GetRestoreHeightResponse,
} from "models/tauriModel";
interface SeedPhraseButtonProps {
onMenuClose: () => void;
onSeedPhraseSuccess: (
response: [GetMoneroSeedResponse, GetRestoreHeightResponse],
) => void;
}
export default function SeedPhraseButton({
onMenuClose,
onSeedPhraseSuccess,
}: SeedPhraseButtonProps) {
const handleSeedPhraseSuccess = (
response: [GetMoneroSeedResponse, GetRestoreHeightResponse],
) => {
onSeedPhraseSuccess(response);
onMenuClose();
};
return (
<MenuItem component="div">
<PromiseInvokeButton
onInvoke={getMoneroSeedAndRestoreHeight}
onSuccess={handleSeedPhraseSuccess}
displayErrorSnackbar={true}
variant="text"
sx={{
justifyContent: "flex-start",
textTransform: "none",
padding: 0,
minHeight: "auto",
width: "100%",
color: "text.primary",
}}
>
<ListItemIcon>
<KeyIcon />
</ListItemIcon>
<Typography>Seedphrase</Typography>
</PromiseInvokeButton>
</MenuItem>
);
}

View file

@ -0,0 +1,64 @@
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Typography,
} from "@mui/material";
import ActionableMonospaceTextBox from "../../other/ActionableMonospaceTextBox";
import {
GetMoneroSeedResponse,
GetRestoreHeightResponse,
} from "models/tauriModel";
interface SeedPhraseModalProps {
onClose: () => void;
seed: [GetMoneroSeedResponse, GetRestoreHeightResponse] | null;
}
export default function SeedPhraseModal({
onClose,
seed,
}: SeedPhraseModalProps) {
if (seed === null) {
return null;
}
return (
<Dialog open={true} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>Wallet Seed Phrase</DialogTitle>
<DialogContent>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<ActionableMonospaceTextBox
content={seed[0].seed}
displayCopyIcon={true}
enableQrCode={false}
spoilerText="Press to reveal"
/>
<ActionableMonospaceTextBox
content={seed[1].height.toString()}
displayCopyIcon={true}
enableQrCode={false}
/>
</Box>
<Typography
variant="caption"
color="text.secondary"
sx={{ mt: 2, display: "block", fontStyle: "italic" }}
>
Keep this seed phrase safe and secure. Write it down on paper and
store it in a safe place. Keep the restore height in mind when you
restore your wallet on another device.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose} variant="contained">
Close
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -25,7 +25,13 @@ import SendTransactionModal from "../SendTransactionModal";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import SetRestoreHeightModal from "../SetRestoreHeightModal"; import SetRestoreHeightModal from "../SetRestoreHeightModal";
import SeedPhraseButton from "../SeedPhraseButton";
import SeedPhraseModal from "../SeedPhraseModal";
import DfxButton from "./DFXWidget"; import DfxButton from "./DFXWidget";
import {
GetMoneroSeedResponse,
GetRestoreHeightResponse,
} from "models/tauriModel";
interface WalletActionButtonsProps { interface WalletActionButtonsProps {
balance: { balance: {
@ -37,11 +43,16 @@ export default function WalletActionButtons({
balance, balance,
}: WalletActionButtonsProps) { }: WalletActionButtonsProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [sendDialogOpen, setSendDialogOpen] = useState(false); const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [restoreHeightDialogOpen, setRestoreHeightDialogOpen] = useState(false); const [restoreHeightDialogOpen, setRestoreHeightDialogOpen] = useState(false);
const [seedPhrase, setSeedPhrase] = useState<
[GetMoneroSeedResponse, GetRestoreHeightResponse] | null
>(null);
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null); const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const menuOpen = Boolean(menuAnchorEl); const menuOpen = Boolean(menuAnchorEl);
const handleMenuClick = (event: React.MouseEvent<HTMLButtonElement>) => { const handleMenuClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setMenuAnchorEl(event.currentTarget); setMenuAnchorEl(event.currentTarget);
}; };
@ -55,6 +66,7 @@ export default function WalletActionButtons({
open={restoreHeightDialogOpen} open={restoreHeightDialogOpen}
onClose={() => setRestoreHeightDialogOpen(false)} onClose={() => setRestoreHeightDialogOpen(false)}
/> />
<SeedPhraseModal onClose={() => setSeedPhrase(null)} seed={seedPhrase} />
<SendTransactionModal <SendTransactionModal
balance={balance} balance={balance}
open={sendDialogOpen} open={sendDialogOpen}
@ -100,6 +112,10 @@ export default function WalletActionButtons({
</ListItemIcon> </ListItemIcon>
<Typography>Restore Height</Typography> <Typography>Restore Height</Typography>
</MenuItem> </MenuItem>
<SeedPhraseButton
onMenuClose={handleMenuClose}
onSeedPhraseSuccess={setSeedPhrase}
/>
</Menu> </Menu>
</Box> </Box>
</> </>

View file

@ -1,16 +0,0 @@
import Button, { ButtonProps } from "@mui/material/Button";
export default function ClipboardIconButton({
text,
...props
}: { text: string } & ButtonProps) {
function writeToClipboard() {
throw new Error("Not implemented");
}
return (
<Button onClick={writeToClipboard} {...props}>
Copy
</Button>
);
}

View file

@ -45,6 +45,7 @@ import {
SetRestoreHeightResponse, SetRestoreHeightResponse,
GetRestoreHeightResponse, GetRestoreHeightResponse,
MoneroNodeConfig, MoneroNodeConfig,
GetMoneroSeedResponse,
} from "models/tauriModel"; } from "models/tauriModel";
import { import {
rpcSetBalance, rpcSetBalance,
@ -495,9 +496,14 @@ export async function getMoneroSyncProgress(): Promise<GetMoneroSyncProgressResp
); );
} }
export async function getMoneroSeed(): Promise<string> { export async function getMoneroSeed(): Promise<GetMoneroSeedResponse> {
// Returns the wallet's seed phrase as a single string. Backend must expose the `get_monero_seed` command. return await invokeNoArgs<GetMoneroSeedResponse>("get_monero_seed");
return await invokeNoArgs<string>("get_monero_seed"); }
export async function getMoneroSeedAndRestoreHeight(): Promise<
[GetMoneroSeedResponse, GetRestoreHeightResponse]
> {
return Promise.all([getMoneroSeed(), getRestoreHeight()]);
} }
// Wallet management functions that handle Redux dispatching // Wallet management functions that handle Redux dispatching

View file

@ -11,11 +11,11 @@ use swap::cli::{
CheckMoneroNodeResponse, CheckSeedArgs, CheckSeedResponse, DfxAuthenticateResponse, CheckMoneroNodeResponse, CheckSeedArgs, CheckSeedResponse, DfxAuthenticateResponse,
ExportBitcoinWalletArgs, GetCurrentSwapArgs, GetDataDirArgs, GetHistoryArgs, ExportBitcoinWalletArgs, GetCurrentSwapArgs, GetDataDirArgs, GetHistoryArgs,
GetLogsArgs, GetMoneroAddressesArgs, GetMoneroBalanceArgs, GetMoneroHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, GetMoneroBalanceArgs, GetMoneroHistoryArgs,
GetMoneroMainAddressArgs, GetMoneroSyncProgressArgs, GetPendingApprovalsResponse, GetMoneroMainAddressArgs, GetMoneroSeedArgs, GetMoneroSyncProgressArgs,
GetRestoreHeightArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs, GetPendingApprovalsResponse, GetRestoreHeightArgs, GetSwapInfoArgs,
MoneroRecoveryArgs, RedactArgs, RejectApprovalArgs, RejectApprovalResponse, GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs,
ResolveApprovalArgs, ResumeSwapArgs, SendMoneroArgs, SetRestoreHeightArgs, RejectApprovalArgs, RejectApprovalResponse, ResolveApprovalArgs, ResumeSwapArgs,
SuspendCurrentSwapArgs, WithdrawBtcArgs, SendMoneroArgs, SetRestoreHeightArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs,
}, },
tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings}, tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings},
Context, ContextBuilder, Context, ContextBuilder,
@ -206,6 +206,7 @@ pub fn run() {
get_monero_balance, get_monero_balance,
send_monero, send_monero,
get_monero_sync_progress, get_monero_sync_progress,
get_monero_seed,
check_seed, check_seed,
get_pending_approvals, get_pending_approvals,
set_monero_restore_height, set_monero_restore_height,
@ -270,6 +271,7 @@ tauri_command!(get_restore_height, GetRestoreHeightArgs, no_args);
tauri_command!(get_monero_main_address, GetMoneroMainAddressArgs, no_args); tauri_command!(get_monero_main_address, GetMoneroMainAddressArgs, no_args);
tauri_command!(get_monero_balance, GetMoneroBalanceArgs, no_args); tauri_command!(get_monero_balance, GetMoneroBalanceArgs, no_args);
tauri_command!(get_monero_sync_progress, GetMoneroSyncProgressArgs, no_args); tauri_command!(get_monero_sync_progress, GetMoneroSyncProgressArgs, no_args);
tauri_command!(get_monero_seed, GetMoneroSeedArgs, no_args);
/// Here we define Tauri commands whose implementation is not delegated to the Request trait /// Here we define Tauri commands whose implementation is not delegated to the Request trait
#[tauri::command] #[tauri::command]

View file

@ -1994,6 +1994,32 @@ impl Request for GetMoneroSyncProgressArgs {
} }
} }
#[typeshare]
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GetMoneroSeedArgs;
#[typeshare]
#[derive(Serialize, Deserialize, Debug)]
pub struct GetMoneroSeedResponse {
pub seed: String,
}
impl Request for GetMoneroSeedArgs {
type Response = GetMoneroSeedResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let wallet_manager = ctx
.monero_manager
.as_ref()
.context("Monero wallet manager not available")?;
let wallet = wallet_manager.main_wallet().await;
let seed = wallet.seed().await?;
Ok(GetMoneroSeedResponse { seed })
}
}
#[typeshare] #[typeshare]
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct GetPendingApprovalsResponse { pub struct GetPendingApprovalsResponse {