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:
Mohan 2025-07-02 16:21:36 +02:00 committed by GitHub
parent 7606982de3
commit 210cc04ced
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 1744 additions and 1153 deletions

View file

@ -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",

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -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

View file

@ -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>
);

View file

@ -53,6 +53,9 @@ export default function MoneroAddressTextField({
setAddresses(response.addresses);
};
fetchAddresses();
const interval = setInterval(fetchAddresses, 5000);
return () => clearInterval(interval);
}, []);
// Event handlers

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);

View file

@ -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

View file

@ -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>

View file

@ -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 <></>;
}

View file

@ -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>
);
}

View file

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

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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,

View file

@ -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>
)}

View file

@ -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";

View file

@ -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 {

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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);

View file

@ -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";

View file

@ -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";

View file

@ -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={() => {}} />
</>
);
}

View file

@ -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 (

View file

@ -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 (

View file

@ -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&apos;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&apos;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} />;
}

View file

@ -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>
</>
);
}

View file

@ -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":

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>
</>
);
}

View file

@ -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" />;

View file

@ -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>
</>
);
}

View file

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

View file

@ -1,4 +1,4 @@
import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle";
import CircularProgressWithSubtitle from "../components/CircularProgressWithSubtitle";
export default function CancelTimelockExpiredPage() {
return <CircularProgressWithSubtitle description="Cancelling the swap" />;

View file

@ -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" />
);
}

View file

@ -4,7 +4,7 @@ import {
} from "store/hooks";
import CircularProgressWithSubtitle, {
LinearProgressWithSubtitle,
} from "../../CircularProgressWithSubtitle";
} from "../components/CircularProgressWithSubtitle";
export default function ReceivedQuotePage() {
const syncProgress = useConservativeBitcoinSyncProgress();

View file

@ -1,4 +1,4 @@
import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle";
import CircularProgressWithSubtitle from "../components/CircularProgressWithSubtitle";
export default function RedeemingMoneroPage() {
return (

View file

@ -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} />

View file

@ -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,

View file

@ -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 />
</>
);
}

View file

@ -1,4 +1,4 @@
import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle";
import CircularProgressWithSubtitle from "../components/CircularProgressWithSubtitle";
export default function XmrLockedPage() {
return (

View file

@ -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>
</>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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";

View file

@ -13,6 +13,15 @@ const baseTheme: ThemeOptions = {
fontFamily: "monospace",
},
},
breakpoints: {
values: {
xs: 0,
sm: 600,
md: 900,
lg: 1000,
xl: 1536,
},
},
components: {
MuiButton: {
styleOverrides: {

View file

@ -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> {

View file

@ -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,

View file

@ -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) {

View file

@ -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;

View file

@ -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));

View file

@ -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();
},
});

View file

@ -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()
);
}

View file

@ -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"