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.
- 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

View file

@ -17,6 +17,7 @@ type Props = {
displayCopyIcon?: boolean;
enableQrCode?: boolean;
light?: boolean;
spoilerText?: string;
};
function QRCodeModal({ open, onClose, content }: ModalProps) {
@ -63,10 +64,12 @@ export default function ActionableMonospaceTextBox({
displayCopyIcon = true,
enableQrCode = true,
light = false,
spoilerText,
}: Props) {
const [copied, setCopied] = useState(false);
const [qrCodeOpen, setQrCodeOpen] = useState(false);
const [isQrCodeButtonHovered, setIsQrCodeButtonHovered] = useState(false);
const [isRevealed, setIsRevealed] = useState(!spoilerText);
const handleCopy = async () => {
await writeText(content);
@ -76,52 +79,84 @@ export default function ActionableMonospaceTextBox({
return (
<>
<Tooltip
title={
isQrCodeButtonHovered
? ""
: copied
? "Copied to clipboard"
: "Click to copy"
}
arrow
>
<Box
sx={{
display: "flex",
alignItems: "center",
cursor: "pointer",
}}
<Box sx={{ position: "relative" }}>
<Tooltip
title={
isQrCodeButtonHovered
? ""
: copied
? "Copied to clipboard"
: "Click to copy"
}
arrow
>
<Box sx={{ flexGrow: 1 }} onClick={handleCopy}>
<MonospaceTextBox light={light}>
{content}
{displayCopyIcon && (
<IconButton
onClick={handleCopy}
size="small"
sx={{ marginLeft: 1 }}
>
<FileCopyOutlined />
</IconButton>
)}
{enableQrCode && (
<Tooltip title="Show QR Code" arrow>
<Box
sx={{
display: "flex",
alignItems: "center",
cursor: "pointer",
filter: spoilerText && !isRevealed ? "blur(8px)" : "none",
transition: "filter 0.3s ease",
}}
>
<Box sx={{ flexGrow: 1 }} onClick={handleCopy}>
<MonospaceTextBox light={light}>
{content}
{displayCopyIcon && (
<IconButton
onClick={() => setQrCodeOpen(true)}
onMouseEnter={() => setIsQrCodeButtonHovered(true)}
onMouseLeave={() => setIsQrCodeButtonHovered(false)}
onClick={handleCopy}
size="small"
sx={{ marginLeft: 1 }}
>
<QrCodeIcon />
<FileCopyOutlined />
</IconButton>
</Tooltip>
)}
</MonospaceTextBox>
)}
{enableQrCode && (
<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>
</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 && (
<QRCodeModal
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 PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import SetRestoreHeightModal from "../SetRestoreHeightModal";
import SeedPhraseButton from "../SeedPhraseButton";
import SeedPhraseModal from "../SeedPhraseModal";
import DfxButton from "./DFXWidget";
import {
GetMoneroSeedResponse,
GetRestoreHeightResponse,
} from "models/tauriModel";
interface WalletActionButtonsProps {
balance: {
@ -37,11 +43,16 @@ export default function WalletActionButtons({
balance,
}: WalletActionButtonsProps) {
const navigate = useNavigate();
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [restoreHeightDialogOpen, setRestoreHeightDialogOpen] = useState(false);
const [seedPhrase, setSeedPhrase] = useState<
[GetMoneroSeedResponse, GetRestoreHeightResponse] | null
>(null);
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const menuOpen = Boolean(menuAnchorEl);
const handleMenuClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setMenuAnchorEl(event.currentTarget);
};
@ -55,6 +66,7 @@ export default function WalletActionButtons({
open={restoreHeightDialogOpen}
onClose={() => setRestoreHeightDialogOpen(false)}
/>
<SeedPhraseModal onClose={() => setSeedPhrase(null)} seed={seedPhrase} />
<SendTransactionModal
balance={balance}
open={sendDialogOpen}
@ -100,6 +112,10 @@ export default function WalletActionButtons({
</ListItemIcon>
<Typography>Restore Height</Typography>
</MenuItem>
<SeedPhraseButton
onMenuClose={handleMenuClose}
onSeedPhraseSuccess={setSeedPhrase}
/>
</Menu>
</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,
GetRestoreHeightResponse,
MoneroNodeConfig,
GetMoneroSeedResponse,
} from "models/tauriModel";
import {
rpcSetBalance,
@ -495,9 +496,14 @@ export async function getMoneroSyncProgress(): Promise<GetMoneroSyncProgressResp
);
}
export async function getMoneroSeed(): Promise<string> {
// Returns the wallet's seed phrase as a single string. Backend must expose the `get_monero_seed` command.
return await invokeNoArgs<string>("get_monero_seed");
export async function getMoneroSeed(): Promise<GetMoneroSeedResponse> {
return await invokeNoArgs<GetMoneroSeedResponse>("get_monero_seed");
}
export async function getMoneroSeedAndRestoreHeight(): Promise<
[GetMoneroSeedResponse, GetRestoreHeightResponse]
> {
return Promise.all([getMoneroSeed(), getRestoreHeight()]);
}
// Wallet management functions that handle Redux dispatching

View file

@ -11,11 +11,11 @@ use swap::cli::{
CheckMoneroNodeResponse, CheckSeedArgs, CheckSeedResponse, DfxAuthenticateResponse,
ExportBitcoinWalletArgs, GetCurrentSwapArgs, GetDataDirArgs, GetHistoryArgs,
GetLogsArgs, GetMoneroAddressesArgs, GetMoneroBalanceArgs, GetMoneroHistoryArgs,
GetMoneroMainAddressArgs, GetMoneroSyncProgressArgs, GetPendingApprovalsResponse,
GetRestoreHeightArgs, GetSwapInfoArgs, GetSwapInfosAllArgs, ListSellersArgs,
MoneroRecoveryArgs, RedactArgs, RejectApprovalArgs, RejectApprovalResponse,
ResolveApprovalArgs, ResumeSwapArgs, SendMoneroArgs, SetRestoreHeightArgs,
SuspendCurrentSwapArgs, WithdrawBtcArgs,
GetMoneroMainAddressArgs, GetMoneroSeedArgs, GetMoneroSyncProgressArgs,
GetPendingApprovalsResponse, GetRestoreHeightArgs, GetSwapInfoArgs,
GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs,
RejectApprovalArgs, RejectApprovalResponse, ResolveApprovalArgs, ResumeSwapArgs,
SendMoneroArgs, SetRestoreHeightArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs,
},
tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings},
Context, ContextBuilder,
@ -206,6 +206,7 @@ pub fn run() {
get_monero_balance,
send_monero,
get_monero_sync_progress,
get_monero_seed,
check_seed,
get_pending_approvals,
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_balance, GetMoneroBalanceArgs, 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
#[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]
#[derive(Serialize, Deserialize, Debug)]
pub struct GetPendingApprovalsResponse {