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", "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]] [[package]]
name = "quick-error" name = "quick-error"
version = "1.2.3" version = "1.2.3"
@ -9823,7 +9814,6 @@ dependencies = [
"once_cell", "once_cell",
"pem", "pem",
"proptest", "proptest",
"qrcode",
"rand 0.8.5", "rand 0.8.5",
"rand_chacha 0.3.1", "rand_chacha 0.3.1",
"regex", "regex",
@ -10806,6 +10796,8 @@ dependencies = [
"futures-core", "futures-core",
"futures-io", "futures-io",
"futures-sink", "futures-sink",
"futures-util",
"hashbrown 0.15.4",
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
] ]

View file

@ -32,6 +32,7 @@
"@tauri-apps/plugin-store": "^2.0.0", "@tauri-apps/plugin-store": "^2.0.0",
"@tauri-apps/plugin-updater": "2.7.1", "@tauri-apps/plugin-updater": "2.7.1",
"@types/react-redux": "^7.1.34", "@types/react-redux": "^7.1.34",
"boring-avatars": "^1.11.2",
"humanize-duration": "^3.32.1", "humanize-duration": "^3.32.1",
"jdenticon": "^3.3.0", "jdenticon": "^3.3.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",

View file

@ -4,6 +4,8 @@ import {
ExpiredTimelocks, ExpiredTimelocks,
GetSwapInfoResponse, GetSwapInfoResponse,
PendingCompleted, PendingCompleted,
QuoteWithAddress,
SelectMakerDetails,
TauriBackgroundProgress, TauriBackgroundProgress,
TauriSwapProgressEvent, TauriSwapProgressEvent,
} from "./tauriModel"; } from "./tauriModel";
@ -303,3 +305,49 @@ export function isBitcoinSyncProgress(
): progress is TauriBitcoinSyncProgress { ): progress is TauriBitcoinSyncProgress {
return progress.componentName === "SyncingBitcoinWallet"; 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 TruncatedText from "../../other/TruncatedText";
import { SwapMoneroRecoveryButton } from "../../pages/history/table/SwapMoneroRecoveryButton"; import { SwapMoneroRecoveryButton } from "../../pages/history/table/SwapMoneroRecoveryButton";
import { TimelockTimeline } from "./TimelockTimeline"; import { TimelockTimeline } from "./TimelockTimeline";
import { useIsSpecificSwapRunning } from "store/hooks";
/** /**
* Component for displaying a list of messages. * 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({ export default function SwapStatusAlert({
swap, swap,
isRunning,
onlyShowIfUnusualAmountOfTimeHasPassed, onlyShowIfUnusualAmountOfTimeHasPassed,
}: { }: {
swap: GetSwapInfoResponseExt; swap: GetSwapInfoResponseExt;
isRunning: boolean;
onlyShowIfUnusualAmountOfTimeHasPassed?: boolean; onlyShowIfUnusualAmountOfTimeHasPassed?: boolean;
}) { }) {
if (swap == null) {
return null;
}
// If the swap is completed, we do not need to display anything // If the swap is completed, we do not need to display anything
if (!isGetSwapInfoResponseRunningSwap(swap)) { if (!isGetSwapInfoResponseRunningSwap(swap)) {
return null; return null;
@ -250,16 +253,18 @@ export default function SwapStatusAlert({
return null; 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 const hasUnusualAmountOfTimePassed =
if (
onlyShowIfUnusualAmountOfTimeHasPassed &&
swap.timelock.type === "None" && swap.timelock.type === "None" &&
swap.timelock.content.blocks_left > 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; return null;
} }
const isRunning = useIsSpecificSwapRunning(swap.swap_id);
return ( return (
<Alert <Alert
key={swap.swap_id} key={swap.swap_id}
@ -274,7 +279,11 @@ export default function SwapStatusAlert({
> >
<AlertTitle> <AlertTitle>
{isRunning ? ( {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 Swap <TruncatedText>{swap.swap_id}</TruncatedText> is not running

View file

@ -11,7 +11,7 @@ export default function SwapTxLockAlertsBox() {
return ( return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{swaps.map((swap) => ( {swaps.map((swap) => (
<SwapStatusAlert key={swap.swap_id} swap={swap} isRunning={false} /> <SwapStatusAlert key={swap.swap_id} swap={swap} />
))} ))}
</Box> </Box>
); );

View file

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

View file

@ -5,7 +5,13 @@ import {
DialogContent, DialogContent,
DialogContentText, DialogContentText,
DialogTitle, DialogTitle,
List,
ListItem,
ListItemIcon,
ListItemText,
Typography,
} from "@mui/material"; } from "@mui/material";
import CircleIcon from "@mui/icons-material/Circle";
import { suspendCurrentSwap } from "renderer/rpc"; import { suspendCurrentSwap } from "renderer/rpc";
import PromiseInvokeButton from "../PromiseInvokeButton"; import PromiseInvokeButton from "../PromiseInvokeButton";
@ -20,10 +26,42 @@ export default function SwapSuspendAlert({
}: SwapCancelAlertProps) { }: SwapCancelAlertProps) {
return ( return (
<Dialog open={open} onClose={onClose}> <Dialog open={open} onClose={onClose}>
<DialogTitle>Force stop running operation?</DialogTitle> <DialogTitle>Suspend running swap?</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText component="div">
Are you sure you want to force stop the running swap? <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> </DialogContentText>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
@ -35,7 +73,7 @@ export default function SwapSuspendAlert({
onSuccess={onClose} onSuccess={onClose}
onInvoke={suspendCurrentSwap} onInvoke={suspendCurrentSwap}
> >
Force stop Suspend
</PromiseInvokeButton> </PromiseInvokeButton>
</DialogActions> </DialogActions>
</Dialog> </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 { import { Box, Dialog, DialogActions, DialogContent } from "@mui/material";
Box,
Button,
Dialog,
DialogActions,
DialogContent,
} from "@mui/material";
import { useState } from "react"; import { useState } from "react";
import { swapReset } from "store/features/swapSlice"; import { useAppSelector } from "store/hooks";
import { useAppDispatch, useAppSelector, useIsSwapRunning } from "store/hooks";
import SwapSuspendAlert from "../SwapSuspendAlert";
import DebugPage from "./pages/DebugPage"; import DebugPage from "./pages/DebugPage";
import SwapStatePage from "./pages/SwapStatePage"; import SwapStatePage from "renderer/components/pages/swap/swap/SwapStatePage";
import SwapDialogTitle from "./SwapDialogTitle"; import SwapDialogTitle from "./SwapDialogTitle";
import SwapStateStepper from "./SwapStateStepper"; import SwapStateStepper from "./SwapStateStepper";
import CancelButton from "renderer/components/pages/swap/swap/CancelButton";
export default function SwapDialog({ export default function SwapDialog({
open, open,
@ -22,26 +15,13 @@ export default function SwapDialog({
onClose: () => void; onClose: () => void;
}) { }) {
const swap = useAppSelector((state) => state.swap); const swap = useAppSelector((state) => state.swap);
const isSwapRunning = useIsSwapRunning();
const [debug, setDebug] = useState(false); 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 // This prevents an issue where the Dialog is shown for a split second without a present swap state
if (!open) return null; if (!open) return null;
return ( return (
<Dialog open={open} onClose={onCancel} maxWidth="md" fullWidth> <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<SwapDialogTitle <SwapDialogTitle
debug={debug} debug={debug}
setDebug={setDebug} setDebug={setDebug}
@ -78,23 +58,8 @@ export default function SwapDialog({
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onCancel} variant="text"> <CancelButton />
Cancel
</Button>
<Button
color="primary"
variant="contained"
onClick={onCancel}
disabled={isSwapRunning || swap.state === null}
>
Done
</Button>
</DialogActions> </DialogActions>
<SwapSuspendAlert
open={openSuspendAlert}
onClose={() => setOpenSuspendAlert(false)}
/>
</Dialog> </Dialog>
); );
} }

View file

@ -1,7 +1,6 @@
import { Box, DialogTitle, Typography } from "@mui/material"; import { Box, DialogTitle, Typography } from "@mui/material";
import DebugPageSwitchBadge from "./pages/DebugPageSwitchBadge"; import DebugPageSwitchBadge from "./pages/DebugPageSwitchBadge";
import FeedbackSubmitBadge from "./pages/FeedbackSubmitBadge"; import FeedbackSubmitBadge from "./pages/FeedbackSubmitBadge";
import TorStatusBadge from "./pages/TorStatusBadge";
export default function SwapDialogTitle({ export default function SwapDialogTitle({
title, title,
@ -24,7 +23,6 @@ export default function SwapDialogTitle({
<Box sx={{ display: "flex", alignItems: "center", gridGap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gridGap: 1 }}>
<FeedbackSubmitBadge /> <FeedbackSubmitBadge />
<DebugPageSwitchBadge enabled={debug} setEnabled={setDebug} /> <DebugPageSwitchBadge enabled={debug} setEnabled={setDebug} />
<TorStatusBadge />
</Box> </Box>
</DialogTitle> </DialogTitle>
); );

View file

@ -57,7 +57,7 @@ function getActiveStep(state: SwapState | null): PathStep | null {
case "ReceivedQuote": case "ReceivedQuote":
case "WaitingForBtcDeposit": case "WaitingForBtcDeposit":
case "SwapSetupInflight": case "SwapSetupInflight":
return [PathType.HAPPY_PATH, 0, isReleased]; return null; // No funds have been locked yet
// Step 1: Waiting for Bitcoin lock confirmation // Step 1: Waiting for Bitcoin lock confirmation
// Bitcoin has been locked, waiting for the counterparty to lock their XMR // 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"; import CliLogsBox from "../../../other/RenderedCliLog";
export default function DebugPage() { export default function DebugPage() {
const torStdOut = useAppSelector((s) => s.tor.stdOut);
const logs = useActiveSwapLogs(); const logs = useActiveSwapLogs();
const guiState = useAppSelector((s) => s);
const cliState = useActiveSwapInfo(); const cliState = useActiveSwapInfo();
return ( return (
<Box> <Box sx={{ padding: 2, display: "flex", flexDirection: "column", gap: 2 }}>
<DialogContentText> <DialogContentText>
<Box <Box
style={{ style={{
@ -27,18 +25,6 @@ export default function DebugPage() {
logs={logs} logs={logs}
label="Logs relevant to the swap (only current session)" 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> </Box>
</DialogContentText> </DialogContentText>
</Box> </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 { DialogContentText } from "@mui/material";
import BitcoinTransactionInfoBox from "../../swap/BitcoinTransactionInfoBox"; import BitcoinTransactionInfoBox from "renderer/components/pages/swap/swap/components/BitcoinTransactionInfoBox";
export default function BtcTxInMempoolPageContent({ export default function BtcTxInMempoolPageContent({
withdrawTxId, withdrawTxId,

View file

@ -1,8 +1,5 @@
import { Box, Button, IconButton, Tooltip } from "@mui/material"; import { Box, Button, IconButton, Tooltip } from "@mui/material";
import { import { FileCopyOutlined, QrCode as QrCodeIcon } from "@mui/icons-material";
FileCopyOutlined,
CropFree as CropFreeIcon,
} from "@mui/icons-material";
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useState } from "react"; import { useState } from "react";
import MonospaceTextBox from "./MonospaceTextBox"; import MonospaceTextBox from "./MonospaceTextBox";
@ -111,7 +108,7 @@ export default function ActionableMonospaceTextBox({
size="small" size="small"
sx={{ marginLeft: 1 }} sx={{ marginLeft: 1 }}
> >
<CropFreeIcon /> <QrCodeIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
)} )}

View file

@ -1,6 +1,6 @@
import { Box, Button, Typography } from "@mui/material"; import { Box, Button, Typography } from "@mui/material";
import { open } from "@tauri-apps/plugin-shell"; 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 = const GITHUB_ISSUE_URL =
"https://github.com/UnstoppableSwap/core/issues/new/choose"; "https://github.com/UnstoppableSwap/core/issues/new/choose";

View file

@ -27,7 +27,7 @@ import {
} from "@mui/material"; } from "@mui/material";
import ChatIcon from "@mui/icons-material/Chat"; import ChatIcon from "@mui/icons-material/Chat";
import SendIcon from "@mui/icons-material/Send"; 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 TruncatedText from "renderer/components/other/TruncatedText";
import clsx from "clsx"; import clsx from "clsx";
import { import {

View file

@ -3,8 +3,8 @@ import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import { useAppSelector } from "store/hooks"; import { useAppSelector } from "store/hooks";
import InfoBox from "../../modal/swap/InfoBox"; import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox";
import CliLogsBox from "../../other/RenderedCliLog"; import CliLogsBox from "renderer/components/other/RenderedCliLog";
import { getDataDir, initializeContext } from "renderer/rpc"; import { getDataDir, initializeContext } from "renderer/rpc";
import { relaunch } from "@tauri-apps/plugin-process"; import { relaunch } from "@tauri-apps/plugin-process";
import RotateLeftIcon from "@mui/icons-material/RotateLeft"; import RotateLeftIcon from "@mui/icons-material/RotateLeft";

View file

@ -1,5 +1,5 @@
import { Box, Typography, styled } from "@mui/material"; 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 { useSettings } from "store/hooks";
import { Search } from "@mui/icons-material"; import { Search } from "@mui/icons-material";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";

View file

@ -1,6 +1,6 @@
import { Link, Typography } from "@mui/material"; import { Link, Typography } from "@mui/material";
import MoneroIcon from "../../icons/MoneroIcon"; import MoneroIcon from "renderer/components/icons/MoneroIcon";
import DepositAddressInfoBox from "../../modal/swap/DepositAddressInfoBox"; import DepositAddressInfoBox from "renderer/components/pages/swap/swap/components/DepositAddressInfoBox";
const XMR_DONATE_ADDRESS = const XMR_DONATE_ADDRESS =
"87jS4C7ngk9EHdqFFuxGFgg8AyH63dRUoULshWDybFJaP75UA89qsutG5B1L1QTc4w228nsqsv8EjhL7bz8fB3611Mh98mg"; "87jS4C7ngk9EHdqFFuxGFgg8AyH63dRUoULshWDybFJaP75UA89qsutG5B1L1QTc4w228nsqsv8EjhL7bz8fB3611Mh98mg";

View file

@ -9,7 +9,7 @@ import {
Link, Link,
DialogContentText, DialogContentText,
} from "@mui/material"; } 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 { useState } from "react";
import { getWalletDescriptor } from "renderer/rpc"; import { getWalletDescriptor } from "renderer/rpc";
import { ExportBitcoinWalletResponse } from "models/tauriModel"; import { ExportBitcoinWalletResponse } from "models/tauriModel";

View file

@ -1,7 +1,7 @@
import { Button, Typography } from "@mui/material"; import { Button, Typography } from "@mui/material";
import { useState } from "react"; import { useState } from "react";
import FeedbackDialog from "../../modal/feedback/FeedbackDialog"; import FeedbackDialog from "renderer/components/modal/feedback/FeedbackDialog";
import InfoBox from "../../modal/swap/InfoBox"; import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox";
export default function FeedbackInfoBox() { export default function FeedbackInfoBox() {
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);

View file

@ -11,7 +11,7 @@ import {
LinearProgress, LinearProgress,
useTheme, useTheme,
} from "@mui/material"; } 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 { ReliableNodeInfo } from "models/tauriModel";
import NetworkWifiIcon from "@mui/icons-material/NetworkWifi"; import NetworkWifiIcon from "@mui/icons-material/NetworkWifi";
import { useAppSelector } from "store/hooks"; import { useAppSelector } from "store/hooks";

View file

@ -57,7 +57,7 @@ import {
import { getNetwork } from "store/config"; import { getNetwork } from "store/config";
import { currencySymbol } from "utils/formatUtils"; 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 { isValidMultiAddressWithPeerId } from "utils/parseUtils";
import { getNodeStatus } from "renderer/rpc"; import { getNodeStatus } from "renderer/rpc";
import { setStatus } from "store/features/nodesSlice"; import { setStatus } from "store/features/nodesSlice";

View file

@ -1,18 +1,13 @@
import { Typography } from "@mui/material"; import { Typography } from "@mui/material";
import { useAppSelector } from "store/hooks";
import SwapTxLockAlertsBox from "../../alert/SwapTxLockAlertsBox"; import SwapTxLockAlertsBox from "../../alert/SwapTxLockAlertsBox";
import SwapDialog from "../../modal/swap/SwapDialog";
import HistoryTable from "./table/HistoryTable"; import HistoryTable from "./table/HistoryTable";
export default function HistoryPage() { export default function HistoryPage() {
const showDialog = useAppSelector((state) => state.swap.state !== null);
return ( return (
<> <>
<Typography variant="h3">History</Typography> <Typography variant="h3">History</Typography>
<SwapTxLockAlertsBox /> <SwapTxLockAlertsBox />
<HistoryTable /> <HistoryTable />
<SwapDialog open={showDialog} onClose={() => {}} />
</> </>
); );
} }

View file

@ -12,20 +12,62 @@ import {
isBobStateNamePossiblyRefundableSwap, isBobStateNamePossiblyRefundableSwap,
} from "models/tauriModelExt"; } from "models/tauriModelExt";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; 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({ export function SwapResumeButton({
swap, swap,
children, children,
...props ...props
}: ButtonProps & { swap: GetSwapInfoResponse }) { }: 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 ( return (
<PromiseInvokeButton <PromiseInvokeButton
variant="contained" variant="contained"
color="primary" color="primary"
disabled={swap.completed} disabled={
swap.completed ||
isAlreadyRunning ||
isAnotherSwapRunningAndHasFundsLocked
}
tooltipTitle={tooltipTitle}
endIcon={<PlayArrowIcon />} endIcon={<PlayArrowIcon />}
onInvoke={() => resumeSwap(swap.swap_id)} onInvoke={resume}
{...props} {...props}
> >
{children} {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) { export default function HistoryRowActions(swap: GetSwapInfoResponse) {
if (swap.state_name === BobStateName.XmrRedeemed) { if (swap.state_name === BobStateName.XmrRedeemed) {
return ( return (

View file

@ -1,6 +1,6 @@
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import ApiAlertsBox from "./ApiAlertsBox"; import ApiAlertsBox from "./ApiAlertsBox";
import SwapWidget from "./SwapWidget"; import SwapWidget from "./swap/SwapWidget";
export default function SwapPage() { export default function SwapPage() {
return ( 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 { SwapState } from "models/storeModel";
import { TauriSwapProgressEventType } from "models/tauriModelExt"; import { TauriSwapProgressEventType } from "models/tauriModelExt";
import CircularProgressWithSubtitle from "../CircularProgressWithSubtitle"; import CircularProgressWithSubtitle from "./components/CircularProgressWithSubtitle";
import BitcoinPunishedPage from "./done/BitcoinPunishedPage"; import BitcoinPunishedPage from "./done/BitcoinPunishedPage";
import { import {
BitcoinRefundedPage, BitcoinRefundedPage,
@ -20,9 +20,10 @@ import SwapSetupInflightPage from "./in_progress/SwapSetupInflightPage";
import WaitingForXmrConfirmationsBeforeRedeemPage from "./in_progress/WaitingForXmrConfirmationsBeforeRedeemPage"; import WaitingForXmrConfirmationsBeforeRedeemPage from "./in_progress/WaitingForXmrConfirmationsBeforeRedeemPage";
import XmrLockedPage from "./in_progress/XmrLockedPage"; import XmrLockedPage from "./in_progress/XmrLockedPage";
import XmrLockTxInMempoolPage from "./in_progress/XmrLockInMempoolPage"; import XmrLockTxInMempoolPage from "./in_progress/XmrLockInMempoolPage";
import InitPage from "./init/InitPage";
import WaitingForBitcoinDepositPage from "./init/WaitingForBitcoinDepositPage";
import { exhaustiveGuard } from "utils/typescriptUtils"; 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 }) { export default function SwapStatePage({ state }: { state: SwapState | null }) {
if (state === null) { if (state === null) {
@ -41,7 +42,7 @@ export default function SwapStatePage({ state }: { state: SwapState | null }) {
case "WaitingForBtcDeposit": case "WaitingForBtcDeposit":
// This double check is necessary for the typescript compiler to infer types // This double check is necessary for the typescript compiler to infer types
if (state.curr.type === "WaitingForBtcDeposit") { if (state.curr.type === "WaitingForBtcDeposit") {
return <WaitingForBitcoinDepositPage {...state.curr.content} />; return <DepositAndChooseOfferPage {...state.curr.content} />;
} }
break; break;
case "SwapSetupInflight": 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 { Box, DialogContentText } from "@mui/material";
import FeedbackInfoBox from "../../../../pages/help/FeedbackInfoBox"; import FeedbackInfoBox from "renderer/components/pages/help/FeedbackInfoBox";
import { TauriSwapProgressEventExt } from "models/tauriModelExt"; import { TauriSwapProgressEventExt } from "models/tauriModelExt";
export default function BitcoinPunishedPage({ export default function BitcoinPunishedPage({
@ -10,7 +10,7 @@ export default function BitcoinPunishedPage({
| TauriSwapProgressEventExt<"CooperativeRedeemRejected">; | TauriSwapProgressEventExt<"CooperativeRedeemRejected">;
}) { }) {
return ( return (
<Box> <>
<DialogContentText> <DialogContentText>
Unfortunately, the swap was unsuccessful. Since you did not refund in Unfortunately, the swap was unsuccessful. Since you did not refund in
time, the Bitcoin has been lost. However, with the cooperation of the time, the Bitcoin has been lost. However, with the cooperation of the
@ -26,6 +26,6 @@ export default function BitcoinPunishedPage({
)} )}
</DialogContentText> </DialogContentText>
<FeedbackInfoBox /> <FeedbackInfoBox />
</Box> </>
); );
} }

View file

@ -1,8 +1,8 @@
import { Box, DialogContentText } from "@mui/material"; import { Box, DialogContentText } from "@mui/material";
import { TauriSwapProgressEventContent } from "models/tauriModelExt"; import { TauriSwapProgressEventContent } from "models/tauriModelExt";
import { useActiveSwapInfo } from "store/hooks"; import { useActiveSwapInfo } from "store/hooks";
import FeedbackInfoBox from "../../../../pages/help/FeedbackInfoBox"; import FeedbackInfoBox from "renderer/components/pages/help/FeedbackInfoBox";
import BitcoinTransactionInfoBox from "../../BitcoinTransactionInfoBox"; import BitcoinTransactionInfoBox from "renderer/components/pages/swap/swap/components/BitcoinTransactionInfoBox";
export function BitcoinRefundPublishedPage({ export function BitcoinRefundPublishedPage({
btc_refund_txid, btc_refund_txid,
@ -66,7 +66,7 @@ function MultiBitcoinRefundedPage({
) : null; ) : null;
return ( return (
<Box> <>
<DialogContentText> <DialogContentText>
Unfortunately, the swap was not successful. However, rest assured that Unfortunately, the swap was not successful. However, rest assured that
all your Bitcoin has been refunded to the specified address. The swap all your Bitcoin has been refunded to the specified address. The swap
@ -87,6 +87,6 @@ function MultiBitcoinRefundedPage({
/> />
<FeedbackInfoBox /> <FeedbackInfoBox />
</Box> </Box>
</Box> </>
); );
} }

View file

@ -1,7 +1,7 @@
import { Box, DialogContentText, Typography } from "@mui/material"; import { Box, DialogContentText, Typography } from "@mui/material";
import { TauriSwapProgressEventContent } from "models/tauriModelExt"; import { TauriSwapProgressEventContent } from "models/tauriModelExt";
import FeedbackInfoBox from "../../../../pages/help/FeedbackInfoBox"; import FeedbackInfoBox from "renderer/components/pages/help/FeedbackInfoBox";
import MoneroTransactionInfoBox from "../../MoneroTransactionInfoBox"; import MoneroTransactionInfoBox from "renderer/components/pages/swap/swap/components/MoneroTransactionInfoBox";
export default function XmrRedeemInMempoolPage( export default function XmrRedeemInMempoolPage(
state: TauriSwapProgressEventContent<"XmrRedeemInMempool">, state: TauriSwapProgressEventContent<"XmrRedeemInMempool">,
@ -9,7 +9,7 @@ export default function XmrRedeemInMempoolPage(
const xmr_redeem_txid = state.xmr_redeem_txids[0] ?? null; const xmr_redeem_txid = state.xmr_redeem_txids[0] ?? null;
return ( return (
<Box> <>
<DialogContentText> <DialogContentText>
The swap was successful and the Monero has been sent to the following 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. address(es). The swap is completed and you may exit the application now.
@ -77,6 +77,6 @@ export default function XmrRedeemInMempoolPage(
/> />
<FeedbackInfoBox /> <FeedbackInfoBox />
</Box> </Box>
</Box> </>
); );
} }

View file

@ -2,7 +2,7 @@ import { Box, DialogContentText } from "@mui/material";
import { TauriSwapProgressEvent } from "models/tauriModel"; import { TauriSwapProgressEvent } from "models/tauriModel";
import CliLogsBox from "renderer/components/other/RenderedCliLog"; import CliLogsBox from "renderer/components/other/RenderedCliLog";
import { useActiveSwapInfo, useActiveSwapLogs } from "store/hooks"; import { useActiveSwapInfo, useActiveSwapLogs } from "store/hooks";
import SwapStatePage from "../SwapStatePage"; import SwapStatePage from "renderer/components/pages/swap/swap/SwapStatePage";
export default function ProcessExitedPage({ export default function ProcessExitedPage({
prevState, prevState,
@ -35,7 +35,7 @@ export default function ProcessExitedPage({
} }
return ( return (
<Box> <>
<DialogContentText> <DialogContentText>
The swap was stopped but it has not been completed yet. Check the logs The swap was stopped but it has not been completed yet. Check the logs
below for more information. The current GUI state is{" "} below for more information. The current GUI state is{" "}
@ -45,6 +45,6 @@ export default function ProcessExitedPage({
<Box> <Box>
<CliLogsBox logs={logs} label="Logs relevant to the swap" /> <CliLogsBox logs={logs} label="Logs relevant to the swap" />
</Box> </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() { export default function BitcoinCancelledPage() {
return <CircularProgressWithSubtitle description="Refunding your Bitcoin" />; return <CircularProgressWithSubtitle description="Refunding your Bitcoin" />;

View file

@ -1,8 +1,6 @@
import { TauriSwapProgressEventContent } from "models/tauriModelExt"; import { TauriSwapProgressEventContent } from "models/tauriModelExt";
import { formatConfirmations } from "utils/formatUtils"; import { formatConfirmations } from "utils/formatUtils";
import BitcoinTransactionInfoBox from "../../BitcoinTransactionInfoBox"; import BitcoinTransactionInfoBox from "renderer/components/pages/swap/swap/components/BitcoinTransactionInfoBox";
import SwapStatusAlert from "renderer/components/alert/SwapStatusAlert/SwapStatusAlert";
import { useActiveSwapInfo } from "store/hooks";
import { Box, DialogContentText } from "@mui/material"; 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 // 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_confirmations,
btc_lock_txid, btc_lock_txid,
}: TauriSwapProgressEventContent<"BtcLockTxInMempool">) { }: TauriSwapProgressEventContent<"BtcLockTxInMempool">) {
const swapInfo = useActiveSwapInfo();
return ( return (
<Box> <>
{(btc_lock_confirmations === undefined || {(btc_lock_confirmations === undefined ||
btc_lock_confirmations < BITCOIN_CONFIRMATIONS_WARNING_THRESHOLD) && ( btc_lock_confirmations < BITCOIN_CONFIRMATIONS_WARNING_THRESHOLD) && (
<DialogContentText> <DialogContentText>
@ -32,10 +28,6 @@ export default function BitcoinLockTxInMempoolPage({
gap: "1rem", gap: "1rem",
}} }}
> >
{btc_lock_confirmations !== undefined &&
btc_lock_confirmations >= BITCOIN_CONFIRMATIONS_WARNING_THRESHOLD && (
<SwapStatusAlert swap={swapInfo} isRunning={true} />
)}
<BitcoinTransactionInfoBox <BitcoinTransactionInfoBox
title="Bitcoin Lock Transaction" title="Bitcoin Lock Transaction"
txId={btc_lock_txid} txId={btc_lock_txid}
@ -51,6 +43,6 @@ export default function BitcoinLockTxInMempoolPage({
} }
/> />
</Box> </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() { export default function CancelTimelockExpiredPage() {
return <CircularProgressWithSubtitle description="Cancelling the swap" />; 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"; } from "store/hooks";
import CircularProgressWithSubtitle, { import CircularProgressWithSubtitle, {
LinearProgressWithSubtitle, LinearProgressWithSubtitle,
} from "../../CircularProgressWithSubtitle"; } from "../components/CircularProgressWithSubtitle";
export default function ReceivedQuotePage() { export default function ReceivedQuotePage() {
const syncProgress = useConservativeBitcoinSyncProgress(); const syncProgress = useConservativeBitcoinSyncProgress();

View file

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

View file

@ -5,10 +5,10 @@ import {
TauriSwapProgressEventContent, TauriSwapProgressEventContent,
} from "models/tauriModelExt"; } from "models/tauriModelExt";
import { SatsAmount, PiconeroAmount } from "renderer/components/other/Units"; 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 { useActiveSwapId, usePendingLockBitcoinApproval } from "store/hooks";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import CircularProgressWithSubtitle from "../../CircularProgressWithSubtitle"; import CircularProgressWithSubtitle from "../components/CircularProgressWithSubtitle";
import CheckIcon from "@mui/icons-material/Check"; import CheckIcon from "@mui/icons-material/Check";
import ArrowRightAltIcon from "@mui/icons-material/ArrowRightAlt"; import ArrowRightAltIcon from "@mui/icons-material/ArrowRightAlt";
import TruncatedText from "renderer/components/other/TruncatedText"; 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 // Display a loading spinner to the user for as long as the swap_setup request is in flight
if (request == null) { if (request == null) {
return ( return (
<CircularProgressWithSubtitle <Box
description={ sx={{
<> height: 200,
Negotiating offer for <SatsAmount amount={btc_lock_amount} /> 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 */} {/* Grid layout for perfect alignment */}
<Box <Box
sx={{ sx={{
display: "grid", display: "flex",
gridTemplateColumns: "max-content auto max-content", flexDirection: { xs: "column", lg: "row" },
gap: "1.5rem", gap: "1.5rem",
alignItems: "stretch", alignItems: "stretch",
justifyContent: "center", justifyContent: "space-between",
}} }}
> >
{/* Row 1: Bitcoin box */} {/* Row 1: Bitcoin box */}
<Box sx={{ height: "100%" }}> <Box sx={{ height: "100%", flex: "0 0 auto" }}>
<BitcoinMainBox <BitcoinMainBox
btc_lock_amount={btc_lock_amount} btc_lock_amount={btc_lock_amount}
btc_network_fee={btc_network_fee} btc_network_fee={btc_network_fee}
@ -110,7 +119,7 @@ export default function SwapSetupInflightPage({
</Box> </Box>
{/* Row 1: Monero main box */} {/* Row 1: Monero main box */}
<Box> <Box sx={{ flex: "0 0 auto" }}>
<MoneroMainBox <MoneroMainBox
monero_receive_pool={monero_receive_pool} monero_receive_pool={monero_receive_pool}
xmr_receive_amount={xmr_receive_amount} xmr_receive_amount={xmr_receive_amount}
@ -120,38 +129,50 @@ export default function SwapSetupInflightPage({
<Box <Box
sx={{ sx={{
marginTop: 2, marginTop: 4,
display: "flex", display: "flex",
justifyContent: "center", flexDirection: "column",
gap: 2, alignItems: "center",
gap: 1.5,
}} }}
> >
<PromiseInvokeButton <Box sx={{ display: "flex", justifyContent: "center", gap: 2 }}>
variant="text" <PromiseInvokeButton
size="large" variant="text"
sx={(theme) => ({ color: theme.palette.text.secondary })} size="large"
onInvoke={() => sx={(theme) => ({ color: theme.palette.text.secondary })}
resolveApproval(request.request_id, false as unknown as object) onInvoke={() =>
} resolveApproval(request.request_id, false as unknown as object)
displayErrorSnackbar }
requiresContext displayErrorSnackbar
> requiresContext
Deny >
</PromiseInvokeButton> Deny
</PromiseInvokeButton>
<PromiseInvokeButton <PromiseInvokeButton
variant="contained" variant="contained"
color="primary" color="primary"
size="large" size="large"
onInvoke={() => onInvoke={() =>
resolveApproval(request.request_id, true as unknown as object) resolveApproval(request.request_id, true as unknown as object)
} }
displayErrorSnackbar displayErrorSnackbar
requiresContext requiresContext
endIcon={<CheckIcon />} endIcon={<CheckIcon />}
>
{`Confirm`}
</PromiseInvokeButton>
</Box>
<Typography
variant="caption"
sx={{
textAlign: "center",
color: (theme) => theme.palette.text.secondary,
}}
> >
{`Confirm (${timeLeft}s)`} {`Offer expires in ${timeLeft}s`}
</PromiseInvokeButton> </Typography>
</Box> </Box>
</Box> </Box>
); );
@ -177,7 +198,15 @@ const BitcoinMainBox = ({
btc_lock_amount: number; btc_lock_amount: number;
btc_network_fee: number; btc_network_fee: number;
}) => ( }) => (
<Box sx={{ position: "relative", height: "100%" }}> <Box
sx={{
position: "relative",
height: "100%",
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@ -188,10 +217,10 @@ const BitcoinMainBox = ({
gap: "0.5rem 1rem", gap: "0.5rem 1rem",
borderColor: "warning.main", borderColor: "warning.main",
borderRadius: 1, borderRadius: 1,
flexGrow: 1,
backgroundColor: (theme) => theme.palette.warning.light + "10", backgroundColor: (theme) => theme.palette.warning.light + "10",
background: (theme) => background: (theme) =>
`linear-gradient(135deg, ${theme.palette.warning.light}20, ${theme.palette.warning.light}05)`, `linear-gradient(135deg, ${theme.palette.warning.light}20, ${theme.palette.warning.light}05)`,
height: "100%", // Match the height of the Monero box
}} }}
> >
<Typography <Typography
@ -217,10 +246,6 @@ const BitcoinMainBox = ({
{/* Network fee box attached to the bottom */} {/* Network fee box attached to the bottom */}
<Box <Box
sx={{ sx={{
position: "absolute",
bottom: "calc(-50%)",
left: "50%",
transform: "translateX(-50%)",
padding: "0.25rem 0.75rem", padding: "0.25rem 0.75rem",
backgroundColor: (theme) => theme.palette.warning.main, backgroundColor: (theme) => theme.palette.warning.main,
color: (theme) => theme.palette.warning.contrastText, color: (theme) => theme.palette.warning.contrastText,
@ -271,7 +296,7 @@ const PoolBreakdown = ({
display: "flex", display: "flex",
justifyContent: "flex-start", justifyContent: "flex-start",
alignItems: "stretch", alignItems: "stretch",
padding: pool.percentage >= 0.05 ? 1.5 : 1.2, padding: pool.percentage >= 0.05 ? 1.5 : "0.25rem 0.75rem",
border: 1, border: 1,
borderColor: borderColor:
pool.percentage >= 0.05 ? "success.main" : "success.light", pool.percentage >= 0.05 ? "success.main" : "success.light",
@ -283,7 +308,6 @@ const PoolBreakdown = ({
width: "100%", // Ensure full width width: "100%", // Ensure full width
minWidth: 0, minWidth: 0,
opacity: pool.percentage >= 0.05 ? 1 : 0.75, opacity: pool.percentage >= 0.05 ? 1 : 0.75,
transform: pool.percentage >= 0.05 ? "scale(1)" : "scale(0.95)",
animation: animation:
pool.percentage >= 0.05 pool.percentage >= 0.05
? "poolPulse 2s ease-in-out infinite" ? "poolPulse 2s ease-in-out infinite"
@ -308,6 +332,7 @@ const PoolBreakdown = ({
sx={{ sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
justifyContent: "center",
gap: 0.5, gap: 0.5,
flex: "1 1 0", flex: "1 1 0",
minWidth: 0, minWidth: 0,
@ -323,18 +348,20 @@ const PoolBreakdown = ({
> >
{pool.label === "user address" ? "Your Wallet" : pool.label} {pool.label === "user address" ? "Your Wallet" : pool.label}
</Typography> </Typography>
<Typography {pool.label === "user address" && (
variant="body2" <Typography
sx={{ variant="body2"
fontFamily: "monospace", sx={{
fontSize: "0.75rem", fontFamily: "monospace",
color: (theme) => theme.palette.text.secondary, fontSize: "0.75rem",
}} color: (theme) => theme.palette.text.secondary,
> }}
<TruncatedText truncateMiddle limit={15}> >
{pool.address} <TruncatedText truncateMiddle limit={15}>
</TruncatedText> {pool.address}
</Typography> </TruncatedText>
</Typography>
)}
</Box> </Box>
<Box <Box
sx={{ sx={{
@ -393,7 +420,7 @@ const MoneroMainBox = ({
); );
return ( return (
<Box sx={{ position: "relative" }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@ -460,20 +487,10 @@ const MoneroMainBox = ({
</Box> </Box>
{/* Secondary Monero content attached to the bottom */} {/* Secondary Monero content attached to the bottom */}
<Box <MoneroSecondaryContent
sx={{ monero_receive_pool={monero_receive_pool}
position: "absolute", xmr_receive_amount={xmr_receive_amount}
bottom: "calc(-100%)", />
left: "50%",
transform: "translateX(-50%)",
zIndex: 1,
}}
>
<MoneroSecondaryContent
monero_receive_pool={monero_receive_pool}
xmr_receive_amount={xmr_receive_amount}
/>
</Box>
</Box> </Box>
); );
}; };
@ -491,8 +508,7 @@ const MoneroSecondaryContent = ({
// Arrow animation styling extracted for reuse // Arrow animation styling extracted for reuse
const arrowSx = { const arrowSx = {
fontSize: "3rem", fontSize: "3rem",
color: (theme: { palette: { primary: { main: string } } }) => color: (theme: Theme) => theme.palette.primary.main,
theme.palette.primary.main,
animation: "slideArrow 2s infinite", animation: "slideArrow 2s infinite",
"@keyframes slideArrow": { "@keyframes slideArrow": {
"0%": { "0%": {
@ -518,6 +534,7 @@ const AnimatedArrow = () => (
justifyContent: "center", justifyContent: "center",
alignSelf: "center", alignSelf: "center",
flex: "0 0 auto", flex: "0 0 auto",
transform: { xs: "rotate(90deg)", lg: "rotate(0deg)" },
}} }}
> >
<ArrowRightAltIcon sx={arrowSx} /> <ArrowRightAltIcon sx={arrowSx} />

View file

@ -1,6 +1,6 @@
import { Box, DialogContentText } from "@mui/material"; import { Box, DialogContentText } from "@mui/material";
import { TauriSwapProgressEventContent } from "models/tauriModelExt"; import { TauriSwapProgressEventContent } from "models/tauriModelExt";
import MoneroTransactionInfoBox from "../../MoneroTransactionInfoBox"; import MoneroTransactionInfoBox from "../components/MoneroTransactionInfoBox";
export default function WaitingForXmrConfirmationsBeforeRedeemPage({ export default function WaitingForXmrConfirmationsBeforeRedeemPage({
xmr_lock_txid, xmr_lock_txid,

View file

@ -1,7 +1,8 @@
import { Box, DialogContentText } from "@mui/material"; import { Box, DialogContentText } from "@mui/material";
import { TauriSwapProgressEventContent } from "models/tauriModelExt"; import { TauriSwapProgressEventContent } from "models/tauriModelExt";
import { formatConfirmations } from "utils/formatUtils"; import { formatConfirmations } from "utils/formatUtils";
import MoneroTransactionInfoBox from "../../MoneroTransactionInfoBox"; import MoneroTransactionInfoBox from "../components/MoneroTransactionInfoBox";
import CancelButton from "../CancelButton";
export default function XmrLockTxInMempoolPage({ export default function XmrLockTxInMempoolPage({
xmr_lock_tx_confirmations, 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)}`; const additionalContent = `Confirmations: ${formatConfirmations(xmr_lock_tx_confirmations, xmr_lock_tx_target_confirmations)}`;
return ( return (
<Box> <>
<DialogContentText> <DialogContentText>
They have published their Monero lock transaction. The swap will proceed They have published their Monero lock transaction. The swap will proceed
once the transaction has been confirmed. once the transaction has been confirmed.
@ -23,6 +24,8 @@ export default function XmrLockTxInMempoolPage({
additionalContent={additionalContent} additionalContent={additionalContent}
loading loading
/> />
</Box>
<CancelButton />
</>
); );
} }

View file

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

View file

@ -1,12 +1,11 @@
import { Box, Paper, Tab, Tabs, Typography } from "@mui/material"; import { Box, Paper, Tab, Tabs, Typography } from "@mui/material";
import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import { useState } from "react"; import { useState } from "react";
import RemainingFundsWillBeUsedAlert from "renderer/components/alert/RemainingFundsWillBeUsedAlert";
import BitcoinAddressTextField from "renderer/components/inputs/BitcoinAddressTextField"; import BitcoinAddressTextField from "renderer/components/inputs/BitcoinAddressTextField";
import MoneroAddressTextField from "renderer/components/inputs/MoneroAddressTextField"; import MoneroAddressTextField from "renderer/components/inputs/MoneroAddressTextField";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton"; import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import { buyXmr } from "renderer/rpc"; import { buyXmr } from "renderer/rpc";
import { useAppSelector, useSettings } from "store/hooks"; import { useSettings } from "store/hooks";
export default function InitPage() { export default function InitPage() {
const [redeemAddress, setRedeemAddress] = useState(""); const [redeemAddress, setRedeemAddress] = useState("");
@ -17,12 +16,10 @@ export default function InitPage() {
const [redeemAddressValid, setRedeemAddressValid] = useState(false); const [redeemAddressValid, setRedeemAddressValid] = useState(false);
const [refundAddressValid, setRefundAddressValid] = useState(false); const [refundAddressValid, setRefundAddressValid] = useState(false);
const selectedMaker = useAppSelector((state) => state.makers.selectedMaker);
const donationRatio = useSettings((s) => s.donateToDevelopment); const donationRatio = useSettings((s) => s.donateToDevelopment);
async function init() { async function init() {
await buyXmr( await buyXmr(
selectedMaker,
useExternalRefundAddress ? refundAddress : null, useExternalRefundAddress ? refundAddress : null,
redeemAddress, redeemAddress,
donationRatio, donationRatio,
@ -30,7 +27,7 @@ export default function InitPage() {
} }
return ( return (
<Box> <>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@ -38,7 +35,6 @@ export default function InitPage() {
gap: 1.5, gap: 1.5,
}} }}
> >
<RemainingFundsWillBeUsedAlert />
<MoneroAddressTextField <MoneroAddressTextField
label="Monero redeem address" label="Monero redeem address"
address={redeemAddress} address={redeemAddress}
@ -84,8 +80,7 @@ export default function InitPage() {
<PromiseInvokeButton <PromiseInvokeButton
disabled={ disabled={
(!refundAddressValid && useExternalRefundAddress) || (!refundAddressValid && useExternalRefundAddress) ||
!redeemAddressValid || !redeemAddressValid
!selectedMaker
} }
variant="contained" variant="contained"
color="primary" color="primary"
@ -95,9 +90,9 @@ export default function InitPage() {
onInvoke={init} onInvoke={init}
displayErrorSnackbar displayErrorSnackbar
> >
Begin swap Continue
</PromiseInvokeButton> </PromiseInvokeButton>
</Box> </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 { SatsAmount } from "renderer/components/other/Units";
import { useAppSelector } from "store/hooks"; import { useAppSelector } from "store/hooks";
import BitcoinIcon from "../../icons/BitcoinIcon"; 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 WithdrawDialog from "../../modal/wallet/WithdrawDialog";
import WalletRefreshButton from "./WalletRefreshButton"; import WalletRefreshButton from "./WalletRefreshButton";

View file

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

View file

@ -29,6 +29,7 @@ import {
ResolveApprovalResponse, ResolveApprovalResponse,
RedactArgs, RedactArgs,
RedactResponse, RedactResponse,
GetCurrentSwapResponse,
LabeledMoneroAddress, LabeledMoneroAddress,
} from "models/tauriModel"; } from "models/tauriModel";
import { rpcSetBalance, rpcSetSwapInfo } from "store/features/rpcSlice"; import { rpcSetBalance, rpcSetSwapInfo } from "store/features/rpcSlice";
@ -174,11 +175,22 @@ export async function withdrawBtc(address: string): Promise<string> {
} }
export async function buyXmr( export async function buyXmr(
seller: Maker,
bitcoin_change_address: string | null, bitcoin_change_address: string | null,
monero_receive_address: string, monero_receive_address: string,
donation_percentage: DonateToDevelopmentTip, 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[] = []; const address_pool: LabeledMoneroAddress[] = [];
if (donation_percentage !== false) { if (donation_percentage !== false) {
const donation_address = isTestnet() const donation_address = isTestnet()
@ -206,7 +218,8 @@ export async function buyXmr(
} }
await invoke<BuyXmrArgs, BuyXmrResponse>("buy_xmr", { await invoke<BuyXmrArgs, BuyXmrResponse>("buy_xmr", {
seller: providerToConcatenatedMultiAddr(seller), rendezvous_points: PRESET_RENDEZVOUS_POINTS,
sellers,
monero_receive_pool: address_pool, monero_receive_pool: address_pool,
bitcoin_change_address, bitcoin_change_address,
}); });
@ -222,6 +235,10 @@ export async function suspendCurrentSwap() {
await invokeNoArgs<SuspendCurrentSwapResponse>("suspend_current_swap"); await invokeNoArgs<SuspendCurrentSwapResponse>("suspend_current_swap");
} }
export async function getCurrentSwapId() {
return await invokeNoArgs<GetCurrentSwapResponse>("get_current_swap");
}
export async function getMoneroRecoveryKeys( export async function getMoneroRecoveryKeys(
swapId: string, swapId: string,
): Promise<MoneroRecoveryResponse> { ): Promise<MoneroRecoveryResponse> {

View file

@ -3,7 +3,6 @@ import makersSlice from "./features/makersSlice";
import ratesSlice from "./features/ratesSlice"; import ratesSlice from "./features/ratesSlice";
import rpcSlice from "./features/rpcSlice"; import rpcSlice from "./features/rpcSlice";
import swapReducer from "./features/swapSlice"; import swapReducer from "./features/swapSlice";
import torSlice from "./features/torSlice";
import settingsSlice from "./features/settingsSlice"; import settingsSlice from "./features/settingsSlice";
import nodesSlice from "./features/nodesSlice"; import nodesSlice from "./features/nodesSlice";
import conversationsSlice from "./features/conversationsSlice"; import conversationsSlice from "./features/conversationsSlice";
@ -12,7 +11,6 @@ import poolSlice from "./features/poolSlice";
export const reducers = { export const reducers = {
swap: swapReducer, swap: swapReducer,
makers: makersSlice, makers: makersSlice,
tor: torSlice,
rpc: rpcSlice, rpc: rpcSlice,
alerts: alertsSlice, alerts: alertsSlice,
rates: ratesSlice, rates: ratesSlice,

View file

@ -4,7 +4,6 @@ import { SellerStatus } from "models/tauriModel";
import { getStubTestnetMaker } from "store/config"; import { getStubTestnetMaker } from "store/config";
import { rendezvousSellerToMakerStatus } from "utils/conversionUtils"; import { rendezvousSellerToMakerStatus } from "utils/conversionUtils";
import { isMakerOutdated } from "utils/multiAddrUtils"; import { isMakerOutdated } from "utils/multiAddrUtils";
import { sortMakerList } from "utils/sortUtils";
const stubTestnetMaker = getStubTestnetMaker(); const stubTestnetMaker = getStubTestnetMaker();
@ -48,10 +47,10 @@ function selectNewSelectedMaker(
} }
// Otherwise we'd prefer to switch to a provider that has the newest version // Otherwise we'd prefer to switch to a provider that has the newest version
const providers = sortMakerList([ const providers = [
...(slice.registry.makers ?? []), ...(slice.registry.makers ?? []),
...(slice.rendezvous.makers ?? []), ...(slice.rendezvous.makers ?? []),
]); ];
return providers.at(0) || null; return providers.at(0) || null;
} }
@ -86,7 +85,6 @@ export const makersSlice = createSlice({
}); });
// Sort the provider list and select a new provider if needed // Sort the provider list and select a new provider if needed
slice.rendezvous.makers = sortMakerList(slice.rendezvous.makers);
slice.selectedMaker = selectNewSelectedMaker(slice); slice.selectedMaker = selectNewSelectedMaker(slice);
}, },
setRegistryMakers(slice, action: PayloadAction<ExtendedMakerStatus[]>) { setRegistryMakers(slice, action: PayloadAction<ExtendedMakerStatus[]>) {
@ -95,7 +93,6 @@ export const makersSlice = createSlice({
} }
// Sort the provider list and select a new provider if needed // Sort the provider list and select a new provider if needed
slice.registry.makers = sortMakerList(action.payload);
slice.selectedMaker = selectNewSelectedMaker(slice); slice.selectedMaker = selectNewSelectedMaker(slice);
}, },
registryConnectionFailed(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, isPendingSeedSelectionApprovalEvent,
PendingApprovalRequest, PendingApprovalRequest,
PendingLockBitcoinApprovalRequest, PendingLockBitcoinApprovalRequest,
PendingSelectMakerApprovalRequest,
isPendingSelectMakerApprovalEvent,
haveFundsBeenLocked,
PendingSeedSelectionApprovalRequest, PendingSeedSelectionApprovalRequest,
} from "models/tauriModelExt"; } from "models/tauriModelExt";
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
@ -18,7 +21,6 @@ import { isCliLogRelatedToSwap } from "models/cliModel";
import { SettingsState } from "./features/settingsSlice"; import { SettingsState } from "./features/settingsSlice";
import { NodesSlice } from "./features/nodesSlice"; import { NodesSlice } from "./features/nodesSlice";
import { RatesState } from "./features/ratesSlice"; import { RatesState } from "./features/ratesSlice";
import { sortMakerList } from "utils/sortUtils";
import { import {
TauriBackgroundProgress, TauriBackgroundProgress,
TauriBitcoinSyncProgress, 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() { export function useIsSwapRunning() {
return useAppSelector( return useAppSelector(
(state) => (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() { export function useIsContextAvailable() {
return useAppSelector( return useAppSelector(
(state) => state.rpc.status === TauriContextStatusEvent.Available, (state) => state.rpc.status === TauriContextStatusEvent.Available,
@ -103,9 +145,7 @@ export function useAllMakers() {
return useAppSelector((state) => { return useAppSelector((state) => {
const registryMakers = state.makers.registry.makers || []; const registryMakers = state.makers.registry.makers || [];
const listSellersMakers = state.makers.rendezvous.makers || []; const listSellersMakers = state.makers.rendezvous.makers || [];
const all = [...registryMakers, ...listSellersMakers]; return [...registryMakers, ...listSellersMakers];
return sortMakerList(all);
}); });
} }
@ -167,6 +207,11 @@ export function usePendingLockBitcoinApproval(): PendingLockBitcoinApprovalReque
return approvals.filter((c) => isPendingLockBitcoinApprovalEvent(c)); return approvals.filter((c) => isPendingLockBitcoinApprovalEvent(c));
} }
export function usePendingSelectMakerApproval(): PendingSelectMakerApprovalRequest[] {
const approvals = usePendingApprovals();
return approvals.filter((c) => isPendingSelectMakerApprovalEvent(c));
}
export function usePendingSeedSelectionApproval(): PendingSeedSelectionApprovalRequest[] { export function usePendingSeedSelectionApproval(): PendingSeedSelectionApprovalRequest[] {
const approvals = usePendingApprovals(); const approvals = usePendingApprovals();
return approvals.filter((c) => isPendingSeedSelectionApprovalEvent(c)); return approvals.filter((c) => isPendingSeedSelectionApprovalEvent(c));

View file

@ -1,4 +1,5 @@
import { createListenerMiddleware } from "@reduxjs/toolkit"; import { createListenerMiddleware } from "@reduxjs/toolkit";
import { throttle, debounce } from "lodash";
import { import {
getAllSwapInfos, getAllSwapInfos,
checkBitcoinBalance, checkBitcoinBalance,
@ -22,6 +23,33 @@ import {
} from "store/features/conversationsSlice"; } from "store/features/conversationsSlice";
import { TauriContextStatusEvent } from "models/tauriModel"; 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() { export function createMainListeners() {
const listener = createListenerMiddleware(); const listener = createListenerMiddleware();
@ -57,11 +85,14 @@ export function createMainListeners() {
await checkBitcoinBalance(); await checkBitcoinBalance();
} }
// Update the swap info // Update the swap info using throttled function
logger.info( 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 {
import { isMakerOnCorrectNetwork, isMakerOutdated } from "./multiAddrUtils"; PendingSelectMakerApprovalRequest,
SortableQuoteWithAddress,
} from "models/tauriModelExt";
import { QuoteWithAddress } from "models/tauriModel";
import { isMakerVersionOutdated } from "./multiAddrUtils";
import _ from "lodash"; 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 ( return (
_(list) _(list)
// Filter out makers that are on the wrong network (testnet / mainnet)
.filter(isMakerOnCorrectNetwork)
// Sort by criteria
.orderBy( .orderBy(
[ [
// Prefer makers that have a 'version' attribute // Prefer makers that have a 'version' attribute
// If we don't have a version, we cannot clarify if it's outdated or not // If we don't have a version, we cannot clarify if it's outdated or not
(m) => (m.version ? 0 : 1), (m) => (m.version ? 0 : 1),
// Prefer makers that are not outdated // Prefer makers that are not outdated
(m) => (isMakerOutdated(m) ? 1 : 0), (m) => (isMakerVersionOutdated(m.version) ? 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),
// Prefer makers with a minimum quantity > 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 // Prefer makers with a lower price
(m) => m.price, (m) => m.quote.price,
], ],
["asc", "asc", "asc", "asc", "asc"], ["asc", "asc", "asc", "asc", "asc"],
) )
// Remove duplicate makers // Remove duplicate makers
.uniqBy((m) => m.peerId) .uniqBy((m) => m.peer_id)
.value() .value()
); );
} }

View file

@ -1554,6 +1554,11 @@ bl@^1.2.1:
readable-stream "^2.3.5" readable-stream "^2.3.5"
safe-buffer "^5.1.1" 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: brace-expansion@^1.1.7:
version "1.1.12" version "1.1.12"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843"

View file

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

View file

@ -51,7 +51,6 @@ monero-sys = { path = "../monero-sys" }
once_cell = "1.19" once_cell = "1.19"
pem = "3.0" pem = "3.0"
proptest = "1" proptest = "1"
qrcode = "0.14"
rand = "0.8" rand = "0.8"
rand_chacha = "0.3" rand_chacha = "0.3"
regex = "1.10" 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 } tauri = { version = "2.0", features = ["config-json5"], optional = true, default-features = false }
thiserror = "1" thiserror = "1"
time = "0.3" 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-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" toml = "0.8"
tor-rtcompat = { version = "0.25.0", features = ["tokio"] } tor-rtcompat = { version = "0.25.0", features = ["tokio"] }
tower = { version = "0.4.13", features = ["full"] } tower = { version = "0.4.13", features = ["full"] }

View file

@ -1,37 +1,40 @@
use super::tauri_bindings::TauriHandle; use super::tauri_bindings::TauriHandle;
use crate::bitcoin::{wallet, CancelTimelock, ExpiredTimelocks, PunishTimelock, TxLock}; use crate::bitcoin::{wallet, CancelTimelock, ExpiredTimelocks, PunishTimelock};
use crate::cli::api::tauri_bindings::{TauriEmitter, TauriSwapProgressEvent}; use crate::cli::api::tauri_bindings::{SelectMakerDetails, TauriEmitter, TauriSwapProgressEvent};
use crate::cli::api::Context; 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::cli::{list_sellers as list_sellers_impl, EventLoop, SellerStatus};
use crate::common::{get_logs, redact}; use crate::common::{get_logs, redact};
use crate::libp2p_ext::MultiAddrExt; use crate::libp2p_ext::MultiAddrExt;
use crate::monero::wallet_rpc::MoneroDaemon; use crate::monero::wallet_rpc::MoneroDaemon;
use crate::monero::MoneroAddressPool; use crate::monero::MoneroAddressPool;
use crate::network::quote::{BidQuote, ZeroQuoteReceived}; use crate::network::quote::{BidQuote, ZeroQuoteReceived};
use crate::network::rendezvous::XmrBtcNamespace;
use crate::network::swarm; use crate::network::swarm;
use crate::protocol::bob::{BobState, Swap}; use crate::protocol::bob::{BobState, Swap};
use crate::protocol::{bob, State}; use crate::protocol::{bob, Database, State};
use crate::{bitcoin, cli, monero}; use crate::{bitcoin, cli, monero};
use ::bitcoin::address::NetworkUnchecked; use ::bitcoin::address::NetworkUnchecked;
use ::bitcoin::Txid; use ::bitcoin::Txid;
use ::monero::Network; use ::monero::Network;
use anyhow::{bail, Context as AnyContext, Result}; use anyhow::{bail, Context as AnyContext, Result};
use arti_client::TorClient;
use futures::stream::FuturesUnordered;
use futures::StreamExt;
use libp2p::core::Multiaddr; use libp2p::core::Multiaddr;
use libp2p::PeerId; use libp2p::{identity, PeerId};
use monero_seed::{Language, Seed as MoneroSeed}; use monero_seed::{Language, Seed as MoneroSeed};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use qrcode::render::unicode;
use qrcode::QrCode;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::cmp::min;
use std::convert::TryInto; use std::convert::TryInto;
use std::future::Future; use std::future::Future;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use thiserror::Error; use thiserror::Error;
use tokio_util::task::AbortOnDropHandle;
use tor_rtcompat::tokio::TokioRustlsRuntime;
use tracing::debug_span; use tracing::debug_span;
use tracing::Instrument; use tracing::Instrument;
use tracing::Span; use tracing::Span;
@ -58,8 +61,10 @@ fn get_swap_tracing_span(swap_id: Uuid) -> Span {
#[typeshare] #[typeshare]
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct BuyXmrArgs { pub struct BuyXmrArgs {
#[typeshare(serialized_as = "string")] #[typeshare(serialized_as = "Vec<string>")]
pub seller: Multiaddr, pub rendezvous_points: Vec<Multiaddr>,
#[typeshare(serialized_as = "Vec<string>")]
pub sellers: Vec<Multiaddr>,
#[typeshare(serialized_as = "Option<string>")] #[typeshare(serialized_as = "Option<string>")]
pub bitcoin_change_address: Option<bitcoin::Address<NetworkUnchecked>>, pub bitcoin_change_address: Option<bitcoin::Address<NetworkUnchecked>>,
pub monero_receive_pool: MoneroAddressPool, pub monero_receive_pool: MoneroAddressPool,
@ -310,8 +315,9 @@ pub struct SuspendCurrentSwapArgs;
#[typeshare] #[typeshare]
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct SuspendCurrentSwapResponse { pub struct SuspendCurrentSwapResponse {
#[typeshare(serialized_as = "string")] // If no swap was running, we still return Ok(...) but this is set to None
pub swap_id: Uuid, #[typeshare(serialized_as = "Option<string>")]
pub swap_id: Option<Uuid>,
} }
impl Request for SuspendCurrentSwapArgs { impl Request for SuspendCurrentSwapArgs {
@ -322,10 +328,19 @@ impl Request for SuspendCurrentSwapArgs {
} }
} }
#[typeshare]
#[derive(Debug, Serialize, Deserialize)]
pub struct GetCurrentSwapArgs; 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 { impl Request for GetCurrentSwapArgs {
type Response = serde_json::Value; type Response = GetCurrentSwapResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> { async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
get_current_swap(ctx).await 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 { if let Some(id_value) = swap_id {
context.swap_lock.send_suspend_signal().await?; context.swap_lock.send_suspend_signal().await?;
Ok(SuspendCurrentSwapResponse { swap_id: id_value }) Ok(SuspendCurrentSwapResponse {
swap_id: Some(id_value),
})
} else { } 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, swap_id: Uuid,
context: Arc<Context>, context: Arc<Context>,
) -> Result<BuyXmrResponse, anyhow::Error> { ) -> Result<BuyXmrResponse, anyhow::Error> {
let _span = get_swap_tracing_span(swap_id);
let BuyXmrArgs { let BuyXmrArgs {
seller, rendezvous_points,
sellers,
bitcoin_change_address, bitcoin_change_address,
monero_receive_pool, monero_receive_pool,
} = buy_xmr; } = buy_xmr;
@ -635,13 +656,103 @@ pub async fn buy_xmr(
let env_config = context.config.env_config; let env_config = context.config.env_config;
let seed = context.config.seed.clone().context("Could not get seed")?; let seed = context.config.seed.clone().context("Could not get seed")?;
let seller_peer_id = seller // Prepare variables for the quote fetching process
.extract_peer_id() let identity = seed.derive_libp2p_identity();
.context("Seller address must contain peer ID")?; 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 context
.db .db
.insert_address(seller_peer_id, seller.clone()) .insert_address(seller_peer_id, seller_multiaddr.clone())
.await?; .await?;
let behaviour = cli::Behaviour::new( let behaviour = cli::Behaviour::new(
@ -658,7 +769,7 @@ pub async fn buy_xmr(
) )
.await?; .await?;
swarm.add_peer_address(seller_peer_id, seller); swarm.add_peer_address(seller_peer_id, seller_multiaddr.clone());
context context
.db .db
@ -667,57 +778,19 @@ pub async fn buy_xmr(
tracing::debug!(peer_id = %swarm.local_peer_id(), "Network layer initialized"); 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 context
.tauri_handle .tauri_handle
.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::RequestingQuote); .emit_swap_progress_event(swap_id, TauriSwapProgressEvent::ReceivedQuote(quote));
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));
context.tasks.clone().spawn(async move { context.tasks.clone().spawn(async move {
tokio::select! { tokio::select! {
@ -730,6 +803,7 @@ pub async fn buy_xmr(
bail!("Shutdown signal received"); bail!("Shutdown signal received");
}, },
event_loop_result = event_loop => { event_loop_result = event_loop => {
match event_loop_result { match event_loop_result {
Ok(_) => { Ok(_) => {
@ -741,36 +815,6 @@ pub async fn buy_xmr(
} }
}, },
swap_result = async { 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( let swap = Swap::new(
Arc::clone(&context.db), Arc::clone(&context.db),
swap_id, swap_id,
@ -779,7 +823,7 @@ pub async fn buy_xmr(
env_config, env_config,
event_loop_handle, event_loop_handle,
monero_receive_pool.clone(), monero_receive_pool.clone(),
bitcoin_change_address, bitcoin_change_address_for_spawn,
tx_lock_amount, tx_lock_amount,
tx_lock_fee tx_lock_fee
).with_event_emitter(context.tauri_handle.clone()); ).with_event_emitter(context.tauri_handle.clone());
@ -809,10 +853,7 @@ pub async fn buy_xmr(
Ok::<_, anyhow::Error>(()) Ok::<_, anyhow::Error>(())
}.in_current_span()).await; }.in_current_span()).await;
Ok(BuyXmrResponse { Ok(BuyXmrResponse { swap_id, quote })
swap_id,
quote: bid_quote,
})
} }
#[tracing::instrument(fields(method = "resume_swap"), skip(context))] #[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))] #[tracing::instrument(fields(method = "get_current_swap"), skip(context))]
pub async fn get_current_swap(context: Arc<Context>) -> Result<serde_json::Value> { pub async fn get_current_swap(context: Arc<Context>) -> Result<GetCurrentSwapResponse> {
Ok(json!({ let swap_id = context.swap_lock.get_current_swap_id().await;
"swap_id": context.swap_lock.get_current_swap_id().await, Ok(GetCurrentSwapResponse { swap_id })
}))
} }
pub async fn resolve_approval_request( pub async fn resolve_approval_request(
@ -1225,133 +1265,270 @@ pub async fn resolve_approval_request(
Ok(ResolveApprovalResponse { success: true }) Ok(ResolveApprovalResponse { success: true })
} }
fn qr_code(value: &impl ToString) -> Result<String> { pub async fn fetch_quotes_task(
let code = QrCode::new(value.to_string())?; rendezvous_points: Vec<Multiaddr>,
let qr_code = code namespace: XmrBtcNamespace,
.render::<unicode::Dense1x2>() sellers: Vec<Multiaddr>,
.dark_color(unicode::Dense1x2::Light) identity: identity::Keypair,
.light_color(unicode::Dense1x2::Dark) db: Option<Arc<dyn Database + Send + Sync>>,
.build(); tor_client: Option<Arc<TorClient<TokioRustlsRuntime>>>,
Ok(qr_code) 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)] #[allow(clippy::too_many_arguments)]
pub async fn determine_btc_to_swap<FB, TB, FMG, TMG, FS, TS>( pub async fn determine_btc_to_swap<FB, TB, FMG, TMG, FS, TS, FQ>(
json: bool, quote_fetch_tasks: FQ,
bid_quote: BidQuote, // TODO: Shouldn't this be a function?
get_new_address: impl Future<Output = Result<bitcoin::Address>>, get_new_address: impl Future<Output = Result<bitcoin::Address>>,
balance: FB, balance: FB,
max_giveable_fn: FMG, max_giveable_fn: FMG,
sync: FS, sync: FS,
event_emitter: Option<TauriHandle>, event_emitter: Option<TauriHandle>,
swap_id: Option<Uuid>, swap_id: Uuid,
) -> Result<(bitcoin::Amount, bitcoin::Amount)> request_approval: impl Fn(QuoteWithAddress) -> Box<dyn Future<Output = Result<bool>> + Send>,
) -> Result<(
Multiaddr,
PeerId,
BidQuote,
bitcoin::Amount,
bitcoin::Amount,
)>
where where
TB: Future<Output = Result<bitcoin::Amount>>, TB: Future<Output = Result<bitcoin::Amount>> + Send + 'static,
FB: Fn() -> TB, FB: Fn() -> TB + Send + 'static,
TMG: Future<Output = Result<(bitcoin::Amount, bitcoin::Amount)>>, TMG: Future<Output = Result<(bitcoin::Amount, bitcoin::Amount)>> + Send + 'static,
FMG: Fn() -> TMG, FMG: Fn() -> TMG + Send + 'static,
TS: Future<Output = Result<()>>, TS: Future<Output = Result<()>> + Send + 'static,
FS: Fn() -> TS, 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 { // Start background tasks with watch channels
bail!(ZeroQuoteReceived) 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!( // Get the abort handles to kill the background tasks when we exit the function
price = %bid_quote.price, let quote_fetch_abort_handle = AbortOnDropHandle::new(quote_fetch_handle);
minimum_amount = %bid_quote.min_quantity, let wallet_refresh_abort_handle = AbortOnDropHandle::new(wallet_refresh_handle);
maximum_amount = %bid_quote.max_quantity,
"Received quote",
);
sync().await.context("Failed to sync of Bitcoin wallet")?; let mut pending_approvals = FuturesUnordered::new();
let (mut max_giveable, mut spending_fee) = max_giveable_fn().await?;
if max_giveable == bitcoin::Amount::ZERO || max_giveable < bid_quote.min_quantity { let deposit_address = get_new_address.await?;
let deposit_address = get_new_address.await?;
let minimum_amount = bid_quote.min_quantity;
let maximum_amount = bid_quote.max_quantity;
// To avoid any issus, we clip maximum_amount to never go above the loop {
// total maximim Bitcoin supply // Get the latest quotes, balance and max_giveable
let maximum_amount = maximum_amount.min(bitcoin::Amount::MAX_MONEY); let quotes = quotes_rx.borrow().clone();
let (balance, max_giveable) = *balance_rx.borrow();
if !json { let success_quotes = quotes
eprintln!("{}", qr_code(&deposit_address)?); .iter()
} .filter_map(|quote| match quote {
SellerStatus::Online(quote_with_address) => Some(quote_with_address.clone()),
SellerStatus::Unreachable(_) => None,
})
.collect::<Vec<_>>();
loop { // Emit a Tauri event
let min_outstanding = bid_quote.min_quantity - max_giveable; event_emitter.emit_swap_progress_event(
let min_bitcoin_lock_tx_fee = spending_fee; swap_id,
let min_deposit_until_swap_will_start = min_outstanding + min_bitcoin_lock_tx_fee; TauriSwapProgressEvent::WaitingForBtcDeposit {
let max_deposit_until_maximum_amount_is_reached = maximum_amount deposit_address: deposit_address.clone(),
.checked_sub(max_giveable) max_giveable: max_giveable,
.context("Overflow when subtracting max_giveable from maximum_amount")? min_bitcoin_lock_tx_fee: balance - max_giveable,
.checked_add(min_bitcoin_lock_tx_fee) known_quotes: success_quotes.clone(),
.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})"))?; },
);
tracing::info!( // Iterate through quotes and find ones that match the balance and max_giveable
"Deposit at least {} to cover the min quantity with fee!", let matching_quotes = success_quotes
min_deposit_until_swap_will_start .iter()
); .filter_map(|quote_with_address| {
tracing::info!( let quote = quote_with_address.quote;
%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",
);
if let Some(swap_id) = swap_id { if quote.min_quantity <= max_giveable && quote.max_quantity > bitcoin::Amount::ZERO
event_emitter.emit_swap_progress_event( {
swap_id, let tx_lock_fee = balance - max_giveable;
TauriSwapProgressEvent::WaitingForBtcDeposit { let tx_lock_amount = std::cmp::min(max_giveable, quote.max_quantity);
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,
},
);
}
(max_giveable, spending_fee) = loop { Some((quote_with_address.clone(), tx_lock_amount, tx_lock_fee))
sync() } else {
.await None
.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);
} }
})
.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?; pending_approvals.push(async move {
tracing::info!(%new_balance, %max_giveable, "Received Bitcoin"); use std::pin::Pin;
let pinned_future = Pin::from(future);
let approved = pinned_future.await?;
if max_giveable < bid_quote.min_quantity { if approved {
tracing::info!("Deposited amount is not enough to cover `min_quantity` when accounting for network fees"); Ok::<
continue; Option<(
} Multiaddr,
PeerId,
break; 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?; tracing::info!(
let fees = balance - max_giveable; swap_id = ?swap_id,
let max_accepted = bid_quote.max_quantity; pending_approvals = ?pending_approvals.len(),
let btc_swap_amount = min(max_giveable, max_accepted); 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] #[typeshare]

View file

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

View file

@ -2,6 +2,7 @@ use crate::cli::api::tauri_bindings::{
ListSellersProgress, TauriBackgroundProgress, TauriBackgroundProgressHandle, TauriEmitter, ListSellersProgress, TauriBackgroundProgress, TauriBackgroundProgressHandle, TauriEmitter,
TauriHandle, TauriHandle,
}; };
use crate::libp2p_ext::MultiAddrExt;
use crate::network::quote::BidQuote; use crate::network::quote::BidQuote;
use crate::network::rendezvous::XmrBtcNamespace; use crate::network::rendezvous::XmrBtcNamespace;
use crate::network::{quote, swarm}; use crate::network::{quote, swarm};
@ -16,7 +17,7 @@ use libp2p::swarm::dial_opts::DialOpts;
use libp2p::swarm::{NetworkBehaviour, SwarmEvent}; use libp2p::swarm::{NetworkBehaviour, SwarmEvent};
use libp2p::{identity, ping, rendezvous, Multiaddr, PeerId, Swarm}; use libp2p::{identity, ping, rendezvous, Multiaddr, PeerId, Swarm};
use semver::Version; use semver::Version;
use serde::Serialize; use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DisplayFromStr}; use serde_with::{serde_as, DisplayFromStr};
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use std::sync::Arc; 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) 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. /// Returns sorted list of sellers, with [Online](Status::Online) listed first.
/// ///
/// First uses the rendezvous node to discover peers in the given namespace, /// 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>>, db: Option<Arc<dyn Database + Send + Sync>>,
tauri_handle: Option<TauriHandle>, tauri_handle: Option<TauriHandle>,
) -> Result<Vec<SellerStatus>> { ) -> Result<Vec<SellerStatus>> {
let behaviour = Behaviour { let fetch_fn = list_sellers_init(
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,
rendezvous_points, rendezvous_points,
namespace, namespace,
external_dial_queue, maybe_tor_client,
identity,
db,
tauri_handle, tauri_handle,
); None,
let sellers = event_loop.run().await; Vec::new(),
)
Ok(sellers) .await?;
Ok(fetch_fn().await)
} }
#[serde_as] #[serde_as]
#[typeshare] #[typeshare]
#[derive(Debug, Serialize, PartialEq, Eq, Hash, Clone, Ord, PartialOrd)] #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Ord, PartialOrd)]
pub struct QuoteWithAddress { pub struct QuoteWithAddress {
/// The multiaddr of the seller (at which we were able to connect to and get the quote from) /// The multiaddr of the seller (at which we were able to connect to and get the quote from)
#[serde_as(as = "DisplayFromStr")] #[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 // Dial all rendezvous points initially
for (peer_id, multiaddr) in &self.rendezvous_points { for (peer_id, multiaddr) in &self.rendezvous_points {
let dial_opts = DialOpts::peer_id(*peer_id) 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 we have pending request to rendezvous points or quote requests, we continue
if !all_rendezvous_points_requests_complete || !all_quotes_fetched { 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; continue;
} }