mirror of
https://github.com/comit-network/xmr-btc-swap.git
synced 2025-11-24 09:53:09 -05:00
feat: cargo project at root
This commit is contained in:
parent
aa0c0623ca
commit
709a2820c4
313 changed files with 1 additions and 740 deletions
22
src-gui/src/renderer/components/modal/DialogHeader.tsx
Normal file
22
src-gui/src/renderer/components/modal/DialogHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src-gui/src/renderer/components/modal/PaperTextBox.tsx
Normal file
33
src-gui/src/renderer/components/modal/PaperTextBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src-gui/src/renderer/components/modal/SwapSuspendAlert.tsx
Normal file
44
src-gui/src/renderer/components/modal/SwapSuspendAlert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
22
src-gui/src/renderer/components/modal/swap/BitcoinQrCode.tsx
Normal file
22
src-gui/src/renderer/components/modal/swap/BitcoinQrCode.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
53
src-gui/src/renderer/components/modal/swap/InfoBox.tsx
Normal file
53
src-gui/src/renderer/components/modal/swap/InfoBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
90
src-gui/src/renderer/components/modal/swap/SwapDialog.tsx
Normal file
90
src-gui/src/renderer/components/modal/swap/SwapDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
166
src-gui/src/renderer/components/modal/swap/SwapStateStepper.tsx
Normal file
166
src-gui/src/renderer/components/modal/swap/SwapStateStepper.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 <></>;
|
||||
}
|
||||
|
|
@ -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've incurred a
|
||||
penalty because the swap was not refunded in time. Both the Bitcoin and
|
||||
Monero are irretrievable.
|
||||
</DialogContentText>
|
||||
<FeedbackInfoBox />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
|
||||
|
||||
export default function BitcoinCancelledPage() {
|
||||
return <CircularProgressWithSubtitle description="Refunding your Bitcoin" />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
|
||||
|
||||
export default function BitcoinRedeemedPage() {
|
||||
return <CircularProgressWithSubtitle description="Redeeming your Monero" />;
|
||||
}
|
||||
|
|
@ -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" />
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
|
||||
|
||||
export function SyncingMoneroWalletPage() {
|
||||
return (
|
||||
<CircularProgressWithSubtitle description="Syncing Monero wallet with blockchain, this might take a while..." />
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import CircularProgressWithSubtitle from '../../CircularProgressWithSubtitle';
|
||||
|
||||
export default function XmrLockedPage() {
|
||||
return (
|
||||
<CircularProgressWithSubtitle description="Waiting for them to redeem the Bitcoin" />
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}) `}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue