feat(gui): Monero wallet (#442)

* feat(gui): Monero wallet

* progress

* refactor

* progress, dont delete wallet, re-fetch approvals and background periodically

* show transaction history correctly

* Enable fetching tx hashes

* Try add the wallet listener event callbacks, not working

* fix: Redeem XMR to internal main wallet, not temp wallet

* type safety

* refactoring of callback system

* make free floating functions generic

* refactor: Format files

* refactor(gui): Split wallet components and redesign balanceOverview component

* refactor(gui): Add action buttons and transaction section

* wrapper event listener

* progress, compiles

* works!

* WORKS! Event received on balance change

* refactor: format and slight refactorings and comments

* refactor(gui): Start with implementation of send dialog

- new number input
- new button variant and size

* add @tauri-apps/plugin-dialog

* feat(gui): Add permissions for file dialog

* fix(monero-harness): Compile issue

* feat(gui): Extract seed from Monero wallet and use for derivation, allow opening existing wallet file

* feat(gui): Always refresh the approval list from frontend when resolving

* fix(monero-rpc-pool): Implement Into<String> for ServerInfo

* fix(monero-sys): Use oneshot channel for all wallets

* feat(gui, monero-sys): Display recently opened wallets

* small refactors

* fix(gui): Enable background_sync, display temp "Loading..." if values are null

* feat(gui): Remove headers from pages, show selected navigation item

* feat(gui): Explicitly tell user if no swaps have been made yet

* feat(gui): send sync and history updates

* feat(gui): Fetch monero wallet details when context becomes availiable

* feat(gui): Display Monero primary address without modal

* feat(gui): Make "swap" button on wallet page take you to "/swap"

* feat(gui): Rework send modal, adjust number input, added send to field

* feat(gui): set block restore height, not working

* refactor(gui): Optimize number input and add support for switching between currency

* feat(gui): Display real fiat currency prices in send modal

* feat(gui): Add error message for too high send amount

* feat(gui): Modern UI for SeedSelectionDialog

* feat(gui): Wrap MoneroWalletActions

* wip

* refactoring approval callback

* feat(gui): Send Direction of Transaction in History to Frontend

* feat(gui): Let user approve transaction before publishing

* feat: Display 8 digits for Monero amounts by default

* feat(monero-sys): Store pending (non published) transactions in Mutex map inside wallet thread

This allows seperating signing and publishing transactions cleanly

* dprint fmt

* fix(gui): Refresh Monero wallet history C++ struct before serializing

* feat(monero-rpc-pool): Fail after three JSON-RPC errors

* feat(monero-sys): Add wrapper around verify_wallet_password

* feat(gui): Allow opening password-protected Wallets

* refactor: fmt, remove receive button

* fix(gui): Convert to XMR before converting into Fiat

* feat(gui): Add dialog for setting restore height

* feat(gui): block height can be changed, blocks when too low

* refactor(monero-sys): Remove old WalletListener code

* feat(gui): Continually ask for user to select wallet and enter password, if user rejects, offer to select different wallet

* refactor(swap): Extract "select Monero wallet" into own function

* refactor(tauri): Dont kill monero-wallet-rpc

* refactor(tauri): Avoid multiple concurrent Contexts starting

* refactor: Change "Cancel" to "Change wallet" on PasswordEntryDialog

* feat(gui): show curent block height, fix blockage

* Cargo.lock update

* refactor(monero-sys): Use match instead of is_err() and expect(...)

* refactor: better context for WalletHandle constructor method errors handling

* refactor(monero-sys): Common open_with<F>(path: String, daemon: Daemon, wallet_op: F) function

* feat: check empty password before requeston password for wallet

* feat: Remove "Checking for available remote nodes" from frontend

* feat(gui): Allow sweeping entire Monero balance

* feat(monero-rpc-pool): Keep alive TCP connections, do not record JSON-RPC errors as failure if >=3 nodes failed

If >=3 nodes failed we assume it was an actual issue on our side, not an issue with the node

* refactor(swap): Remove dead code

* add comment to WalletHandleListener::on_refreshed{...}

* feat(gui): show current block height in the field

* refactor: remove unused UserCancelledError;

* refactor: No Arc<Mutex<_>> for Pending TXs map

* refactor: remove redundant } catch (error) {

* feat: add our new crates to `OUR_CRATES` in tracing util

* fix(gui): Add math.ceil to piconero conversion to ensure integer

* fix(gui): Close menu when option is clicked

* review and improve/reduce uses of unsafe, also remove unique_ptr wrapper around TransactionHistory to avoid double free

* fix(gui): Use monero amount from units.tsx

* fix(gui): Use PromiseInvokeButton for simplification for approving of send transaction

* update comment, rename function

* refactor(gui): Fix alignment of amounts

* refactor(gui): Remove sending and refreshing states from wallet

* fix(cli, gui): use old seed flow on no tauri, fix minor issues in gui

* fix: use the new named function

* refactor(gui): Add skeletons for monero wallet when still loading

* refactor(gui): Remove isLoading from wallet slice

* feat(gui): Add success dialog after send transaction was approved

* fix(gui): Floor piconero amount in sendMoneroTransaction

* feat(gui): Allow view on explorer button on send success modal

* feat(backend): save the wallet state on events

* fix(structure): move throttle into its own crate

* fix(log): remove spammy logs

* fix(logs): log folder in confid

* remove "sync progress: " log

* small refactors

* save wallet at most every 60s

* remove useless logs

* underscore unused variables

* feat(gui): Add timestamp of the tx

* feat(gui): Add the legacy wallet init option

* legac ybutton

* Fix(gui, asb): reverse the log config
remove log in bridge.h
cleanup

* use none for .store(..)

* display dot for running swap

---------

Co-authored-by: Maksim Kirillov <maksim.kirillov@staticlabs.de>
Co-authored-by: b-enedict <benedict.seuss@gmail.com>
Co-authored-by: einliterflasche <einliterflasche@pm.me>
This commit is contained in:
Mohan 2025-07-18 15:08:36 +02:00 committed by GitHub
parent eb0dc10489
commit a7823d7489
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
118 changed files with 7857 additions and 3456 deletions

File diff suppressed because it is too large Load diff

View file

@ -4,9 +4,9 @@
"version": "0.7.0",
"type": "module",
"scripts": {
"check-bindings": "typeshare --lang=typescript --output-file __temp_bindings.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src && dprint fmt __temp_bindings.ts && diff -wbB __temp_bindings.ts ./src/models/tauriModel.ts && rm __temp_bindings.ts",
"gen-bindings-verbose": "RUST_LOG=debug RUST_BACKTRACE=1 typeshare --lang=typescript --output-file ./src/models/tauriModel.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src && dprint fmt ./src/models/tauriModel.ts",
"gen-bindings": "typeshare --lang=typescript --output-file ./src/models/tauriModel.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src && dprint fmt ./src/models/tauriModel.ts",
"check-bindings": "typeshare --lang=typescript --output-file __temp_bindings.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src ../monero-sys/src && dprint fmt __temp_bindings.ts && diff -wbB __temp_bindings.ts ./src/models/tauriModel.ts && rm __temp_bindings.ts",
"gen-bindings-verbose": "RUST_LOG=debug RUST_BACKTRACE=1 typeshare --lang=typescript --output-file ./src/models/tauriModel.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src ../monero-sys/src && dprint fmt ./src/models/tauriModel.ts",
"gen-bindings": "typeshare --lang=typescript --output-file ./src/models/tauriModel.ts ../swap/src ../monero-rpc-pool/src ../electrum-pool/src ../monero-sys/src && dprint fmt ./src/models/tauriModel.ts",
"test": "vitest",
"test:ui": "vitest --ui",
"dev": "vite",
@ -22,10 +22,12 @@
"@mui/icons-material": "^7.1.1",
"@mui/lab": "^7.0.0-beta.13",
"@mui/material": "^7.1.1",
"@mui/x-date-pickers": "^8.8.0",
"@reduxjs/toolkit": "^2.3.0",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-cli": "^2.0.0",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-opener": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
@ -33,6 +35,7 @@
"@tauri-apps/plugin-updater": "2.7.1",
"@types/react-redux": "^7.1.34",
"boring-avatars": "^1.11.2",
"dayjs": "^1.11.13",
"humanize-duration": "^3.32.1",
"jdenticon": "^3.3.0",
"lodash": "^4.17.21",

View file

@ -8,6 +8,7 @@ import {
SelectMakerDetails,
TauriBackgroundProgress,
TauriSwapProgressEvent,
SendMoneroDetails,
} from "./tauriModel";
export type TauriSwapProgressEventType = TauriSwapProgressEvent["type"];
@ -310,10 +311,13 @@ export type PendingSelectMakerApprovalRequest = PendingApprovalRequest & {
request: { type: "SelectMaker"; content: SelectMakerDetails };
};
export interface SortableQuoteWithAddress extends QuoteWithAddress {
expiration_ts?: number;
request_id?: string;
}
export type PendingSendMoneroApprovalRequest = PendingApprovalRequest & {
request: { type: "SendMonero"; content: SendMoneroDetails };
};
export type PendingPasswordApprovalRequest = PendingApprovalRequest & {
request: { type: "PasswordRequest"; content: { wallet_path: string } };
};
export function isPendingSelectMakerApprovalEvent(
event: ApprovalRequest,
@ -327,6 +331,30 @@ export function isPendingSelectMakerApprovalEvent(
return event.request.type === "SelectMaker";
}
export function isPendingSendMoneroApprovalEvent(
event: ApprovalRequest,
): event is PendingSendMoneroApprovalRequest {
// Check if the request is pending
if (event.request_status.state !== "Pending") {
return false;
}
// Check if the request is a SendMonero request
return event.request.type === "SendMonero";
}
export function isPendingPasswordApprovalEvent(
event: ApprovalRequest,
): event is PendingPasswordApprovalRequest {
// Check if the request is pending
if (event.request_status.state !== "Pending") {
return false;
}
// Check if the request is a PasswordRequest request
return event.request.type === "PasswordRequest";
}
/**
* Checks if any funds have been locked yet based on the swap progress event
* Returns true for events where funds have been locked

View file

@ -24,9 +24,16 @@ import {
listSellersAtRendezvousPoint,
refreshApprovals,
updateAllNodeStatuses,
fetchAndUpdateBackgroundItems,
fetchAndUpdateApprovalItems,
} from "./rpc";
import { store } from "./store/storeRenderer";
import { exhaustiveGuard } from "utils/typescriptUtils";
import {
setBalance,
setHistory,
setSyncProgress,
} from "store/features/walletSlice";
const TAURI_UNIFIED_EVENT_CHANNEL_NAME = "tauri-unified-event";
@ -45,7 +52,7 @@ const UPDATE_RATE_INTERVAL = 5 * 60 * 1_000;
// Fetch all conversations every 10 minutes
const FETCH_CONVERSATIONS_INTERVAL = 10 * 60 * 1_000;
// Fetch pending approvals every 10 seconds
// Fetch pending approvals every 2 seconds
const FETCH_PENDING_APPROVALS_INTERVAL = 2 * 1_000;
function setIntervalImmediate(callback: () => void, interval: number): void {
@ -137,6 +144,19 @@ export async function setupBackgroundTasks(): Promise<void> {
store.dispatch(poolStatusReceived(eventData));
break;
case "MoneroWalletUpdate":
console.log("MoneroWalletUpdate", eventData);
if (eventData.type === "BalanceChange") {
store.dispatch(setBalance(eventData.content));
}
if (eventData.type === "HistoryUpdate") {
store.dispatch(setHistory(eventData.content));
}
if (eventData.type === "SyncProgress") {
store.dispatch(setSyncProgress(eventData.content));
}
break;
default:
exhaustiveGuard(channelName);
}

View file

@ -20,7 +20,11 @@ import { setupBackgroundTasks } from "renderer/background";
import "@fontsource/roboto";
import FeedbackPage from "./pages/feedback/FeedbackPage";
import IntroductionModal from "./modal/introduction/IntroductionModal";
import MoneroWalletPage from "./pages/monero/MoneroWalletPage";
import SeedSelectionDialog from "./modal/seed-selection/SeedSelectionDialog";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import PasswordEntryDialog from "./modal/password-entry/PasswordEntryDialog";
declare module "@mui/material/styles" {
interface Theme {
@ -44,16 +48,19 @@ export default function App() {
return (
<StyledEngineProvider injectFirst>
<ThemeProvider theme={currentTheme}>
<CssBaseline />
<GlobalSnackbarProvider>
<IntroductionModal />
<SeedSelectionDialog />
<Router>
<Navigation />
<InnerContent />
<UpdaterDialog />
</Router>
</GlobalSnackbarProvider>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<CssBaseline />
<GlobalSnackbarProvider>
<IntroductionModal />
<SeedSelectionDialog />
<PasswordEntryDialog />
<Router>
<Navigation />
<InnerContent />
<UpdaterDialog />
</Router>
</GlobalSnackbarProvider>
</LocalizationProvider>
</ThemeProvider>
</StyledEngineProvider>
);
@ -70,12 +77,13 @@ function InnerContent() {
}}
>
<Routes>
<Route path="/" element={<MoneroWalletPage />} />
<Route path="/monero-wallet" element={<MoneroWalletPage />} />
<Route path="/swap" element={<SwapPage />} />
<Route path="/history" element={<HistoryPage />} />
<Route path="/wallet" element={<WalletPage />} />
<Route path="/bitcoin-wallet" element={<WalletPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/feedback" element={<FeedbackPage />} />
<Route path="/" element={<SwapPage />} />
</Routes>
</Box>
);

View file

@ -186,18 +186,11 @@ export default function DaemonStatusAlert() {
const contextStatus = useAppSelector((s) => s.rpc.status);
const navigate = useNavigate();
if (
contextStatus === null ||
contextStatus === TauriContextStatusEvent.NotInitialized
) {
return (
<LoadingSpinnerAlert severity="warning">
Checking for available remote nodes
</LoadingSpinnerAlert>
);
}
switch (contextStatus) {
case null:
return null;
case TauriContextStatusEvent.NotInitialized:
return null;
case TauriContextStatusEvent.Initializing:
return null;
case TauriContextStatusEvent.Available:

View file

@ -16,7 +16,7 @@ export default function FundsLeftInWalletAlert() {
<Button
variant="outlined"
size="small"
onClick={() => navigate("/wallet")}
onClick={() => navigate("/bitcoin-wallet")}
>
View
</Button>

View file

@ -1,3 +1,4 @@
import React from "react";
import { Box, Alert, AlertTitle } from "@mui/material";
import {
BobStateName,

View file

@ -23,7 +23,7 @@ type MoneroAddressTextFieldProps = TextFieldProps & {
address: string;
onAddressChange: (address: string) => void;
onAddressValidityChange: (valid: boolean) => void;
helperText: string;
helperText?: string;
};
export default function MoneroAddressTextField({

View file

@ -0,0 +1,178 @@
import { useEffect, useRef, useState } from "react";
import { darken, useTheme } from "@mui/material";
interface NumberInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
fontSize?: string;
fontWeight?: number;
textAlign?: "left" | "center" | "right";
minWidth?: number;
step?: number;
largeStep?: number;
className?: string;
style?: React.CSSProperties;
}
export default function NumberInput({
value,
onChange,
placeholder = "0.00",
fontSize = "2em",
fontWeight = 600,
textAlign = "right",
minWidth = 60,
step = 0.001,
largeStep = 0.1,
className,
style,
}: NumberInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [inputWidth, setInputWidth] = useState(minWidth);
const [isFocused, setIsFocused] = useState(false);
const measureRef = useRef<HTMLSpanElement>(null);
// Calculate precision from step value
const getDecimalPrecision = (num: number): number => {
const str = num.toString();
if (str.includes(".")) {
return str.split(".")[1].length;
}
return 0;
};
const [userPrecision, setUserPrecision] = useState(() =>
getDecimalPrecision(step),
); // Track user's decimal precision
const [minPrecision, setMinPrecision] = useState(3);
const appliedPrecision =
userPrecision > minPrecision ? userPrecision : minPrecision;
const theme = useTheme();
// Initialize with placeholder if no value provided
useEffect(() => {
if (
(!value || value.trim() === "" || parseFloat(value) === 0) &&
!isFocused
) {
onChange(placeholder);
}
}, [placeholder, isFocused, value, onChange]);
// Update precision when step changes
useEffect(() => {
setUserPrecision(getDecimalPrecision(step));
setMinPrecision(getDecimalPrecision(step));
}, [step]);
// Measure text width to size input dynamically
useEffect(() => {
if (measureRef.current) {
const text = value;
measureRef.current.textContent = text;
const textWidth = measureRef.current.offsetWidth;
setInputWidth(Math.max(textWidth + 5, minWidth)); // Add padding and minimum width
}
}, [value, placeholder, minWidth]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const currentValue = parseFloat(value) || 0;
const increment = e.shiftKey ? largeStep : step;
const newValue =
e.key === "ArrowUp"
? currentValue + increment
: Math.max(0, currentValue - increment);
// Use the user's precision for formatting
onChange(newValue.toFixed(appliedPrecision));
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
// Allow empty string, numbers, and decimal points
if (inputValue === "" || /^\d*\.?\d*$/.test(inputValue)) {
onChange(inputValue);
// Track the user's decimal precision
if (inputValue.includes(".")) {
const decimalPart = inputValue.split(".")[1];
setUserPrecision(decimalPart ? decimalPart.length : 0);
} else if (inputValue && !isNaN(parseFloat(inputValue))) {
// No decimal point, so precision is 0
setUserPrecision(0);
}
}
};
const handleBlur = () => {
setIsFocused(false);
// Reset to placeholder if value is zero or empty
if (!value || value.trim() === "" || parseFloat(value) === 0) {
onChange(placeholder);
} else if (!isNaN(parseFloat(value))) {
// Format valid numbers on blur using user's precision
const formatted = parseFloat(value).toFixed(appliedPrecision);
// Remove trailing zeros if precision allows
onChange(parseFloat(formatted).toString());
}
};
const handleFocus = () => {
setIsFocused(true);
};
// Determine if we should show placeholder styling
const isShowingPlaceholder = value === placeholder && !isFocused;
const defaultStyle: React.CSSProperties = {
fontSize,
fontWeight,
textAlign,
border: "none",
outline: "none",
background: "transparent",
width: `${inputWidth}px`,
minWidth: `${minWidth}px`,
fontFamily: "inherit",
color: isShowingPlaceholder
? darken(theme.palette.text.primary, 0.5)
: theme.palette.text.primary,
padding: "4px 0",
transition: "color 0.2s ease",
...style,
};
return (
<div style={{ position: "relative", display: "inline-block" }}>
{/* Hidden span for measuring text width */}
<span
ref={measureRef}
style={{
position: "absolute",
visibility: "hidden",
fontSize,
fontWeight,
fontFamily: "inherit",
whiteSpace: "nowrap",
}}
/>
<input
ref={inputRef}
type="text"
inputMode="decimal"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onFocus={handleFocus}
className={className}
style={defaultStyle}
/>
</div>
);
}

View file

@ -0,0 +1,126 @@
import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
Button,
IconButton,
InputAdornment,
} from "@mui/material";
import { Visibility, VisibilityOff } from "@mui/icons-material";
import { useState } from "react";
import { usePendingPasswordApproval } from "store/hooks";
import { rejectApproval, resolveApproval } from "renderer/rpc";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
export default function PasswordEntryDialog() {
const pendingApprovals = usePendingPasswordApproval();
const [password, setPassword] = useState<string>("");
const [error, setError] = useState<string>("");
const [showPassword, setShowPassword] = useState<boolean>(false);
const approval = pendingApprovals[0];
const accept = async () => {
if (!approval) {
throw new Error("No approval request found for password entry");
}
try {
await resolveApproval<string>(approval.request_id, password);
setPassword("");
setError("");
} catch (err) {
setError("Invalid password. Please try again.");
throw err;
}
};
const reject = async () => {
if (!approval) {
throw new Error("No approval request found for password entry");
}
try {
await rejectApproval<string>(approval.request_id, "");
setPassword("");
setError("");
} catch (err) {
console.error("Error rejecting password request:", err);
throw err;
}
};
const handleTogglePasswordVisibility = () => {
setShowPassword(!showPassword);
};
if (!approval) {
return null;
}
return (
<Dialog
open={true}
maxWidth="sm"
fullWidth
BackdropProps={{
sx: {
backdropFilter: "blur(8px)",
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
}}
>
<DialogTitle>Enter Wallet Password</DialogTitle>
<DialogContent>
<TextField
fullWidth
type={showPassword ? "text" : "password"}
label="Password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
if (error) setError("");
}}
error={!!error}
helperText={error}
autoFocus
margin="normal"
onKeyPress={(e) => {
if (e.key === "Enter") {
accept();
}
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={handleTogglePasswordVisibility}
edge="end"
aria-label="toggle password visibility"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={reject} variant="outlined">
Change wallet
</Button>
<PromiseInvokeButton
onInvoke={accept}
variant="contained"
requiresContext={false}
>
Unlock
</PromiseInvokeButton>
</DialogActions>
</Dialog>
);
}

View file

@ -1,5 +1,4 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
@ -10,17 +9,43 @@ import {
RadioGroup,
TextField,
Typography,
Button,
Box,
List,
ListItem,
ListItemButton,
ListItemText,
Divider,
Card,
CardContent,
} from "@mui/material";
import { useState, useEffect } 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";
export default function SeedSelectionDialog() {
const pendingApprovals = usePendingSeedSelectionApproval();
const [selectedOption, setSelectedOption] = useState<string>("RandomSeed");
const [selectedOption, setSelectedOption] = useState<
SeedChoice["type"] | undefined
>("RandomSeed");
const [customSeed, setCustomSeed] = useState<string>("");
const [isSeedValid, setIsSeedValid] = useState<boolean>(false);
const approval = pendingApprovals[0]; // Handle the first pending approval
const [walletPath, setWalletPath] = useState<string>("");
const approval = pendingApprovals[0];
// Extract recent wallets from the approval request content
const recentWallets =
approval?.request?.type === "SeedSelection"
? approval.request.content.recent_wallets
: [];
useEffect(() => {
if (selectedOption === "FromSeed" && customSeed.trim()) {
@ -36,51 +61,193 @@ export default function SeedSelectionDialog() {
}
}, [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" });
// Auto-select the first recent wallet if available
useEffect(() => {
if (recentWallets.length > 0) {
setSelectedOption("FromWalletPath");
setWalletPath(recentWallets[0]);
}
}, [recentWallets.length]);
const selectWalletFile = async () => {
const selected = await open({
multiple: false,
directory: false,
});
if (selected) {
setWalletPath(selected);
}
};
const Legacy = async () => {
if (!approval)
throw new Error("No approval request found for seed selection");
await resolveApproval<SeedChoice>(approval.request_id, {
type: "Legacy",
});
};
const accept = async () => {
if (!approval)
throw new Error("No approval request found for seed selection");
const seedChoice: SeedChoice =
selectedOption === "RandomSeed"
? { type: "RandomSeed" }
: selectedOption === "FromSeed"
? { type: "FromSeed", content: { seed: customSeed } }
: { type: "FromWalletPath", content: { wallet_path: walletPath } };
await resolveApproval<SeedChoice>(approval.request_id, seedChoice);
};
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>
// 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
const isDisabled =
selectedOption === "FromSeed"
? customSeed.trim().length === 0 || !isSeedValid
: selectedOption === "FromWalletPath"
? !walletPath
: false;
<FormControl component="fieldset">
<RadioGroup
value={selectedOption}
onChange={(e) => setSelectedOption(e.target.value)}
return (
<Dialog
open={true}
maxWidth="sm"
fullWidth
sx={{ "& .MuiDialog-paper": { minHeight: "min(32rem, 80vh)" } }}
BackdropProps={{
sx: {
backdropFilter: "blur(8px)",
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
}}
>
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
<Box sx={{ display: "flex", flexDirection: "row", gap: 2 }}>
{/* Open existing wallet option */}
<Card
sx={{
cursor: "pointer",
border: selectedOption === "FromWalletPath" ? 2 : 1,
borderColor:
selectedOption === "FromWalletPath"
? "primary.main"
: "divider",
"&:hover": { borderColor: "primary.main" },
flex: 1,
}}
onClick={() => setSelectedOption("FromWalletPath")}
>
<FormControlLabel
value="RandomSeed"
control={<Radio />}
label="Create a new wallet"
/>
<FormControlLabel
value="FromSeed"
control={<Radio />}
label="Restore wallet from seed"
/>
</RadioGroup>
</FormControl>
<CardContent
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 1,
}}
>
<FolderOpenIcon sx={{ fontSize: 32, color: "text.secondary" }} />
<Typography
variant="caption"
color="text.secondary"
sx={{ textAlign: "center" }}
>
Open wallet file
</Typography>
</CardContent>
</Card>
{/* Create new wallet option */}
<Card
sx={{
cursor: "pointer",
border: selectedOption === "RandomSeed" ? 2 : 1,
borderColor:
selectedOption === "RandomSeed" ? "primary.main" : "divider",
"&:hover": { borderColor: "primary.main" },
flex: 1,
}}
onClick={() => setSelectedOption("RandomSeed")}
>
<CardContent
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 1,
}}
>
<AddIcon sx={{ fontSize: 32, color: "text.secondary" }} />
<Typography
variant="caption"
color="text.secondary"
sx={{ textAlign: "center" }}
>
Create new wallet
</Typography>
</CardContent>
</Card>
{/* Restore from seed option */}
<Card
sx={{
cursor: "pointer",
border: selectedOption === "FromSeed" ? 2 : 1,
borderColor:
selectedOption === "FromSeed" ? "primary.main" : "divider",
"&:hover": { borderColor: "primary.main" },
flex: 1,
}}
onClick={() => setSelectedOption("FromSeed")}
>
<CardContent
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 1,
}}
>
<RefreshIcon sx={{ fontSize: 32, color: "text.secondary" }} />
<Typography
variant="caption"
color="text.secondary"
sx={{ textAlign: "center" }}
>
Restore from seed
</Typography>
</CardContent>
</Card>
</Box>
{selectedOption === "RandomSeed" && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: "center" }}
>
A new wallet with a random seed phrase will be generated.
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ textAlign: "center" }}
>
You will have the option to back it up later.
</Typography>
</Box>
)}
{selectedOption === "FromSeed" && (
<TextField
@ -90,7 +257,6 @@ export default function SeedSelectionDialog() {
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={
@ -102,19 +268,115 @@ export default function SeedSelectionDialog() {
}
/>
)}
{selectedOption === "FromWalletPath" && (
<Box sx={{ gap: 2, display: "flex", flexDirection: "column" }}>
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<TextField
fullWidth
label="Wallet file path"
value={walletPath || ""}
placeholder="Select a wallet file..."
InputProps={{
readOnly: true,
}}
/>
<Button
variant="outlined"
onClick={selectWalletFile}
sx={{ minWidth: "120px", height: "56px" }}
startIcon={<SearchIcon />}
>
Browse
</Button>
</Box>
{recentWallets.length > 0 && (
<Box>
<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={index}>
<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>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button
onClick={() => handleClose(true)}
variant="contained"
disabled={
selectedOption === "FromSeed"
? !customSeed.trim() || !isSeedValid
: false
}
<DialogActions sx={{ justifyContent: "space-between" }}>
<PromiseInvokeButton
variant="text"
onInvoke={Legacy}
requiresContext={false}
color="inherit"
>
Confirm
</Button>
No wallet (Legacy)
</PromiseInvokeButton>
<PromiseInvokeButton
onInvoke={accept}
variant="contained"
disabled={isDisabled}
requiresContext={false}
>
Continue
</PromiseInvokeButton>
</DialogActions>
</Dialog>
);

View file

@ -1,29 +0,0 @@
import { Box, DialogTitle, Typography } from "@mui/material";
import DebugPageSwitchBadge from "./pages/DebugPageSwitchBadge";
import FeedbackSubmitBadge from "./pages/FeedbackSubmitBadge";
export default function SwapDialogTitle({
title,
debug,
setDebug,
}: {
title: string;
debug: boolean;
setDebug: (d: boolean) => void;
}) {
return (
<DialogTitle
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h6">{title}</Typography>
<Box sx={{ display: "flex", alignItems: "center", gridGap: 1 }}>
<FeedbackSubmitBadge />
<DebugPageSwitchBadge enabled={debug} setEnabled={setDebug} />
</Box>
</DialogTitle>
);
}

View file

@ -22,6 +22,7 @@ export default function DebugPage() {
}}
>
<CliLogsBox
minHeight="min(20rem, 70vh)"
logs={logs}
label="Logs relevant to the swap (only current session)"
/>

View file

@ -5,26 +5,35 @@ import SwapHorizOutlinedIcon from "@mui/icons-material/SwapHorizOutlined";
import FeedbackOutlinedIcon from "@mui/icons-material/FeedbackOutlined";
import RouteListItemIconButton from "./RouteListItemIconButton";
import UnfinishedSwapsBadge from "./UnfinishedSwapsCountBadge";
import { useTotalUnreadMessagesCount } from "store/hooks";
import { useIsSwapRunning, useTotalUnreadMessagesCount } from "store/hooks";
import SettingsIcon from "@mui/icons-material/Settings";
import AttachMoneyIcon from "@mui/icons-material/AttachMoney";
import BitcoinIcon from "../icons/BitcoinIcon";
import MoneroIcon from "../icons/MoneroIcon";
export default function NavigationHeader() {
const totalUnreadCount = useTotalUnreadMessagesCount();
const isSwapRunning = useIsSwapRunning();
return (
<Box>
<List>
<RouteListItemIconButton name="Swap" route="/swap">
<SwapHorizOutlinedIcon />
<RouteListItemIconButton name="Wallet" route={["/monero-wallet", "/"]}>
<MoneroIcon />
</RouteListItemIconButton>
<RouteListItemIconButton name="Wallet" route="/bitcoin-wallet">
<BitcoinIcon />
</RouteListItemIconButton>
<RouteListItemIconButton name="Swap" route={["/swap"]}>
<Badge invisible={!isSwapRunning} variant="dot" color="primary">
<SwapHorizOutlinedIcon />
</Badge>
</RouteListItemIconButton>
<RouteListItemIconButton name="History" route="/history">
<UnfinishedSwapsBadge>
<HistoryOutlinedIcon />
</UnfinishedSwapsBadge>
</RouteListItemIconButton>
<RouteListItemIconButton name="Wallet" route="/wallet">
<AccountBalanceWalletIcon />
</RouteListItemIconButton>
<RouteListItemIconButton name="Feedback" route="/feedback">
<Badge
badgeContent={totalUnreadCount}

View file

@ -1,6 +1,6 @@
import { ListItemIcon, ListItemText } from "@mui/material";
import { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, useLocation } from "react-router-dom";
import ListItemButton from "@mui/material/ListItemButton";
@ -10,13 +10,31 @@ export default function RouteListItemIconButton({
children,
}: {
name: string;
route: string;
route: string[] | string;
children: ReactNode;
}) {
const navigate = useNavigate();
const location = useLocation();
const routeArray = Array.isArray(route) ? route : [route];
const firstRoute = routeArray[0];
const isSelected = routeArray.some((r) => location.pathname === r);
return (
<ListItemButton onClick={() => navigate(route)} key={name}>
<ListItemButton
onClick={() => navigate(firstRoute)}
key={name}
sx={
isSelected
? {
backgroundColor: "action.hover",
"&:hover": {
backgroundColor: "action.selected",
},
}
: undefined
}
>
<ListItemIcon>{children}</ListItemIcon>
<ListItemText primary={name} />
</ListItemButton>

View file

@ -1,15 +1,18 @@
import React from "react";
import { Badge } from "@mui/material";
import { useResumeableSwapsCountExcludingPunished } from "store/hooks";
import { useIsSwapRunning, useResumeableSwapsCountExcludingPunished } from "store/hooks";
export default function UnfinishedSwapsBadge({
children,
}: {
children: React.ReactNode;
}) {
const isSwapRunning = useIsSwapRunning();
const resumableSwapsCount = useResumeableSwapsCountExcludingPunished();
if (resumableSwapsCount > 0) {
const displayedResumableSwapsCount = isSwapRunning ? resumableSwapsCount - 1 : resumableSwapsCount;
if (displayedResumableSwapsCount > 0) {
return (
<Badge badgeContent={resumableSwapsCount} color="primary">
{children}

View file

@ -16,6 +16,7 @@ type Props = {
content: string;
displayCopyIcon?: boolean;
enableQrCode?: boolean;
light?: boolean;
};
function QRCodeModal({ open, onClose, content }: ModalProps) {
@ -57,6 +58,7 @@ export default function ActionableMonospaceTextBox({
content,
displayCopyIcon = true,
enableQrCode = true,
light = false,
}: Props) {
const [copied, setCopied] = useState(false);
const [qrCodeOpen, setQrCodeOpen] = useState(false);
@ -88,7 +90,7 @@ export default function ActionableMonospaceTextBox({
}}
>
<Box sx={{ flexGrow: 1 }} onClick={handleCopy}>
<MonospaceTextBox>
<MonospaceTextBox light={light}>
{content}
{displayCopyIcon && (
<IconButton

View file

@ -2,16 +2,18 @@ import { Box, Typography } from "@mui/material";
type Props = {
children: React.ReactNode;
light?: boolean;
};
export default function MonospaceTextBox({ children }: Props) {
export default function MonospaceTextBox({ children, light = false }: Props) {
return (
<Box
sx={(theme) => ({
display: "flex",
alignItems: "center",
backgroundColor: theme.palette.grey[900],
backgroundColor: light ? "transparent" : theme.palette.grey[900],
borderRadius: 2,
border: light ? `1px solid ${theme.palette.grey[800]}` : "none",
padding: theme.spacing(1),
})}
>

View file

@ -63,11 +63,13 @@ export default function CliLogsBox({
logs,
topRightButton = null,
autoScroll = false,
minHeight,
}: {
label: string;
logs: (CliLog | string)[];
topRightButton?: ReactNode;
autoScroll?: boolean;
minHeight?: string;
}) {
const [searchQuery, setSearchQuery] = useState<string>("");
@ -82,6 +84,7 @@ export default function CliLogsBox({
return (
<ScrollablePaperTextBox
minHeight={minHeight}
title={label}
copyValue={logsToRawString(logs)}
searchQuery={searchQuery}

View file

@ -81,7 +81,7 @@ export default function ScrollablePaperTextBox({
gap: "0.5rem",
}}
>
<VList ref={virtuaEl} style={{ height: MIN_HEIGHT, width: "100%" }}>
<VList ref={virtuaEl} style={{ height: "100vh", width: "100%" }}>
{rows}
</VList>
</Box>

View file

@ -12,7 +12,7 @@ export default function TruncatedText({
let finalChildren = children ?? "";
const truncatedText =
finalChildren.length > limit
finalChildren.length > limit
? truncateMiddle
? finalChildren.slice(0, Math.floor(limit / 2)) +
ellipsis +

View file

@ -32,16 +32,43 @@ export function AmountWithUnit({
return (
<Tooltip arrow title={title}>
<span>
{amount != null
? Number.parseFloat(amount.toFixed(fixedPrecision))
: "?"}{" "}
{unit}
{amount != null ? amount.toFixed(fixedPrecision) : "?"} {unit}
{parenthesisText != null ? ` (${parenthesisText})` : null}
</span>
</Tooltip>
);
}
export function FiatPiconeroAmount({
amount,
fixedPrecision = 2,
}: {
amount: Amount;
fixedPrecision?: number;
}) {
const xmrPrice = useAppSelector((state) => state.rates.xmrPrice);
const [fetchFiatPrices, fiatCurrency] = useSettings((settings) => [
settings.fetchFiatPrices,
settings.fiatCurrency,
]);
if (
!fetchFiatPrices ||
fiatCurrency == null ||
amount == null ||
xmrPrice == null
) {
return null;
}
return (
<span>
{(piconerosToXmr(amount) * xmrPrice).toFixed(fixedPrecision)}{" "}
{fiatCurrency}
</span>
);
}
AmountWithUnit.defaultProps = {
exchangeRate: null,
};
@ -59,14 +86,20 @@ export function BitcoinAmount({ amount }: { amount: Amount }) {
);
}
export function MoneroAmount({ amount }: { amount: Amount }) {
export function MoneroAmount({
amount,
fixedPrecision = 4,
}: {
amount: Amount;
fixedPrecision?: number;
}) {
const xmrRate = useAppSelector((state) => state.rates.xmrPrice);
return (
<AmountWithUnit
amount={amount}
unit="XMR"
fixedPrecision={4}
fixedPrecision={fixedPrecision}
exchangeRate={xmrRate}
/>
);
@ -128,8 +161,17 @@ export function SatsAmount({ amount }: { amount: Amount }) {
return <BitcoinAmount amount={btcAmount} />;
}
export function PiconeroAmount({ amount }: { amount: Amount }) {
export function PiconeroAmount({
amount,
fixedPrecision = 8,
}: {
amount: Amount;
fixedPrecision?: number;
}) {
return (
<MoneroAmount amount={amount == null ? null : piconerosToXmr(amount)} />
<MoneroAmount
amount={amount == null ? null : piconerosToXmr(amount)}
fixedPrecision={fixedPrecision}
/>
);
}

View file

@ -5,7 +5,6 @@ import HistoryTable from "./table/HistoryTable";
export default function HistoryPage() {
return (
<>
<Typography variant="h3">History</Typography>
<SwapTxLockAlertsBox />
<HistoryTable />
</>

View file

@ -7,6 +7,8 @@ import {
TableContainer,
TableHead,
TableRow,
Typography,
Skeleton,
} from "@mui/material";
import { useSwapInfosSortedByDate } from "../../../../../store/hooks";
import HistoryRow from "./HistoryRow";
@ -23,19 +25,75 @@ export default function HistoryTable() {
>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell />
<TableCell>ID</TableCell>
<TableCell>Amount</TableCell>
<TableCell>State</TableCell>
<TableCell />
</TableRow>
</TableHead>
{swapSortedByDate.length > 0 && (
<TableHead>
<TableRow>
<TableCell />
<TableCell>ID</TableCell>
<TableCell>Amount</TableCell>
<TableCell>State</TableCell>
<TableCell />
</TableRow>
</TableHead>
)}
<TableBody>
{swapSortedByDate.map((swap) => (
<HistoryRow {...swap} key={swap.swap_id} />
))}
{swapSortedByDate.length === 0 ? (
<>
<TableRow>
<TableCell colSpan={5} sx={{ textAlign: "center", py: 4 }}>
<Typography
variant="h6"
color="text.secondary"
gutterBottom
>
Nothing to see here
</Typography>
<Typography variant="body2" color="text.secondary">
You haven't made any swaps yet
</Typography>
</TableCell>
</TableRow>
{/* Skeleton rows for visual loading effect */}
{Array.from({ length: 3 }).map((_, index) => (
<TableRow key={index}>
<TableCell>
<Skeleton
animation={false}
variant="circular"
width={24}
height={24}
/>
</TableCell>
<TableCell>
<Skeleton animation={false} variant="text" width="80%" />
</TableCell>
<TableCell>
<Skeleton animation={false} variant="text" width="60%" />
</TableCell>
<TableCell>
<Skeleton
animation={false}
variant="rectangular"
width={80}
height={24}
/>
</TableCell>
<TableCell>
<Skeleton
animation={false}
variant="circular"
width={24}
height={24}
/>
</TableCell>
</TableRow>
))}
</>
) : (
swapSortedByDate.map((swap) => (
<HistoryRow {...swap} key={swap.swap_id} />
))
)}
</TableBody>
</Table>
</TableContainer>

View file

@ -43,7 +43,11 @@ export default function SwapLogFileOpenButton({
<Dialog open onClose={() => setLogs(null)} fullWidth maxWidth="lg">
<DialogTitle>Logs of swap {swapId}</DialogTitle>
<DialogContent>
<CliLogsBox logs={logs} label="Logs relevant to the swap" />
<CliLogsBox
minHeight="min(20rem, 70vh)"
logs={logs}
label="Logs relevant to the swap"
/>
</DialogContent>
<DialogActions>
<Button

View file

@ -0,0 +1,49 @@
import { useEffect } from "react";
import { Box } from "@mui/material";
import { useAppSelector } from "store/hooks";
import { initializeMoneroWallet } from "renderer/rpc";
import {
WalletOverview,
TransactionHistory,
WalletActionButtons,
} from "./components";
import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox";
import WalletPageLoadingState from "./components/WalletPageLoadingState";
// Main MoneroWalletPage component
export default function MoneroWalletPage() {
const { mainAddress, balance, syncProgress, history } = useAppSelector(
(state) => state.wallet.state,
);
useEffect(() => {
initializeMoneroWallet();
}, []);
const isLoading = balance === null;
if (isLoading) {
return <WalletPageLoadingState />;
}
return (
<Box
sx={{
maxWidth: 800,
mx: "auto",
display: "flex",
flexDirection: "column",
gap: 2,
pb: 2,
}}
>
<WalletOverview balance={balance} syncProgress={syncProgress} />
<ActionableMonospaceTextBox
content={mainAddress}
displayCopyIcon={true}
/>
<WalletActionButtons balance={balance} />
<TransactionHistory history={history} />
</Box>
);
}

View file

@ -0,0 +1,55 @@
import { Dialog } from "@mui/material";
import SendTransactionContent from "./components/SendTransactionContent";
import SendApprovalContent from "./components/SendApprovalContent";
import { useState } from "react";
import SendSuccessContent from "./components/SendSuccessContent";
import { usePendingSendMoneroApproval } from "store/hooks";
import { SendMoneroResponse } from "models/tauriModel";
interface SendTransactionModalProps {
open: boolean;
onClose: () => void;
balance: {
unlocked_balance: string;
};
}
export default function SendTransactionModal({
balance,
open,
onClose,
}: SendTransactionModalProps) {
const pendingApprovals = usePendingSendMoneroApproval();
const hasPendingApproval = pendingApprovals.length > 0;
const [successResponse, setSuccessResponse] = useState<SendMoneroResponse | null>(null);
const showSuccess = successResponse !== null;
const handleClose = () => {
onClose();
setSuccessResponse(null);
};
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="sm"
fullWidth={!showSuccess}
PaperProps={{
sx: { borderRadius: 2 },
}}
>
{!showSuccess && !hasPendingApproval && (
<SendTransactionContent balance={balance} onClose={onClose} onSuccess={setSuccessResponse} />
)}
{!showSuccess && hasPendingApproval && (
<SendApprovalContent onClose={onClose} />
)}
{showSuccess && (
<SendSuccessContent onClose={onClose} successDetails={successResponse} />
)}
</Dialog>
);
}

View file

@ -0,0 +1,142 @@
import {
Accordion,
AccordionDetails,
AccordionSummary,
Button,
Dialog,
DialogActions,
DialogContent,
TextField,
Typography,
Radio,
} from "@mui/material";
import { DialogTitle } from "@mui/material";
import { useState, useEffect } from "react";
import { getRestoreHeight, setMoneroRestoreHeight } from "renderer/rpc";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { Dayjs } from "dayjs";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
enum RestoreOption {
BlockHeight = "blockHeight",
RestoreDate = "restoreDate",
}
export default function SetRestoreHeightModal({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const [restoreOption, setRestoreOption] = useState(RestoreOption.BlockHeight);
const [restoreHeight, setRestoreHeight] = useState<number | "">("");
const [restoreDate, setRestoreDate] = useState<Dayjs | null>(null);
const [isPending, setIsPending] = useState(false);
const [currentRestoreHeight, setCurrentRestoreHeight] =
useState<string>("Loading...");
const handleRestoreHeight = async () => {
if (restoreOption === RestoreOption.BlockHeight) {
if (typeof restoreHeight === "number") {
await setMoneroRestoreHeight(restoreHeight);
}
} else if (restoreOption === RestoreOption.RestoreDate) {
if (restoreDate) {
await setMoneroRestoreHeight(restoreDate.toDate());
}
}
};
useEffect(() => {
const fetchCurrentRestoreHeight = async () => {
try {
const response = await getRestoreHeight();
setCurrentRestoreHeight(response.height.toString());
setRestoreHeight(response.height); // Set the input field to current height
} catch (error) {
console.error("Failed to fetch restore height:", error);
setCurrentRestoreHeight("Error");
}
};
if (open) {
fetchCurrentRestoreHeight();
}
}, [open, isPending]);
const accordionStyle = {
"& .MuiAccordionSummary-content": {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1,
},
"&::before": {
opacity: "1 !important",
},
};
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Set Restore Height</DialogTitle>
<DialogContent sx={{ minWidth: "500px", minHeight: "300px" }}>
<Accordion
elevation={0}
expanded={restoreOption === RestoreOption.BlockHeight}
onChange={() => setRestoreOption(RestoreOption.BlockHeight)}
disableGutters
sx={accordionStyle}
>
<AccordionSummary>
<Typography>Restore by block height</Typography>
<Radio checked={restoreOption === RestoreOption.BlockHeight} />
</AccordionSummary>
<AccordionDetails>
<TextField
label="Restore Height"
type="number"
value={restoreHeight}
onChange={(e) => {
const value = e.target.value;
setRestoreHeight(value === "" ? "" : Number(value));
}}
/>
</AccordionDetails>
</Accordion>
<Accordion
elevation={0}
expanded={restoreOption === RestoreOption.RestoreDate}
onChange={() => setRestoreOption(RestoreOption.RestoreDate)}
disableGutters
sx={accordionStyle}
>
<AccordionSummary>
<Typography>Restore by date</Typography>
<Radio checked={restoreOption === RestoreOption.RestoreDate} />
</AccordionSummary>
<AccordionDetails>
<DatePicker
label="Restore Date"
value={restoreDate}
disableFuture
onChange={(date) => setRestoreDate(date)}
/>
</AccordionDetails>
</Accordion>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<PromiseInvokeButton
onInvoke={handleRestoreHeight}
onSuccess={onClose}
displayErrorSnackbar={true}
onPendingChange={setIsPending}
>
Confirm
</PromiseInvokeButton>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,226 @@
import { Box, Button, Card, Grow, Typography } from "@mui/material";
import NumberInput from "renderer/components/inputs/NumberInput";
import SwapVertIcon from "@mui/icons-material/SwapVert";
import { useTheme } from "@mui/material/styles";
import { piconerosToXmr } from "../../../../../utils/conversionUtils";
import { MoneroAmount } from "renderer/components/other/Units";
interface SendAmountInputProps {
balance: {
unlocked_balance: string;
};
amount: string;
onAmountChange: (amount: string) => void;
onMaxClicked?: () => void;
onMaxToggled?: () => void;
currency: string;
onCurrencyChange: (currency: string) => void;
fiatCurrency: string;
xmrPrice: number;
showFiatRate: boolean;
disabled?: boolean;
}
export default function SendAmountInput({
balance,
amount,
currency,
onCurrencyChange,
onAmountChange,
onMaxClicked,
onMaxToggled,
fiatCurrency,
xmrPrice,
showFiatRate,
disabled = false,
}: SendAmountInputProps) {
const theme = useTheme();
const isMaxSelected = amount === "<MAX>";
// Calculate secondary amount for display
const secondaryAmount = (() => {
if (isMaxSelected) {
return "All available funds";
}
if (!amount || amount === "" || isNaN(parseFloat(amount))) {
return "0.00";
}
const primaryValue = parseFloat(amount);
if (currency === "XMR") {
// Primary is XMR, secondary is USD
return (primaryValue * xmrPrice).toFixed(2);
} else {
// Primary is USD, secondary is XMR
return (primaryValue / xmrPrice).toFixed(3);
}
})();
const handleMaxAmount = () => {
if (disabled) return;
if (onMaxToggled) {
onMaxToggled();
} else if (onMaxClicked) {
onMaxClicked();
} else {
// Fallback to old behavior if no callback provided
if (
balance?.unlocked_balance !== undefined &&
balance?.unlocked_balance !== null
) {
// TODO: We need to use a real fee here and call sweep(...) instead of just subtracting a fixed amount
const unlocked = parseFloat(balance.unlocked_balance);
const maxAmountXmr = piconerosToXmr(unlocked - 10000000000); // Subtract ~0.01 XMR for fees
if (currency === "XMR") {
onAmountChange(Math.max(0, maxAmountXmr).toString());
} else {
// Convert to USD for display
const maxAmountUsd = maxAmountXmr * xmrPrice;
onAmountChange(Math.max(0, maxAmountUsd).toString());
}
}
}
};
const handleMaxTextClick = () => {
if (disabled) return;
if (isMaxSelected && onMaxToggled) {
onMaxToggled();
}
};
const handleCurrencySwap = () => {
if (!isMaxSelected && !disabled) {
onCurrencyChange(currency === "XMR" ? fiatCurrency : "XMR");
}
};
const isAmountTooHigh =
!isMaxSelected &&
(currency === "XMR"
? parseFloat(amount) >
piconerosToXmr(parseFloat(balance.unlocked_balance))
: parseFloat(amount) / xmrPrice >
piconerosToXmr(parseFloat(balance.unlocked_balance)));
return (
<Card
elevation={0}
tabIndex={0}
sx={{
position: "relative",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
border: `1px solid ${theme.palette.grey[800]}`,
width: "100%",
height: 250,
opacity: disabled ? 0.6 : 1,
pointerEvents: disabled ? "none" : "auto",
}}
>
<Box
sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}
>
{isAmountTooHigh && (
<Grow
in
style={{ transitionDelay: isAmountTooHigh ? "100ms" : "0ms" }}
>
<Typography variant="caption" align="center" color="error">
You don't have enough
<br /> unlocked balance to send this amount.
</Typography>
</Grow>
)}
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1 }}>
{isMaxSelected ? (
<Typography
variant="h3"
onClick={handleMaxTextClick}
sx={{
fontWeight: 600,
color: "primary.main",
cursor: disabled ? "default" : "pointer",
userSelect: "none",
"&:hover": {
opacity: disabled ? 1 : 0.8,
},
}}
title={disabled ? "" : "Click to edit amount"}
>
&lt;MAX&gt;
</Typography>
) : (
<>
<NumberInput
value={amount}
onChange={disabled ? () => {} : onAmountChange}
placeholder={currency === "XMR" ? "0.000" : "0.00"}
fontSize="3em"
fontWeight={600}
minWidth={60}
step={currency === "XMR" ? 0.001 : 0.01}
largeStep={currency === "XMR" ? 0.1 : 10}
/>
<Typography variant="h4" color="text.secondary">
{currency}
</Typography>
</>
)}
</Box>
{showFiatRate && (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<SwapVertIcon
onClick={handleCurrencySwap}
sx={{
cursor: isMaxSelected || disabled ? "default" : "pointer",
opacity: isMaxSelected || disabled ? 0.5 : 1,
}}
/>
<Typography color="text.secondary">
{secondaryAmount}{" "}
{isMaxSelected ? "" : currency === "XMR" ? fiatCurrency : "XMR"}
</Typography>
</Box>
)}
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
width: "100%",
justifyContent: "center",
gap: 1.5,
position: "absolute",
bottom: 12,
left: 0,
}}
>
<Typography color="text.secondary">Available</Typography>
<Box sx={{ display: "flex", alignItems: "baseline", gap: 0.5 }}>
<Typography color="text.primary">
<MoneroAmount
amount={piconerosToXmr(parseFloat(balance.unlocked_balance))}
/>
</Typography>
<Typography color="text.secondary">XMR</Typography>
</Box>
<Button
variant={isMaxSelected ? "contained" : "secondary"}
size="tiny"
onClick={handleMaxAmount}
disabled={disabled}
>
Max
</Button>
</Box>
</Card>
);
}

View file

@ -0,0 +1,151 @@
import { useState, useEffect } from "react";
import {
DialogTitle,
DialogContent,
DialogActions,
Typography,
Box,
} from "@mui/material";
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import { resolveApproval } from "renderer/rpc";
import { usePendingSendMoneroApproval } from "store/hooks";
import { PiconeroAmount } from "renderer/components/other/Units";
import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
interface SendApprovalContentProps {
onClose: () => void;
}
export default function SendApprovalContent({
onClose,
}: SendApprovalContentProps) {
const pendingApprovals = usePendingSendMoneroApproval();
const [timeLeft, setTimeLeft] = useState<number>(0);
const approval = pendingApprovals[0]; // Handle the first approval request
useEffect(() => {
if (
!approval?.request_status ||
approval.request_status.state !== "Pending"
) {
return;
}
const expirationTs = approval.request_status.content.expiration_ts;
const expiresAtMs = expirationTs * 1000;
const tick = () => {
const remainingMs = Math.max(expiresAtMs - Date.now(), 0);
setTimeLeft(Math.ceil(remainingMs / 1000));
};
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [approval]);
const handleApprove = async () => {
if (!approval) throw new Error("No approval request available");
await resolveApproval(approval.request_id, true);
};
const handleReject = async () => {
if (!approval) throw new Error("No approval request available");
await resolveApproval(approval.request_id, false);
};
if (!approval) {
return null;
}
const { address, amount, fee } = approval.request.content;
return (
<>
<DialogTitle>
<Typography variant="h6" component="div">
Confirm Monero Transfer
</Typography>
</DialogTitle>
<DialogContent>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{/* Amount */}
<Box>
<Typography variant="subtitle2" gutterBottom>
Amount to Send
</Typography>
<Typography variant="h6" color="primary">
<PiconeroAmount amount={amount} fixedPrecision={12} />
</Typography>
</Box>
{/* Fee */}
<Box>
<Typography variant="subtitle2" gutterBottom>
Network Fee
</Typography>
<Typography variant="h6" color="text.secondary">
<PiconeroAmount amount={fee} fixedPrecision={12} />
</Typography>
</Box>
{/* Destination Address */}
<Box>
<Typography variant="subtitle2" gutterBottom>
Destination Address
</Typography>
<Typography variant="h6" color="text.secondary">
<ActionableMonospaceTextBox
content={address}
displayCopyIcon={true}
enableQrCode={false}
light={true}
/>
</Typography>
</Box>
{/* Time remaining */}
<Typography
variant="caption"
color="text.secondary"
sx={{ textAlign: "center" }}
>
{timeLeft > 0
? `Request expires in ${timeLeft}s`
: "Request expired"}
</Typography>
</Box>
</DialogContent>
<DialogActions sx={{ p: 3, gap: 1 }}>
<PromiseInvokeButton
onInvoke={handleReject}
onSuccess={onClose}
disabled={timeLeft === 0}
variant="outlined"
color="error"
startIcon={<CloseIcon />}
displayErrorSnackbar={true}
requiresContext={false}
>
Reject
</PromiseInvokeButton>
<PromiseInvokeButton
onInvoke={handleApprove}
disabled={timeLeft === 0}
variant="contained"
color="primary"
startIcon={<CheckIcon />}
displayErrorSnackbar={true}
requiresContext={false}
>
Send
</PromiseInvokeButton>
</DialogActions>
</>
);
}

View file

@ -0,0 +1,66 @@
import { Box, Button, Typography } from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { FiatPiconeroAmount, PiconeroAmount } from "renderer/components/other/Units";
import MonospaceTextBox from "renderer/components/other/MonospaceTextBox";
import ArrowOutwardIcon from "@mui/icons-material/ArrowOutward";
import { SendMoneroResponse } from "models/tauriModel";
import { getMoneroTxExplorerUrl } from "../../../../../utils/conversionUtils";
import { isTestnet } from "store/config";
import { open } from "@tauri-apps/plugin-shell";
export default function SendSuccessContent({
onClose,
successDetails,
}: {
onClose: () => void;
successDetails: SendMoneroResponse | null;
}) {
const address = successDetails?.address;
const amount = successDetails?.amount_sent;
const explorerUrl = getMoneroTxExplorerUrl(successDetails?.tx_hash, isTestnet());
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
minHeight: "400px",
minWidth: "500px",
gap: 7,
p: 4,
}}
>
<CheckCircleIcon sx={{ fontSize: 64, mt: 3 }} />
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 1,
}}
>
<Typography variant="h4">Transaction Published</Typography>
<Box sx={{ display: "flex", flexDirection: "row", alignItems: "center", gap: 1 }}>
<Typography variant="body1" color="text.secondary">Sent</Typography>
<Typography variant="body1" color="text.primary">
<PiconeroAmount amount={amount} fixedPrecision={4}/>
</Typography>
<Typography variant="body1" color="text.secondary">(<FiatPiconeroAmount amount={amount} />)</Typography>
</Box>
<Box sx={{ display: "flex", flexDirection: "row", alignItems: "center", gap: 1 }}>
<Typography variant="body1" color="text.secondary">to</Typography>
<Typography variant="body1" color="text.primary">
<MonospaceTextBox>{address.slice(0, 8)} ... {address.slice(-8)}</MonospaceTextBox>
</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
<Button onClick={onClose} variant="contained" color="primary">Done</Button>
<Button color="primary" size="small" endIcon={<ArrowOutwardIcon />} onClick={() => open(explorerUrl)}>View on Explorer</Button>
</Box>
</Box>
);
}

View file

@ -0,0 +1,179 @@
import {
Button,
Box,
DialogActions,
DialogContent,
DialogTitle,
Typography,
} from "@mui/material";
import { useState } from "react";
import { xmrToPiconeros } from "../../../../../utils/conversionUtils";
import SendAmountInput from "./SendAmountInput";
import MoneroAddressTextField from "renderer/components/inputs/MoneroAddressTextField";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import { sendMoneroTransaction } from "renderer/rpc";
import { useAppSelector } from "store/hooks";
import { SendMoneroResponse } from "models/tauriModel";
interface SendTransactionContentProps {
balance: {
unlocked_balance: string;
};
onClose: () => void;
onSuccess: (response: SendMoneroResponse) => void;
}
export default function SendTransactionContent({
balance,
onSuccess,
onClose,
}: SendTransactionContentProps) {
const [sendAddress, setSendAddress] = useState("");
const [sendAmount, setSendAmount] = useState("");
const [previousAmount, setPreviousAmount] = useState("");
const [enableSend, setEnableSend] = useState(false);
const [currency, setCurrency] = useState("XMR");
const [isMaxSelected, setIsMaxSelected] = useState(false);
const [isSending, setIsSending] = useState(false);
const showFiatRate = useAppSelector(
(state) => state.settings.fetchFiatPrices,
);
const fiatCurrency = useAppSelector((state) => state.settings.fiatCurrency);
const xmrPrice = useAppSelector((state) => state.rates.xmrPrice);
const handleCurrencyChange = (newCurrency: string) => {
if (!showFiatRate || !xmrPrice || isMaxSelected || isSending) {
return;
}
if (sendAmount === "" || parseFloat(sendAmount) === 0) {
setSendAmount(newCurrency === "XMR" ? "0.000" : "0.00");
} else {
setSendAmount(
newCurrency === "XMR"
? (parseFloat(sendAmount) / xmrPrice).toFixed(3)
: (parseFloat(sendAmount) * xmrPrice).toFixed(2),
);
}
setCurrency(newCurrency);
};
const handleMaxToggled = () => {
if (isSending) return;
if (isMaxSelected) {
// Disable MAX mode - restore previous amount
setIsMaxSelected(false);
setSendAmount(previousAmount);
} else {
// Enable MAX mode - save current amount first
setPreviousAmount(sendAmount);
setIsMaxSelected(true);
setSendAmount("<MAX>");
}
};
const handleAmountChange = (newAmount: string) => {
if (isSending) return;
if (newAmount !== "<MAX>") {
setIsMaxSelected(false);
}
setSendAmount(newAmount);
};
const handleAddressChange = (newAddress: string) => {
if (isSending) return;
setSendAddress(newAddress);
};
const moneroAmount =
currency === "XMR"
? parseFloat(sendAmount)
: parseFloat(sendAmount) / xmrPrice;
const handleSend = async () => {
if (!sendAddress) {
throw new Error("Address is required");
}
if (isMaxSelected) {
return sendMoneroTransaction({
address: sendAddress,
amount: { type: "Sweep" },
});
} else {
if (!sendAmount || sendAmount === "<MAX>") {
throw new Error("Amount is required");
}
return sendMoneroTransaction({
address: sendAddress,
amount: {
type: "Specific",
// Floor the amount to avoid rounding decimal amounts
// The amount is in piconeros, so it NEEDS to be a whole number
amount: Math.floor(xmrToPiconeros(moneroAmount)),
},
});
}
};
const handleSendSuccess = (response: SendMoneroResponse) => {
// Clear form after successful send
handleClear();
onSuccess(response);
};
const handleClear = () => {
setSendAddress("");
setSendAmount("");
setPreviousAmount("");
setIsMaxSelected(false);
};
const isSendDisabled =
!enableSend || (!isMaxSelected && (!sendAmount || sendAmount === "<MAX>"));
return (
<>
<DialogTitle>Send</DialogTitle>
<DialogContent>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<SendAmountInput
balance={balance}
amount={sendAmount}
onAmountChange={handleAmountChange}
onMaxToggled={handleMaxToggled}
currency={currency}
fiatCurrency={fiatCurrency}
xmrPrice={xmrPrice}
showFiatRate={showFiatRate}
onCurrencyChange={handleCurrencyChange}
disabled={isSending}
/>
<MoneroAddressTextField
address={sendAddress}
onAddressChange={handleAddressChange}
onAddressValidityChange={setEnableSend}
label="Send to"
fullWidth
disabled={isSending}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<PromiseInvokeButton
onInvoke={handleSend}
disabled={isSendDisabled}
onSuccess={handleSendSuccess}
onPendingChange={setIsSending}
>
Send
</PromiseInvokeButton>
</DialogActions>
</>
);
}

View file

@ -0,0 +1,58 @@
import { Box, darken, lighten, useTheme } from "@mui/material";
function getColor(colorName: string) {
const theme = useTheme();
switch (colorName) {
case "primary":
return theme.palette.primary.main;
case "secondary":
return theme.palette.secondary.main;
case "success":
return theme.palette.success.main;
case "warning":
return theme.palette.warning.main;
}
}
export default function StateIndicator({
color,
pulsating,
}: {
color: string;
pulsating: boolean;
}) {
const mainShade = getColor(color);
const darkShade = darken(mainShade, 0.4);
const glowShade = lighten(mainShade, 0.4);
const intensePulsatingStyles = {
animation: "pulse 2s infinite",
"@keyframes pulse": {
"0%": { opacity: 0.5 },
"50%": { opacity: 1 },
"100%": { opacity: 0.5 },
},
};
const softPulsatingStyles = {
animation: "pulse 3.5s infinite",
"@keyframes pulse": {
"0%": { opacity: 0.7 },
"50%": { opacity: 1 },
"100%": { opacity: 0.7 },
},
};
return (
<Box
sx={{
width: 10,
height: 10,
borderRadius: "50%",
backgroundImage: `radial-gradient(circle, ${mainShade}, ${darkShade})`,
boxShadow: `0 0 10px 0 ${glowShade}`,
...(pulsating ? intensePulsatingStyles : softPulsatingStyles),
}}
></Box>
);
}

View file

@ -0,0 +1,102 @@
import {
Typography,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
IconButton,
Tooltip,
Stack,
} from "@mui/material";
import { OpenInNew as OpenInNewIcon } from "@mui/icons-material";
import { open } from "@tauri-apps/plugin-shell";
import { PiconeroAmount } from "../../../other/Units";
import { getMoneroTxExplorerUrl } from "../../../../../utils/conversionUtils";
import { isTestnet } from "store/config";
import { TransactionInfo } from "models/tauriModel";
interface TransactionHistoryProps {
history?: {
transactions: TransactionInfo[];
};
}
// Component for displaying transaction history
export default function TransactionHistory({
history,
}: TransactionHistoryProps) {
if (!history || !history.transactions || history.transactions.length === 0) {
return <Typography variant="h5">Transaction History</Typography>;
}
return (
<>
<Typography variant="h5">Transaction History</Typography>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Amount</TableCell>
<TableCell>Fee</TableCell>
<TableCell align="right">Confirmations</TableCell>
<TableCell align="center">Explorer</TableCell>
</TableRow>
</TableHead>
<TableBody>
{[...history.transactions]
.sort((a, b) => a.confirmations - b.confirmations)
.map((tx, index) => (
<TableRow key={index}>
<TableCell>
<Stack direction="row" spacing={1} alignItems="center">
<PiconeroAmount amount={tx.amount} />
<Chip
label={tx.direction === "In" ? "Received" : "Sent"}
color={tx.direction === "In" ? "success" : "default"}
size="small"
/>
</Stack>
</TableCell>
<TableCell>
<PiconeroAmount amount={tx.fee} />
</TableCell>
<TableCell align="right">
<Chip
label={tx.confirmations}
color={tx.confirmations >= 10 ? "success" : "warning"}
size="small"
/>
</TableCell>
<TableCell align="center">
{tx.tx_hash && (
<Tooltip title="View on block explorer">
<IconButton
size="small"
onClick={() => {
const url = getMoneroTxExplorerUrl(
tx.tx_hash,
isTestnet(),
);
open(url);
}}
>
<OpenInNewIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
);
}

View file

@ -0,0 +1,144 @@
import {
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
ListItemIcon,
Menu,
MenuItem,
TextField,
Typography,
} from "@mui/material";
import {
Send as SendIcon,
SwapHoriz as SwapIcon,
Restore as RestoreIcon,
MoreHoriz as MoreHorizIcon,
} from "@mui/icons-material";
import { useState } from "react";
import { setMoneroRestoreHeight } from "renderer/rpc";
import SendTransactionModal from "../SendTransactionModal";
import { useNavigate } from "react-router-dom";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import SetRestoreHeightModal from "../SetRestoreHeightModal";
interface WalletActionButtonsProps {
balance: {
unlocked_balance: string;
};
}
function RestoreHeightDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const [restoreHeight, setRestoreHeight] = useState(0);
const handleRestoreHeight = async () => {
await setMoneroRestoreHeight(restoreHeight);
onClose();
};
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Restore Height</DialogTitle>
<DialogContent>
<TextField
label="Restore Height"
type="number"
value={restoreHeight}
onChange={(e) => setRestoreHeight(Number(e.target.value))}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<PromiseInvokeButton
onInvoke={handleRestoreHeight}
displayErrorSnackbar={true}
variant="contained"
>
Restore
</PromiseInvokeButton>
</DialogActions>
</Dialog>
);
}
export default function WalletActionButtons({
balance,
}: WalletActionButtonsProps) {
const navigate = useNavigate();
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [restoreHeightDialogOpen, setRestoreHeightDialogOpen] = useState(false);
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const menuOpen = Boolean(menuAnchorEl);
const handleMenuClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setMenuAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setMenuAnchorEl(null);
};
return (
<>
<SetRestoreHeightModal
open={restoreHeightDialogOpen}
onClose={() => setRestoreHeightDialogOpen(false)}
/>
<SendTransactionModal
balance={balance}
open={sendDialogOpen}
onClose={() => setSendDialogOpen(false)}
/>
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: 1,
mb: 2,
alignItems: "center",
}}
>
<Chip
icon={<SendIcon />}
label="Send"
variant="button"
clickable
onClick={() => setSendDialogOpen(true)}
/>
<Chip
onClick={() => navigate("/swap")}
icon={<SwapIcon />}
label="Swap"
variant="button"
clickable
/>
<IconButton onClick={handleMenuClick}>
<MoreHorizIcon />
</IconButton>
<Menu anchorEl={menuAnchorEl} open={menuOpen} onClose={handleMenuClose}>
<MenuItem
onClick={() => {
setRestoreHeightDialogOpen(true);
handleMenuClose();
}}
>
<ListItemIcon>
<RestoreIcon />
</ListItemIcon>
<Typography>Restore Height</Typography>
</MenuItem>
</Menu>
</Box>
</>
);
}

View file

@ -0,0 +1,148 @@
import {
Box,
Typography,
CircularProgress,
Button,
Card,
CardContent,
Divider,
CardHeader,
LinearProgress,
} from "@mui/material";
import { PiconeroAmount } from "../../../other/Units";
import { FiatPiconeroAmount } from "../../../other/Units";
import StateIndicator from "./StateIndicator";
interface WalletOverviewProps {
balance?: {
unlocked_balance: string;
total_balance: string;
};
syncProgress?: {
current_block: number;
target_block: number;
progress_percentage: number;
};
}
// Component for displaying wallet address and balance
export default function WalletOverview({
balance,
syncProgress,
}: WalletOverviewProps) {
const pendingBalance =
parseFloat(balance.total_balance) - parseFloat(balance.unlocked_balance);
const isSyncing = syncProgress && syncProgress.progress_percentage < 100;
const blocksLeft = syncProgress?.target_block - syncProgress?.current_block;
return (
<Card sx={{ p: 2, position: "relative", borderRadius: 2 }} elevation={4}>
{syncProgress && syncProgress.progress_percentage < 100 && (
<LinearProgress
value={syncProgress.progress_percentage}
variant="determinate"
sx={{
width: "100%",
position: "absolute",
top: 0,
left: 0,
}}
/>
)}
{/* Balance */}
<Box
sx={{
display: "grid",
gridTemplateColumns: "1.5fr 1fr max-content",
rowGap: 0.5,
columnGap: 2,
mb: 1,
}}
>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 1, gridColumn: "1", gridRow: "1" }}
>
Available Funds
</Typography>
<Typography variant="h4" sx={{ gridColumn: "1", gridRow: "2" }}>
<PiconeroAmount
amount={parseFloat(balance.unlocked_balance)}
fixedPrecision={4}
/>
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ gridColumn: "1", gridRow: "3" }}
>
<FiatPiconeroAmount amount={parseFloat(balance.unlocked_balance)} />
</Typography>
{pendingBalance > 0 && (
<>
<Typography
variant="body2"
color="warning"
sx={{
mb: 1,
animation: "pulse 2s infinite",
gridColumn: "2",
gridRow: "1",
alignSelf: "end",
}}
>
Pending
</Typography>
<Typography
variant="h5"
sx={{ gridColumn: "2", gridRow: "2", alignSelf: "center" }}
>
<PiconeroAmount amount={pendingBalance} fixedPrecision={4} />
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ gridColumn: "2", gridRow: "3" }}
>
<FiatPiconeroAmount amount={pendingBalance} />
</Typography>
</>
)}
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 1,
}}
>
<Typography variant="body2">
{isSyncing ? "syncing" : "synced"}
</Typography>
<StateIndicator
color={isSyncing ? "primary" : "success"}
pulsating={isSyncing}
/>
</Box>
{isSyncing && (
<Typography variant="body2" color="text.secondary">
{blocksLeft.toLocaleString()} blocks left
</Typography>
)}
</Box>
</Box>
</Card>
);
}

View file

@ -0,0 +1,87 @@
import { Box, Card, Chip, Skeleton, Typography } from "@mui/material";
import StateIndicator from "./StateIndicator";
import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox";
const DUMMY_ADDRESS =
"888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H";
export default function WalletPageLoadingState() {
return (
<Box
sx={{
maxWidth: 800,
mx: "auto",
display: "flex",
flexDirection: "column",
gap: 2,
pb: 2,
}}
>
<Card sx={{ p: 2, position: "relative", borderRadius: 2 }} elevation={4}>
{/* Balance */}
<Box
sx={{
display: "grid",
gridTemplateColumns: "1.5fr 1fr max-content",
rowGap: 0.5,
columnGap: 2,
mb: 1,
}}
>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 1, gridColumn: "1", gridRow: "1" }}
>
Available Funds
</Typography>
<Typography variant="h4" sx={{ gridColumn: "1", gridRow: "2" }}>
<Skeleton variant="text" width="80%" />
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ gridColumn: "1", gridRow: "3" }}
>
<Skeleton variant="text" width="40%" />
</Typography>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 1,
}}
>
<Typography variant="body2">loading</Typography>
<StateIndicator color="primary" pulsating={true} />
</Box>
</Box>
</Box>
</Card>
<Skeleton variant="rounded" width="100%">
<ActionableMonospaceTextBox content={DUMMY_ADDRESS} />
</Skeleton>
<Box sx={{ display: "flex", flexDirection: "row", gap: 2, mb: 2 }}>
{Array.from({ length: 2 }).map((_) => (
<Skeleton variant="rounded" sx={{ borderRadius: "100px" }}>
<Chip label="Loading..." variant="button" />
</Skeleton>
))}
</Box>
<Typography variant="h5">Transaction History</Typography>
<Skeleton variant="rounded" width="100%" height={40} />
</Box>
);
}

View file

@ -0,0 +1,5 @@
export { default as WalletOverview } from "./WalletOverview";
export { default as TransactionHistory } from "./TransactionHistory";
export { default as WalletActionButtons } from "./WalletActionButtons";
export { default as SendTransactionContent } from "./SendTransactionContent";
export { default as SendApprovalContent } from "./SendApprovalContent";

View file

@ -1,5 +1,5 @@
import React, { ReactNode } from "react";
import { Box, Link, Typography } from "@mui/material";
import { ReactNode } from "react";
import InfoBox from "./InfoBox";
import TruncatedText from "renderer/components/other/TruncatedText";

View file

@ -14,8 +14,8 @@ export default function InitPage() {
useState(false);
// We force this to true for now because the internal wallet is not really accessible from the GUI yet
const [useExternalRedeemAddress, _setUseExternalRedeemAddress] =
useState(true);
const [useExternalRedeemAddress, setUseExternalRedeemAddress] =
useState(true);
const [redeemAddressValid, setRedeemAddressValid] = useState(false);
const [refundAddressValid, setRefundAddressValid] = useState(false);
@ -40,14 +40,35 @@ export default function InitPage() {
}}
>
<Paper variant="outlined" style={{}}>
<MoneroAddressTextField
label="Monero redeem address"
address={redeemAddress}
onAddressChange={setRedeemAddress}
onAddressValidityChange={setRedeemAddressValid}
fullWidth
helperText="The monero will be sent to this address"
/>
<Tabs
value={useExternalRedeemAddress ? 1 : 0}
indicatorColor="primary"
variant="fullWidth"
onChange={(_, newValue) =>
setUseExternalRedeemAddress(newValue === 1)
}
>
<Tab label="Redeem to internal Monero wallet" value={0} />
<Tab label="Redeem to external Monero address" value={1} />
</Tabs>
<Box style={{ padding: "16px" }}>
{useExternalRedeemAddress ? (
<MoneroAddressTextField
label="External Monero redeem address"
address={redeemAddress}
onAddressChange={setRedeemAddress}
onAddressValidityChange={setRedeemAddressValid}
helperText="The monero will be sent to this address if the swap is successful."
fullWidth
/>
) : (
<Typography variant="caption">
The Monero will be sent to the internal Monero wallet of the
GUI. You can then withdraw them from there or use them for
another swap directly.
</Typography>
)}
</Box>
</Paper>
<Paper variant="outlined" style={{}}>

View file

@ -11,7 +11,6 @@ export default function WalletPage() {
gap: "1rem",
}}
>
<Typography variant="h3">Wallet</Typography>
<Alert severity="info">
You do not have to deposit money before starting a swap. Instead, you
will be greeted with a deposit address after you initiate one.

View file

@ -1,6 +1,23 @@
import { createTheme, ThemeOptions } from "@mui/material";
import { indigo } from "@mui/material/colors";
// Extend the theme to include custom chip variants
declare module "@mui/material/Chip" {
interface ChipPropsVariantOverrides {
button: true;
}
}
// Extend the theme to include custom button variants and sizes
declare module "@mui/material/Button" {
interface ButtonPropsVariantOverrides {
secondary: true;
}
interface ButtonPropsSizeOverrides {
tiny: true;
}
}
export enum Theme {
Light = "light",
Dark = "dark",
@ -33,7 +50,61 @@ const baseTheme: ThemeOptions = {
backgroundColor: "color-mix(in srgb, #bdbdbd 10%, transparent)",
},
},
sizeTiny: {
fontSize: "0.75rem",
fontWeight: 500,
padding: "4px 8px",
minHeight: "24px",
minWidth: "auto",
lineHeight: 1.2,
textTransform: "none",
borderRadius: "4px",
},
},
variants: [
{
props: { variant: "secondary" },
style: ({ theme }) => ({
backgroundColor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.08)"
: "rgba(0, 0, 0, 0.04)",
color: theme.palette.text.secondary,
"&:hover": {
backgroundColor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.12)"
: "rgba(0, 0, 0, 0.08)",
borderColor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.23)"
: "rgba(0, 0, 0, 0.23)",
},
"&:disabled": {
backgroundColor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.04)"
: "rgba(0, 0, 0, 0.02)",
color: theme.palette.text.disabled,
borderColor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.08)"
: "rgba(0, 0, 0, 0.08)",
},
}),
},
],
},
MuiChip: {
variants: [
{
props: { variant: "button" },
style: ({ theme }) => ({
padding: "12px 16px",
cursor: "pointer",
}),
},
],
},
MuiDialog: {
defaultProps: {

View file

@ -31,16 +31,31 @@ import {
RedactResponse,
GetCurrentSwapResponse,
LabeledMoneroAddress,
GetPendingApprovalsArgs,
GetMoneroHistoryResponse,
GetMoneroMainAddressResponse,
GetMoneroBalanceResponse,
SendMoneroArgs,
SendMoneroResponse,
GetMoneroSyncProgressResponse,
GetPendingApprovalsResponse,
RejectApprovalArgs,
RejectApprovalResponse,
SetRestoreHeightArgs,
SetRestoreHeightResponse,
GetRestoreHeightResponse,
} from "models/tauriModel";
import {
rpcSetBalance,
rpcSetSwapInfo,
approvalRequestsReplaced,
} from "store/features/rpcSlice";
import {
setMainAddress,
setBalance,
setSyncProgress,
setHistory,
} from "store/features/walletSlice";
import { store } from "./store/storeRenderer";
import { Maker } from "models/apiModel";
import { providerToConcatenatedMultiAddr } from "utils/multiAddrUtils";
import { MoneroRecoveryResponse } from "models/rpcModel";
import { ListSellersResponse } from "../models/tauriModel";
@ -417,6 +432,129 @@ export async function getMoneroAddresses(): Promise<GetMoneroAddressesResponse>
return await invokeNoArgs<GetMoneroAddressesResponse>("get_monero_addresses");
}
export async function getRestoreHeight(): Promise<GetRestoreHeightResponse> {
return await invokeNoArgs<GetRestoreHeightResponse>("get_restore_height");
}
export async function setMoneroRestoreHeight(
height: number | Date,
): Promise<SetRestoreHeightResponse> {
const args: SetRestoreHeightArgs =
typeof height === "number"
? { type: "Height", height: height }
: {
type: "Date",
height: {
year: height.getFullYear(),
month: height.getMonth() + 1, // JavaScript months are 0-indexed, but we want 1-indexed
day: height.getDate(),
},
};
return await invoke<SetRestoreHeightArgs, SetRestoreHeightResponse>(
"set_monero_restore_height",
args,
);
}
export async function getMoneroHistory(): Promise<GetMoneroHistoryResponse> {
return await invokeNoArgs<GetMoneroHistoryResponse>("get_monero_history");
}
export async function getMoneroMainAddress(): Promise<GetMoneroMainAddressResponse> {
return await invokeNoArgs<GetMoneroMainAddressResponse>(
"get_monero_main_address",
);
}
export async function getMoneroBalance(): Promise<GetMoneroBalanceResponse> {
return await invokeNoArgs<GetMoneroBalanceResponse>("get_monero_balance");
}
export async function sendMonero(
args: SendMoneroArgs,
): Promise<SendMoneroResponse> {
return await invoke<SendMoneroArgs, SendMoneroResponse>("send_monero", args);
}
export async function getMoneroSyncProgress(): Promise<GetMoneroSyncProgressResponse> {
return await invokeNoArgs<GetMoneroSyncProgressResponse>(
"get_monero_sync_progress",
);
}
// Wallet management functions that handle Redux dispatching
export async function initializeMoneroWallet() {
try {
const [
addressResponse,
balanceResponse,
syncProgressResponse,
historyResponse,
] = await Promise.all([
getMoneroMainAddress(),
getMoneroBalance(),
getMoneroSyncProgress(),
getMoneroHistory(),
]);
store.dispatch(setMainAddress(addressResponse.address));
store.dispatch(setBalance(balanceResponse));
store.dispatch(setSyncProgress(syncProgressResponse));
store.dispatch(setHistory(historyResponse));
} catch (err) {
console.error("Failed to fetch Monero wallet data:", err);
}
}
export async function sendMoneroTransaction(
args: SendMoneroArgs,
): Promise<SendMoneroResponse> {
try {
const response = await sendMonero(args);
// Refresh balance and history after sending - but don't let this block the response
Promise.all([
getMoneroBalance(),
getMoneroHistory(),
]).then(([newBalance, newHistory]) => {
store.dispatch(setBalance(newBalance));
store.dispatch(setHistory(newHistory));
}).catch(refreshErr => {
console.error("Failed to refresh wallet data after send:", refreshErr);
// Could emit a toast notification here
});
return response;
} catch (err) {
console.error("Failed to send Monero:", err);
throw err; // ✅ Re-throw so caller can handle appropriately
}
}
async function refreshWalletDataAfterTransaction() {
try {
const [newBalance, newHistory] = await Promise.all([
getMoneroBalance(),
getMoneroHistory(),
]);
store.dispatch(setBalance(newBalance));
store.dispatch(setHistory(newHistory));
} catch (err) {
console.error("Failed to refresh wallet data after transaction:", err);
// Maybe show a non-blocking notification to user
}
}
export async function updateMoneroSyncProgress() {
try {
const response = await getMoneroSyncProgress();
store.dispatch(setSyncProgress(response));
} catch (err) {
console.error("Failed to fetch sync progress:", err);
}
}
export async function getDataDir(): Promise<string> {
const testnet = isTestnet();
return await invoke<GetDataDirArgs, string>("get_data_dir", {
@ -424,22 +562,37 @@ export async function getDataDir(): Promise<string> {
});
}
export async function resolveApproval(
export async function resolveApproval<T>(
requestId: string,
accept: object,
accept: T,
): Promise<void> {
try {
await invoke<ResolveApprovalArgs, ResolveApprovalResponse>(
"resolve_approval_request",
{ request_id: requestId, accept },
{ request_id: requestId, accept: accept as object },
);
} catch (error) {
// Refresh approval list when resolve fails to keep UI in sync
} finally {
// Always refresh the approval list
await refreshApprovals();
throw error;
// Refresh the approval list a few miliseconds later to again
// Just to make sure :)
setTimeout(() => {
refreshApprovals();
}, 200);
}
}
export async function rejectApproval<T>(
requestId: string,
reject: T,
): Promise<void> {
await invoke<RejectApprovalArgs, RejectApprovalResponse>(
"reject_approval_request",
{ request_id: requestId },
);
}
export async function refreshApprovals(): Promise<void> {
const response = await invokeNoArgs<GetPendingApprovalsResponse>(
"get_pending_approvals",

View file

@ -7,6 +7,7 @@ import settingsSlice from "./features/settingsSlice";
import nodesSlice from "./features/nodesSlice";
import conversationsSlice from "./features/conversationsSlice";
import poolSlice from "./features/poolSlice";
import walletSlice from "./features/walletSlice";
export const reducers = {
swap: swapReducer,
@ -18,4 +19,5 @@ export const reducers = {
nodes: nodesSlice,
conversations: conversationsSlice,
pool: poolSlice,
wallet: walletSlice,
};

View file

@ -156,6 +156,18 @@ export const rpcSlice = createSlice({
backgroundProgressEventRemoved(slice, action: PayloadAction<string>) {
delete slice.state.background[action.payload];
},
rpcSetBackgroundItems(
slice,
action: PayloadAction<{ [key: string]: TauriBackgroundProgress }>,
) {
slice.state.background = action.payload;
},
rpcSetApprovalItems(
slice,
action: PayloadAction<{ [requestId: string]: ApprovalRequest }>,
) {
slice.state.approvalRequests = action.payload;
},
},
});
@ -175,6 +187,8 @@ export const {
approvalRequestsReplaced,
backgroundProgressEventReceived,
backgroundProgressEventRemoved,
rpcSetBackgroundItems,
rpcSetApprovalItems,
} = rpcSlice.actions;
export default rpcSlice.reducer;

View file

@ -0,0 +1,65 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import {
GetMoneroBalanceResponse,
GetMoneroHistoryResponse,
GetMoneroSyncProgressResponse,
} from "models/tauriModel";
interface WalletState {
// Wallet data
mainAddress: string | null;
balance: GetMoneroBalanceResponse | null;
syncProgress: GetMoneroSyncProgressResponse | null;
history: GetMoneroHistoryResponse | null;
}
export interface WalletSlice {
state: WalletState;
}
const initialState: WalletSlice = {
state: {
// Wallet data
mainAddress: null,
balance: null,
syncProgress: null,
history: null,
},
};
export const walletSlice = createSlice({
name: "wallet",
initialState,
reducers: {
// Wallet data actions
setMainAddress(slice, action: PayloadAction<string>) {
slice.state.mainAddress = action.payload;
},
setBalance(slice, action: PayloadAction<GetMoneroBalanceResponse>) {
slice.state.balance = action.payload;
},
setSyncProgress(
slice,
action: PayloadAction<GetMoneroSyncProgressResponse>,
) {
slice.state.syncProgress = action.payload;
},
setHistory(slice, action: PayloadAction<GetMoneroHistoryResponse>) {
slice.state.history = action.payload;
},
// Reset actions
resetWalletState(slice) {
slice.state = initialState.state;
},
},
});
export const {
setMainAddress,
setBalance,
setSyncProgress,
setHistory,
resetWalletState,
} = walletSlice.actions;
export default walletSlice.reducer;

View file

@ -12,6 +12,10 @@ import {
isPendingSelectMakerApprovalEvent,
haveFundsBeenLocked,
PendingSeedSelectionApprovalRequest,
PendingSendMoneroApprovalRequest,
isPendingSendMoneroApprovalEvent,
PendingPasswordApprovalRequest,
isPendingPasswordApprovalEvent,
} from "models/tauriModelExt";
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { AppDispatch, RootState } from "renderer/store/storeRenderer";
@ -207,6 +211,11 @@ export function usePendingLockBitcoinApproval(): PendingLockBitcoinApprovalReque
return approvals.filter((c) => isPendingLockBitcoinApprovalEvent(c));
}
export function usePendingSendMoneroApproval(): PendingSendMoneroApprovalRequest[] {
const approvals = usePendingApprovals();
return approvals.filter((c) => isPendingSendMoneroApprovalEvent(c));
}
export function usePendingSelectMakerApproval(): PendingSelectMakerApprovalRequest[] {
const approvals = usePendingApprovals();
return approvals.filter((c) => isPendingSelectMakerApprovalEvent(c));
@ -217,6 +226,11 @@ export function usePendingSeedSelectionApproval(): PendingSeedSelectionApprovalR
return approvals.filter((c) => isPendingSeedSelectionApprovalEvent(c));
}
export function usePendingPasswordApproval(): PendingPasswordApprovalRequest[] {
const approvals = usePendingApprovals();
return approvals.filter((c) => isPendingPasswordApprovalEvent(c));
}
/// Returns all the pending background processes
/// In the format [id, {componentName, {type: "Pending", content: {consumed, total}}}]
export function usePendingBackgroundProcesses(): [

View file

@ -6,6 +6,7 @@ import {
updateAllNodeStatuses,
fetchSellersAtPresetRendezvousPoints,
getSwapInfo,
initializeMoneroWallet,
} from "renderer/rpc";
import logger from "utils/logger";
import { contextStatusEventReceived } from "store/features/rpcSlice";
@ -69,6 +70,7 @@ export function createMainListeners() {
checkBitcoinBalance(),
getAllSwapInfos(),
fetchSellersAtPresetRendezvousPoints(),
initializeMoneroWallet(),
]);
}
},

View file

@ -15,6 +15,10 @@ export function piconerosToXmr(piconeros: number): number {
return piconeros / 1000000000000;
}
export function xmrToPiconeros(xmr: number): number {
return Math.ceil(xmr * 1000000000000);
}
export function isXmrAddressValid(address: string, stagenet: boolean) {
const re = stagenet
? "^(?:[57][0-9A-Za-z]{94}|[57][0-9A-Za-z]{105})$"

File diff suppressed because it is too large Load diff