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

@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: false
---
NEVER NEVER NEVER upgrade or changr the versions used in dependencies without explicitly asking for confirmation!!

12
Cargo.lock generated
View file

@ -7591,15 +7591,6 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
dependencies = [
"image",
]
[[package]]
name = "quick-error"
version = "1.2.3"
@ -9823,7 +9814,6 @@ dependencies = [
"once_cell",
"pem",
"proptest",
"qrcode",
"rand 0.8.5",
"rand_chacha 0.3.1",
"regex",
@ -10806,6 +10796,8 @@ dependencies = [
"futures-core",
"futures-io",
"futures-sink",
"futures-util",
"hashbrown 0.15.4",
"pin-project-lite",
"tokio",
]

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"

View file

@ -9,10 +9,10 @@ use swap::cli::{
request::{
BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, CheckElectrumNodeArgs,
CheckElectrumNodeResponse, CheckMoneroNodeArgs, CheckMoneroNodeResponse, CheckSeedArgs,
CheckSeedResponse, ExportBitcoinWalletArgs, GetDataDirArgs, GetHistoryArgs,
GetLogsArgs, GetMoneroAddressesArgs, GetSwapInfoArgs, GetSwapInfosAllArgs,
ListSellersArgs, MoneroRecoveryArgs, RedactArgs, ResolveApprovalArgs, ResumeSwapArgs,
SuspendCurrentSwapArgs, WithdrawBtcArgs,
CheckSeedResponse, ExportBitcoinWalletArgs, GetCurrentSwapArgs, GetDataDirArgs,
GetHistoryArgs, GetLogsArgs, GetMoneroAddressesArgs, GetSwapInfoArgs,
GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs,
ResolveApprovalArgs, ResumeSwapArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs,
},
tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings},
Context, ContextBuilder,
@ -195,6 +195,7 @@ pub fn run() {
check_monero_node,
check_electrum_node,
get_wallet_descriptor,
get_current_swap,
get_data_dir,
resolve_approval_request,
redact,
@ -249,6 +250,7 @@ tauri_command!(get_swap_info, GetSwapInfoArgs);
tauri_command!(get_swap_infos_all, GetSwapInfosAllArgs, no_args);
tauri_command!(get_history, GetHistoryArgs, no_args);
tauri_command!(get_monero_addresses, GetMoneroAddressesArgs, no_args);
tauri_command!(get_current_swap, GetCurrentSwapArgs, no_args);
/// Here we define Tauri commands whose implementation is not delegated to the Request trait
#[tauri::command]

View file

@ -51,7 +51,6 @@ monero-sys = { path = "../monero-sys" }
once_cell = "1.19"
pem = "3.0"
proptest = "1"
qrcode = "0.14"
rand = "0.8"
rand_chacha = "0.3"
regex = "1.10"
@ -72,9 +71,9 @@ strum = { version = "0.26", features = ["derive"] }
tauri = { version = "2.0", features = ["config-json5"], optional = true, default-features = false }
thiserror = "1"
time = "0.3"
tokio = { version = "1", features = ["rt-multi-thread", "time", "macros", "sync", "process", "fs", "net", "parking_lot"] }
tokio = { version = "1", features = ["rt-multi-thread", "time", "macros", "sync", "process", "fs", "net", "parking_lot", "rt"] }
tokio-tungstenite = { version = "0.15", features = ["rustls-tls"] }
tokio-util = { version = "0.7", features = ["io", "codec"] }
tokio-util = { version = "0.7", features = ["io", "codec", "rt"] }
toml = "0.8"
tor-rtcompat = { version = "0.25.0", features = ["tokio"] }
tower = { version = "0.4.13", features = ["full"] }

View file

@ -1,37 +1,40 @@
use super::tauri_bindings::TauriHandle;
use crate::bitcoin::{wallet, CancelTimelock, ExpiredTimelocks, PunishTimelock, TxLock};
use crate::cli::api::tauri_bindings::{TauriEmitter, TauriSwapProgressEvent};
use crate::bitcoin::{wallet, CancelTimelock, ExpiredTimelocks, PunishTimelock};
use crate::cli::api::tauri_bindings::{SelectMakerDetails, TauriEmitter, TauriSwapProgressEvent};
use crate::cli::api::Context;
use crate::cli::list_sellers::{QuoteWithAddress, UnreachableSeller};
use crate::cli::list_sellers::{list_sellers_init, QuoteWithAddress, UnreachableSeller};
use crate::cli::{list_sellers as list_sellers_impl, EventLoop, SellerStatus};
use crate::common::{get_logs, redact};
use crate::libp2p_ext::MultiAddrExt;
use crate::monero::wallet_rpc::MoneroDaemon;
use crate::monero::MoneroAddressPool;
use crate::network::quote::{BidQuote, ZeroQuoteReceived};
use crate::network::rendezvous::XmrBtcNamespace;
use crate::network::swarm;
use crate::protocol::bob::{BobState, Swap};
use crate::protocol::{bob, State};
use crate::protocol::{bob, Database, State};
use crate::{bitcoin, cli, monero};
use ::bitcoin::address::NetworkUnchecked;
use ::bitcoin::Txid;
use ::monero::Network;
use anyhow::{bail, Context as AnyContext, Result};
use arti_client::TorClient;
use futures::stream::FuturesUnordered;
use futures::StreamExt;
use libp2p::core::Multiaddr;
use libp2p::PeerId;
use libp2p::{identity, PeerId};
use monero_seed::{Language, Seed as MoneroSeed};
use once_cell::sync::Lazy;
use qrcode::render::unicode;
use qrcode::QrCode;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::cmp::min;
use std::convert::TryInto;
use std::future::Future;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use thiserror::Error;
use tokio_util::task::AbortOnDropHandle;
use tor_rtcompat::tokio::TokioRustlsRuntime;
use tracing::debug_span;
use tracing::Instrument;
use tracing::Span;
@ -58,8 +61,10 @@ fn get_swap_tracing_span(swap_id: Uuid) -> Span {
#[typeshare]
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct BuyXmrArgs {
#[typeshare(serialized_as = "string")]
pub seller: Multiaddr,
#[typeshare(serialized_as = "Vec<string>")]
pub rendezvous_points: Vec<Multiaddr>,
#[typeshare(serialized_as = "Vec<string>")]
pub sellers: Vec<Multiaddr>,
#[typeshare(serialized_as = "Option<string>")]
pub bitcoin_change_address: Option<bitcoin::Address<NetworkUnchecked>>,
pub monero_receive_pool: MoneroAddressPool,
@ -310,8 +315,9 @@ pub struct SuspendCurrentSwapArgs;
#[typeshare]
#[derive(Serialize, Deserialize, Debug)]
pub struct SuspendCurrentSwapResponse {
#[typeshare(serialized_as = "string")]
pub swap_id: Uuid,
// If no swap was running, we still return Ok(...) but this is set to None
#[typeshare(serialized_as = "Option<string>")]
pub swap_id: Option<Uuid>,
}
impl Request for SuspendCurrentSwapArgs {
@ -322,10 +328,19 @@ impl Request for SuspendCurrentSwapArgs {
}
}
#[typeshare]
#[derive(Debug, Serialize, Deserialize)]
pub struct GetCurrentSwapArgs;
#[typeshare]
#[derive(Serialize, Deserialize, Debug)]
pub struct GetCurrentSwapResponse {
#[typeshare(serialized_as = "Option<string>")]
pub swap_id: Option<Uuid>,
}
impl Request for GetCurrentSwapArgs {
type Response = serde_json::Value;
type Response = GetCurrentSwapResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
get_current_swap(ctx).await
@ -463,9 +478,12 @@ pub async fn suspend_current_swap(context: Arc<Context>) -> Result<SuspendCurren
if let Some(id_value) = swap_id {
context.swap_lock.send_suspend_signal().await?;
Ok(SuspendCurrentSwapResponse { swap_id: id_value })
Ok(SuspendCurrentSwapResponse {
swap_id: Some(id_value),
})
} else {
bail!("No swap is currently running")
// If no swap was running, we still return Ok(...) with None
Ok(SuspendCurrentSwapResponse { swap_id: None })
}
}
@ -593,8 +611,11 @@ pub async fn buy_xmr(
swap_id: Uuid,
context: Arc<Context>,
) -> Result<BuyXmrResponse, anyhow::Error> {
let _span = get_swap_tracing_span(swap_id);
let BuyXmrArgs {
seller,
rendezvous_points,
sellers,
bitcoin_change_address,
monero_receive_pool,
} = buy_xmr;
@ -635,13 +656,103 @@ pub async fn buy_xmr(
let env_config = context.config.env_config;
let seed = context.config.seed.clone().context("Could not get seed")?;
let seller_peer_id = seller
.extract_peer_id()
.context("Seller address must contain peer ID")?;
// Prepare variables for the quote fetching process
let identity = seed.derive_libp2p_identity();
let namespace = context.config.namespace;
let tor_client = context.tor_client.clone();
let db = Some(context.db.clone());
let tauri_handle = context.tauri_handle.clone();
// Wait for the user to approve a seller and to deposit coins
// Calling determine_btc_to_swap
let address_len = bitcoin_wallet.new_address().await?.script_pubkey().len();
let bitcoin_wallet_for_closures = Arc::clone(&bitcoin_wallet);
// Clone bitcoin_change_address before moving it in the emit call
let bitcoin_change_address_for_spawn = bitcoin_change_address.clone();
let rendezvous_points_clone = rendezvous_points.clone();
let sellers_clone = sellers.clone();
// Acquire the lock before the user has selected a maker and we already have funds in the wallet
// because we need to be able to cancel the determine_btc_to_swap(..)
context.swap_lock.acquire_swap_lock(swap_id).await?;
let (seller_multiaddr, seller_peer_id, quote, tx_lock_amount, tx_lock_fee) = tokio::select! {
result = determine_btc_to_swap(
move || {
let rendezvous_points = rendezvous_points_clone.clone();
let sellers = sellers_clone.clone();
let namespace = namespace;
let identity = identity.clone();
let db = db.clone();
let tor_client = tor_client.clone();
let tauri_handle = tauri_handle.clone();
Box::pin(async move {
fetch_quotes_task(
rendezvous_points,
namespace,
sellers,
identity,
db,
tor_client,
tauri_handle,
).await
})
},
bitcoin_wallet.new_address(),
{
let wallet = Arc::clone(&bitcoin_wallet_for_closures);
move || {
let w = wallet.clone();
async move { w.balance().await }
}
},
{
let wallet = Arc::clone(&bitcoin_wallet_for_closures);
move || {
let w = wallet.clone();
async move { w.max_giveable(address_len).await }
}
},
{
let wallet = Arc::clone(&bitcoin_wallet_for_closures);
move || {
let w = wallet.clone();
async move { w.sync().await }
}
},
context.tauri_handle.clone(),
swap_id,
|quote_with_address| {
let tauri_handle = context.tauri_handle.clone();
Box::new(async move {
let details = SelectMakerDetails {
swap_id,
btc_amount_to_swap: quote_with_address.quote.max_quantity,
maker: quote_with_address,
};
tauri_handle.request_maker_selection(details, 300).await
}) as Box<dyn Future<Output = Result<bool>> + Send>
},
) => {
result?
}
_ = context.swap_lock.listen_for_swap_force_suspension() => {
context.swap_lock.release_swap_lock().await.expect("Shutdown signal received but failed to release swap lock. The swap process has been terminated but the swap lock is still active.");
context.tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released);
bail!("Shutdown signal received");
},
};
// Insert the peer_id into the database
context.db.insert_peer_id(swap_id, seller_peer_id).await?;
context
.db
.insert_address(seller_peer_id, seller.clone())
.insert_address(seller_peer_id, seller_multiaddr.clone())
.await?;
let behaviour = cli::Behaviour::new(
@ -658,7 +769,7 @@ pub async fn buy_xmr(
)
.await?;
swarm.add_peer_address(seller_peer_id, seller);
swarm.add_peer_address(seller_peer_id, seller_multiaddr.clone());
context
.db
@ -667,57 +778,19 @@ pub async fn buy_xmr(
tracing::debug!(peer_id = %swarm.local_peer_id(), "Network layer initialized");
context.swap_lock.acquire_swap_lock(swap_id).await?;
context.tauri_handle.emit_swap_progress_event(
swap_id,
TauriSwapProgressEvent::ReceivedQuote(quote.clone()),
);
// Now create the event loop we use for the swap
let (event_loop, event_loop_handle) =
EventLoop::new(swap_id, swarm, seller_peer_id, context.db.clone())?;
let event_loop = tokio::spawn(event_loop.run().in_current_span());
context
.tauri_handle
.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::RequestingQuote);
let initialize_swap = tokio::select! {
biased;
_ = context.swap_lock.listen_for_swap_force_suspension() => {
tracing::debug!("Shutdown signal received, exiting");
context.swap_lock.release_swap_lock().await.expect("Shutdown signal received but failed to release swap lock. The swap process has been terminated but the swap lock is still active.");
context.tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released);
bail!("Shutdown signal received");
},
result = async {
let (event_loop, mut event_loop_handle) =
EventLoop::new(swap_id, swarm, seller_peer_id, context.db.clone())?;
let event_loop = tokio::spawn(event_loop.run().in_current_span());
let bid_quote = event_loop_handle.request_quote().await?;
Ok::<_, anyhow::Error>((event_loop, event_loop_handle, bid_quote))
} => {
result
},
};
let (event_loop, event_loop_handle, bid_quote) = match initialize_swap {
Ok(result) => result,
Err(error) => {
tracing::error!(%swap_id, "Swap initialization failed: {:#}", error);
context
.swap_lock
.release_swap_lock()
.await
.expect("Could not release swap lock");
context
.tauri_handle
.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released);
bail!(error);
}
};
context
.tauri_handle
.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::ReceivedQuote(bid_quote));
.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::ReceivedQuote(quote));
context.tasks.clone().spawn(async move {
tokio::select! {
@ -730,6 +803,7 @@ pub async fn buy_xmr(
bail!("Shutdown signal received");
},
event_loop_result = event_loop => {
match event_loop_result {
Ok(_) => {
@ -741,36 +815,6 @@ pub async fn buy_xmr(
}
},
swap_result = async {
let max_givable = || async {
let (amount, fee) = bitcoin_wallet.max_giveable(TxLock::script_size()).await?;
Ok((amount, fee))
};
let determine_amount = determine_btc_to_swap(
context.config.json,
bid_quote,
bitcoin_wallet.new_address(),
|| bitcoin_wallet.balance(),
max_givable,
|| bitcoin_wallet.sync(),
context.tauri_handle.clone(),
Some(swap_id)
);
let (tx_lock_amount, tx_lock_fee) = match determine_amount.await {
Ok(val) => val,
Err(error) => match error.downcast::<ZeroQuoteReceived>() {
Ok(_) => {
bail!("Seller's XMR balance is currently too low to initiate a swap, please try again later")
}
Err(other) => bail!(other),
},
};
tracing::info!(%tx_lock_amount, %tx_lock_fee, "Determined swap amount");
context.db.insert_peer_id(swap_id, seller_peer_id).await?;
let swap = Swap::new(
Arc::clone(&context.db),
swap_id,
@ -779,7 +823,7 @@ pub async fn buy_xmr(
env_config,
event_loop_handle,
monero_receive_pool.clone(),
bitcoin_change_address,
bitcoin_change_address_for_spawn,
tx_lock_amount,
tx_lock_fee
).with_event_emitter(context.tauri_handle.clone());
@ -809,10 +853,7 @@ pub async fn buy_xmr(
Ok::<_, anyhow::Error>(())
}.in_current_span()).await;
Ok(BuyXmrResponse {
swap_id,
quote: bid_quote,
})
Ok(BuyXmrResponse { swap_id, quote })
}
#[tracing::instrument(fields(method = "resume_swap"), skip(context))]
@ -1202,10 +1243,9 @@ pub async fn monero_recovery(
}
#[tracing::instrument(fields(method = "get_current_swap"), skip(context))]
pub async fn get_current_swap(context: Arc<Context>) -> Result<serde_json::Value> {
Ok(json!({
"swap_id": context.swap_lock.get_current_swap_id().await,
}))
pub async fn get_current_swap(context: Arc<Context>) -> Result<GetCurrentSwapResponse> {
let swap_id = context.swap_lock.get_current_swap_id().await;
Ok(GetCurrentSwapResponse { swap_id })
}
pub async fn resolve_approval_request(
@ -1225,133 +1265,270 @@ pub async fn resolve_approval_request(
Ok(ResolveApprovalResponse { success: true })
}
fn qr_code(value: &impl ToString) -> Result<String> {
let code = QrCode::new(value.to_string())?;
let qr_code = code
.render::<unicode::Dense1x2>()
.dark_color(unicode::Dense1x2::Light)
.light_color(unicode::Dense1x2::Dark)
.build();
Ok(qr_code)
pub async fn fetch_quotes_task(
rendezvous_points: Vec<Multiaddr>,
namespace: XmrBtcNamespace,
sellers: Vec<Multiaddr>,
identity: identity::Keypair,
db: Option<Arc<dyn Database + Send + Sync>>,
tor_client: Option<Arc<TorClient<TokioRustlsRuntime>>>,
tauri_handle: Option<TauriHandle>,
) -> Result<(
tokio::task::JoinHandle<()>,
::tokio::sync::watch::Receiver<Vec<SellerStatus>>,
)> {
let (tx, rx) = ::tokio::sync::watch::channel(Vec::new());
let rendezvous_nodes: Vec<_> = rendezvous_points
.iter()
.filter_map(|addr| addr.split_peer_id())
.collect();
let fetch_fn = list_sellers_init(
rendezvous_nodes,
namespace,
tor_client,
identity,
db,
tauri_handle,
Some(tx.clone()),
sellers,
)
.await?;
let handle = tokio::task::spawn(async move {
loop {
let sellers = fetch_fn().await;
let _ = tx.send(sellers);
tokio::time::sleep(std::time::Duration::from_secs(90)).await;
}
});
Ok((handle, rx))
}
// TODO: Let this take a refresh interval as an argument
pub async fn refresh_wallet_task<FMG, TMG, FB, TB, FS, TS>(
max_giveable_fn: FMG,
balance_fn: FB,
sync_fn: FS,
) -> Result<(
tokio::task::JoinHandle<()>,
::tokio::sync::watch::Receiver<(bitcoin::Amount, bitcoin::Amount)>,
)>
where
TMG: Future<Output = Result<(bitcoin::Amount, bitcoin::Amount)>> + Send + 'static,
FMG: Fn() -> TMG + Send + 'static,
TB: Future<Output = Result<bitcoin::Amount>> + Send + 'static,
FB: Fn() -> TB + Send + 'static,
TS: Future<Output = Result<()>> + Send + 'static,
FS: Fn() -> TS + Send + 'static,
{
let (tx, rx) = ::tokio::sync::watch::channel((bitcoin::Amount::ZERO, bitcoin::Amount::ZERO));
let handle = tokio::task::spawn(async move {
loop {
// Sync wallet before checking balance
let _ = sync_fn().await;
if let (Ok(balance), Ok((max_giveable, _fee))) =
(balance_fn().await, max_giveable_fn().await)
{
let _ = tx.send((balance, max_giveable));
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
});
Ok((handle, rx))
}
#[allow(clippy::too_many_arguments)]
pub async fn determine_btc_to_swap<FB, TB, FMG, TMG, FS, TS>(
json: bool,
bid_quote: BidQuote,
pub async fn determine_btc_to_swap<FB, TB, FMG, TMG, FS, TS, FQ>(
quote_fetch_tasks: FQ,
// TODO: Shouldn't this be a function?
get_new_address: impl Future<Output = Result<bitcoin::Address>>,
balance: FB,
max_giveable_fn: FMG,
sync: FS,
event_emitter: Option<TauriHandle>,
swap_id: Option<Uuid>,
) -> Result<(bitcoin::Amount, bitcoin::Amount)>
swap_id: Uuid,
request_approval: impl Fn(QuoteWithAddress) -> Box<dyn Future<Output = Result<bool>> + Send>,
) -> Result<(
Multiaddr,
PeerId,
BidQuote,
bitcoin::Amount,
bitcoin::Amount,
)>
where
TB: Future<Output = Result<bitcoin::Amount>>,
FB: Fn() -> TB,
TMG: Future<Output = Result<(bitcoin::Amount, bitcoin::Amount)>>,
FMG: Fn() -> TMG,
TS: Future<Output = Result<()>>,
FS: Fn() -> TS,
TB: Future<Output = Result<bitcoin::Amount>> + Send + 'static,
FB: Fn() -> TB + Send + 'static,
TMG: Future<Output = Result<(bitcoin::Amount, bitcoin::Amount)>> + Send + 'static,
FMG: Fn() -> TMG + Send + 'static,
TS: Future<Output = Result<()>> + Send + 'static,
FS: Fn() -> TS + Send + 'static,
FQ: Fn() -> std::pin::Pin<
Box<
dyn Future<
Output = Result<(
tokio::task::JoinHandle<()>,
::tokio::sync::watch::Receiver<Vec<SellerStatus>>,
)>,
> + Send,
>,
>,
{
if bid_quote.max_quantity == bitcoin::Amount::ZERO {
bail!(ZeroQuoteReceived)
}
// Start background tasks with watch channels
let (quote_fetch_handle, mut quotes_rx): (
_,
::tokio::sync::watch::Receiver<Vec<SellerStatus>>,
) = quote_fetch_tasks().await?;
let (wallet_refresh_handle, mut balance_rx): (
_,
::tokio::sync::watch::Receiver<(bitcoin::Amount, bitcoin::Amount)>,
) = refresh_wallet_task(max_giveable_fn, balance, sync).await?;
tracing::info!(
price = %bid_quote.price,
minimum_amount = %bid_quote.min_quantity,
maximum_amount = %bid_quote.max_quantity,
"Received quote",
);
// Get the abort handles to kill the background tasks when we exit the function
let quote_fetch_abort_handle = AbortOnDropHandle::new(quote_fetch_handle);
let wallet_refresh_abort_handle = AbortOnDropHandle::new(wallet_refresh_handle);
sync().await.context("Failed to sync of Bitcoin wallet")?;
let (mut max_giveable, mut spending_fee) = max_giveable_fn().await?;
let mut pending_approvals = FuturesUnordered::new();
if max_giveable == bitcoin::Amount::ZERO || max_giveable < bid_quote.min_quantity {
let deposit_address = get_new_address.await?;
let minimum_amount = bid_quote.min_quantity;
let maximum_amount = bid_quote.max_quantity;
let deposit_address = get_new_address.await?;
// To avoid any issus, we clip maximum_amount to never go above the
// total maximim Bitcoin supply
let maximum_amount = maximum_amount.min(bitcoin::Amount::MAX_MONEY);
loop {
// Get the latest quotes, balance and max_giveable
let quotes = quotes_rx.borrow().clone();
let (balance, max_giveable) = *balance_rx.borrow();
if !json {
eprintln!("{}", qr_code(&deposit_address)?);
}
let success_quotes = quotes
.iter()
.filter_map(|quote| match quote {
SellerStatus::Online(quote_with_address) => Some(quote_with_address.clone()),
SellerStatus::Unreachable(_) => None,
})
.collect::<Vec<_>>();
loop {
let min_outstanding = bid_quote.min_quantity - max_giveable;
let min_bitcoin_lock_tx_fee = spending_fee;
let min_deposit_until_swap_will_start = min_outstanding + min_bitcoin_lock_tx_fee;
let max_deposit_until_maximum_amount_is_reached = maximum_amount
.checked_sub(max_giveable)
.context("Overflow when subtracting max_giveable from maximum_amount")?
.checked_add(min_bitcoin_lock_tx_fee)
.context(format!("Overflow when adding min_bitcoin_lock_tx_fee ({min_bitcoin_lock_tx_fee}) to max_giveable ({max_giveable}) with maximum_amount ({maximum_amount})"))?;
// Emit a Tauri event
event_emitter.emit_swap_progress_event(
swap_id,
TauriSwapProgressEvent::WaitingForBtcDeposit {
deposit_address: deposit_address.clone(),
max_giveable: max_giveable,
min_bitcoin_lock_tx_fee: balance - max_giveable,
known_quotes: success_quotes.clone(),
},
);
tracing::info!(
"Deposit at least {} to cover the min quantity with fee!",
min_deposit_until_swap_will_start
);
tracing::info!(
%deposit_address,
%min_deposit_until_swap_will_start,
%max_deposit_until_maximum_amount_is_reached,
%max_giveable,
%minimum_amount,
%maximum_amount,
%min_bitcoin_lock_tx_fee,
price = %bid_quote.price,
"Waiting for Bitcoin deposit",
);
// Iterate through quotes and find ones that match the balance and max_giveable
let matching_quotes = success_quotes
.iter()
.filter_map(|quote_with_address| {
let quote = quote_with_address.quote;
if let Some(swap_id) = swap_id {
event_emitter.emit_swap_progress_event(
swap_id,
TauriSwapProgressEvent::WaitingForBtcDeposit {
deposit_address: deposit_address.clone(),
max_giveable,
min_deposit_until_swap_will_start,
max_deposit_until_maximum_amount_is_reached,
min_bitcoin_lock_tx_fee,
quote: bid_quote,
},
);
}
if quote.min_quantity <= max_giveable && quote.max_quantity > bitcoin::Amount::ZERO
{
let tx_lock_fee = balance - max_giveable;
let tx_lock_amount = std::cmp::min(max_giveable, quote.max_quantity);
(max_giveable, spending_fee) = loop {
sync()
.await
.context("Failed to sync Bitcoin wallet while waiting for deposit")?;
let (new_max_givable, new_fee) = max_giveable_fn().await?;
if new_max_givable > max_giveable {
break (new_max_givable, new_fee);
Some((quote_with_address.clone(), tx_lock_amount, tx_lock_fee))
} else {
None
}
})
.collect::<Vec<_>>();
tokio::time::sleep(Duration::from_secs(1)).await;
};
// Put approval requests into FuturesUnordered
for (quote, tx_lock_amount, tx_lock_fee) in matching_quotes {
let future = request_approval(quote.clone());
let new_balance = balance().await?;
tracing::info!(%new_balance, %max_giveable, "Received Bitcoin");
pending_approvals.push(async move {
use std::pin::Pin;
let pinned_future = Pin::from(future);
let approved = pinned_future.await?;
if max_giveable < bid_quote.min_quantity {
tracing::info!("Deposited amount is not enough to cover `min_quantity` when accounting for network fees");
continue;
}
break;
if approved {
Ok::<
Option<(
Multiaddr,
PeerId,
BidQuote,
bitcoin::Amount,
bitcoin::Amount,
)>,
anyhow::Error,
>(Some((
quote.multiaddr.clone(),
quote.peer_id.clone(),
quote.quote.clone(),
tx_lock_amount,
tx_lock_fee,
)))
} else {
Ok::<
Option<(
Multiaddr,
PeerId,
BidQuote,
bitcoin::Amount,
bitcoin::Amount,
)>,
anyhow::Error,
>(None)
}
});
}
};
let balance = balance().await?;
let fees = balance - max_giveable;
let max_accepted = bid_quote.max_quantity;
let btc_swap_amount = min(max_giveable, max_accepted);
tracing::info!(
swap_id = ?swap_id,
pending_approvals = ?pending_approvals.len(),
balance = ?balance,
max_giveable = ?max_giveable,
quotes = ?quotes,
"Waiting for user to select an offer"
);
Ok((btc_swap_amount, fees))
// Listen for approvals, balance changes, or quote changes
let result: Option<(
Multiaddr,
PeerId,
BidQuote,
bitcoin::Amount,
bitcoin::Amount,
)> = tokio::select! {
// Any approval request completes
approval_result = pending_approvals.next(), if !pending_approvals.is_empty() => {
match approval_result {
Some(Ok(Some(result))) => Some(result),
Some(Ok(None)) => None, // User rejected
Some(Err(_)) => None, // Error in approval
None => None, // No more futures
}
}
// Balance changed - drop all pending approval requests and and re-calculate
_ = balance_rx.changed() => {
pending_approvals.clear();
None
}
// Quotes changed - drop all pending approval requests and re-calculate
_ = quotes_rx.changed() => {
pending_approvals.clear();
None
}
};
// If user accepted an offer, return it to start the swap
if let Some((multiaddr, peer_id, quote, tx_lock_amount, tx_lock_fee)) = result {
quote_fetch_abort_handle.abort();
wallet_refresh_abort_handle.abort();
return Ok((multiaddr, peer_id, quote, tx_lock_amount, tx_lock_fee));
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
#[typeshare]

View file

@ -1,5 +1,6 @@
use super::request::BalanceResponse;
use crate::bitcoin;
use crate::cli::list_sellers::QuoteWithAddress;
use crate::monero::MoneroAddressPool;
use crate::{bitcoin::ExpiredTimelocks, monero, network::quote::BidQuote};
use anyhow::{anyhow, bail, Context, Result};
@ -9,12 +10,11 @@ use monero_rpc_pool::pool::PoolStatus;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt::Display;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use strum::Display;
use tokio::sync::{oneshot, Mutex as TokioMutex};
use tokio::sync::oneshot;
use typeshare::typeshare;
use uuid::Uuid;
@ -51,6 +51,17 @@ pub struct LockBitcoinDetails {
pub swap_id: Uuid,
}
#[typeshare]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SelectMakerDetails {
#[typeshare(serialized_as = "string")]
pub swap_id: Uuid,
#[typeshare(serialized_as = "number")]
#[serde(with = "::bitcoin::amount::serde::as_sat")]
pub btc_amount_to_swap: bitcoin::Amount,
pub maker: QuoteWithAddress,
}
#[typeshare]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", content = "content")]
@ -75,6 +86,9 @@ pub enum ApprovalRequestType {
/// Request approval before locking Bitcoin.
/// Contains specific details for review.
LockBitcoin(LockBitcoinDetails),
/// Request approval for maker selection.
/// Contains available makers and swap details.
SelectMaker(SelectMakerDetails),
/// Request seed selection from user.
/// User can choose between random seed or provide their own.
SeedSelection,
@ -101,6 +115,15 @@ struct PendingApproval {
expiration_ts: u64,
}
impl Drop for PendingApproval {
fn drop(&mut self) {
if let Some(responder) = self.responder.take() {
tracing::debug!("Dropping pending approval because handle was dropped");
let _ = responder.send(serde_json::Value::Bool(false));
}
}
}
#[typeshare]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TorBootstrapStatus {
@ -112,7 +135,7 @@ pub struct TorBootstrapStatus {
#[cfg(feature = "tauri")]
struct TauriHandleInner {
app_handle: tauri::AppHandle,
pending_approvals: TokioMutex<HashMap<Uuid, PendingApproval>>,
pending_approvals: Arc<Mutex<HashMap<Uuid, PendingApproval>>>,
}
#[derive(Clone)]
@ -131,7 +154,7 @@ impl TauriHandle {
#[cfg(feature = "tauri")]
Arc::new(TauriHandleInner {
app_handle: tauri_handle,
pending_approvals: TokioMutex::new(HashMap::new()),
pending_approvals: Arc::new(Mutex::new(HashMap::new())),
}),
)
}
@ -149,6 +172,7 @@ impl TauriHandle {
/// Helper to emit a approval event via the unified event name
fn emit_approval(&self, event: ApprovalRequest) {
tracing::debug!(?event, "Emitting approval event");
self.emit_unified_event(TauriEvent::Approval(event))
}
@ -175,7 +199,7 @@ impl TauriHandle {
let timeout_secs = timeout_secs.unwrap_or(60 * 60 * 24 * 7);
let expiration_ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.map_err(|e| anyhow!("Failed to get current time: {}", e))?
.as_secs()
+ timeout_secs;
let request = ApprovalRequest {
@ -188,7 +212,6 @@ impl TauriHandle {
self.emit_approval(request.clone());
tracing::debug!(%request, "Emitted approval request event");
// Construct the data structure we use to internally track the approval request
let (responder, receiver) = oneshot::channel();
@ -198,17 +221,28 @@ impl TauriHandle {
responder: Some(responder),
expiration_ts: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.map_err(|e| anyhow!("Failed to get current time: {}", e))?
.as_secs()
+ timeout_secs,
};
// Lock map and insert the pending approval
{
let mut pending_map = self.0.pending_approvals.lock().await;
pending_map.insert(request.request_id, pending);
let mut pending_map = self
.0
.pending_approvals
.lock()
.map_err(|e| anyhow!("Failed to acquire approval lock: {}", e))?;
pending_map.insert(request_id, pending);
}
// Create cleanup guard to handle cancellation
let mut cleanup_guard = ApprovalCleanupGuard::new(
request_id,
self.clone(),
self.0.pending_approvals.clone(),
);
// Determine if the request will be accepted or rejected
// Either by being resolved by the user, or by timing out
let unparsed_response = tokio::select! {
@ -223,14 +257,18 @@ impl TauriHandle {
let response: Result<Response> = serde_json::from_value(unparsed_response.clone())
.context("Failed to parse approval response to expected type");
let mut map = self.0.pending_approvals.lock().await;
if let Some(_pending) = map.remove(&request.request_id) {
let mut map = self
.0
.pending_approvals
.lock()
.map_err(|e| anyhow!("Failed to acquire approval lock: {}", e))?;
if let Some(_pending) = map.remove(&request_id) {
let status = if response.is_ok() {
RequestStatus::Resolved {
approve_input: unparsed_response,
}
} else {
RequestStatus::Rejected {}
RequestStatus::Rejected
};
let mut approval = request.clone();
@ -260,15 +298,19 @@ impl TauriHandle {
#[cfg(feature = "tauri")]
{
let mut pending_map = self.0.pending_approvals.lock().await;
if let Some(pending) = pending_map.get_mut(&request_id) {
let _ = pending
.responder
.take()
.context("Approval responder was already consumed")?
.send(response);
Ok(())
let mut pending_map = self
.0
.pending_approvals
.lock()
.map_err(|e| anyhow!("Failed to acquire approval lock: {}", e))?;
if let Some(mut pending) = pending_map.remove(&request_id) {
// Send response through oneshot channel
if let Some(responder) = pending.responder.take() {
let _ = responder.send(response);
Ok(())
} else {
Err(anyhow!("Approval responder was already consumed"))
}
} else {
Err(anyhow!("Approval not found or already handled"))
}
@ -280,6 +322,7 @@ impl Display for ApprovalRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.request {
ApprovalRequestType::LockBitcoin(..) => write!(f, "LockBitcoin()"),
ApprovalRequestType::SelectMaker(..) => write!(f, "SelectMaker()"),
ApprovalRequestType::SeedSelection => write!(f, "SeedSelection()"),
}
}
@ -293,6 +336,12 @@ pub trait TauriEmitter {
timeout_secs: u64,
) -> Result<bool>;
async fn request_maker_selection(
&self,
details: SelectMakerDetails,
timeout_secs: u64,
) -> Result<bool>;
async fn request_seed_selection(&self) -> Result<SeedChoice>;
fn emit_tauri_event<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()>;
@ -375,6 +424,20 @@ impl TauriEmitter for TauriHandle {
.unwrap_or(false))
}
async fn request_maker_selection(
&self,
details: SelectMakerDetails,
timeout_secs: u64,
) -> Result<bool> {
Ok(self
.request_approval(
ApprovalRequestType::SelectMaker(details),
Some(timeout_secs),
)
.await
.unwrap_or(false))
}
async fn request_seed_selection(&self) -> Result<SeedChoice> {
self.request_approval(ApprovalRequestType::SeedSelection, None)
.await
@ -431,6 +494,17 @@ impl TauriEmitter for Option<TauriHandle> {
}
}
async fn request_maker_selection(
&self,
details: SelectMakerDetails,
timeout_secs: u64,
) -> Result<bool> {
match self {
Some(tauri) => tauri.request_maker_selection(details, timeout_secs).await,
None => bail!("No Tauri handle available"),
}
}
async fn request_seed_selection(&self) -> Result<SeedChoice> {
match self {
Some(tauri) => tauri.request_seed_selection().await,
@ -648,14 +722,8 @@ pub enum TauriSwapProgressEvent {
max_giveable: bitcoin::Amount,
#[typeshare(serialized_as = "number")]
#[serde(with = "::bitcoin::amount::serde::as_sat")]
min_deposit_until_swap_will_start: bitcoin::Amount,
#[typeshare(serialized_as = "number")]
#[serde(with = "::bitcoin::amount::serde::as_sat")]
max_deposit_until_maximum_amount_is_reached: bitcoin::Amount,
#[typeshare(serialized_as = "number")]
#[serde(with = "::bitcoin::amount::serde::as_sat")]
min_bitcoin_lock_tx_fee: bitcoin::Amount,
quote: BidQuote,
known_quotes: Vec<QuoteWithAddress>,
},
SwapSetupInflight {
#[typeshare(serialized_as = "number")]
@ -795,3 +863,48 @@ pub struct ListSellersProgress {
pub quotes_received: u32,
pub quotes_failed: u32,
}
// Add this struct before the TauriHandle implementation
struct ApprovalCleanupGuard {
request_id: Option<Uuid>,
approval_store: Arc<Mutex<HashMap<Uuid, PendingApproval>>>,
handle: TauriHandle,
}
impl ApprovalCleanupGuard {
fn new(
request_id: Uuid,
handle: TauriHandle,
approval_store: Arc<Mutex<HashMap<Uuid, PendingApproval>>>,
) -> Self {
Self {
request_id: Some(request_id),
handle,
approval_store,
}
}
/// Disarm the guard so it won't cleanup on drop (call when normally resolved)
fn disarm(&mut self) {
self.request_id = None;
}
}
impl Drop for ApprovalCleanupGuard {
fn drop(&mut self) {
if let Some(request_id) = self.request_id {
tracing::debug!(%request_id, "Approval handle dropped, we should cleanup now");
// Lock the Mutex
if let Ok(mut approval_store) = self.approval_store.lock() {
// Check if the request id still present in the map
if let Some(mut pending_approval) = approval_store.remove(&request_id) {
// If there is still someone listening, send a rejection
if let Some(responder) = pending_approval.responder.take() {
let _ = responder.send(serde_json::Value::Bool(false));
}
}
}
}
}
}

View file

@ -89,7 +89,8 @@ where
);
BuyXmrArgs {
seller,
rendezvous_points: vec![],
sellers: vec![seller],
bitcoin_change_address,
monero_receive_pool,
}

View file

@ -2,6 +2,7 @@ use crate::cli::api::tauri_bindings::{
ListSellersProgress, TauriBackgroundProgress, TauriBackgroundProgressHandle, TauriEmitter,
TauriHandle,
};
use crate::libp2p_ext::MultiAddrExt;
use crate::network::quote::BidQuote;
use crate::network::rendezvous::XmrBtcNamespace;
use crate::network::{quote, swarm};
@ -16,7 +17,7 @@ use libp2p::swarm::dial_opts::DialOpts;
use libp2p::swarm::{NetworkBehaviour, SwarmEvent};
use libp2p::{identity, ping, rendezvous, Multiaddr, PeerId, Swarm};
use semver::Version;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DisplayFromStr};
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
@ -32,6 +33,102 @@ fn build_identify_config(identity: identity::Keypair) -> identify::Config {
identify::Config::new(protocol_version, identity.public()).with_agent_version(agent_version)
}
/// Returns a function that when called will return sorted list of sellers, with [Online](Status::Online) listed first.
///
/// First uses the rendezvous node to discover peers in the given namespace,
/// then fetches a quote from each peer that was discovered. If fetching a quote
/// from a discovered peer fails the seller's status will be
/// [Unreachable](Status::Unreachable).
///
/// If a database is provided, it will be used to get the list of peers that
/// have already been discovered previously and attempt to fetch a quote from them.
pub async fn list_sellers_init(
rendezvous_points: Vec<(PeerId, Multiaddr)>,
namespace: XmrBtcNamespace,
maybe_tor_client: Option<Arc<TorClient<TokioRustlsRuntime>>>,
identity: identity::Keypair,
db: Option<Arc<dyn Database + Send + Sync>>,
tauri_handle: Option<TauriHandle>,
sender: Option<::tokio::sync::watch::Sender<Vec<SellerStatus>>>,
sellers: Vec<Multiaddr>,
) -> Result<
impl Fn() -> std::pin::Pin<
Box<dyn std::future::Future<Output = Vec<SellerStatus>> + Send + 'static>,
> + Send
+ Sync
+ 'static,
> {
// Capture variables needed to build an EventLoop on each invocation
let rendezvous_points_clone = rendezvous_points.clone();
let namespace_clone = namespace;
let maybe_tor_client_clone = maybe_tor_client.clone();
let identity_clone = identity.clone();
let db_clone = db.clone();
let tauri_handle_clone = tauri_handle.clone();
let sellers_clone = sellers.clone();
Ok(move || {
// Clone captured values inside the closure to avoid moving them and thus implement `Fn`
let rendezvous_points = rendezvous_points_clone.clone();
let namespace = namespace_clone;
let maybe_tor_client = maybe_tor_client_clone.clone();
let identity = identity_clone.clone();
let db = db_clone.clone();
let tauri_handle = tauri_handle_clone.clone();
let sender = sender.clone();
let sellers = sellers_clone.clone();
Box::pin(async move {
// Build a fresh swarm and event loop for every call so the closure can be invoked multiple times.
let behaviour = Behaviour {
rendezvous: rendezvous::client::Behaviour::new(identity.clone()),
quote: quote::cli(),
ping: ping::Behaviour::new(
ping::Config::new().with_timeout(Duration::from_secs(60)),
),
identify: identify::Behaviour::new(build_identify_config(identity.clone())),
};
// TODO: Dont use unwrap
let swarm = swarm::cli(identity, maybe_tor_client, behaviour)
.await
.unwrap();
// Get peers from the database, add them to the dial queue
let mut external_dial_queue = match db {
Some(db) => match db.get_all_peer_addresses().await {
Ok(peers) => VecDeque::from(peers),
Err(err) => {
tracing::error!(%err, "Failed to get peers from database for list_sellers");
VecDeque::new()
}
},
None => VecDeque::new(),
};
// Get peers the user has manually passed in, add them to the dial queue
for seller_addr in sellers {
if let Some((peer_id, multiaddr)) = seller_addr.split_peer_id() {
external_dial_queue.push_back((peer_id, vec![multiaddr]));
}
}
let event_loop = EventLoop::new(
swarm,
rendezvous_points,
namespace,
external_dial_queue,
tauri_handle,
);
event_loop.run(sender).await
})
as std::pin::Pin<
Box<dyn std::future::Future<Output = Vec<SellerStatus>> + Send + 'static>,
>
})
}
/// Returns sorted list of sellers, with [Online](Status::Online) listed first.
///
/// First uses the rendezvous node to discover peers in the given namespace,
@ -49,38 +146,23 @@ pub async fn list_sellers(
db: Option<Arc<dyn Database + Send + Sync>>,
tauri_handle: Option<TauriHandle>,
) -> Result<Vec<SellerStatus>> {
let behaviour = Behaviour {
rendezvous: rendezvous::client::Behaviour::new(identity.clone()),
quote: quote::cli(),
ping: ping::Behaviour::new(ping::Config::new().with_timeout(Duration::from_secs(60))),
identify: identify::Behaviour::new(build_identify_config(identity.clone())),
};
let swarm = swarm::cli(identity, maybe_tor_client, behaviour).await?;
// If a database is passed in: Fetch all peer addresses from the database and fetch quotes from them
let external_dial_queue = match db {
Some(db) => {
let peers = db.get_all_peer_addresses().await?;
VecDeque::from(peers)
}
None => VecDeque::new(),
};
let event_loop = EventLoop::new(
swarm,
let fetch_fn = list_sellers_init(
rendezvous_points,
namespace,
external_dial_queue,
maybe_tor_client,
identity,
db,
tauri_handle,
);
let sellers = event_loop.run().await;
Ok(sellers)
None,
Vec::new(),
)
.await?;
Ok(fetch_fn().await)
}
#[serde_as]
#[typeshare]
#[derive(Debug, Serialize, PartialEq, Eq, Hash, Clone, Ord, PartialOrd)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Ord, PartialOrd)]
pub struct QuoteWithAddress {
/// The multiaddr of the seller (at which we were able to connect to and get the quote from)
#[serde_as(as = "DisplayFromStr")]
@ -578,7 +660,49 @@ impl EventLoop {
}
}
async fn run(mut self) -> Vec<SellerStatus> {
fn build_current_sellers(&self) -> Vec<SellerStatus> {
let mut sellers: Vec<SellerStatus> = self
.peer_states
.values()
.filter_map(|peer_state| match peer_state {
PeerState::Complete {
peer_id,
version,
quote,
reachable_addresses,
} => Some(SellerStatus::Online(QuoteWithAddress {
peer_id: *peer_id,
multiaddr: reachable_addresses[0].clone(),
quote: *quote,
version: version.clone(),
})),
PeerState::Failed { peer_id, .. } => {
Some(SellerStatus::Unreachable(UnreachableSeller {
peer_id: *peer_id,
}))
}
_ => None, // Skip pending states for partial updates
})
.collect();
sellers.sort();
sellers
}
fn emit_partial_update(
&self,
sender: &Option<::tokio::sync::watch::Sender<Vec<SellerStatus>>>,
) {
if let Some(sender) = sender {
let current_sellers = self.build_current_sellers();
let _ = sender.send(current_sellers);
}
}
async fn run(
mut self,
sender: Option<::tokio::sync::watch::Sender<Vec<SellerStatus>>>,
) -> Vec<SellerStatus> {
// Dial all rendezvous points initially
for (peer_id, multiaddr) in &self.rendezvous_points {
let dial_opts = DialOpts::peer_id(*peer_id)
@ -787,6 +911,8 @@ impl EventLoop {
// If we have pending request to rendezvous points or quote requests, we continue
if !all_rendezvous_points_requests_complete || !all_quotes_fetched {
// Emit partial update with any completed quotes we have so far
self.emit_partial_update(&sender);
continue;
}