feat: Recover from / write down seed (#439)

* vendor: seed crate from monero-serai

* refactor: make approval type generic over respone from frontend

* progress

* Raphaels Progress

* feat(gui): seed import flow skeleton

* fix(gui): Seed import in the development version

* fix(gui): specify the imported seed type

* remove initializeHandle, make tauri handle non optional in state, dont allow closing seed dialog

* feat(gui): check if seed is valid dynamically

* fix(gui): refine the dynamic seed validation

* push

* progress

* progress

* Fix pending trimeout

---------

Co-authored-by: Maksim Kirillov <artist@eduroam-141-23-183-184.wlan.tu-berlin.de>
Co-authored-by: Maksim Kirillov <artist@eduroam-141-23-189-144.wlan.tu-berlin.de>
Co-authored-by: Maksim Kirillov <maksim.kirillov@staticlabs.de>
This commit is contained in:
Mohan 2025-07-02 14:01:56 +02:00 committed by GitHub
parent b8982b5ac2
commit 7606982de3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 22465 additions and 171 deletions

View file

@ -253,27 +253,38 @@ export function isGetSwapInfoResponseWithTimelock(
return response.timelock !== null;
}
export type PendingApprovalRequest = Extract<
ApprovalRequest,
{ state: "Pending" }
>;
export type PendingApprovalRequest = ApprovalRequest & {
content: Extract<ApprovalRequest["request_status"], { state: "Pending" }>;
};
export type PendingLockBitcoinApprovalRequest = PendingApprovalRequest & {
content: {
details: { type: "LockBitcoin" };
};
export type PendingLockBitcoinApprovalRequest = ApprovalRequest & {
request: Extract<ApprovalRequest["request"], { type: "LockBitcoin" }>;
content: Extract<ApprovalRequest["request_status"], { state: "Pending" }>;
};
export type PendingSeedSelectionApprovalRequest = ApprovalRequest & {
type: "SeedSelection";
content: Extract<ApprovalRequest["request_status"], { state: "Pending" }>;
};
export function isPendingLockBitcoinApprovalEvent(
event: ApprovalRequest,
): event is PendingLockBitcoinApprovalRequest {
// Check if the request is pending
if (event.state !== "Pending") {
return false;
}
// Check if the request is a LockBitcoin request and is pending
return (
event.request.type === "LockBitcoin" &&
event.request_status.state === "Pending"
);
}
// Check if the request is a LockBitcoin request
return event.content.details.type === "LockBitcoin";
export function isPendingSeedSelectionApprovalEvent(
event: ApprovalRequest,
): event is PendingSeedSelectionApprovalRequest {
// Check if the request is a SeedSelection request and is pending
return (
event.request.type === "SeedSelection" &&
event.request_status.state === "Pending"
);
}
export function isPendingBackgroundProcess(

View file

@ -20,6 +20,7 @@ import { setupBackgroundTasks } from "renderer/background";
import "@fontsource/roboto";
import FeedbackPage from "./pages/feedback/FeedbackPage";
import IntroductionModal from "./modal/introduction/IntroductionModal";
import SeedSelectionDialog from "./modal/seed-selection/SeedSelectionDialog";
declare module "@mui/material/styles" {
interface Theme {
@ -46,6 +47,7 @@ export default function App() {
<CssBaseline />
<GlobalSnackbarProvider>
<IntroductionModal />
<SeedSelectionDialog />
<Router>
<Navigation />
<InnerContent />

View file

@ -0,0 +1,121 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
FormControlLabel,
Radio,
RadioGroup,
TextField,
Typography,
} from "@mui/material";
import { useState, useEffect } from "react";
import { usePendingSeedSelectionApproval } from "store/hooks";
import { resolveApproval, checkSeed } from "renderer/rpc";
export default function SeedSelectionDialog() {
const pendingApprovals = usePendingSeedSelectionApproval();
const [selectedOption, setSelectedOption] = useState<string>("RandomSeed");
const [customSeed, setCustomSeed] = useState<string>("");
const [isSeedValid, setIsSeedValid] = useState<boolean>(false);
const approval = pendingApprovals[0]; // Handle the first pending approval
useEffect(() => {
if (selectedOption === "FromSeed" && customSeed.trim()) {
checkSeed(customSeed.trim())
.then((valid) => {
setIsSeedValid(valid);
})
.catch(() => {
setIsSeedValid(false);
});
} else {
setIsSeedValid(false);
}
}, [customSeed, selectedOption]);
const handleClose = async (accept: boolean) => {
if (!approval) return;
if (accept) {
const seedChoice =
selectedOption === "RandomSeed"
? { type: "RandomSeed" }
: { type: "FromSeed", content: { seed: customSeed } };
await resolveApproval(approval.request_id, seedChoice);
} else {
// On reject, just close without approval
await resolveApproval(approval.request_id, { type: "RandomSeed" });
}
};
if (!approval) {
return null;
}
return (
<Dialog open={true} maxWidth="sm" fullWidth>
<DialogTitle>Monero Wallet</DialogTitle>
<DialogContent>
<Typography variant="body1" sx={{ mb: 2 }}>
Choose what seed to use for the wallet.
</Typography>
<FormControl component="fieldset">
<RadioGroup
value={selectedOption}
onChange={(e) => setSelectedOption(e.target.value)}
>
<FormControlLabel
value="RandomSeed"
control={<Radio />}
label="Create a new wallet"
/>
<FormControlLabel
value="FromSeed"
control={<Radio />}
label="Restore wallet from seed"
/>
</RadioGroup>
</FormControl>
{selectedOption === "FromSeed" && (
<TextField
fullWidth
multiline
rows={3}
label="Enter your seed phrase"
value={customSeed}
onChange={(e) => setCustomSeed(e.target.value)}
sx={{ mt: 2 }}
placeholder="Enter your Monero 25 words seed phrase..."
error={!isSeedValid && customSeed.length > 0}
helperText={
isSeedValid
? "Seed is valid"
: customSeed.length > 0
? "Seed is invalid"
: ""
}
/>
)}
</DialogContent>
<DialogActions>
<Button
onClick={() => handleClose(true)}
variant="contained"
disabled={
selectedOption === "FromSeed"
? !customSeed.trim() || !isSeedValid
: false
}
>
Confirm
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -20,9 +20,7 @@ function useActiveLockBitcoinApprovalRequest(): PendingLockBitcoinApprovalReques
const activeSwapId = useActiveSwapId();
return (
approvals?.find(
(r) => r.content.details.content.swap_id === activeSwapId,
) || null
approvals?.find((r) => r.request.content.swap_id === activeSwapId) || null
);
}
@ -32,10 +30,18 @@ export default function SwapSetupInflightPage({
const request = useActiveLockBitcoinApprovalRequest();
const [timeLeft, setTimeLeft] = useState<number>(0);
const expiresAtMs = request?.content.expiration_ts * 1000 || 0;
const expirationTs =
request?.request_status.state === "Pending"
? request.request_status.content.expiration_ts
: null;
useEffect(() => {
if (expirationTs == null) {
return;
}
const expiresAtMs = expirationTs * 1000 || 0;
const tick = () => {
const remainingMs = Math.max(expiresAtMs - Date.now(), 0);
setTimeLeft(Math.ceil(remainingMs / 1000));
@ -44,11 +50,11 @@ export default function SwapSetupInflightPage({
tick();
const id = setInterval(tick, 250);
return () => clearInterval(id);
}, [expiresAtMs]);
}, [request, expirationTs]);
// If we do not have an approval request yet for the Bitcoin lock transaction, we haven't received the offer from Alice yet
// Display a loading spinner to the user for as long as the swap_setup request is in flight
if (!request) {
if (request == null) {
return (
<CircularProgressWithSubtitle
description={
@ -61,7 +67,7 @@ export default function SwapSetupInflightPage({
}
const { btc_network_fee, monero_receive_pool, xmr_receive_amount } =
request.content.details.content;
request.request.content;
return (
<Box
@ -124,7 +130,9 @@ export default function SwapSetupInflightPage({
variant="text"
size="large"
sx={(theme) => ({ color: theme.palette.text.secondary })}
onInvoke={() => resolveApproval(request.content.request_id, false)}
onInvoke={() =>
resolveApproval(request.request_id, false as unknown as object)
}
displayErrorSnackbar
requiresContext
>
@ -135,7 +143,9 @@ export default function SwapSetupInflightPage({
variant="contained"
color="primary"
size="large"
onInvoke={() => resolveApproval(request.content.request_id, true)}
onInvoke={() =>
resolveApproval(request.request_id, true as unknown as object)
}
displayErrorSnackbar
requiresContext
endIcon={<CheckIcon />}

View file

@ -17,6 +17,8 @@ import {
GetSwapInfoArgs,
ExportBitcoinWalletResponse,
CheckMoneroNodeArgs,
CheckSeedArgs,
CheckSeedResponse,
CheckMoneroNodeResponse,
TauriSettings,
CheckElectrumNodeArgs,
@ -304,10 +306,16 @@ export async function initializeContext() {
logger.info("Initializing context with settings", tauriSettings);
await invokeUnsafe<void>("initialize_context", {
settings: tauriSettings,
testnet,
});
try {
await invokeUnsafe<void>("initialize_context", {
settings: tauriSettings,
testnet,
});
} catch (error) {
throw new Error("Couldn't initialize context: " + error);
}
logger.info("Initialized context");
}
export async function getWalletDescriptor() {
@ -395,7 +403,7 @@ export async function getDataDir(): Promise<string> {
export async function resolveApproval(
requestId: string,
accept: boolean,
accept: object,
): Promise<void> {
await invoke<ResolveApprovalArgs, ResolveApprovalResponse>(
"resolve_approval_request",
@ -403,6 +411,16 @@ export async function resolveApproval(
);
}
export async function checkSeed(seed: string): Promise<boolean> {
const response = await invoke<CheckSeedArgs, CheckSeedResponse>(
"check_seed",
{
seed,
},
);
return response.available;
}
export async function saveLogFiles(
zipFileName: string,
content: Record<string, string>,

View file

@ -137,7 +137,7 @@ export const rpcSlice = createSlice({
},
approvalEventReceived(slice, action: PayloadAction<ApprovalRequest>) {
const event = action.payload;
const requestId = event.content.request_id;
const requestId = event.request_id;
slice.state.approvalRequests[requestId] = event;
},
backgroundProgressEventReceived(

View file

@ -5,8 +5,10 @@ import {
isBitcoinSyncProgress,
isPendingBackgroundProcess,
isPendingLockBitcoinApprovalEvent,
isPendingSeedSelectionApprovalEvent,
PendingApprovalRequest,
PendingLockBitcoinApprovalRequest,
PendingSeedSelectionApprovalRequest,
} from "models/tauriModelExt";
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { AppDispatch, RootState } from "renderer/store/storeRenderer";
@ -155,7 +157,9 @@ export function useNodes<T>(selector: (nodes: NodesSlice) => T): T {
export function usePendingApprovals(): PendingApprovalRequest[] {
const approvals = useAppSelector((state) => state.rpc.state.approvalRequests);
return Object.values(approvals).filter((c) => c.state === "Pending");
return Object.values(approvals).filter(
(c) => c.request_status.state === "Pending",
) as PendingApprovalRequest[];
}
export function usePendingLockBitcoinApproval(): PendingLockBitcoinApprovalRequest[] {
@ -163,6 +167,11 @@ export function usePendingLockBitcoinApproval(): PendingLockBitcoinApprovalReque
return approvals.filter((c) => isPendingLockBitcoinApprovalEvent(c));
}
export function usePendingSeedSelectionApproval(): PendingSeedSelectionApprovalRequest[] {
const approvals = usePendingApprovals();
return approvals.filter((c) => isPendingSeedSelectionApprovalEvent(c));
}
/// Returns all the pending background processes
/// In the format [id, {componentName, {type: "Pending", content: {consumed, total}}}]
export function usePendingBackgroundProcesses(): [