mirror of
https://github.com/comit-network/xmr-btc-swap.git
synced 2025-11-24 09:53:09 -05:00
feat(gui, cli): Request quotes concurrently at all sellers (#429)
* feat(gui): Implement base structure for new swap ux - refactored file structure to match common projecte structure - implement step get bitcoin * feat(gui): Implement basic multi step modal * feat(gui): Add outline of add choose maker and offer step * feat(gui): Add receive address selector * refactor(gui): format code * feat(gui): Make Swap Overveiw interactive * feat(gui): Add action to swap amount selector to quickly go to deposit bitcoin step * progress * feat(gui, cli): Request quotes concurrently at all sellers * refresh offers occasionally, display progress * progress * feat(gui, cli): Request quotes concurrently at all sellers * refresh offers occasionally, display progress * progress, works again * allow closing dialog without warning if no funds have been locked * progress * feat(gui): Rewrite Swap Components to have flow directly on swap page * feat: log monero_rpc_pool only at >= INFO level * remove full_url, add migration to change scheme of node.monerodevs.org to http * feat: send known_quotes with WaitingForBitcoinDeposit Tauri progress event (even if our balance is too low) * lock swap lock later * refactor(monero-rpc-pool): Pass around tuple of (scheme, host, port) as nodes * refactor(gui): Remove modal for swap and adjust few pages for swap process - Moved files from swap modal to page directory - Use new layouts for init page - Use new layout for depositBTC Step - Use new layout for Offer Page * allow cancel before lock * remove unused code * dynamic layout, chips for amounts * feat(gui): Add breakpoints * remove continue button, add select button on each maker box * add GetCurrentSwapArgs tauri command to only suspend swap if one is actually running * feat(gui): Show all known quotes and disable the ones that aren't available * fix get_current_swap, kill tasks when buy_xmr is cancelled * cleanup: remove CleanupGuard * feat(gui): Add cancel button on every page * refactor(gui): Fix merge issues * refactor(gui): Unify Cancel Button insertion by using a swap base page * refactor(gui): Unify Cancel Button insertion by using a swap base page * refactor(gui): Remove deeply nested relative paths * refactor(gui): Made BaseSwapPage obsolete by moving Cancel Button to SwapStatePage * refactor(gui): Adjust condition for showing SwapSuspendAlert * fix(gui): Fetch previous monero redeem addresses repeatedly * refactor(gui): Remove QR Code from deposit and choose maker page * refactor(gui): Don't display dialog on History page * fix(gui): If no swap was running "suspend_current_swap" will still return success now, less logic in the CancelButton * get offer select working * refactor: dont display cancel button on set redeem address page * feat: add pagination to offers * refactor * emit partial events for list_sellers * refactor: remove torSlice * refactor: use sync (non tokio) mutex for approvals * throttle getSwapInfo calls * feat: add debug page back, add info in suspend dialog about what will happen * refactor: format files * refactor(gui): Remove sortMakers method and replace with method that sorts approvals * refactor(gui): Refactor swap page structure * fix(gui): Add breakpoints to swapSetupInflightPage * feat(gui): Add flag for outdated makers * refactor(gui): Reduce fetch rate for maker quotes * fix(gui): Debug Window size * no unwrap --------- Co-authored-by: b-enedict <benedict.seuss@gmail.com>
This commit is contained in:
parent
7606982de3
commit
210cc04ced
80 changed files with 1744 additions and 1153 deletions
|
|
@ -32,6 +32,7 @@
|
|||
"@tauri-apps/plugin-store": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "2.7.1",
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"boring-avatars": "^1.11.2",
|
||||
"humanize-duration": "^3.32.1",
|
||||
"jdenticon": "^3.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import {
|
|||
ExpiredTimelocks,
|
||||
GetSwapInfoResponse,
|
||||
PendingCompleted,
|
||||
QuoteWithAddress,
|
||||
SelectMakerDetails,
|
||||
TauriBackgroundProgress,
|
||||
TauriSwapProgressEvent,
|
||||
} from "./tauriModel";
|
||||
|
|
@ -303,3 +305,49 @@ export function isBitcoinSyncProgress(
|
|||
): progress is TauriBitcoinSyncProgress {
|
||||
return progress.componentName === "SyncingBitcoinWallet";
|
||||
}
|
||||
|
||||
export type PendingSelectMakerApprovalRequest = PendingApprovalRequest & {
|
||||
request: { type: "SelectMaker"; content: SelectMakerDetails };
|
||||
};
|
||||
|
||||
export interface SortableQuoteWithAddress extends QuoteWithAddress {
|
||||
expiration_ts?: number;
|
||||
request_id?: string;
|
||||
}
|
||||
|
||||
export function isPendingSelectMakerApprovalEvent(
|
||||
event: ApprovalRequest,
|
||||
): event is PendingSelectMakerApprovalRequest {
|
||||
// Check if the request is pending
|
||||
if (event.request_status.state !== "Pending") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the request is a SelectMaker request
|
||||
return event.request.type === "SelectMaker";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any funds have been locked yet based on the swap progress event
|
||||
* Returns true for events where funds have been locked
|
||||
* @param event The TauriSwapProgressEvent to check
|
||||
* @returns True if funds have been locked, false otherwise
|
||||
*/
|
||||
export function haveFundsBeenLocked(
|
||||
event: TauriSwapProgressEvent | null,
|
||||
): boolean {
|
||||
if (event === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case "RequestingQuote":
|
||||
case "Resuming":
|
||||
case "ReceivedQuote":
|
||||
case "WaitingForBtcDeposit":
|
||||
case "SwapSetupInflight":
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
import { Box } from "@mui/material";
|
||||
import { Alert } from "@mui/material";
|
||||
import { useAppSelector } from "store/hooks";
|
||||
import { SatsAmount } from "../other/Units";
|
||||
import WalletRefreshButton from "../pages/wallet/WalletRefreshButton";
|
||||
|
||||
export default function RemainingFundsWillBeUsedAlert() {
|
||||
const balance = useAppSelector((s) => s.rpc.state.balance);
|
||||
|
||||
if (balance == null || balance <= 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ paddingBottom: 1 }}>
|
||||
<Alert
|
||||
severity="warning"
|
||||
action={<WalletRefreshButton />}
|
||||
variant="filled"
|
||||
>
|
||||
The remaining funds of <SatsAmount amount={balance} /> in the wallet
|
||||
will be used for the next swap
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import HumanizedBitcoinBlockDuration from "../../other/HumanizedBitcoinBlockDura
|
|||
import TruncatedText from "../../other/TruncatedText";
|
||||
import { SwapMoneroRecoveryButton } from "../../pages/history/table/SwapMoneroRecoveryButton";
|
||||
import { TimelockTimeline } from "./TimelockTimeline";
|
||||
import { useIsSpecificSwapRunning } from "store/hooks";
|
||||
|
||||
/**
|
||||
* Component for displaying a list of messages.
|
||||
|
|
@ -233,13 +234,15 @@ const UNUSUAL_AMOUNT_OF_TIME_HAS_PASSED_THRESHOLD = 72 - 4;
|
|||
*/
|
||||
export default function SwapStatusAlert({
|
||||
swap,
|
||||
isRunning,
|
||||
onlyShowIfUnusualAmountOfTimeHasPassed,
|
||||
}: {
|
||||
swap: GetSwapInfoResponseExt;
|
||||
isRunning: boolean;
|
||||
onlyShowIfUnusualAmountOfTimeHasPassed?: boolean;
|
||||
}) {
|
||||
if (swap == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the swap is completed, we do not need to display anything
|
||||
if (!isGetSwapInfoResponseRunningSwap(swap)) {
|
||||
return null;
|
||||
|
|
@ -250,16 +253,18 @@ export default function SwapStatusAlert({
|
|||
return null;
|
||||
}
|
||||
|
||||
// If we are only showing if an unusual amount of time has passed, we need to check if the swap has been running for a while
|
||||
if (
|
||||
onlyShowIfUnusualAmountOfTimeHasPassed &&
|
||||
const hasUnusualAmountOfTimePassed =
|
||||
swap.timelock.type === "None" &&
|
||||
swap.timelock.content.blocks_left >
|
||||
UNUSUAL_AMOUNT_OF_TIME_HAS_PASSED_THRESHOLD
|
||||
) {
|
||||
UNUSUAL_AMOUNT_OF_TIME_HAS_PASSED_THRESHOLD;
|
||||
|
||||
// If we are only showing if an unusual amount of time has passed, we need to check if the swap has been running for a while
|
||||
if (onlyShowIfUnusualAmountOfTimeHasPassed && hasUnusualAmountOfTimePassed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isRunning = useIsSpecificSwapRunning(swap.swap_id);
|
||||
|
||||
return (
|
||||
<Alert
|
||||
key={swap.swap_id}
|
||||
|
|
@ -274,7 +279,11 @@ export default function SwapStatusAlert({
|
|||
>
|
||||
<AlertTitle>
|
||||
{isRunning ? (
|
||||
"Swap has been running for a while"
|
||||
hasUnusualAmountOfTimePassed ? (
|
||||
"Swap has been running for a while"
|
||||
) : (
|
||||
"Swap is running"
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
Swap <TruncatedText>{swap.swap_id}</TruncatedText> is not running
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export default function SwapTxLockAlertsBox() {
|
|||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{swaps.map((swap) => (
|
||||
<SwapStatusAlert key={swap.swap_id} swap={swap} isRunning={false} />
|
||||
<SwapStatusAlert key={swap.swap_id} swap={swap} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ export default function MoneroAddressTextField({
|
|||
setAddresses(response.addresses);
|
||||
};
|
||||
fetchAddresses();
|
||||
|
||||
const interval = setInterval(fetchAddresses, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Event handlers
|
||||
|
|
|
|||
|
|
@ -5,7 +5,13 @@ import {
|
|||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import CircleIcon from "@mui/icons-material/Circle";
|
||||
import { suspendCurrentSwap } from "renderer/rpc";
|
||||
import PromiseInvokeButton from "../PromiseInvokeButton";
|
||||
|
||||
|
|
@ -20,10 +26,42 @@ export default function SwapSuspendAlert({
|
|||
}: SwapCancelAlertProps) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose}>
|
||||
<DialogTitle>Force stop running operation?</DialogTitle>
|
||||
<DialogTitle>Suspend running swap?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to force stop the running swap?
|
||||
<DialogContentText component="div">
|
||||
<List dense>
|
||||
<ListItem sx={{ pl: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: "30px" }}>
|
||||
<CircleIcon sx={{ fontSize: "8px" }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="The swap and any network requests between you and the maker will be paused until you resume" />
|
||||
</ListItem>
|
||||
<ListItem sx={{ pl: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: "30px" }}>
|
||||
<CircleIcon sx={{ fontSize: "8px" }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<>
|
||||
Refund timelocks will <strong>not</strong> be paused. They
|
||||
will continue to count down until they expire
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem sx={{ pl: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: "30px" }}>
|
||||
<CircleIcon sx={{ fontSize: "8px" }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="You can monitor the timelock on the history page" />
|
||||
</ListItem>
|
||||
<ListItem sx={{ pl: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: "30px" }}>
|
||||
<CircleIcon sx={{ fontSize: "8px" }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="If the refund timelock expires, a refund will be initiated in the background. This still requires the app to be running." />
|
||||
</ListItem>
|
||||
</List>
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
|
|
@ -35,7 +73,7 @@ export default function SwapSuspendAlert({
|
|||
onSuccess={onClose}
|
||||
onInvoke={suspendCurrentSwap}
|
||||
>
|
||||
Force stop
|
||||
Suspend
|
||||
</PromiseInvokeButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
import { Box } from "@mui/material";
|
||||
import QRCode from "react-qr-code";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,11 @@
|
|||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
} from "@mui/material";
|
||||
import { Box, Dialog, DialogActions, DialogContent } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { swapReset } from "store/features/swapSlice";
|
||||
import { useAppDispatch, useAppSelector, useIsSwapRunning } from "store/hooks";
|
||||
import SwapSuspendAlert from "../SwapSuspendAlert";
|
||||
import { useAppSelector } from "store/hooks";
|
||||
import DebugPage from "./pages/DebugPage";
|
||||
import SwapStatePage from "./pages/SwapStatePage";
|
||||
import SwapStatePage from "renderer/components/pages/swap/swap/SwapStatePage";
|
||||
import SwapDialogTitle from "./SwapDialogTitle";
|
||||
import SwapStateStepper from "./SwapStateStepper";
|
||||
import CancelButton from "renderer/components/pages/swap/swap/CancelButton";
|
||||
|
||||
export default function SwapDialog({
|
||||
open,
|
||||
|
|
@ -22,26 +15,13 @@ export default function SwapDialog({
|
|||
onClose: () => void;
|
||||
}) {
|
||||
const swap = useAppSelector((state) => state.swap);
|
||||
const isSwapRunning = useIsSwapRunning();
|
||||
const [debug, setDebug] = useState(false);
|
||||
const [openSuspendAlert, setOpenSuspendAlert] = useState(false);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
function onCancel() {
|
||||
if (isSwapRunning) {
|
||||
setOpenSuspendAlert(true);
|
||||
} else {
|
||||
onClose();
|
||||
dispatch(swapReset());
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<SwapDialogTitle
|
||||
debug={debug}
|
||||
setDebug={setDebug}
|
||||
|
|
@ -78,23 +58,8 @@ export default function SwapDialog({
|
|||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onCancel} variant="text">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="contained"
|
||||
onClick={onCancel}
|
||||
disabled={isSwapRunning || swap.state === null}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
<CancelButton />
|
||||
</DialogActions>
|
||||
|
||||
<SwapSuspendAlert
|
||||
open={openSuspendAlert}
|
||||
onClose={() => setOpenSuspendAlert(false)}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Box, DialogTitle, Typography } from "@mui/material";
|
||||
import DebugPageSwitchBadge from "./pages/DebugPageSwitchBadge";
|
||||
import FeedbackSubmitBadge from "./pages/FeedbackSubmitBadge";
|
||||
import TorStatusBadge from "./pages/TorStatusBadge";
|
||||
|
||||
export default function SwapDialogTitle({
|
||||
title,
|
||||
|
|
@ -24,7 +23,6 @@ export default function SwapDialogTitle({
|
|||
<Box sx={{ display: "flex", alignItems: "center", gridGap: 1 }}>
|
||||
<FeedbackSubmitBadge />
|
||||
<DebugPageSwitchBadge enabled={debug} setEnabled={setDebug} />
|
||||
<TorStatusBadge />
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ function getActiveStep(state: SwapState | null): PathStep | null {
|
|||
case "ReceivedQuote":
|
||||
case "WaitingForBtcDeposit":
|
||||
case "SwapSetupInflight":
|
||||
return [PathType.HAPPY_PATH, 0, isReleased];
|
||||
return null; // No funds have been locked yet
|
||||
|
||||
// Step 1: Waiting for Bitcoin lock confirmation
|
||||
// Bitcoin has been locked, waiting for the counterparty to lock their XMR
|
||||
|
|
|
|||
|
|
@ -8,13 +8,11 @@ import JsonTreeView from "../../../other/JSONViewTree";
|
|||
import CliLogsBox from "../../../other/RenderedCliLog";
|
||||
|
||||
export default function DebugPage() {
|
||||
const torStdOut = useAppSelector((s) => s.tor.stdOut);
|
||||
const logs = useActiveSwapLogs();
|
||||
const guiState = useAppSelector((s) => s);
|
||||
const cliState = useActiveSwapInfo();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ padding: 2, display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<DialogContentText>
|
||||
<Box
|
||||
style={{
|
||||
|
|
@ -27,18 +25,6 @@ export default function DebugPage() {
|
|||
logs={logs}
|
||||
label="Logs relevant to the swap (only current session)"
|
||||
/>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
import { IconButton, Tooltip } from "@mui/material";
|
||||
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 size="large">
|
||||
<TorIcon htmlColor="#7D4698" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import SwapStatusAlert from "renderer/components/alert/SwapStatusAlert/SwapStatusAlert";
|
||||
import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle";
|
||||
import { useActiveSwapInfo, useSwapInfosSortedByDate } from "store/hooks";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
export default function EncryptedSignatureSentPage() {
|
||||
const swap = useActiveSwapInfo();
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<SwapStatusAlert
|
||||
swap={swap}
|
||||
isRunning={true}
|
||||
onlyShowIfUnusualAmountOfTimeHasPassed={true}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "10rem",
|
||||
}}
|
||||
>
|
||||
<CircularProgressWithSubtitle description="Waiting for them to redeem the Bitcoin" />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle";
|
||||
|
||||
export function SyncingMoneroWalletPage() {
|
||||
return (
|
||||
<CircularProgressWithSubtitle description="Syncing Monero wallet with blockchain, this might take a while..." />
|
||||
);
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { Box, TextField, Typography } from "@mui/material";
|
||||
import { BidQuote } from "models/tauriModel";
|
||||
import { useState } from "react";
|
||||
import { useAppSelector } from "store/hooks";
|
||||
import { btcToSats, satsToBtc } from "utils/conversionUtils";
|
||||
import { MoneroAmount } from "../../../../other/Units";
|
||||
|
||||
const MONERO_FEE = 0.000016;
|
||||
|
||||
function calcBtcAmountWithoutFees(amount: number, fees: number) {
|
||||
return amount - fees;
|
||||
}
|
||||
|
||||
export default function DepositAmountHelper({
|
||||
min_deposit_until_swap_will_start,
|
||||
max_deposit_until_maximum_amount_is_reached,
|
||||
min_bitcoin_lock_tx_fee,
|
||||
quote,
|
||||
}: {
|
||||
min_deposit_until_swap_will_start: number;
|
||||
max_deposit_until_maximum_amount_is_reached: number;
|
||||
min_bitcoin_lock_tx_fee: number;
|
||||
quote: BidQuote;
|
||||
}) {
|
||||
const [amount, setAmount] = useState(min_deposit_until_swap_will_start);
|
||||
const bitcoinBalance = useAppSelector((s) => s.rpc.state.balance) || 0;
|
||||
|
||||
function getTotalAmountAfterDeposit() {
|
||||
return amount + bitcoinBalance;
|
||||
}
|
||||
|
||||
function hasError() {
|
||||
return (
|
||||
amount < min_deposit_until_swap_will_start ||
|
||||
getTotalAmountAfterDeposit() > max_deposit_until_maximum_amount_is_reached
|
||||
);
|
||||
}
|
||||
|
||||
function calcXMRAmount(): number | null {
|
||||
if (Number.isNaN(amount)) return null;
|
||||
if (hasError()) return null;
|
||||
if (quote.price == null) return null;
|
||||
|
||||
return (
|
||||
calcBtcAmountWithoutFees(
|
||||
getTotalAmountAfterDeposit(),
|
||||
min_bitcoin_lock_tx_fee,
|
||||
) /
|
||||
quote.price -
|
||||
MONERO_FEE
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2">
|
||||
Depositing {bitcoinBalance > 0 && <>another</>}
|
||||
</Typography>
|
||||
<TextField
|
||||
error={!!hasError()}
|
||||
value={satsToBtc(amount)}
|
||||
onChange={(e) => setAmount(btcToSats(parseFloat(e.target.value)))}
|
||||
size="small"
|
||||
type="number"
|
||||
sx={{
|
||||
"& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button":
|
||||
{
|
||||
display: "none",
|
||||
},
|
||||
"& input[type=number]": {
|
||||
MozAppearance: "textfield",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography variant="subtitle2">
|
||||
BTC will give you approximately{" "}
|
||||
<MoneroAmount amount={calcXMRAmount()} />.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
import { Box, Typography } from "@mui/material";
|
||||
import { TauriSwapProgressEventContent } from "models/tauriModelExt";
|
||||
import BitcoinIcon from "../../../../icons/BitcoinIcon";
|
||||
import { MoneroSatsExchangeRate, SatsAmount } from "../../../../other/Units";
|
||||
import DepositAddressInfoBox from "../../DepositAddressInfoBox";
|
||||
import DepositAmountHelper from "./DepositAmountHelper";
|
||||
import { Alert } from "@mui/material";
|
||||
|
||||
export default function WaitingForBtcDepositPage({
|
||||
deposit_address,
|
||||
min_deposit_until_swap_will_start,
|
||||
max_deposit_until_maximum_amount_is_reached,
|
||||
min_bitcoin_lock_tx_fee,
|
||||
max_giveable,
|
||||
quote,
|
||||
}: TauriSwapProgressEventContent<"WaitingForBtcDeposit">) {
|
||||
return (
|
||||
<Box>
|
||||
<DepositAddressInfoBox
|
||||
title="Bitcoin Deposit Address"
|
||||
address={deposit_address}
|
||||
additionalContent={
|
||||
<Box
|
||||
sx={{
|
||||
paddingTop: 1,
|
||||
gap: 0.5,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2">
|
||||
<ul>
|
||||
{max_giveable > 0 ? (
|
||||
<li>
|
||||
You have already deposited enough funds to swap{" "}
|
||||
<SatsAmount amount={max_giveable} />. However, that is below
|
||||
the minimum amount required to start the swap.
|
||||
</li>
|
||||
) : null}
|
||||
<li>
|
||||
Send any amount between{" "}
|
||||
<SatsAmount amount={min_deposit_until_swap_will_start} /> and{" "}
|
||||
<SatsAmount
|
||||
amount={max_deposit_until_maximum_amount_is_reached}
|
||||
/>{" "}
|
||||
to the address above
|
||||
{max_giveable > 0 && (
|
||||
<> (on top of the already deposited funds)</>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
Bitcoin sent to this this address will be converted into
|
||||
Monero at an exchange rate of{" ≈ "}
|
||||
<MoneroSatsExchangeRate
|
||||
rate={quote.price}
|
||||
displayMarkup={true}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
The Network fee of{" ≈ "}
|
||||
<SatsAmount amount={min_bitcoin_lock_tx_fee} /> will
|
||||
automatically be deducted from the deposited coins
|
||||
</li>
|
||||
<li>
|
||||
After the deposit is detected, you'll get to confirm the exact
|
||||
details before your funds are locked
|
||||
</li>
|
||||
<li>
|
||||
<DepositAmountHelper
|
||||
min_deposit_until_swap_will_start={
|
||||
min_deposit_until_swap_will_start
|
||||
}
|
||||
max_deposit_until_maximum_amount_is_reached={
|
||||
max_deposit_until_maximum_amount_is_reached
|
||||
}
|
||||
min_bitcoin_lock_tx_fee={min_bitcoin_lock_tx_fee}
|
||||
quote={quote}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</Typography>
|
||||
|
||||
<Alert severity="info">
|
||||
Please do not use replace-by-fee on your deposit transaction.
|
||||
You'll need to start a new swap if you do. The funds will be
|
||||
available for future swaps.
|
||||
</Alert>
|
||||
</Box>
|
||||
}
|
||||
icon={<BitcoinIcon />}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { DialogContentText } from "@mui/material";
|
||||
import BitcoinTransactionInfoBox from "../../swap/BitcoinTransactionInfoBox";
|
||||
import BitcoinTransactionInfoBox from "renderer/components/pages/swap/swap/components/BitcoinTransactionInfoBox";
|
||||
|
||||
export default function BtcTxInMempoolPageContent({
|
||||
withdrawTxId,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import { Box, Button, IconButton, Tooltip } from "@mui/material";
|
||||
import {
|
||||
FileCopyOutlined,
|
||||
CropFree as CropFreeIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { FileCopyOutlined, QrCode as QrCodeIcon } from "@mui/icons-material";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { useState } from "react";
|
||||
import MonospaceTextBox from "./MonospaceTextBox";
|
||||
|
|
@ -111,7 +108,7 @@ export default function ActionableMonospaceTextBox({
|
|||
size="small"
|
||||
sx={{ marginLeft: 1 }}
|
||||
>
|
||||
<CropFreeIcon />
|
||||
<QrCodeIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Box, Button, Typography } from "@mui/material";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import InfoBox from "../../modal/swap/InfoBox";
|
||||
import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox";
|
||||
|
||||
const GITHUB_ISSUE_URL =
|
||||
"https://github.com/UnstoppableSwap/core/issues/new/choose";
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import {
|
|||
} from "@mui/material";
|
||||
import ChatIcon from "@mui/icons-material/Chat";
|
||||
import SendIcon from "@mui/icons-material/Send";
|
||||
import InfoBox from "renderer/components/modal/swap/InfoBox";
|
||||
import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox";
|
||||
import TruncatedText from "renderer/components/other/TruncatedText";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
|||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
|
||||
import { useAppSelector } from "store/hooks";
|
||||
import InfoBox from "../../modal/swap/InfoBox";
|
||||
import CliLogsBox from "../../other/RenderedCliLog";
|
||||
import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox";
|
||||
import CliLogsBox from "renderer/components/other/RenderedCliLog";
|
||||
import { getDataDir, initializeContext } from "renderer/rpc";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Box, Typography, styled } from "@mui/material";
|
||||
import InfoBox from "renderer/components/modal/swap/InfoBox";
|
||||
import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox";
|
||||
import { useSettings } from "store/hooks";
|
||||
import { Search } from "@mui/icons-material";
|
||||
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Link, Typography } from "@mui/material";
|
||||
import MoneroIcon from "../../icons/MoneroIcon";
|
||||
import DepositAddressInfoBox from "../../modal/swap/DepositAddressInfoBox";
|
||||
import MoneroIcon from "renderer/components/icons/MoneroIcon";
|
||||
import DepositAddressInfoBox from "renderer/components/pages/swap/swap/components/DepositAddressInfoBox";
|
||||
|
||||
const XMR_DONATE_ADDRESS =
|
||||
"87jS4C7ngk9EHdqFFuxGFgg8AyH63dRUoULshWDybFJaP75UA89qsutG5B1L1QTc4w228nsqsv8EjhL7bz8fB3611Mh98mg";
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
Link,
|
||||
DialogContentText,
|
||||
} from "@mui/material";
|
||||
import InfoBox from "renderer/components/modal/swap/InfoBox";
|
||||
import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox";
|
||||
import { useState } from "react";
|
||||
import { getWalletDescriptor } from "renderer/rpc";
|
||||
import { ExportBitcoinWalletResponse } from "models/tauriModel";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Button, Typography } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import FeedbackDialog from "../../modal/feedback/FeedbackDialog";
|
||||
import InfoBox from "../../modal/swap/InfoBox";
|
||||
import FeedbackDialog from "renderer/components/modal/feedback/FeedbackDialog";
|
||||
import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox";
|
||||
|
||||
export default function FeedbackInfoBox() {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
LinearProgress,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import InfoBox from "renderer/components/modal/swap/InfoBox";
|
||||
import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox";
|
||||
import { ReliableNodeInfo } from "models/tauriModel";
|
||||
import NetworkWifiIcon from "@mui/icons-material/NetworkWifi";
|
||||
import { useAppSelector } from "store/hooks";
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ import {
|
|||
|
||||
import { getNetwork } from "store/config";
|
||||
import { currencySymbol } from "utils/formatUtils";
|
||||
import InfoBox from "renderer/components/modal/swap/InfoBox";
|
||||
import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox";
|
||||
import { isValidMultiAddressWithPeerId } from "utils/parseUtils";
|
||||
import { getNodeStatus } from "renderer/rpc";
|
||||
import { setStatus } from "store/features/nodesSlice";
|
||||
|
|
|
|||
|
|
@ -1,18 +1,13 @@
|
|||
import { Typography } from "@mui/material";
|
||||
import { useAppSelector } from "store/hooks";
|
||||
import SwapTxLockAlertsBox from "../../alert/SwapTxLockAlertsBox";
|
||||
import SwapDialog from "../../modal/swap/SwapDialog";
|
||||
import HistoryTable from "./table/HistoryTable";
|
||||
|
||||
export default function HistoryPage() {
|
||||
const showDialog = useAppSelector((state) => state.swap.state !== null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h3">History</Typography>
|
||||
<SwapTxLockAlertsBox />
|
||||
<HistoryTable />
|
||||
<SwapDialog open={showDialog} onClose={() => {}} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,20 +12,62 @@ import {
|
|||
isBobStateNamePossiblyRefundableSwap,
|
||||
} from "models/tauriModelExt";
|
||||
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
|
||||
import { resumeSwap } from "renderer/rpc";
|
||||
import { resumeSwap, suspendCurrentSwap } from "renderer/rpc";
|
||||
import {
|
||||
useIsSpecificSwapRunning,
|
||||
useIsSwapRunning,
|
||||
useIsSwapRunningAndHasFundsLocked,
|
||||
} from "store/hooks";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function SwapResumeButton({
|
||||
swap,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps & { swap: GetSwapInfoResponse }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// We cannot resume at all if the swap of this button is already running
|
||||
const isAlreadyRunning = useIsSpecificSwapRunning(swap.swap_id);
|
||||
|
||||
// If another swap is running, we can resume but only if no funds have been locked
|
||||
// for that swap. If funds have been locked, we cannot resume. If no funds have been locked,
|
||||
// we suspend the other swap and resume this one.
|
||||
const isAnotherSwapRunningAndHasFundsLocked =
|
||||
useIsSwapRunningAndHasFundsLocked() && !isAlreadyRunning;
|
||||
|
||||
async function resume() {
|
||||
// We always suspend the current swap first
|
||||
// If that swap has any funds locked, the button will be disabled
|
||||
// and this function will not be called
|
||||
// If no swap is running, this is a no-op
|
||||
await suspendCurrentSwap();
|
||||
|
||||
// Now resume this swap
|
||||
await resumeSwap(swap.swap_id);
|
||||
|
||||
// Navigate to the swap page
|
||||
navigate(`/swap`);
|
||||
}
|
||||
|
||||
const tooltipTitle = isAlreadyRunning
|
||||
? "This swap is already running"
|
||||
: isAnotherSwapRunningAndHasFundsLocked
|
||||
? "Another swap is running. Suspend it first before resuming this one"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<PromiseInvokeButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={swap.completed}
|
||||
disabled={
|
||||
swap.completed ||
|
||||
isAlreadyRunning ||
|
||||
isAnotherSwapRunningAndHasFundsLocked
|
||||
}
|
||||
tooltipTitle={tooltipTitle}
|
||||
endIcon={<PlayArrowIcon />}
|
||||
onInvoke={() => resumeSwap(swap.swap_id)}
|
||||
onInvoke={resume}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -33,32 +75,6 @@ export function SwapResumeButton({
|
|||
);
|
||||
}
|
||||
|
||||
export function SwapCancelRefundButton({
|
||||
swap,
|
||||
...props
|
||||
}: { swap: GetSwapInfoResponseExt } & ButtonProps) {
|
||||
const cancelOrRefundable =
|
||||
isBobStateNamePossiblyCancellableSwap(swap.state_name) ||
|
||||
isBobStateNamePossiblyRefundableSwap(swap.state_name);
|
||||
|
||||
if (!cancelOrRefundable) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<PromiseInvokeButton
|
||||
displayErrorSnackbar={false}
|
||||
{...props}
|
||||
onInvoke={async () => {
|
||||
// TODO: Implement this using the Tauri RPC
|
||||
throw new Error("Not implemented");
|
||||
}}
|
||||
>
|
||||
Attempt manual Cancel & Refund
|
||||
</PromiseInvokeButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HistoryRowActions(swap: GetSwapInfoResponse) {
|
||||
if (swap.state_name === BobStateName.XmrRedeemed) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Box } from "@mui/material";
|
||||
import ApiAlertsBox from "./ApiAlertsBox";
|
||||
import SwapWidget from "./SwapWidget";
|
||||
import SwapWidget from "./swap/SwapWidget";
|
||||
|
||||
export default function SwapPage() {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,233 +0,0 @@
|
|||
import {
|
||||
Box,
|
||||
Fab,
|
||||
LinearProgress,
|
||||
Paper,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
||||
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
|
||||
import { Alert } from "@mui/material";
|
||||
import { ExtendedMakerStatus } from "models/apiModel";
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { useAppSelector } from "store/hooks";
|
||||
import { satsToBtc } from "utils/conversionUtils";
|
||||
import { MakerSubmitDialogOpenButton } from "../../modal/provider/MakerListDialog";
|
||||
import MakerSelect from "../../modal/provider/MakerSelect";
|
||||
import SwapDialog from "../../modal/swap/SwapDialog";
|
||||
|
||||
// After RECONNECTION_ATTEMPTS_UNTIL_ASSUME_DOWN failed reconnection attempts we can assume the public registry is down
|
||||
const RECONNECTION_ATTEMPTS_UNTIL_ASSUME_DOWN = 1;
|
||||
|
||||
function isRegistryDown(reconnectionAttempts: number): boolean {
|
||||
return reconnectionAttempts > RECONNECTION_ATTEMPTS_UNTIL_ASSUME_DOWN;
|
||||
}
|
||||
|
||||
function Title() {
|
||||
return (
|
||||
<Box sx={{ padding: 0 }}>
|
||||
<Typography variant="h5" sx={{ padding: 1 }}>
|
||||
Swap
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function HasMakerSwapWidget({
|
||||
selectedMaker,
|
||||
}: {
|
||||
selectedMaker: ExtendedMakerStatus;
|
||||
}) {
|
||||
const forceShowDialog = useAppSelector((state) => state.swap.state !== null);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [btcFieldValue, setBtcFieldValue] = useState<number | string>(
|
||||
satsToBtc(selectedMaker.minSwapAmount),
|
||||
);
|
||||
const [xmrFieldValue, setXmrFieldValue] = useState(1);
|
||||
|
||||
function onBtcAmountChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
setBtcFieldValue(event.target.value);
|
||||
}
|
||||
|
||||
function updateXmrValue() {
|
||||
const parsedBtcAmount = Number(btcFieldValue);
|
||||
if (Number.isNaN(parsedBtcAmount)) {
|
||||
setXmrFieldValue(0);
|
||||
} else {
|
||||
const convertedXmrAmount =
|
||||
parsedBtcAmount / satsToBtc(selectedMaker.price);
|
||||
setXmrFieldValue(convertedXmrAmount);
|
||||
}
|
||||
}
|
||||
|
||||
function getBtcFieldError(): string | null {
|
||||
const parsedBtcAmount = Number(btcFieldValue);
|
||||
if (Number.isNaN(parsedBtcAmount)) {
|
||||
return "This is not a valid number";
|
||||
}
|
||||
if (parsedBtcAmount < satsToBtc(selectedMaker.minSwapAmount)) {
|
||||
return `The minimum swap amount is ${satsToBtc(
|
||||
selectedMaker.minSwapAmount,
|
||||
)} BTC. Switch to a different maker if you want to swap less.`;
|
||||
}
|
||||
if (parsedBtcAmount > satsToBtc(selectedMaker.maxSwapAmount)) {
|
||||
return `The maximum swap amount is ${satsToBtc(
|
||||
selectedMaker.maxSwapAmount,
|
||||
)} BTC. Switch to a different maker if you want to swap more.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleGuideDialogOpen() {
|
||||
setShowDialog(true);
|
||||
}
|
||||
|
||||
useEffect(updateXmrValue, [btcFieldValue, selectedMaker]);
|
||||
|
||||
return (
|
||||
// 'elevation' prop can't be passed down (type def issue)
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={(theme) => ({
|
||||
width: "min(480px, 100%)",
|
||||
minHeight: "150px",
|
||||
display: "grid",
|
||||
padding: theme.spacing(2),
|
||||
gridGap: theme.spacing(1),
|
||||
})}
|
||||
>
|
||||
<Title />
|
||||
<TextField
|
||||
label="For this many BTC"
|
||||
size="medium"
|
||||
variant="outlined"
|
||||
value={btcFieldValue}
|
||||
onChange={onBtcAmountChange}
|
||||
error={!!getBtcFieldError()}
|
||||
helperText={getBtcFieldError()}
|
||||
autoFocus
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">BTC</InputAdornment>,
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: "flex", justifyContent: "center" }}>
|
||||
<ArrowDownwardIcon fontSize="small" />
|
||||
</Box>
|
||||
<TextField
|
||||
label="You'd receive that many XMR"
|
||||
variant="outlined"
|
||||
size="medium"
|
||||
value={xmrFieldValue.toFixed(6)}
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">XMR</InputAdornment>,
|
||||
}}
|
||||
/>
|
||||
<MakerSelect />
|
||||
<Fab variant="extended" color="primary" onClick={handleGuideDialogOpen}>
|
||||
<SwapHorizIcon sx={{ marginRight: 1 }} />
|
||||
Swap
|
||||
</Fab>
|
||||
<SwapDialog
|
||||
open={showDialog || forceShowDialog}
|
||||
onClose={() => setShowDialog(false)}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function HasNoMakersSwapWidget() {
|
||||
const forceShowDialog = useAppSelector((state) => state.swap.state !== null);
|
||||
const isPublicRegistryDown = useAppSelector((state) =>
|
||||
isRegistryDown(state.makers.registry.connectionFailsCount),
|
||||
);
|
||||
|
||||
const alertBox = isPublicRegistryDown ? (
|
||||
<Alert severity="info">
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Typography>
|
||||
Currently, the public registry of makers seems to be unreachable.
|
||||
Here's what you can do:
|
||||
<ul>
|
||||
<li>Try discovering a maker by connecting to a rendezvous point</li>
|
||||
<li>
|
||||
Try again later when the public registry may be reachable again
|
||||
</li>
|
||||
</ul>
|
||||
</Typography>
|
||||
<Box>
|
||||
<MakerSubmitDialogOpenButton />
|
||||
</Box>
|
||||
</Box>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Typography>
|
||||
Currently, there are no makers (trading partners) available in the
|
||||
official registry. Here's what you can do:
|
||||
<ul>
|
||||
<li>Try discovering a maker by connecting to a rendezvous point</li>
|
||||
<li>Add a new maker to the public registry</li>
|
||||
<li>Try again later when more makers may be available</li>
|
||||
</ul>
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<MakerSubmitDialogOpenButton />
|
||||
</Box>
|
||||
</Box>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{alertBox}
|
||||
<SwapDialog open={forceShowDialog} onClose={() => {}} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MakerLoadingSwapWidget() {
|
||||
return (
|
||||
// 'elevation' prop can't be passed down (type def issue)
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
<Box
|
||||
component={Paper}
|
||||
elevation={15}
|
||||
sx={{
|
||||
width: "min(480px, 100%)",
|
||||
minHeight: "150px",
|
||||
display: "grid",
|
||||
padding: 1,
|
||||
gridGap: 1,
|
||||
}}
|
||||
>
|
||||
<Title />
|
||||
<LinearProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SwapWidget() {
|
||||
const selectedMaker = useAppSelector((state) => state.makers.selectedMaker);
|
||||
// If we fail more than RECONNECTION_ATTEMPTS_UNTIL_ASSUME_DOWN reconnect attempts, we'll show the "no makers" widget. We can assume the public registry is down.
|
||||
const makerLoading = useAppSelector(
|
||||
(state) =>
|
||||
state.makers.registry.makers === null &&
|
||||
!isRegistryDown(state.makers.registry.connectionFailsCount),
|
||||
);
|
||||
|
||||
if (makerLoading) {
|
||||
return <MakerLoadingSwapWidget />;
|
||||
}
|
||||
|
||||
if (selectedMaker === null) {
|
||||
return <HasNoMakersSwapWidget />;
|
||||
}
|
||||
|
||||
return <HasMakerSwapWidget selectedMaker={selectedMaker} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { Box, Button } from "@mui/material";
|
||||
import { haveFundsBeenLocked } from "models/tauriModelExt";
|
||||
import { getCurrentSwapId, suspendCurrentSwap } from "renderer/rpc";
|
||||
import { swapReset } from "store/features/swapSlice";
|
||||
import { useAppDispatch, useAppSelector, useIsSwapRunning } from "store/hooks";
|
||||
import { useState } from "react";
|
||||
import SwapSuspendAlert from "renderer/components/modal/SwapSuspendAlert";
|
||||
|
||||
export default function CancelButton() {
|
||||
const dispatch = useAppDispatch();
|
||||
const swap = useAppSelector((state) => state.swap);
|
||||
const isSwapRunning = useIsSwapRunning();
|
||||
const [openSuspendAlert, setOpenSuspendAlert] = useState(false);
|
||||
|
||||
const hasFundsBeenLocked = haveFundsBeenLocked(swap.state?.curr);
|
||||
|
||||
async function onCancel() {
|
||||
const swapId = await getCurrentSwapId();
|
||||
|
||||
if (swapId.swap_id !== null) {
|
||||
if (hasFundsBeenLocked && isSwapRunning) {
|
||||
setOpenSuspendAlert(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await suspendCurrentSwap();
|
||||
}
|
||||
|
||||
dispatch(swapReset());
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SwapSuspendAlert
|
||||
open={openSuspendAlert}
|
||||
onClose={() => setOpenSuspendAlert(false)}
|
||||
/>
|
||||
<Box
|
||||
sx={{ display: "flex", justifyContent: "flex-start", width: "100%" }}
|
||||
>
|
||||
<Button variant="outlined" onClick={onCancel}>
|
||||
{hasFundsBeenLocked && swap.state?.curr.type !== "Released"
|
||||
? "Suspend"
|
||||
: swap.state?.curr.type === "Released"
|
||||
? "Close"
|
||||
: "Cancel"}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { SwapState } from "models/storeModel";
|
||||
import { TauriSwapProgressEventType } from "models/tauriModelExt";
|
||||
import CircularProgressWithSubtitle from "../CircularProgressWithSubtitle";
|
||||
import CircularProgressWithSubtitle from "./components/CircularProgressWithSubtitle";
|
||||
import BitcoinPunishedPage from "./done/BitcoinPunishedPage";
|
||||
import {
|
||||
BitcoinRefundedPage,
|
||||
|
|
@ -20,9 +20,10 @@ import SwapSetupInflightPage from "./in_progress/SwapSetupInflightPage";
|
|||
import WaitingForXmrConfirmationsBeforeRedeemPage from "./in_progress/WaitingForXmrConfirmationsBeforeRedeemPage";
|
||||
import XmrLockedPage from "./in_progress/XmrLockedPage";
|
||||
import XmrLockTxInMempoolPage from "./in_progress/XmrLockInMempoolPage";
|
||||
import InitPage from "./init/InitPage";
|
||||
import WaitingForBitcoinDepositPage from "./init/WaitingForBitcoinDepositPage";
|
||||
import { exhaustiveGuard } from "utils/typescriptUtils";
|
||||
import DepositAndChooseOfferPage from "renderer/components/pages/swap/swap/init/deposit_and_choose_offer/DepositAndChooseOfferPage";
|
||||
import InitPage from "./init/InitPage";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
export default function SwapStatePage({ state }: { state: SwapState | null }) {
|
||||
if (state === null) {
|
||||
|
|
@ -41,7 +42,7 @@ export default function SwapStatePage({ state }: { state: SwapState | null }) {
|
|||
case "WaitingForBtcDeposit":
|
||||
// This double check is necessary for the typescript compiler to infer types
|
||||
if (state.curr.type === "WaitingForBtcDeposit") {
|
||||
return <WaitingForBitcoinDepositPage {...state.curr.content} />;
|
||||
return <DepositAndChooseOfferPage {...state.curr.content} />;
|
||||
}
|
||||
break;
|
||||
case "SwapSetupInflight":
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { Box, Button, Dialog, DialogActions, Paper } from "@mui/material";
|
||||
import { useActiveSwapInfo, useAppSelector } from "store/hooks";
|
||||
import SwapStatePage from "renderer/components/pages/swap/swap/SwapStatePage";
|
||||
import CancelButton from "./CancelButton";
|
||||
import SwapStateStepper from "renderer/components/modal/swap/SwapStateStepper";
|
||||
import SwapStatusAlert from "renderer/components/alert/SwapStatusAlert/SwapStatusAlert";
|
||||
import DebugPageSwitchBadge from "renderer/components/modal/swap/pages/DebugPageSwitchBadge";
|
||||
import DebugPage from "renderer/components/modal/swap/pages/DebugPage";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function SwapWidget() {
|
||||
const swap = useAppSelector((state) => state.swap);
|
||||
const swapInfo = useActiveSwapInfo();
|
||||
|
||||
const [debug, setDebug] = useState(false);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ display: "flex", flexDirection: "column", gap: 2, width: "100%" }}
|
||||
>
|
||||
<SwapStatusAlert swap={swapInfo} onlyShowIfUnusualAmountOfTimeHasPassed />
|
||||
<Dialog fullWidth maxWidth="md" open={debug} onClose={() => setDebug(false)}>
|
||||
<DebugPage />
|
||||
<DialogActions>
|
||||
<Button variant="outlined" onClick={() => setDebug(false)}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxWidth: 800,
|
||||
borderRadius: 2,
|
||||
margin: "0 auto",
|
||||
padding: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
justifyContent: "space-between",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<SwapStatePage state={swap.state} />
|
||||
{swap.state !== null && (
|
||||
<>
|
||||
<SwapStateStepper state={swap.state} />
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<CancelButton />
|
||||
<DebugPageSwitchBadge enabled={debug} setEnabled={setDebug} />
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { Box } from "@mui/material";
|
||||
import QRCode from "react-qr-code";
|
||||
|
||||
export default function BitcoinQrCode({ address }: { address: string }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: "white",
|
||||
padding: 1,
|
||||
borderRadius: 1,
|
||||
width: "100%",
|
||||
aspectRatio: "1 / 1",
|
||||
}}
|
||||
>
|
||||
<QRCode
|
||||
value={`bitcoin:${address}`}
|
||||
size={1}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
height: "min-content",
|
||||
aspectRatio: 1,
|
||||
}}
|
||||
/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */
|
||||
/* @ts-ignore */
|
||||
viewBox="0 0 1 1"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { Box, DialogContentText } from "@mui/material";
|
||||
import FeedbackInfoBox from "../../../../pages/help/FeedbackInfoBox";
|
||||
import FeedbackInfoBox from "renderer/components/pages/help/FeedbackInfoBox";
|
||||
import { TauriSwapProgressEventExt } from "models/tauriModelExt";
|
||||
|
||||
export default function BitcoinPunishedPage({
|
||||
|
|
@ -10,7 +10,7 @@ export default function BitcoinPunishedPage({
|
|||
| TauriSwapProgressEventExt<"CooperativeRedeemRejected">;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
<>
|
||||
<DialogContentText>
|
||||
Unfortunately, the swap was unsuccessful. Since you did not refund in
|
||||
time, the Bitcoin has been lost. However, with the cooperation of the
|
||||
|
|
@ -26,6 +26,6 @@ export default function BitcoinPunishedPage({
|
|||
)}
|
||||
</DialogContentText>
|
||||
<FeedbackInfoBox />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { Box, DialogContentText } from "@mui/material";
|
||||
import { TauriSwapProgressEventContent } from "models/tauriModelExt";
|
||||
import { useActiveSwapInfo } from "store/hooks";
|
||||
import FeedbackInfoBox from "../../../../pages/help/FeedbackInfoBox";
|
||||
import BitcoinTransactionInfoBox from "../../BitcoinTransactionInfoBox";
|
||||
import FeedbackInfoBox from "renderer/components/pages/help/FeedbackInfoBox";
|
||||
import BitcoinTransactionInfoBox from "renderer/components/pages/swap/swap/components/BitcoinTransactionInfoBox";
|
||||
|
||||
export function BitcoinRefundPublishedPage({
|
||||
btc_refund_txid,
|
||||
|
|
@ -66,7 +66,7 @@ function MultiBitcoinRefundedPage({
|
|||
) : 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
|
||||
|
|
@ -87,6 +87,6 @@ function MultiBitcoinRefundedPage({
|
|||
/>
|
||||
<FeedbackInfoBox />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Box, DialogContentText, Typography } from "@mui/material";
|
||||
import { TauriSwapProgressEventContent } from "models/tauriModelExt";
|
||||
import FeedbackInfoBox from "../../../../pages/help/FeedbackInfoBox";
|
||||
import MoneroTransactionInfoBox from "../../MoneroTransactionInfoBox";
|
||||
import FeedbackInfoBox from "renderer/components/pages/help/FeedbackInfoBox";
|
||||
import MoneroTransactionInfoBox from "renderer/components/pages/swap/swap/components/MoneroTransactionInfoBox";
|
||||
|
||||
export default function XmrRedeemInMempoolPage(
|
||||
state: TauriSwapProgressEventContent<"XmrRedeemInMempool">,
|
||||
|
|
@ -9,7 +9,7 @@ export default function XmrRedeemInMempoolPage(
|
|||
const xmr_redeem_txid = state.xmr_redeem_txids[0] ?? null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<>
|
||||
<DialogContentText>
|
||||
The swap was successful and the Monero has been sent to the following
|
||||
address(es). The swap is completed and you may exit the application now.
|
||||
|
|
@ -77,6 +77,6 @@ export default function XmrRedeemInMempoolPage(
|
|||
/>
|
||||
<FeedbackInfoBox />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { Box, DialogContentText } from "@mui/material";
|
|||
import { TauriSwapProgressEvent } from "models/tauriModel";
|
||||
import CliLogsBox from "renderer/components/other/RenderedCliLog";
|
||||
import { useActiveSwapInfo, useActiveSwapLogs } from "store/hooks";
|
||||
import SwapStatePage from "../SwapStatePage";
|
||||
import SwapStatePage from "renderer/components/pages/swap/swap/SwapStatePage";
|
||||
|
||||
export default function ProcessExitedPage({
|
||||
prevState,
|
||||
|
|
@ -35,7 +35,7 @@ export default function ProcessExitedPage({
|
|||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<>
|
||||
<DialogContentText>
|
||||
The swap was stopped but it has not been completed yet. Check the logs
|
||||
below for more information. The current GUI state is{" "}
|
||||
|
|
@ -45,6 +45,6 @@ export default function ProcessExitedPage({
|
|||
<Box>
|
||||
<CliLogsBox logs={logs} label="Logs relevant to the swap" />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle";
|
||||
import CircularProgressWithSubtitle from "renderer/components/pages/swap/swap/components/CircularProgressWithSubtitle";
|
||||
|
||||
export default function BitcoinCancelledPage() {
|
||||
return <CircularProgressWithSubtitle description="Refunding your Bitcoin" />;
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
import { TauriSwapProgressEventContent } from "models/tauriModelExt";
|
||||
import { formatConfirmations } from "utils/formatUtils";
|
||||
import BitcoinTransactionInfoBox from "../../BitcoinTransactionInfoBox";
|
||||
import SwapStatusAlert from "renderer/components/alert/SwapStatusAlert/SwapStatusAlert";
|
||||
import { useActiveSwapInfo } from "store/hooks";
|
||||
import BitcoinTransactionInfoBox from "renderer/components/pages/swap/swap/components/BitcoinTransactionInfoBox";
|
||||
import { Box, DialogContentText } from "@mui/material";
|
||||
|
||||
// This is the number of blocks after which we consider the swap to be at risk of being unsuccessful
|
||||
|
|
@ -12,10 +10,8 @@ export default function BitcoinLockTxInMempoolPage({
|
|||
btc_lock_confirmations,
|
||||
btc_lock_txid,
|
||||
}: TauriSwapProgressEventContent<"BtcLockTxInMempool">) {
|
||||
const swapInfo = useActiveSwapInfo();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<>
|
||||
{(btc_lock_confirmations === undefined ||
|
||||
btc_lock_confirmations < BITCOIN_CONFIRMATIONS_WARNING_THRESHOLD) && (
|
||||
<DialogContentText>
|
||||
|
|
@ -32,10 +28,6 @@ export default function BitcoinLockTxInMempoolPage({
|
|||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{btc_lock_confirmations !== undefined &&
|
||||
btc_lock_confirmations >= BITCOIN_CONFIRMATIONS_WARNING_THRESHOLD && (
|
||||
<SwapStatusAlert swap={swapInfo} isRunning={true} />
|
||||
)}
|
||||
<BitcoinTransactionInfoBox
|
||||
title="Bitcoin Lock Transaction"
|
||||
txId={btc_lock_txid}
|
||||
|
|
@ -51,6 +43,6 @@ export default function BitcoinLockTxInMempoolPage({
|
|||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import CircularProgressWithSubtitle from "renderer/components/pages/swap/swap/components/CircularProgressWithSubtitle";
|
||||
|
||||
export default function BitcoinRedeemedPage() {
|
||||
return <CircularProgressWithSubtitle description="Redeeming your Monero" />;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle";
|
||||
import CircularProgressWithSubtitle from "../components/CircularProgressWithSubtitle";
|
||||
|
||||
export default function CancelTimelockExpiredPage() {
|
||||
return <CircularProgressWithSubtitle description="Cancelling the swap" />;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import CircularProgressWithSubtitle from "../components/CircularProgressWithSubtitle";
|
||||
import { useActiveSwapInfo, useSwapInfosSortedByDate } from "store/hooks";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
export default function EncryptedSignatureSentPage() {
|
||||
return (
|
||||
<CircularProgressWithSubtitle description="Waiting for them to redeem the Bitcoin" />
|
||||
);
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import {
|
|||
} from "store/hooks";
|
||||
import CircularProgressWithSubtitle, {
|
||||
LinearProgressWithSubtitle,
|
||||
} from "../../CircularProgressWithSubtitle";
|
||||
} from "../components/CircularProgressWithSubtitle";
|
||||
|
||||
export default function ReceivedQuotePage() {
|
||||
const syncProgress = useConservativeBitcoinSyncProgress();
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle";
|
||||
import CircularProgressWithSubtitle from "../components/CircularProgressWithSubtitle";
|
||||
|
||||
export default function RedeemingMoneroPage() {
|
||||
return (
|
||||
|
|
@ -5,10 +5,10 @@ import {
|
|||
TauriSwapProgressEventContent,
|
||||
} from "models/tauriModelExt";
|
||||
import { SatsAmount, PiconeroAmount } from "renderer/components/other/Units";
|
||||
import { Box, Typography, Divider } from "@mui/material";
|
||||
import { Box, Typography, Divider, Theme } from "@mui/material";
|
||||
import { useActiveSwapId, usePendingLockBitcoinApproval } from "store/hooks";
|
||||
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
|
||||
import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle";
|
||||
import CircularProgressWithSubtitle from "../components/CircularProgressWithSubtitle";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import ArrowRightAltIcon from "@mui/icons-material/ArrowRightAlt";
|
||||
import TruncatedText from "renderer/components/other/TruncatedText";
|
||||
|
|
@ -56,13 +56,22 @@ export default function SwapSetupInflightPage({
|
|||
// Display a loading spinner to the user for as long as the swap_setup request is in flight
|
||||
if (request == null) {
|
||||
return (
|
||||
<CircularProgressWithSubtitle
|
||||
description={
|
||||
<>
|
||||
Negotiating offer for <SatsAmount amount={btc_lock_amount} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
height: 200,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<CircularProgressWithSubtitle
|
||||
description={
|
||||
<>
|
||||
Negotiating offer for <SatsAmount amount={btc_lock_amount} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -83,15 +92,15 @@ export default function SwapSetupInflightPage({
|
|||
{/* Grid layout for perfect alignment */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "max-content auto max-content",
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", lg: "row" },
|
||||
gap: "1.5rem",
|
||||
alignItems: "stretch",
|
||||
justifyContent: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
{/* Row 1: Bitcoin box */}
|
||||
<Box sx={{ height: "100%" }}>
|
||||
<Box sx={{ height: "100%", flex: "0 0 auto" }}>
|
||||
<BitcoinMainBox
|
||||
btc_lock_amount={btc_lock_amount}
|
||||
btc_network_fee={btc_network_fee}
|
||||
|
|
@ -110,7 +119,7 @@ export default function SwapSetupInflightPage({
|
|||
</Box>
|
||||
|
||||
{/* Row 1: Monero main box */}
|
||||
<Box>
|
||||
<Box sx={{ flex: "0 0 auto" }}>
|
||||
<MoneroMainBox
|
||||
monero_receive_pool={monero_receive_pool}
|
||||
xmr_receive_amount={xmr_receive_amount}
|
||||
|
|
@ -120,38 +129,50 @@ export default function SwapSetupInflightPage({
|
|||
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
marginTop: 4,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<PromiseInvokeButton
|
||||
variant="text"
|
||||
size="large"
|
||||
sx={(theme) => ({ color: theme.palette.text.secondary })}
|
||||
onInvoke={() =>
|
||||
resolveApproval(request.request_id, false as unknown as object)
|
||||
}
|
||||
displayErrorSnackbar
|
||||
requiresContext
|
||||
>
|
||||
Deny
|
||||
</PromiseInvokeButton>
|
||||
<Box sx={{ display: "flex", justifyContent: "center", gap: 2 }}>
|
||||
<PromiseInvokeButton
|
||||
variant="text"
|
||||
size="large"
|
||||
sx={(theme) => ({ color: theme.palette.text.secondary })}
|
||||
onInvoke={() =>
|
||||
resolveApproval(request.request_id, false as unknown as object)
|
||||
}
|
||||
displayErrorSnackbar
|
||||
requiresContext
|
||||
>
|
||||
Deny
|
||||
</PromiseInvokeButton>
|
||||
|
||||
<PromiseInvokeButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
onInvoke={() =>
|
||||
resolveApproval(request.request_id, true as unknown as object)
|
||||
}
|
||||
displayErrorSnackbar
|
||||
requiresContext
|
||||
endIcon={<CheckIcon />}
|
||||
<PromiseInvokeButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
onInvoke={() =>
|
||||
resolveApproval(request.request_id, true as unknown as object)
|
||||
}
|
||||
displayErrorSnackbar
|
||||
requiresContext
|
||||
endIcon={<CheckIcon />}
|
||||
>
|
||||
{`Confirm`}
|
||||
</PromiseInvokeButton>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
textAlign: "center",
|
||||
color: (theme) => theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
{`Confirm (${timeLeft}s)`}
|
||||
</PromiseInvokeButton>
|
||||
{`Offer expires in ${timeLeft}s`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -177,7 +198,15 @@ const BitcoinMainBox = ({
|
|||
btc_lock_amount: number;
|
||||
btc_network_fee: number;
|
||||
}) => (
|
||||
<Box sx={{ position: "relative", height: "100%" }}>
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
|
|
@ -188,10 +217,10 @@ const BitcoinMainBox = ({
|
|||
gap: "0.5rem 1rem",
|
||||
borderColor: "warning.main",
|
||||
borderRadius: 1,
|
||||
flexGrow: 1,
|
||||
backgroundColor: (theme) => theme.palette.warning.light + "10",
|
||||
background: (theme) =>
|
||||
`linear-gradient(135deg, ${theme.palette.warning.light}20, ${theme.palette.warning.light}05)`,
|
||||
height: "100%", // Match the height of the Monero box
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
|
|
@ -217,10 +246,6 @@ const BitcoinMainBox = ({
|
|||
{/* Network fee box attached to the bottom */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: "calc(-50%)",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
padding: "0.25rem 0.75rem",
|
||||
backgroundColor: (theme) => theme.palette.warning.main,
|
||||
color: (theme) => theme.palette.warning.contrastText,
|
||||
|
|
@ -271,7 +296,7 @@ const PoolBreakdown = ({
|
|||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "stretch",
|
||||
padding: pool.percentage >= 0.05 ? 1.5 : 1.2,
|
||||
padding: pool.percentage >= 0.05 ? 1.5 : "0.25rem 0.75rem",
|
||||
border: 1,
|
||||
borderColor:
|
||||
pool.percentage >= 0.05 ? "success.main" : "success.light",
|
||||
|
|
@ -283,7 +308,6 @@ const PoolBreakdown = ({
|
|||
width: "100%", // Ensure full width
|
||||
minWidth: 0,
|
||||
opacity: pool.percentage >= 0.05 ? 1 : 0.75,
|
||||
transform: pool.percentage >= 0.05 ? "scale(1)" : "scale(0.95)",
|
||||
animation:
|
||||
pool.percentage >= 0.05
|
||||
? "poolPulse 2s ease-in-out infinite"
|
||||
|
|
@ -308,6 +332,7 @@ const PoolBreakdown = ({
|
|||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
gap: 0.5,
|
||||
flex: "1 1 0",
|
||||
minWidth: 0,
|
||||
|
|
@ -323,18 +348,20 @@ const PoolBreakdown = ({
|
|||
>
|
||||
{pool.label === "user address" ? "Your Wallet" : pool.label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75rem",
|
||||
color: (theme) => theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
<TruncatedText truncateMiddle limit={15}>
|
||||
{pool.address}
|
||||
</TruncatedText>
|
||||
</Typography>
|
||||
{pool.label === "user address" && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75rem",
|
||||
color: (theme) => theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
<TruncatedText truncateMiddle limit={15}>
|
||||
{pool.address}
|
||||
</TruncatedText>
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
|
|
@ -393,7 +420,7 @@ const MoneroMainBox = ({
|
|||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ position: "relative" }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
|
|
@ -460,20 +487,10 @@ const MoneroMainBox = ({
|
|||
</Box>
|
||||
|
||||
{/* Secondary Monero content attached to the bottom */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: "calc(-100%)",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<MoneroSecondaryContent
|
||||
monero_receive_pool={monero_receive_pool}
|
||||
xmr_receive_amount={xmr_receive_amount}
|
||||
/>
|
||||
</Box>
|
||||
<MoneroSecondaryContent
|
||||
monero_receive_pool={monero_receive_pool}
|
||||
xmr_receive_amount={xmr_receive_amount}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -491,8 +508,7 @@ const MoneroSecondaryContent = ({
|
|||
// Arrow animation styling extracted for reuse
|
||||
const arrowSx = {
|
||||
fontSize: "3rem",
|
||||
color: (theme: { palette: { primary: { main: string } } }) =>
|
||||
theme.palette.primary.main,
|
||||
color: (theme: Theme) => theme.palette.primary.main,
|
||||
animation: "slideArrow 2s infinite",
|
||||
"@keyframes slideArrow": {
|
||||
"0%": {
|
||||
|
|
@ -518,6 +534,7 @@ const AnimatedArrow = () => (
|
|||
justifyContent: "center",
|
||||
alignSelf: "center",
|
||||
flex: "0 0 auto",
|
||||
transform: { xs: "rotate(90deg)", lg: "rotate(0deg)" },
|
||||
}}
|
||||
>
|
||||
<ArrowRightAltIcon sx={arrowSx} />
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Box, DialogContentText } from "@mui/material";
|
||||
import { TauriSwapProgressEventContent } from "models/tauriModelExt";
|
||||
import MoneroTransactionInfoBox from "../../MoneroTransactionInfoBox";
|
||||
import MoneroTransactionInfoBox from "../components/MoneroTransactionInfoBox";
|
||||
|
||||
export default function WaitingForXmrConfirmationsBeforeRedeemPage({
|
||||
xmr_lock_txid,
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { Box, DialogContentText } from "@mui/material";
|
||||
import { TauriSwapProgressEventContent } from "models/tauriModelExt";
|
||||
import { formatConfirmations } from "utils/formatUtils";
|
||||
import MoneroTransactionInfoBox from "../../MoneroTransactionInfoBox";
|
||||
import MoneroTransactionInfoBox from "../components/MoneroTransactionInfoBox";
|
||||
import CancelButton from "../CancelButton";
|
||||
|
||||
export default function XmrLockTxInMempoolPage({
|
||||
xmr_lock_tx_confirmations,
|
||||
|
|
@ -11,7 +12,7 @@ export default function XmrLockTxInMempoolPage({
|
|||
const additionalContent = `Confirmations: ${formatConfirmations(xmr_lock_tx_confirmations, xmr_lock_tx_target_confirmations)}`;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<>
|
||||
<DialogContentText>
|
||||
They have published their Monero lock transaction. The swap will proceed
|
||||
once the transaction has been confirmed.
|
||||
|
|
@ -23,6 +24,8 @@ export default function XmrLockTxInMempoolPage({
|
|||
additionalContent={additionalContent}
|
||||
loading
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<CancelButton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle";
|
||||
import CircularProgressWithSubtitle from "../components/CircularProgressWithSubtitle";
|
||||
|
||||
export default function XmrLockedPage() {
|
||||
return (
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
import { Box, Paper, Tab, Tabs, Typography } from "@mui/material";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import { useState } from "react";
|
||||
import RemainingFundsWillBeUsedAlert from "renderer/components/alert/RemainingFundsWillBeUsedAlert";
|
||||
import BitcoinAddressTextField from "renderer/components/inputs/BitcoinAddressTextField";
|
||||
import MoneroAddressTextField from "renderer/components/inputs/MoneroAddressTextField";
|
||||
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
|
||||
import { buyXmr } from "renderer/rpc";
|
||||
import { useAppSelector, useSettings } from "store/hooks";
|
||||
import { useSettings } from "store/hooks";
|
||||
|
||||
export default function InitPage() {
|
||||
const [redeemAddress, setRedeemAddress] = useState("");
|
||||
|
|
@ -17,12 +16,10 @@ export default function InitPage() {
|
|||
const [redeemAddressValid, setRedeemAddressValid] = useState(false);
|
||||
const [refundAddressValid, setRefundAddressValid] = useState(false);
|
||||
|
||||
const selectedMaker = useAppSelector((state) => state.makers.selectedMaker);
|
||||
const donationRatio = useSettings((s) => s.donateToDevelopment);
|
||||
|
||||
async function init() {
|
||||
await buyXmr(
|
||||
selectedMaker,
|
||||
useExternalRefundAddress ? refundAddress : null,
|
||||
redeemAddress,
|
||||
donationRatio,
|
||||
|
|
@ -30,7 +27,7 @@ export default function InitPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
|
|
@ -38,7 +35,6 @@ export default function InitPage() {
|
|||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<RemainingFundsWillBeUsedAlert />
|
||||
<MoneroAddressTextField
|
||||
label="Monero redeem address"
|
||||
address={redeemAddress}
|
||||
|
|
@ -84,8 +80,7 @@ export default function InitPage() {
|
|||
<PromiseInvokeButton
|
||||
disabled={
|
||||
(!refundAddressValid && useExternalRefundAddress) ||
|
||||
!redeemAddressValid ||
|
||||
!selectedMaker
|
||||
!redeemAddressValid
|
||||
}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
|
|
@ -95,9 +90,9 @@ export default function InitPage() {
|
|||
onInvoke={init}
|
||||
displayErrorSnackbar
|
||||
>
|
||||
Begin swap
|
||||
Continue
|
||||
</PromiseInvokeButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
import { Typography, Box, Paper, Divider, Pagination } from "@mui/material";
|
||||
import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox";
|
||||
import MakerOfferItem from "./MakerOfferItem";
|
||||
import { usePendingSelectMakerApproval } from "store/hooks";
|
||||
import MakerDiscoveryStatus from "./MakerDiscoveryStatus";
|
||||
import { TauriSwapProgressEventContent } from "models/tauriModelExt";
|
||||
import { SatsAmount } from "renderer/components/other/Units";
|
||||
import { useState } from "react";
|
||||
import { sortApprovalsAndKnownQuotes } from "utils/sortUtils";
|
||||
|
||||
export default function DepositAndChooseOfferPage({
|
||||
deposit_address,
|
||||
max_giveable,
|
||||
known_quotes,
|
||||
}: TauriSwapProgressEventContent<"WaitingForBtcDeposit">) {
|
||||
const pendingSelectMakerApprovals = usePendingSelectMakerApproval();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const offersPerPage = 3;
|
||||
|
||||
const makerOffers = sortApprovalsAndKnownQuotes(
|
||||
pendingSelectMakerApprovals,
|
||||
known_quotes,
|
||||
);
|
||||
|
||||
// Pagination calculations
|
||||
const totalPages = Math.ceil(makerOffers.length / offersPerPage);
|
||||
const startIndex = (currentPage - 1) * offersPerPage;
|
||||
const endIndex = startIndex + offersPerPage;
|
||||
const paginatedOffers = makerOffers.slice(startIndex, endIndex);
|
||||
|
||||
const handlePageChange = (
|
||||
event: React.ChangeEvent<unknown>,
|
||||
value: number,
|
||||
) => {
|
||||
setCurrentPage(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{
|
||||
padding: 2,
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", md: "row" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flexGrow: 1, flexShrink: 0, minWidth: "12em" }}>
|
||||
<Typography variant="body1">Bitcoin Balance</Typography>
|
||||
<Typography variant="h5">
|
||||
<SatsAmount amount={max_giveable} />
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
sx={{
|
||||
marginX: { xs: 0, md: 1 },
|
||||
marginY: { xs: 1, md: 0 },
|
||||
display: { xs: "none", md: "block" },
|
||||
}}
|
||||
/>
|
||||
<Divider
|
||||
orientation="horizontal"
|
||||
flexItem
|
||||
sx={{
|
||||
marginX: { xs: 0, md: 1 },
|
||||
marginY: { xs: 1, md: 0 },
|
||||
display: { xs: "block", md: "none" },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flexShrink: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1">Deposit</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Send Bitcoin to your internal wallet to swap your desired amount of
|
||||
Monero
|
||||
</Typography>
|
||||
<ActionableMonospaceTextBox content={deposit_address} />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Available Makers Section */}
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5">Select an offer</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Maker Discovery Status */}
|
||||
<MakerDiscoveryStatus />
|
||||
|
||||
{/* Real Maker Offers */}
|
||||
<Box>
|
||||
{makerOffers.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{paginatedOffers.map((quote, index) => {
|
||||
return (
|
||||
<MakerOfferItem
|
||||
key={startIndex + index}
|
||||
quoteWithAddress={quote}
|
||||
requestId={quote.request_id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={currentPage}
|
||||
onChange={handlePageChange}
|
||||
color="primary"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* TODO: Differentiate between no makers found and still loading */}
|
||||
{makerOffers.length === 0 && (
|
||||
<Paper variant="outlined" sx={{ p: 3, textAlign: "center" }}>
|
||||
<Typography variant="body1" color="textSecondary">
|
||||
Searching for available makers...
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
|
||||
Please wait while we find the best offers for your swap.
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import { Box, Typography, LinearProgress, Paper } from "@mui/material";
|
||||
import { usePendingBackgroundProcesses } from "store/hooks";
|
||||
|
||||
export default function MakerDiscoveryStatus() {
|
||||
const backgroundProcesses = usePendingBackgroundProcesses();
|
||||
|
||||
// Find active ListSellers processes
|
||||
const listSellersProcesses = backgroundProcesses.filter(
|
||||
([, status]) =>
|
||||
status.componentName === "ListSellers" &&
|
||||
status.progress.type === "Pending",
|
||||
);
|
||||
|
||||
const isActive = listSellersProcesses.length > 0;
|
||||
|
||||
// Default values for inactive state
|
||||
let progress = {
|
||||
rendezvous_points_total: 0,
|
||||
peers_discovered: 0,
|
||||
rendezvous_points_connected: 0,
|
||||
quotes_received: 0,
|
||||
quotes_failed: 0,
|
||||
};
|
||||
let progressValue = 0;
|
||||
|
||||
if (isActive) {
|
||||
// Use the first ListSellers process for display
|
||||
const [, status] = listSellersProcesses[0];
|
||||
|
||||
// Type guard to ensure we have ListSellers progress
|
||||
if (
|
||||
status.componentName === "ListSellers" &&
|
||||
status.progress.type === "Pending"
|
||||
) {
|
||||
progress = status.progress.content;
|
||||
|
||||
const totalExpected =
|
||||
progress.rendezvous_points_total + progress.peers_discovered;
|
||||
const totalCompleted =
|
||||
progress.rendezvous_points_connected +
|
||||
progress.quotes_received +
|
||||
progress.quotes_failed;
|
||||
progressValue =
|
||||
totalExpected > 0 ? (totalCompleted / totalExpected) * 100 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
width: "100%",
|
||||
mb: 2,
|
||||
p: 2,
|
||||
border: "1px solid",
|
||||
borderColor: isActive ? "success.main" : "divider",
|
||||
borderRadius: 1,
|
||||
opacity: isActive ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1.5,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: "medium",
|
||||
color: isActive ? "info.main" : "text.disabled",
|
||||
}}
|
||||
>
|
||||
{isActive
|
||||
? "Getting offers..."
|
||||
: "Waiting a few seconds before refreshing offers"}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: isActive ? "success.main" : "text.disabled",
|
||||
fontWeight: "medium",
|
||||
}}
|
||||
>
|
||||
{progress.quotes_received} online
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: isActive ? "error.main" : "text.disabled",
|
||||
fontWeight: "medium",
|
||||
}}
|
||||
>
|
||||
{progress.quotes_failed} offline
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(progressValue, 100)}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
opacity: isActive ? 1 : 0.4,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { Box, Button, Chip, Paper, Tooltip, Typography } from "@mui/material";
|
||||
import Avatar from "boring-avatars";
|
||||
import { QuoteWithAddress } from "models/tauriModel";
|
||||
import {
|
||||
MoneroSatsExchangeRate,
|
||||
SatsAmount,
|
||||
} from "renderer/components/other/Units";
|
||||
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
|
||||
import { resolveApproval } from "renderer/rpc";
|
||||
import { isMakerVersionOutdated } from "utils/multiAddrUtils";
|
||||
import WarningIcon from "@mui/icons-material/Warning";
|
||||
|
||||
export default function MakerOfferItem({
|
||||
quoteWithAddress,
|
||||
requestId,
|
||||
}: {
|
||||
requestId?: string;
|
||||
quoteWithAddress: QuoteWithAddress;
|
||||
}) {
|
||||
const { multiaddr, peer_id, quote, version } = quoteWithAddress;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
gap: 2,
|
||||
borderRadius: 2,
|
||||
padding: 2,
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
alignItems: { xs: "stretch", sm: "center" },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
size={40}
|
||||
name={peer_id}
|
||||
variant="marble"
|
||||
colors={["#92A1C6", "#146A7C", "#F0AB3D", "#C271B4", "#C20D90"]}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ maxWidth: "200px" }} noWrap>
|
||||
{multiaddr}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ maxWidth: "200px" }} noWrap>
|
||||
{peer_id}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
label={
|
||||
<MoneroSatsExchangeRate
|
||||
rate={quote.price}
|
||||
displayMarkup={true}
|
||||
/>
|
||||
}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={
|
||||
<>
|
||||
<SatsAmount amount={quote.min_quantity} /> -{" "}
|
||||
<SatsAmount amount={quote.max_quantity} />
|
||||
</>
|
||||
}
|
||||
size="small"
|
||||
/>
|
||||
{isMakerVersionOutdated(version) ? (
|
||||
<Tooltip title="Outdated maker version. This may cause issues with the swap.">
|
||||
<Chip
|
||||
color="warning"
|
||||
label={
|
||||
<Box
|
||||
sx={{ display: "flex", alignItems: "center", gap: 0.5 }}
|
||||
>
|
||||
<WarningIcon sx={{ fontSize: "1rem" }} />
|
||||
<Typography variant="body2">{version}</Typography>
|
||||
</Box>
|
||||
}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Chip label={version} size="small" />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<PromiseInvokeButton
|
||||
variant="contained"
|
||||
onInvoke={() => resolveApproval(requestId, true as unknown as object)}
|
||||
displayErrorSnackbar
|
||||
disabled={!requestId}
|
||||
tooltipTitle={
|
||||
requestId == null
|
||||
? "You don't have enough Bitcoin to swap with this maker"
|
||||
: null
|
||||
}
|
||||
>
|
||||
Select
|
||||
</PromiseInvokeButton>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import { useState } from "react";
|
|||
import { SatsAmount } from "renderer/components/other/Units";
|
||||
import { useAppSelector } from "store/hooks";
|
||||
import BitcoinIcon from "../../icons/BitcoinIcon";
|
||||
import InfoBox from "../../modal/swap/InfoBox";
|
||||
import InfoBox from "../swap/swap/components/InfoBox";
|
||||
import WithdrawDialog from "../../modal/wallet/WithdrawDialog";
|
||||
import WalletRefreshButton from "./WalletRefreshButton";
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,15 @@ const baseTheme: ThemeOptions = {
|
|||
fontFamily: "monospace",
|
||||
},
|
||||
},
|
||||
breakpoints: {
|
||||
values: {
|
||||
xs: 0,
|
||||
sm: 600,
|
||||
md: 900,
|
||||
lg: 1000,
|
||||
xl: 1536,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
ResolveApprovalResponse,
|
||||
RedactArgs,
|
||||
RedactResponse,
|
||||
GetCurrentSwapResponse,
|
||||
LabeledMoneroAddress,
|
||||
} from "models/tauriModel";
|
||||
import { rpcSetBalance, rpcSetSwapInfo } from "store/features/rpcSlice";
|
||||
|
|
@ -174,11 +175,22 @@ export async function withdrawBtc(address: string): Promise<string> {
|
|||
}
|
||||
|
||||
export async function buyXmr(
|
||||
seller: Maker,
|
||||
bitcoin_change_address: string | null,
|
||||
monero_receive_address: string,
|
||||
donation_percentage: DonateToDevelopmentTip,
|
||||
) {
|
||||
// Get all available makers from the Redux store
|
||||
const state = store.getState();
|
||||
const allMakers = [
|
||||
...(state.makers.registry.makers || []),
|
||||
...state.makers.rendezvous.makers,
|
||||
];
|
||||
|
||||
// Convert all makers to multiaddr format
|
||||
const sellers = allMakers.map((maker) =>
|
||||
providerToConcatenatedMultiAddr(maker),
|
||||
);
|
||||
|
||||
const address_pool: LabeledMoneroAddress[] = [];
|
||||
if (donation_percentage !== false) {
|
||||
const donation_address = isTestnet()
|
||||
|
|
@ -206,7 +218,8 @@ export async function buyXmr(
|
|||
}
|
||||
|
||||
await invoke<BuyXmrArgs, BuyXmrResponse>("buy_xmr", {
|
||||
seller: providerToConcatenatedMultiAddr(seller),
|
||||
rendezvous_points: PRESET_RENDEZVOUS_POINTS,
|
||||
sellers,
|
||||
monero_receive_pool: address_pool,
|
||||
bitcoin_change_address,
|
||||
});
|
||||
|
|
@ -222,6 +235,10 @@ export async function suspendCurrentSwap() {
|
|||
await invokeNoArgs<SuspendCurrentSwapResponse>("suspend_current_swap");
|
||||
}
|
||||
|
||||
export async function getCurrentSwapId() {
|
||||
return await invokeNoArgs<GetCurrentSwapResponse>("get_current_swap");
|
||||
}
|
||||
|
||||
export async function getMoneroRecoveryKeys(
|
||||
swapId: string,
|
||||
): Promise<MoneroRecoveryResponse> {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import makersSlice from "./features/makersSlice";
|
|||
import ratesSlice from "./features/ratesSlice";
|
||||
import rpcSlice from "./features/rpcSlice";
|
||||
import swapReducer from "./features/swapSlice";
|
||||
import torSlice from "./features/torSlice";
|
||||
import settingsSlice from "./features/settingsSlice";
|
||||
import nodesSlice from "./features/nodesSlice";
|
||||
import conversationsSlice from "./features/conversationsSlice";
|
||||
|
|
@ -12,7 +11,6 @@ import poolSlice from "./features/poolSlice";
|
|||
export const reducers = {
|
||||
swap: swapReducer,
|
||||
makers: makersSlice,
|
||||
tor: torSlice,
|
||||
rpc: rpcSlice,
|
||||
alerts: alertsSlice,
|
||||
rates: ratesSlice,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { SellerStatus } from "models/tauriModel";
|
|||
import { getStubTestnetMaker } from "store/config";
|
||||
import { rendezvousSellerToMakerStatus } from "utils/conversionUtils";
|
||||
import { isMakerOutdated } from "utils/multiAddrUtils";
|
||||
import { sortMakerList } from "utils/sortUtils";
|
||||
|
||||
const stubTestnetMaker = getStubTestnetMaker();
|
||||
|
||||
|
|
@ -48,10 +47,10 @@ function selectNewSelectedMaker(
|
|||
}
|
||||
|
||||
// Otherwise we'd prefer to switch to a provider that has the newest version
|
||||
const providers = sortMakerList([
|
||||
const providers = [
|
||||
...(slice.registry.makers ?? []),
|
||||
...(slice.rendezvous.makers ?? []),
|
||||
]);
|
||||
];
|
||||
|
||||
return providers.at(0) || null;
|
||||
}
|
||||
|
|
@ -86,7 +85,6 @@ export const makersSlice = createSlice({
|
|||
});
|
||||
|
||||
// Sort the provider list and select a new provider if needed
|
||||
slice.rendezvous.makers = sortMakerList(slice.rendezvous.makers);
|
||||
slice.selectedMaker = selectNewSelectedMaker(slice);
|
||||
},
|
||||
setRegistryMakers(slice, action: PayloadAction<ExtendedMakerStatus[]>) {
|
||||
|
|
@ -95,7 +93,6 @@ export const makersSlice = createSlice({
|
|||
}
|
||||
|
||||
// Sort the provider list and select a new provider if needed
|
||||
slice.registry.makers = sortMakerList(action.payload);
|
||||
slice.selectedMaker = selectNewSelectedMaker(slice);
|
||||
},
|
||||
registryConnectionFailed(slice) {
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
export interface TorSlice {
|
||||
exitCode: number | null;
|
||||
processRunning: boolean;
|
||||
stdOut: string;
|
||||
proxyStatus:
|
||||
| false
|
||||
| {
|
||||
proxyHostname: string;
|
||||
proxyPort: number;
|
||||
bootstrapped: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const initialState: TorSlice = {
|
||||
processRunning: false,
|
||||
exitCode: null,
|
||||
stdOut: "",
|
||||
proxyStatus: false,
|
||||
};
|
||||
|
||||
const socksListenerRegex =
|
||||
/Opened Socks listener connection.*on (\d+\.\d+\.\d+\.\d+):(\d+)/;
|
||||
const bootstrapDoneRegex = /Bootstrapped 100% \(done\)/;
|
||||
|
||||
export const torSlice = createSlice({
|
||||
name: "tor",
|
||||
initialState,
|
||||
reducers: {
|
||||
torAppendStdOut(slice, action: PayloadAction<string>) {
|
||||
slice.stdOut += action.payload;
|
||||
|
||||
const logs = slice.stdOut.split("\n");
|
||||
logs.forEach((log) => {
|
||||
if (socksListenerRegex.test(log)) {
|
||||
const match = socksListenerRegex.exec(log);
|
||||
if (match) {
|
||||
slice.proxyStatus = {
|
||||
proxyHostname: match[1],
|
||||
proxyPort: Number.parseInt(match[2], 10),
|
||||
bootstrapped: slice.proxyStatus
|
||||
? slice.proxyStatus.bootstrapped
|
||||
: false,
|
||||
};
|
||||
}
|
||||
} else if (bootstrapDoneRegex.test(log)) {
|
||||
if (slice.proxyStatus) {
|
||||
slice.proxyStatus.bootstrapped = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
torInitiate(slice) {
|
||||
slice.processRunning = true;
|
||||
},
|
||||
torProcessExited(
|
||||
slice,
|
||||
action: PayloadAction<{
|
||||
exitCode: number | null;
|
||||
exitSignal: NodeJS.Signals | null;
|
||||
}>,
|
||||
) {
|
||||
slice.processRunning = false;
|
||||
slice.exitCode = action.payload.exitCode;
|
||||
slice.proxyStatus = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { torAppendStdOut, torInitiate, torProcessExited } =
|
||||
torSlice.actions;
|
||||
|
||||
export default torSlice.reducer;
|
||||
|
|
@ -8,6 +8,9 @@ import {
|
|||
isPendingSeedSelectionApprovalEvent,
|
||||
PendingApprovalRequest,
|
||||
PendingLockBitcoinApprovalRequest,
|
||||
PendingSelectMakerApprovalRequest,
|
||||
isPendingSelectMakerApprovalEvent,
|
||||
haveFundsBeenLocked,
|
||||
PendingSeedSelectionApprovalRequest,
|
||||
} from "models/tauriModelExt";
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
|
||||
|
|
@ -18,7 +21,6 @@ import { isCliLogRelatedToSwap } from "models/cliModel";
|
|||
import { SettingsState } from "./features/settingsSlice";
|
||||
import { NodesSlice } from "./features/nodesSlice";
|
||||
import { RatesState } from "./features/ratesSlice";
|
||||
import { sortMakerList } from "utils/sortUtils";
|
||||
import {
|
||||
TauriBackgroundProgress,
|
||||
TauriBitcoinSyncProgress,
|
||||
|
|
@ -56,7 +58,7 @@ export function useResumeableSwapsCountExcludingPunished() {
|
|||
);
|
||||
}
|
||||
|
||||
/// Returns true if we have a swap that is running
|
||||
/// Returns true if we have any swap that is running
|
||||
export function useIsSwapRunning() {
|
||||
return useAppSelector(
|
||||
(state) =>
|
||||
|
|
@ -64,6 +66,46 @@ export function useIsSwapRunning() {
|
|||
);
|
||||
}
|
||||
|
||||
/// Returns true if we have a swap that is running and
|
||||
/// that swap has any funds locked
|
||||
export function useIsSwapRunningAndHasFundsLocked() {
|
||||
const swapInfo = useActiveSwapInfo();
|
||||
const swapTauriState = useAppSelector(
|
||||
(state) => state.swap.state?.curr ?? null,
|
||||
);
|
||||
|
||||
// If the swap is in the Released state, we return false
|
||||
if (swapTauriState?.type === "Released") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the tauri state tells us that funds have been locked, we return true
|
||||
if (haveFundsBeenLocked(swapTauriState)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we have a database entry (swapInfo) for this swap, we return true
|
||||
if (swapInfo != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Returns true if we have a swap that is running
|
||||
export function useIsSpecificSwapRunning(swapId: string | null) {
|
||||
if (swapId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return useAppSelector(
|
||||
(state) =>
|
||||
state.swap.state !== null &&
|
||||
state.swap.state.swapId === swapId &&
|
||||
state.swap.state.curr.type !== "Released",
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsContextAvailable() {
|
||||
return useAppSelector(
|
||||
(state) => state.rpc.status === TauriContextStatusEvent.Available,
|
||||
|
|
@ -103,9 +145,7 @@ export function useAllMakers() {
|
|||
return useAppSelector((state) => {
|
||||
const registryMakers = state.makers.registry.makers || [];
|
||||
const listSellersMakers = state.makers.rendezvous.makers || [];
|
||||
const all = [...registryMakers, ...listSellersMakers];
|
||||
|
||||
return sortMakerList(all);
|
||||
return [...registryMakers, ...listSellersMakers];
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -167,6 +207,11 @@ export function usePendingLockBitcoinApproval(): PendingLockBitcoinApprovalReque
|
|||
return approvals.filter((c) => isPendingLockBitcoinApprovalEvent(c));
|
||||
}
|
||||
|
||||
export function usePendingSelectMakerApproval(): PendingSelectMakerApprovalRequest[] {
|
||||
const approvals = usePendingApprovals();
|
||||
return approvals.filter((c) => isPendingSelectMakerApprovalEvent(c));
|
||||
}
|
||||
|
||||
export function usePendingSeedSelectionApproval(): PendingSeedSelectionApprovalRequest[] {
|
||||
const approvals = usePendingApprovals();
|
||||
return approvals.filter((c) => isPendingSeedSelectionApprovalEvent(c));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createListenerMiddleware } from "@reduxjs/toolkit";
|
||||
import { throttle, debounce } from "lodash";
|
||||
import {
|
||||
getAllSwapInfos,
|
||||
checkBitcoinBalance,
|
||||
|
|
@ -22,6 +23,33 @@ import {
|
|||
} from "store/features/conversationsSlice";
|
||||
import { TauriContextStatusEvent } from "models/tauriModel";
|
||||
|
||||
// Create a Map to store throttled functions per swap_id
|
||||
const throttledGetSwapInfoFunctions = new Map<
|
||||
string,
|
||||
ReturnType<typeof throttle>
|
||||
>();
|
||||
|
||||
// Function to get or create a throttled getSwapInfo for a specific swap_id
|
||||
const getThrottledSwapInfoUpdater = (swapId: string) => {
|
||||
if (!throttledGetSwapInfoFunctions.has(swapId)) {
|
||||
// Create a throttled function that executes at most once every 2 seconds
|
||||
// but will wait for 3 seconds of quiet during rapid calls (using debounce)
|
||||
const debouncedGetSwapInfo = debounce(() => {
|
||||
logger.debug(`Executing getSwapInfo for swap ${swapId}`);
|
||||
getSwapInfo(swapId);
|
||||
}, 3000); // 3 seconds debounce for rapid calls
|
||||
|
||||
const throttledFunction = throttle(debouncedGetSwapInfo, 2000, {
|
||||
leading: true, // Execute immediately on first call
|
||||
trailing: true, // Execute on trailing edge if needed
|
||||
});
|
||||
|
||||
throttledGetSwapInfoFunctions.set(swapId, throttledFunction);
|
||||
}
|
||||
|
||||
return throttledGetSwapInfoFunctions.get(swapId)!;
|
||||
};
|
||||
|
||||
export function createMainListeners() {
|
||||
const listener = createListenerMiddleware();
|
||||
|
||||
|
|
@ -57,11 +85,14 @@ export function createMainListeners() {
|
|||
await checkBitcoinBalance();
|
||||
}
|
||||
|
||||
// Update the swap info
|
||||
// Update the swap info using throttled function
|
||||
logger.info(
|
||||
"Swap progress event received, updating swap info from database...",
|
||||
"Swap progress event received, scheduling throttled swap info update...",
|
||||
);
|
||||
await getSwapInfo(action.payload.swap_id);
|
||||
const throttledUpdater = getThrottledSwapInfoUpdater(
|
||||
action.payload.swap_id,
|
||||
);
|
||||
throttledUpdater();
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +1,57 @@
|
|||
import { ExtendedMakerStatus } from "models/apiModel";
|
||||
import { isMakerOnCorrectNetwork, isMakerOutdated } from "./multiAddrUtils";
|
||||
import {
|
||||
PendingSelectMakerApprovalRequest,
|
||||
SortableQuoteWithAddress,
|
||||
} from "models/tauriModelExt";
|
||||
import { QuoteWithAddress } from "models/tauriModel";
|
||||
import { isMakerVersionOutdated } from "./multiAddrUtils";
|
||||
import _ from "lodash";
|
||||
|
||||
export function sortMakerList(list: ExtendedMakerStatus[]) {
|
||||
export function sortApprovalsAndKnownQuotes(
|
||||
pendingSelectMakerApprovals: PendingSelectMakerApprovalRequest[],
|
||||
known_quotes: QuoteWithAddress[],
|
||||
) {
|
||||
const sortableQuotes = pendingSelectMakerApprovals.map((approval) => {
|
||||
return {
|
||||
...approval.request.content.maker,
|
||||
expiration_ts:
|
||||
approval.request_status.state === "Pending"
|
||||
? approval.request_status.content.expiration_ts
|
||||
: undefined,
|
||||
request_id: approval.request_id,
|
||||
} as SortableQuoteWithAddress;
|
||||
});
|
||||
|
||||
sortableQuotes.push(
|
||||
...known_quotes.map((quote) => ({
|
||||
...quote,
|
||||
request_id: null,
|
||||
})),
|
||||
);
|
||||
|
||||
return sortMakerApprovals(sortableQuotes);
|
||||
}
|
||||
|
||||
export function sortMakerApprovals(list: SortableQuoteWithAddress[]) {
|
||||
return (
|
||||
_(list)
|
||||
// Filter out makers that are on the wrong network (testnet / mainnet)
|
||||
.filter(isMakerOnCorrectNetwork)
|
||||
// Sort by criteria
|
||||
.orderBy(
|
||||
[
|
||||
// Prefer makers that have a 'version' attribute
|
||||
// If we don't have a version, we cannot clarify if it's outdated or not
|
||||
(m) => (m.version ? 0 : 1),
|
||||
// Prefer makers that are not outdated
|
||||
(m) => (isMakerOutdated(m) ? 1 : 0),
|
||||
// Prefer makers that have a relevancy score
|
||||
(m) => (m.relevancy == null ? 1 : 0),
|
||||
// Prefer makers with a higher relevancy score
|
||||
(m) => -(m.relevancy ?? 0),
|
||||
(m) => (isMakerVersionOutdated(m.version) ? 1 : 0),
|
||||
// Prefer makers with a minimum quantity > 0
|
||||
(m) => ((m.minSwapAmount ?? 0) > 0 ? 0 : 1),
|
||||
(m) => ((m.quote.min_quantity ?? 0) > 0 ? 0 : 1),
|
||||
// Prefer approvals over actual quotes
|
||||
(m) => (m.request_id ? 0 : 1),
|
||||
// Prefer makers with a lower price
|
||||
(m) => m.price,
|
||||
(m) => m.quote.price,
|
||||
],
|
||||
["asc", "asc", "asc", "asc", "asc"],
|
||||
)
|
||||
// Remove duplicate makers
|
||||
.uniqBy((m) => m.peerId)
|
||||
.uniqBy((m) => m.peer_id)
|
||||
.value()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1554,6 +1554,11 @@ bl@^1.2.1:
|
|||
readable-stream "^2.3.5"
|
||||
safe-buffer "^5.1.1"
|
||||
|
||||
boring-avatars@^1.11.2:
|
||||
version "1.11.2"
|
||||
resolved "https://registry.yarnpkg.com/boring-avatars/-/boring-avatars-1.11.2.tgz#365e0b765fb0065ca0cb2fd20c200674d0a9ded6"
|
||||
integrity sha512-3+wkwPeObwS4R37FGXMYViqc4iTrIRj5yzfX9Qy4mnpZ26sX41dGMhsAgmKks1r/uufY1pl4vpgzMWHYfJRb2A==
|
||||
|
||||
brace-expansion@^1.1.7:
|
||||
version "1.1.12"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue