feat: cargo project at root

This commit is contained in:
binarybaron 2024-08-08 00:49:04 +02:00
parent aa0c0623ca
commit 709a2820c4
No known key found for this signature in database
GPG key ID: 99B75D3E1476A26E
313 changed files with 1 additions and 740 deletions

View file

@ -0,0 +1,22 @@
import { DialogTitle, makeStyles, Typography } from '@material-ui/core';
const useStyles = makeStyles({
root: {
display: 'flex',
justifyContent: 'space-between',
},
});
type DialogTitleProps = {
title: string;
};
export default function DialogHeader({ title }: DialogTitleProps) {
const classes = useStyles();
return (
<DialogTitle disableTypography className={classes.root}>
<Typography variant="h6">{title}</Typography>
</DialogTitle>
);
}

View file

@ -0,0 +1,33 @@
import { Button, makeStyles, Paper, Typography } from '@material-ui/core';
const useStyles = makeStyles((theme) => ({
logsOuter: {
overflow: 'auto',
padding: theme.spacing(1),
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
maxHeight: '10rem',
},
copyButton: {
marginTop: theme.spacing(1),
},
}));
export default function PaperTextBox({ stdOut }: { stdOut: string }) {
const classes = useStyles();
function handleCopyLogs() {
throw new Error('Not implemented');
}
return (
<Paper className={classes.logsOuter} variant="outlined">
<Typography component="pre" variant="body2">
{stdOut}
</Typography>
<Button onClick={handleCopyLogs} className={classes.copyButton}>
Copy
</Button>
</Paper>
);
}

View file

@ -0,0 +1,44 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from '@material-ui/core';
import IpcInvokeButton from '../IpcInvokeButton';
type SwapCancelAlertProps = {
open: boolean;
onClose: () => void;
};
export default function SwapSuspendAlert({
open,
onClose,
}: SwapCancelAlertProps) {
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Force stop running operation?</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to force stop the running swap?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="primary">
No
</Button>
<IpcInvokeButton
ipcChannel="suspend-current-swap"
ipcArgs={[]}
color="primary"
onSuccess={onClose}
requiresRpc
>
Force stop
</IpcInvokeButton>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,170 @@
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
MenuItem,
Select,
TextField,
} from '@material-ui/core';
import { useState } from 'react';
import { useSnackbar } from 'notistack';
import {
useActiveSwapInfo,
useAppSelector,
} from 'store/hooks';
import { parseDateString } from 'utils/parseUtils';
import { store } from 'renderer/store/storeRenderer';
import { CliLog } from 'models/cliModel';
import { submitFeedbackViaHttp } from '../../../api';
import { PiconeroAmount } from '../../other/Units';
import LoadingButton from '../../other/LoadingButton';
async function submitFeedback(body: string, swapId: string | number) {
let attachedBody = '';
if (swapId !== 0 && typeof swapId === 'string') {
const swapInfo = store.getState().rpc.state.swapInfos[swapId];
const logs = [] as CliLog[];
throw new Error('Not implemented');
if (swapInfo === undefined) {
throw new Error(`Swap with id ${swapId} not found`);
}
attachedBody = `${JSON.stringify(swapInfo, null, 4)} \n\nLogs: ${logs
.map((l) => JSON.stringify(l))
.join('\n====\n')}`;
}
await submitFeedbackViaHttp(body, attachedBody);
}
/*
* This component is a dialog that allows the user to submit feedback to the
* developers. The user can enter a message and optionally attach logs from a
* specific swap.
* selectedSwap = 0 means no swap is attached
*/
function SwapSelectDropDown({
selectedSwap,
setSelectedSwap,
}: {
selectedSwap: string | number;
setSelectedSwap: (swapId: string | number) => void;
}) {
const swaps = useAppSelector((state) =>
Object.values(state.rpc.state.swapInfos),
);
return (
<Select
value={selectedSwap}
label="Attach logs"
variant="outlined"
onChange={(e) => setSelectedSwap(e.target.value as string)}
>
<MenuItem value={0}>Do not attach logs</MenuItem>
{swaps.map((swap) => (
<MenuItem value={swap.swapId}>
Swap {swap.swapId.substring(0, 5)}... from{' '}
{new Date(parseDateString(swap.startDate)).toDateString()} (
<PiconeroAmount amount={swap.xmrAmount} />)
</MenuItem>
))}
</Select>
);
}
const MAX_FEEDBACK_LENGTH = 4000;
export default function FeedbackDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const [pending, setPending] = useState(false);
const [bodyText, setBodyText] = useState('');
const currentSwapId = useActiveSwapInfo();
const { enqueueSnackbar } = useSnackbar();
const [selectedAttachedSwap, setSelectedAttachedSwap] = useState<
string | number
>(currentSwapId?.swapId || 0);
const bodyTooLong = bodyText.length > MAX_FEEDBACK_LENGTH;
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Submit Feedback</DialogTitle>
<DialogContent>
<DialogContentText>
Got something to say? Drop us a message below. If you had an issue
with a specific swap, select it from the dropdown to attach the logs.
It will help us figure out what went wrong. Hit that submit button
when you are ready. We appreciate you taking the time to share your
thoughts!
</DialogContentText>
<Box style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<TextField
variant="outlined"
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
label={
bodyTooLong
? `Text is too long (${bodyText.length}/${MAX_FEEDBACK_LENGTH})`
: 'Feedback'
}
multiline
minRows={4}
maxRows={4}
fullWidth
error={bodyTooLong}
/>
<SwapSelectDropDown
selectedSwap={selectedAttachedSwap}
setSelectedSwap={setSelectedAttachedSwap}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<LoadingButton
color="primary"
variant="contained"
onClick={async () => {
if (pending) {
return;
}
try {
setPending(true);
await submitFeedback(bodyText, selectedAttachedSwap);
enqueueSnackbar('Feedback submitted successfully!', {
variant: 'success',
});
} catch (e) {
console.error(`Failed to submit feedback: ${e}`);
enqueueSnackbar(`Failed to submit feedback (${e})`, {
variant: 'error',
});
} finally {
setPending(false);
}
onClose();
}}
loading={pending}
>
Submit
</LoadingButton>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,136 @@
import { ChangeEvent, useState } from 'react';
import {
DialogTitle,
Dialog,
DialogContent,
DialogContentText,
TextField,
DialogActions,
Button,
Box,
Chip,
makeStyles,
Theme,
} from '@material-ui/core';
import { Multiaddr } from 'multiaddr';
import { useSnackbar } from 'notistack';
import IpcInvokeButton from '../../IpcInvokeButton';
const PRESET_RENDEZVOUS_POINTS = [
'/dns4/discover.unstoppableswap.net/tcp/8888/p2p/12D3KooWA6cnqJpVnreBVnoro8midDL9Lpzmg8oJPoAGi7YYaamE',
'/dns4/eratosthen.es/tcp/7798/p2p/12D3KooWAh7EXXa2ZyegzLGdjvj1W4G3EXrTGrf6trraoT1MEobs',
];
const useStyles = makeStyles((theme: Theme) => ({
chipOuter: {
display: 'flex',
flexWrap: 'wrap',
gap: theme.spacing(1),
},
}));
type ListSellersDialogProps = {
open: boolean;
onClose: () => void;
};
export default function ListSellersDialog({
open,
onClose,
}: ListSellersDialogProps) {
const classes = useStyles();
const [rendezvousAddress, setRendezvousAddress] = useState('');
const { enqueueSnackbar } = useSnackbar();
function handleMultiAddrChange(event: ChangeEvent<HTMLInputElement>) {
setRendezvousAddress(event.target.value);
}
function getMultiAddressError(): string | null {
try {
const multiAddress = new Multiaddr(rendezvousAddress);
if (!multiAddress.protoNames().includes('p2p')) {
return 'The multi address must contain the peer id (/p2p/)';
}
return null;
} catch (e) {
return 'Not a valid multi address';
}
}
function handleSuccess(amountOfSellers: number) {
let message: string;
switch (amountOfSellers) {
case 0:
message = `No providers were discovered at the rendezvous point`;
break;
case 1:
message = `Discovered one provider at the rendezvous point`;
break;
default:
message = `Discovered ${amountOfSellers} providers at the rendezvous point`;
}
enqueueSnackbar(message, {
variant: 'success',
autoHideDuration: 5000,
});
onClose();
}
return (
<Dialog onClose={onClose} open={open}>
<DialogTitle>Discover swap providers</DialogTitle>
<DialogContent dividers>
<DialogContentText>
The rendezvous protocol provides a way to discover providers (trading
partners) without relying on one singular centralized institution. By
manually connecting to a rendezvous point run by a volunteer, you can
discover providers and then connect and swap with them.
</DialogContentText>
<TextField
autoFocus
margin="dense"
label="Rendezvous point"
fullWidth
helperText={
getMultiAddressError() || 'Multiaddress of the rendezvous point'
}
value={rendezvousAddress}
onChange={handleMultiAddrChange}
placeholder="/dns4/discover.unstoppableswap.net/tcp/8888/p2p/12D3KooWA6cnqJpVnreBVnoro8midDL9Lpzmg8oJPoAGi7YYaamE"
error={!!getMultiAddressError()}
/>
<Box className={classes.chipOuter}>
{PRESET_RENDEZVOUS_POINTS.map((rAddress) => (
<Chip
key={rAddress}
clickable
label={`${rAddress.substring(
0,
Math.min(rAddress.length - 1, 20),
)}...`}
onClick={() => setRendezvousAddress(rAddress)}
/>
))}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<IpcInvokeButton
variant="contained"
disabled={!(rendezvousAddress && !getMultiAddressError())}
color="primary"
onSuccess={handleSuccess}
ipcChannel="spawn-list-sellers"
ipcArgs={[rendezvousAddress]}
requiresRpc
>
Connect
</IpcInvokeButton>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,75 @@
import { makeStyles, Box, Typography, Chip, Tooltip } from '@material-ui/core';
import { VerifiedUser } from '@material-ui/icons';
import { satsToBtc, secondsToDays } from 'utils/conversionUtils';
import { ExtendedProviderStatus } from 'models/apiModel';
import {
MoneroBitcoinExchangeRate,
SatsAmount,
} from 'renderer/components/other/Units';
const useStyles = makeStyles((theme) => ({
content: {
flex: 1,
'& *': {
lineBreak: 'anywhere',
},
},
chipsOuter: {
display: 'flex',
marginTop: theme.spacing(1),
gap: theme.spacing(0.5),
flexWrap: 'wrap',
},
}));
export default function ProviderInfo({
provider,
}: {
provider: ExtendedProviderStatus;
}) {
const classes = useStyles();
return (
<Box className={classes.content}>
<Typography color="textSecondary" gutterBottom>
Swap Provider
</Typography>
<Typography variant="h5" component="h2">
{provider.multiAddr}
</Typography>
<Typography color="textSecondary" gutterBottom>
{provider.peerId.substring(0, 8)}...{provider.peerId.slice(-8)}
</Typography>
<Typography variant="caption">
Exchange rate:{' '}
<MoneroBitcoinExchangeRate rate={satsToBtc(provider.price)} />
<br />
Minimum swap amount: <SatsAmount amount={provider.minSwapAmount} />
<br />
Maximum swap amount: <SatsAmount amount={provider.maxSwapAmount} />
</Typography>
<Box className={classes.chipsOuter}>
<Chip label={provider.testnet ? 'Testnet' : 'Mainnet'} />
{provider.uptime && (
<Tooltip title="A high uptime indicates reliability. Providers with low uptime may be unreliable and cause swaps to take longer to complete or fail entirely.">
<Chip label={`${Math.round(provider.uptime * 100)} % uptime`} />
</Tooltip>
)}
{provider.age ? (
<Chip
label={`Went online ${Math.round(secondsToDays(provider.age))} ${
provider.age === 1 ? 'day' : 'days'
} ago`}
/>
) : (
<Chip label="Discovered via rendezvous point" />
)}
{provider.recommended === true && (
<Tooltip title="This provider has shown to be exceptionally reliable">
<Chip label="Recommended" icon={<VerifiedUser />} color="primary" />
</Tooltip>
)}
</Box>
</Box>
);
}

View file

@ -0,0 +1,129 @@
import {
Avatar,
List,
ListItem,
ListItemAvatar,
ListItemText,
DialogTitle,
Dialog,
DialogActions,
Button,
DialogContent,
makeStyles,
CircularProgress,
} from '@material-ui/core';
import AddIcon from '@material-ui/icons/Add';
import { useState } from 'react';
import SearchIcon from '@material-ui/icons/Search';
import { ExtendedProviderStatus } from 'models/apiModel';
import {
useAllProviders,
useAppDispatch,
useIsRpcEndpointBusy,
} from 'store/hooks';
import { setSelectedProvider } from 'store/features/providersSlice';
import { RpcMethod } from 'models/rpcModel';
import ProviderSubmitDialog from './ProviderSubmitDialog';
import ListSellersDialog from '../listSellers/ListSellersDialog';
import ProviderInfo from './ProviderInfo';
const useStyles = makeStyles({
dialogContent: {
padding: 0,
},
});
type ProviderSelectDialogProps = {
open: boolean;
onClose: () => void;
};
export function ProviderSubmitDialogOpenButton() {
const [open, setOpen] = useState(false);
return (
<ListItem
autoFocus
button
onClick={() => {
// Prevents background from being clicked and reopening dialog
if (!open) {
setOpen(true);
}
}}
>
<ProviderSubmitDialog open={open} onClose={() => setOpen(false)} />
<ListItemAvatar>
<Avatar>
<AddIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Add a new provider to public registry" />
</ListItem>
);
}
export function ListSellersDialogOpenButton() {
const [open, setOpen] = useState(false);
const running = useIsRpcEndpointBusy(RpcMethod.LIST_SELLERS);
return (
<ListItem
autoFocus
button
disabled={running}
onClick={() => {
// Prevents background from being clicked and reopening dialog
if (!open) {
setOpen(true);
}
}}
>
<ListSellersDialog open={open} onClose={() => setOpen(false)} />
<ListItemAvatar>
<Avatar>{running ? <CircularProgress /> : <SearchIcon />}</Avatar>
</ListItemAvatar>
<ListItemText primary="Discover providers by connecting to a rendezvous point" />
</ListItem>
);
}
export default function ProviderListDialog({
open,
onClose,
}: ProviderSelectDialogProps) {
const classes = useStyles();
const providers = useAllProviders();
const dispatch = useAppDispatch();
function handleProviderChange(provider: ExtendedProviderStatus) {
dispatch(setSelectedProvider(provider));
onClose();
}
return (
<Dialog onClose={onClose} open={open}>
<DialogTitle>Select a swap provider</DialogTitle>
<DialogContent className={classes.dialogContent} dividers>
<List>
{providers.map((provider) => (
<ListItem
button
onClick={() => handleProviderChange(provider)}
key={provider.peerId}
>
<ProviderInfo provider={provider} key={provider.peerId} />
</ListItem>
))}
<ListSellersDialogOpenButton />
<ProviderSubmitDialogOpenButton />
</List>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,62 @@
import {
makeStyles,
Card,
CardContent,
Box,
IconButton,
} from '@material-ui/core';
import ArrowForwardIosIcon from '@material-ui/icons/ArrowForwardIos';
import { useState } from 'react';
import { useAppSelector } from 'store/hooks';
import ProviderInfo from './ProviderInfo';
import ProviderListDialog from './ProviderListDialog';
const useStyles = makeStyles({
inner: {
textAlign: 'left',
width: '100%',
height: '100%',
},
providerCard: {
width: '100%',
},
providerCardContent: {
display: 'flex',
alignItems: 'center',
},
});
export default function ProviderSelect() {
const classes = useStyles();
const [selectDialogOpen, setSelectDialogOpen] = useState(false);
const selectedProvider = useAppSelector(
(state) => state.providers.selectedProvider,
);
if (!selectedProvider) return <>No provider selected</>;
function handleSelectDialogClose() {
setSelectDialogOpen(false);
}
function handleSelectDialogOpen() {
setSelectDialogOpen(true);
}
return (
<Box>
<ProviderListDialog
open={selectDialogOpen}
onClose={handleSelectDialogClose}
/>
<Card variant="outlined" className={classes.providerCard}>
<CardContent className={classes.providerCardContent}>
<ProviderInfo provider={selectedProvider} />
<IconButton onClick={handleSelectDialogOpen} size="small">
<ArrowForwardIosIcon />
</IconButton>
</CardContent>
</Card>
</Box>
);
}

View file

@ -0,0 +1,111 @@
import { ChangeEvent, useState } from 'react';
import {
DialogTitle,
Dialog,
DialogContent,
DialogContentText,
TextField,
DialogActions,
Button,
} from '@material-ui/core';
import { Multiaddr } from 'multiaddr';
type ProviderSubmitDialogProps = {
open: boolean;
onClose: () => void;
};
export default function ProviderSubmitDialog({
open,
onClose,
}: ProviderSubmitDialogProps) {
const [multiAddr, setMultiAddr] = useState('');
const [peerId, setPeerId] = useState('');
async function handleProviderSubmit() {
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 provider 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 provider to the public registry</DialogTitle>
<DialogContent dividers>
<DialogContentText>
If the provider 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 provider 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 provider and allows for secure communication"
value={peerId}
onChange={handlePeerIdChange}
placeholder="12D3KooWCdMKjesXMJz1SiZ7HgotrxuqhQJbP5sgBm2BwP1cqThi"
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="contained"
onClick={handleProviderSubmit}
disabled={!(multiAddr && peerId && !getMultiAddressError())}
color="primary"
>
Submit
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,22 @@
import QRCode from 'react-qr-code';
import { Box } from '@material-ui/core';
export default function BitcoinQrCode({ address }: { address: string }) {
return (
<Box
style={{
height: '100%',
margin: '0 auto',
}}
>
<QRCode
value={`bitcoin:${address}`}
size={256}
style={{ height: 'auto', maxWidth: '100%', width: '100%' }}
/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */
/* @ts-ignore */
viewBox="0 0 256 256"
/>
</Box>
);
}

View file

@ -0,0 +1,25 @@
import { isTestnet } from 'store/config';
import { getBitcoinTxExplorerUrl } from 'utils/conversionUtils';
import BitcoinIcon from 'renderer/components/icons/BitcoinIcon';
import { ReactNode } from 'react';
import TransactionInfoBox from './TransactionInfoBox';
type Props = {
title: string;
txId: string;
additionalContent: ReactNode;
loading: boolean;
};
export default function BitcoinTransactionInfoBox({ txId, ...props }: Props) {
const explorerUrl = getBitcoinTxExplorerUrl(txId, isTestnet());
return (
<TransactionInfoBox
txId={txId}
explorerUrl={explorerUrl}
icon={<BitcoinIcon />}
{...props}
/>
);
}

View file

@ -0,0 +1,35 @@
import {
Box,
CircularProgress,
makeStyles,
Typography,
} from '@material-ui/core';
import { ReactNode } from 'react';
const useStyles = makeStyles((theme) => ({
subtitle: {
paddingTop: theme.spacing(1),
},
}));
export default function CircularProgressWithSubtitle({
description,
}: {
description: string | ReactNode;
}) {
const classes = useStyles();
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
flexDirection="column"
>
<CircularProgress size={50} />
<Typography variant="subtitle2" className={classes.subtitle}>
{description}
</Typography>
</Box>
);
}

View file

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

View file

@ -0,0 +1,53 @@
import { ReactNode } from 'react';
import { Box, Typography } from '@material-ui/core';
import FileCopyOutlinedIcon from '@material-ui/icons/FileCopyOutlined';
import InfoBox from './InfoBox';
import ClipboardIconButton from './ClipbiardIconButton';
import BitcoinQrCode from './BitcoinQrCode';
type Props = {
title: string;
address: string;
additionalContent: ReactNode;
icon: ReactNode;
};
export default function DepositAddressInfoBox({
title,
address,
additionalContent,
icon,
}: Props) {
return (
<InfoBox
title={title}
mainContent={<Typography variant="h5">{address}</Typography>}
additionalContent={
<Box>
<Box>
<ClipboardIconButton
text={address}
endIcon={<FileCopyOutlinedIcon />}
color="primary"
variant="contained"
size="medium"
/>
<Box
style={{
display: 'flex',
flexDirection: 'row',
gap: '0.5rem',
alignItems: 'center',
}}
>
<Box>{additionalContent}</Box>
<BitcoinQrCode address={address} />
</Box>
</Box>
</Box>
}
icon={icon}
loading={false}
/>
);
}

View file

@ -0,0 +1,53 @@
import {
Box,
LinearProgress,
makeStyles,
Paper,
Typography,
} from '@material-ui/core';
import { ReactNode } from 'react';
type Props = {
title: ReactNode;
mainContent: ReactNode;
additionalContent: ReactNode;
loading: boolean;
icon: ReactNode;
};
const useStyles = makeStyles((theme) => ({
outer: {
padding: theme.spacing(1.5),
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
},
upperContent: {
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.5),
},
}));
export default function InfoBox({
title,
mainContent,
additionalContent,
icon,
loading,
}: Props) {
const classes = useStyles();
return (
<Paper variant="outlined" className={classes.outer}>
<Typography variant="subtitle1">{title}</Typography>
<Box className={classes.upperContent}>
{icon}
{mainContent}
</Box>
{loading ? <LinearProgress variant="indeterminate" /> : null}
<Box>{additionalContent}</Box>
</Paper>
);
}

View file

@ -0,0 +1,25 @@
import { isTestnet } from 'store/config';
import { getMoneroTxExplorerUrl } from 'utils/conversionUtils';
import MoneroIcon from 'renderer/components/icons/MoneroIcon';
import { ReactNode } from 'react';
import TransactionInfoBox from './TransactionInfoBox';
type Props = {
title: string;
txId: string;
additionalContent: ReactNode;
loading: boolean;
};
export default function MoneroTransactionInfoBox({ txId, ...props }: Props) {
const explorerUrl = getMoneroTxExplorerUrl(txId, isTestnet());
return (
<TransactionInfoBox
txId={txId}
explorerUrl={explorerUrl}
icon={<MoneroIcon />}
{...props}
/>
);
}

View file

@ -0,0 +1,90 @@
import { useState } from 'react';
import {
Button,
Dialog,
DialogActions,
DialogContent,
makeStyles,
} from '@material-ui/core';
import { useAppDispatch, useAppSelector } from 'store/hooks';
import { swapReset } from 'store/features/swapSlice';
import SwapStatePage from './pages/SwapStatePage';
import SwapStateStepper from './SwapStateStepper';
import SwapSuspendAlert from '../SwapSuspendAlert';
import SwapDialogTitle from './SwapDialogTitle';
import DebugPage from './pages/DebugPage';
const useStyles = makeStyles({
content: {
minHeight: '25rem',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
},
});
export default function SwapDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const classes = useStyles();
const swap = useAppSelector((state) => state.swap);
const [debug, setDebug] = useState(false);
const [openSuspendAlert, setOpenSuspendAlert] = useState(false);
const dispatch = useAppDispatch();
function onCancel() {
if (swap.processRunning) {
setOpenSuspendAlert(true);
} else {
onClose();
setTimeout(() => dispatch(swapReset()), 0);
}
}
// This prevents an issue where the Dialog is shown for a split second without a present swap state
if (!open) return null;
return (
<Dialog open={open} onClose={onCancel} maxWidth="md" fullWidth>
<SwapDialogTitle
debug={debug}
setDebug={setDebug}
title="Swap Bitcoin for Monero"
/>
<DialogContent dividers className={classes.content}>
{debug ? (
<DebugPage />
) : (
<>
<SwapStatePage swapState={swap.state} />
<SwapStateStepper />
</>
)}
</DialogContent>
<DialogActions>
<Button onClick={onCancel} variant="text">
Cancel
</Button>
<Button
color="primary"
variant="contained"
onClick={onCancel}
disabled={!(swap.state !== null && !swap.processRunning)}
>
Done
</Button>
</DialogActions>
<SwapSuspendAlert
open={openSuspendAlert}
onClose={() => setOpenSuspendAlert(false)}
/>
</Dialog>
);
}

View file

@ -0,0 +1,45 @@
import {
Box,
DialogTitle,
makeStyles,
Typography,
} from '@material-ui/core';
import TorStatusBadge from './pages/TorStatusBadge';
import FeedbackSubmitBadge from './pages/FeedbackSubmitBadge';
import DebugPageSwitchBadge from './pages/DebugPageSwitchBadge';
const useStyles = makeStyles((theme) => ({
root: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
rightSide: {
display: 'flex',
alignItems: 'center',
gridGap: theme.spacing(1),
},
}));
export default function SwapDialogTitle({
title,
debug,
setDebug,
}: {
title: string;
debug: boolean;
setDebug: (d: boolean) => void;
}) {
const classes = useStyles();
return (
<DialogTitle disableTypography className={classes.root}>
<Typography variant="h6">{title}</Typography>
<Box className={classes.rightSide}>
<FeedbackSubmitBadge />
<DebugPageSwitchBadge enabled={debug} setEnabled={setDebug} />
<TorStatusBadge />
</Box>
</DialogTitle>
);
}

View file

@ -0,0 +1,166 @@
import { Step, StepLabel, Stepper, Typography } from '@material-ui/core';
import { SwapSpawnType } from 'models/cliModel';
import { SwapStateName } from 'models/rpcModel';
import { useActiveSwapInfo, useAppSelector } from 'store/hooks';
import { exhaustiveGuard } from 'utils/typescriptUtils';
export enum PathType {
HAPPY_PATH = 'happy path',
UNHAPPY_PATH = 'unhappy path',
}
function getActiveStep(
stateName: SwapStateName | null,
processExited: boolean,
): [PathType, number, boolean] {
switch (stateName) {
/// // Happy Path
// Step: 0 (Waiting for Bitcoin lock tx to be published)
case null:
return [PathType.HAPPY_PATH, 0, false];
case SwapStateName.Started:
case SwapStateName.SwapSetupCompleted:
return [PathType.HAPPY_PATH, 0, processExited];
// Step: 1 (Waiting for Bitcoin Lock confirmation and XMR Lock Publication)
// We have locked the Bitcoin and are waiting for the other party to lock their XMR
case SwapStateName.BtcLocked:
return [PathType.HAPPY_PATH, 1, processExited];
// Step: 2 (Waiting for XMR Lock confirmation)
// We have locked the Bitcoin and the other party has locked their XMR
case SwapStateName.XmrLockProofReceived:
return [PathType.HAPPY_PATH, 1, processExited];
// Step: 3 (Sending Encrypted Signature and waiting for Bitcoin Redemption)
// The XMR lock transaction has been confirmed
// We now need to send the encrypted signature to the other party and wait for them to redeem the Bitcoin
case SwapStateName.XmrLocked:
case SwapStateName.EncSigSent:
return [PathType.HAPPY_PATH, 2, processExited];
// Step: 4 (Waiting for XMR Redemption)
case SwapStateName.BtcRedeemed:
return [PathType.HAPPY_PATH, 3, processExited];
// Step: 4 (Completed) (Swap completed, XMR redeemed)
case SwapStateName.XmrRedeemed:
return [PathType.HAPPY_PATH, 4, false];
// Edge Case of Happy Path where the swap is safely aborted. We "fail" at the first step.
case SwapStateName.SafelyAborted:
return [PathType.HAPPY_PATH, 0, true];
// // Unhappy Path
// Step: 1 (Cancelling swap, checking if cancel transaction has been published already by the other party)
case SwapStateName.CancelTimelockExpired:
return [PathType.UNHAPPY_PATH, 0, processExited];
// Step: 2 (Attempt to publish the Bitcoin refund transaction)
case SwapStateName.BtcCancelled:
return [PathType.UNHAPPY_PATH, 1, processExited];
// Step: 2 (Completed) (Bitcoin refunded)
case SwapStateName.BtcRefunded:
return [PathType.UNHAPPY_PATH, 2, false];
// Step: 2 (We failed to publish the Bitcoin refund transaction)
// We failed to publish the Bitcoin refund transaction because the timelock has expired.
// We will be punished. Nothing we can do about it now.
case SwapStateName.BtcPunished:
return [PathType.UNHAPPY_PATH, 1, true];
default:
return exhaustiveGuard(stateName);
}
}
function HappyPathStepper({
activeStep,
error,
}: {
activeStep: number;
error: boolean;
}) {
return (
<Stepper activeStep={activeStep}>
<Step key={0}>
<StepLabel
optional={<Typography variant="caption">~12min</Typography>}
error={error && activeStep === 0}
>
Locking your BTC
</StepLabel>
</Step>
<Step key={1}>
<StepLabel
optional={<Typography variant="caption">~18min</Typography>}
error={error && activeStep === 1}
>
They lock their XMR
</StepLabel>
</Step>
<Step key={2}>
<StepLabel
optional={<Typography variant="caption">~2min</Typography>}
error={error && activeStep === 2}
>
They redeem the BTC
</StepLabel>
</Step>
<Step key={3}>
<StepLabel
optional={<Typography variant="caption">~2min</Typography>}
error={error && activeStep === 3}
>
Redeeming your XMR
</StepLabel>
</Step>
</Stepper>
);
}
function UnhappyPathStepper({
activeStep,
error,
}: {
activeStep: number;
error: boolean;
}) {
return (
<Stepper activeStep={activeStep}>
<Step key={0}>
<StepLabel
optional={<Typography variant="caption">~20min</Typography>}
error={error && activeStep === 0}
>
Cancelling swap
</StepLabel>
</Step>
<Step key={1}>
<StepLabel
optional={<Typography variant="caption">~20min</Typography>}
error={error && activeStep === 1}
>
Refunding your BTC
</StepLabel>
</Step>
</Stepper>
);
}
export default function SwapStateStepper() {
const currentSwapSpawnType = useAppSelector((s) => s.swap.spawnType);
const stateName = useActiveSwapInfo()?.stateName ?? null;
const processExited = useAppSelector((s) => !s.swap.processRunning);
const [pathType, activeStep, error] = getActiveStep(stateName, processExited);
// If the current swap is being manually cancelled and refund, we want to show the unhappy path even though the current state is not a "unhappy" state
if (currentSwapSpawnType === SwapSpawnType.CANCEL_REFUND) {
return <UnhappyPathStepper activeStep={0} error={error} />;
}
if (pathType === PathType.HAPPY_PATH) {
return <HappyPathStepper activeStep={activeStep} error={error} />;
}
return <UnhappyPathStepper activeStep={activeStep} error={error} />;
}

View file

@ -0,0 +1,40 @@
import { Link, Typography } from '@material-ui/core';
import { ReactNode } from 'react';
import InfoBox from './InfoBox';
type TransactionInfoBoxProps = {
title: string;
txId: string;
explorerUrl: string;
additionalContent: ReactNode;
loading: boolean;
icon: JSX.Element;
};
export default function TransactionInfoBox({
title,
txId,
explorerUrl,
additionalContent,
icon,
loading,
}: TransactionInfoBoxProps) {
return (
<InfoBox
title={title}
mainContent={<Typography variant="h5">{txId}</Typography>}
loading={loading}
additionalContent={
<>
<Typography variant="subtitle2">{additionalContent}</Typography>
<Typography variant="body1">
<Link href={explorerUrl} target="_blank">
View on explorer
</Link>
</Typography>
</>
}
icon={icon}
/>
);
}

View file

@ -0,0 +1,36 @@
import { Box, DialogContentText } from "@material-ui/core";
import { useActiveSwapInfo, useAppSelector } from "store/hooks";
import CliLogsBox from "../../../other/RenderedCliLog";
import JsonTreeView from "../../../other/JSONViewTree";
export default function DebugPage() {
const torStdOut = useAppSelector((s) => s.tor.stdOut);
const logs = useAppSelector((s) => s.swap.logs);
const guiState = useAppSelector((s) => s);
const cliState = useActiveSwapInfo();
return (
<Box>
<DialogContentText>
<Box
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<CliLogsBox logs={logs} label="Logs relevant to the swap" />
<JsonTreeView
data={guiState}
label="Internal GUI State (inferred from Logs)"
/>
<JsonTreeView
data={cliState}
label="Swap Daemon State (exposed via API)"
/>
<CliLogsBox label="Tor Daemon Logs" logs={torStdOut.split("\n")} />
</Box>
</DialogContentText>
</Box>
);
}

View file

@ -0,0 +1,26 @@
import { Tooltip } from '@material-ui/core';
import IconButton from '@material-ui/core/IconButton';
import DeveloperBoardIcon from '@material-ui/icons/DeveloperBoard';
export default function DebugPageSwitchBadge({
enabled,
setEnabled,
}: {
enabled: boolean;
setEnabled: (enabled: boolean) => void;
}) {
const handleToggle = () => {
setEnabled(!enabled);
};
return (
<Tooltip title={enabled ? 'Hide debug view' : 'Show debug view'}>
<IconButton
onClick={handleToggle}
color={enabled ? 'primary' : 'default'}
>
<DeveloperBoardIcon />
</IconButton>
</Tooltip>
);
}

View file

@ -0,0 +1,22 @@
import { IconButton } from '@material-ui/core';
import FeedbackIcon from '@material-ui/icons/Feedback';
import FeedbackDialog from '../../feedback/FeedbackDialog';
import { useState } from 'react';
export default function FeedbackSubmitBadge() {
const [showFeedbackDialog, setShowFeedbackDialog] = useState(false);
return (
<>
{showFeedbackDialog && (
<FeedbackDialog
open={showFeedbackDialog}
onClose={() => setShowFeedbackDialog(false)}
/>
)}
<IconButton onClick={() => setShowFeedbackDialog(true)}>
<FeedbackIcon />
</IconButton>
</>
);
}

View file

@ -0,0 +1,106 @@
import { Box } from '@material-ui/core';
import { useAppSelector } from 'store/hooks';
import {
isSwapStateBtcCancelled,
isSwapStateBtcLockInMempool,
isSwapStateBtcPunished,
isSwapStateBtcRedemeed,
isSwapStateBtcRefunded,
isSwapStateInitiated,
isSwapStateProcessExited,
isSwapStateReceivedQuote,
isSwapStateStarted,
isSwapStateWaitingForBtcDeposit,
isSwapStateXmrLocked,
isSwapStateXmrLockInMempool,
isSwapStateXmrRedeemInMempool,
SwapState,
} from '../../../../../models/storeModel';
import InitiatedPage from './init/InitiatedPage';
import WaitingForBitcoinDepositPage from './init/WaitingForBitcoinDepositPage';
import StartedPage from './in_progress/StartedPage';
import BitcoinLockTxInMempoolPage from './in_progress/BitcoinLockTxInMempoolPage';
import XmrLockTxInMempoolPage from './in_progress/XmrLockInMempoolPage';
// eslint-disable-next-line import/no-cycle
import ProcessExitedPage from './exited/ProcessExitedPage';
import XmrRedeemInMempoolPage from './done/XmrRedeemInMempoolPage';
import ReceivedQuotePage from './in_progress/ReceivedQuotePage';
import BitcoinRedeemedPage from './in_progress/BitcoinRedeemedPage';
import InitPage from './init/InitPage';
import XmrLockedPage from './in_progress/XmrLockedPage';
import BitcoinCancelledPage from './in_progress/BitcoinCancelledPage';
import BitcoinRefundedPage from './done/BitcoinRefundedPage';
import BitcoinPunishedPage from './done/BitcoinPunishedPage';
import { SyncingMoneroWalletPage } from './in_progress/SyncingMoneroWalletPage';
export default function SwapStatePage({
swapState,
}: {
swapState: SwapState | null;
}) {
const isSyncingMoneroWallet = useAppSelector(
(state) => state.rpc.state.moneroWallet.isSyncing,
);
if (isSyncingMoneroWallet) {
return <SyncingMoneroWalletPage />;
}
if (swapState === null) {
return <InitPage />;
}
if (isSwapStateInitiated(swapState)) {
return <InitiatedPage />;
}
if (isSwapStateReceivedQuote(swapState)) {
return <ReceivedQuotePage />;
}
if (isSwapStateWaitingForBtcDeposit(swapState)) {
return <WaitingForBitcoinDepositPage state={swapState} />;
}
if (isSwapStateStarted(swapState)) {
return <StartedPage state={swapState} />;
}
if (isSwapStateBtcLockInMempool(swapState)) {
return <BitcoinLockTxInMempoolPage state={swapState} />;
}
if (isSwapStateXmrLockInMempool(swapState)) {
return <XmrLockTxInMempoolPage state={swapState} />;
}
if (isSwapStateXmrLocked(swapState)) {
return <XmrLockedPage />;
}
if (isSwapStateBtcRedemeed(swapState)) {
return <BitcoinRedeemedPage />;
}
if (isSwapStateXmrRedeemInMempool(swapState)) {
return <XmrRedeemInMempoolPage state={swapState} />;
}
if (isSwapStateBtcCancelled(swapState)) {
return <BitcoinCancelledPage />;
}
if (isSwapStateBtcRefunded(swapState)) {
return <BitcoinRefundedPage state={swapState} />;
}
if (isSwapStateBtcPunished(swapState)) {
return <BitcoinPunishedPage />;
}
if (isSwapStateProcessExited(swapState)) {
return <ProcessExitedPage state={swapState} />;
}
console.error(
`No swap state page found for swap state State: ${JSON.stringify(
swapState,
null,
4,
)}`,
);
return (
<Box>
No information to display
<br />
State: ${JSON.stringify(swapState, null, 4)}
</Box>
);
}

View file

@ -0,0 +1,19 @@
import { IconButton, Tooltip } from '@material-ui/core';
import { useAppSelector } from 'store/hooks';
import TorIcon from '../../../icons/TorIcon';
export default function TorStatusBadge() {
const tor = useAppSelector((s) => s.tor);
if (tor.processRunning) {
return (
<Tooltip title="Tor is running in the background">
<IconButton>
<TorIcon htmlColor="#7D4698" />
</IconButton>
</Tooltip>
);
}
return <></>;
}

View file

@ -0,0 +1,15 @@
import { Box, DialogContentText } from '@material-ui/core';
import FeedbackInfoBox from '../../../../pages/help/FeedbackInfoBox';
export default function BitcoinPunishedPage() {
return (
<Box>
<DialogContentText>
Unfortunately, the swap was not successful, and you&apos;ve incurred a
penalty because the swap was not refunded in time. Both the Bitcoin and
Monero are irretrievable.
</DialogContentText>
<FeedbackInfoBox />
</Box>
);
}

View file

@ -0,0 +1,43 @@
import { Box, DialogContentText } from '@material-ui/core';
import { SwapStateBtcRefunded } from 'models/storeModel';
import { useActiveSwapInfo } from 'store/hooks';
import BitcoinTransactionInfoBox from '../../BitcoinTransactionInfoBox';
import FeedbackInfoBox from '../../../../pages/help/FeedbackInfoBox';
export default function BitcoinRefundedPage({
state,
}: {
state: SwapStateBtcRefunded | null;
}) {
const swap = useActiveSwapInfo();
const additionalContent = swap
? `Refund address: ${swap.btcRefundAddress}`
: null;
return (
<Box>
<DialogContentText>
Unfortunately, the swap was not successful. However, rest assured that
all your Bitcoin has been refunded to the specified address. The swap
process is now complete, and you are free to exit the application.
</DialogContentText>
<Box
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
>
{state && (
<BitcoinTransactionInfoBox
title="Bitcoin Refund Transaction"
txId={state.bobBtcRefundTxId}
loading={false}
additionalContent={additionalContent}
/>
)}
<FeedbackInfoBox />
</Box>
</Box>
);
}

View file

@ -0,0 +1,49 @@
import { Box, DialogContentText } from '@material-ui/core';
import { SwapStateXmrRedeemInMempool } from 'models/storeModel';
import { useActiveSwapInfo } from 'store/hooks';
import { getSwapXmrAmount } from 'models/rpcModel';
import MoneroTransactionInfoBox from '../../MoneroTransactionInfoBox';
import FeedbackInfoBox from '../../../../pages/help/FeedbackInfoBox';
type XmrRedeemInMempoolPageProps = {
state: SwapStateXmrRedeemInMempool | null;
};
export default function XmrRedeemInMempoolPage({
state,
}: XmrRedeemInMempoolPageProps) {
const swap = useActiveSwapInfo();
const additionalContent = swap
? `This transaction transfers ${getSwapXmrAmount(swap).toFixed(6)} XMR to ${
state?.bobXmrRedeemAddress
}`
: null;
return (
<Box>
<DialogContentText>
The swap was successful and the Monero has been sent to the address you
specified. The swap is completed and you may exit the application now.
</DialogContentText>
<Box
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
>
{state && (
<>
<MoneroTransactionInfoBox
title="Monero Redeem Transaction"
txId={state.bobXmrRedeemTxId}
additionalContent={additionalContent}
loading={false}
/>
</>
)}
<FeedbackInfoBox />
</Box>
</Box>
);
}

View file

@ -0,0 +1,71 @@
import { Box, DialogContentText } from '@material-ui/core';
import { useActiveSwapInfo, useAppSelector } from 'store/hooks';
import { SwapStateProcessExited } from 'models/storeModel';
import CliLogsBox from '../../../../other/RenderedCliLog';
import { SwapSpawnType } from 'models/cliModel';
export default function ProcessExitedAndNotDonePage({
state,
}: {
state: SwapStateProcessExited;
}) {
const swap = useActiveSwapInfo();
const logs = useAppSelector((s) => s.swap.logs);
const spawnType = useAppSelector((s) => s.swap.spawnType);
function getText() {
const isCancelRefund = spawnType === SwapSpawnType.CANCEL_REFUND;
const hasRpcError = state.rpcError != null;
const hasSwap = swap != null;
let messages = [];
messages.push(
isCancelRefund
? 'The manual cancel and refund was unsuccessful.'
: 'The swap exited unexpectedly without completing.',
);
if (!hasSwap && !isCancelRefund) {
messages.push('No funds were locked.');
}
messages.push(
hasRpcError
? 'Check the error and the logs below for more information.'
: 'Check the logs below for more information.',
);
if (hasSwap) {
messages.push(`The swap is in the "${swap.stateName}" state.`);
if (!isCancelRefund) {
messages.push(
'Try resuming the swap or attempt to initiate a manual cancel and refund.',
);
}
}
return messages.join(' ');
}
return (
<Box>
<DialogContentText>{getText()}</DialogContentText>
<Box
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
>
{state.rpcError && (
<CliLogsBox
logs={[state.rpcError]}
label="Error returned by the Swap Daemon"
/>
)}
<CliLogsBox logs={logs} label="Logs relevant to the swap" />
</Box>
</Box>
);
}

View file

@ -0,0 +1,47 @@
import { useActiveSwapInfo } from 'store/hooks';
import { SwapStateName } from 'models/rpcModel';
import {
isSwapStateBtcPunished,
isSwapStateBtcRefunded,
isSwapStateXmrRedeemInMempool,
SwapStateProcessExited,
} from '../../../../../../models/storeModel';
import XmrRedeemInMempoolPage from '../done/XmrRedeemInMempoolPage';
import BitcoinPunishedPage from '../done/BitcoinPunishedPage';
// eslint-disable-next-line import/no-cycle
import SwapStatePage from '../SwapStatePage';
import BitcoinRefundedPage from '../done/BitcoinRefundedPage';
import ProcessExitedAndNotDonePage from './ProcessExitedAndNotDonePage';
type ProcessExitedPageProps = {
state: SwapStateProcessExited;
};
export default function ProcessExitedPage({ state }: ProcessExitedPageProps) {
const swap = useActiveSwapInfo();
// If we have a swap state, for a "done" state we should use it to display additional information that can't be extracted from the database
if (
isSwapStateXmrRedeemInMempool(state.prevState) ||
isSwapStateBtcRefunded(state.prevState) ||
isSwapStateBtcPunished(state.prevState)
) {
return <SwapStatePage swapState={state.prevState} />;
}
// If we don't have a swap state for a "done" state, we should fall back to using the database to display as much information as we can
if (swap) {
if (swap.stateName === SwapStateName.XmrRedeemed) {
return <XmrRedeemInMempoolPage state={null} />;
}
if (swap.stateName === SwapStateName.BtcRefunded) {
return <BitcoinRefundedPage state={null} />;
}
if (swap.stateName === SwapStateName.BtcPunished) {
return <BitcoinPunishedPage />;
}
}
// If the swap is not a "done" state (or we don't have a db state because the swap did complete the SwapSetup yet) we should tell the user and show logs
return <ProcessExitedAndNotDonePage state={state} />;
}

View file

@ -0,0 +1,5 @@
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
export default function BitcoinCancelledPage() {
return <CircularProgressWithSubtitle description="Refunding your Bitcoin" />;
}

View file

@ -0,0 +1,38 @@
import { Box, DialogContentText } from '@material-ui/core';
import { SwapStateBtcLockInMempool } from 'models/storeModel';
import BitcoinTransactionInfoBox from '../../BitcoinTransactionInfoBox';
import SwapMightBeCancelledAlert from '../../../../alert/SwapMightBeCancelledAlert';
type BitcoinLockTxInMempoolPageProps = {
state: SwapStateBtcLockInMempool;
};
export default function BitcoinLockTxInMempoolPage({
state,
}: BitcoinLockTxInMempoolPageProps) {
return (
<Box>
<SwapMightBeCancelledAlert
bobBtcLockTxConfirmations={state.bobBtcLockTxConfirmations}
/>
<DialogContentText>
The Bitcoin lock transaction has been published. The swap will proceed
once the transaction is confirmed and the swap provider locks their
Monero.
</DialogContentText>
<BitcoinTransactionInfoBox
title="Bitcoin Lock Transaction"
txId={state.bobBtcLockTxId}
loading
additionalContent={
<>
Most swap providers require one confirmation before locking their
Monero
<br />
Confirmations: {state.bobBtcLockTxConfirmations}
</>
}
/>
</Box>
);
}

View file

@ -0,0 +1,5 @@
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
export default function BitcoinRedeemedPage() {
return <CircularProgressWithSubtitle description="Redeeming your Monero" />;
}

View file

@ -0,0 +1,7 @@
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
export default function ReceivedQuotePage() {
return (
<CircularProgressWithSubtitle description="Exchanging keys, zero-knowledge proofs and generating multi-signature addresses" />
);
}

View file

@ -0,0 +1,16 @@
import { SwapStateStarted } from 'models/storeModel';
import { BitcoinAmount } from 'renderer/components/other/Units';
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
export default function StartedPage({ state }: { state: SwapStateStarted }) {
const description = state.txLockDetails ? (
<>
Locking <BitcoinAmount amount={state.txLockDetails.amount} /> with a
network fee of <BitcoinAmount amount={state.txLockDetails.fees} />
</>
) : (
'Locking Bitcoin'
);
return <CircularProgressWithSubtitle description={description} />;
}

View file

@ -0,0 +1,7 @@
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
export function SyncingMoneroWalletPage() {
return (
<CircularProgressWithSubtitle description="Syncing Monero wallet with blockchain, this might take a while..." />
);
}

View file

@ -0,0 +1,29 @@
import { Box, DialogContentText } from '@material-ui/core';
import { SwapStateXmrLockInMempool } from 'models/storeModel';
import MoneroTransactionInfoBox from '../../MoneroTransactionInfoBox';
type XmrLockTxInMempoolPageProps = {
state: SwapStateXmrLockInMempool;
};
export default function XmrLockTxInMempoolPage({
state,
}: XmrLockTxInMempoolPageProps) {
const additionalContent = `Confirmations: ${state.aliceXmrLockTxConfirmations}/10`;
return (
<Box>
<DialogContentText>
They have published their Monero lock transaction. The swap will proceed
once the transaction has been confirmed.
</DialogContentText>
<MoneroTransactionInfoBox
title="Monero Lock Transaction"
txId={state.aliceXmrLockTxId}
additionalContent={additionalContent}
loading
/>
</Box>
);
}

View file

@ -0,0 +1,7 @@
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
export default function XmrLockedPage() {
return (
<CircularProgressWithSubtitle description="Waiting for them to redeem the Bitcoin" />
);
}

View file

@ -0,0 +1,91 @@
import { useState } from 'react';
import { Box, makeStyles, TextField, Typography } from '@material-ui/core';
import { SwapStateWaitingForBtcDeposit } from 'models/storeModel';
import { useAppSelector } from 'store/hooks';
import { satsToBtc } from 'utils/conversionUtils';
import { MoneroAmount } from '../../../../other/Units';
const MONERO_FEE = 0.000016;
const useStyles = makeStyles((theme) => ({
outer: {
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
},
textField: {
'& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button': {
display: 'none',
},
'& input[type=number]': {
MozAppearance: 'textfield',
},
maxWidth: theme.spacing(16),
},
}));
function calcBtcAmountWithoutFees(amount: number, fees: number) {
return amount - fees;
}
export default function DepositAmountHelper({
state,
}: {
state: SwapStateWaitingForBtcDeposit;
}) {
const classes = useStyles();
const [amount, setAmount] = useState(state.minDeposit);
const bitcoinBalance = useAppSelector((s) => s.rpc.state.balance) || 0;
function getTotalAmountAfterDeposit() {
return amount + satsToBtc(bitcoinBalance);
}
function hasError() {
return (
amount < state.minDeposit ||
getTotalAmountAfterDeposit() > state.maximumAmount
);
}
function calcXMRAmount(): number | null {
if (Number.isNaN(amount)) return null;
if (hasError()) return null;
if (state.price == null) return null;
console.log(
`Calculating calcBtcAmountWithoutFees(${getTotalAmountAfterDeposit()}, ${
state.minBitcoinLockTxFee
}) / ${state.price} - ${MONERO_FEE}`,
);
return (
calcBtcAmountWithoutFees(
getTotalAmountAfterDeposit(),
state.minBitcoinLockTxFee,
) /
state.price -
MONERO_FEE
);
}
return (
<Box className={classes.outer}>
<Typography variant="subtitle2">
Depositing {bitcoinBalance > 0 && <>another</>}
</Typography>
<TextField
error={hasError()}
value={amount}
onChange={(e) => setAmount(parseFloat(e.target.value))}
size="small"
type="number"
className={classes.textField}
/>
<Typography variant="subtitle2">
BTC will give you approximately{' '}
<MoneroAmount amount={calcXMRAmount()} />.
</Typography>
</Box>
);
}

View file

@ -0,0 +1,14 @@
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
import { MoneroWalletRpcUpdateState } from '../../../../../../models/storeModel';
export default function DownloadingMoneroWalletRpcPage({
updateState,
}: {
updateState: MoneroWalletRpcUpdateState;
}) {
return (
<CircularProgressWithSubtitle
description={`Updating monero-wallet-rpc (${updateState.progress}) `}
/>
);
}

View file

@ -0,0 +1,82 @@
import { Box, DialogContentText, makeStyles } from '@material-ui/core';
import { useState } from 'react';
import BitcoinAddressTextField from 'renderer/components/inputs/BitcoinAddressTextField';
import MoneroAddressTextField from 'renderer/components/inputs/MoneroAddressTextField';
import { useAppSelector } from 'store/hooks';
import PlayArrowIcon from '@material-ui/icons/PlayArrow';
import { isTestnet } from 'store/config';
import RemainingFundsWillBeUsedAlert from '../../../../alert/RemainingFundsWillBeUsedAlert';
import IpcInvokeButton from '../../../../IpcInvokeButton';
const useStyles = makeStyles((theme) => ({
initButton: {
marginTop: theme.spacing(1),
},
fieldsOuter: {
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
},
}));
export default function InitPage() {
const classes = useStyles();
const [redeemAddress, setRedeemAddress] = useState(
''
);
const [refundAddress, setRefundAddress] = useState(
''
);
const [redeemAddressValid, setRedeemAddressValid] = useState(false);
const [refundAddressValid, setRefundAddressValid] = useState(false);
const selectedProvider = useAppSelector(
(state) => state.providers.selectedProvider,
);
return (
<Box>
<RemainingFundsWillBeUsedAlert />
<DialogContentText>
Please specify the address to which the Monero should be sent upon
completion of the swap and the address for receiving a Bitcoin refund if
the swap fails.
</DialogContentText>
<Box className={classes.fieldsOuter}>
<MoneroAddressTextField
label="Monero redeem address"
address={redeemAddress}
onAddressChange={setRedeemAddress}
onAddressValidityChange={setRedeemAddressValid}
helperText="The monero will be sent to this address"
fullWidth
/>
<BitcoinAddressTextField
label="Bitcoin refund address"
address={refundAddress}
onAddressChange={setRefundAddress}
onAddressValidityChange={setRefundAddressValid}
helperText="In case something goes terribly wrong, all Bitcoin will be refunded to this address"
fullWidth
/>
</Box>
<IpcInvokeButton
disabled={
!refundAddressValid || !redeemAddressValid || !selectedProvider
}
variant="contained"
color="primary"
size="large"
className={classes.initButton}
endIcon={<PlayArrowIcon />}
ipcChannel="spawn-buy-xmr"
ipcArgs={[selectedProvider, redeemAddress, refundAddress]}
displayErrorSnackbar={false}
>
Start swap
</IpcInvokeButton>
</Box>
);
}

View file

@ -0,0 +1,21 @@
import { useAppSelector } from 'store/hooks';
import { SwapSpawnType } from 'models/cliModel';
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
export default function InitiatedPage() {
const description = useAppSelector((s) => {
switch (s.swap.spawnType) {
case SwapSpawnType.INIT:
return 'Requesting quote from provider...';
case SwapSpawnType.RESUME:
return 'Resuming swap...';
case SwapSpawnType.CANCEL_REFUND:
return 'Attempting to cancel & refund swap...';
default:
// Should never be hit
return 'Initiating swap...';
}
});
return <CircularProgressWithSubtitle description={description} />;
}

View file

@ -0,0 +1,86 @@
import { Box, makeStyles, Typography } from '@material-ui/core';
import { SwapStateWaitingForBtcDeposit } from 'models/storeModel';
import { useAppSelector } from 'store/hooks';
import DepositAddressInfoBox from '../../DepositAddressInfoBox';
import BitcoinIcon from '../../../../icons/BitcoinIcon';
import DepositAmountHelper from './DepositAmountHelper';
import {
BitcoinAmount,
MoneroBitcoinExchangeRate,
SatsAmount,
} from '../../../../other/Units';
const useStyles = makeStyles((theme) => ({
amountHelper: {
display: 'flex',
alignItems: 'center',
},
additionalContent: {
paddingTop: theme.spacing(1),
gap: theme.spacing(0.5),
display: 'flex',
flexDirection: 'column',
},
}));
type WaitingForBtcDepositPageProps = {
state: SwapStateWaitingForBtcDeposit;
};
export default function WaitingForBtcDepositPage({
state,
}: WaitingForBtcDepositPageProps) {
const classes = useStyles();
const bitcoinBalance = useAppSelector((s) => s.rpc.state.balance) || 0;
// TODO: Account for BTC lock tx fees
return (
<Box>
<DepositAddressInfoBox
title="Bitcoin Deposit Address"
address={state.depositAddress}
additionalContent={
<Box className={classes.additionalContent}>
<Typography variant="subtitle2">
<ul>
{bitcoinBalance > 0 ? (
<li>
You have already deposited{' '}
<SatsAmount amount={bitcoinBalance} />
</li>
) : null}
<li>
Send any amount between{' '}
<BitcoinAmount amount={state.minDeposit} /> and{' '}
<BitcoinAmount amount={state.maxDeposit} /> to the address
above
{bitcoinBalance > 0 && (
<> (on top of the already deposited funds)</>
)}
</li>
<li>
All Bitcoin sent to this this address will converted into
Monero at an exchance rate of{' '}
<MoneroBitcoinExchangeRate rate={state.price} />
</li>
<li>
The network fee of{' '}
<BitcoinAmount amount={state.minBitcoinLockTxFee} /> will
automatically be deducted from the deposited coins
</li>
<li>
The swap will start automatically as soon as the minimum
amount is deposited
</li>
</ul>
</Typography>
<DepositAmountHelper
state={state}
/>
</Box>
}
icon={<BitcoinIcon />}
/>
</Box>
);
}

View file

@ -0,0 +1,34 @@
import { Dialog } from '@material-ui/core';
import { useAppDispatch, useIsRpcEndpointBusy } from 'store/hooks';
import { RpcMethod } from 'models/rpcModel';
import { rpcResetWithdrawTxId } from 'store/features/rpcSlice';
import WithdrawStatePage from './WithdrawStatePage';
import DialogHeader from '../DialogHeader';
export default function WithdrawDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const isRpcEndpointBusy = useIsRpcEndpointBusy(RpcMethod.WITHDRAW_BTC);
const dispatch = useAppDispatch();
function onCancel() {
if (!isRpcEndpointBusy) {
onClose();
dispatch(rpcResetWithdrawTxId());
}
}
// This prevents an issue where the Dialog is shown for a split second without a present withdraw state
if (!open && !isRpcEndpointBusy) return null;
return (
<Dialog open onClose={onCancel} maxWidth="sm" fullWidth>
<DialogHeader title="Withdraw Bitcoin" />
<WithdrawStatePage onCancel={onCancel} />
</Dialog>
);
}

View file

@ -0,0 +1,27 @@
import { ReactNode } from 'react';
import { Box, DialogContent, makeStyles } from '@material-ui/core';
import WithdrawStepper from './WithdrawStepper';
const useStyles = makeStyles({
outer: {
minHeight: '15rem',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
},
});
export default function WithdrawDialogContent({
children,
}: {
children: ReactNode;
}) {
const classes = useStyles();
return (
<DialogContent dividers className={classes.outer}>
<Box>{children}</Box>
<WithdrawStepper />
</DialogContent>
);
}

View file

@ -0,0 +1,27 @@
import { useAppSelector, useIsRpcEndpointBusy } from 'store/hooks';
import { RpcMethod } from 'models/rpcModel';
import AddressInputPage from './pages/AddressInputPage';
import InitiatedPage from './pages/InitiatedPage';
import BtcTxInMempoolPageContent from './pages/BitcoinWithdrawTxInMempoolPage';
export default function WithdrawStatePage({
onCancel,
}: {
onCancel: () => void;
}) {
const isRpcEndpointBusy = useIsRpcEndpointBusy(RpcMethod.WITHDRAW_BTC);
const withdrawTxId = useAppSelector((state) => state.rpc.state.withdrawTxId);
if (withdrawTxId !== null) {
return (
<BtcTxInMempoolPageContent
withdrawTxId={withdrawTxId}
onCancel={onCancel}
/>
);
}
if (isRpcEndpointBusy) {
return <InitiatedPage onCancel={onCancel} />;
}
return <AddressInputPage onCancel={onCancel} />;
}

View file

@ -0,0 +1,32 @@
import { Step, StepLabel, Stepper } from '@material-ui/core';
import { useAppSelector, useIsRpcEndpointBusy } from 'store/hooks';
import { RpcMethod } from 'models/rpcModel';
function getActiveStep(
isWithdrawInProgress: boolean,
withdrawTxId: string | null,
) {
if (isWithdrawInProgress) {
return 1;
}
if (withdrawTxId !== null) {
return 2;
}
return 0;
}
export default function WithdrawStepper() {
const isWithdrawInProgress = useIsRpcEndpointBusy(RpcMethod.WITHDRAW_BTC);
const withdrawTxId = useAppSelector((s) => s.rpc.state.withdrawTxId);
return (
<Stepper activeStep={getActiveStep(isWithdrawInProgress, withdrawTxId)}>
<Step key={0}>
<StepLabel>Enter withdraw address</StepLabel>
</Step>
<Step key={2}>
<StepLabel error={false}>Transfer funds to wallet</StepLabel>
</Step>
</Stepper>
);
}

View file

@ -0,0 +1,49 @@
import { useState } from 'react';
import { Button, DialogActions, DialogContentText } from '@material-ui/core';
import BitcoinAddressTextField from '../../../inputs/BitcoinAddressTextField';
import WithdrawDialogContent from '../WithdrawDialogContent';
import IpcInvokeButton from '../../../IpcInvokeButton';
export default function AddressInputPage({
onCancel,
}: {
onCancel: () => void;
}) {
const [withdrawAddressValid, setWithdrawAddressValid] = useState(false);
const [withdrawAddress, setWithdrawAddress] = useState('');
return (
<>
<WithdrawDialogContent>
<DialogContentText>
To withdraw the BTC of the internal wallet, please enter an address.
All funds will be sent to that address.
</DialogContentText>
<BitcoinAddressTextField
address={withdrawAddress}
onAddressChange={setWithdrawAddress}
onAddressValidityChange={setWithdrawAddressValid}
helperText="All Bitcoin of the internal wallet will be transferred to this address"
fullWidth
/>
</WithdrawDialogContent>
<DialogActions>
<Button onClick={onCancel} variant="text">
Cancel
</Button>
<IpcInvokeButton
disabled={!withdrawAddressValid}
ipcChannel="spawn-withdraw-btc"
ipcArgs={[withdrawAddress]}
color="primary"
variant="contained"
requiresRpc
>
Withdraw
</IpcInvokeButton>
</DialogActions>
</>
);
}

View file

@ -0,0 +1,36 @@
import { Button, DialogActions, DialogContentText } from '@material-ui/core';
import BitcoinTransactionInfoBox from '../../swap/BitcoinTransactionInfoBox';
import WithdrawDialogContent from '../WithdrawDialogContent';
export default function BtcTxInMempoolPageContent({
withdrawTxId,
onCancel,
}: {
withdrawTxId: string;
onCancel: () => void;
}) {
return (
<>
<WithdrawDialogContent>
<DialogContentText>
All funds of the internal Bitcoin wallet have been transferred to your
withdraw address.
</DialogContentText>
<BitcoinTransactionInfoBox
txId={withdrawTxId}
loading={false}
title="Bitcoin Withdraw Transaction"
additionalContent={null}
/>
</WithdrawDialogContent>
<DialogActions>
<Button onClick={onCancel} variant="text">
Cancel
</Button>
<Button onClick={onCancel} color="primary" variant="contained">
Done
</Button>
</DialogActions>
</>
);
}

View file

@ -0,0 +1,21 @@
import { Button, DialogActions } from '@material-ui/core';
import CircularProgressWithSubtitle from '../../swap/CircularProgressWithSubtitle';
import WithdrawDialogContent from '../WithdrawDialogContent';
export default function InitiatedPage({ onCancel }: { onCancel: () => void }) {
return (
<>
<WithdrawDialogContent>
<CircularProgressWithSubtitle description="Withdrawing Bitcoin" />
</WithdrawDialogContent>
<DialogActions>
<Button onClick={onCancel} variant="text">
Cancel
</Button>
<Button disabled color="primary" variant="contained">
Done
</Button>
</DialogActions>
</>
);
}