fix(gui): Strictly enforce UI types with Typescript (#678)

* fix(gui): we were not checking for null everywhere

* add more null checks, enable tsconfig strict

* remove dead code

* more nullish checks

* remove unused JSONViewTree.tsx

* fix a bunch of small typescript lints

* add explanations as to why LabeledMoneroAddress.address is non-nullish but we pass in null due to typeshare limitation

* remove @mui/lab from yarn.lock

* re-add SortableQuoteWithAddress

* add guard function for ExportBitcoinWalletResponseExt ("wallet_descriptor")

* fix remaining linter errors

* remove duplicate XMR

* fix hasUnusualAmountOfTimePassed in SwapStatusAlert.tsx
This commit is contained in:
Mohan 2025-11-05 18:38:00 +01:00 committed by GitHub
parent 3ce8e360c5
commit 5948a40c8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 312 additions and 648 deletions

View file

@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
- GUI: Fix an issue where an error in the UI runtime would cause a white screen to be displayed and nothing would be rendered.
## [3.2.8] - 2025-11-02 ## [3.2.8] - 2025-11-02
- ASB + CONTROLLER: Add a `registration-status` command to the controller shell. You can use it to get the registration status of the ASB at the configured rendezvous points. - ASB + CONTROLLER: Add a `registration-status` command to the controller shell. You can use it to get the registration status of the ASB at the configured rendezvous points.

View file

@ -20,7 +20,6 @@
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@fontsource/roboto": "^5.1.0", "@fontsource/roboto": "^5.1.0",
"@mui/icons-material": "^7.1.1", "@mui/icons-material": "^7.1.1",
"@mui/lab": "^7.0.0-beta.13",
"@mui/material": "^7.1.1", "@mui/material": "^7.1.1",
"@mui/x-date-pickers": "^8.8.0", "@mui/x-date-pickers": "^8.8.0",
"@reduxjs/toolkit": "^2.3.0", "@reduxjs/toolkit": "^2.3.0",

View file

@ -8,6 +8,8 @@ import {
TauriSwapProgressEvent, TauriSwapProgressEvent,
SendMoneroDetails, SendMoneroDetails,
ContextStatus, ContextStatus,
QuoteWithAddress,
ExportBitcoinWalletResponse,
} from "./tauriModel"; } from "./tauriModel";
import { import {
ContextStatusType, ContextStatusType,
@ -17,6 +19,16 @@ import {
export type TauriSwapProgressEventType = TauriSwapProgressEvent["type"]; export type TauriSwapProgressEventType = TauriSwapProgressEvent["type"];
// Wrapper for QuoteWithAddress with an optional approval request
// Approving that request will result in a swap being initiated with that maker
export type SortableQuoteWithAddress = {
quote_with_address: QuoteWithAddress;
approval: {
request_id: string;
expiration_ts: number;
} | null;
};
export type TauriSwapProgressEventContent< export type TauriSwapProgressEventContent<
T extends TauriSwapProgressEventType, T extends TauriSwapProgressEventType,
> = Extract<TauriSwapProgressEvent, { type: T }>["content"]; > = Extract<TauriSwapProgressEvent, { type: T }>["content"];
@ -354,9 +366,9 @@ export function isPendingPasswordApprovalEvent(
* @returns True if funds have been locked, false otherwise * @returns True if funds have been locked, false otherwise
*/ */
export function haveFundsBeenLocked( export function haveFundsBeenLocked(
event: TauriSwapProgressEvent | null, event: TauriSwapProgressEvent | null | undefined,
): boolean { ): boolean {
if (event === null) { if (event === null || event === undefined) {
return false; return false;
} }
@ -372,7 +384,7 @@ export function haveFundsBeenLocked(
} }
export function isContextFullyInitialized( export function isContextFullyInitialized(
status: ResultContextStatus, status: ResultContextStatus | null,
): boolean { ): boolean {
if (status == null || status.type === ContextStatusType.Error) { if (status == null || status.type === ContextStatusType.Error) {
return false; return false;
@ -396,3 +408,20 @@ export function isContextWithMoneroWallet(
): boolean { ): boolean {
return status?.monero_wallet_available ?? false; return status?.monero_wallet_available ?? false;
} }
export type ExportBitcoinWalletResponseExt = ExportBitcoinWalletResponse & {
wallet_descriptor: {
descriptor: string;
};
};
export function hasDescriptorProperty(
response: ExportBitcoinWalletResponse,
): response is ExportBitcoinWalletResponseExt {
return (
typeof response.wallet_descriptor === "object" &&
response.wallet_descriptor !== null &&
"descriptor" in response.wallet_descriptor &&
typeof (response.wallet_descriptor as { descriptor?: unknown }).descriptor === "string"
);
}

View file

@ -26,6 +26,7 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import PasswordEntryDialog from "./modal/password-entry/PasswordEntryDialog"; import PasswordEntryDialog from "./modal/password-entry/PasswordEntryDialog";
import ContextErrorDialog from "./modal/context-error/ContextErrorDialog"; import ContextErrorDialog from "./modal/context-error/ContextErrorDialog";
import ErrorBoundary from "./other/ErrorBoundary";
declare module "@mui/material/styles" { declare module "@mui/material/styles" {
interface Theme { interface Theme {
@ -47,24 +48,26 @@ export default function App() {
console.log("Current theme:", { theme, currentTheme }); console.log("Current theme:", { theme, currentTheme });
return ( return (
<StyledEngineProvider injectFirst> <ErrorBoundary>
<ThemeProvider theme={currentTheme}> <StyledEngineProvider injectFirst>
<LocalizationProvider dateAdapter={AdapterDayjs}> <ThemeProvider theme={currentTheme}>
<CssBaseline /> <LocalizationProvider dateAdapter={AdapterDayjs}>
<GlobalSnackbarProvider> <CssBaseline />
<IntroductionModal /> <GlobalSnackbarProvider>
<SeedSelectionDialog /> <IntroductionModal />
<PasswordEntryDialog /> <SeedSelectionDialog />
<ContextErrorDialog /> <PasswordEntryDialog />
<Router> <ContextErrorDialog />
<Navigation /> <Router>
<InnerContent /> <Navigation />
<UpdaterDialog /> <InnerContent />
</Router> <UpdaterDialog />
</GlobalSnackbarProvider> </Router>
</LocalizationProvider> </GlobalSnackbarProvider>
</ThemeProvider> </LocalizationProvider>
</StyledEngineProvider> </ThemeProvider>
</StyledEngineProvider>
</ErrorBoundary>
); );
} }
@ -79,13 +82,13 @@ function InnerContent() {
}} }}
> >
<Routes> <Routes>
<Route path="/" element={<MoneroWalletPage />} /> <Route path="/" element={<ErrorBoundary><MoneroWalletPage /></ErrorBoundary>} />
<Route path="/monero-wallet" element={<MoneroWalletPage />} /> <Route path="/monero-wallet" element={<ErrorBoundary><MoneroWalletPage /></ErrorBoundary>} />
<Route path="/swap" element={<SwapPage />} /> <Route path="/swap" element={<ErrorBoundary><SwapPage /></ErrorBoundary>} />
<Route path="/history" element={<HistoryPage />} /> <Route path="/history" element={<ErrorBoundary><HistoryPage /></ErrorBoundary>} />
<Route path="/bitcoin-wallet" element={<WalletPage />} /> <Route path="/bitcoin-wallet" element={<ErrorBoundary><WalletPage /></ErrorBoundary>} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<ErrorBoundary><SettingsPage /></ErrorBoundary>} />
<Route path="/feedback" element={<FeedbackPage />} /> <Route path="/feedback" element={<ErrorBoundary><FeedbackPage /></ErrorBoundary>} />
</Routes> </Routes>
</Box> </Box>
); );

View file

@ -34,7 +34,7 @@ interface PromiseInvokeButtonProps<T> {
export default function PromiseInvokeButton<T>({ export default function PromiseInvokeButton<T>({
disabled = false, disabled = false,
onSuccess = null, onSuccess,
onInvoke, onInvoke,
children, children,
startIcon, startIcon,
@ -44,7 +44,7 @@ export default function PromiseInvokeButton<T>({
isIconButton = false, isIconButton = false,
isChipButton = false, isChipButton = false,
displayErrorSnackbar = false, displayErrorSnackbar = false,
onPendingChange = null, onPendingChange,
contextRequirement = true, contextRequirement = true,
tooltipTitle = null, tooltipTitle = null,
...rest ...rest

View file

@ -69,11 +69,7 @@ function BitcoinRedeemedStateAlert({ swap }: { swap: GetSwapInfoResponseExt }) {
"If this step fails, you can manually redeem your funds", "If this step fails, you can manually redeem your funds",
]} ]}
/> />
<SwapMoneroRecoveryButton <SwapMoneroRecoveryButton swap={swap} size="small" variant="contained" />
swap={swap}
size="small"
variant="contained"
/>
</Box> </Box>
); );
} }
@ -208,10 +204,7 @@ export function StateAlert({
); );
case "Cancel": case "Cancel":
return ( return (
<BitcoinPossiblyCancelledAlert <BitcoinPossiblyCancelledAlert timelock={timelock} swap={swap} />
timelock={timelock}
swap={swap}
/>
); );
case "Punish": case "Punish":
return <PunishTimelockExpiredAlert />; return <PunishTimelockExpiredAlert />;
@ -235,6 +228,7 @@ export function StateAlert({
// 72 is the default cancel timelock in blocks // 72 is the default cancel timelock in blocks
// 4 blocks are around 40 minutes // 4 blocks are around 40 minutes
// If the swap has taken longer than 40 minutes, we consider it unusual // If the swap has taken longer than 40 minutes, we consider it unusual
// See: swap-env/src/env.rs
const UNUSUAL_AMOUNT_OF_TIME_HAS_PASSED_THRESHOLD = 72 - 4; const UNUSUAL_AMOUNT_OF_TIME_HAS_PASSED_THRESHOLD = 72 - 4;
/** /**
@ -246,10 +240,16 @@ export default function SwapStatusAlert({
swap, swap,
onlyShowIfUnusualAmountOfTimeHasPassed, onlyShowIfUnusualAmountOfTimeHasPassed,
}: { }: {
swap: GetSwapInfoResponseExt; swap: GetSwapInfoResponseExt | null;
onlyShowIfUnusualAmountOfTimeHasPassed?: boolean; onlyShowIfUnusualAmountOfTimeHasPassed?: boolean;
}) { }) {
const timelock = useAppSelector(selectSwapTimelock(swap.swap_id)); const swapId = swap?.swap_id ?? null;
const timelock = useAppSelector(selectSwapTimelock(swapId));
const isRunning = useIsSpecificSwapRunning(swapId);
if (swap == null) {
return null;
}
if (!isGetSwapInfoResponseRunningSwap(swap)) { if (!isGetSwapInfoResponseRunningSwap(swap)) {
return null; return null;
@ -263,12 +263,10 @@ export default function SwapStatusAlert({
timelock.type === "None" && timelock.type === "None" &&
timelock.content.blocks_left > UNUSUAL_AMOUNT_OF_TIME_HAS_PASSED_THRESHOLD; timelock.content.blocks_left > UNUSUAL_AMOUNT_OF_TIME_HAS_PASSED_THRESHOLD;
if (onlyShowIfUnusualAmountOfTimeHasPassed && hasUnusualAmountOfTimePassed) { if (onlyShowIfUnusualAmountOfTimeHasPassed && !hasUnusualAmountOfTimePassed) {
return null; return null;
} }
const isRunning = useIsSpecificSwapRunning(swap.swap_id);
return ( return (
<Alert <Alert
key={swap.swap_id} key={swap.swap_id}
@ -290,8 +288,7 @@ export default function SwapStatusAlert({
) )
) : ( ) : (
<> <>
Swap <TruncatedText>{swap.swap_id}</TruncatedText> is Swap <TruncatedText>{swap.swap_id}</TruncatedText> is not running
not running
</> </>
)} )}
</AlertTitle> </AlertTitle>
@ -303,7 +300,7 @@ export default function SwapStatusAlert({
}} }}
> >
<StateAlert swap={swap} timelock={timelock} isRunning={isRunning} /> <StateAlert swap={swap} timelock={timelock} isRunning={isRunning} />
<TimelockTimeline swap={swap} timelock={timelock} /> {timelock && <TimelockTimeline swap={swap} timelock={timelock} />}
</Box> </Box>
</Alert> </Alert>
); );

View file

@ -50,7 +50,7 @@ function TimelineSegment({
opacity: isActive ? 1 : 0.3, opacity: isActive ? 1 : 0.3,
}} }}
> >
{isActive && ( {isActive && durationOfSegment && (
<Box <Box
sx={{ sx={{
position: "absolute", position: "absolute",

View file

@ -1,115 +0,0 @@
import { Box, Chip, Paper, Tooltip, Typography } from "@mui/material";
import { VerifiedUser } from "@mui/icons-material";
import { ExtendedMakerStatus } from "models/apiModel";
import TruncatedText from "renderer/components/other/TruncatedText";
import {
MoneroBitcoinExchangeRate,
SatsAmount,
} from "renderer/components/other/Units";
import { getMarkup, satsToBtc, secondsToDays } from "utils/conversionUtils";
import { isMakerOutdated, isMakerVersionOutdated } from "utils/multiAddrUtils";
import WarningIcon from "@mui/icons-material/Warning";
import { useAppSelector } from "store/hooks";
import IdentIcon from "renderer/components/icons/IdentIcon";
/**
* A chip that displays the markup of the maker's exchange rate compared to the market rate.
*/
function MakerMarkupChip({ maker }: { maker: ExtendedMakerStatus }) {
const marketExchangeRate = useAppSelector((s) => s.rates?.xmrBtcRate);
if (marketExchangeRate == null) return null;
const makerExchangeRate = satsToBtc(maker.price);
/** The markup of the exchange rate compared to the market rate in percent */
const markup = getMarkup(makerExchangeRate, marketExchangeRate);
return (
<Tooltip title="The markup this maker charges compared to centralized markets. A lower markup means that you get more Monero for your Bitcoin.">
<Chip label={`Markup ${markup.toFixed(2)}%`} />
</Tooltip>
);
}
export default function MakerInfo({ maker }: { maker: ExtendedMakerStatus }) {
const isOutdated = isMakerOutdated(maker);
return (
<Box
sx={{
flex: 1,
"& *": {
lineBreak: "anywhere",
},
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Tooltip
title={
"This avatar is deterministically derived from the public key of the maker"
}
arrow
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<IdentIcon value={maker.peerId} size={"3rem"} />
</Box>
</Tooltip>
<Box>
<Typography variant="subtitle1">
<TruncatedText limit={16} truncateMiddle>
{maker.peerId}
</TruncatedText>
</Typography>
<Typography color="textSecondary" variant="body2">
{maker.multiAddr}
</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", flexDirection: "column" }}>
<Typography variant="caption">
Exchange rate:{" "}
<MoneroBitcoinExchangeRate rate={satsToBtc(maker.price)} />
</Typography>
<Typography variant="caption">
Minimum amount: <SatsAmount amount={maker.minSwapAmount} />
</Typography>
<Typography variant="caption">
Maximum amount: <SatsAmount amount={maker.maxSwapAmount} />
</Typography>
</Box>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
{maker.testnet && <Chip label="Testnet" />}
{maker.uptime && (
<Tooltip title="A high uptime (>90%) indicates reliability. Makers with very low uptime may be unreliable and cause swaps to take longer to complete or fail entirely.">
<Chip label={`${Math.round(maker.uptime * 100)}% uptime`} />
</Tooltip>
)}
{maker.age && (
<Chip
label={`Went online ${Math.round(secondsToDays(maker.age))} ${
maker.age === 1 ? "day" : "days"
} ago`}
/>
)}
{maker.recommended === true && (
<Tooltip title="This maker has shown to be exceptionally reliable">
<Chip label="Recommended" icon={<VerifiedUser />} color="primary" />
</Tooltip>
)}
{isOutdated && (
<Tooltip title="This maker is running an older version of the software. Outdated makers may be unreliable and cause swaps to take longer to complete or fail entirely.">
<Chip label="Outdated" icon={<WarningIcon />} color="primary" />
</Tooltip>
)}
{maker.version && (
<Tooltip title="The version of the maker's software">
<Chip label={`v${maker.version}`} />
</Tooltip>
)}
<MakerMarkupChip maker={maker} />
</Box>
</Box>
);
}

View file

@ -1,85 +0,0 @@
import {
Avatar,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List,
ListItemAvatar,
ListItemText,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import SearchIcon from "@mui/icons-material/Search";
import { ExtendedMakerStatus } from "models/apiModel";
import { useState } from "react";
import { setSelectedMaker } from "store/features/makersSlice";
import { useAllMakers, useAppDispatch } from "store/hooks";
import MakerInfo from "./MakerInfo";
import MakerSubmitDialog from "./MakerSubmitDialog";
import ListItemButton from "@mui/material/ListItemButton";
type MakerSelectDialogProps = {
open: boolean;
onClose: () => void;
};
export function MakerSubmitDialogOpenButton() {
const [open, setOpen] = useState(false);
return (
<ListItemButton
autoFocus
onClick={() => {
// Prevents background from being clicked and reopening dialog
if (!open) {
setOpen(true);
}
}}
>
<MakerSubmitDialog open={open} onClose={() => setOpen(false)} />
<ListItemAvatar>
<Avatar>
<AddIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Add a new maker to public registry" />
</ListItemButton>
);
}
export default function MakerListDialog({
open,
onClose,
}: MakerSelectDialogProps) {
const makers = useAllMakers();
const dispatch = useAppDispatch();
function handleMakerChange(maker: ExtendedMakerStatus) {
dispatch(setSelectedMaker(maker));
onClose();
}
return (
<Dialog onClose={onClose} open={open}>
<DialogTitle>Select a maker</DialogTitle>
<DialogContent sx={{ padding: 0 }} dividers>
<List>
{makers.map((maker) => (
<ListItemButton
onClick={() => handleMakerChange(maker)}
key={maker.peerId}
>
<MakerInfo maker={maker} key={maker.peerId} />
</ListItemButton>
))}
<MakerSubmitDialogOpenButton />
</List>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -1,38 +0,0 @@
import { Paper, Card, CardContent, IconButton } from "@mui/material";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
import { useState } from "react";
import { useAppSelector } from "store/hooks";
import MakerInfo from "./MakerInfo";
import MakerListDialog from "./MakerListDialog";
export default function MakerSelect() {
const [selectDialogOpen, setSelectDialogOpen] = useState(false);
const selectedMaker = useAppSelector((state) => state.makers.selectedMaker);
if (!selectedMaker) return <>No maker selected</>;
function handleSelectDialogClose() {
setSelectDialogOpen(false);
}
function handleSelectDialogOpen() {
setSelectDialogOpen(true);
}
return (
<Paper variant="outlined" elevation={4}>
<MakerListDialog
open={selectDialogOpen}
onClose={handleSelectDialogClose}
/>
<Card sx={{ width: "100%" }}>
<CardContent sx={{ display: "flex", alignItems: "center" }}>
<MakerInfo maker={selectedMaker} />
<IconButton onClick={handleSelectDialogOpen} size="small">
<ArrowForwardIosIcon />
</IconButton>
</CardContent>
</Card>
</Paper>
);
}

View file

@ -1,111 +0,0 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
TextField,
} from "@mui/material";
import { Multiaddr } from "multiaddr";
import { ChangeEvent, useState } from "react";
type MakerSubmitDialogProps = {
open: boolean;
onClose: () => void;
};
export default function MakerSubmitDialog({
open,
onClose,
}: MakerSubmitDialogProps) {
const [multiAddr, setMultiAddr] = useState("");
const [peerId, setPeerId] = useState("");
async function handleMakerSubmit() {
if (multiAddr && peerId) {
await fetch("https://api.unstoppableswap.net/api/submit-provider", {
method: "post",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
multiAddr,
peerId,
}),
});
setMultiAddr("");
setPeerId("");
onClose();
}
}
function handleMultiAddrChange(event: ChangeEvent<HTMLInputElement>) {
setMultiAddr(event.target.value);
}
function handlePeerIdChange(event: ChangeEvent<HTMLInputElement>) {
setPeerId(event.target.value);
}
function getMultiAddressError(): string | null {
try {
const multiAddress = new Multiaddr(multiAddr);
if (multiAddress.protoNames().includes("p2p")) {
return "The multi address should not contain the peer id (/p2p/)";
}
if (multiAddress.protoNames().find((name) => name.includes("onion"))) {
return "It is currently not possible to add a maker that is only reachable via Tor";
}
return null;
} catch (e) {
return "Not a valid multi address";
}
}
return (
<Dialog onClose={onClose} open={open}>
<DialogTitle>Submit a maker to the public registry</DialogTitle>
<DialogContent dividers>
<DialogContentText>
If the maker is valid and reachable, it will be displayed to all other
users to trade with.
</DialogContentText>
<TextField
autoFocus
margin="dense"
label="Multiaddress"
fullWidth
helperText={
getMultiAddressError() ||
"Tells the swap client where the maker can be reached"
}
value={multiAddr}
onChange={handleMultiAddrChange}
placeholder="/ip4/182.3.21.93/tcp/9939"
error={!!getMultiAddressError()}
/>
<TextField
margin="dense"
label="Peer ID"
fullWidth
helperText="Identifies the maker and allows for secure communication"
value={peerId}
onChange={handlePeerIdChange}
placeholder="12D3KooWCdMKjesXMJz1SiZ7HgotrxuqhQJbP5sgBm2BwP1cqThi"
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="contained"
onClick={handleMakerSubmit}
disabled={!(multiAddr && peerId && !getMultiAddressError())}
color="primary"
>
Submit
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -49,7 +49,7 @@ function LinearProgressWithLabel(
}} }}
> >
<Typography variant="body2" color="textSecondary"> <Typography variant="body2" color="textSecondary">
{props.label || `${Math.round(props.value)}%`} {props.label || `${Math.round(props.value ?? 0)}%`}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
@ -84,6 +84,8 @@ export default function UpdaterDialog() {
} }
async function handleInstall() { async function handleInstall() {
if (!availableUpdate) return;
try { try {
await availableUpdate.downloadAndInstall((event: DownloadEvent) => { await availableUpdate.downloadAndInstall((event: DownloadEvent) => {
if (event.event === "Started") { if (event.event === "Started") {
@ -92,10 +94,13 @@ export default function UpdaterDialog() {
downloadedBytes: 0, downloadedBytes: 0,
}); });
} else if (event.event === "Progress") { } else if (event.event === "Progress") {
setDownloadProgress((prev) => ({ setDownloadProgress((prev) => {
...prev, if (!prev) return null;
downloadedBytes: prev.downloadedBytes + event.data.chunkLength, return {
})); contentLength: prev.contentLength,
downloadedBytes: prev.downloadedBytes + event.data.chunkLength,
};
});
} }
}); });
@ -110,12 +115,13 @@ export default function UpdaterDialog() {
const isDownloading = downloadProgress !== null; const isDownloading = downloadProgress !== null;
const progress = isDownloading const progress =
? Math.round( isDownloading && downloadProgress.contentLength
(downloadProgress.downloadedBytes / downloadProgress.contentLength) * ? Math.round(
100, (downloadProgress.downloadedBytes / downloadProgress.contentLength) *
) 100,
: 0; )
: 0;
return ( return (
<Dialog <Dialog

View file

@ -1,7 +1,7 @@
import { Button, Dialog, DialogActions } from "@mui/material"; import { Button, Dialog, DialogActions } from "@mui/material";
import { useState } from "react"; import { useState } from "react";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import { withdrawBtc } from "renderer/rpc"; import { sweepBtc } from "renderer/rpc";
import DialogHeader from "../DialogHeader"; import DialogHeader from "../DialogHeader";
import AddressInputPage from "./pages/AddressInputPage"; import AddressInputPage from "./pages/AddressInputPage";
import BtcTxInMempoolPageContent from "./pages/BitcoinWithdrawTxInMempoolPage"; import BtcTxInMempoolPageContent from "./pages/BitcoinWithdrawTxInMempoolPage";
@ -59,7 +59,7 @@ export default function WithdrawDialog({
variant="contained" variant="contained"
color="primary" color="primary"
disabled={!withdrawAddressValid} disabled={!withdrawAddressValid}
onInvoke={() => withdrawBtc(withdrawAddress)} onInvoke={() => sweepBtc(withdrawAddress)}
onPendingChange={setPending} onPendingChange={setPending}
onSuccess={setWithdrawTxId} onSuccess={setWithdrawTxId}
contextRequirement={isContextWithBitcoinWallet} contextRequirement={isContextWithBitcoinWallet}

View file

@ -13,7 +13,7 @@ type ModalProps = {
}; };
type Props = { type Props = {
content: string; content: string | null;
displayCopyIcon?: boolean; displayCopyIcon?: boolean;
enableQrCode?: boolean; enableQrCode?: boolean;
light?: boolean; light?: boolean;
@ -72,6 +72,7 @@ export default function ActionableMonospaceTextBox({
const [isRevealed, setIsRevealed] = useState(!spoilerText); const [isRevealed, setIsRevealed] = useState(!spoilerText);
const handleCopy = async () => { const handleCopy = async () => {
if (!content) return;
await writeText(content); await writeText(content);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
@ -160,7 +161,7 @@ export default function ActionableMonospaceTextBox({
)} )}
</Box> </Box>
{enableQrCode && ( {enableQrCode && content && (
<QRCodeModal <QRCodeModal
open={qrCodeOpen} open={qrCodeOpen}
onClose={() => setQrCodeOpen(false)} onClose={() => setQrCodeOpen(false)}

View file

@ -0,0 +1,52 @@
// Vendored from https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/error_boundaries/
import React, { Component, ErrorInfo, ReactNode } from "react";
interface Props {
children?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null
};
public static getDerivedStateFromError(error: Error): State {
// Update state so the next render will show the fallback UI.
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Uncaught error:", error, errorInfo);
}
public render() {
if (this.state.hasError) {
return (
<div>
<h1>Sorry.. there was an error</h1>
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{this.state.error?.message}
</pre>
{this.state.error?.stack && (
<details style={{ marginTop: '1rem' }}>
<summary>Stack trace</summary>
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: '0.875rem' }}>
{this.state.error.stack}
</pre>
</details>
)}
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View file

@ -1,51 +0,0 @@
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import TreeItem from "@mui/lab/TreeItem";
import TreeView from "@mui/lab/TreeView";
import ScrollablePaperTextBox from "./ScrollablePaperTextBox";
interface JsonTreeViewProps {
data: unknown;
label: string;
}
export default function JsonTreeView({ data, label }: JsonTreeViewProps) {
const renderTree = (nodes: unknown, parentId: string) => {
return Object.keys(nodes).map((key, _) => {
const nodeId = `${parentId}.${key}`;
if (typeof nodes[key] === "object" && nodes[key] !== null) {
return (
<TreeItem nodeId={nodeId} label={key} key={nodeId}>
{renderTree(nodes[key], nodeId)}
</TreeItem>
);
}
return (
<TreeItem
nodeId={nodeId}
label={`${key}: ${nodes[key]}`}
key={nodeId}
/>
);
});
};
return (
<ScrollablePaperTextBox
title={label}
copyValue={JSON.stringify(data, null, 4)}
rows={[
<TreeView
key={1}
defaultCollapseIcon={<ExpandMoreIcon />}
defaultExpandIcon={<ChevronRightIcon />}
defaultExpanded={["root"]}
>
<TreeItem nodeId="root" label={label}>
{renderTree(data ?? {}, "root")}
</TreeItem>
</TreeView>,
]}
/>
);
}

View file

@ -108,7 +108,7 @@ export default function ScrollablePaperTextBox({
<IconButton onClick={scrollToBottom} size="small"> <IconButton onClick={scrollToBottom} size="small">
<KeyboardArrowDownIcon /> <KeyboardArrowDownIcon />
</IconButton> </IconButton>
{searchQuery !== undefined && setSearchQuery !== undefined && ( {searchQuery !== null && setSearchQuery !== null && (
<ExpandableSearchBox query={searchQuery} setQuery={setSearchQuery} /> <ExpandableSearchBox query={searchQuery} setQuery={setSearchQuery} />
)} )}
</Box> </Box>

View file

@ -9,7 +9,7 @@ export default function TruncatedText({
ellipsis?: string; ellipsis?: string;
truncateMiddle?: boolean; truncateMiddle?: boolean;
}) { }) {
let finalChildren = children ?? ""; const finalChildren = children ?? "";
const truncatedText = const truncatedText =
finalChildren.length > limit finalChildren.length > limit

View file

@ -9,7 +9,7 @@ export function AmountWithUnit({
unit, unit,
fixedPrecision, fixedPrecision,
exchangeRate, exchangeRate,
parenthesisText = null, parenthesisText,
labelStyles, labelStyles,
amountStyles, amountStyles,
disableTooltip = false, disableTooltip = false,
@ -18,7 +18,7 @@ export function AmountWithUnit({
unit: string; unit: string;
fixedPrecision: number; fixedPrecision: number;
exchangeRate?: Amount; exchangeRate?: Amount;
parenthesisText?: string; parenthesisText?: string | null;
labelStyles?: SxProps; labelStyles?: SxProps;
amountStyles?: SxProps; amountStyles?: SxProps;
disableTooltip?: boolean; disableTooltip?: boolean;
@ -142,7 +142,7 @@ export function MoneroBitcoinExchangeRate({
}) { }) {
const marketRate = useAppSelector((state) => state.rates?.xmrBtcRate); const marketRate = useAppSelector((state) => state.rates?.xmrBtcRate);
const markup = const markup =
displayMarkup && marketRate != null displayMarkup && marketRate != null && rate != null
? `${getMarkup(rate, marketRate).toFixed(2)}% markup` ? `${getMarkup(rate, marketRate).toFixed(2)}% markup`
: null; : null;
@ -179,7 +179,7 @@ export function MoneroSatsExchangeRate({
rate: Amount; rate: Amount;
displayMarkup?: boolean; displayMarkup?: boolean;
}) { }) {
const btc = satsToBtc(rate); const btc = rate == null ? null : satsToBtc(rate);
return <MoneroBitcoinExchangeRate rate={btc} displayMarkup={displayMarkup} />; return <MoneroBitcoinExchangeRate rate={btc} displayMarkup={displayMarkup} />;
} }

View file

@ -378,10 +378,6 @@ function MessageBubble({ message }: { message: Message }) {
<Box <Box
sx={(theme) => ({ sx={(theme) => ({
padding: 1.5, padding: 1.5,
borderRadius:
typeof theme.shape.borderRadius === "number"
? theme.shape.borderRadius * 2
: 8,
maxWidth: "75%", maxWidth: "75%",
wordBreak: "break-word", wordBreak: "break-word",
boxShadow: theme.shadows[1], boxShadow: theme.shadows[1],

View file

@ -432,7 +432,7 @@ function MoneroNodeUrlSetting() {
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<ValidatedTextField <ValidatedTextField
value={moneroNodeUrl} value={moneroNodeUrl}
onValidatedChange={handleNodeUrlChange} onValidatedChange={(value) => value && handleNodeUrlChange(value)}
placeholder={PLACEHOLDER_MONERO_NODE_URL} placeholder={PLACEHOLDER_MONERO_NODE_URL}
disabled={useMoneroRpcPool} disabled={useMoneroRpcPool}
fullWidth fullWidth
@ -675,7 +675,7 @@ function NodeTable({
<ValidatedTextField <ValidatedTextField
label="Add a new node" label="Add a new node"
value={newNode} value={newNode}
onValidatedChange={setNewNode} onValidatedChange={(value) => setNewNode(value ?? "")}
placeholder={placeholder} placeholder={placeholder}
fullWidth fullWidth
isValid={isValid} isValid={isValid}
@ -843,7 +843,9 @@ function RendezvousPointsSetting() {
<ValidatedTextField <ValidatedTextField
label="Add new rendezvous point" label="Add new rendezvous point"
value={newPoint} value={newPoint}
onValidatedChange={setNewPoint} onValidatedChange={(value) =>
setNewPoint(value ?? "")
}
placeholder="/dns4/rendezvous.observer/tcp/8888/p2p/12D3KooWMjceGXrYuGuDMGrfmJxALnSDbK4km6s1i1sJEgDTgGQa" placeholder="/dns4/rendezvous.observer/tcp/8888/p2p/12D3KooWMjceGXrYuGuDMGrfmJxALnSDbK4km6s1i1sJEgDTgGQa"
fullWidth fullWidth
isValid={isValidMultiAddressWithPeerId} isValid={isValidMultiAddressWithPeerId}

View file

@ -119,9 +119,9 @@ export function SwapMoneroRecoveryButton({
onInvoke={(): Promise<MoneroRecoveryResponse> => onInvoke={(): Promise<MoneroRecoveryResponse> =>
getMoneroRecoveryKeys(swap.swap_id) getMoneroRecoveryKeys(swap.swap_id)
} }
onSuccess={(keys: MoneroRecoveryResponse) => onSuccess={(keys: MoneroRecoveryResponse) => {
store.dispatch(rpcSetMoneroRecoveryKeys([swap.swap_id, keys])) store.dispatch(rpcSetMoneroRecoveryKeys([swap.swap_id, keys]));
} }}
{...props} {...props}
> >
Display Monero Recovery Keys Display Monero Recovery Keys

View file

@ -16,7 +16,7 @@ interface SendAmountInputProps {
currency: string; currency: string;
onCurrencyChange: (currency: string) => void; onCurrencyChange: (currency: string) => void;
fiatCurrency: string; fiatCurrency: string;
xmrPrice: number; xmrPrice: number | null;
showFiatRate: boolean; showFiatRate: boolean;
disabled?: boolean; disabled?: boolean;
} }
@ -48,6 +48,10 @@ export default function SendAmountInput({
return "0.00"; return "0.00";
} }
if (xmrPrice === null) {
return "?";
}
const primaryValue = parseFloat(amount); const primaryValue = parseFloat(amount);
if (currency === "XMR") { if (currency === "XMR") {
// Primary is XMR, secondary is USD // Primary is XMR, secondary is USD
@ -76,7 +80,7 @@ export default function SendAmountInput({
if (currency === "XMR") { if (currency === "XMR") {
onAmountChange(Math.max(0, maxAmountXmr).toString()); onAmountChange(Math.max(0, maxAmountXmr).toString());
} else { } else if (xmrPrice !== null) {
// Convert to USD for display // Convert to USD for display
const maxAmountUsd = maxAmountXmr * xmrPrice; const maxAmountUsd = maxAmountXmr * xmrPrice;
onAmountChange(Math.max(0, maxAmountUsd).toString()); onAmountChange(Math.max(0, maxAmountUsd).toString());
@ -103,8 +107,9 @@ export default function SendAmountInput({
(currency === "XMR" (currency === "XMR"
? parseFloat(amount) > ? parseFloat(amount) >
piconerosToXmr(parseFloat(balance.unlocked_balance)) piconerosToXmr(parseFloat(balance.unlocked_balance))
: parseFloat(amount) / xmrPrice > : xmrPrice !== null &&
piconerosToXmr(parseFloat(balance.unlocked_balance))); parseFloat(amount) / xmrPrice >
piconerosToXmr(parseFloat(balance.unlocked_balance)));
return ( return (
<Card <Card
@ -203,14 +208,11 @@ export default function SendAmountInput({
}} }}
> >
<Typography color="text.secondary">Available</Typography> <Typography color="text.secondary">Available</Typography>
<Box sx={{ display: "flex", alignItems: "baseline", gap: 0.5 }}> <Typography color="text.primary">
<Typography color="text.primary"> <MoneroAmount
<MoneroAmount amount={piconerosToXmr(parseFloat(balance.unlocked_balance))}
amount={piconerosToXmr(parseFloat(balance.unlocked_balance))} />
/> </Typography>
</Typography>
<Typography color="text.secondary">XMR</Typography>
</Box>
<Button <Button
variant={isMaxSelected ? "contained" : "secondary"} variant={isMaxSelected ? "contained" : "secondary"}
size="tiny" size="tiny"

View file

@ -20,10 +20,9 @@ export default function SendSuccessContent({
}) { }) {
const address = successDetails?.address; const address = successDetails?.address;
const amount = successDetails?.amount_sent; const amount = successDetails?.amount_sent;
const explorerUrl = getMoneroTxExplorerUrl( const explorerUrl = successDetails?.tx_hash
successDetails?.tx_hash, ? getMoneroTxExplorerUrl(successDetails.tx_hash, isTestnet())
isTestnet(), : null;
);
return ( return (
<Box <Box
@ -79,7 +78,7 @@ export default function SendSuccessContent({
</Typography> </Typography>
<Typography variant="body1" color="text.primary"> <Typography variant="body1" color="text.primary">
<MonospaceTextBox> <MonospaceTextBox>
{address.slice(0, 8)} ... {address.slice(-8)} {address ? `${address.slice(0, 8)}...${address.slice(-8)}` : "?"}
</MonospaceTextBox> </MonospaceTextBox>
</Typography> </Typography>
</Box> </Box>
@ -98,8 +97,13 @@ export default function SendSuccessContent({
<Button <Button
color="primary" color="primary"
size="small" size="small"
disabled={explorerUrl == null}
endIcon={<ArrowOutwardIcon />} endIcon={<ArrowOutwardIcon />}
onClick={() => open(explorerUrl)} onClick={() => {
if (explorerUrl != null) {
open(explorerUrl);
}
}}
> >
View on Explorer View on Explorer
</Button> </Button>

View file

@ -90,7 +90,9 @@ export default function SendTransactionContent({
const moneroAmount = const moneroAmount =
currency === "XMR" currency === "XMR"
? parseFloat(sendAmount) ? parseFloat(sendAmount)
: parseFloat(sendAmount) / xmrPrice; : xmrPrice !== null
? parseFloat(sendAmount) / xmrPrice
: null;
const handleSend = async () => { const handleSend = async () => {
if (!sendAddress) { if (!sendAddress) {
@ -103,7 +105,7 @@ export default function SendTransactionContent({
amount: { type: "Sweep" }, amount: { type: "Sweep" },
}); });
} else { } else {
if (!sendAmount || sendAmount === "<MAX>") { if (!sendAmount || sendAmount === "<MAX>" || moneroAmount === null) {
throw new Error("Amount is required"); throw new Error("Amount is required");
} }

View file

@ -1,6 +1,6 @@
import { Box, darken, lighten, useTheme } from "@mui/material"; import { Box, darken, lighten, useTheme } from "@mui/material";
function getColor(colorName: string) { function getColor(colorName: string): string {
const theme = useTheme(); const theme = useTheme();
switch (colorName) { switch (colorName) {
case "primary": case "primary":
@ -11,6 +11,8 @@ function getColor(colorName: string) {
return theme.palette.success.main; return theme.palette.success.main;
case "warning": case "warning":
return theme.palette.warning.main; return theme.palette.warning.main;
default:
return theme.palette.primary.main;
} }
} }

View file

@ -5,9 +5,9 @@ import dayjs from "dayjs";
import TransactionItem from "./TransactionItem"; import TransactionItem from "./TransactionItem";
interface TransactionHistoryProps { interface TransactionHistoryProps {
history?: { history: {
transactions: TransactionInfo[]; transactions: TransactionInfo[];
}; } | null;
} }
interface TransactionGroup { interface TransactionGroup {

View file

@ -4,7 +4,10 @@ import { PiconeroAmount } from "../../../other/Units";
import { FiatPiconeroAmount } from "../../../other/Units"; import { FiatPiconeroAmount } from "../../../other/Units";
import StateIndicator from "./StateIndicator"; import StateIndicator from "./StateIndicator";
import humanizeDuration from "humanize-duration"; import humanizeDuration from "humanize-duration";
import { GetMoneroSyncProgressResponse } from "models/tauriModel"; import {
GetMoneroBalanceResponse,
GetMoneroSyncProgressResponse,
} from "models/tauriModel";
interface TimeEstimationResult { interface TimeEstimationResult {
blocksLeft: number; blocksLeft: number;
@ -16,7 +19,7 @@ interface TimeEstimationResult {
const AVG_MONERO_BLOCK_SIZE_KB = 130; const AVG_MONERO_BLOCK_SIZE_KB = 130;
function useSyncTimeEstimation( function useSyncTimeEstimation(
syncProgress: GetMoneroSyncProgressResponse | undefined, syncProgress: GetMoneroSyncProgressResponse | null,
): TimeEstimationResult | null { ): TimeEstimationResult | null {
const poolStatus = useAppSelector((state) => state.pool.status); const poolStatus = useAppSelector((state) => state.pool.status);
const restoreHeight = useAppSelector( const restoreHeight = useAppSelector(
@ -80,11 +83,8 @@ function useSyncTimeEstimation(
} }
interface WalletOverviewProps { interface WalletOverviewProps {
balance?: { balance: GetMoneroBalanceResponse | null;
unlocked_balance: string; syncProgress: GetMoneroSyncProgressResponse | null;
total_balance: string;
};
syncProgress?: GetMoneroSyncProgressResponse;
} }
// Component for displaying wallet address and balance // Component for displaying wallet address and balance
@ -99,10 +99,11 @@ export default function WalletOverview({
const poolStatus = useAppSelector((state) => state.pool.status); const poolStatus = useAppSelector((state) => state.pool.status);
const timeEstimation = useSyncTimeEstimation(syncProgress); const timeEstimation = useSyncTimeEstimation(syncProgress);
const pendingBalance = const pendingBalance = balance
parseFloat(balance.total_balance) - parseFloat(balance.unlocked_balance); ? parseFloat(balance.total_balance) - parseFloat(balance.unlocked_balance)
: null;
const isSyncing = syncProgress && syncProgress.progress_percentage < 100; const isSyncing = !!(syncProgress && syncProgress.progress_percentage < 100);
// syncProgress.progress_percentage is not good to display // syncProgress.progress_percentage is not good to display
// assuming we have an old wallet, eventually we will always only use the last few cm of the progress bar // assuming we have an old wallet, eventually we will always only use the last few cm of the progress bar
@ -184,18 +185,18 @@ export default function WalletOverview({
</Typography> </Typography>
<Typography variant="h4"> <Typography variant="h4">
<PiconeroAmount <PiconeroAmount
amount={parseFloat(balance.unlocked_balance)} amount={balance ? parseFloat(balance.unlocked_balance) : null}
fixedPrecision={4} fixedPrecision={4}
disableTooltip disableTooltip
/> />
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
<FiatPiconeroAmount <FiatPiconeroAmount
amount={parseFloat(balance.unlocked_balance)} amount={balance ? parseFloat(balance.unlocked_balance) : null}
/> />
</Typography> </Typography>
</Box> </Box>
{pendingBalance > 0 && ( {pendingBalance !== null && pendingBalance > 0 && (
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",

View file

@ -71,8 +71,8 @@ export default function WalletPageLoadingState() {
</Skeleton> </Skeleton>
<Box sx={{ display: "flex", flexDirection: "row", gap: 2, mb: 2 }}> <Box sx={{ display: "flex", flexDirection: "row", gap: 2, mb: 2 }}>
{Array.from({ length: 2 }).map((_) => ( {Array.from({ length: 2 }).map((_, i) => (
<Skeleton variant="rounded" sx={{ borderRadius: "100px" }}> <Skeleton key={i} variant="rounded" sx={{ borderRadius: "100px" }}>
<Chip label="Loading..." variant="button" /> <Chip label="Loading..." variant="button" />
</Skeleton> </Skeleton>
))} ))}

View file

@ -2,7 +2,7 @@ import { Box, LinearProgress, Paper, Typography } from "@mui/material";
import { ReactNode } from "react"; import { ReactNode } from "react";
type Props = { type Props = {
id?: string; id?: string | null;
title: ReactNode | null; title: ReactNode | null;
mainContent: ReactNode; mainContent: ReactNode;
additionalContent: ReactNode; additionalContent: ReactNode;
@ -11,7 +11,7 @@ type Props = {
}; };
export default function InfoBox({ export default function InfoBox({
id = null, id,
title, title,
mainContent, mainContent,
additionalContent, additionalContent,
@ -21,7 +21,7 @@ export default function InfoBox({
return ( return (
<Paper <Paper
variant="outlined" variant="outlined"
id={id} id={id ?? undefined}
sx={{ sx={{
padding: 1.5, padding: 1.5,
overflow: "hidden", overflow: "hidden",

View file

@ -107,8 +107,8 @@ export default function DepositAndChooseOfferPage({
return ( return (
<MakerOfferItem <MakerOfferItem
key={startIndex + index} key={startIndex + index}
quoteWithAddress={quote} quoteWithAddress={quote.quote_with_address}
requestId={quote.request_id} requestId={quote.approval?.request_id}
/> />
); );
})} })}

View file

@ -111,7 +111,12 @@ export default function MakerOfferItem({
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<PromiseInvokeButton <PromiseInvokeButton
variant="contained" variant="contained"
onInvoke={() => resolveApproval(requestId, true as unknown as object)} onInvoke={() => {
if (!requestId) {
throw new Error("Request ID is required");
}
return resolveApproval(requestId, true as unknown as object);
}}
displayErrorSnackbar displayErrorSnackbar
disabled={!requestId} disabled={!requestId}
tooltipTitle={ tooltipTitle={

View file

@ -13,7 +13,10 @@ import ActionableMonospaceTextBox from "renderer/components/other/ActionableMono
import { getWalletDescriptor } from "renderer/rpc"; import { getWalletDescriptor } from "renderer/rpc";
import { ExportBitcoinWalletResponse } from "models/tauriModel"; import { ExportBitcoinWalletResponse } from "models/tauriModel";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import { isContextWithBitcoinWallet } from "models/tauriModelExt"; import {
isContextWithBitcoinWallet,
hasDescriptorProperty,
} from "models/tauriModelExt";
const WALLET_DESCRIPTOR_DOCS_URL = const WALLET_DESCRIPTOR_DOCS_URL =
"https://github.com/eigenwallet/core/blob/master/dev-docs/asb/README.md#exporting-the-bitcoin-wallet-descriptor"; "https://github.com/eigenwallet/core/blob/master/dev-docs/asb/README.md#exporting-the-bitcoin-wallet-descriptor";
@ -58,8 +61,12 @@ function WalletDescriptorModal({
onClose: () => void; onClose: () => void;
walletDescriptor: ExportBitcoinWalletResponse; walletDescriptor: ExportBitcoinWalletResponse;
}) { }) {
if (!hasDescriptorProperty(walletDescriptor)) {
throw new Error("Wallet descriptor does not have descriptor property");
}
const parsedDescriptor = JSON.parse( const parsedDescriptor = JSON.parse(
walletDescriptor.wallet_descriptor["descriptor"], walletDescriptor.wallet_descriptor.descriptor,
); );
const stringifiedDescriptor = JSON.stringify(parsedDescriptor, null, 4); const stringifiedDescriptor = JSON.stringify(parsedDescriptor, null, 4);

View file

@ -24,7 +24,7 @@ declare module "@mui/material/styles" {
tint?: string; tint?: string;
} }
interface PaletteColorOptions { interface SimplePaletteColorOptions {
tint?: string; tint?: string;
} }
} }
@ -61,16 +61,6 @@ const baseTheme: ThemeOptions = {
backgroundColor: "color-mix(in srgb, #bdbdbd 10%, transparent)", 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: [ variants: [
{ {

View file

@ -210,7 +210,13 @@ export async function buyXmr() {
address_pool.push( address_pool.push(
{ {
address: moneroReceiveAddress, // We need to assert this as being not null even though it can be null
//
// This is correct because a LabeledMoneroAddress can actually have a null address but
// typeshare cannot express that yet (easily)
//
// TODO: Let typescript do its job here and not assert it
address: moneroReceiveAddress!,
percentage: 1 - donationPercentage, percentage: 1 - donationPercentage,
label: "Your wallet", label: "Your wallet",
}, },
@ -222,7 +228,13 @@ export async function buyXmr() {
); );
} else { } else {
address_pool.push({ address_pool.push({
address: moneroReceiveAddress, // We need to assert this as being not null even though it can be null
//
// This is correct because a LabeledMoneroAddress can actually have a null address but
// typeshare cannot express that yet (easily)
//
// TODO: Let typescript do its job here and not assert it
address: moneroReceiveAddress!,
percentage: 1, percentage: 1,
label: "Your wallet", label: "Your wallet",
}); });
@ -232,7 +244,9 @@ export async function buyXmr() {
rendezvous_points: PRESET_RENDEZVOUS_POINTS, rendezvous_points: PRESET_RENDEZVOUS_POINTS,
sellers, sellers,
monero_receive_pool: address_pool, monero_receive_pool: address_pool,
bitcoin_change_address: bitcoinChangeAddress, // We convert null to undefined because typescript
// expects undefined if the field is optional and does not accept null here
bitcoin_change_address: bitcoinChangeAddress ?? undefined,
}); });
} }
@ -284,7 +298,7 @@ export async function initializeContext() {
}); });
logger.info("Initialized context"); logger.info("Initialized context");
} catch (error) { } catch (error) {
throw new Error(error); throw new Error(String(error));
} }
} }
@ -340,12 +354,12 @@ export async function getSwapInfo(swapId: string) {
} }
export async function getSwapTimelock(swapId: string) { export async function getSwapTimelock(swapId: string) {
const response = await invoke< const response = await invoke<GetSwapTimelockArgs, GetSwapTimelockResponse>(
GetSwapTimelockArgs, "get_swap_timelock",
GetSwapTimelockResponse {
>("get_swap_timelock", { swap_id: swapId,
swap_id: swapId, },
}); );
store.dispatch( store.dispatch(
timelockChangeEventReceived({ timelockChangeEventReceived({
@ -369,12 +383,12 @@ export async function getAllSwapTimelocks() {
); );
} }
export async function withdrawBtc(address: string): Promise<string> { export async function sweepBtc(address: string): Promise<string> {
const response = await invoke<WithdrawBtcArgs, WithdrawBtcResponse>( const response = await invoke<WithdrawBtcArgs, WithdrawBtcResponse>(
"withdraw_btc", "withdraw_btc",
{ {
address, address,
amount: null, amount: undefined,
}, },
); );

View file

@ -30,31 +30,6 @@ const initialState: MakersSlice = {
selectedMaker: null, selectedMaker: null,
}; };
function selectNewSelectedMaker(
slice: MakersSlice,
peerId?: string,
): MakerStatus {
const selectedPeerId = peerId || slice.selectedMaker?.peerId;
// Check if we still have a record of the currently selected provider
const currentMaker =
slice.registry.makers?.find((prov) => prov.peerId === selectedPeerId) ||
slice.rendezvous.makers.find((prov) => prov.peerId === selectedPeerId);
// If the currently selected provider is not outdated, keep it
if (currentMaker != null && !isMakerOutdated(currentMaker)) {
return currentMaker;
}
// Otherwise we'd prefer to switch to a provider that has the newest version
const providers = [
...(slice.registry.makers ?? []),
...(slice.rendezvous.makers ?? []),
];
return providers.at(0) || null;
}
export const makersSlice = createSlice({ export const makersSlice = createSlice({
name: "providers", name: "providers",
initialState, initialState,
@ -83,32 +58,15 @@ export const makersSlice = createSlice({
slice.rendezvous.makers.push(discoveredMakerStatus); slice.rendezvous.makers.push(discoveredMakerStatus);
} }
}); });
// Sort the provider list and select a new provider if needed
slice.selectedMaker = selectNewSelectedMaker(slice);
}, },
setRegistryMakers(slice, action: PayloadAction<ExtendedMakerStatus[]>) { setRegistryMakers(slice, action: PayloadAction<ExtendedMakerStatus[]>) {
if (stubTestnetMaker) { if (stubTestnetMaker) {
action.payload.push(stubTestnetMaker); action.payload.push(stubTestnetMaker);
} }
// Sort the provider list and select a new provider if needed
slice.selectedMaker = selectNewSelectedMaker(slice);
}, },
registryConnectionFailed(slice) { registryConnectionFailed(slice) {
slice.registry.connectionFailsCount += 1; slice.registry.connectionFailsCount += 1;
}, },
setSelectedMaker(
slice,
action: PayloadAction<{
peerId: string;
}>,
) {
slice.selectedMaker = selectNewSelectedMaker(
slice,
action.payload.peerId,
);
},
}, },
}); });
@ -116,7 +74,6 @@ export const {
discoveredMakersByRendezvous, discoveredMakersByRendezvous,
setRegistryMakers, setRegistryMakers,
registryConnectionFailed, registryConnectionFailed,
setSelectedMaker,
} = makersSlice.actions; } = makersSlice.actions;
export default makersSlice.reducer; export default makersSlice.reducer;

View file

@ -89,8 +89,10 @@ export const rpcSlice = createSlice({
slice: RPCSlice, slice: RPCSlice,
action: PayloadAction<TauriTimelockChangeEvent>, action: PayloadAction<TauriTimelockChangeEvent>,
) { ) {
slice.state.swapTimelocks[action.payload.swap_id] = if (action.payload.timelock) {
action.payload.timelock; slice.state.swapTimelocks[action.payload.swap_id] =
action.payload.timelock;
}
}, },
rpcSetWithdrawTxId(slice, action: PayloadAction<string>) { rpcSetWithdrawTxId(slice, action: PayloadAction<string>) {
slice.state.withdrawTxId = action.payload; slice.state.withdrawTxId = action.payload;

View file

@ -48,8 +48,8 @@ export const walletSlice = createSlice({
slice.state.lowestCurrentBlock = Math.min( slice.state.lowestCurrentBlock = Math.min(
// We ignore anything below 10 blocks as this may be something like wallet2 // We ignore anything below 10 blocks as this may be something like wallet2
// sending a wrong value when it hasn't initialized yet // sending a wrong value when it hasn't initialized yet
slice.state.lowestCurrentBlock < 10 || slice.state.lowestCurrentBlock === null ||
slice.state.lowestCurrentBlock === null slice.state.lowestCurrentBlock < 10
? Infinity ? Infinity
: slice.state.lowestCurrentBlock, : slice.state.lowestCurrentBlock,
action.payload.current_block, action.payload.current_block,

View file

@ -253,7 +253,11 @@ export function useBitcoinSyncProgress(): TauriBitcoinSyncProgress[] {
const syncingProcesses = pendingProcesses const syncingProcesses = pendingProcesses
.map(([_, c]) => c) .map(([_, c]) => c)
.filter(isBitcoinSyncProgress); .filter(isBitcoinSyncProgress);
return syncingProcesses.map((c) => c.progress.content); return syncingProcesses
.map((c) => c.progress.content)
.filter(
(content): content is TauriBitcoinSyncProgress => content !== undefined,
);
} }
export function isSyncingBitcoin(): boolean { export function isSyncingBitcoin(): boolean {

View file

@ -18,10 +18,9 @@ export const selectSwapTimelocks = createSelector(
(rpcState) => rpcState.swapTimelocks, (rpcState) => rpcState.swapTimelocks,
); );
export const selectSwapTimelock = (swapId: string) => export const selectSwapTimelock = (swapId: string | null) =>
createSelector( createSelector([selectSwapTimelocks], (timelocks) =>
[selectSwapTimelocks], swapId ? (timelocks[swapId] ?? null) : null,
(timelocks) => timelocks[swapId] ?? null,
); );
export const selectSwapInfoWithTimelock = (swapId: string) => export const selectSwapInfoWithTimelock = (swapId: string) =>

View file

@ -10,48 +10,47 @@ export function sortApprovalsAndKnownQuotes(
pendingSelectMakerApprovals: PendingSelectMakerApprovalRequest[], pendingSelectMakerApprovals: PendingSelectMakerApprovalRequest[],
known_quotes: QuoteWithAddress[], known_quotes: QuoteWithAddress[],
) { ) {
const sortableQuotes = pendingSelectMakerApprovals.map((approval) => { const sortableQuotes: SortableQuoteWithAddress[] =
return { pendingSelectMakerApprovals.map((approval) => {
...approval.request.content.maker, return {
expiration_ts: quote_with_address: approval.request.content.maker,
approval.request_status.state === "Pending" approval:
? approval.request_status.content.expiration_ts approval.request_status.state === "Pending"
: undefined, ? {
request_id: approval.request_id, request_id: approval.request_id,
} as SortableQuoteWithAddress; expiration_ts: approval.request_status.content.expiration_ts,
}); }
: null,
};
});
sortableQuotes.push( sortableQuotes.push(
...known_quotes.map((quote) => ({ ...known_quotes.map((quote) => ({
...quote, quote_with_address: quote,
request_id: null, approval: null,
})), })),
); );
return sortMakerApprovals(sortableQuotes);
}
export function sortMakerApprovals(list: SortableQuoteWithAddress[]) {
return ( return (
_(list) _(sortableQuotes)
.orderBy( .orderBy(
[ [
// Prefer makers that have a 'version' attribute // Prefer makers that have a 'version' attribute
// If we don't have a version, we cannot clarify if it's outdated or not // If we don't have a version, we cannot clarify if it's outdated or not
(m) => (m.version ? 0 : 1), (m) => (m.quote_with_address.version ? 0 : 1),
// Prefer makers with a minimum quantity > 0 // Prefer makers with a minimum quantity > 0
(m) => ((m.quote.min_quantity ?? 0) > 0 ? 0 : 1), (m) => ((m.quote_with_address.quote.min_quantity ?? 0) > 0 ? 0 : 1),
// Prefer makers that are not outdated // Prefer makers that are not outdated
(m) => (isMakerVersionOutdated(m.version) ? 1 : 0), (m) => (isMakerVersionOutdated(m.quote_with_address.version) ? 1 : 0),
// Prefer approvals over actual quotes // Prefer approvals over actual quotes
(m) => (m.request_id ? 0 : 1), (m) => (m.approval ? 0 : 1),
// Prefer makers with a lower price // Prefer makers with a lower price
(m) => m.quote.price, (m) => m.quote_with_address.quote.price,
], ],
["asc", "asc", "asc", "asc", "asc"], ["asc", "asc", "asc", "asc", "asc"],
) )
// Remove duplicate makers // Remove duplicate makers
.uniqBy((m) => m.peer_id) .uniqBy((m) => m.quote_with_address.peer_id)
.value() .value()
); );
} }

View file

@ -15,7 +15,7 @@
"jsx": "react-jsx", "jsx": "react-jsx",
/* Linting */ /* Linting */
"strict": false, "strict": true,
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"noFallthroughCasesInSwitch": false, "noFallthroughCasesInSwitch": false,

View file

@ -531,19 +531,7 @@
integrity sha512-upzCtG6awpL6noEZlJ5Z01khZ9VnLNLaj7tb6iPbN6G97eYfUTs8e9OyPKy3rEms3VQWmVBfri7jzeaRxdFIzA== integrity sha512-upzCtG6awpL6noEZlJ5Z01khZ9VnLNLaj7tb6iPbN6G97eYfUTs8e9OyPKy3rEms3VQWmVBfri7jzeaRxdFIzA==
dependencies: dependencies:
"@babel/runtime" "^7.28.2" "@babel/runtime" "^7.28.2"
"@mui/lab@^7.0.0-beta.13":
version "7.0.0-beta.16"
resolved "https://registry.yarnpkg.com/@mui/lab/-/lab-7.0.0-beta.16.tgz#99045e2840c3f4db0383cdcc477af8c7b60c83a2"
integrity sha512-YiyDU84F6ujjaa5xuItuXa40KN1aPC+8PBkP2OAOJGO2MMvdEicuvkEfVSnikH6uLHtKOwGzOeqEqrfaYxcOxw==
dependencies:
"@babel/runtime" "^7.28.2"
"@mui/system" "^7.3.1"
"@mui/types" "^7.4.5"
"@mui/utils" "^7.3.1"
clsx "^2.1.1"
prop-types "^15.8.1"
"@mui/material@^7.1.1": "@mui/material@^7.1.1":
version "7.3.1" version "7.3.1"
resolved "https://registry.yarnpkg.com/@mui/material/-/material-7.3.1.tgz#bd1bf1344cc7a69b6e459248b544f0ae97945b1d" resolved "https://registry.yarnpkg.com/@mui/material/-/material-7.3.1.tgz#bd1bf1344cc7a69b6e459248b544f0ae97945b1d"

View file

@ -216,6 +216,7 @@ impl Amount {
#[typeshare] #[typeshare]
pub struct LabeledMoneroAddress { pub struct LabeledMoneroAddress {
// If this is None, we will use an address of the internal Monero wallet // If this is None, we will use an address of the internal Monero wallet
// TODO: This should be string | null but typeshare cannot do that yet
#[typeshare(serialized_as = "string")] #[typeshare(serialized_as = "string")]
address: Option<monero::Address>, address: Option<monero::Address>,
#[typeshare(serialized_as = "number")] #[typeshare(serialized_as = "number")]

View file

@ -78,9 +78,7 @@ pub fn init(
let tracing_file_layer = json_rolling_layer!( let tracing_file_layer = json_rolling_layer!(
&dir, &dir,
"tracing", "tracing",
env_filter_with_all_crates(vec![ env_filter_with_all_crates(vec![(crates::OUR_CRATES.to_vec(), LevelFilter::TRACE)]),
(crates::OUR_CRATES.to_vec(), LevelFilter::TRACE)
]),
24 24
); );
@ -104,7 +102,10 @@ pub fn init(
let monero_wallet_file_layer = json_rolling_layer!( let monero_wallet_file_layer = json_rolling_layer!(
&dir, &dir,
"tracing-monero-wallet", "tracing-monero-wallet",
env_filter_with_all_crates(vec![(crates::MONERO_WALLET_CRATES.to_vec(), LevelFilter::TRACE)]), env_filter_with_all_crates(vec![(
crates::MONERO_WALLET_CRATES.to_vec(),
LevelFilter::TRACE
)]),
24 24
); );
@ -145,7 +146,9 @@ pub fn init(
(crates::LIBP2P_CRATES.to_vec(), LevelFilter::INFO), (crates::LIBP2P_CRATES.to_vec(), LevelFilter::INFO),
(crates::TOR_CRATES.to_vec(), LevelFilter::INFO), (crates::TOR_CRATES.to_vec(), LevelFilter::INFO),
])?, ])?,
false => env_filter_with_all_crates(vec![(crates::OUR_CRATES.to_vec(), LevelFilter::INFO)])?, false => {
env_filter_with_all_crates(vec![(crates::OUR_CRATES.to_vec(), LevelFilter::INFO)])?
}
}; };
let final_terminal_layer = match format { let final_terminal_layer = match format {
@ -227,10 +230,7 @@ mod crates {
"unstoppableswap_gui_rs", "unstoppableswap_gui_rs",
]; ];
pub const MONERO_WALLET_CRATES: &[&str] = &[ pub const MONERO_WALLET_CRATES: &[&str] = &["monero_cpp", "monero_rpc_pool"];
"monero_cpp",
"monero_rpc_pool",
];
} }
/// A writer that forwards tracing log messages to the tauri guest. /// A writer that forwards tracing log messages to the tauri guest.
@ -275,4 +275,4 @@ impl std::io::Write for TauriWriter {
// No-op, we don't need to flush anything // No-op, we don't need to flush anything
Ok(()) Ok(())
} }
} }